"use server"; import { env } from "@/env"; import { checkLogin } from "@/lib/auth/password"; import { prisma } from "@/lib/prisma"; import { clientIp, rateLimit } from "@/lib/rate-limit"; import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha"; import { siteSettings } from "@/lib/services/site-settings"; export type PrecheckResult = | "ok" | "invalid" | "twofactor" | "unverified" | "captcha"; /** * Validates username+password WITHOUT creating a session, and reports whether a * TOTP code is still required. Lets the login form do the two-step 2FA flow. * Also enforces captcha + optional email-verification when configured. */ export async function precheckLogin( username: string, password: string, captchaToken?: string | null, ): Promise { const u = String(username ?? "") .normalize("NFC") .trim(); const p = String(password ?? ""); if (!u || !p) return "invalid"; const ip = await clientIp(); if (!(await rateLimit(`precheck:${ip}`, 10, 5 * 60_000)).ok) return "invalid"; const cfg = await captchaConfig(); if (cfg.provider !== "none") { if (!(await verifyCaptcha(captchaToken ?? null, ip))) return "captcha"; } let user: { password: string; twoFactorConfirmedAt: Date | null; mail: string | null; mailVerified: string; } | null; try { user = await prisma.user.findUnique({ where: { username: u }, select: { password: true, twoFactorConfirmedAt: true, mail: true, mailVerified: true, }, }); } catch { return "invalid"; } if (!user) { // Prevent timing-based enumeration: always run a dummy hash check. await checkLogin( p, "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd", { convertPasswords: false, }, ); return "invalid"; } const res = await checkLogin(p, user.password, { convertPasswords: env.CONVERT_PASSWORDS, }); if (!res.valid) return "invalid"; if ( (await siteSettings.getBool("require_email_verification", false)) && user.mail && user.mailVerified !== "1" ) { return "unverified"; } return user.twoFactorConfirmedAt ? "twofactor" : "ok"; }