← 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 });
  }
}