- Add LICENSE file (CC BY-NC-SA 4.0) - Add .nvmrc pinning Node 22 - Add Renovate config with daily schedule and Gitea Actions workflow - Reduce ESLint max-warnings from 1000 to 50 - Re-enable Biome a11y/security recommended rules - Fix Biome lint issues (a11y, hook deps, SVG labels, checkbox semantics) - Improve CI: run on pushes to feat/fix branches, add pnpm audit - Add Vitest coverage with v8 provider and thresholds - Add E2E tests (auth, admin, navigation specs) - Add admin-maintenance server action test - Install @vitest/coverage-v8 - Ignore coverage/ directory
206 lines
5.9 KiB
TypeScript
206 lines
5.9 KiB
TypeScript
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 { logger } from "@/lib/logger";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
|
import { siteSettings } from "@/lib/services/site-settings";
|
|
|
|
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 {
|
|
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 prisma.user.update({
|
|
where: { id: userId },
|
|
data: { twoFactorRecoveryCodes: remaining },
|
|
});
|
|
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 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 (
|
|
(await siteSettings.getBool("require_email_verification", false)) &&
|
|
user.mail &&
|
|
user.mailVerified !== "1"
|
|
) {
|
|
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,
|
|
userAgent: ua,
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
} catch {
|
|
logger.warn("Failed to record login log for user", {
|
|
userId: user.id,
|
|
});
|
|
}
|
|
|
|
return {
|
|
id: String(user.id),
|
|
name: user.username,
|
|
rank: user.rank,
|
|
jwtVersion: user.websiteJwtVersion,
|
|
};
|
|
},
|
|
}),
|
|
],
|
|
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;
|
|
},
|
|
},
|
|
});
|