import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; import Discord from "next-auth/providers/discord"; import Google from "next-auth/providers/google"; import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter"; import { checkLogin } from "@/lib/auth/password"; import { verifyTotp } from "@/lib/auth/totp"; import { prisma } from "@/lib/prisma"; import { clientIp, rateLimit } from "@/lib/rate-limit"; import { siteSettings } from "@/lib/services/site-settings"; import { env } from "@/env"; async function verify2faCode(userId: number, code: string): Promise { const user = await prisma.user.findUnique({ where: { id: userId }, select: { twoFactorSecret: true, twoFactorRecoveryCodes: true }, }); 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 { /* fall through to recovery */ } // Try recovery codes if (user.twoFactorRecoveryCodes) { let codes: string[]; try { codes = JSON.parse(user.twoFactorRecoveryCodes) as string[]; } catch { return false; } const idx = codes.indexOf(code); if (idx !== -1) { codes.splice(idx, 1); const remaining = codes.length > 0 ? JSON.stringify(codes) : null; await prisma.user.update({ where: { id: userId }, data: { twoFactorRecoveryCodes: remaining }, }); return true; } } return false; } export const { handlers, signIn, signOut, auth } = NextAuth({ trustHost: true, secret: process.env.AUTH_SECRET, session: { strategy: "jwt", maxAge: 24 * 60 * 60 }, pages: { signIn: "/login" }, 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; // Throttle login attempts per IP (10 per 5 min) against credential stuffing. if (!(await rateLimit(`login:${await clientIp()}`, 10, 5 * 60_000)).ok) return null; const user = await prisma.user.findUnique({ where: { 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/bcrypt + md5->argon2id upgrade). const res = await checkLogin(password, user.password, { convertPasswords: env.CONVERT_PASSWORDS, }); if (!res.valid) return null; if (res.upgradedHash) { await prisma.user.update({ where: { id: user.id }, data: { password: res.upgradedHash }, }); } // 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 prisma.websiteLoginLogs.create({ data: { userId: user.id, ip: await clientIp(), userAgent: ua, createdAt: new Date() }, }); } catch { /* ignore */ } return { id: String(user.id), name: user.username, rank: user.rank }; }, }), // OAuth providers — enabled only when both id + secret are configured. ...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET ? [Discord({ clientId: env.DISCORD_CLIENT_ID, clientSecret: env.DISCORD_CLIENT_SECRET })] : []), ...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET ? [Google({ clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET })] : []), ], callbacks: { async signIn({ user, account }) { if (account?.provider === "credentials") return true; const requireLink = await siteSettings.getBool("oauth_require_link", false); // Always allow explicitly linked accounts. if (account?.provider === "discord" && account.providerAccountId) { try { const linked = await prisma.socialAccounts.findUnique({ where: { provider_providerId: { provider: "discord", providerId: account.providerAccountId } }, select: { userId: true }, }); if (linked) return true; } catch { return "/login?error=Unavailable"; } } // Email-based binding: only allowed when oauth_require_link is disabled // AND the matched account does NOT have 2FA enabled (account takeover guard). if (!requireLink && user.email) { try { const dbUser = await prisma.user.findFirst({ where: { mail: user.email, twoFactorConfirmedAt: null }, select: { id: true }, }); if (dbUser) return true; } catch { return "/login?error=Unavailable"; } } return "/login?error=NoAccount"; }, async jwt({ token, user, account }) { if (user && account?.provider === "credentials") { token.rank = (user as { rank?: number }).rank; return token; } const requireLink = await siteSettings.getBool("oauth_require_link", false); // Try Discord ID via SocialAccounts (always allowed, even when requireLink is true). if (!token.sub && account?.provider === "discord" && account.providerAccountId) { try { const linked = await prisma.socialAccounts.findUnique({ where: { provider_providerId: { provider: "discord", providerId: account.providerAccountId } }, }); if (linked) { const dbUser = await prisma.user.findUnique({ where: { id: Number(linked.userId) }, select: { id: true, rank: true, username: true }, }); if (dbUser) { token.sub = String(dbUser.id); token.rank = dbUser.rank; token.name = dbUser.username; return token; } } } catch { // leave token as-is on lookup failure } } // Email-based binding: only when requireLink is off AND account has no 2FA. if (!requireLink && user?.email && !token.sub) { try { const dbUser = await prisma.user.findFirst({ where: { mail: user.email, twoFactorConfirmedAt: null }, select: { id: true, rank: true, username: true }, }); if (dbUser) { token.sub = String(dbUser.id); token.rank = dbUser.rank; token.name = dbUser.username; } } catch { // leave token as-is on lookup failure } } return token; }, session({ session, token }) { if (token.sub && session.user) session.user.id = token.sub; if (typeof token.rank === "number" && session.user) session.user.rank = token.rank; return session; }, }, });