import { eq } from "drizzle-orm"; import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; import { env } from "@/env"; import { getCachedJwtVersion } from "@/lib/auth/jwt-version-cache"; import { getLoginUser, invalidateLoginCache, isEmailUnverified, normalizeLoginInput, runDummyHashCheck, verifyLoginPassword, } from "@/lib/auth/login-core"; export { invalidateLoginCache }; import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter"; import { verifyTotp } from "@/lib/auth/totp"; import { db, User, WebsiteLoginLogs } from "@/lib/db"; import { logger } from "@/lib/logger"; import { clientIp, rateLimit } from "@/lib/rate-limit"; async function verify2faCode(userId: number, code: string): Promise { const [user] = await db .select({ twoFactorSecret: User.twoFactorSecret, twoFactorRecoveryCodes: User.twoFactorRecoveryCodes, }) .from(User) .where(eq(User.id, userId)) .limit(1); if (!user?.twoFactorSecret) return false; // Try TOTP first try { const appKey = env.APP_KEY; if (!appKey) throw new Error("APP_KEY not configured"); const secret = new LaravelEncrypter(appKey).decrypt(user.twoFactorSecret); if (verifyTotp(code, secret)) return true; } catch { logger.warn( "2FA TOTP verification failed, falling through to recovery codes", ); } // Try recovery codes if (user.twoFactorRecoveryCodes) { let codes: string[]; try { codes = JSON.parse(user.twoFactorRecoveryCodes) as string[]; } catch { logger.warn("Failed to parse 2FA recovery codes JSON"); return false; } const idx = codes.indexOf(code); if (idx !== -1) { codes.splice(idx, 1); const remaining = codes.length > 0 ? JSON.stringify(codes) : null; await db .update(User) .set({ twoFactorRecoveryCodes: remaining }) .where(eq(User.id, userId)); return true; } } return false; } export const { handlers, signOut, auth } = NextAuth({ trustHost: true, secret: env.AUTH_SECRET, session: { strategy: "jwt", maxAge: 24 * 60 * 60 }, pages: { signIn: "/login", error: "/login" }, logger: { error(error) { logger.error("NextAuth error", { error: error.message, stack: error.stack, }); }, }, providers: [ Credentials({ credentials: { username: { label: "Username", type: "text" }, password: { label: "Password", type: "password" }, code: { label: "2FA code", type: "text" }, }, authorize: async (credentials) => { const { username, password } = normalizeLoginInput( credentials?.username, credentials?.password, ); if (!username || !password) return null; const ip = await clientIp(); // Throttle login attempts per IP (10 per 5 min) against credential stuffing. if (!(await rateLimit(`login:${ip}`, 10, 5 * 60_000)).ok) return null; const user = await getLoginUser(username); if (!user) { // Prevent timing-based enumeration: always run a dummy hash check. await runDummyHashCheck(password); return null; } const res = await verifyLoginPassword(user, password); if (!res.valid) return null; if (await isEmailUnverified(user)) { return null; } if (res.upgradedHash) { await db .update(User) .set({ password: res.upgradedHash }) .where(eq(User.id, user.id)); invalidateLoginCache(username); } // Two-factor: if enabled, a valid TOTP or recovery code is required. if (user.twoFactorConfirmedAt && user.twoFactorSecret) { const code = String(credentials?.code ?? "").trim(); if (!code || !env.APP_KEY) return null; // Per-user 2FA rate limit (5 attempts per 30s) — prevents TOTP brute-force // even when the attacker rotates IPs or knows the password. if (!(await rateLimit(`2fa:${user.id}`, 5, 30_000)).ok) return null; if (!(await verify2faCode(user.id, code))) return null; } // Record the successful login for the user's "session logs" page. // Best-effort — never let logging block or fail the sign-in. try { const { headers } = await import("next/headers"); const ua = (await headers()).get("user-agent")?.slice(0, 512) ?? null; await db.insert(WebsiteLoginLogs).values({ userId: user.id, ip, userAgent: ua, createdAt: new Date(), }); } catch { logger.warn("Failed to record login log for user", { userId: user.id, }); } const jwtVersion = await getCachedJwtVersion(user.id); return { id: String(user.id), name: user.username, rank: user.rank, jwtVersion, }; }, }), ], callbacks: { async jwt({ token, user, account }) { if (user) { token.jwtVersion = (user as { jwtVersion?: number }).jwtVersion ?? token.jwtVersion ?? 0; token.jwtCheckedAt = Date.now(); } if (user && account?.provider === "credentials") { token.rank = (user as { rank?: number }).rank; token.sub = String((user as { id?: string }).id); return token; } // Re-check jwt version at most once per minute (memory/Redis cached). if (token.sub && !token.invalid) { const lastCheck = typeof token.jwtCheckedAt === "number" ? token.jwtCheckedAt : 0; if (Date.now() - lastCheck >= 60_000) { try { const version = await getCachedJwtVersion(Number(token.sub)); if (version === null || (token.jwtVersion ?? 0) !== version) { token.invalid = true; delete token.sub; return token; } token.jwtCheckedAt = Date.now(); } catch { logger.warn("JWT version check failed, keeping session"); } } } return token; }, session({ session, token }) { if (token.invalid || !token.sub) { return session; } if (session.user) session.user.id = token.sub; if (typeof token.rank === "number" && session.user) session.user.rank = token.rank; return session; }, }, });