← back to Hub

app/api/auth/login/route.ts

71 lines

// Login route — admin + guest-tier logins via the shared capability registry.
//
// Credentials + capabilities live in lib/accounts (createCapabilityRegistry):
// the admin account plus any guest tier (guest/viewer/demo) whose password env
// is set. registry.verify is a constant-time scrypt multi-credential check.
// Rate-limits POSTs per IP: 10 fails / 15 min → 30 min lockout (429 + Retry-After).
//
// Capability enforcement (read-only / no-AI guests) is authoritative in
// middleware.ts via capabilityGate.

import { NextRequest, NextResponse } from 'next/server';
import { createSession, buildAuthCookie } from '@/lib/auth';
import { registry } from '@/lib/accounts';
import {
  checkLoginAttempt,
  recordLoginFailure,
  clearLoginCounter,
  loginClientIp,
} from '@/lib/rate-limit';

// ── 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) {
    return NextResponse.json(
      { error: 'Username and password are required' },
      { status: 400 },
    );
  }

  // 3) Constant-time multi-credential check (admin + active guest tiers)
  const matchedUsername = registry.verify(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 });
  }
}