Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
66 changes: 66 additions & 0 deletions backend/src/middleware/__tests__/loginRateLimit.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
101 changes: 101 additions & 0 deletions backend/src/middleware/loginRateLimit.ts
Original file line number Diff line number Diff line change
@@ -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<string, Bucket>();
const emailBuckets = new Map<string, Bucket>();
const lockouts = new Map<string, FailureBucket>();

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<string, Bucket>, 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();
}
8 changes: 8 additions & 0 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down