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 6 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
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)
}
}
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
105 changes: 85 additions & 20 deletions packages/wallet/backend/src/middleware/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,92 @@
import { env } from '@/config/env'
import rateLimit from 'express-rate-limit'
import Redis from 'ioredis'
import { RateLimiterRedisHelper } from '@/rateLimit/service'
import { NextFunction, Request, Response } from 'express'

export const setRateLimit = (
requests: number,
intervalSeconds: number,
skipFailedRequests: boolean = false
) => {
if (env.NODE_ENV !== 'production') {
return (_req: Request, _res: Response, next: NextFunction) => {
next()
const redisClient = new Redis(env.REDIS_URL)
const sendEmailLimiterArgs = {
key: 'send_email',
maxAttempts: 1,
attemptsPause: 60
}
const loginBlockIPLimiterAtrs = {
key: 'login_block_ip',
maxAttempts: 500,
attemptsPause: 24 * 60
}
const loginIPLimiterAtrs = {
key: 'login_ip',
maxAttempts: 30,
attemptsPause: 60
}
const loginAttemptLimiterAtrs = {
key: 'login_email',
maxAttempts: 3,
attemptsPause: 10
}

const sendEmailLimiter = new RateLimiterRedisHelper({
storeClient: redisClient,
keyPrefix: sendEmailLimiterArgs.key,
points: sendEmailLimiterArgs.maxAttempts,
duration: 60 * 60 * 24,
blockDuration: 60 * sendEmailLimiterArgs.attemptsPause
})
const loginAttemptLimiter = new RateLimiterRedisHelper({
storeClient: redisClient,
keyPrefix: loginAttemptLimiterAtrs.key,
points: loginAttemptLimiterAtrs.maxAttempts,
duration: 60 * 60 * 1,
blockDuration: 60 * loginAttemptLimiterAtrs.attemptsPause
})
const loginIPLimiter = new RateLimiterRedisHelper({
storeClient: redisClient,
keyPrefix: loginIPLimiterAtrs.key,
points: loginIPLimiterAtrs.maxAttempts,
duration: 60 * 60 * 1,
blockDuration: 60 * loginIPLimiterAtrs.attemptsPause
})
const loginBlockIPLimiter = new RateLimiterRedisHelper({
storeClient: redisClient,
keyPrefix: loginBlockIPLimiterAtrs.key,
points: loginBlockIPLimiterAtrs.maxAttempts,
duration: 60 * 60 * 1,
blockDuration: 60 * loginBlockIPLimiterAtrs.attemptsPause
})
const EmailRoutes = ['/resend-verify-email', '/forgot-password']
export const rateLimiterEmail = async (
req: Request,
_res: Response,
next: NextFunction
): Promise<void> => {
try {
if (EmailRoutes.includes(req.url)) {
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}`
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
})
}
}
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading