// Per-email login lockout abstraction with two implementations: // - memory: per-process Map (the original routes/auth.ts behavior) // - redis: shared counter across all instances so the lockout cannot be // bypassed by round-robining replicas // // Semantics: recordFailure starts a window on the first failure; once the // failure count reaches the max the email is locked until the window expires; // clear removes the counter on successful login. Selection is based on // REDIS_URL. The redis implementation fails open (never locked, failures not // recorded) — the per-IP auth rate limit remains as a backstop during a // Redis outage. import type Redis from 'ioredis'; import { getRedis, isRedisEnabled } from '../redis.js'; export const MAX_LOGIN_ATTEMPTS = 5; export const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes export interface LockoutStatus { locked: boolean; // Seconds until the lockout lifts; only set when locked. retryAfter?: number; } export interface LoginLockout { readonly backend: 'memory' | 'redis'; isLocked(email: string): Promise; recordFailure(email: string): Promise; clear(email: string): Promise; } // Emails are compared case-insensitively so "User@x.com" and "user@x.com" // share one counter. function normalize(email: string): string { return email.trim().toLowerCase(); } // ==================== Memory implementation ==================== export class MemoryLoginLockout implements LoginLockout { readonly backend = 'memory' as const; private attempts = new Map(); constructor() { // Periodically drop expired entries so the Map does not grow unbounded. const cleanup = setInterval(() => { const now = Date.now(); for (const [key, entry] of this.attempts) { if (now > entry.resetAt) this.attempts.delete(key); } }, 60_000); (cleanup as any).unref?.(); } async isLocked(email: string): Promise { const entry = this.attempts.get(normalize(email)); const now = Date.now(); if (!entry || now > entry.resetAt) return { locked: false }; if (entry.count >= MAX_LOGIN_ATTEMPTS) { return { locked: true, retryAfter: Math.ceil((entry.resetAt - now) / 1000) }; } return { locked: false }; } async recordFailure(email: string): Promise { const key = normalize(email); const now = Date.now(); const entry = this.attempts.get(key); if (!entry || now > entry.resetAt) { this.attempts.set(key, { count: 1, resetAt: now + LOCKOUT_DURATION_MS }); return; } entry.count++; } async clear(email: string): Promise { this.attempts.delete(normalize(email)); } } // ==================== Redis implementation ==================== // Same atomic INCR+PEXPIRE shape as the rate limiter's consume script: the // window starts at the first failure and any TTL-less counter is repaired. const RECORD_FAILURE_SCRIPT = ` local count = redis.call('INCR', KEYS[1]) if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end if redis.call('PTTL', KEYS[1]) < 0 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end return count `; type RedisWithLockout = Redis & { lockoutRecordFailure(key: string, windowMs: number): Promise; }; function withLockoutCommand(redis: Redis): RedisWithLockout { if (typeof (redis as any).lockoutRecordFailure !== 'function') { redis.defineCommand('lockoutRecordFailure', { numberOfKeys: 1, lua: RECORD_FAILURE_SCRIPT }); } return redis as RedisWithLockout; } export class RedisLoginLockout implements LoginLockout { readonly backend = 'redis' as const; private key(email: string): string { return `lockout:${normalize(email)}`; } async isLocked(email: string): Promise { const redis = getRedis(); if (!redis) return { locked: false }; try { const [count, ttl] = await Promise.all([ redis.get(this.key(email)), redis.pttl(this.key(email)), ]); if (count !== null && parseInt(count, 10) >= MAX_LOGIN_ATTEMPTS) { const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(LOCKOUT_DURATION_MS / 1000); return { locked: true, retryAfter }; } return { locked: false }; } catch (err: any) { // Fail open: the per-IP auth rate limit still applies. console.error('[loginLockout] redis error, treating as unlocked:', err?.message || err); return { locked: false }; } } async recordFailure(email: string): Promise { const redis = getRedis(); if (!redis) return; try { await withLockoutCommand(redis).lockoutRecordFailure(this.key(email), LOCKOUT_DURATION_MS); } catch (err: any) { console.error('[loginLockout] redis error recording failure:', err?.message || err); } } async clear(email: string): Promise { const redis = getRedis(); if (!redis) return; try { await redis.del(this.key(email)); } catch (err: any) { console.error('[loginLockout] redis error clearing failures:', err?.message || err); } } } // ==================== Selection ==================== let instance: LoginLockout | null = null; export function getLoginLockout(): LoginLockout { if (!instance) { instance = isRedisEnabled() ? new RedisLoginLockout() : new MemoryLoginLockout(); } return instance; }