-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
SIAP-101: Add bruteforce protection on login
- Loading branch information
1 parent
21454ac
commit 0277e9e
Showing
19 changed files
with
240 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,12 @@ | ||
import { errors as defaults } from 'tree-house-errors'; | ||
|
||
// tslint:disable:max-line-length | ||
export const errors = Object.assign({}, defaults, { | ||
USER_INACTIVE: { code: 'USER_INACTIVE', message: 'Activate user account before login' }, | ||
USER_DUPLICATE: { code: 'USER_DUPLICATE', message: 'A user with this email already exists' }, | ||
USER_NOT_FOUND: { code: 'USER_NOT_FOUND', message: 'User not found' }, | ||
MISSING_HEADERS: { code: 'MISSING_HEADERS', message: 'Not all required headers are provided' }, | ||
NO_PERMISSION: { code: 'NO_PERMISSION', message:'You do not have the proper permissions to execute this operation' }, | ||
TOO_MANY_REQUESTS: { code: 'TOO_MANY_REQUESTS', message: 'You\'ve made too many failed attempts in a short period of time, please try again later' }, | ||
}); | ||
// tslint:enable:max-line-length |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
import * as httpStatus from 'http-status'; | ||
import { RateLimiterOptions } from 'tree-house'; | ||
import { InternalServerError, ApiError } from 'tree-house-errors'; | ||
import { getRedisClient } from '../lib/memory-store'; | ||
import { logger } from '../lib/logger'; | ||
import { errors } from './errors.config'; | ||
|
||
|
||
/** | ||
* Handle a rejected request due to too many requests for example | ||
*/ | ||
const failCallback = (req, _res, next, timeTooWait) => { | ||
logger.info(`User with username ${req.body.username} has tried to login too many times. Reset time: ${new Date(timeTooWait).toISOString()}`); | ||
next(new ApiError(httpStatus.TOO_MANY_REQUESTS, errors.TOO_MANY_REQUESTS)); | ||
}; | ||
|
||
|
||
/** | ||
* Handle a store error that occured with the persistent memory store (Redis) | ||
*/ | ||
const handleStoreError = (error) => { | ||
logger.error(error); | ||
throw new InternalServerError(errors.INTERNAL_ERROR, { message: error.message }); | ||
}; | ||
|
||
|
||
/** | ||
* No more than 1000 attempts per day per IP | ||
*/ | ||
export const globalBruteConfig: RateLimiterOptions = { | ||
handleStoreError, | ||
failCallback, | ||
freeRetries: 1000, | ||
attachResetToRequest: false, | ||
refreshTimeoutOnRequest: false, | ||
minWait: 25 * 60 * 60 * 1000, // 1 day 1 hour (should never reach this wait time) | ||
maxWait: 25 * 60 * 60 * 1000, // 1 day 1 hour (should never reach this wait time) | ||
lifetime: 24 * 60 * 60, // 1 day (seconds not milliseconds) | ||
redis: process.env.NODE_ENV === 'development' ? undefined : { client: getRedisClient() }, // Use our existing Redis client (in staging/production) | ||
}; | ||
|
||
|
||
/** | ||
* Start slowing requests after 5 failed attempts | ||
*/ | ||
export const userBruteConfig: RateLimiterOptions = { | ||
handleStoreError, | ||
failCallback, | ||
freeRetries: 5, | ||
minWait: 5 * 60 * 1000, // 5 minutes | ||
maxWait: 60 * 60 * 1000, // 1 hour, | ||
redis: process.env.NODE_ENV === 'development' ? undefined : { client: getRedisClient() }, // Use our existing Redis client (in staging/production) | ||
}; | ||
|
||
|
||
/** | ||
* Check for same key per request (username) | ||
*/ | ||
export const userBruteMiddlewareConfig = { | ||
failCallback, | ||
ignoreIP: false, | ||
key: (req, _res, next) => next(req.body.username), // Call per username per ip | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import * as redis from 'redis'; | ||
|
||
let redisClient; | ||
|
||
const options: redis.ClientOpts = { | ||
url: process.env.REDISCLOUD_URL, | ||
}; | ||
|
||
|
||
/** | ||
* Make sure we return the same instance | ||
* Don't create the instance on startup (not needed in all environments) | ||
*/ | ||
export function getRedisClient() { | ||
if (!redisClient) redisClient = redis.createClient(options); | ||
return redisClient; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import { getRateLimiter } from 'tree-house'; | ||
import { RequestHandler } from 'express'; | ||
import { globalBruteConfig, userBruteConfig, userBruteMiddlewareConfig } from '../config/security.config'; | ||
|
||
|
||
/** | ||
* No more than 1000 login attempts per day per IP | ||
*/ | ||
export const setGlobalBruteforce: RequestHandler = getRateLimiter(globalBruteConfig).prevent; | ||
|
||
|
||
/** | ||
* Start slowing requests after 5 failed attempts to do something for the same user | ||
*/ | ||
export const setUserBruteForce: RequestHandler = getRateLimiter(userBruteConfig) | ||
.getMiddleware(userBruteMiddlewareConfig); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import { getRedisClient } from '../../../src/lib/memory-store'; | ||
|
||
|
||
/** | ||
* Clear Redis memory store | ||
*/ | ||
export async function clearMemoryStore() { | ||
return new Promise((resolve, reject) => { | ||
getRedisClient().flushdb((error, succeeded) => { | ||
if (error) reject(error); | ||
resolve(succeeded); | ||
}); | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
import * as httpStatus from 'http-status'; | ||
import * as request from 'supertest'; | ||
import * as express from 'express'; | ||
import { setBodyParser } from 'tree-house'; | ||
import { setUserBruteForce } from '../../src/middleware/bruteforce.middleware'; | ||
import { userBruteConfig } from '../../src/config/security.config'; | ||
import { errors } from '../../src/config/errors.config'; | ||
import { responder } from '../../src/lib/responder'; | ||
import { clearMemoryStore } from '../_helpers/mockdata/memory-store.data'; | ||
|
||
describe('bruteforce middleware', () => { | ||
let app; | ||
|
||
beforeEach(async () => { | ||
app = express(); | ||
setBodyParser(app, '*'); | ||
|
||
await clearMemoryStore(); | ||
}); | ||
|
||
it('Should start blocking requests with the same ip and username after number of retries', async () => { | ||
app.use('/test', setUserBruteForce, (_req, res) => res.status(httpStatus.OK).send('Welcome')); | ||
app.use((error, _req, res, _next) => responder.error(res, error)); | ||
|
||
const numberOfCalls = userBruteConfig.freeRetries + 1; | ||
|
||
// Successful calls | ||
for (const call of Array(numberOfCalls)) { | ||
const { status } = await request(app) | ||
.post('/test') | ||
.send({ username: 'test@icapps.com' }); | ||
expect(status).toEqual(httpStatus.OK); | ||
} | ||
|
||
// Blocked call | ||
const { status, body } = await request(app) | ||
.post('/test') | ||
.send({ username: 'test@icapps.com' }); | ||
|
||
expect(status).toEqual(httpStatus.TOO_MANY_REQUESTS); | ||
expect(body.errors[0].code).toEqual(errors.TOO_MANY_REQUESTS.code); | ||
expect(body.errors[0].title).toEqual(errors.TOO_MANY_REQUESTS.message); | ||
|
||
// Allow call with other username | ||
const { status: status2, body: body2 } = await request(app) | ||
.post('/test') | ||
.send({ username: 'test2@icapps.com' }); | ||
expect(status2).toEqual(httpStatus.OK); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.