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