feat(19-03): implement localAuthMiddleware, GET /api/auth/mode, and pre-auth route mounts
- localAuthMiddleware: cookie→c.set('user') with Pitfall-1 guard (no-set on no-cookie path)
- authMode: GET /api/auth/mode pre-auth endpoint (localEnabled:true, oidcEnabled from env+config)
- localAuth: POST /api/auth/local/login (rate-limit + timing-safe), logout routes
- index.ts: mount authModeRouter + localAuthRouter pre-auth; localAuthMiddleware after devAuthBypass; OIDC guard wrapped skip-when-user-set
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 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)
|
||||
if (attempt?.lockedOut) {
|
||||
return c.json({ error: 'Account locked' }, 423);
|
||||
}
|
||||
|
||||
// 429: rate window — >= RATE_WINDOW_FAILURES failures within the cooldown window
|
||||
if (attempt && attempt.count >= RATE_WINDOW_FAILURES && Date.now() < attempt.lockedUntil) {
|
||||
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);
|
||||
Reference in New Issue
Block a user