Files
EpicNext-Cms/src/lib/auth.ts
T
2026-07-11 20:52:56 +02:00

222 lines
7.9 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 { 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<boolean> {
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;
},
},
});