"use server"; import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; import bcrypt from "bcryptjs"; import { sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { COOKIE_NAME, DEFAULT_TTL_SECONDS, signSession, type Role, } from "@/lib/auth-cookie"; import { checkRateLimit } from "@/lib/rate-limit"; import { safeRedirect } from "@/lib/safe-redirect"; import { logger } from "@/lib/logger"; export type LoginResult = { ok: true } | { ok: false; error: string }; const MAX_FIELD_LEN = 256; // Precomputed bcryptjs hash of the throwaway string "x", cost 10. // Compared against on the user-not-found path so timing matches the // wrong-password path. Generating fresh per request would double the // bcrypt work and create its own timing signal. const DUMMY_HASH = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"; async function clientIp(): Promise { const h = await headers(); const fwd = h.get("x-forwarded-for"); if (fwd) return fwd.split(",")[0]!.trim(); return h.get("x-real-ip") ?? "unknown"; } export async function loginAction(formData: FormData): Promise { const username = (formData.get("username") ?? "").toString(); const password = (formData.get("password") ?? "").toString(); const next = (formData.get("next") ?? "").toString(); if (!username.trim() || !password) { return { ok: false, error: "Username and password are required." }; } if (username.length > MAX_FIELD_LEN || password.length > MAX_FIELD_LEN) { return { ok: false, error: "Input too long." }; } const ip = await clientIp(); const rl = await checkRateLimit(`login:${ip}`, { max: 10, windowSec: 300 }); if (rl.limited) { return { ok: false, error: "Too many attempts. Try again later." }; } const row = await db.query.operators.findFirst({ where: (o) => sql`lower(${o.username}) = lower(${username})`, }); // User exists but has no password configured: this is a server-side // setup error, not a credential mismatch. Surface a distinct message // so the operator knows to run scripts/set-password.sh. We still ran // the DB lookup, so the username-enumeration concern is not relevant // here (the attacker would already need a known username). if (row && row.passwordHash === null) { return { ok: false, error: "Set a password via scripts/set-password.sh before signing in.", }; } // Run bcrypt regardless to keep the user-not-found path timing- // equivalent to the wrong-password path. const hash = row?.passwordHash ?? DUMMY_HASH; const ok = await bcrypt.compare(password, hash); if (!row || !ok) { logger.warn({ username, ip }, "login failed"); return { ok: false, error: "Invalid username or password." }; } if (row.role !== "admin" && row.role !== "user") { return { ok: false, error: "Account is not enabled." }; } const secret = process.env.AUTH_SECRET; if (!secret) { logger.warn({}, "AUTH_SECRET unset — cannot issue cookie"); return { ok: false, error: "Server is not configured for sign-in." }; } const v = Number(process.env.OPERATOR_TOKEN_VERSION ?? "1"); const now = Math.floor(Date.now() / 1000); const cookie = await signSession( { userId: row.id, role: row.role as Role, iat: now, exp: now + DEFAULT_TTL_SECONDS, v, }, secret, ); const jar = await cookies(); // Secure: only require https in production. In dev we hit // http://localhost:9000 directly, and Firefox/Safari silently drop // Set-Cookie when Secure is set on http origins (Chrome has a // localhost exception, others don't), which manifested as the // session cookie never being persisted across requests. jar.set(COOKIE_NAME, cookie, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: DEFAULT_TTL_SECONDS, }); // Typed-routes is on (next.config.ts experimental.typedRoutes); the // `next` value is a runtime string from the form so we cast through any. // eslint-disable-next-line @typescript-eslint/no-explicit-any redirect(safeRedirect(next) as any); } export async function logoutAction(): Promise { const jar = await cookies(); jar.delete(COOKIE_NAME); // eslint-disable-next-line @typescript-eslint/no-explicit-any redirect("/login" as any); }