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 10 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
12 changes: 12 additions & 0 deletions docker/dev/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ GATEHUB_GATEWAY_UUID=
GATEHUB_VAULT_UUID_EUR=
GATEHUB_VAULT_UUID_USD=
GATEHUB_SETTLEMENT_WALLET_ADDRESS=
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
adrianboros marked this conversation as resolved.
Show resolved Hide resolved

# commerce env variables
# encoded base 64 private key
Expand Down
12 changes: 12 additions & 0 deletions docker/dev/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ services:
GATEHUB_VAULT_UUID_EUR: ${GATEHUB_VAULT_UUID_EUR}
GATEHUB_VAULT_UUID_USD: ${GATEHUB_VAULT_UUID_USD}
GATEHUB_SETTLEMENT_WALLET_ADDRESS: ${GATEHUB_SETTLEMENT_WALLET_ADDRESS}
SEND_EMAIL_RATE_LIMIT: ${SEND_EMAIL_RATE_LIMIT}
SEND_EMAIL_RATE_LIMIT_PAUSE_IN_SECONDS: ${SEND_EMAIL_RATE_LIMIT_PAUSE_IN_SECONDS}
SEND_EMAIL_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS: ${SEND_EMAIL_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS}
LOGIN_RATE_LIMIT: ${LOGIN_RATE_LIMIT}
LOGIN_RATE_LIMIT_PAUSE_IN_SECONDS: ${LOGIN_RATE_LIMIT_PAUSE_IN_SECONDS}
LOGIN_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS: ${LOGIN_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS}
LOGIN_IP_RATE_LIMIT: ${LOGIN_IP_RATE_LIMIT}
LOGIN_IP_RATE_LIMIT_PAUSE_IN_SECONDS: ${LOGIN_IP_RATE_LIMIT_PAUSE_IN_SECONDS}
LOGIN_IP_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS: ${LOGIN_IP_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS}
LOGIN_IP_BLOCK_RATE_LIMIT: ${LOGIN_IP_BLOCK_RATE_LIMIT}
LOGIN_IP_BLOCK_RATE_LIMIT_PAUSE_IN_SECONDS: ${LOGIN_IP_BLOCK_RATE_LIMIT_PAUSE_IN_SECONDS}
LOGIN_IP_BLOCK_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS: ${LOGIN_IP_BLOCK_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS}
restart: always
networks:
- testnet
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 @@ -32,6 +32,7 @@
"objection": "^3.1.4",
"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.14.2",
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 @@ -24,6 +24,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 @@ -163,15 +164,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
20 changes: 19 additions & 1 deletion packages/wallet/backend/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,25 @@ const envSchema = z.object({
SEND_EMAIL: z
.enum(['true', 'false'])
.default('false')
.transform((value) => value === 'true')
.transform((value) => value === 'true'),
SEND_EMAIL_RATE_LIMIT: z.coerce.number().default(1),
SEND_EMAIL_RATE_LIMIT_PAUSE_IN_SECONDS: z.coerce.number().default(3600),
SEND_EMAIL_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS: z.coerce
.number()
.default(3600),
LOGIN_RATE_LIMIT: z.coerce.number().default(3),
LOGIN_RATE_LIMIT_PAUSE_IN_SECONDS: z.coerce.number().default(600),
LOGIN_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS: z.coerce.number().default(3600),
LOGIN_IP_RATE_LIMIT: z.coerce.number().default(30),
LOGIN_IP_RATE_LIMIT_PAUSE_IN_SECONDS: z.coerce.number().default(3600),
LOGIN_IP_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS: z.coerce
.number()
.default(3600),
LOGIN_IP_BLOCK_RATE_LIMIT: z.coerce.number().default(500),
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)
})

export type Env = z.infer<typeof envSchema>
Expand Down
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
83 changes: 62 additions & 21 deletions packages/wallet/backend/src/middleware/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,68 @@
import { env } from '@/config/env'
import rateLimit from 'express-rate-limit'
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) => {
next()
}
export const rateLimiterEmail = async (
req: Request,
_res: Response,
next: NextFunction
): Promise<void> => {
try {
const sendEmailLimiter = new RateLimiterRedisHelper({
storeClient: getRedisClient(env),
keyPrefix: 'send_email',
points: env.SEND_EMAIL_RATE_LIMIT,
duration: env.SEND_EMAIL_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS,
blockDuration: env.SEND_EMAIL_RATE_LIMIT_PAUSE_IN_SECONDS
})
await sendEmailLimiter.checkAttempts(req.body.email)
await sendEmailLimiter.useAttempt(req.body.email)
} catch (e) {
next(e)
}

return rateLimit({
windowMs: intervalSeconds * 1000,
max: requests,
skipFailedRequests,
standardHeaders: true,
legacyHeaders: false,
message: {
message: 'Too many requests, please try again later.',
success: false
}
})
next()
}
export const rateLimiterLogin = async (
req: Request,
_res: Response,
next: NextFunction
): Promise<void> => {
try {
const userIp = `${req.ip}`
const loginAttemptLimiter = new RateLimiterRedisHelper({
storeClient: getRedisClient(env),
keyPrefix: 'login_email',
points: env.LOGIN_RATE_LIMIT,
duration: env.LOGIN_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS,
blockDuration: env.LOGIN_RATE_LIMIT_PAUSE_IN_SECONDS
})
const loginIPLimiter = new RateLimiterRedisHelper({
storeClient: getRedisClient(env),
keyPrefix: 'login_ip',
points: env.LOGIN_IP_RATE_LIMIT,
duration: env.LOGIN_IP_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS,
blockDuration: env.LOGIN_IP_RATE_LIMIT_PAUSE_IN_SECONDS
})
const loginBlockIPLimiter = new RateLimiterRedisHelper({
storeClient: getRedisClient(env),
keyPrefix: 'login_block_ip',
points: env.LOGIN_IP_BLOCK_RATE_LIMIT,
duration: env.LOGIN_IP_BLOCK_RATE_LIMIT_RESET_INTERVAL_IN_SECONDS,
blockDuration: env.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()
}
89 changes: 89 additions & 0 deletions packages/wallet/backend/src/rateLimit/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import Redis from 'ioredis'
import {
RateLimiterRes,
RateLimiterRedis,
IRateLimiterRedisOptions
} from 'rate-limiter-flexible'

import type { Env } from '@/config/env'
import { TooManyRequests } from '@shared/backend'

interface getLimiterlArgs {
key: string
maxWrongAttempts: number
wrongAttemptsPause: number
}
interface IRateLimitService {
buildLimiter(args: getLimiterlArgs): Promise<RateLimiterRedis>
}
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)
}
}
}
}
export class RateLimitService implements IRateLimitService {
private redisClient: Redis
constructor(private env: Env) {
const redis_url = this.env.REDIS_URL
this.redisClient = new Redis(redis_url)
}

public async buildLimiter({
key,
maxWrongAttempts,
wrongAttemptsPause
}: getLimiterlArgs) {
return new RateLimiterRedisHelper({
storeClient: this.redisClient,
keyPrefix: key,
points: maxWrongAttempts,
duration: 60 * 60 * 24,
blockDuration: 60 * wrongAttemptsPause
})
}
}
Loading
Loading