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