Files
EpicNext-Cms/src/lib/auth.ts
T
openhands 22d455da7a
CI / check (push) Successful in 34s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m42s
fix(auth): drop nonexistent account_blocked column from login lookup
getLoginUser selected users.account_blocked, which does not exist in the
DB (nor the Drizzle schema). Every credentials authorize() call threw a
SQL error -> NextAuth CallbackRouteError -> 'error=Configuration', so no
login could ever succeed. Remove the phantom column from the query and
LoginUser interface.

Also fix all remaining biome noNonNullAssertion / noExplicitAny lint
warnings so CI's check job (biome:lint) passes and the push deploy runs.
2026-08-01 17:38:43 +02:00

282 lines
8.0 KiB
TypeScript

import { eq, sql } from "drizzle-orm";
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 { cachedQuery, invalidateKey } from "@/lib/cached-db";
import { db, User, WebsiteLoginLogs } from "@/lib/db";
import { logger } from "@/lib/logger";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { siteSettings } from "@/lib/services/site-settings";
interface LoginUser {
id: number;
username: string;
password: string | null;
rank: number;
mail: string | null;
mailVerified: string | null;
twoFactorConfirmedAt: string | null;
twoFactorSecret: string | null;
}
/**
* Cached login user lookup — short TTL to survive brute-force attempts
* while still reflecting recent password/account changes reasonably fast.
*/
async function getLoginUser(username: string): Promise<LoginUser | null> {
return cachedQuery<LoginUser | null>(
`login:user:${username}`,
async () => {
const [result] = await db.execute<{
id: number;
username: string;
password: string | null;
rank: number;
mail: string | null;
mail_verified: string | null;
two_factor_confirmed_at: string | null;
two_factor_secret: string | null;
}>(sql`
SELECT id, username, password, rank, mail,
mail_verified,
two_factor_confirmed_at,
two_factor_secret
FROM users
WHERE username = ${username}
LIMIT 1
`);
const rows = result as unknown as Array<{
id: number;
username: string;
password: string | null;
rank: number;
mail: string | null;
mail_verified: string | null;
two_factor_confirmed_at: string | null;
two_factor_secret: string | null;
}>;
return rows.length > 0
? {
id: rows[0].id,
username: rows[0].username,
password: rows[0].password,
rank: rows[0].rank,
mail: rows[0].mail,
mailVerified: rows[0].mail_verified,
twoFactorConfirmedAt: rows[0].two_factor_confirmed_at,
twoFactorSecret: rows[0].two_factor_secret,
}
: null;
},
15, // 15s TTL — brute-force protection without blocking legit changes
);
}
/** Call after password reset / rank change to invalidate the cached login row. */
export async function invalidateLoginCache(username: string): Promise<void> {
await invalidateKey(`login:user:${username}`);
}
async function verify2faCode(userId: number, code: string): Promise<boolean> {
const [user] = await db
.select({
twoFactorSecret: User.twoFactorSecret,
twoFactorRecoveryCodes: User.twoFactorRecoveryCodes,
})
.from(User)
.where(eq(User.id, userId))
.limit(1);
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 db
.update(User)
.set({ twoFactorRecoveryCodes: remaining })
.where(eq(User.id, userId));
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 getLoginUser(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 + legacy md5/bcrypt upgrade).
if (!user.password) return null;
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 db
.update(User)
.set({ password: res.upgradedHash })
.where(eq(User.id, user.id));
invalidateLoginCache(username);
}
// 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 db.insert(WebsiteLoginLogs).values({
userId: user.id,
ip,
userAgent: ua,
createdAt: new Date(),
});
} catch {
logger.warn("Failed to record login log for user", {
userId: user.id,
});
}
const jwtVersion = await getCachedJwtVersion(user.id);
return {
id: String(user.id),
name: user.username,
rank: user.rank,
jwtVersion,
};
},
}),
],
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;
},
},
});