Add validateCaptcha function to @rondo.dev/captcha

This commit is contained in:
Jerko Steiner 2019-11-03 18:05:34 -04:00
parent b2620e8050
commit a9f37103bd
7 changed files with 155 additions and 13 deletions

View File

@ -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(),
}
}

View File

@ -67,7 +67,11 @@ describe('audio', () => {
.set('cookie', cookie) .set('cookie', cookie)
.expect(200) .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 () => { it('fails with error 500 when unable to generate', async () => {

View File

@ -2,6 +2,7 @@ import { Request, Response } from 'express'
import SVGCaptcha from 'svg-captcha' import SVGCaptcha from 'svg-captcha'
import { Command, ReadableProcess, ReadableWritable, run } from './run' import { Command, ReadableProcess, ReadableWritable, run } from './run'
import { TextStream } from './TextStream' import { TextStream } from './TextStream'
import { createCaptcha } from './Captcha'
export interface AudioConfig { export interface AudioConfig {
commands: Command[] commands: Command[]
@ -14,7 +15,7 @@ export const audio = (config: AudioConfig) => async (
) => { ) => {
const { commands, size } = config const { commands, size } = config
const captcha = SVGCaptcha.randomText(size) const captcha = SVGCaptcha.randomText(size)
req.session!.captcha = captcha req.session!.captcha = createCaptcha(captcha, 'audio')
let speech: ReadableProcess let speech: ReadableProcess
try { try {
speech = await speak('test', commands) speech = await speak('test', commands)

View File

@ -1,13 +1,17 @@
import bodyParser from 'body-parser'
import cookieParser from 'cookie-parser'
import express from 'express' import express from 'express'
import session from 'express-session' import session from 'express-session'
import cookieParser from 'cookie-parser'
import request from 'supertest' import request from 'supertest'
import { Captcha } from './Captcha'
import { image } from './image' import { image } from './image'
import { validateCaptcha } from './validateCaptcha'
describe('image', () => { describe('image', () => {
const app = express() const app = express()
app.use(cookieParser()) app.use(cookieParser())
app.use(bodyParser.json())
app.use(session({ app.use(session({
saveUninitialized: false, saveUninitialized: false,
resave: false, resave: false,
@ -17,22 +21,92 @@ describe('image', () => {
app.get('/captcha/session', (req, res) => { app.get('/captcha/session', (req, res) => {
res.json(req.session) 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')
})
describe('/captcha/image', () => { async function getImage() {
it('generates a new captcha', async () => { const res = await request(app)
const res1 = await request(app)
.get('/captcha/image') .get('/captcha/image')
.expect('Content-type', /svg/) .expect('Content-type', /svg/)
.expect(200) .expect(200)
const cookie = res1.header['set-cookie'][0] return res.header['set-cookie'][0]
}
const res2 = await request(app) async function getCaptcha() {
const cookie = await getImage()
const res = await request(app)
.get('/captcha/session') .get('/captcha/session')
.set('cookie', cookie) .set('cookie', cookie)
.expect(200) .expect(200)
expect(res2.body.captcha).toEqual(jasmine.any(String)) return {captcha: res.body.captcha as Captcha, cookie}
}
describe('/captcha/image', () => {
it('generates a new captcha', async () => {
const { captcha } = await getCaptcha()
expect(captcha).toEqual({
value: jasmine.any(String),
type: 'image',
timestamp: jasmine.any(Number),
})
})
})
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')
await request(app)
.post('/captcha/validate')
.set('cookie', cookie)
.send({ captcha: captcha.value })
.expect(403)
}) })
}) })

View File

@ -1,5 +1,6 @@
import SVGCaptcha from 'svg-captcha' import SVGCaptcha from 'svg-captcha'
import { Request, Response } from 'express' import { Request, Response } from 'express'
import { createCaptcha } from './Captcha'
export interface ImageConfig { export interface ImageConfig {
size: number size: number
@ -9,7 +10,7 @@ export const image = (config: ImageConfig) => (req: Request, res: Response) => {
const { text, data } = SVGCaptcha.create({ const { text, data } = SVGCaptcha.create({
size: config.size, size: config.size,
}) })
req.session!.captcha = text req.session!.captcha = createCaptcha(text, 'image')
res.type('svg') res.type('svg')
res.status(200) res.status(200)
res.send(data) res.send(data)

View File

@ -1,3 +1,4 @@
export * from './audio' export * from './audio'
export * from './image' export * from './image'
export * from './commands' export * from './commands'
export * from './validateCaptcha'

View File

@ -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<ValidationConfig>,
): 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()
}
}