Files
EpicNext-Cms/src/lib/auth.ts
T
Simo e668fa85ec Add 2FA, email + password reset, and batch-7 pages
Auth (hand-built on the auth core):
- 2FA: User model gains two_factor_secret/recovery_codes/confirmed_at (+ idempotent
  MariaDB migration). authorize() requires a valid TOTP code when 2FA is confirmed
  (secret decrypted via Laravel APP_KEY, fail-closed). Two-step login (precheckLogin
  reveals the code field). /settings/2fa enable/confirm/disable flow.
- Password reset: nodemailer email service; PasswordReset model + migration;
  /forgot (request, generic response) + /reset (token sha256 + 1h TTL, sets argon2id
  hash). Login links to forgot.

Batch 7 (parallel agents): /admin/commandocentrum (RCON controls + emulator_errors),
social write actions (friend request + guild forum new thread), /help/[category],
/badges (public). env: APP_KEY, APP_URL, SMTP_*. Nav extended.

Verified: tsc exit 0, vitest 48/48, next build exit 0 (64 page routes).
2026-06-28 14:25:19 +02:00

111 lines
4.0 KiB
TypeScript

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;
},
},
});