Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add rate limiters for sending emails and login #1632

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading