-
Notifications
You must be signed in to change notification settings - Fork 22
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add rate limiters for sending emails and login (#1632)
* 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
1 parent
928b102
commit 5084734
Showing
14 changed files
with
341 additions
and
24 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
} | ||
} |
Oops, something went wrong.