DEV Community

Cover image for Create your own CAPTCHA - part 4 - Algorithm, Node, TypeScript & React
Meat Boy
Meat Boy

Posted on • Edited on

Create your own CAPTCHA - part 4 - Algorithm, Node, TypeScript & React

Welcome in the last episode of the custom CAPTCHA mechanism course series. In the previous article, we already prepared the basics of our captcha for the client-side. Today we are going to fill our mechanism with some fuel from server-side and prepare an algorithm for verification both puzzle and leading zero challenges! Let's get started! 🤩

Welcome in the last episode

Why server-side?

Because our captcha must be secure, we cannot fetch images for background directly from their file paths. Why? Because all the photos would be the same every time. Moreover, after downloading, they would have the same file signature and file hash. It would be much faster to build a bypass mechanism for solving our captcha if captcha relies on repeated data.
Furthermore, sending destination coordinates (a place where the user should move puzzle) may result with network interceptors which scan, capture and then send as the answer the same value as captured.

Instead, we are going to make for every user little different background with puzzle already on it. User in our case is a device with a different IP address. In other words, when someone loads captcha, it will fetch a unique background for himself, so file signature will not match with those from previous users.

To achieve this, we will use an image manipulation library. Node has few different libraries for this. JIMP and SHARP are the two most popular. Both have very rich API for image manipulation but the devil is in the details. JIMP works directly on buffers and matrix of typed JS arrays. SHARP from the other hands, uses low-level LIBAV multimedia library and from the benchmark that I previously took is 5x faster.

Benchmark & Optimization

Composition of two 2k resolution images with JIMP took 4-5s when SHARP can do the same with 500ms. However, 500ms latency for each request is still not acceptable. Both libs perform better if our background images are smaller, like the size of the canvas. Composition of two images 480x240 with JIMP took about 20ms and with SHARP about 10ms.

To make sure images are optimized, we can pre-process them on startup. To do this, we can check the resolution of each image in a specific directory and save output with little lower quality.



const fs = require('fs');
const path = require('path');
const sharp = require('sharp');
import {OptimizeConfig} from "../models/OptimizeConfig";

export enum ImageFormat {
  'JPEG',
  'PNG'
}

export default class Optimize {
  static async dir(config: OptimizeConfig) : Promise<Array<string>> {
    const inputFileList = fs.readdirSync(config.inputDirectory);
    const outputFileList = fs.readdirSync(config.outputDirectory);

    for (const file of inputFileList) {
      if (!outputFileList.includes(file) || config.forceCleanCache) {
        const img = await sharp(path.join(config.inputDirectory, file));
        await img.resize({
          width: config.outputWidth,
          height: config.outputHeight,
        });

        if (config.outputFormat === ImageFormat.JPEG) {
          await img
            .jpeg({quality: config.outputQuality})
            .toFile(path.join(config.outputDirectory, file));
        } else if (config.outputFormat === ImageFormat.PNG) {
          await img
            .png({quality: config.outputQuality})
            .toFile(path.join(config.outputDirectory, file));
        }
      }
    }

    return fs.readdirSync(config.outputDirectory);
  }
}


Enter fullscreen mode Exit fullscreen mode

Image Composition

Our captcha requires background and puzzle to work correctly. The background should be composite with a puzzle on the server-side to indicate where the user should move puzzle on the client-side.



import {PuzzleCompositeConfig} from "../models/CompositeConfig";
import {ImageFormat} from "./Optimize";

const path = require('path');
const sharp = require('sharp');

export default class Background {
  private readonly filepath : string;

  constructor(filepath : string) {
    this.filepath = filepath;
  }

  public async compositePuzzle(config : PuzzleCompositeConfig) : Promise<Buffer> {
    const bg = await sharp(path.join(this.filepath));

    await bg
      .composite([{
        input: path.join(config.compositeFilepath),
        top: config.top,
        left: config.left,
        blend: "over"
      }]);

    if (config.outputFormat === ImageFormat.PNG) {
      return await bg.png({
        quality: config.outputQuality
      }).toBuffer();
    } else if (config.outputFormat === ImageFormat.JPEG) {
      return await bg.jpeg({
        quality: config.outputQuality
      }).toBuffer();
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

Now, when we have a background with destination puzzle, we need to prepare a puzzle for the client-side. This puzzle should look like the piece that we extract from the background. So, this time we are overlapping puzzle with the background at the correct position and with proper composite mode.



import {BackgroundCompositeConfig} from "../models/CompositeConfig";
import {ImageFormat} from "./Optimize";
const sharp = require('sharp');

export default class Puzzle {
  private readonly filepath : string;

  constructor(filepath : string) {
    this.filepath = filepath;
  }

  public async compositeBackground (config : BackgroundCompositeConfig) : Promise<Buffer> {
    const puzzle = await sharp(this.filepath);
    const background = sharp(config.compositeFilepath);

    await background.extract({
      left: config.left,
      top: config.top,
      width: config.puzzleWidth,
      height: config.puzzleHeight
    });

    await puzzle
      .composite([{
        input: await background.toBuffer(),
        blend: 'in'
      }])


    if (config.outputFormat === ImageFormat.PNG) {
      return await puzzle.png({
        quality: config.outputQuality
      }).toBuffer();
    } else if (config.outputFormat === ImageFormat.JPEG) {
      return await puzzle.jpeg({
        quality: config.outputQuality
      }).toBuffer();
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

We also need to save coordinates for the future for verifying response. To do that, we can use Redis, which is fast, in-memory database. To quickly run Redis, we can use Docker.



import {UserDataResponse} from "../models/UserDataResponse";
import {UserDataRequest} from "../models/UserDataRequest";

const path = require('path');
const {getClientIp} = require('request-ip');
const crypto = require('crypto');

export default class UserDataController {
  static getRandomFileIndex(files: string[]) {
    return Math.floor(Math.random() * files.length);
  }

  static async getOrSetUserData(userDataRequest : UserDataRequest) : Promise<UserDataResponse> {
    const {req, redisClient, fileList, config} = userDataRequest;

    let userData: UserDataResponse;

    const clientIp = getClientIp(req);
    const key = crypto.createHash('md5')
      .update(clientIp)
      .digest("hex");

    if (await redisClient.ttl(key) > 0) {
      const userDataJSON = await redisClient.get(key);
      userData = JSON.parse(userDataJSON);
    } else {
      await redisClient.del(key);
      const imageIndex = this.getRandomFileIndex(fileList);
      const challenges = this.getRandomChallenges(config.challengeCount, config.challengeLength);

      userData = {
        backgroundPath: path.join(__dirname, '../../', config.backgroundImagesPath, fileList[imageIndex]),
        backgroundPuzzlePath: path.join(__dirname, '../../', config.backgroundPuzzlePath),
        clientPuzzlePath: path.join(__dirname, '../../', config.clientPuzzlePath),
        positionX: this.getRandomPuzzlePosition(0, 480, config.puzzleWidth),
        positionY: this.getRandomPuzzlePosition(32, 248, config.puzzleHeight),
        challenges,
        key
      };

      await redisClient.set(key, JSON.stringify(userData), 'EX', config.maxTTL);
    }

    return userData;
  }

  private static getRandomPuzzlePosition(min : number, max : number, puzzleSize : number) {
    return Math.round(Math.random() * ((max - puzzleSize) - (min + puzzleSize))) + min + puzzleSize;
  }
}


Enter fullscreen mode Exit fullscreen mode

Now, when we have images, we can alter the client app to use them.



    const background = PIXI.Sprite.from(`${this.props.baseUrl}/bg.jpeg`);

// ...

    const puzzle = PIXI.Sprite.from(`${this.props.baseUrl}/puzzle.png`);

// ...

const response = await fetch(`${this.props.baseUrl}/challenge`);
    const data = await response.json();
    this.setState(() => {
      return {
        challenges: data,
      };
    });


Enter fullscreen mode Exit fullscreen mode

Also, we can make captcha more configurable by extending config options.



export type CaptchaConfig = {
  appendSelector: string,
  promptText: string,
  lockedText: string,
  savingText: string,
  privacyUrl: string,
  termsUrl: string,
  baseUrl: string,
  puzzleAlpha: number,
  canvasContainerId: string,
  leadingZerosLength: number,
  workerPath: string,
  responseRef: number
}

export type CaptchaResponse = {
  x: number,
  y: number,
  challenge: object
}

export interface ICaptcha {
  config: CaptchaConfig,
  getResponse(): Promise<CaptchaResponse>
}


Enter fullscreen mode Exit fullscreen mode

Security of our captcha relies on different Web APIs, image recognition and leading-zero mechanism similar to this in hashcash (spam prevention tool). The client should receive an array full of challenges and find a hash which results with a required number of zeros in front of the string. Of course, the bot may extract this hash and operate on their machines to find prefix, but it cost a little time to calculate a hash, and it requires an effort. So it is not about making it impossible but cost-ineffective.

To make the leading-zero challenge, we will prepare another endpoint which generates few long strings, save them inside Redis and returns to the user.



// ...
  private static getRandomChallenges(challengeCount : number, challengeLength : number) {
    const challenges = [];
    for (let i = 0; i < challengeCount; i++) {
      challenges.push(crypto.randomBytes(challengeLength)
        .toString('base64'));
    }
    return challenges;
  }
// ...


Enter fullscreen mode Exit fullscreen mode

On the client-side, we are going to make the process of finding leading zero asynchronous. To achieve that we can separate algorithm for finding prefix answers to a different file and run it with Worker API who uses different thread and will not block the user interface. The non-blocking operation may be crucial for mobile devices which still have less performance than desktops.



async getResponse() : Promise<CaptchaResponse> {
    return new Promise(((resolve, reject) => {
      if (this.state.progressState !== ProgressState.INITIAL) {
        reject('Already responded');
      }

      this.workerStart();

      const worker = new Worker(this.props.workerPath);
      worker.postMessage({
        challenges: this.state.challenges,
        leadingZerosLength: this.props.leadingZerosLength
      });

      worker.addEventListener('message', (event : MessageEvent) => {
        if (event.data.type === 'next') {
          this.setWorkerProgress(event.data['solved'], event.data['total']);
        } else if (event.data.type === 'success') {
          this.workerEnd();

          resolve({
            x: this.state.puzzle.x - this.state.puzzle.width / 2,
            y: this.state.puzzle.y - this.state.puzzle.height / 2,
            challenge: event.data['arr']
          });
        }
      });
    }));
  }


Enter fullscreen mode Exit fullscreen mode

Worker file:




/**
 * [js-sha256]{@link https://github.com/emn178/js-sha256}
 *
 * @version 0.9.0
 * @author Chen, Yi-Cyuan [emn178@gmail.com]
 * @copyright Chen, Yi-Cyuan 2014-2017
 * @license MIT
 */
!function(){"use strict";function t(t,i)!function(){"use strict";function t(t,i){i?(d[0]=d[16]=d[1]=d[2]=d[3]=d[4]=d[5]=d[6]=d[7]=d[8]=d[9]=d[10]=d[11]=d[12]=d[13]=d[14]=d[15]=0,this.blocks=d): ... // https://github.com/emn178/js-sha256


/**
 * Captcha Worker
 * @description Part of devcaptcha client
 * @param event
 */
self.onmessage = (event) => {
  const arr = [];
  for (const challenge of event.data.challenges) {
    let prefix = 0;
    while (true) {
      const answer = sha256(prefix + challenge);
      if (answer.startsWith('0'.repeat(event.data.leadingZerosLength))) {
        arr.push({
          challenge,
          prefix
        });
        self.postMessage({
          type: 'next',
          solved: arr.length,
          total: event.data.challenges.length
        });
        break;
      }
      prefix++;
    }
  }

  self.postMessage({
    type: 'success',
    arr
  });
}


Enter fullscreen mode Exit fullscreen mode

To make better UX feeling we can lock captcha from the moment when it is not interactive and show real progress of solving.




  workerStart() {
    this.setState(() => {
      return {
        progressState: ProgressState.SAVING
      };
    }, () => {
      const {puzzle, lockOverlay, stepIndicator, progressText} = this.state;
      puzzle.interactive = false;
      puzzle.buttonMode = false;
      lockOverlay.alpha = 0.5;
      stepIndicator.visible = true;
      progressText.visible = true;

      this.setWorkerProgress(0, 1);
    });
  }

  setWorkerProgress(solved : number, total : number) {
    const {stepIndicator, progressText, loadingSpinner} = this.state;
    progressText.text = Math.ceil(solved/total * 100) + '%';
    if (solved < total) {
      stepIndicator.text = this.props.savingText;
      loadingSpinner.visible = true;
    } else {
      stepIndicator.text = this.props.lockedText;
      loadingSpinner.visible = false;
    }
  }

  workerEnd() {
    this.setState(() => {
      return {
        progressState: ProgressState.LOCKED
      };
    }, () => {
      this.setWorkerProgress(1, 1);
    });
  }


Enter fullscreen mode Exit fullscreen mode

We can also add dark overlay, loading spinner and helper texts:



    const lockOverlay = new PIXI.Graphics();
    lockOverlay.beginFill(0x000000);
    lockOverlay.alpha = 0;
    lockOverlay.drawRect(0, 0,
      this.state.app.view.width,
      this.state.app.view.height
    );
    lockOverlay.endFill();
    this.state.app.stage.addChild(lockOverlay);

    const loadingSpinner = PIXI.Sprite.from(`${this.props.baseUrl}/static/loading.png`);
    loadingSpinner.anchor.set(0.5, 0.5);
    loadingSpinner.visible = false;
    loadingSpinner.x = this.state.app.view.width / 2;
    loadingSpinner.y = this.state.app.view.height / 2;
    this.state.app.stage.addChild(loadingSpinner);

    this.state.app.ticker.add(delta => {
      loadingSpinner.rotation += 0.1 * delta;
    });

    const progressText = new PIXI.Text('0%', {
      fontFamily: 'Arial',
      fontSize: 24,
      fill: '#ffffff'
    });
    progressText.visible = false;
    progressText.anchor.set(0.5, 0.5);
    progressText.x = this.state.app.view.width / 2;
    progressText.y = this.state.app.view.height / 2 + 12;
    this.state.app.stage.addChild(progressText);

    const stepIndicator = new PIXI.Text('Saving...', {
      fontFamily: 'Arial',
      fontSize: 16,
      fontWeight: 'bold',
      fill: '#ffffff',
    });
    stepIndicator.visible = false;
    stepIndicator.anchor.set(0.5, 0.5);
    stepIndicator.x = this.state.app.view.width / 2;
    stepIndicator.y = this.state.app.view.height / 2 - 12;
    this.state.app.stage.addChild(stepIndicator);

    this.setState(() => {
      return {
        puzzle,
        lockOverlay,
        progressText,
        stepIndicator,
        loadingSpinner
      }
    });


Enter fullscreen mode Exit fullscreen mode

To run this code we need to prepare public method in Captcha class and run method inside App component. This may be a little tricky because React captcha in our case it's not static so we can't force application to run this directly. Instead, we can prepare helper array and bind it to the global context as we do with Captcha class, then push reference of internal method from each instance and run from a public instance method.



// App.tsx
// constructor
window.__getDevCaptchaResponses.push(this.getResponse);


Enter fullscreen mode Exit fullscreen mode


// index.tsx
import * as React from "react";
import * as ReactDOM from "react-dom";

import { App } from "./components/App";
import {CaptchaConfig, CaptchaResponse, ICaptcha} from "./models/Captcha";

class DevCaptcha implements ICaptcha {
  readonly config : CaptchaConfig;
  readonly responseRef : number = 0;

  public constructor(config : CaptchaConfig) {
    this.config = config;

    if (window.__getDevCaptchaResponses) {
      this.responseRef = window.__getDevCaptchaResponses.length;
    }

    ReactDOM.render(<App {...this.config} responseRef={this.responseRef} />, document.querySelector(this.config.appendSelector));
  }

  public async getResponse() : Promise<CaptchaResponse> {
    return window.__getDevCaptchaResponses[this.responseRef]();
  }
}

declare global {
  interface Window {
    DevCaptcha: ICaptcha | object,
    __getDevCaptchaResponses: Array<() => Promise<CaptchaResponse>>
  }
}

let _window : Window = window;
_window['DevCaptcha'] = DevCaptcha;
_window['__getDevCaptchaResponses'] = [];


Enter fullscreen mode Exit fullscreen mode

At this moment you should be able to run your captcha and check user humanity:



<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8" />
    <title>Hello React!</title>
</head>
<body>

<div class="h-100 flex center">
    <div id="captcha"></div>
</div>

<div class="h-100 flex center">
    <div id="captcha2"></div>
</div>

<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<script crossorigin src="https://cdnjs.cloudflare.com/ajax/libs/pixi.js/5.1.3/pixi.min.js"></script>
<script src="main.js"></script>
<script>
    const devcaptcha = new DevCaptcha({
      appendSelector: '#captcha',
      promptText: 'Move the puzzle to the correct position to solve captcha',
      lockedText: 'Locked',
      savingText: 'Wait',
      privacyUrl: 'https://example.com',
      termsUrl: 'https://example.com',
      baseUrl: 'http://localhost:8081',
      puzzleAlpha: 0.9,
      canvasContainerId: 'devcaptcha-container',
      leadingZerosLength: 3,
      workerPath: './worker.js'
    });
</script>
</body>
</html>


Enter fullscreen mode Exit fullscreen mode

UX/UI

Recently I asked you for advice about UI/UX and you respond with a lot of great opinions!

Some of you recommend to make the puzzle more visible, we can do that by changing the source puzzle image. I make it blurred to better blend with the background, however, we can make more sharp edge to be better visible for people (but remember, for software like OpenCV and edge detection tools also!).

Also, you recommend making canvas border more rounded. Because canvas is an element of HTML we can use CSS to do this.



canvas {
            box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.75);
            border-radius: 5px;
        }


Enter fullscreen mode Exit fullscreen mode

You recommend changing the submit button as well. And because we have a public method to run captcha programmable we do not need it anymore. So we can remove button, text on it and icon.

To make loading of this captcha we can add fade-out effect:



    const fadeOut = new PIXI.Graphics();
    fadeOut.beginFill(0xffffff);
    fadeOut.drawRect(0, 0,
      this.state.app.view.width,
      this.state.app.view.height
    );
    fadeOut.endFill();
    this.state.app.stage.addChild(fadeOut);

    for (let i = 0; i < 100; i++) {
      fadeOut.alpha -= i/100;
      await wait(16);
    }


Enter fullscreen mode Exit fullscreen mode

Uff. And this is how we create our fast, responsive CAPTCHA mechanism! 🥳 You can use it now to secure your website, forum or blog. To make it even more secure you can change some parts of the algorithm, so it will be unique and tailored for your site.

You did it!

Full source code you can find at GitHub.

GitHub logo pilotpirxie / devcaptcha

🤖 Open source captcha made with React, Node and TypeScript for DEV.to community

devcaptcha

Open source captcha made with React, Node and TypeScript for DEV.to community

Features

  • Fast and efficient, uses Redis as temp storage,
  • Implements leading zero challenge,
  • Requires image recognition to find coordinates on a background,
  • Customizable, you can easily tailor to your needs,
  • Simple integration in just few minutes,
  • Written with Typescript, React, Node and Express,

Screenshot 1

Screenshot 2

Screenshot 3

Getting started

git clone https://github.com/pilotpirxie/devcaptcha.git
cd devcaptcha/devcaptcha-server
yarn install
yarn start
Enter fullscreen mode Exit fullscreen mode

Integration

Captcha should be configured equally on the client and backend side to works correctly.

const devcaptcha = new DevCaptcha({
  appendSelector: '#captcha',
  promptText: 'Move the puzzle to the correct position to solve captcha',
  lockedText: 'Locked',
  savingText: 'Wait',
  privacyUrl: 'https://example.com',
  termsUrl: 'https://example.com',
  baseUrl: 'http://localhost:8081',
  puzzleAlpha: 0.9,
  canvasContainerId: 'devcaptcha-container',
  leadingZerosLength: 3,
  workerPath: './worker.js'
});
Enter fullscreen mode Exit fullscreen mode

Client Config Definition:

export type CaptchaConfig

Thank you for this long journey. Maybe in the future, we will work on another security solution. If you want to see more tutorials like this follow me on DEV.to and star repo on GitHub. Have a nice day and see you soon! 😉

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.