Skip to content

Commit

Permalink
SIAP-101: Add bruteforce protection on login
Browse files Browse the repository at this point in the history
  • Loading branch information
knor-el-snor committed Apr 23, 2018
1 parent 21454ac commit 0277e9e
Show file tree
Hide file tree
Showing 19 changed files with 240 additions and 18 deletions.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ DATABASE_URL=
INITIAL_SEED_USERNAME=
INITIAL_SEED_PASSWORD=

## Memorystore
REDISCLOUD_URL=

## Mailing
MANDRILL_API_KEY=
SYSTEM_EMAIL=
Expand Down
1 change: 1 addition & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ language: node_js

services:
- postgresql
- redis-server

before_script:
- psql -c "CREATE DATABASE silverback_test;" -U postgres
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
version: '3.3'
services:
redis:
image: redis:latest
container_name: redis_silverback
ports:
- "6379:6379"
postgres:
image: sameersbn/postgresql:latest
container_name: pgsql_silverback
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
"lodash": "~4.17.5",
"mandrill-api": "~1.0.45",
"pg": "~7.4.1",
"tree-house": "~3.1.0",
"redis": "~2.8.0",
"tree-house": "~3.2.0",
"tree-house-authentication": "~2.0.5",
"tree-house-errors": "^1.0.3",
"uuid": "~3.2.1",
Expand All @@ -42,6 +43,7 @@
"@types/cors": "~2.8.3",
"@types/dotenv-safe": "~4.0.1",
"@types/express": "~4.11.0",
"@types/express-brute": "~0.0.36",
"@types/faker": "~4.1.2",
"@types/helmet": "~0.0.37",
"@types/http-status": "~0.2.30",
Expand All @@ -50,6 +52,7 @@
"@types/knex": "~0.14.11",
"@types/lodash": "~4.14.105",
"@types/mandrill-api": "~1.0.30",
"@types/redis": "~2.8.6",
"@types/supertest": "~2.0.4",
"@types/uuid": "~3.4.3",
"@types/winston": "~2.3.9",
Expand Down
9 changes: 3 additions & 6 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,16 @@ import { responder } from './lib/responder';
// Create express instance
const app: express.Application = express();

// Basic security setup
treehouse.setBodyParser(app, '*');
treehouse.setBasicSecurity(app, '*', {
cors: {
methods: ['GET', 'PUT', 'POST', 'DELETE', 'PATCH'],
allowedHeaders: ['Cache-Control', 'Pragma', 'Origin', 'Authorization', 'Content-Type', 'X-Requested-With'],
},
});

treehouse.setBodyParser(app, '*');
// treehouse.setRateLimiter(app, '*'); // TODO: Fix proper settings

// Display all versions
app.get('/', (_req, res) => res.json(appConfig.VERSIONS));
app.set('trust proxy', 1); // Heroku proxy stuff
app.get('/', (_req, res) => res.json(appConfig.VERSIONS));// Display all versions

// Load routes (versioned routes go in the routes/ directory)
for (const x in appConfig.VERSIONS) {
Expand Down
3 changes: 3 additions & 0 deletions src/config/errors.config.ts
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
63 changes: 63 additions & 0 deletions src/config/security.config.ts
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
};
16 changes: 10 additions & 6 deletions src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,22 @@ import { responder } from '../lib/responder';
import { authSerializer } from '../serializers/auth.serializer';
import { extractJwt } from '../lib/utils';
import { JwtPayload } from '../middleware/permission.middleware';
import { AuthRequest } from '../models/request.model';
import { AuthRequest, BruteRequest } from '../models/request.model';
import * as authService from '../services/auth.service';

/**
* Return all users
*/
export async function login(req: Request, res: Response): Promise<void> {
export async function login(req: BruteRequest, res: Response): Promise<void> {
const data = await authService.login(req.body);
responder.success(res, {
status: httpStatus.OK,
payload: data,
serializer: authSerializer,

// Reset brute force protection and return response
req.brute.reset(() => {
responder.success(res, {
status: httpStatus.OK,
payload: data,
serializer: authSerializer,
});
});
}

Expand Down
17 changes: 17 additions & 0 deletions src/lib/memory-store.ts
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;
}
16 changes: 16 additions & 0 deletions src/middleware/bruteforce.middleware.ts
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);
7 changes: 7 additions & 0 deletions src/models/request.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,10 @@ export interface AuthRequest extends Request {
user: User;
};
}

export interface BruteRequest extends Request {
brute: {
reset: (fn: Function) => {};
};
}

3 changes: 3 additions & 0 deletions src/routes/v1/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@
import { Router } from 'express';
import { handleAsyncFn, validateSchema } from 'tree-house';
import { authSchema } from '../../schemes/auth.schema';
import { setGlobalBruteforce, setUserBruteForce } from '../../middleware/bruteforce.middleware';
import { hasPermission } from '../../middleware/permission.middleware';
import * as controller from '../../controllers/auth.controller';

export const routes: Router = Router({ mergeParams: true })
.post('/auth/login',
setGlobalBruteforce,
setUserBruteForce,
validateSchema(authSchema.login),
handleAsyncFn(controller.login))

Expand Down
3 changes: 2 additions & 1 deletion tests/_helpers/mockdata/data.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { tableNames } from '../../../src/constants';
import { db } from '../../../src/lib/db';

import { clearMemoryStore } from './memory-store.data';

/**
* Clear all databases
*/
export function clearAll() {
return Promise.all([
clearMemoryStore(),
db(tableNames.USERS).del(),
db(tableNames.CODES).del(),
db(tableNames.CODETYPES).del(),
Expand Down
14 changes: 14 additions & 0 deletions tests/_helpers/mockdata/memory-store.data.ts
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);
});
});
}
1 change: 1 addition & 0 deletions tests/integration/auth.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('/auth', () => {
});

describe('POST /login', () => {
// TODO: Test if brute force protection gets reset after successful attempt!
it('Should succesfully login a user with correct credentials', async () => {
const { body, status } = await request(app)
.post(`${prefix}/auth/login`)
Expand Down
2 changes: 1 addition & 1 deletion tests/lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { UnauthorizedError } from 'tree-house-errors';
import * as httpMocks from 'node-mocks-http';
import { UnauthorizedError } from 'tree-house-errors';
import { errors } from '../../src/config/errors.config';
import { roles } from '../../src/config/roles.config';
import { User } from '../../src/models/user.model';
Expand Down
50 changes: 50 additions & 0 deletions tests/middleware/bruteforce.middleware.test.ts
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);
});
});
1 change: 1 addition & 0 deletions tests/test.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const environment = {
MIN_VERSION_IOS: '1.0.0',
LATEST_VERSION_IOS: '2.0.2',
MANDRILL_API_KEY: 'myKey',
REDISCLOUD_URL: 'redis://0.0.0.0:6379',
};

Object.keys(environment).forEach((key) => {
Expand Down
Loading

0 comments on commit 0277e9e

Please sign in to comment.