// Auth brute-force lockout (in-memory). // // Tracks failed authentication attempts keyed by an opaque, namespaced key // (e.g. `web:${sessionId}:${clientIp}` or `desktop:${sessionId}:${clientIp}`). // Namespacing by path and client IP keeps the web and desktop keyspaces // separate so one abusive web client cannot lock out other users or the // desktop for the same session. After `authLockoutMaxAttempts` failures within // `authLockoutWindowMs`, further attempts are rejected until // `authLockoutDurationMs` has elapsed. import { config } from '../config'; interface AttemptRecord { count: number; windowStart: number; lockedUntil: number; } const records = new Map(); /** * Returns the number of milliseconds remaining on a lockout for this key, * or 0 if the key is not currently locked. */ export function getLockoutRemaining(key: string): number { const rec = records.get(key); if (!rec) return 0; const now = Date.now(); if (rec.lockedUntil > now) { return rec.lockedUntil - now; } return 0; } /** * Record a failed auth attempt. Locks the key out for * `authLockoutDurationMs` once `authLockoutMaxAttempts` failures accumulate * within `authLockoutWindowMs`. Callers query the resulting lockout via * getLockoutRemaining(), so this returns nothing. */ export function recordFailure(key: string): void { const now = Date.now(); let rec = records.get(key); // Start a fresh window if none exists or the current one has expired. if (!rec || now - rec.windowStart > config.authLockoutWindowMs) { rec = { count: 0, windowStart: now, lockedUntil: 0 }; records.set(key, rec); } rec.count += 1; if (rec.count >= config.authLockoutMaxAttempts) { rec.lockedUntil = now + config.authLockoutDurationMs; } } /** * Clear any recorded failures for this key (call on successful auth). */ export function recordSuccess(key: string): void { records.delete(key); } // Periodically drop stale records so the map does not grow unbounded. const cleanup = setInterval(() => { const now = Date.now(); for (const [key, rec] of records) { const windowExpired = now - rec.windowStart > config.authLockoutWindowMs; const notLocked = rec.lockedUntil <= now; if (windowExpired && notLocked) { records.delete(key); } } }, Math.max(60000, config.authLockoutWindowMs)); // Do not keep the process alive solely for this timer. if (typeof cleanup.unref === 'function') { cleanup.unref(); }