visit
In this post, we'll walk through the process of creating a custom slider CAPTCHA service using Node.js and Redis without complex checking of full behavior, like duration for solving captcha and checking if users make movement linear (from my experience, bots make linear).
Here's a general way it might work:
To create a slider captcha service with Node.js, you'll need to follow these steps:
mkdir slider-captcha-service
cd slider-captcha-service
npm init -y
yarn add typescript ts-node @tsconfig/node20 --dev
And create tsconfig.json
{
"extends": "@tsconfig/node20/tsconfig.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"],
"exclude": ["node_modules"]
}
yarn add express redis uuid jimp
express
: Framework for creating the web server.redis
: Node.js Redis client.uuid
: For generating unique identifiers for each CAPTCHA instance.yarn add @types/express @types/uuid --dev
create src/index.ts
import express from 'express';
import {createClient} from 'redis';
const app = express();
const client = createClient();
client.on('error', (err) => {
console.error('Redis error:', err);
});
const PORT = 3000;
client.connect().then(() => {
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
})
import jimp from "jimp";
const FRAME_WIDTH = 100;
const FRAME_HEIGHT = 100;
const BORDER = 2;
export async function getImages(positionX: number, positionY: number): Promise<{ back: string, puzzle: string }> {
const startX = 147 + positionX * 1.5;
const startY = 70 + positionY * 2;
// generate back image
const back = await jimp.read("server/assets/puzzle.png");
const backShadow = new jimp(FRAME_WIDTH, FRAME_HEIGHT, 'rgba(0,0,0,0.5)');
back.composite(backShadow, startX - FRAME_WIDTH / 2, startY - FRAME_HEIGHT / 2);
// generate puzzle image
const puzzlePattern = await jimp.read("server/assets/puzzle.png");
puzzlePattern.crop(startX + BORDER - FRAME_WIDTH / 2, startY + BORDER - FRAME_HEIGHT / 2, FRAME_WIDTH - BORDER * 2, FRAME_HEIGHT - BORDER * 2);
const puzzle = new jimp(100, 400, 'transparent')
const puzzleBack = new jimp(FRAME_WIDTH, FRAME_HEIGHT, 'rgba(0,0,0,0.5)');
puzzle.composite(puzzleBack, 0, startY - FRAME_WIDTH / 2);
puzzle.composite(puzzlePattern, 0 + BORDER, startY + BORDER - FRAME_HEIGHT / 2);
return {
back: await back.getBase64Async(jimp.AUTO),
puzzle: await puzzle.getBase64Async(jimp.AUTO)
}
}
Three constants are defined: FRAME_WIDTH
, FRAME_HEIGHT
, and BORDER
, which determines the size and border dimensions of the puzzle piece. Function getImages
takes positionX and positionY coordinates for the captcha.
app.get('/captcha', async (req, res) => {
// Generate a random position for the slider between 0 to 255 for x position
const positionX = Math.floor(Math.random() * 255) + 1;
// Generate a random position for the slider between 0 to 130 for y position
const positionY = Math.floor(Math.random() * 130) + 1;
const captchaId = uuidv4();
// Store the position in Redis with an expiry time of 5 minutes
await client.set(captchaId, positionX, {'EX': 300});
const {back, puzzle} = await getImages(positionX, positionY);
res.json({
captchaId,
back,
puzzle,
prompt: 'Drag the slider to the correct position.'
});
});
app.post('/captcha', async (req, res) => {
const {captchaId} = req.body;
try {
const actualPosition = await client.get(captchaId);
if (!actualPosition) {
return res.status(400).json({message: 'Invalid or expired CAPTCHA'})
}
await client.del(captchaId);
const values = Buffer.from(req.body.value, 'base64');
if (Math.abs(values[values.length - 1] - parseInt(actualPosition, 10)) <= 10) {
return res.json({success: true});
} else {
return res.json({success: false});
}
} catch (e) {
return res.status(500).json({message: 'Internal Server Error'});
}
});
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<div id="captcha-container">
<div id="slider-bar">
<img src="" alt="back"/>
<div id="slider"><img src="" alt="slider" /></div>
<div id="slider-status"></div>
</div>
<div id="slider-navigation">
<input type="range" min="0" max="500" value="0" class="slider">
</div>
</div>
`
const slider = document.querySelector('#slider') as HTMLElement;
const sliderBar = document.querySelector('#slider-bar') as HTMLElement;
const sliderStatus = document.querySelector('#slider-status') as HTMLElement;
const input = document.querySelector('#slider-navigation input') as HTMLInputElement;
async function check(captchaId: string, value: string) {
const checkResp = await fetch('//localhost:3000/captcha', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
captchaId,
value,
})
});
const resp = await checkResp.json();
if (resp.success) {
sliderStatus.innerText = 'Success';
sliderStatus.style.backgroundColor = '#0080003d';
sliderStatus.style.opacity = '1';
} else {
sliderStatus.innerText = 'Failed';
sliderStatus.style.backgroundColor = '#ff00003d';
sliderStatus.style.opacity = '1';
}
}
let path = [0];
input.addEventListener('input', (_) => {
const value = parseInt(input.value, 10);
const pos = Math.round((value - 97.5) / 1.5);
if (pos !== path[path.length - 1]) {
path.push(pos);
}
slider.style.left = value + 'px';
});
document.addEventListener('DOMContentLoaded', async () => {
const {captchaId, back, puzzle} = await fetch('//localhost:3000/captcha').then(res => res.json());
let path = [0];
sliderBar.querySelector('img')!.src = back;
slider.querySelector('img')!.src = puzzle;
input.disabled = false;
input.addEventListener('change', async (_) => {
input.disabled = true;
const value = parseInt(input.value, 10);
path.push(Math.round((value - 97.5) / 1.5));
await check(captchaId, arrayBufferToBase64(path));
path = []
})
});
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
--slider-width: 600px;
--slider-height: 400px;
--slider-bar-width: 600px;
--slider-thumb-width: 25px;
--slider-thumb-height: 25px;
--slider-thumb-color: #04AA6D;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
#slider-bar {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 1rem;
width: 600px;
height: 400px;
position: relative;
img {
width: 100%;
height: 100%;
z-index: 1;
}
}
#slider {
width: 100px;
height: 400px;
position: absolute;
top: 0;
left: 0;
overflow: hidden;
z-index: 2;
}
#slider-status {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
font-size: 2rem;
font-weight: 700;
color: #fff;
opacity: 0;
transition: opacity 0.2s ease-in-out;
z-index: 3;
}
#slider-navigation {
width: 600px;
input {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 25px;
background: #d3d3d3;
outline: none;
opacity: 0.7;
-webkit-transition: .2s;
transition: opacity .2s;
&:hover {
opacity: 1;
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: var(--slider-thumb-width);
height: var(--slider-thumb-height);
background: var(--slider-thumb-color);
cursor: pointer;
}
&::-moz-range-thumb {
width: var(--slider-thumb-width);
height: var(--slider-thumb-height);
background: var(--slider-thumb-color);
cursor: pointer;
}
}
}
Time Analysis: Bots usually fill out forms much faster than humans. Monitoring how quickly form fields are filled out can help in differentiating bots from humans.
Limit Attempts: Restrict the number of CAPTCHA attempts allowed from a single IP in a given time period to deter brute-forcing.
Add Randomness: Randomly change the layout, style, and type of CAPTCHA to keep it unpredictable.
Monitor and Adapt: Constantly monitor attempts to solve or bypass your CAPTCHA. Any patterns or trends could indicate weaknesses in the system that you can address.