First of all: I was looking for a simple image captcha solution for a website, but all I found were "Over-the-top" solutions for me. So I decided to write something to self.
I mainly use two Java libraries for this: Blade and NanoCaptcha, for image generation.
To protect the server from overload (DoS?), a new captcha may only be generated every 10 seconds per IP address.
Is this implementation theoretical secure? Could there be performance issues?
Java server code:
import com.hellokaton.blade.Blade;
import java.awt.*;
import java.awt.image.RenderedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.*;
import javax.imageio.ImageIO;
import net.logicsquad.nanocaptcha.content.LatinContentProducer;
import net.logicsquad.nanocaptcha.image.ImageCaptcha;
import net.logicsquad.nanocaptcha.image.backgrounds.GradiatedBackgroundProducer;
import net.logicsquad.nanocaptcha.image.noise.CurvedLineNoiseProducer;
import net.logicsquad.nanocaptcha.image.noise.StraightLineNoiseProducer;
import net.logicsquad.nanocaptcha.image.renderer.DefaultWordRenderer;
import org.json.JSONObject;
public class Main {
public static void main(String[] args) throws IOException, FontFormatException {
// Load the custom font "High Empathy.ttf" from resources
GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
ge.registerFont(
Font.createFont(
Font.TRUETYPE_FONT,
Objects.requireNonNull(Main.class.getResourceAsStream("High Empathy.ttf"))));
Font font =
Arrays.stream(ge.getAllFonts())
.filter(f -> f.getFontName().equals("High Empathy"))
.findFirst()
.orElseThrow(() -> new RuntimeException("Font not found"))
.deriveFont(Font.PLAIN, 72f);
// Create a color gradient from light blue-grey to black
Color[] ca = new Color[20];
for (int i = 0; i < ca.length; i++) {
float ratio = (float) i / (ca.length - 1);
ca[i] = colorTransition(new Color(192, 192, 255), Color.BLACK, ratio);
}
ArrayList<Color> colors = new ArrayList<>(Arrays.asList(ca));
colors.add(new Color(255, 0, 0)); // Red
colors.add(new Color(0, 255, 0)); // Green
colors.add(new Color(0, 0, 255)); // Blue
colors.add(new Color(255, 255, 0)); // Yellow
colors.add(new Color(255, 165, 0)); // Orange
colors.add(new Color(75, 0, 130)); // Indigo
colors.add(new Color(238, 130, 238)); // Violet
colors.add(new Color(128, 128, 128)); // Grey
// Create maps to store last access times and captcha codes
final HashMap<String, Long> lastAccessMap = new HashMap<>();
final HashMap<Integer, String> hashCodeMap = new HashMap<>();
// Start the Blade server
Blade.create()
.get(
"/captcha/get",
ctx -> {
long now = System.currentTimeMillis();
String clientIp = ctx.address();
System.out.println("Client IP: " + clientIp + ", Request Time: " + now);
if (lastAccessMap.containsKey(clientIp)) {
long last = lastAccessMap.get(clientIp);
if (now - last <= 10_000) {
lastAccessMap.put(clientIp, now);
JSONObject response = new JSONObject();
response.put("ok", false);
response.put(
"message", "Please wait 10 seconds before requesting a new captcha.");
ctx.status(403);
ctx.json(response.toString());
}
}
lastAccessMap.put(clientIp, now);
ImageCaptcha ic =
new ImageCaptcha.Builder(500, 150)
.addContent(
new LatinContentProducer(12),
new DefaultWordRenderer.Builder().font(font).randomColor(colors).build())
.addBackground(new GradiatedBackgroundProducer())
.addNoise(new CurvedLineNoiseProducer())
.addNoise(new StraightLineNoiseProducer())
.addBorder()
.build();
String code = ic.getContent();
RenderedImage img = ic.getImage();
String base64Image = imgToBase64String(img, "PNG");
int hash = base64Image.hashCode();
hashCodeMap.put(hash, code);
JSONObject response = new JSONObject();
response.put("ok", true);
response.put("base64Image", base64Image);
ctx.json(response.toString());
System.out.println("Generated captcha with hash: " + hash + " and code: " + code);
})
.get(
"/captcha/check/:hash/:code",
ctx -> {
long now = System.currentTimeMillis();
String clientIp = ctx.address();
System.out.println("Client IP: " + clientIp + ", Request Time: " + now);
if (lastAccessMap.containsKey(clientIp)) {
long last = lastAccessMap.get(clientIp);
if (now - last <= 10_000) {
lastAccessMap.put(clientIp, now);
JSONObject response = new JSONObject();
response.put("ok", false);
response.put("message", "Please wait 10 seconds before checking a new captcha.");
ctx.status(403);
ctx.json(response.toString());
return;
}
}
lastAccessMap.put(clientIp, now);
try {
int hash = ctx.pathInt("hash");
String code = ctx.pathString("code");
System.out.println("Checking captcha with hash: " + hash + " and code: " + code);
if (hashCodeMap.containsKey(hash)) {
String expectedCode = hashCodeMap.get(hash);
if (expectedCode.equals(code)) {
JSONObject response = new JSONObject();
response.put("ok", true);
response.put("message", "Captcha is correct.");
response.put(
"secret_message",
"Congratulations! You have successfully solved the captcha. This is a secret message just for you: 'Keep up the great work!'");
ctx.json(response.toString());
} else {
JSONObject response = new JSONObject();
response.put("ok", false);
response.put("message", "Captcha is incorrect.");
ctx.status(403);
ctx.json(response.toString());
}
} else {
JSONObject response = new JSONObject();
response.put("ok", false);
response.put("message", "Captcha not found or expired.");
ctx.status(403);
ctx.json(response.toString());
}
} catch (Exception e) {
JSONObject response = new JSONObject();
response.put("ok", false);
response.put("message", "Invalid request.");
ctx.status(400);
ctx.json(response.toString());
}
})
.get(
"/captcha/demo",
ctx -> {
long now = System.currentTimeMillis();
String clientIp = ctx.address();
System.out.println("Client IP: " + clientIp + ", Request Time: " + now);
ctx.render("demo.html");
})
.listen(80)
.start();
}
public static String imgToBase64String(RenderedImage img, String formatName) {
ByteArrayOutputStream os = new ByteArrayOutputStream();
try {
ImageIO.write(img, formatName, os);
return Base64.getEncoder().encodeToString(os.toByteArray());
} catch (IOException ioe) {
throw new UncheckedIOException(ioe);
}
}
public static Color colorTransition(Color startColor, Color endColor, float ratio) {
int red = (int) (startColor.getRed() + (endColor.getRed() - startColor.getRed()) * ratio);
int green =
(int) (startColor.getGreen() + (endColor.getGreen() - startColor.getGreen()) * ratio);
int blue = (int) (startColor.getBlue() + (endColor.getBlue() - startColor.getBlue()) * ratio);
return new Color(red, green, blue);
}
}
Gradle dependencies:
dependencies {
implementation 'org.json:json:20250517'
implementation 'com.hellokaton:blade-core:2.1.2.RELEASE'
implementation 'net.logicsquad:nanocaptcha:2.1'
implementation 'org.slf4j:slf4j-simple:2.0.17'
}
A demo HTML template:
templates/demo.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Demo</title>
</head>
<body>
<p><input type="button" value="Request a new captcha"
onclick="requestCaptcha();"></p>
<div id="captchaContainer">
<p>Captcha will be displayed here after request.</p>
</div>
<p>
<label for="captchaInput">Captcha text:</label>
<input type="text" id="captchaInput" placeholder="Enter captcha here">
<input type="button" value="Submit captcha" onclick="submitCaptcha();">
</p>
<script>
let hash = null;
function hashString(str) {
let hash = 0, l = str.length, i = 0;
if (l > 0)
while (i < l)
hash = (hash << 5) - hash + str.charCodeAt(i++) | 0;
return hash;
}
async function fetchJson(req) {
try {
let response = await fetch(req);
let contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
return response.json();
} else {
return {
"ok": false,
"message": "No json received, invalid request."
};
}
} catch (err) {
return {
"ok": false,
"message": error.message || "Unknown error"
};
}
}
async function requestCaptcha() {
await fetchJson('/captcha/get')
.then(json => {
if (!json.ok) {
throw new Error(json.message || 'Failed to fetch captcha');
}
let captchaContainer = document.getElementById('captchaContainer');
captchaContainer.innerHTML = ''; // Clear previous content
let img = document.createElement('img');
img.src = 'data:image/png;base64,' + json.base64Image;
img.alt = 'Captcha Image';
captchaContainer.appendChild(img);
hash = hashString(json.base64Image); // Store the hash of the image
})
.catch(error => {
let captchaContainer = document.getElementById('captchaContainer');
let errorMessage = document.createElement('p');
errorMessage.textContent = 'Error fetching captcha: ' + (error.message || 'Unknown error');
captchaContainer.appendChild(errorMessage);
});
}
async function submitCaptcha() {
let hashVal = hash || 0;
let captchaText = document.getElementById('captchaInput').value || 'empty';
await fetchJson('/captcha/check/' + hashVal + '/' + encodeURIComponent(captchaText))
.then(json => {
if (!json.ok) {
throw new Error(json.message || 'Failed to fetch captcha');
}
let captchaContainer = document.getElementById('captchaContainer');
captchaContainer.innerHTML = ''; // Clear previous content
let resultMessage = document.createElement('p');
resultMessage.textContent = json.message || 'Captcha is correct!';
captchaContainer.appendChild(resultMessage);
let secretMessage = document.createElement('p');
secretMessage.innerHTML = json.secret_message || 'No secret message provided.';
captchaContainer.appendChild(secretMessage);
hash = null; // Reset hash after submission
})
.catch(error => {
let captchaContainer = document.getElementById('captchaContainer');
let errorMessage = document.createElement('p');
errorMessage.textContent = 'Error submitting captcha: ' + (error.message || 'Unknown error');
captchaContainer.appendChild(errorMessage);
});
}
</script>
</body>
</html>
Now you can run the demo and open http://localhost/captcha/demo to test it.