diff --git a/backend/src/middleware/__tests__/loginRateLimit.test.ts b/backend/src/middleware/__tests__/loginRateLimit.test.ts new file mode 100644 index 00000000..6a8f07f8 --- /dev/null +++ b/backend/src/middleware/__tests__/loginRateLimit.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearLoginFailures, + loginRateLimit, + recordLoginFailure, + resetLoginRateLimitStateForTests, +} from '../loginRateLimit.js'; + +function mockRes() { + const res: any = {}; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + return res; +} + +describe('loginRateLimit', () => { + beforeEach(() => { + resetLoginRateLimitStateForTests(); + process.env.AUTH_LOGIN_IP_MAX = '3'; + process.env.AUTH_LOGIN_EMAIL_MAX = '2'; + process.env.AUTH_LOGIN_LOCKOUT_FAILURES = '3'; + process.env.AUTH_LOGIN_LOCKOUT_MS = '60000'; + }); + + it('allows requests under the limit', () => { + const req: any = { ip: '1.1.1.1', body: { email: 'a@x.com' }, socket: {} }; + const res = mockRes(); + const next = vi.fn(); + loginRateLimit(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + it('429s after per-email max', () => { + const res = mockRes(); + for (let i = 0; i < 2; i++) { + loginRateLimit( + { ip: '2.2.2.2', body: { email: 'b@x.com' }, socket: {} } as any, + mockRes(), + vi.fn() + ); + } + const next = vi.fn(); + loginRateLimit( + { ip: '2.2.2.2', body: { email: 'b@x.com' }, socket: {} } as any, + res, + next + ); + expect(res.status).toHaveBeenCalledWith(429); + expect(next).not.toHaveBeenCalled(); + }); + + it('locks out after repeated failures', () => { + recordLoginFailure('c@x.com', '3.3.3.3'); + recordLoginFailure('c@x.com', '3.3.3.3'); + recordLoginFailure('c@x.com', '3.3.3.3'); + const res = mockRes(); + const next = vi.fn(); + loginRateLimit( + { ip: '3.3.3.3', body: { email: 'c@x.com' }, socket: {} } as any, + res, + next + ); + expect(res.status).toHaveBeenCalledWith(429); + clearLoginFailures('c@x.com', '3.3.3.3'); + }); +}); diff --git a/backend/src/middleware/loginRateLimit.ts b/backend/src/middleware/loginRateLimit.ts new file mode 100644 index 00000000..012173aa --- /dev/null +++ b/backend/src/middleware/loginRateLimit.ts @@ -0,0 +1,101 @@ +import type { Request, Response, NextFunction } from 'express'; + +/** + * Per-IP and per-email rate limiting for POST /auth/login. + * + * Defaults (override via env): + * AUTH_LOGIN_IP_WINDOW_MS=900000 (15 min) + * AUTH_LOGIN_IP_MAX=30 + * AUTH_LOGIN_EMAIL_WINDOW_MS=900000 + * AUTH_LOGIN_EMAIL_MAX=10 + * AUTH_LOGIN_LOCKOUT_FAILURES=8 + * AUTH_LOGIN_LOCKOUT_MS=900000 + * + * On limit: 429 { error: 'Too many login attempts. Try again later.' } + */ + +interface Bucket { + count: number; + resetAt: number; +} + +interface FailureBucket { + failures: number; + lockedUntil: number; +} + +const ipBuckets = new Map(); +const emailBuckets = new Map(); +const lockouts = new Map(); + +function envInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +function touchBucket(map: Map, key: string, windowMs: number, max: number): boolean { + const now = Date.now(); + const existing = map.get(key); + if (!existing || existing.resetAt <= now) { + map.set(key, { count: 1, resetAt: now + windowMs }); + return true; + } + existing.count += 1; + return existing.count <= max; +} + +export function resetLoginRateLimitStateForTests(): void { + ipBuckets.clear(); + emailBuckets.clear(); + lockouts.clear(); +} + +export function recordLoginFailure(email: string, ip: string): void { + const maxFailures = envInt('AUTH_LOGIN_LOCKOUT_FAILURES', 8); + const lockoutMs = envInt('AUTH_LOGIN_LOCKOUT_MS', 15 * 60 * 1000); + const key = `${email.toLowerCase()}|${ip}`; + const now = Date.now(); + const bucket = lockouts.get(key) ?? { failures: 0, lockedUntil: 0 }; + if (bucket.lockedUntil > now) return; + bucket.failures += 1; + if (bucket.failures >= maxFailures) { + bucket.lockedUntil = now + lockoutMs; + bucket.failures = 0; + } + lockouts.set(key, bucket); +} + +export function clearLoginFailures(email: string, ip: string): void { + lockouts.delete(`${email.toLowerCase()}|${ip}`); +} + +export function loginRateLimit(req: Request, res: Response, next: NextFunction): void { + const ip = (req.ip || req.socket?.remoteAddress || 'unknown').toString(); + const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''; + + const ipWindow = envInt('AUTH_LOGIN_IP_WINDOW_MS', 15 * 60 * 1000); + const ipMax = envInt('AUTH_LOGIN_IP_MAX', 30); + const emailWindow = envInt('AUTH_LOGIN_EMAIL_WINDOW_MS', 15 * 60 * 1000); + const emailMax = envInt('AUTH_LOGIN_EMAIL_MAX', 10); + + const lockKey = `${email}|${ip}`; + const lock = lockouts.get(lockKey); + if (lock && lock.lockedUntil > Date.now()) { + res.status(429).json({ error: 'Too many login attempts. Try again later.' }); + return; + } + + if (!touchBucket(ipBuckets, `ip:${ip}`, ipWindow, ipMax)) { + res.status(429).json({ error: 'Too many login attempts. Try again later.' }); + return; + } + + if (email && !touchBucket(emailBuckets, `email:${email}`, emailWindow, emailMax)) { + res.status(429).json({ error: 'Too many login attempts. Try again later.' }); + return; + } + + next(); +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 29422f4b..3a5f565c 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -6,6 +6,7 @@ import { appLogger } from '../logging/logger.js'; import { asyncHandler } from '../middleware/asyncHandler.js'; import { requireAuth, requireAuthAllowUnverified, invalidateUserCache } from '../middleware/auth.js'; import { validateRequest } from '../middleware/validateRequest.js'; +import { loginRateLimit, recordLoginFailure, clearLoginFailures } from '../middleware/loginRateLimit.js'; import { UserRepository } from '../repositories/userRepository.js'; import { authService } from '../services/authService.js'; import { emailService } from '../services/emailService.js'; @@ -119,24 +120,31 @@ export function registerAuthRoutes(router: Router, pool: Pool) { ); // POST /auth/login + // Rate limits: per-IP (default 30/15m) and per-email (default 10/15m); + // optional lockout after AUTH_LOGIN_LOCKOUT_FAILURES failed attempts. router.post( '/auth/login', + loginRateLimit, validateRequest(loginSchema), asyncHandler(async (req, res) => { const { email, password, rememberMe } = req.body; + const ip = (req.ip || req.socket?.remoteAddress || 'unknown').toString(); const userWithHash = await userRepository.findByEmail(email); if (!userWithHash) { + recordLoginFailure(email, ip); sendUnauthorized(res, 'Invalid email or password'); return; } const validPassword = await authService.verifyPassword(password, userWithHash.password_hash); if (!validPassword) { + recordLoginFailure(email, ip); sendUnauthorized(res, 'Invalid email or password'); return; } + clearLoginFailures(email, ip); const user = userRepository.toSafeUser(userWithHash); await userRepository.updateLastLogin(user.user_id); const tokens = await issueAndStoreTokens(user, req, rememberMe);