← back to Grant
app/api/auth/login/route.ts
181 lines
// Hardened login route shared across Patty / Grant / Hub / Freddy.
// Was: plaintext compare of submitted password against
// const expectedPassword = process.env.AUTH_PASSWORD ?? 'DWSecure2024!'
// — no rate-limit, hard-coded fallback in prod, timing-leaky strcmp.
//
// Now:
// 1. Refuses to boot under NODE_ENV=production if AUTH_USERNAME/PASSWORD env
// are missing (process.exit(2) on first import).
// 2. Scrypt-hashes the password once at module load + scrubs the plaintext
// from process.env; subsequent compares use crypto.timingSafeEqual.
// 3. Rate-limits POSTs per IP: 10 fails / 15 min → 30 min lockout, returning
// 429 with Retry-After.
//
// Date hardened: 2026-05-21
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
import { createSession, buildAuthCookie } from '@/lib/auth';
import { GUEST_USERNAMES, GUEST_PASSWORD_ENV } from '@/lib/accounts';
import {
checkLoginAttempt,
recordLoginFailure,
clearLoginCounter,
loginClientIp,
} from '@/lib/rate-limit';
// ── module-load credential setup ──────────────────────────────────────────
const SCRYPT_N = 16384;
const SCRYPT_KEYLEN = 64;
function deriveHash(plaintext: string, salt: Buffer): Buffer {
return crypto.scryptSync(plaintext, salt, SCRYPT_KEYLEN, { N: SCRYPT_N });
}
interface CredentialRecord {
username: string;
salt: Buffer;
hash: Buffer;
}
function makeRecord(username: string, password: string): CredentialRecord {
const salt = crypto.randomBytes(16);
return { username, salt, hash: deriveHash(password, salt) };
}
/**
* Load every valid login credential at module load:
* - the admin account (AUTH_USERNAME/AUTH_PASSWORD), and
* - any guest tier (guest/viewer/demo) whose password env var is set.
*
* A guest tier with no password env is simply not a login — it never enters
* the list, so it cannot authenticate. Capabilities for each username live in
* lib/accounts.ts and are enforced in middleware.
*/
function loadCredentialList(): CredentialRecord[] {
const isProd = process.env.NODE_ENV === 'production';
const usernameRaw = process.env.AUTH_USERNAME;
const passwordRaw = process.env.AUTH_PASSWORD;
if (isProd && (!usernameRaw || !passwordRaw)) {
const missing = [
!usernameRaw ? ' - AUTH_USERNAME' : null,
!passwordRaw ? ' - AUTH_PASSWORD' : null,
].filter(Boolean).join('\n');
console.error(
`\n[auth] REFUSING TO BOOT in NODE_ENV=production with missing login credentials:\n${missing}\n\n` +
`Set them in .env.local on prod and restart. Silent fallback to a hard-coded\n` +
`default password ('DWSecure2024!') in production is not allowed.\n`,
);
process.exit(2);
}
const username = usernameRaw || 'admin';
const password = passwordRaw || 'DWSecure2024!';
if (!passwordRaw) {
console.warn(`[auth] dev fallback: AUTH_PASSWORD not set, using default`);
}
const records: CredentialRecord[] = [makeRecord(username, password)];
// Guest tiers — only when their password env is present.
for (const guest of GUEST_USERNAMES) {
const pw = process.env[GUEST_PASSWORD_ENV[guest]];
if (pw && pw.length > 0) {
// A guest username must never collide with the admin username.
if (guest === username) {
console.warn(`[auth] guest tier "${guest}" collides with AUTH_USERNAME; skipping`);
continue;
}
records.push(makeRecord(guest, pw));
}
}
console.log(`[auth] loaded ${records.length} login account(s): ${records.map(r => r.username).join(', ')}`);
// 2026-05-30 (audit P1-1): do NOT delete process.env.AUTH_PASSWORD here
// (lib/sister-auth.ts reads it at module-load). The env is readable from
// process start, so scrubbing buys almost nothing.
return records;
}
const CREDS = loadCredentialList();
// Constant-time string compare that doesn't early-return on length.
function ctEqualString(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) {
crypto.timingSafeEqual(ab, ab); // burn equivalent cycles
return false;
}
return crypto.timingSafeEqual(ab, bb);
}
/**
* Verify a submitted username+password against the credential list.
* Hashes the password once per record regardless of match so timing does not
* leak which usernames exist. Returns the matched username, or null.
*/
function verifyCredentials(username: string, password: string): string | null {
let matched: string | null = null;
for (const rec of CREDS) {
const candidate = deriveHash(password, rec.salt);
const userOk = ctEqualString(username, rec.username);
const passOk = crypto.timingSafeEqual(candidate, rec.hash);
if (userOk && passOk) matched = rec.username;
}
return matched;
}
// ── POST /api/auth/login ──────────────────────────────────────────────────
function invalidCredentials(ip: string): NextResponse {
recordLoginFailure(ip);
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
export async function POST(request: NextRequest) {
const ip = loginClientIp(request);
// 1) Rate-limit gate
const gate = checkLoginAttempt(ip);
if (gate.locked) {
return NextResponse.json(
{ error: 'too many attempts', retryAfter: gate.retryAfter },
{ status: 429, headers: { 'Retry-After': String(gate.retryAfter) } },
);
}
// 2) Body validation
let body: { username?: string; password?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const { username, password } = body;
if (!username || !password) {
// Missing-field is a 400, not a counter-incrementing 401.
return NextResponse.json(
{ error: 'Username and password are required' },
{ status: 400 },
);
}
// 3) Constant-time credential check against all login accounts
const matchedUsername = verifyCredentials(username, password);
if (!matchedUsername) return invalidCredentials(ip);
// 4) Success — clear counter, mint session for the matched account
clearLoginCounter(ip);
try {
const token = createSession(matchedUsername);
const cookieValue = buildAuthCookie(token);
const response = NextResponse.json({ success: true });
response.headers.set('Set-Cookie', cookieValue);
return response;
} catch (err) {
console.error('[auth/login] Error issuing session:', (err as Error).message);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}