← back to Govarbitrage

src/app/api/auth/login/route.ts

74 lines

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { verifyPassword, hashPassword } from "@/lib/password";
import { createSession, SESSION_COOKIE, SESSION_MAX_AGE } from "@/lib/session";
import { rateLimit, clientIp, tooManyRequests } from "@/lib/rate-limit";

export const dynamic = "force-dynamic";

// A well-formed dummy hash so verifyPassword runs the full scrypt path even
// when the user doesn't exist — keeps login timing uniform (no user-enumeration
// oracle). Computed once at module load.
const DUMMY_HASH = hashPassword("invalid-password-placeholder");

// Username-only login (Steve 2026-07-22: "no more email login"). The username
// is resolved against the unique user whose email local-part matches
// ("admin" → admin@govarbitrage.local). Full emails are rejected outright.
const Schema = z.object({ username: z.string().trim().min(1), password: z.string().min(1) });

async function resolveUser(username: string) {
  const id = username.toLowerCase();
  if (id.includes("@")) return null; // email-style logins are retired
  const matches = await prisma.user.findMany({
    where: { email: { startsWith: `${id}@` } },
    take: 2,
  });
  return matches.length === 1 ? matches[0] : null;
}

export async function POST(req: NextRequest) {
  // Brute-force protection: cap login attempts per IP.
  const ip = clientIp(req);
  const ipLimit = rateLimit(`login:ip:${ip}`, 10, 5 * 60_000);
  if (!ipLimit.allowed) return tooManyRequests(ipLimit);

  const parsed = Schema.safeParse(await req.json().catch(() => ({})));
  if (!parsed.success) {
    return NextResponse.json({ error: "Username and password required" }, { status: 400 });
  }
  const { username, password } = parsed.data;

  // And per targeted username (credential-stuffing protection).
  const userLimit = rateLimit(`login:user:${username.toLowerCase()}`, 5, 15 * 60_000);
  if (!userLimit.allowed) return tooManyRequests(userLimit);
  const user = await resolveUser(username);

  // Always run a full scrypt verification (against a dummy hash when the user
  // is absent) so response timing doesn't reveal whether the account exists.
  const ok = verifyPassword(password, user ? user.passwordHash : DUMMY_HASH);
  if (!user || !ok) {
    await prisma.auditLog.create({
      data: { action: "auth.login.failed", entity: "User", meta: { username } },
    });
    return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
  }

  const token = await createSession({ sub: user.id, email: user.email, role: user.role });
  await prisma.auditLog.create({
    data: { userId: user.id, action: "auth.login", entity: "User", entityId: user.id },
  });

  const res = NextResponse.json({
    user: { id: user.id, email: user.email, name: user.name, role: user.role },
  });
  res.cookies.set(SESSION_COOKIE, token, {
    httpOnly: true,
    sameSite: "lax",
    secure: process.env.NODE_ENV === "production",
    path: "/",
    maxAge: SESSION_MAX_AGE,
  });
  return res;
}