Building a CAPTCHA API with Nodejs

Building a CAPTCHA API with Nodejs

In this post we will see how to build a captcha API with Nodejs. We will keep our implementation simple for now but it can have more complex implementation to make it difficult for the programs to crack it.

Introduction

CAPTCHA is very important to protect our application by doing a test to prove that the user is a human and not a program or a bot using the application. It is generally recommended as a part of VAPT report to have a strong CAPTCHA implementation in the application.

There has been a wide spread development of bots and automation in the recent times. These programs and bots use the application and try to crack the password and cause denial of service and many different types of attacks to the web application.

It is thus very important that we have an implementation of CAPTCHA at places where such attacks can be made. This ensures that the user of the application is a human and not a bot or program.

Do you know the fullform of CAPTCHA ?

Well, I always throught that captcha itself is a word rather than an abbreviation.
CAPTCHA stands for Completely Automated Public Turing test to tell Computers and Humans Apart.

Features of the CAPTCHA in our implementation

  1. The CAPTCHA is of six characters
  2. It contains numbers and alphabets
  3. The alphabets will have alternate upper and lower case.
  4. The CAPTCHA image will have dynamic background color.

API Endpoints

There are 2 API endpoints for the generation and validation of CAPTCHA

  1. GET /captcha - This will generate a CAPTCHA image and the hash of the text displayed in the image. The data of the image will be sent back to the user in the API response along with the HASH. We are sending back the HASH of the text instead of the CAPTCHA text so that the captcha text cannot be identified by the bots or programs by inspecting the source.

  2. POST /captcha - This endpoint is a POST request and it will need a JSON body to be passed in the request as mentioned below.
    Request

{
    "captcha":"ETc3l1",
    "hash":"$2b$10$0BkpmUJiBJfJSfzf7BrYiuJvGZtCJBBIjV.fk3ACrPiZ4Rl.FrrSy"
}

The captcha is the text on the image seen by the user and hash is the value returned by the GET api while generating the captcha.

Since the response returned by the GET endpoint a JSON we have another endpoint which will let you see the CAPTCHA generated.

/captcha/test

Implementation of the API

Packages used for the development of the API

The following packages have been used for the development of the API. You can install it at once in order to get started.

  1. express - Used for creating a RESTful API with nodejs.
  2. body-parser - To work with the request body sent as JSON format.
  3. canvas - This package is a backend implementation of the Canvas API in javascript. It is used to generate and customize the CAPTCHA image.
  4. bcrypt - This is a popular package used to perform hashing. In our API it is used to generate the hash of the CAPTCHA text and also verify the CAPTCHA entered by the user with the hash sent in the generate api (GET /canvas API)
  5. morgan - This is a popular logging package for nodejs applications.

While we are using these APIs for this demo , feel free to use the package you are comfortable with.

Creating the server

Lets us create a server and start listening it on a PORT. (4000 in our case)

server.js

const http = require('http')
const app = require('./app')
const port = process.env.PORT||4000
const server = http.createServer(app)
server.listen(port)

Creating an express app

app.js

const express = require('express')
const app = express()
const morgan = require('morgan')
const bodyParser = require('body-parser')

const captchaRoutes = require('./api/routes/captcha')
app.use(morgan("dev"));
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());

app.use((req, res, next) => {
    res.header("Access-Control-Allow-Origin", "*");
    res.header(
        "Access-Control-Allow-Headers",
        "Origin, X-Requested-With, Content-Type, Accept, Authorization"
    );
    if (req.method === "OPTIONS") {
        res.header("Access-Control-Allow-Methods", "PUT, POST, PATCH, DELETE, GET");
        return res.status(200).json({});
    }
    next();
});

app.use('/captcha', captchaRoutes)


app.use((req, res, next) => {
    const error = new Error("Not found");
    error.status = 404;
    next(error);
});


app.use((error, req, res, next) => {
    res.status(error.status || 500);
    console.log(error);
    res.json({
        error: {
            message: error.message
        }
    });
});
module.exports = app;

In the code above we are creating an express app. We have added middlewares to handle our request. The middlewares does the logging, parsing the body, adding the CORS headers to the response before it is sent to the client.

We then add our route handler to handle the route \captcha. We will look into the implementation soon.

app.use('/captcha', captchaRoutes)

We also add the handlers for the invalid routes and also the unhandled error in the application.

//handles invalid routes
app.use((req, res, next) => {
    const error = new Error("Not found");
    error.status = 404;
    next(error);
});

//handles error
app.use((error, req, res, next) => {
    res.status(error.status || 500);
    console.log(error);
    res.json({
        error: {
            message: error.message
        }
    });
});

CAPTCHA route handler

api\routes\captcha.js

const express = require('express')
const router = express.Router()
const { createCanvas } = require("canvas");
const bcrypt = require("bcrypt");

// https://gist.github.com/wesbos/1bb53baf84f6f58080548867290ac2b5
const alternateCapitals = str =>
    [...str].map((char, i) => char[`to${i % 2 ? "Upper" : "Lower"}Case`]()).join("");

