77 lines
2.1 KiB
TypeScript
77 lines
2.1 KiB
TypeScript
|
|
// Per-session-id auth brute-force lockout (in-memory).
|
||
|
|
//
|
||
|
|
// Tracks failed authentication attempts keyed by session id. 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<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();
|
||
|
|
}
|