Merge branch 'main' of https://gitlab.epicnabbo.nl/remco/EpicNext-Cms into codex/housekeeping-complete

This commit is contained in:
Simo committed 2026-08-27 17:25:31 +02:00
commit 64bb230de5
23 files changed
+846 -1248

No files matched your search

+19 -92
View File
@@ -1,85 +1,24 @@
import { eq, sql } from "drizzle-orm";
import { eq } 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 {
getLoginUser,
invalidateLoginCache,
isEmailUnverified,
normalizeLoginInput,
runDummyHashCheck,
verifyLoginPassword,
} from "@/lib/auth/login-core";
export { invalidateLoginCache };
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
@@ -149,8 +88,10 @@ export const { handlers, signOut, auth } = NextAuth({
code: { label: "2FA code", type: "text" },
},
authorize: async (credentials) => {
const username = String(credentials?.username ?? "").trim();
const password = String(credentials?.password ?? "");
const { username, password } = normalizeLoginInput(
credentials?.username,
credentials?.password,
);
if (!username || !password) return null;
const ip = await clientIp();
@@ -161,28 +102,14 @@ export const { handlers, signOut, auth } = NextAuth({
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,
},
);
await runDummyHashCheck(password);
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,
});
const res = await verifyLoginPassword(user, password);
if (!res.valid) return null;
if (
(await siteSettings.getBool("require_email_verification", false)) &&
user.mail &&
user.mailVerified !== "1"
) {
if (await isEmailUnverified(user)) {
return null;
}
+122
View File
@@ -0,0 +1,122 @@
import { sql } from "drizzle-orm";
import { env } from "@/env";
import { checkLogin } from "@/lib/auth/password";
import { cachedQuery, invalidateKey } from "@/lib/cached-db";
import { db } from "@/lib/db";
import { siteSettings } from "@/lib/services/site-settings";
export interface LoginUser {
id: number;
username: string;
password: string | null;
rank: number;
mail: string | null;
mailVerified: string | null;
twoFactorConfirmedAt: string | null;
twoFactorSecret: string | null;
}
/**
* Fixed dummy bcrypt hash used to keep timing roughly constant when a username
* does not exist, so attackers can't enumerate accounts by response time.
*/
const DUMMY_BCRYPT_HASH =
"$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd";
/**
* Normalize credentials exactly like the registration flow hashes them, so
* accounts with accented/non-ASCII usernames or passwords verify correctly.
*/
export function normalizeLoginInput(username: unknown, password: unknown) {
return {
username: String(username ?? "")
.normalize("NFC")
.trim(),
password: String(password ?? "").normalize("NFC"),
};
}
/**
* Cached login user lookup — short TTL to survive brute-force attempts
* while still reflecting recent password/account changes reasonably fast.
*/
export 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}`);
}
/** Runs a dummy hash check so missing-user responses stay timing-constant. */
export async function runDummyHashCheck(password: string): Promise<void> {
await checkLogin(password, DUMMY_BCRYPT_HASH, { convertPasswords: false });
}
/** Verifies the password against the stored hash and reports a possible upgrade. */
export async function verifyLoginPassword(
user: LoginUser,
password: string,
): Promise<{ valid: boolean; upgradedHash?: string }> {
if (!user.password) return { valid: false };
return checkLogin(password, user.password, {
convertPasswords: env.CONVERT_PASSWORDS,
});
}
/** True when email verification is required but this account hasn't verified yet. */
export async function isEmailUnverified(user: LoginUser): Promise<boolean> {
return (
(await siteSettings.getBool("require_email_verification", false)) &&
!!user.mail &&
user.mailVerified !== "1"
);
}
-10
View File
@@ -1,10 +0,0 @@
/**
* Single hardcoded fallback hotel brand.
* Override order at runtime:
* 1. website_settings.hotel_name
* 2. HOTEL_NAME env
* 3. this constant
*
* Safe for client components (no env / DB imports).
*/
export const FALLBACK_HOTEL_NAME = "Atom";
+4 -9
View File
@@ -1,17 +1,12 @@
import "server-only";
import { env } from "@/env";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
import { siteSettings } from "@/lib/services/site-settings";
/**
* Resolve the public hotel name: CMS setting → HOTEL_NAME env → FALLBACK_HOTEL_NAME.
* The public hotel name is read directly from the required HOTEL_NAME env var.
* There is no CMS override or hardcoded preset — the site must be configured,
* otherwise HOTEL_NAME fails validation at startup.
*/
export async function resolveHotelName(): Promise<string> {
const fromSettings = await siteSettings.get("hotel_name", env.HOTEL_NAME);
const trimmed = fromSettings?.trim();
if (trimmed) return trimmed;
const fromEnv = env.HOTEL_NAME?.trim();
if (fromEnv) return fromEnv;
return FALLBACK_HOTEL_NAME;
return env.HOTEL_NAME;
}
+1 -3
View File
@@ -1,12 +1,10 @@
import "server-only";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
import { db, WebsiteSetting } from "@/lib/db";
import { logger } from "@/lib/logger";
import { redis } from "@/lib/redis";
const DEFAULTS: Record<string, string> = {
hotel_name: FALLBACK_HOTEL_NAME,
habbo_imaging_url: "/imaging",
logo_url: "",
nitro_client_url: "",
@@ -16,7 +14,7 @@ const DEFAULTS: Record<string, string> = {
const CACHE_TTL_MS = 300_000;
const REDIS_CACHE_KEY = "site_settings";
// Short in-process window so repeated getters in one request (header, nav,
// footer all read hotel_name / logo) don't each pay a Redis round-trip.
// footer all read logo and other settings) don't each pay a Redis round-trip.
// Redis stays the source of truth across instances.
const MEMORY_TTL_MS = 60_000;