diff --git a/.env.example b/.env.example index 971e5800..d8bec43b 100644 --- a/.env.example +++ b/.env.example @@ -28,12 +28,11 @@ AUTH_URL=http://localhost:3002 IMAGING_UPSTREAM_URL=http://127.0.0.1:3030/imaging NEXT_PUBLIC_IMAGER_URL=http://localhost:3002/imaging -# --- SECURITY & HASHING (High Performance) --- +# --- SECURITY & HASHING --- AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars APP_KEY=base64:your-app-key-here= CONVERT_PASSWORDS=true -PASSWORD_HASH=argon2id -BCRYPT_ROUNDS=10 +BCRYPT_ROUNDS=12 # --- PATHS --- BADGE_UPLOAD_DIR=./public/assets/images/badges diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 85994bde..65bdab25 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -34,8 +34,6 @@ jobs: export DATABASE_URL="mysql://test:test@localhost:3306/test?charset=utf8mb4" export AUTH_SECRET="ci-test-secret-key-that-is-long-enough" export REDIS_URL="redis://127.0.0.1:6379?connect_timeout=1" - export ARGON2_MEMORY_SIZE=1024 - export ARGON2_ITERATIONS=1 export BCRYPT_ROUNDS=4 pnpm install --frozen-lockfile pnpm prisma:generate diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 13d012fc..1eb28608 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -54,7 +54,7 @@ jobs: echo '' echo "## What is EpicNext-CMS?" echo "" - echo "EpicNext-CMS is a full public-facing hotel website plus an administrative panel. It features NextAuth authentication (argon2id/bcrypt with MD5 upgrade), real-time RCON communication with the emulator, Server-Sent Events for live radio, smooth page transitions and extensive extensibility. Full documentation: https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/README.md" + echo "EpicNext-CMS is a full public-facing hotel website plus an administrative panel. It features NextAuth authentication (bcrypt with MD5 upgrade), real-time RCON communication with the emulator, Server-Sent Events for live radio, smooth page transitions and extensive extensibility. Full documentation: https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/README.md" echo "" echo '' echo "## System Requirements" @@ -314,7 +314,7 @@ jobs: pnpm install --frozen-lockfile # prisma generate does not need a live DB connection. pnpm prisma:generate - export ARGON2_MEMORY_SIZE=1024 ARGON2_ITERATIONS=1 BCRYPT_ROUNDS=4 + export BCRYPT_ROUNDS=4 pnpm typecheck pnpm test # Validate production env (AUTH_SECRET, DATABASE_URL, …) during build. diff --git a/README.md b/README.md index a2ab3998..25f05255 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Prisma 7** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases. -Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (argon2id/bcrypt with MD5-to-argon2id upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment. +Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (bcrypt with MD5-to-bcrypt upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment. --- diff --git a/src/env.ts b/src/env.ts index a351ced2..9814071c 100644 --- a/src/env.ts +++ b/src/env.ts @@ -50,17 +50,11 @@ const schema = z // Laravel APP_KEY (base64:...) — needed to read existing 2FA secrets. APP_KEY: z.string().optional(), - // Mirrors Laravel config('habbo.site.convert_passwords') — enables md5->argon2id. + // Mirrors Laravel config('habbo.site.convert_passwords') — enables md5->bcrypt upgrade. CONVERT_PASSWORDS: z .string() .optional() .transform((v) => v === "true" || v === "1"), - // Hashing driver for NEW passwords: bcrypt (default, fits varchar(64)) | argon2id. - PASSWORD_HASH: z.enum(["bcrypt", "argon2id"]).optional(), - // Argon2id parameters (mirrors config/hashing.php). - ARGON2_PARALLELISM: z.coerce.number().int().positive().default(1), - ARGON2_ITERATIONS: z.coerce.number().int().positive().default(4), - ARGON2_MEMORY_SIZE: z.coerce.number().int().positive().default(65536), // Bcrypt cost factor (rounds). BCRYPT_ROUNDS: z.coerce.number().int().positive().default(12), // Filesystem dir the badge uploader writes .gif into (the emulator's diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 5ca54f25..e93aa365 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -96,7 +96,7 @@ export const { handlers, signOut, auth } = NextAuth({ return null; } - // Byte-compatible AtomCMS check (argon2id/bcrypt + md5->argon2id upgrade). + // Byte-compatible AtomCMS check (bcrypt + md5->bcrypt upgrade). const res = await checkLogin(password, user.password, { convertPasswords: env.CONVERT_PASSWORDS, }); diff --git a/src/lib/auth/password.test.ts b/src/lib/auth/password.test.ts index ea0a778d..94f4a3c2 100644 --- a/src/lib/auth/password.test.ts +++ b/src/lib/auth/password.test.ts @@ -1,12 +1,6 @@ -import { randomBytes } from "node:crypto"; -import { argon2id } from "hash-wasm"; import { describe, expect, it, vi } from "vitest"; const mockEnv = vi.hoisted(() => ({ - PASSWORD_HASH: undefined as string | undefined, - ARGON2_PARALLELISM: 1, - ARGON2_ITERATIONS: 4, - ARGON2_MEMORY_SIZE: 65536, BCRYPT_ROUNDS: 12, })); @@ -29,9 +23,8 @@ describe("md5Hex", () => { }); }); -describe("hashPassword (default driver: bcrypt)", () => { +describe("hashPassword", () => { it("emits a bcrypt hash and round-trips", async () => { - mockEnv.PASSWORD_HASH = undefined; const h = await hashPassword("s3cret!"); expect(h).toMatch(/^\$2y\$\d{2}\$/); expect(await verifyPassword("s3cret!", h)).toBe(true); @@ -39,35 +32,8 @@ describe("hashPassword (default driver: bcrypt)", () => { }); }); -describe("hashPassword (PASSWORD_HASH=argon2id)", () => { - it("hashes with the AtomCMS params and round-trips", async () => { - mockEnv.PASSWORD_HASH = "argon2id"; - mockEnv.ARGON2_MEMORY_SIZE = 1024; - mockEnv.ARGON2_ITERATIONS = 1; - const h = await hashPassword("s3cret!"); - expect(h).toMatch(/^\$argon2id\$v=19\$m=1024,t=1,p=1\$/); - expect(await verifyPassword("s3cret!", h)).toBe(true); - expect(await verifyPassword("wrong", h)).toBe(false); - }); -}); - describe("verifyPassword", () => { - it("verifies argon2id hashes", async () => { - const h = await argon2id({ - password: "hunter2", - salt: randomBytes(16), - outputType: "encoded", - parallelism: 1, - iterations: 4, - memorySize: 1024, - hashLength: 32, - }); - expect(await verifyPassword("hunter2", h)).toBe(true); - expect(await verifyPassword("nope", h)).toBe(false); - }); - it("verifies legacy bcrypt hashes ($2y$)", async () => { - mockEnv.PASSWORD_HASH = undefined; const h = await hashPassword("hunter2"); expect(h).toMatch(/^\$2y\$/); expect(await verifyPassword("hunter2", h)).toBe(true); @@ -85,7 +51,6 @@ describe("isMd5Of", () => { describe("checkLogin", () => { it("upgrades a legacy md5 hash to bcrypt when conversion is enabled", async () => { - mockEnv.PASSWORD_HASH = undefined; const stored = await md5Hex("oldpass"); const res = await checkLogin("oldpass", stored, { convertPasswords: true }); expect(res.valid).toBe(true); @@ -105,10 +70,9 @@ describe("checkLogin", () => { }); it("validates an existing modern hash with no upgrade", async () => { - mockEnv.PASSWORD_HASH = undefined; const stored = await hashPassword("modern"); const res = await checkLogin("modern", stored, { convertPasswords: true }); expect(res.valid).toBe(true); expect(res.upgradedHash).toBeUndefined(); }); -}); +}); \ No newline at end of file diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts index 2ec005b6..bf3bac97 100644 --- a/src/lib/auth/password.ts +++ b/src/lib/auth/password.ts @@ -1,7 +1,5 @@ import { randomBytes } from "node:crypto"; import { - argon2id, - argon2Verify, bcrypt, bcryptVerify, md5, @@ -9,55 +7,7 @@ import { import { env } from "@/env"; -// AtomCMS hashing (config/hashing.php): argon2id with memory=65536 KiB, time=4, -// threads=1; bcrypt rounds=12 as the legacy fallback. The game emulator -// validates the SAME users.password hash, so these must match. -function argon2Params() { - return { - parallelism: env.ARGON2_PARALLELISM, - iterations: env.ARGON2_ITERATIONS, - memorySize: env.ARGON2_MEMORY_SIZE, - hashLength: 32, - } as const; -} - - -// Which algorithm hashPassword() emits for NEW/upgraded passwords. -// - "bcrypt" (DEFAULT): 60-char $2y$ hash. Fits varchar(255) users.password. -// - "argon2id": ~97-char PHC hash. Opt in with PASSWORD_HASH=argon2id. -// verifyPassword() always accepts BOTH, so logins keep working either way. -function hashDriver(): "bcrypt" | "argon2id" { - return env.PASSWORD_HASH === "argon2id" ? "argon2id" : "bcrypt"; -} - -/** - * Lowercase hex md5 of a UTF-8 string (matches PHP md5()). - * - * This uses hash-wasm's MD5 (not node:crypto) to match PHP's md5() output, - * enabling verification of legacy AtomCMS password hashes during the on-login - * upgrade path (isMd5Of → checkLogin). It is NOT used to hash new passwords - * and does NOT affect credential security. - */ -export async function md5Hex(input: string): Promise { - return await md5(input); -} - -/** - * Hash a new password with the configured driver. Defaults to bcrypt ($2y$, - * rounds=12) so the result fits a varchar(64) column; set PASSWORD_HASH=argon2id - * for argon2id (requires a wider column). Both are verifiable by verifyPassword. - */ export async function hashPassword(password: string): Promise { - if (hashDriver() === "argon2id") { - return argon2id({ - password, - salt: randomBytes(16), - outputType: "encoded", - ...argon2Params(), - }); - } - // hash-wasm bcrypt emits $2a$; normalise to the PHP-canonical $2y$ the - // emulator and existing AtomCMS rows use. const h = await bcrypt({ password, salt: randomBytes(16), @@ -67,7 +17,10 @@ export async function hashPassword(password: string): Promise { return h.replace(/^\$2[ab]\$/, "$2y$"); } -/** True when `stored` is exactly the md5 of `password` (legacy AtomCMS accounts). */ +export async function md5Hex(input: string): Promise { + return await md5(input); +} + export async function isMd5Of( password: string, stored: string, @@ -78,22 +31,10 @@ export async function isMd5Of( ); } -/** - * Verify a password against a stored hash, auto-detecting the algorithm the way - * Laravel's Hash::check does. Returns false for unknown/legacy formats (md5 is - * handled by the conversion path in checkLogin, not here). - */ export async function verifyPassword( password: string, stored: string, ): Promise { - if (stored.startsWith("$argon2")) { - try { - return await argon2Verify({ password, hash: stored }); - } catch { - return false; - } - } if (/^\$2[aby]\$/.test(stored)) { try { return await bcryptVerify({ password, hash: stored }); @@ -106,15 +47,9 @@ export async function verifyPassword( export interface LoginCheck { valid: boolean; - /** Set when a legacy md5 hash was upgraded — persist it to users.password. */ upgradedHash?: string; } -/** - * Full AtomCMS credential check including the md5 -> argon2id on-login upgrade - * (gated by `convertPasswords`, i.e. config('habbo.site.convert_passwords')). - * Mirrors RedirectIfTwoFactorAuthenticatable::convertUserPassword + validate. - */ export async function checkLogin( password: string, stored: string, @@ -124,4 +59,4 @@ export async function checkLogin( return { valid: true, upgradedHash: await hashPassword(password) }; } return { valid: await verifyPassword(password, stored) }; -} +} \ No newline at end of file