diff --git a/server/package.json b/server/package.json index 926c4a5f1..d8398fac5 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/api/controllers/Authentication.ts b/server/src/api/controllers/Authentication.ts index b5c50649c..77e6a6b10 100644 --- a/server/src/api/controllers/Authentication.ts +++ b/server/src/api/controllers/Authentication.ts @@ -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( diff --git a/server/src/api/middleware/LoginThrottlerMiddleware.ts b/server/src/api/middleware/LoginThrottlerMiddleware.ts new file mode 100644 index 000000000..df770e365 --- /dev/null +++ b/server/src/api/middleware/LoginThrottlerMiddleware.ts @@ -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(); + } +} \ No newline at end of file diff --git a/server/src/api/middleware/RateLimiterMiddleware.ts b/server/src/api/middleware/RateLimiterMiddleware.ts new file mode 100644 index 000000000..69a79f7e9 --- /dev/null +++ b/server/src/api/middleware/RateLimiterMiddleware.ts @@ -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'); + }); +} \ No newline at end of file diff --git a/server/src/config/index.js b/server/src/config/index.js index 7ce065268..a25409724 100644 --- a/server/src/config/index.js +++ b/server/src/config/index.js @@ -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, + } + } }; \ No newline at end of file diff --git a/server/src/loaders/dependencyInjector.ts b/server/src/loaders/dependencyInjector.ts index 562fcf5bd..97ed5722e 100644 --- a/server/src/loaders/dependencyInjector.ts +++ b/server/src/loaders/dependencyInjector.ts @@ -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); diff --git a/server/src/loaders/express.ts b/server/src/loaders/express.ts index 242239de5..528502ed0 100644 --- a/server/src/loaders/express.ts +++ b/server/src/loaders/express.ts @@ -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. diff --git a/server/src/loaders/rateLimiterLoader.ts b/server/src/loaders/rateLimiterLoader.ts new file mode 100644 index 000000000..3e3b935a1 --- /dev/null +++ b/server/src/loaders/rateLimiterLoader.ts @@ -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); +}; \ No newline at end of file diff --git a/server/src/services/Authentication/RateLimiter.ts b/server/src/services/Authentication/RateLimiter.ts new file mode 100644 index 000000000..efbe7b7d2 --- /dev/null +++ b/server/src/services/Authentication/RateLimiter.ts @@ -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 { + 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 { + return this.rateLimiter.penalty(key, points, secDuration); + } + + /** + * Retrieve the rate limiter response of the given key. + * @param {string} key + */ + public get(key: string): Promise { + return this.rateLimiter.get(key); + } + + /** + * Resets the rate limiter of the given key. + * @param key + */ + public reset(key: string): Promise { + return this.rateLimiter.delete(key); + } +} \ No newline at end of file diff --git a/server/src/services/Authentication/index.ts b/server/src/services/Authentication/index.ts index 5a97bc254..d3441dc42 100644 --- a/server/src/services/Authentication/index.ts +++ b/server/src/services/Authentication/index.ts @@ -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'); diff --git a/server/src/subscribers/authentication.ts b/server/src/subscribers/authentication.ts index 8402b7036..142c017c6 100644 --- a/server/src/subscribers/authentication.ts +++ b/server/src/subscribers/authentication.ts @@ -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)