// Per-session cooldown for FAILED credential logins against iDempiere // `auth/tokens`. A dead session (stale cookies) polled by an open tab used to // hammer a credential login on every request/poll tick — thousands of // AuthFailure.log entries. This ledger throttles retries with exponential // backoff so at most one real login attempt goes out per backoff window. // // Per-process Map — PM2 runs 2 cluster instances, so the effective allowed // rate doubles (same accepted caveat as notificationBus/chatPresence). // Only the credential FALLBACK is throttled; the interactive login route is // deliberately not (users mistyping a password must not get locked out here — // iDempiere has its own lockout). import { getRequestIP } from 'h3' type CooldownEntry = { fails: number; nextAt: number; lastAt: number } const attempts = new Map() const BASE_MS = 30_000 // 1st failure → 30s const MAX_MS = 30 * 60_000 // cap: 30 min (30s, 1m, 2m, 4m, 8m, 16m, 30m, …) const ENTRY_TTL = 60 * 60_000 // forget a key 1h after its last failure const SWEEP_THRESHOLD = 500 // lazy cleanup to bound memory export function cooldownKey(event: any): string { // logship_session is set at login and survives refreshes; it can be absent // (cleared cookies, direct API hits) → fall back to the base64 username // (stable per user), then to client IP. return getCookie(event, 'logship_session') || getCookie(event, 'logship_xu') || 'ip:' + (getRequestIP(event, { xForwardedFor: true }) ?? 'unknown') } export function assertNotCoolingDown(key: string): void { sweep() const entry = attempts.get(key) if (entry && Date.now() < entry.nextAt) { throw createError({ statusCode: 429, statusMessage: 'Credential login suppressed', data: { status: 429, message: `Credential login suppressed after ${entry.fails} failed attempt(s); retry in ${Math.ceil((entry.nextAt - Date.now()) / 1000)}s` } }) } } export function recordFailure(key: string): void { const entry = attempts.get(key) ?? { fails: 0, nextAt: 0, lastAt: 0 } entry.fails += 1 entry.lastAt = Date.now() entry.nextAt = entry.lastAt + Math.min(BASE_MS * 2 ** (entry.fails - 1), MAX_MS) attempts.set(key, entry) } export function recordSuccess(key: string): void { attempts.delete(key) } function sweep(): void { if (attempts.size < SWEEP_THRESHOLD) return const now = Date.now() for (const [key, entry] of attempts) { if (now - entry.lastAt > ENTRY_TTL) attempts.delete(key) } }