Create Slider Or Puzzle Captcha Service with Node.js

Written by vivalaakam | Published 2023/09/14
Tech Story Tags: web-development | captcha | nodejs | redis | slider-captcha | puzzle-captcha | captcha-with-nodejs | captcha-generator-logic

TLDRThis post provides a step-by-step guide to creating a custom slider CAPTCHA service using Node.js and Redis. Slider CAPTCHAs are user-friendly and require users to slide an element to a specific position on a scale to prove they are human. The post covers generating CAPTCHA images, implementing CAPTCHA logic, and the frontend component in vanilla JavaScript. It also provides tips for improving CAPTCHA security, such as time analysis, limiting attempts, adding randomness, and monitoring and adapting.via the TL;DR App

CAPTCHAs have long been the first line of defense against spam and automated bots on the internet. While there are many types of CAPTCHAs available, the slider CAPTCHA has gained popularity due to its intuitive nature, however, this type of captcha is most applicable on mobile devices, where a swipe represents an intuitive action.

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).

Introduction

A "slider captcha" refers to a type of CAPTCHA (Completely Automated Public Turing test to tell Computers and Humans Apart) that requires users to drag or slide an element to a particular position on a scale or along a track to verify that they are human. The primary idea behind CAPTCHAs is to prevent bots from automatically submitting forms, scraping websites, or conducting other potentially harmful actions.

In a slider captcha, the user might be presented with a simple puzzle, such as sliding a piece to complete an image or moving a slider to a specified position. The action required to solve this type of CAPTCHA typically involves nuanced human coordination, which is difficult for bots to replicate accurately.

Here's a general way it might work:

  1. The user is presented with a visual element (like a puzzle piece or slider) and a target area or track.

  2. The user needs to drag or slide that element to the correct position.

  3. When the element is in the correct position, the system should validate the user's action, and if the result is correct, it allows the user to proceed..

This kind of CAPTCHA is designed to be more user-friendly than traditional text-based CAPTCHAs, which often require users to decipher distorted characters. However, the efficacy of slider CAPTCHAs can vary, and sophisticated bots can defeat some. As with any other security measure, evaluating its effectiveness and adapting to emerging threats is essential.

Prerequisites

For this project, were selected:

  • node.js
  • redis
  • npm package manage (or yarn)

I chose JavaScript language because in my opinion is an almost ideal tool for prototyping, and in the future, this example can easily be rewritten to suit your programming language.

To create a slider captcha service with Node.js, you'll need to follow these steps:

Create a new project

mkdir slider-captcha-service
cd slider-captcha-service
npm init -y

Prepare project

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"]
}

Install the Required Packages

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.

And types

yarn add @types/express @types/uuid --dev

Create skeleton

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}`);
    });
})

Generate images

The main component of the captcha slider is two pictures that need to be superimposed on each other.

  1. Main background image

  2. The place where there should be a puzzle

  3. Puzzle

Puzzle may be every form and size that you prefer:

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.

Back image - background image for our captcha

Puzzle image - image for the puzzle. to make it easier to work with positioning on the Y-axis, we make the height of the puzzle equal to the height of the back

Create CAPTCHA Generator Logic

The basic principle of operation of such types of captcha is to guess a certain number on our backend and let the user guess it using a hint (in this case, by combining two pictures)

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.'
    });
});

The range for positionX is generated in the range between 0 and 255 because it can be easily converted to bytes and after in base64.

CAPTCHA Validation Logic

After the user combined two pictures, we need to check his result. Right now, it's a simple comparison of two numbers in the specific range from guessed numbers. We use a range of numbers because it matches a specific number.

Also, to prevent bruteforce, we need to remove the current captcha Id, because otherwise user can create many requests, and one of them will be correct. Otherwise, the user has only one attempt to solve a captcha, and if it fails to request a new captcha.

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'});
    }
});

Frontend part

This block will be described rather superficially, leaving more freedom in choosing tools for implementation (for example, react or web components, or whatever you like best)

We will make it in Vanila javascript

At first, markup for our slider:

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>
`

Define basic variables for the slider.

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;

Check the user's answer and show our answer for his solution

async function check(captchaId: string, value: string) {
    const checkResp = await fetch('http://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';
    }
}

On moving range input, we interpolate the puzzle image horizontally

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';
});

After all content is loaded, we request a new capture, place all required images, and await input changes to check the results

document.addEventListener('DOMContentLoaded', async () => {
    const {captchaId, back, puzzle} = await fetch('http://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 = []
    })
});

Little bit css

: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;
    }
  }
}

Advice block

  • 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.


Written by vivalaakam | Enthusiast
Published by HackerNoon on 2023/09/14