2026-07-17 18:07:48 -07:00
|
|
|
// Auth brute-force lockout (in-memory).
|
2026-07-17 10:15:23 -07:00
|
|
|
//
|
2026-07-17 18:07:48 -07:00
|
|
|
// 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.
|
2026-07-17 10:15:23 -07:00
|
|
|
|
|
|
|
|
import { config } from '../config';
|
|
|
|
|
|
|
|
|
|
interface AttemptRecord {
|
|
|
|
|
count: number;
|
|
|
|
|
windowStart: number;
|
|
|
|
|
lockedUntil: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const records = new Map<string, AttemptRecord>();
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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. Returns the lockout remaining (ms) after
|
|
|
|
|
* this failure is applied (0 if still not locked).
|
|
|
|
|
*/
|
|
|
|
|
export function recordFailure(key: string): number {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return rec.lockedUntil > now ? rec.lockedUntil - now : 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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();
|
|
|
|
|
}
|