Skip to content

Commit

Permalink
feat: add rate limiters for sending emails and login (#1632)
Browse files Browse the repository at this point in the history
* add rate limiters for sending emails and login

* update some rate limit key duration to reset it hourly

* update some rate limit key duration to reset it hourly

* remove 'fail' from limiter key name

* fix: resolve lock file issue

* fix jest REDIS_URL to be able to use redis

* move rate limit config into env vars and add rate limit tests

* prettier fix

* move redis object in config, and disconnect after test

* remove limiterService as we only use limiter from a limiter middleware

* reduce rate limit env variable and set them by level in a config file

---------

Co-authored-by: dragosp1011 <dragosh1011@gmail.com>
  • Loading branch information
adrianboros and dragosp1011 authored Nov 8, 2024
1 parent 928b102 commit 5084734
Show file tree
Hide file tree
Showing 14 changed files with 341 additions and 24 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 2 additions & 0 deletions docker/dev/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 2 additions & 0 deletions docker/dev/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
1 change: 1 addition & 0 deletions packages/shared/backend/src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
8 changes: 8 additions & 0 deletions packages/shared/backend/src/errors/too-many-requests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { BaseError } from './base'

export class TooManyRequests extends BaseError {
constructor(message: string) {
super(429, message)
Object.setPrototypeOf(this, TooManyRequests.prototype)
}
}
2 changes: 1 addition & 1 deletion packages/wallet/backend/jest.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/wallet/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 12 additions & 3 deletions packages/wallet/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions packages/wallet/backend/src/config/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -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
}
18 changes: 18 additions & 0 deletions packages/wallet/backend/src/config/redis.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
92 changes: 72 additions & 20 deletions packages/wallet/backend/src/middleware/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
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()
}
58 changes: 58 additions & 0 deletions packages/wallet/backend/src/rateLimit/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {
RateLimiterRes,
RateLimiterRedis,
IRateLimiterRedisOptions
} from 'rate-limiter-flexible'

import { TooManyRequests } from '@shared/backend'

interface IRateLimiterRedisHelper {
checkAttempts(inputKey: string): Promise<void>
useAttempt(inputKey: string): Promise<void>
}
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)
}
}
}
}
Loading

0 comments on commit 5084734

Please sign in to comment.