/** * localAuth.ts — local authentication routes (AUTH-LOCAL-03, AUTH-LOCAL-06). * * Routes: * POST /api/auth/local/login — rate-limited credential verify + session cookie issue * POST /api/auth/local/logout — clear local-session cookie * GET /api/auth/local/logout — alias for POST /logout (some browsers prefer GET) * * Mounted in index.ts as app.route('/api/auth', localAuthRouter) BEFORE any auth middleware. * This means POST /api/auth/local/login is reachable without a session (pre-auth, per D-01). * * Security (T-19-11, T-19-12, T-19-14): * - noEchoHook on login: Zod errors NEVER return received values (T-19-14 / Pitfall 7). * - Dummy-hash timing defense: verifyPassword is always called, even for unknown usernames, * to prevent timing-oracle username enumeration attacks (T-19-12 / RESEARCH Pitfall 2). * - Same 401 body for wrong password AND unknown username — no field discrimination (T-19-12). * - Per-USERNAME in-memory rate-limiting: 5 failures → 429; 10 failures → 423 (T-19-11). * - Lockout (423) auto-expires after LOCKOUT_TTL_MS (CR-04) so a single attacker cannot * permanently deny login for the whole household, and no process restart is needed to * recover. Admin password reset still clears it immediately (resetLoginAttempts). * * CR-04: the limiter is keyed on the submitted username, NOT the client IP. In this * deployment all household traffic egresses the Pangolin tunnel with the same * X-Forwarded-For first hop, so IP-keying made one bad actor (or one fat-fingered user) * able to lock out every member, and X-Forwarded-For is attacker-spoofable. Username-keying * scopes the lockout to the identity actually under attack and the TTL makes it self-healing. */ import { Hono } from 'hono'; import type { Context } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { eq } from 'drizzle-orm'; import { db } from '../db/client.js'; import { localCredentials } from '../db/schema.js'; import { verifyPassword, hashPassword } from '../auth/localCredentials.js'; import { issueLocalSessionCookie, clearLocalSessionCookie } from '../auth/localSession.js'; export const localAuthRouter = new Hono(); // --------------------------------------------------------------------------- // noEchoHook — NEVER return Zod validation error details for the login route. // Zod's error object contains issues[].received which may echo the submitted password. // Always return { error: 'Invalid request' } 400, no other fields (T-19-14). // --------------------------------------------------------------------------- const noEchoHook = (result: { success: boolean }, c: Context) => { if (!result.success) { return c.json({ error: 'Invalid request' }, 400); } }; // --------------------------------------------------------------------------- // Zod schema for login body // --------------------------------------------------------------------------- const loginSchema = z.object({ username: z.string().min(1).max(128).trim(), password: z.string().min(1).max(1000), }); // --------------------------------------------------------------------------- // Per-USERNAME rate-limiting state (in-memory Map — household scale, no Redis needed). // // State shape per username: { count, lockedUntil (epoch ms), lockedOut (bool), lockedAt (epoch ms) } // count >= RATE_WINDOW_FAILURES AND Date.now() < lockedUntil → 429 // count >= LOCKOUT_FAILURES → 423 (until LOCKOUT_TTL_MS elapses OR admin reset) // Success → delete entry (clears counter) // // RATE_WINDOW_FAILURES: 5 failures → 60s cooldown (429) // LOCKOUT_FAILURES: 10 failures → account locked (423) // LOCKOUT_TTL_MS: 15 min after which a 423 lockout auto-expires (CR-04) // // CR-04: keyed on the validated username (not the client IP) so a lockout is scoped to // the identity under attack, and self-heals after LOCKOUT_TTL_MS without a restart. // --------------------------------------------------------------------------- export const loginAttempts = new Map< string, { count: number; lockedUntil: number; lockedOut: boolean; lockedAt: number } >(); const RATE_WINDOW_FAILURES = 5; const RATE_WINDOW_SECS = 60; const LOCKOUT_FAILURES = 10; const LOCKOUT_TTL_MS = 15 * 60 * 1000; // CR-04: 423 lockout auto-expires after 15 minutes /** * Immediately clear any rate-limit / lockout state for a username (CR-04). * * Called by the admin password-reset path so a reset is an instant unlock and the * lockout is not "unrecoverable without a process restart". Safe to call for an * unknown username (no-op). Normalizes the username the same way the login schema * does (trim) so the key matches what the limiter stored. */ export function resetLoginAttempts(username: string): void { loginAttempts.delete(username.trim()); } /** * IN-03: evict stale rate-limit entries to bound the in-memory map size. * * An entry is stale (and safe to drop) when it is neither inside an active rate-limit * window nor inside an active lockout TTL: * - locked-out entries expire LOCKOUT_TTL_MS after lockedAt * - non-locked entries expire once their lockedUntil window has passed * * Called opportunistically at the top of each login request. Dropping an entry is * behaviourally identical to the entry having naturally expired, so eviction never * weakens the brute-force defense — it only reclaims memory for identities no longer * under active rate-limiting. */ function evictStaleLoginAttempts(now: number): void { for (const [k, v] of loginAttempts) { const lockoutExpired = v.lockedOut ? now - v.lockedAt >= LOCKOUT_TTL_MS : true; const windowExpired = now >= v.lockedUntil; if (lockoutExpired && windowExpired) { loginAttempts.delete(k); } } } // Pre-computed dummy hash used to run verifyPassword on unknown-username paths // (timing defense — prevents timing-oracle username enumeration, RESEARCH Pitfall 2). // WR-03: hashPassword is now async. Kick off the computation once at module load and keep // the PROMISE; the login handler awaits it. The value is never used for auth — only to make // the unknown-username path perform the same scrypt work as the known-username path. const dummyHashPromise: Promise = hashPassword('dummy-constant-time-filler-xyzzy'); // --------------------------------------------------------------------------- // POST /local/login → POST /api/auth/local/login // --------------------------------------------------------------------------- localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook), async (c) => { // CR-04: the rate-limit / lockout key is the validated, normalized username — NOT the // client IP. zValidator has already run, so c.req.valid('json') is available here. const { username, password } = c.req.valid('json'); const key = username; // loginSchema .trim()s the username; the map key matches resetLoginAttempts const now = Date.now(); // IN-03: opportunistically reclaim memory from entries that are no longer actively // rate-limited or locked out. Cheap at household scale; bounds the map under input churn. evictStaleLoginAttempts(now); const attempt = loginAttempts.get(key); // 423: account locked (>= LOCKOUT_FAILURES failures). CR-04: the lockout auto-expires // after LOCKOUT_TTL_MS so a single attacker cannot deny login indefinitely and no restart // is required to recover. On expiry, drop the entry so the next attempt starts clean. if (attempt?.lockedOut) { if (now - attempt.lockedAt >= LOCKOUT_TTL_MS) { loginAttempts.delete(key); } else { return c.json({ error: 'Account locked' }, 423); } } // Re-read after a possible expiry-delete above. const live = loginAttempts.get(key); // 429: rate window — >= RATE_WINDOW_FAILURES failures within the cooldown window. // Increment the counter even on 429 so continued brute-force accumulates toward lockout. if (live && live.count >= RATE_WINDOW_FAILURES && now < live.lockedUntil) { live.count += 1; // WR-06: do NOT extend lockedUntil here. This request was itself REJECTED by the window; // re-arming the cooldown on every blocked attempt let an attacker who keeps hammering the // endpoint slide the window forward forever, so a legitimate user behind the same identity // could never get back in even after pausing. The window stays anchored to when it was // first armed (in the failure path below); it expires on schedule regardless of rejected // traffic. The lockout (423) still triggers once the failure count crosses the threshold. live.lockedOut = live.count >= LOCKOUT_FAILURES; if (live.lockedOut && live.lockedAt === 0) live.lockedAt = now; loginAttempts.set(key, live); if (live.lockedOut) { return c.json({ error: 'Account locked' }, 423); } return c.json({ error: 'Too many attempts' }, 429); } // Look up local_credentials by username let cred: { userId: number; passwordHash: string } | undefined; try { const [found] = await db .select({ userId: localCredentials.userId, passwordHash: localCredentials.passwordHash }) .from(localCredentials) .where(eq(localCredentials.username, username)) .limit(1); cred = found; } catch (err) { console.error( '[localAuth/POST /local/login] DB error:', err instanceof Error ? err.message : String(err), ); return c.json({ error: 'Service unavailable' }, 503); } // ALWAYS run verifyPassword — even for unknown usernames — to prevent timing-oracle // username enumeration (T-19-12 / RESEARCH Pitfall 2). Use a pre-computed dummy hash // so the scrypt work is always performed regardless of whether username was found. // WR-03: verifyPassword is async (threadpool scrypt) — await it. const valid = cred ? await verifyPassword(cred.passwordHash, password) : await verifyPassword(await dummyHashPromise, password); if (!valid || !cred) { // Increment failure counter (keyed on username) const cur = loginAttempts.get(key) ?? { count: 0, lockedUntil: 0, lockedOut: false, lockedAt: 0, }; cur.count += 1; cur.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000; cur.lockedOut = cur.count >= LOCKOUT_FAILURES; if (cur.lockedOut && cur.lockedAt === 0) cur.lockedAt = Date.now(); loginAttempts.set(key, cur); // Same 401 body for wrong password AND unknown username — no field discrimination (T-19-12) return c.json({ error: 'Invalid credentials' }, 401); } // Success: clear failure counter, issue the local-session JWT cookie, return ok loginAttempts.delete(key); try { await issueLocalSessionCookie(c, cred.userId); } catch (err) { console.error( '[localAuth/POST /local/login] Cookie issue error:', err instanceof Error ? err.message : String(err), ); return c.json({ error: 'Service unavailable' }, 503); } return c.json({ ok: true }, 200); }); // --------------------------------------------------------------------------- // POST /local/logout → POST /api/auth/local/logout // GET /local/logout → GET /api/auth/local/logout (browser-redirect alias) // --------------------------------------------------------------------------- function handleLogout(c: Context) { clearLocalSessionCookie(c); return c.json({ ok: true }, 200); } localAuthRouter.post('/local/logout', handleLogout); localAuthRouter.get('/local/logout', handleLogout);