feat: Rate limiter on requests and login attempts.

This commit is contained in:
a.bouhuolia
2020-12-15 20:25:23 +02:00
parent 27483495cb
commit 6dd1229412
11 changed files with 170 additions and 4 deletions

View File

@@ -7,6 +7,7 @@ import AuthenticationService from 'services/Authentication';
import { ILoginDTO, ISystemUser, IRegisterOTD } from 'interfaces';
import { ServiceError, ServiceErrors } from "exceptions";
import { DATATYPES_LENGTH } from 'data/DataTypes';
import LoginThrottlerMiddleware from 'api/middleware/LoginThrottlerMiddleware';
@Service()
export default class AuthenticationController extends BaseController{
@@ -23,6 +24,7 @@ export default class AuthenticationController extends BaseController{
'/login',
this.loginSchema,
this.validationResult,
LoginThrottlerMiddleware,
asyncMiddleware(this.login.bind(this))
);
router.post(

View File

@@ -0,0 +1,24 @@
import { Container } from 'typedi';
import { Request, Response, NextFunction } from 'express';
import config from 'config';
const MAX_CONSECUTIVE_FAILS = config.throttler.login.points;
export default async (req: Request, res: Response, next: NextFunction) => {
const { crediential } = req.body;
const loginThrottler = Container.get('rateLimiter.login');
// Retrieve the rate limiter response of the given crediential.
const emailRateRes = await loginThrottler.get(crediential);
if (emailRateRes !== null && emailRateRes.consumedPoints >= MAX_CONSECUTIVE_FAILS) {
const retrySecs = Math.round(emailRateRes.msBeforeNext / 1000) || 1;
res.set('Retry-After', retrySecs);
res.status(429).send({
errors: [{ type: 'LOGIN_TO_MANY_ATTEMPTS', code: 400 }],
});
} else {
next();
}
}

View File

@@ -0,0 +1,16 @@
import { Container } from 'typedi';
import { Request, Response, NextFunction } from 'express';
/**
* Rate limiter middleware.
*/
export default (req: Request, res: Response, next: NextFunction) => {
const requestRateLimiter = Container.get('rateLimiter.request');
requestRateLimiter.attempt(req.ip).then(() => {
next();
})
.catch(() => {
res.status(429).send('Too Many Requests');
});
}

View File

@@ -122,4 +122,27 @@ export default {
user: process.env.LICENSES_AUTH_USER,
password: process.env.LICENSES_AUTH_PASSWORD,
},
/**
* Redis storage configuration.
*/
redis: {
port: 6379,
},
/**
* Throttler configuration.
*/
throttler: {
login: {
points: 5,
duration: 60 * 60 * 24 * 1, // Store number for 90 days since first fail
blockDuration: 60 * 15,
},
requests: {
points: 30,
duration: 60,
blockDuration: 60 * 10,
}
}
};

View File

@@ -7,6 +7,8 @@ import dbManagerFactory from 'loaders/dbManager';
import i18n from 'loaders/i18n';
import repositoriesLoader from 'loaders/systemRepositories';
import Cache from 'services/Cache';
import redisLoader from './redisLoader';
import rateLimiterLoaders from './rateLimiterLoader';
export default ({ mongoConnection, knex }) => {
try {
@@ -42,6 +44,9 @@ export default ({ mongoConnection, knex }) => {
Container.set('repositories', repositoriesLoader());
LoggerInstance.info('[DI] repositories has been injected into container');
rateLimiterLoaders();
LoggerInstance.info('[DI] rate limiter has been injected into container.');
return { agenda: agendaInstance };
} catch (e) {
LoggerInstance.error('Error on dependency injector loader: %o', e);

View File

@@ -8,6 +8,7 @@ import routes from 'api';
import LoggerMiddleware from 'api/middleware/LoggerMiddleware';
import AgendashController from 'api/controllers/Agendash';
import ConvertEmptyStringsToNull from 'api/middleware/ConvertEmptyStringsToNull';
import RateLimiterMiddleware from 'api/middleware/RateLimiterMiddleware'
import config from 'config';
export default ({ app }) => {
@@ -41,6 +42,7 @@ export default ({ app }) => {
app.use(ConvertEmptyStringsToNull);
// Prefix all application routes.
app.use(config.api.prefix, RateLimiterMiddleware)
app.use(config.api.prefix, routes());
// Agendash application load.

View File

@@ -0,0 +1,24 @@
import RateLimiter from 'services/Authentication/RateLimiter';
import { Container } from 'typedi';
import { RateLimiterMemory } from 'rate-limiter-flexible';
import config from 'config';
export default () => {
const rateLimiterRequestsMemory = new RateLimiterMemory({
points: config.throttler.requests.points,
duration: config.throttler.requests.duration,
blockDuration: config.throttler.requests.blockDuration,
});
const rateLimiterMemoryLogin = new RateLimiterMemory({
points: config.throttler.login.points,
duration: config.throttler.login.duration,
blockDuration: config.throttler.login.blockDuration,
});
const rateLimiterRequest = new RateLimiter(rateLimiterRequestsMemory);
const rateLimiterLogin = new RateLimiter(rateLimiterMemoryLogin)
// Inject the rate limiter of the global requests and login into the container.
Container.set('rateLimiter.request', rateLimiterRequest);
Container.set('rateLimiter.login', rateLimiterLogin);
};

View File

@@ -0,0 +1,49 @@
import { RateLimiterClusterMasterPM2, RateLimiterMemory, RateLimiterRedis, RateLimiterRes } from 'rate-limiter-flexible';
export default class RateLimiter {
rateLimiter: RateLimiterRedis;
/**
* Rate limiter redis constructor.
* @param {RateLimiterRedis} rateLimiter
*/
constructor(rateLimiter: RateLimiterMemory) {
this.rateLimiter = rateLimiter;
}
/**
*
* @return {boolean}
*/
public attempt(key: string, pointsToConsume = 1): Promise<RateLimiterRes> {
return this.rateLimiter.consume(key, pointsToConsume);
}
/**
* Increment the counter for a given key for a given decay time.
* @param {string} key -
*/
public hit(
key: string | number,
points: number,
secDuration: number,
): Promise<RateLimiterRes> {
return this.rateLimiter.penalty(key, points, secDuration);
}
/**
* Retrieve the rate limiter response of the given key.
* @param {string} key
*/
public get(key: string): Promise<RateLimiterRes> {
return this.rateLimiter.get(key);
}
/**
* Resets the rate limiter of the given key.
* @param key
*/
public reset(key: string): Promise<boolean> {
return this.rateLimiter.delete(key);
}
}

View File

@@ -50,18 +50,32 @@ export default class AuthenticationService implements IAuthenticationService {
* @param {string} password - Password.
* @return {Promise<{user: IUser, token: string}>}
*/
public async signIn(emailOrPhone: string, password: string): Promise<{user: ISystemUser, token: string, tenant: ITenant }> {
public async signIn(
emailOrPhone: string,
password: string
): Promise<{
user: ISystemUser,
token: string,
tenant: ITenant
}> {
this.logger.info('[login] Someone trying to login.', { emailOrPhone, password });
const { systemUserRepository } = this.sysRepositories;
const loginThrottler = Container.get('rateLimiter.login');
const user = await systemUserRepository.findByCrediential(emailOrPhone);
if (!user) {
await loginThrottler.hit(emailOrPhone);
this.logger.info('[login] invalid data');
throw new ServiceError('invalid_details');
}
this.logger.info('[login] check password validation.', { emailOrPhone, password });
if (!user.verifyPassword(password)) {
await loginThrottler.hit(emailOrPhone);
throw new ServiceError('invalid_password');
}
@@ -80,7 +94,7 @@ export default class AuthenticationService implements IAuthenticationService {
// Triggers `onLogin` event.
this.eventDispatcher.dispatch(events.auth.login, {
emailOrPhone, password,
emailOrPhone, password, user,
});
const tenant = await user.$relatedQuery('tenant');

View File

@@ -7,8 +7,14 @@ import events from 'subscribers/events';
export class AuthenticationSubscriber {
@On(events.auth.login)
public onLogin(payload) {
const { emailOrPhone, password } = payload;
public async onLogin(payload) {
const { emailOrPhone, password, user } = payload;
const loginThrottler = Container.get('rateLimiter.login');
// Reset the login throttle by the given email and phone number.
await loginThrottler.reset(user.email);
await loginThrottler.reset(user.phoneNumber);
}
@On(events.auth.register)