From a9f37103bd0153a56fd89cf88ceb76442cf37242 Mon Sep 17 00:00:00 2001 From: Jerko Steiner Date: Sun, 3 Nov 2019 18:05:34 -0400 Subject: [PATCH] Add validateCaptcha function to @rondo.dev/captcha --- packages/captcha/src/Captcha.ts | 28 ++++++++ packages/captcha/src/audio.test.ts | 6 +- packages/captcha/src/audio.ts | 3 +- packages/captcha/src/image.test.ts | 94 ++++++++++++++++++++++--- packages/captcha/src/image.ts | 3 +- packages/captcha/src/index.ts | 1 + packages/captcha/src/validateCaptcha.ts | 33 +++++++++ 7 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 packages/captcha/src/Captcha.ts create mode 100644 packages/captcha/src/validateCaptcha.ts diff --git a/packages/captcha/src/Captcha.ts b/packages/captcha/src/Captcha.ts new file mode 100644 index 0000000..01b68da --- /dev/null +++ b/packages/captcha/src/Captcha.ts @@ -0,0 +1,28 @@ +export type CaptchaType = 'image' | 'audio' + +export interface Captcha { + type: CaptchaType + value: string + timestamp: number +} + +declare global { + // eslint-disable-next-line + namespace Express { + interface Session { + captcha?: Captcha + } + } +} + +export function createCaptcha( + value: string, + type: CaptchaType, +): Captcha { + return { + value, + type, + timestamp: Date.now(), + } + +} diff --git a/packages/captcha/src/audio.test.ts b/packages/captcha/src/audio.test.ts index 800c6c3..2609d10 100644 --- a/packages/captcha/src/audio.test.ts +++ b/packages/captcha/src/audio.test.ts @@ -67,7 +67,11 @@ describe('audio', () => { .set('cookie', cookie) .expect(200) - expect(res2.body.captcha).toEqual(jasmine.any(String)) + expect(res2.body.captcha).toEqual({ + value: jasmine.any(String), + type: 'audio', + timestamp: jasmine.any(Number), + }) }) it('fails with error 500 when unable to generate', async () => { diff --git a/packages/captcha/src/audio.ts b/packages/captcha/src/audio.ts index dc641de..2e44dba 100644 --- a/packages/captcha/src/audio.ts +++ b/packages/captcha/src/audio.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express' import SVGCaptcha from 'svg-captcha' import { Command, ReadableProcess, ReadableWritable, run } from './run' import { TextStream } from './TextStream' +import { createCaptcha } from './Captcha' export interface AudioConfig { commands: Command[] @@ -14,7 +15,7 @@ export const audio = (config: AudioConfig) => async ( ) => { const { commands, size } = config const captcha = SVGCaptcha.randomText(size) - req.session!.captcha = captcha + req.session!.captcha = createCaptcha(captcha, 'audio') let speech: ReadableProcess try { speech = await speak('test', commands) diff --git a/packages/captcha/src/image.test.ts b/packages/captcha/src/image.test.ts index 3243f5c..0f5eecb 100644 --- a/packages/captcha/src/image.test.ts +++ b/packages/captcha/src/image.test.ts @@ -1,13 +1,17 @@ +import bodyParser from 'body-parser' +import cookieParser from 'cookie-parser' import express from 'express' import session from 'express-session' -import cookieParser from 'cookie-parser' import request from 'supertest' +import { Captcha } from './Captcha' import { image } from './image' +import { validateCaptcha } from './validateCaptcha' describe('image', () => { const app = express() app.use(cookieParser()) + app.use(bodyParser.json()) app.use(session({ saveUninitialized: false, resave: false, @@ -17,22 +21,92 @@ describe('image', () => { app.get('/captcha/session', (req, res) => { res.json(req.session) }) + app.post('/captcha/validate', validateCaptcha(), (req, res) => { + res.send('OK') + }) + app.post('/captcha/validate/expired', validateCaptcha({ + property: 'c', + ttl: -1, + }), (req, res) => { + res.send('OK') + }) + + async function getImage() { + const res = await request(app) + .get('/captcha/image') + .expect('Content-type', /svg/) + .expect(200) + + return res.header['set-cookie'][0] + } + + async function getCaptcha() { + const cookie = await getImage() + const res = await request(app) + .get('/captcha/session') + .set('cookie', cookie) + .expect(200) + + return {captcha: res.body.captcha as Captcha, cookie} + } describe('/captcha/image', () => { it('generates a new captcha', async () => { - const res1 = await request(app) - .get('/captcha/image') - .expect('Content-type', /svg/) - .expect(200) + const { captcha } = await getCaptcha() + expect(captcha).toEqual({ + value: jasmine.any(String), + type: 'image', + timestamp: jasmine.any(Number), + }) + }) + }) - const cookie = res1.header['set-cookie'][0] - - const res2 = await request(app) - .get('/captcha/session') + describe('/captcha/validate', () => { + it('fails when no captcha in session', async () => { + await request(app) + .post('/captcha/validate') + .send({ captcha: '123' }) + .expect(403) + }) + it('fails when captcha is expired', async () => { + const { captcha, cookie } = await getCaptcha() + await request(app) + .post('/captcha/validate/expired') + .send({ captcha: captcha.value }) .set('cookie', cookie) + .expect(403) + }) + it('fails when captcha does not match', async () => { + const { cookie } = await getCaptcha() + await request(app) + .post('/captcha/validate') + .set('cookie', cookie) + .send({ captcha: 'invalid-captcha' }) + .expect(403) + }) + it('validates captcha', async () => { + const { captcha, cookie } = await getCaptcha() + await request(app) + .post('/captcha/validate') + .set('cookie', cookie) + .send({ captcha: captcha.value }) .expect(200) + .expect('OK') + }) + it('cannot validate same captcha twice', async () => { + const { captcha, cookie } = await getCaptcha() + await request(app) + .post('/captcha/validate') + .set('cookie', cookie) + .send({ captcha: captcha.value }) + .expect(200) + .expect('OK') - expect(res2.body.captcha).toEqual(jasmine.any(String)) + await request(app) + .post('/captcha/validate') + .set('cookie', cookie) + .send({ captcha: captcha.value }) + .expect(403) }) }) diff --git a/packages/captcha/src/image.ts b/packages/captcha/src/image.ts index 018cd0a..fcabb57 100644 --- a/packages/captcha/src/image.ts +++ b/packages/captcha/src/image.ts @@ -1,5 +1,6 @@ import SVGCaptcha from 'svg-captcha' import { Request, Response } from 'express' +import { createCaptcha } from './Captcha' export interface ImageConfig { size: number @@ -9,7 +10,7 @@ export const image = (config: ImageConfig) => (req: Request, res: Response) => { const { text, data } = SVGCaptcha.create({ size: config.size, }) - req.session!.captcha = text + req.session!.captcha = createCaptcha(text, 'image') res.type('svg') res.status(200) res.send(data) diff --git a/packages/captcha/src/index.ts b/packages/captcha/src/index.ts index 715a574..4241b19 100644 --- a/packages/captcha/src/index.ts +++ b/packages/captcha/src/index.ts @@ -1,3 +1,4 @@ export * from './audio' export * from './image' export * from './commands' +export * from './validateCaptcha' diff --git a/packages/captcha/src/validateCaptcha.ts b/packages/captcha/src/validateCaptcha.ts new file mode 100644 index 0000000..c1d91f8 --- /dev/null +++ b/packages/captcha/src/validateCaptcha.ts @@ -0,0 +1,33 @@ +import { RequestHandler } from 'express' +import createError from 'http-errors' + +export interface ValidationConfig { + readonly property: string + readonly ttl: number +} + +const defaultConfig: ValidationConfig = { + property: 'captcha', + ttl: 10 * 60 * 1000, +} + +export const validateCaptcha = ( + config?: Partial, +): RequestHandler => { + const cfg: ValidationConfig = Object.assign({}, defaultConfig, config) + + return (req, res, next) => { + const captcha = req.session && req.session.captcha + if (!captcha) { + return next(createError(403, 'Invalid captcha')) + } + if (Date.now() >= captcha.timestamp + cfg.ttl) { + return next(createError(403, 'Invalid captcha')) + } + if (captcha.value !== req.body[cfg.property]) { + return next(createError(403, 'Invalid captcha')) + } + delete req.session!.captcha + next() + } +}