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 { env } from "@/env"; export const { handlers, signIn, signOut, auth } = NextAuth({ trustHost: true, 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; const user = await prisma.user.findUnique({ where: { username } }); if (!user) 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 code is required. The secret is // Laravel-encrypted with APP_KEY (fail closed if it cannot be read). if (user.twoFactorConfirmedAt && user.twoFactorSecret) { const code = String(credentials?.code ?? "").trim(); if (!code || !env.APP_KEY) return null; try { const secret = new LaravelEncrypter(env.APP_KEY).decrypt(user.twoFactorSecret); if (!verifyTotp(code, secret)) return null; } catch { return null; } } 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; // OAuth: only allow if a hotel account with this email already exists. const email = user.email; if (!email) return "/login?error=NoEmail"; try { const dbUser = await prisma.user.findFirst({ where: { mail: email }, select: { id: true }, }); return dbUser ? true : "/login?error=NoAccount"; } catch { return "/login?error=Unavailable"; } }, async jwt({ token, user, account }) { if (user && account?.provider === "credentials") { token.rank = (user as { rank?: number }).rank; } else if (user?.email) { // OAuth: bind the session to the matching hotel account. try { const dbUser = await prisma.user.findFirst({ where: { mail: user.email }, 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; }, }, });