mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-18 05:40:31 +00:00
feat: Rate limiter on requests and login attempts.
This commit is contained in:
@@ -64,6 +64,7 @@
|
||||
"objection-filter": "^4.0.1",
|
||||
"objection-soft-delete": "^1.0.7",
|
||||
"pluralize": "^8.0.0",
|
||||
"rate-limiter-flexible": "^2.1.14",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"ts-transformer-keys": "^0.4.2",
|
||||
"tsyringe": "^4.3.0",
|
||||
|
||||
@@ -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(
|
||||
|
||||
24
server/src/api/middleware/LoginThrottlerMiddleware.ts
Normal file
24
server/src/api/middleware/LoginThrottlerMiddleware.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
16
server/src/api/middleware/RateLimiterMiddleware.ts
Normal file
16
server/src/api/middleware/RateLimiterMiddleware.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
24
server/src/loaders/rateLimiterLoader.ts
Normal file
24
server/src/loaders/rateLimiterLoader.ts
Normal 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);
|
||||
};
|
||||
49
server/src/services/Authentication/RateLimiter.ts
Normal file
49
server/src/services/Authentication/RateLimiter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user