Repository Public
1/**2 * Brute-force throttling backed by `login_attempts` (survives restarts).3 * per IP : 10 failures / 15 min4 * per username : 5 failures / 15 min5 * global breaker: 100 failures / 15 min6 * Once a key reaches its limit it is locked progressively: 60 s, doubled for every further7 * failure, capped at 15 min. A success clears the failures of the ip/user keys (not `global`).8 * Applies to password, TOTP, setup-token and passkey verification alike.9 */10import type { Queryable } from '../db.js';1112const WINDOW_SEC = 15 * 60;13export const THROTTLE_LIMITS = { ip: 10, user: 5, global: 100 } as const;1415export interface ThrottleKeys {16 ip: string;17 username?: string | null;18}19function keysOf(k: ThrottleKeys): { key: string; limit: number }[] {20 const out: { key: string; limit: number }[] = [21 { key: 'global', limit: THROTTLE_LIMITS.global },22 { key: `ip:${k.ip}`, limit: THROTTLE_LIMITS.ip },23 ];24 if (k.username) out.push({ key: `user:${k.username.toLowerCase().slice(0, 64)}`, limit: THROTTLE_LIMITS.user });25 return out;26}2728/** Seconds the caller must wait (0 = allowed). */29export async function throttleCheck(db: Queryable, k: ThrottleKeys): Promise<number> {30 let wait = 0;31 for (const { key, limit } of keysOf(k)) {32 const row = await db.one<{ failures: number; last_age: number | null }>(33 `SELECT count(*)::int AS failures, extract(epoch FROM now() - max(at))::float8 AS last_age34 FROM login_attempts35 WHERE key = $1 AND NOT ok AND at > now() - make_interval(secs => $2)36 AND at > coalesce((SELECT max(at) FROM login_attempts WHERE key = $1 AND ok), '-infinity')`,37 [key, WINDOW_SEC],38 );39 if (!row || row.failures < limit || row.last_age === null) continue;40 const lockSec = Math.min(60 * 2 ** Math.min(row.failures - limit, 10), WINDOW_SEC);41 wait = Math.max(wait, Math.ceil(lockSec - row.last_age));42 }43 return Math.max(0, wait);44}4546export async function throttleRecord(db: Queryable, k: ThrottleKeys, ok: boolean): Promise<void> {47 const keys = keysOf(k)48 .map((x) => x.key)49 .filter((key) => !ok || key !== 'global');50 await db.q('INSERT INTO login_attempts (key, ok) SELECT unnest($1::text[]), $2', [keys, ok]);51}52