diff --git a/README.md b/README.md index 110f84414..c046c35a0 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ The `GATEHUB` related environment variables are necessary in order to complete S To create a new Interledger Test Wallet account, a verification email will be sent to the provided email address. If you want to send emails within the development environment, you will need to have a personal Sendgrid account and update the following environment variables: `SEND_EMAIL` to `true`, `SENDGRID_API_KEY` and `FROM_EMAIL`. If you prefer not to send emails in the development environment, simply set `SEND_EMAIL` to `false` and use the verification link found in the Docker `wallet-backend` container logs to finalize the registration process for a new user. +To enable rate limiter on the wallet for security purposes you can set these environment variables: `RATE_LIMIT` to `true` and `RATE_LIMIT_LEVEL`. `RATE_LIMIT_LEVEL` has three possible values: `LAX|NORMAL|STRICT`, default is `LAX`. + Cross-currency transactions are supported. To enable this functionality, you will need to register at [freecurrencyapi.com/](https://freecurrencyapi.com/) and update the `RATE_API_KEY` environment variable with your own API key. Currencies can be added in the `admin` environment. For example `assetCode` is `EUR`, `assetScale` is `2`, and you will need to add an amount to `liquidity`. diff --git a/docker/dev/.env.example b/docker/dev/.env.example index fc420d8ef..60322eb77 100644 --- a/docker/dev/.env.example +++ b/docker/dev/.env.example @@ -13,6 +13,8 @@ GATEHUB_GATEWAY_UUID= GATEHUB_SETTLEMENT_WALLET_ADDRESS= GATEHUB_ORG_ID= GATEHUB_CARD_APP_ID= +RATE_LIMIT= +RATE_LIMIT_LEVEL= GATEHUB_ACCOUNT_PRODUCT_CODE= GATEHUB_CARD_PRODUCT_CODE= GATEHUB_NAME_ON_CARD= diff --git a/docker/dev/docker-compose.yml b/docker/dev/docker-compose.yml index e22bc61b3..e33a64718 100644 --- a/docker/dev/docker-compose.yml +++ b/docker/dev/docker-compose.yml @@ -63,6 +63,8 @@ services: GATEHUB_SETTLEMENT_WALLET_ADDRESS: ${GATEHUB_SETTLEMENT_WALLET_ADDRESS} GATEHUB_ORG_ID: ${GATEHUB_ORG_ID} GATEHUB_CARD_APP_ID: ${GATEHUB_CARD_APP_ID} + RATE_LIMIT: ${RATE_LIMIT} + RATE_LIMIT_LEVEL: ${RATE_LIMIT_LEVEL} GATEHUB_ACCOUNT_PRODUCT_CODE: ${GATEHUB_ACCOUNT_PRODUCT_CODE} GATEHUB_CARD_PRODUCT_CODE: ${GATEHUB_CARD_PRODUCT_CODE} GATEHUB_NAME_ON_CARD: ${GATEHUB_NAME_ON_CARD} diff --git a/packages/shared/backend/src/errors/index.ts b/packages/shared/backend/src/errors/index.ts index e948f68bc..d160fd14c 100644 --- a/packages/shared/backend/src/errors/index.ts +++ b/packages/shared/backend/src/errors/index.ts @@ -5,4 +5,5 @@ export * from './not-found' export * from './unauthorized' export * from './forbidden' export * from './internal-server-error' +export * from './too-many-requests' export * from './base' diff --git a/packages/shared/backend/src/errors/too-many-requests.ts b/packages/shared/backend/src/errors/too-many-requests.ts new file mode 100644 index 000000000..b23d44727 --- /dev/null +++ b/packages/shared/backend/src/errors/too-many-requests.ts @@ -0,0 +1,8 @@ +import { BaseError } from './base' + +export class TooManyRequests extends BaseError { + constructor(message: string) { + super(429, message) + Object.setPrototypeOf(this, TooManyRequests.prototype) + } +} diff --git a/packages/wallet/backend/jest.setup.js b/packages/wallet/backend/jest.setup.js index ee7c0bf24..400d7183b 100644 --- a/packages/wallet/backend/jest.setup.js +++ b/packages/wallet/backend/jest.setup.js @@ -19,7 +19,7 @@ module.exports = async () => { .withExposedPorts(REDIS_PORT) .start() - process.env.REDIS_URL = `redis://redis:${REDIS_PORT}/0` + process.env.REDIS_URL = `redis://localhost:${redisContainer.getMappedPort(REDIS_PORT)}/0` process.env.DATABASE_URL = `postgresql://postgres:${POSTGRES_PASSWORD}@localhost:${container.getMappedPort( POSTGRES_PORT diff --git a/packages/wallet/backend/package.json b/packages/wallet/backend/package.json index 403f1760f..a405c15e9 100644 --- a/packages/wallet/backend/package.json +++ b/packages/wallet/backend/package.json @@ -34,6 +34,7 @@ "objection": "^3.1.5", "pg": "^8.13.0", "randexp": "^0.5.3", + "rate-limiter-flexible": "^5.0.3", "socket.io": "^4.8.0", "uuid": "^10.0.0", "winston": "^3.15.0", diff --git a/packages/wallet/backend/src/app.ts b/packages/wallet/backend/src/app.ts index 7190acaf8..a1f690957 100644 --- a/packages/wallet/backend/src/app.ts +++ b/packages/wallet/backend/src/app.ts @@ -29,6 +29,7 @@ import type { AuthService } from './auth/service' import type { Env } from './config/env' import { isAuth } from './middleware/isAuth' import { withSession } from './middleware/withSession' +import { rateLimiterEmail, rateLimiterLogin } from './middleware/rateLimit' import { QuoteController } from './quote/controller' import { QuoteService } from './quote/service' import { RafikiController } from './rafiki/controller' @@ -176,15 +177,23 @@ export class App { // Auth Routes router.post('/signup', authController.signUp) - router.post('/login', authController.logIn) + router.post('/login', rateLimiterLogin, authController.logIn) router.post('/logout', isAuth, authController.logOut) // Reset password routes - router.post('/forgot-password', userController.requestResetPassword) + router.post( + '/forgot-password', + rateLimiterEmail, + userController.requestResetPassword + ) router.get('/reset-password/:token/validate', userController.checkToken) router.post('/reset-password/:token', userController.resetPassword) router.post('/verify-email/:token', authController.verifyEmail) - router.post('/resend-verify-email', authController.resendVerifyEmail) + router.post( + '/resend-verify-email', + rateLimiterEmail, + authController.resendVerifyEmail + ) router.patch('/change-password', isAuth, userController.changePassword) // Me Endpoint diff --git a/packages/wallet/backend/src/config/rateLimit.ts b/packages/wallet/backend/src/config/rateLimit.ts new file mode 100644 index 000000000..e04919329 --- /dev/null +++ b/packages/wallet/backend/src/config/rateLimit.ts @@ -0,0 +1,81 @@ +import { z } from 'zod' + +export const envRateLimit = () => { + const rateLimitSchema = z + .object({ + RATE_LIMIT: z + .enum(['true', 'false', '']) + .default('false') + .transform((value) => value === 'true'), + RATE_LIMIT_LEVEL: z.enum(['STRICT', 'NORMAL', 'LAX', '']).default('LAX'), + SEND_EMAIL_RATE_LIMIT: z.coerce.number().default(1), + SEND_EMAIL_RATE_LIMIT_PAUSE_IN_SECONDS: z.coerce.number().default(1800), + SEND_EMAIL_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS: z.coerce + .number() + .default(1800), + LOGIN_RATE_LIMIT: z.coerce.number().default(6), + LOGIN_RATE_LIMIT_PAUSE_IN_SECONDS: z.coerce.number().default(300), + LOGIN_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS: z.coerce + .number() + .default(1800), + LOGIN_IP_RATE_LIMIT: z.coerce.number().default(30), + LOGIN_IP_RATE_LIMIT_PAUSE_IN_SECONDS: z.coerce.number().default(1800), + LOGIN_IP_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS: z.coerce + .number() + .default(1800), + LOGIN_IP_BLOCK_RATE_LIMIT: z.coerce.number().default(1500), + LOGIN_IP_BLOCK_RATE_LIMIT_PAUSE_IN_SECONDS: z.coerce + .number() + .default(86400), + LOGIN_IP_BLOCK_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS: z.coerce + .number() + .default(86400) + }) + .transform((data) => { + switch (data.RATE_LIMIT_LEVEL) { + case 'NORMAL': + return { + ...data, + SEND_EMAIL_RATE_LIMIT: 1, + SEND_EMAIL_RATE_LIMIT_PAUSE_IN_SECONDS: 3600, + SEND_EMAIL_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS: 3600, + LOGIN_RATE_LIMIT: 3, + LOGIN_RATE_LIMIT_PAUSE_IN_SECONDS: 600, + LOGIN_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS: 3600, + LOGIN_IP_RATE_LIMIT: 30, + LOGIN_IP_RATE_LIMIT_PAUSE_IN_SECONDS: 3600, + LOGIN_IP_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS: 3600, + LOGIN_IP_BLOCK_RATE_LIMIT: 500, + LOGIN_IP_BLOCK_RATE_LIMIT_PAUSE_IN_SECONDS: 86400, + LOGIN_IP_BLOCK_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS: 86400 + } + case 'STRICT': + return { + ...data, + SEND_EMAIL_RATE_LIMIT: 1, + SEND_EMAIL_RATE_LIMIT_PAUSE_IN_SECONDS: 7200, + SEND_EMAIL_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS: 3600, + LOGIN_RATE_LIMIT: 3, + LOGIN_RATE_LIMIT_PAUSE_IN_SECONDS: 1800, + LOGIN_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS: 3600, + LOGIN_IP_RATE_LIMIT: 20, + LOGIN_IP_RATE_LIMIT_PAUSE_IN_SECONDS: 7200, + LOGIN_IP_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS: 3600, + LOGIN_IP_BLOCK_RATE_LIMIT: 250, + LOGIN_IP_BLOCK_RATE_LIMIT_PAUSE_IN_SECONDS: 86400, + LOGIN_IP_BLOCK_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS: 86400 + } + } + return data + }) + + const result = rateLimitSchema.safeParse(process.env) + if (!result.success) { + console.error( + 'Error parsing rate limit environment variables:', + result.error.flatten() + ) + process.exit(1) + } + return result.data +} diff --git a/packages/wallet/backend/src/config/redis.ts b/packages/wallet/backend/src/config/redis.ts index b8b44510b..b5e0f0b63 100644 --- a/packages/wallet/backend/src/config/redis.ts +++ b/packages/wallet/backend/src/config/redis.ts @@ -1,6 +1,24 @@ import { Env } from '@/config/env' import { Redis } from 'ioredis' import { RedisClient } from '@shared/backend' +let redisClient: Redis | null = null + +export const createRedisClient = (env: Env): Redis => { + redisClient = new Redis(env.REDIS_URL) + + redisClient.on('error', (err) => { + console.error('Redis error:', err) + }) + + return redisClient +} + +export const getRedisClient = (env: Env): Redis | null => { + if (!redisClient) { + return createRedisClient(env) + } + return redisClient +} export function createRedis(env: Env) { const redis = new Redis(env.REDIS_URL) diff --git a/packages/wallet/backend/src/middleware/rateLimit.ts b/packages/wallet/backend/src/middleware/rateLimit.ts index d0fac3f66..752bbdc94 100644 --- a/packages/wallet/backend/src/middleware/rateLimit.ts +++ b/packages/wallet/backend/src/middleware/rateLimit.ts @@ -1,27 +1,79 @@ import { env } from '@/config/env' -import rateLimit from 'express-rate-limit' +import { envRateLimit } from '@/config/rateLimit' +import { RateLimiterRedisHelper } from '@/rateLimit/service' import { NextFunction, Request, Response } from 'express' +import { getRedisClient } from '@/config/redis' -export const setRateLimit = ( - requests: number, - intervalSeconds: number, - skipFailedRequests: boolean = false -) => { - if (env.NODE_ENV !== 'production') { - return (_req: Request, _res: Response, next: NextFunction) => { +const rateLimit = envRateLimit() + +export const rateLimiterEmail = async ( + req: Request, + _res: Response, + next: NextFunction +): Promise => { + if (!rateLimit.RATE_LIMIT) { + next() + return + } + try { + const sendEmailLimiter = new RateLimiterRedisHelper({ + storeClient: getRedisClient(env), + keyPrefix: 'send_email', + points: rateLimit.SEND_EMAIL_RATE_LIMIT, + duration: rateLimit.SEND_EMAIL_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS, + blockDuration: rateLimit.SEND_EMAIL_RATE_LIMIT_PAUSE_IN_SECONDS + }) + await sendEmailLimiter.checkAttempts(req.body.email) + await sendEmailLimiter.useAttempt(req.body.email) + } catch (e) { + next(e) + } + next() +} +export const rateLimiterLogin = async ( + req: Request, + _res: Response, + next: NextFunction +): Promise => { + try { + if (!rateLimit.RATE_LIMIT) { next() + return } - } - return rateLimit({ - windowMs: intervalSeconds * 1000, - max: requests, - skipFailedRequests, - standardHeaders: true, - legacyHeaders: false, - message: { - message: 'Too many requests, please try again later.', - success: false - } - }) + const userIp = `${req.ip}` + const loginAttemptLimiter = new RateLimiterRedisHelper({ + storeClient: getRedisClient(env), + keyPrefix: 'login_email', + points: rateLimit.LOGIN_RATE_LIMIT, + duration: rateLimit.LOGIN_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS, + blockDuration: rateLimit.LOGIN_RATE_LIMIT_PAUSE_IN_SECONDS + }) + const loginIPLimiter = new RateLimiterRedisHelper({ + storeClient: getRedisClient(env), + keyPrefix: 'login_ip', + points: rateLimit.LOGIN_IP_RATE_LIMIT, + duration: rateLimit.LOGIN_IP_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS, + blockDuration: rateLimit.LOGIN_IP_RATE_LIMIT_PAUSE_IN_SECONDS + }) + const loginBlockIPLimiter = new RateLimiterRedisHelper({ + storeClient: getRedisClient(env), + keyPrefix: 'login_block_ip', + points: rateLimit.LOGIN_IP_BLOCK_RATE_LIMIT, + duration: rateLimit.LOGIN_IP_BLOCK_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS, + blockDuration: rateLimit.LOGIN_IP_BLOCK_RATE_LIMIT_PAUSE_IN_SECONDS + }) + + await loginBlockIPLimiter.checkAttempts(userIp) + await loginBlockIPLimiter.useAttempt(userIp) + + await loginIPLimiter.checkAttempts(userIp) + await loginIPLimiter.useAttempt(userIp) + + await loginAttemptLimiter.checkAttempts(req.body.email) + await loginAttemptLimiter.useAttempt(req.body.email) + } catch (e) { + next(e) + } + next() } diff --git a/packages/wallet/backend/src/rateLimit/service.ts b/packages/wallet/backend/src/rateLimit/service.ts new file mode 100644 index 000000000..ddb43dd41 --- /dev/null +++ b/packages/wallet/backend/src/rateLimit/service.ts @@ -0,0 +1,58 @@ +import { + RateLimiterRes, + RateLimiterRedis, + IRateLimiterRedisOptions +} from 'rate-limiter-flexible' + +import { TooManyRequests } from '@shared/backend' + +interface IRateLimiterRedisHelper { + checkAttempts(inputKey: string): Promise + useAttempt(inputKey: string): Promise +} +export class RateLimiterRedisHelper + extends RateLimiterRedis + implements IRateLimiterRedisHelper +{ + private attempts: number + constructor(opts: IRateLimiterRedisOptions) { + super(opts) + this.attempts = opts.points || 1 + } + + public async checkAttempts(inputKey: string) { + let retrySecs = 0 + try { + const resSlowByEmail = await this.get(inputKey) + + if ( + resSlowByEmail !== null && + resSlowByEmail.consumedPoints > this.attempts + ) { + retrySecs = Math.ceil(resSlowByEmail.msBeforeNext / 60000) || 1 + } + } catch (err) { + console.log(`Error checking limiter attempt`, err) + } + + if (retrySecs > 0) { + throw new TooManyRequests( + `Too many requests. Retry after ${retrySecs} minutes.` + ) + } + } + public async useAttempt(inputKey: string) { + try { + await this.consume(inputKey) + } catch (err) { + if (err instanceof RateLimiterRes) { + const timeOut = String(Math.ceil(err.msBeforeNext / 60000)) || 1 + throw new TooManyRequests( + `Too many attempts. Retry after ${timeOut} minutes` + ) + } else { + console.log(`Error consuming limiter attempt`, err) + } + } + } +} diff --git a/packages/wallet/backend/tests/auth/controller.test.ts b/packages/wallet/backend/tests/auth/controller.test.ts index 783cc8d3a..62be689c0 100644 --- a/packages/wallet/backend/tests/auth/controller.test.ts +++ b/packages/wallet/backend/tests/auth/controller.test.ts @@ -1,5 +1,6 @@ import { Cradle, createContainer } from '@/createContainer' import { env } from '@/config/env' +import { envRateLimit } from '@/config/rateLimit' import { createApp, TestApp } from '@/tests/app' import { Knex } from 'knex' import { truncateTables } from '@shared/backend/tests' @@ -14,6 +15,8 @@ import type { AuthController } from '@/auth/controller' import type { AuthService } from '@/auth/service' import { applyMiddleware } from '@/tests/utils' import { withSession } from '@/middleware/withSession' +import { getRedisClient } from '@/config/redis' +import { rateLimiterLogin, rateLimiterEmail } from '@/middleware/rateLimit' import type { UserService } from '@/user/service' import { fakeLoginData, @@ -25,6 +28,7 @@ import { createUser, errorHandler } from '@/tests/helpers' import { AwilixContainer } from 'awilix' import { getRandomToken, hashToken } from '@/utils/helpers' import { GateHubClient } from '@/gatehub/client' +import { BaseError } from '@shared/backend' describe('Authentication Controller', (): void => { let bindings: AwilixContainer @@ -36,6 +40,7 @@ describe('Authentication Controller', (): void => { let req: MockRequest let res: MockResponse + const rateLimit = envRateLimit() const next = jest.fn() beforeAll(async (): Promise => { @@ -61,6 +66,8 @@ describe('Authentication Controller', (): void => { afterAll(async (): Promise => { await appContainer.stop() await knex.destroy() + const redisClient = getRedisClient(env) + redisClient?.disconnect() }) afterEach(async (): Promise => { @@ -212,6 +219,40 @@ describe('Authentication Controller', (): void => { message: 'Internal Server Error' }) }) + it('should return status 429 (rate limit) if the user login rate limit is reached', async (): Promise => { + if (!rateLimit.RATE_LIMIT) { + return + } + const fakeLogin = fakeLoginData() + const newUserData = { + ...fakeLogin, + isEmailVerified: true + } + await createUser(newUserData) + req.body = { ...fakeLogin, password: 'invalid' } + + let failedLoginResp: MockResponse + for (let i = 0; i < rateLimit.LOGIN_RATE_LIMIT; i++) { + failedLoginResp = createResponse() + await applyMiddleware(rateLimiterLogin, req, failedLoginResp) + await authController.logIn(req, failedLoginResp, (err) => { + next() + errorHandler(err, req, failedLoginResp, next) + }) + expect(next).toHaveBeenCalledTimes(i + 1) + expect(failedLoginResp.statusCode).toBe(401) + } + + try { + await applyMiddleware(rateLimiterLogin, req, res) + await authController.logIn(req, res, next) + } catch (err) { + expect((err as BaseError).statusCode).toBe(429) + expect((err as BaseError).message).toMatch( + `Too many attempts. Retry after ${rateLimit.LOGIN_RATE_LIMIT_PAUSE_IN_SECONDS / 60} minutes` + ) + } + }) }) describe('Log out', () => { @@ -344,5 +385,39 @@ describe('Authentication Controller', (): void => { message: 'Verification email has been sent successfully' }) }) + + it('should return 429 (rate limit) if the endpoint to resend verify email is called twice ', async () => { + if (!rateLimit.RATE_LIMIT) { + return + } + const fakeLogin = fakeLoginData() + const newUserData = { + ...fakeLogin, + isEmailVerified: false + } + await createUser(newUserData) + + req.body = { + email: fakeLogin.email + } + await applyMiddleware(rateLimiterEmail, req, res) + await authController.resendVerifyEmail(req, res, next) + expect(next).toHaveBeenCalledTimes(0) + expect(res.statusCode).toBe(201) + expect(res._getJSONData()).toMatchObject({ + success: true, + message: 'Verification email has been sent successfully' + }) + try { + const limitReachedResp: MockResponse = createResponse() + await applyMiddleware(rateLimiterEmail, req, limitReachedResp) + await authController.resendVerifyEmail(req, limitReachedResp, next) + } catch (err) { + expect((err as BaseError).statusCode).toBe(429) + expect((err as BaseError).message).toMatch( + `Too many attempts. Retry after ${rateLimit.SEND_EMAIL_RATE_LIMIT_PAUSE_IN_SECONDS / 60} minutes` + ) + } + }) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abf5b19c4..5fe119de3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,6 +354,9 @@ importers: randexp: specifier: ^0.5.3 version: 0.5.3 + rate-limiter-flexible: + specifier: ^5.0.3 + version: 5.0.3 socket.io: specifier: ^4.8.0 version: 4.8.0 @@ -5391,6 +5394,9 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + rate-limiter-flexible@5.0.3: + resolution: {integrity: sha512-lWx2y8NBVlTOLPyqs+6y7dxfEpT6YFqKy3MzWbCy95sTTOhOuxufP2QvRyOHpfXpB9OUJPbVLybw3z3AVAS5fA==} + raw-body@2.5.2: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} engines: {node: '>= 0.8'} @@ -12157,6 +12163,8 @@ snapshots: range-parser@1.2.1: {} + rate-limiter-flexible@5.0.3: {} + raw-body@2.5.2: dependencies: bytes: 3.1.2