The task was to implement a slider-based CAPTCHA for a web application, requiring access from both browser clients and mobile app developers. After evaluating existing libraries, many were either abandoned, overly complex, or lacked clear backend implementaitons. The solution described here follows a straightforward contract: the server supplies background and puzzle images as base64 strings, along with dimentions and a single-use token to thwart replay attacks.
Backend Design
The server exposes two endpoints. One serves a new CAPTCHA challenge, the other validates the user's drag offset. Background images are randomly selected from a preloaded set. A small rectangle is carved out to become the puzzle piece, and the remaining hole is filled with sampled color noise to obscure its position. The correct horizontal offset is stored in Redis with a short TTL, keyed by a UUID token that is consumed upon verification.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.web.bind.annotation.*;
import javax.annotation.PostConstruct;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.TimeUnit;
@RestController
public class CaptchaController {
private final StringRedisTemplate redisTemplate;
private String[] imagePaths;
private static final int BLOCK_WIDTH = 40;
private static final int BLOCK_HEIGHT = 40;
public CaptchaController(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@PostConstruct
public void loadResources() throws IOException {
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
Resource[] resources = resolver.getResources("classpath:/images/*.jpg");
imagePaths = new String[resources.length];
for (int i = 0; i < resources.length; i++) {
imagePaths[i] = resources[i].getURI().toString();
}
}
@GetMapping("/challenge")
public Map<String, Object> fetchChallenge() throws IOException {
BufferedImage background = pickRandomBackground();
int clueX = (int) (Math.random() * (background.getWidth() - BLOCK_WIDTH));
int clueY = (int) (Math.random() * (background.getHeight() - BLOCK_HEIGHT));
BufferedImage puzzleBlock = captureBlock(background, clueX, clueY);
obscureBlockArea(background, clueX, clueY);
String bgBase64 = encodeAsJpeg(background);
String blockBase64 = encodeAsPng(puzzleBlock);
String token = UUID.randomUUID().toString();
ValueOperations<String, String> store = redisTemplate.opsForValue();
store.set(token, String.valueOf(clueX), 5, TimeUnit.MINUTES);
Map<String, Object> payload = new HashMap<>();
payload.put("backgroundImage", bgBase64);
payload.put("puzzlePiece", blockBase64);
payload.put("token", token);
payload.put("backgroundWidth", background.getWidth());
payload.put("backgroundHeight", background.getHeight());
payload.put("puzzlePieceWidth", BLOCK_WIDTH);
payload.put("puzzlePieceHeight", BLOCK_HEIGHT);
return payload;
}
@PostMapping("/verify")
public Map<String, Object> checkAnswer(@RequestBody Map<String, Object> body) {
String token = (String) body.get("token");
int submittedPosition = (Integer) body.get("position");
ValueOperations<String, String> store = redisTemplate.opsForValue();
String stored = store.get(token);
boolean passed = false;
if (stored != null) {
int correct = Integer.parseInt(stored);
passed = Math.abs(submittedPosition - correct) < 10;
redisTemplate.delete(token);
}
Map<String, Object> result = new HashMap<>();
result.put("success", passed);
return result;
}
private BufferedImage pickRandomBackground() throws IOException {
String selected = imagePaths[(int) (Math.random() * imagePaths.length)];
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
Resource resource = resolver.getResource(selected);
try (var stream = resource.getInputStream()) {
return ImageIO.read(stream);
}
}
private BufferedImage captureBlock(BufferedImage source, int x, int y) {
BufferedImage block = new BufferedImage(BLOCK_WIDTH, BLOCK_HEIGHT, BufferedImage.TYPE_INT_ARGB);
Graphics2D graphics = block.createGraphics();
graphics.drawImage(source, 0, 0, BLOCK_WIDTH, BLOCK_HEIGHT, x, y, x + BLOCK_WIDTH, y + BLOCK_HEIGHT, null);
graphics.dispose();
return block;
}
private void obscureBlockArea(BufferedImage image, int x, int y) {
Graphics2D graphics = image.createGraphics();
graphics.setComposite(AlphaComposite.Src);
graphics.setColor(Color.WHITE);
graphics.fillRect(x, y, BLOCK_WIDTH, BLOCK_HEIGHT);
// Inject local color samples as noise to disguise the filled rectangle
int maxH = image.getHeight();
int maxW = image.getWidth();
for (int row = x; row < x + BLOCK_WIDTH; row++) {
for (int col = y; col < y + BLOCK_HEIGHT; col++) {
int sampleX = (int) (Math.random() * Math.min(row + 1, maxW));
int sampleY = (int) (Math.random() * Math.min(col + 1, maxH));
Color pick = new Color(image.getRGB(sampleX, sampleY));
graphics.setColor(pick);
graphics.fillRect(row, col, 1, 1);
}
}
graphics.dispose();
}
private String encodeAsJpeg(BufferedImage image) throws IOException {
return encode(image, "jpg");
}
private String encodeAsPng(BufferedImage image) throws IOException {
return encode(image, "png");
}
private String encode(BufferedImage image, String format) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
ImageIO.write(image, format, buffer);
return Base64.getEncoder().encodeToString(buffer.toByteArray());
}
}
Frontend Implemantation
A plain HTML interface renders the background and the draggable puzzle slice. The slider controls the horizontal placement of the block. On release, the current position is sent to the verification endpoint. A refresh button fetches a brand-new challenge. An nginx reverse proxy avoids cross-origin issues during development and production.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Slider CAPTCHA</title>
<style>
.captcha-stage {
position: relative;
width: 367px;
height: 267px;
margin: 50px auto;
border: 1px solid #ccc;
background: #f0f0f0;
}
.bg-picture {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.puzzle-clue {
position: absolute;
width: 40px;
height: 40px;
cursor: grab;
box-shadow: 0 0 12px rgba(0,0,0,0.25);
}
.control-bar {
width: 380px;
margin: 20px auto;
display: flex;
align-items: center;
justify-content: center;
}
.dragger {
width: 100%;
-webkit-appearance: none;
appearance: none;
height: 10px;
background: #ddd;
border-radius: 5px;
outline: none;
}
.dragger::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 22px;
height: 22px;
background: #2e7d32;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 6px rgba(0,0,0,0.5);
}
.refresh-action {
margin-left: 10px;
padding: 8px 16px;
background: #2e7d32;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
</style>
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css">
</head>
<body>
<div class="captcha-stage">
<img id="backgroundImg" class="bg-picture" src="" alt="Background">
<div id="puzzlePiece" class="puzzle-clue"></div>
</div>
<div class="control-bar">
<input type="range" min="0" max="327" value="0" class="dragger" id="dragSlider">
<button class="refresh-action" id="reloadBtn"><i class="fas fa-sync-alt"></i></button>
</div>
<script>
(function() {
const slider = document.getElementById('dragSlider');
const puzzle = document.getElementById('puzzlePiece');
const bgImg = document.getElementById('backgroundImg');
let securityToken = '';
async function requestNewChallenge() {
try {
const res = await fetch('/api/challenge');
const data = await res.json();
bgImg.src = 'data:image/jpeg;base64,' + data.backgroundImage;
puzzle.style.backgroundImage = 'url(data:image/png;base64,' + data.puzzlePiece + ')';
puzzle.style.top = data.puzzlePieceTop + 'px';
puzzle.style.left = '0px';
securityToken = data.token;
slider.value = 0;
} catch (err) {
console.error('Failed to load challenge', err);
}
}
document.getElementById('reloadBtn').addEventListener('click', requestNewChallenge);
slider.addEventListener('input', () => {
puzzle.style.left = slider.value + 'px';
});
slider.addEventListener('change', async () => {
const offset = parseInt(slider.value, 10);
try {
const res = await fetch('/api/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: securityToken, position: offset })
});
const outcome = await res.json();
if (outcome.success) {
alert('Verification successful!');
} else {
alert('Verification failed, please try again.');
}
} catch (err) {
console.error('Verification error', err);
} finally {
requestNewChallenge();
}
});
requestNewChallenge();
})();
</script>
</body>
</html>