mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-21 15:20:34 +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-filter": "^4.0.1",
|
||||||
"objection-soft-delete": "^1.0.7",
|
"objection-soft-delete": "^1.0.7",
|
||||||
"pluralize": "^8.0.0",
|
"pluralize": "^8.0.0",
|
||||||
|
"rate-limiter-flexible": "^2.1.14",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"ts-transformer-keys": "^0.4.2",
|
"ts-transformer-keys": "^0.4.2",
|
||||||
"tsyringe": "^4.3.0",
|
"tsyringe": "^4.3.0",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import AuthenticationService from 'services/Authentication';
|
|||||||
import { ILoginDTO, ISystemUser, IRegisterOTD } from 'interfaces';
|
import { ILoginDTO, ISystemUser, IRegisterOTD } from 'interfaces';
|
||||||
import { ServiceError, ServiceErrors } from "exceptions";
|
import { ServiceError, ServiceErrors } from "exceptions";
|
||||||
import { DATATYPES_LENGTH } from 'data/DataTypes';
|
import { DATATYPES_LENGTH } from 'data/DataTypes';
|
||||||
|
import LoginThrottlerMiddleware from 'api/middleware/LoginThrottlerMiddleware';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class AuthenticationController extends BaseController{
|
export default class AuthenticationController extends BaseController{
|
||||||
@@ -23,6 +24,7 @@ export default class AuthenticationController extends BaseController{
|
|||||||
'/login',
|
'/login',
|
||||||
this.loginSchema,
|
this.loginSchema,
|
||||||
this.validationResult,
|
this.validationResult,
|
||||||
|
LoginThrottlerMiddleware,
|
||||||
asyncMiddleware(this.login.bind(this))
|
asyncMiddleware(this.login.bind(this))
|
||||||
);
|
);
|
||||||
router.post(
|
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,
|
user: process.env.LICENSES_AUTH_USER,
|
||||||
password: process.env.LICENSES_AUTH_PASSWORD,
|
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 i18n from 'loaders/i18n';
|
||||||
import repositoriesLoader from 'loaders/systemRepositories';
|
import repositoriesLoader from 'loaders/systemRepositories';
|
||||||
import Cache from 'services/Cache';
|
import Cache from 'services/Cache';
|
||||||
|
import redisLoader from './redisLoader';
|
||||||
|
import rateLimiterLoaders from './rateLimiterLoader';
|
||||||
|
|
||||||
export default ({ mongoConnection, knex }) => {
|
export default ({ mongoConnection, knex }) => {
|
||||||
try {
|
try {
|
||||||
@@ -42,6 +44,9 @@ export default ({ mongoConnection, knex }) => {
|
|||||||
Container.set('repositories', repositoriesLoader());
|
Container.set('repositories', repositoriesLoader());
|
||||||
LoggerInstance.info('[DI] repositories has been injected into container');
|
LoggerInstance.info('[DI] repositories has been injected into container');
|
||||||
|
|
||||||
|
rateLimiterLoaders();
|
||||||
|
LoggerInstance.info('[DI] rate limiter has been injected into container.');
|
||||||
|
|
||||||
return { agenda: agendaInstance };
|
return { agenda: agendaInstance };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
LoggerInstance.error('Error on dependency injector loader: %o', 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 LoggerMiddleware from 'api/middleware/LoggerMiddleware';
|
||||||
import AgendashController from 'api/controllers/Agendash';
|
import AgendashController from 'api/controllers/Agendash';
|
||||||
import ConvertEmptyStringsToNull from 'api/middleware/ConvertEmptyStringsToNull';
|
import ConvertEmptyStringsToNull from 'api/middleware/ConvertEmptyStringsToNull';
|
||||||
|
import RateLimiterMiddleware from 'api/middleware/RateLimiterMiddleware'
|
||||||
import config from 'config';
|
import config from 'config';
|
||||||
|
|
||||||
export default ({ app }) => {
|
export default ({ app }) => {
|
||||||
@@ -41,6 +42,7 @@ export default ({ app }) => {
|
|||||||
app.use(ConvertEmptyStringsToNull);
|
app.use(ConvertEmptyStringsToNull);
|
||||||
|
|
||||||
// Prefix all application routes.
|
// Prefix all application routes.
|
||||||
|
app.use(config.api.prefix, RateLimiterMiddleware)
|
||||||
app.use(config.api.prefix, routes());
|
app.use(config.api.prefix, routes());
|
||||||
|
|
||||||
// Agendash application load.
|
// 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.
|
* @param {string} password - Password.
|
||||||
* @return {Promise<{user: IUser, token: string}>}
|
* @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 });
|
this.logger.info('[login] Someone trying to login.', { emailOrPhone, password });
|
||||||
|
|
||||||
const { systemUserRepository } = this.sysRepositories;
|
const { systemUserRepository } = this.sysRepositories;
|
||||||
|
const loginThrottler = Container.get('rateLimiter.login');
|
||||||
|
|
||||||
const user = await systemUserRepository.findByCrediential(emailOrPhone);
|
const user = await systemUserRepository.findByCrediential(emailOrPhone);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
await loginThrottler.hit(emailOrPhone);
|
||||||
|
|
||||||
this.logger.info('[login] invalid data');
|
this.logger.info('[login] invalid data');
|
||||||
throw new ServiceError('invalid_details');
|
throw new ServiceError('invalid_details');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.info('[login] check password validation.', { emailOrPhone, password });
|
this.logger.info('[login] check password validation.', { emailOrPhone, password });
|
||||||
if (!user.verifyPassword(password)) {
|
if (!user.verifyPassword(password)) {
|
||||||
|
await loginThrottler.hit(emailOrPhone);
|
||||||
|
|
||||||
throw new ServiceError('invalid_password');
|
throw new ServiceError('invalid_password');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +94,7 @@ export default class AuthenticationService implements IAuthenticationService {
|
|||||||
|
|
||||||
// Triggers `onLogin` event.
|
// Triggers `onLogin` event.
|
||||||
this.eventDispatcher.dispatch(events.auth.login, {
|
this.eventDispatcher.dispatch(events.auth.login, {
|
||||||
emailOrPhone, password,
|
emailOrPhone, password, user,
|
||||||
});
|
});
|
||||||
const tenant = await user.$relatedQuery('tenant');
|
const tenant = await user.$relatedQuery('tenant');
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,14 @@ import events from 'subscribers/events';
|
|||||||
export class AuthenticationSubscriber {
|
export class AuthenticationSubscriber {
|
||||||
|
|
||||||
@On(events.auth.login)
|
@On(events.auth.login)
|
||||||
public onLogin(payload) {
|
public async onLogin(payload) {
|
||||||
const { emailOrPhone, password } = 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)
|
@On(events.auth.register)
|
||||||
|
|||||||
Reference in New Issue
Block a user