import { eq, sql } 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 { LaravelEncrypter } from "@/lib/auth/laravel-encrypter"; import { checkLogin } from "@/lib/auth/password"; import { verifyTotp } from "@/lib/auth/totp"; import { cachedQuery, invalidateKey } from "@/lib/cached-db"; import { db, User, WebsiteLoginLogs } from "@/lib/db"; import { logger } from "@/lib/logger"; import { clientIp, rateLimit } from "@/lib/rate-limit"; import { siteSettings } from "@/lib/services/site-settings"; interface LoginUser { id: number; username: string; password: string | null; rank: number; mail: string | null; mailVerified: string | null; twoFactorConfirmedAt: string | null; twoFactorSecret: string | null; } /** * Cached login user lookup — short TTL to survive brute-force attempts * while still reflecting recent password/account changes reasonably fast. */ async function getLoginUser(username: string): Promise { return cachedQuery( `login:user:${username}`, async () => { const [result] = await db.execute<{ id: number; username: string; password: string | null; rank: number; mail: string | null; mail_verified: string | null; two_factor_confirmed_at: string | null; two_factor_secret: string | null; }>(sql` SELECT id, username, password, rank, mail, mail_verified, two_factor_confirmed_at, two_factor_secret FROM users WHERE username = ${username} LIMIT 1 `); const rows = result as unknown as Array<{ id: number; username: string; password: string | null; rank: number; mail: string | null; mail_verified: string | null; two_factor_confirmed_at: string | null; two_factor_secret: string | null; }>; return rows.length > 0 ? { id: rows[0].id, username: rows[0].username, password: rows[0].password, rank: rows[0].rank, mail: rows[0].mail, mailVerified: rows[0].mail_verified, twoFactorConfirmedAt: rows[0].two_factor_confirmed_at, twoFactorSecret: rows[0].two_factor_secret, } : null; }, 15, // 15s TTL — brute-force protection without blocking legit changes ); } /** Call after password reset / rank change to invalidate the cached login row. */ export async function invalidateLoginCache(username: string): Promise { await invalidateKey(`login:user:${username}`); } 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 = String(credentials?.username ?? "").trim(); const password = String(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 checkLogin( password, "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd", { convertPasswords: false, }, ); return null; } // Byte-compatible AtomCMS check (argon2id + legacy md5/bcrypt upgrade). if (!user.password) return null; const res = await checkLogin(password, user.password, { convertPasswords: env.CONVERT_PASSWORDS, }); if (!res.valid) return null; if ( (await siteSettings.getBool("require_email_verification", false)) && user.mail && user.mailVerified !== "1" ) { 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; }, }, });