/* Login rate limiting. Failures are counted per client address and per user name: after MAX_FAILURES within WINDOW_MS, further attempts are refused for LOCK_MS. A success clears the user's counter. In memory; `sweep` drops what has expired. */ export const MAX_FAILURES = 5 export const WINDOW_MS = 15 * 60_000 export const LOCK_MS = 15 * 60_000 interface Entry { failures: number[] lockedUntil: number } const userKey = (user: string): string => user.trim().toLowerCase().slice(0, 128) export class LoginLimiter { #now: () => number #ips = new Map() #users = new Map() constructor(now: () => number = Date.now) { this.#now = now } /** Milliseconds left of a lock on this address or this user, or 0 when attempts are allowed. */ lockedFor(ip: string, user: string): number { const now = this.#now() const until = Math.max(this.#ips.get(ip)?.lockedUntil ?? 0, this.#users.get(userKey(user))?.lockedUntil ?? 0) return until > now ? until - now : 0 } failure(ip: string, user: string): void { this.#record(this.#ips, ip) this.#record(this.#users, userKey(user)) this.sweep() } /** A good login clears the user's counter (the address keeps its own). */ success(user: string): void { this.#users.delete(userKey(user)) } #record(map: Map, key: string): void { const now = this.#now() const entry = map.get(key) ?? { failures: [], lockedUntil: 0 } entry.failures = entry.failures.filter((t) => now - t < WINDOW_MS) entry.failures.push(now) if (entry.failures.length >= MAX_FAILURES) { entry.lockedUntil = now + LOCK_MS entry.failures = [] } map.set(key, entry) } sweep(): void { const now = this.#now() for (const map of [this.#ips, this.#users]) { for (const [key, entry] of map) { const recent = entry.failures.some((t) => now - t < WINDOW_MS) if (entry.lockedUntil <= now && !recent) map.delete(key) } } } } /** Technitium-like message for a locked attempt. */ export const lockedMessage = (ms: number): string => { const minutes = Math.max(1, Math.ceil(ms / 60_000)) return `Too many failed logins. Try again in ${minutes} minute${minutes === 1 ? '' : 's'}.` }