/** * 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-IP in-memory rate-limiting: 5 failures → 429; 10 failures → 423 (T-19-11). * - Lockout (423) cleared only by admin password reset — no self-service unlock. */ 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-IP rate-limiting state (in-memory Map — household scale, no Redis needed). // // State shape per IP: { count, lockedUntil (epoch ms), lockedOut (bool) } // count >= RATE_WINDOW_FAILURES AND Date.now() < lockedUntil → 429 // count >= LOCKOUT_FAILURES → 423 (permanent until admin reset) // Success → delete entry (clears counter) // // RATE_WINDOW_FAILURES: 5 failures → 60s cooldown (429) // LOCKOUT_FAILURES: 10 failures → account locked (423) // --------------------------------------------------------------------------- export const loginAttempts = new Map(); const RATE_WINDOW_FAILURES = 5; const RATE_WINDOW_SECS = 60; const LOCKOUT_FAILURES = 10; // Pre-computed dummy hash used to run verifyPassword on unknown-username paths // (timing defense — prevents timing-oracle username enumeration, RESEARCH Pitfall 2). // Computed once at module load time; the actual value is never used for auth. const DUMMY_HASH = hashPassword('dummy-constant-time-filler-xyzzy'); // --------------------------------------------------------------------------- // POST /local/login → POST /api/auth/local/login // --------------------------------------------------------------------------- localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook), async (c) => { // Derive client IP from Pangolin-set X-Forwarded-For header; fall back to host header. const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? c.req.raw.headers.get('host') ?? 'unknown'; const attempt = loginAttempts.get(ip); // 423: account locked (>= LOCKOUT_FAILURES total failures, admin must reset) // Check lockedOut FIRST — lockout takes precedence over rate window. if (attempt?.lockedOut) { return c.json({ error: 'Account locked' }, 423); } // 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 (attempt && attempt.count >= RATE_WINDOW_FAILURES && Date.now() < attempt.lockedUntil) { attempt.count += 1; attempt.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000; attempt.lockedOut = attempt.count >= LOCKOUT_FAILURES; loginAttempts.set(ip, attempt); if (attempt.lockedOut) { return c.json({ error: 'Account locked' }, 423); } return c.json({ error: 'Too many attempts' }, 429); } const { username, password } = c.req.valid('json'); // 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. const valid = cred ? verifyPassword(cred.passwordHash, password) : verifyPassword(DUMMY_HASH, password); if (!valid || !cred) { // Increment failure counter const cur = loginAttempts.get(ip) ?? { count: 0, lockedUntil: 0, lockedOut: false }; cur.count += 1; cur.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000; cur.lockedOut = cur.count >= LOCKOUT_FAILURES; loginAttempts.set(ip, 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(ip); 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) // --------------------------------------------------------------------------- async function handleLogout(c: Context) { clearLocalSessionCookie(c); return c.json({ ok: true }, 200); } localAuthRouter.post('/local/logout', handleLogout); localAuthRouter.get('/local/logout', handleLogout);