- Rate-limit: per-IP in-memory Map; 5 failures → 429, 10 → 423 (lockedOut) - Counter increments even on 429 so brute-force accumulates toward lockout - Timing-safe: DUMMY_HASH ensures verifyPassword runs for unknown usernames (T-19-12) - noEchoHook: Zod errors never echo submitted values (T-19-14) - Same 401 body for wrong-password and unknown-username (no enumeration) - POST+GET /local/logout clear the local-session cookie
165 lines
7.3 KiB
TypeScript
165 lines
7.3 KiB
TypeScript
/**
|
|
* 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<string, { count: number; lockedUntil: number; lockedOut: boolean }>();
|
|
|
|
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);
|