feat: replace fixed login rate limit with progressive lockout
Made-with: Cursor
This commit is contained in:
@@ -23,8 +23,7 @@ LLM_MAX_RETRIES=1
|
|||||||
LLM_TEMPERATURE=0.7
|
LLM_TEMPERATURE=0.7
|
||||||
LLM_MAX_TOKENS=2048
|
LLM_MAX_TOKENS=2048
|
||||||
|
|
||||||
# Rate limits
|
# Rate limits (login uses progressive lockout: 5/10/20 failed attempts -> 15m/1h/24h block)
|
||||||
RATE_LIMIT_LOGIN=5
|
|
||||||
RATE_LIMIT_REGISTER=3
|
RATE_LIMIT_REGISTER=3
|
||||||
RATE_LIMIT_FORGOT_PASSWORD=3
|
RATE_LIMIT_FORGOT_PASSWORD=3
|
||||||
RATE_LIMIT_VERIFY_EMAIL=5
|
RATE_LIMIT_VERIFY_EMAIL=5
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ const envSchema = z.object({
|
|||||||
LLM_TEMPERATURE: z.coerce.number().min(0).max(2).default(0.7),
|
LLM_TEMPERATURE: z.coerce.number().min(0).max(2).default(0.7),
|
||||||
LLM_MAX_TOKENS: z.coerce.number().default(2048),
|
LLM_MAX_TOKENS: z.coerce.number().default(2048),
|
||||||
|
|
||||||
RATE_LIMIT_LOGIN: z.coerce.number().default(5),
|
|
||||||
RATE_LIMIT_REGISTER: z.coerce.number().default(3),
|
RATE_LIMIT_REGISTER: z.coerce.number().default(3),
|
||||||
RATE_LIMIT_FORGOT_PASSWORD: z.coerce.number().default(3),
|
RATE_LIMIT_FORGOT_PASSWORD: z.coerce.number().default(3),
|
||||||
RATE_LIMIT_VERIFY_EMAIL: z.coerce.number().default(5),
|
RATE_LIMIT_VERIFY_EMAIL: z.coerce.number().default(5),
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { env } from '../config/env.js';
|
|||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
rateLimitOptions: {
|
rateLimitOptions: {
|
||||||
login: { max: number; timeWindow: string };
|
|
||||||
register: { max: number; timeWindow: string };
|
register: { max: number; timeWindow: string };
|
||||||
forgotPassword: { max: number; timeWindow: string };
|
forgotPassword: { max: number; timeWindow: string };
|
||||||
verifyEmail: { max: number; timeWindow: string };
|
verifyEmail: { max: number; timeWindow: string };
|
||||||
@@ -18,7 +17,6 @@ declare module 'fastify' {
|
|||||||
|
|
||||||
const rateLimitPlugin: FastifyPluginAsync = async (app: FastifyInstance) => {
|
const rateLimitPlugin: FastifyPluginAsync = async (app: FastifyInstance) => {
|
||||||
const options = {
|
const options = {
|
||||||
login: { max: env.RATE_LIMIT_LOGIN, timeWindow: '15 minutes' },
|
|
||||||
register: { max: env.RATE_LIMIT_REGISTER, timeWindow: '1 hour' },
|
register: { max: env.RATE_LIMIT_REGISTER, timeWindow: '1 hour' },
|
||||||
forgotPassword: { max: env.RATE_LIMIT_FORGOT_PASSWORD, timeWindow: '1 hour' },
|
forgotPassword: { max: env.RATE_LIMIT_FORGOT_PASSWORD, timeWindow: '1 hour' },
|
||||||
verifyEmail: { max: env.RATE_LIMIT_VERIFY_EMAIL, timeWindow: '15 minutes' },
|
verifyEmail: { max: env.RATE_LIMIT_VERIFY_EMAIL, timeWindow: '15 minutes' },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { AuthService } from '../services/auth/auth.service.js';
|
import { AuthService } from '../services/auth/auth.service.js';
|
||||||
|
import { checkBlocked, clearOnSuccess, recordFailedAttempt } from '../utils/loginLockout.js';
|
||||||
|
|
||||||
const registerSchema = {
|
const registerSchema = {
|
||||||
body: {
|
body: {
|
||||||
@@ -89,20 +90,43 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
'/login',
|
'/login',
|
||||||
{ schema: loginSchema, config: { rateLimit: rateLimitOptions.login } },
|
{
|
||||||
|
schema: loginSchema,
|
||||||
|
preValidation: async (req, reply) => {
|
||||||
|
const ip = req.ip ?? 'unknown';
|
||||||
|
const { blocked, retryAfter } = await checkBlocked(app.redis, ip);
|
||||||
|
if (blocked) {
|
||||||
|
if (retryAfter !== undefined) {
|
||||||
|
reply.header('Retry-After', String(retryAfter));
|
||||||
|
}
|
||||||
|
return reply.status(429).send({
|
||||||
|
error: {
|
||||||
|
code: 'RATE_LIMIT_EXCEEDED',
|
||||||
|
message: 'Too many failed login attempts. Please try again later.',
|
||||||
|
retryAfter,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const body = req.body as { email: string; password: string };
|
const body = req.body as { email: string; password: string };
|
||||||
const userAgent = req.headers['user-agent'];
|
const userAgent = req.headers['user-agent'];
|
||||||
const ipAddress = req.ip;
|
const ip = req.ip ?? 'unknown';
|
||||||
|
|
||||||
const result = await authService.login({
|
try {
|
||||||
email: body.email,
|
const result = await authService.login({
|
||||||
password: body.password,
|
email: body.email,
|
||||||
userAgent,
|
password: body.password,
|
||||||
ipAddress,
|
userAgent,
|
||||||
});
|
ipAddress: ip,
|
||||||
|
});
|
||||||
return reply.send(result);
|
await clearOnSuccess(app.redis, ip);
|
||||||
|
return reply.send(result);
|
||||||
|
} catch (err) {
|
||||||
|
await recordFailedAttempt(app.redis, ip);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
106
src/utils/loginLockout.ts
Normal file
106
src/utils/loginLockout.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
|
||||||
|
const WINDOW_15M_SEC = 15 * 60; // 900
|
||||||
|
const WINDOW_1H_SEC = 60 * 60; // 3600
|
||||||
|
const WINDOW_24H_SEC = 24 * 60 * 60; // 86400
|
||||||
|
|
||||||
|
const THRESHOLD_15M = 5;
|
||||||
|
const THRESHOLD_1H = 10;
|
||||||
|
const THRESHOLD_24H = 20;
|
||||||
|
|
||||||
|
const BLOCK_15M_SEC = WINDOW_15M_SEC;
|
||||||
|
const BLOCK_1H_SEC = WINDOW_1H_SEC;
|
||||||
|
const BLOCK_24H_SEC = WINDOW_24H_SEC;
|
||||||
|
|
||||||
|
const KEY_PREFIX = 'lockout';
|
||||||
|
|
||||||
|
function key15m(ip: string): string {
|
||||||
|
return `${KEY_PREFIX}:15m:${ip}`;
|
||||||
|
}
|
||||||
|
function key1h(ip: string): string {
|
||||||
|
return `${KEY_PREFIX}:1h:${ip}`;
|
||||||
|
}
|
||||||
|
function key24h(ip: string): string {
|
||||||
|
return `${KEY_PREFIX}:24h:${ip}`;
|
||||||
|
}
|
||||||
|
function keyBlocked(ip: string): string {
|
||||||
|
return `${KEY_PREFIX}:blocked:${ip}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the IP is currently blocked due to progressive login lockout.
|
||||||
|
* @returns { blocked: true, retryAfter } if blocked, { blocked: false } otherwise
|
||||||
|
*/
|
||||||
|
export async function checkBlocked(
|
||||||
|
redis: Redis,
|
||||||
|
ip: string
|
||||||
|
): Promise<{ blocked: boolean; retryAfter?: number }> {
|
||||||
|
const blockedKey = keyBlocked(ip);
|
||||||
|
const ttl = await redis.ttl(blockedKey);
|
||||||
|
if (ttl > 0) {
|
||||||
|
return { blocked: true, retryAfter: ttl };
|
||||||
|
}
|
||||||
|
return { blocked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECORD_SCRIPT = `
|
||||||
|
local c15 = redis.call('INCR', KEYS[1])
|
||||||
|
if redis.call('TTL', KEYS[1]) == -1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
|
||||||
|
local c1h = redis.call('INCR', KEYS[2])
|
||||||
|
if redis.call('TTL', KEYS[2]) == -1 then redis.call('EXPIRE', KEYS[2], ARGV[2]) end
|
||||||
|
local c24 = redis.call('INCR', KEYS[3])
|
||||||
|
if redis.call('TTL', KEYS[3]) == -1 then redis.call('EXPIRE', KEYS[3], ARGV[3]) end
|
||||||
|
return {c15, c1h, c24}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a failed login attempt. Increments counters and sets blocked key when thresholds are reached.
|
||||||
|
* Thresholds: 5 in 15m -> 15m block; 10 in 1h -> 1h block; 20 in 24h -> 24h block.
|
||||||
|
*/
|
||||||
|
export async function recordFailedAttempt(redis: Redis, ip: string): Promise<void> {
|
||||||
|
const k15 = key15m(ip);
|
||||||
|
const k1h = key1h(ip);
|
||||||
|
const k24 = key24h(ip);
|
||||||
|
const kBlocked = keyBlocked(ip);
|
||||||
|
|
||||||
|
const counts = (await redis.eval(
|
||||||
|
RECORD_SCRIPT,
|
||||||
|
3,
|
||||||
|
k15,
|
||||||
|
k1h,
|
||||||
|
k24,
|
||||||
|
String(WINDOW_15M_SEC),
|
||||||
|
String(WINDOW_1H_SEC),
|
||||||
|
String(WINDOW_24H_SEC)
|
||||||
|
)) as number[];
|
||||||
|
|
||||||
|
const count15m = counts[0] ?? 0;
|
||||||
|
const count1h = counts[1] ?? 0;
|
||||||
|
const count24h = counts[2] ?? 0;
|
||||||
|
|
||||||
|
let blockTtl = 0;
|
||||||
|
if (count24h >= THRESHOLD_24H) {
|
||||||
|
blockTtl = BLOCK_24H_SEC;
|
||||||
|
} else if (count1h >= THRESHOLD_1H) {
|
||||||
|
blockTtl = BLOCK_1H_SEC;
|
||||||
|
} else if (count15m >= THRESHOLD_15M) {
|
||||||
|
blockTtl = BLOCK_15M_SEC;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockTtl > 0) {
|
||||||
|
await redis.setex(kBlocked, blockTtl, '1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all lockout counters and blocked state on successful login.
|
||||||
|
*/
|
||||||
|
export async function clearOnSuccess(redis: Redis, ip: string): Promise<void> {
|
||||||
|
const keys = [
|
||||||
|
key15m(ip),
|
||||||
|
key1h(ip),
|
||||||
|
key24h(ip),
|
||||||
|
keyBlocked(ip),
|
||||||
|
];
|
||||||
|
await redis.del(...keys);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user