// Get a random string of alphanumeric characters
const randomText = () =>
    alternateCapitals(
        Math.random()
            .toString(36)
            .substring(2, 8)
    );
const _generateRandomColour = () => {
    return "rgb(" + Math.floor((Math.random() * 255)) + ", " + Math.floor((Math.random() * 255)) + ", " + Math.floor((Math.random() * 255)) + ")";
}
const FONTBASE = 200;
const FONTSIZE = 35;

// Get a font size relative to base size and canvas width
const relativeFont = width => {
    const ratio = FONTSIZE / FONTBASE;
    const size = width * ratio;
    return `${size}px serif`;
};

// Get a float between min and max
const arbitraryRandom = (min, max) => Math.random() * (max - min) + min;

// Get a rotation between -degrees and degrees converted to radians
const randomRotation = (degrees = 15) => (arbitraryRandom(-degrees, degrees) * Math.PI) / 180;

// Configure captcha text
const configureText = (ctx, width, height) => {
    ctx.font = relativeFont(width);
    ctx.textBaseline = "middle";
    ctx.textAlign = "center";
    const text = randomText();
    ctx.globalCompositeOperation = "difference";
    ctx.strokeStyle = "white"
    ctx.strokeText(text, width / 2, height / 2);
    return text;
};

// Get a PNG dataURL of a captcha image
const generate = (width, height) => {
    const canvas = createCanvas(width, height);
    const ctx = canvas.getContext("2d");
    //ctx.rotate(randomRotation());
    const text = configureText(ctx, width, height);

    const colour1 = _generateRandomColour();
    const colour2 = _generateRandomColour();
    const gradient1 = ctx.createLinearGradient(0, 0, width, 0);
    gradient1.addColorStop(0, colour1);
    gradient1.addColorStop(1, colour2);

    ctx.fillStyle = gradient1;
    ctx.fillRect(0, 0, width, height);

    const gradient2 = ctx.createLinearGradient(0, 0, width, 0);
    gradient2.addColorStop(0, colour2);
    gradient2.addColorStop(1, colour1);

    ctx.fillStyle = gradient2;
    ctx.setTransform((Math.random() / 10) + 0.9,    //scalex
        0.1 - (Math.random() / 5),      //skewx
        0.1 - (Math.random() / 5),      //skewy
        (Math.random() / 10) + 0.9,     //scaley
        (Math.random() * 20) + 10,      //transx
        100);                           //transy

    return {
        image: canvas.toDataURL(),
        text: text
    };
};

// Human checkable test path, returns image for browser
router.get("/test/:width?/:height?/", (req, res) => {
    const width = parseInt(req.params.width) || 200;
    const height = parseInt(req.params.height) || 100;
    const { image } = generate(width, height);
    res.send(`<img class="generated-captcha" src="${image}">`);
});

// Captcha generation, returns PNG data URL and validation text
router.get("/:width?/:height?/", (req, res) => {
    const width = parseInt(req.params.width) || 200;
    const height = parseInt(req.params.height) || 100;
    const { image, text } = generate(width, height);
    bcrypt.hash(text, 10, (err, hash) => {
        if (err) {
            res.send({ error: 'Error generating the captcha. Please try again.' });
        }
        else {
            res.send({ image, hash });
        }
    });
});

router.get('/', (req, res, next) => {
    res.status(200).json({ message: 'GET Captcha' })
})

router.post('/', (req, res, next) => {
    bcrypt.compare(req.body.captcha, req.body.hash, (err, result) => {
        if (err) {
            return res.status(500).json({ error: 'Error in captcha verification' })
        }

        else if (result) {
            res.status(200).json({ message: 'Verification successful' })
        }
        else {
            res.status(200).json({ message: 'Invalid captcha' })
        }
    })
})
module.exports = router;

The code above might change at a later point in time as we plan to enhance the implementation but the logic should be the same.

This is all about our implementation of CAPTCHA generation and verification API.

Additional Features

We can add more features to the implementation discussed in this POST. Our implementation did not involve any database but you might need a database to add more features.

Some additonal features could be -

  1. Expiry - We can have an expiry associated with our captcha so that the CAPTCHA returned by the API is valid for a short period of time.

  2. Validity - Once a user regenerates the CAPTCHA the previously generated CAPTCHA should become invalid.

  3. CAPTCHA Image - We can have a strikethrough in the CAPTCHA text, include special characters and have a hazy image.

  4. Audio - The captcha could also be listened by the user through an audio device in case the user is not able to identiy the text in the image.

Conclusion

I hope you got an understanding to the implementation of CAPTCHA in nodejs. We covered the following points in this post -

  1. What is a CAPTCHA and why it is needed ?
  2. Features that a CAPTCHA should have.
  3. CAPTCHA Endpoints
  4. Node packages needed for the implementation
  5. Creating a server, an express app and route and error handlers.
  6. Additional features that a CAPTCHA could have.

References

  1. https://healeycodes.com/lets-generate-captchas
  2. https://www.stackoverflow.com
  3. https://gist.github.com/SneakyBrian/5209271

You can find the source code of this post in the link below - https://github.com/abyshekhar/captcha-api

Thank you for reading and see you in the next post.