feat(auth): switch password hashing to argon2id with legacy auto-upgrade
CI / check (push) Failing after 10s
CI / release (push) Skipped
CI / deploy (push) Skipped

- hashPassword now emits argon2id (same params as the legacy AtomCMS
  Laravel setup: memory 64MB, iterations 4, parallelism 1)
- legacy md5 and bcrypt hashes are verified and auto-upgraded to
  argon2id on successful login (CONVERT_PASSWORDS=true)
- replace BCRYPT_ROUNDS env with ARGON2_MEMORY_KB / ARGON2_ITERATIONS /
  ARGON2_PARALLELISM
- update README and add tests for argon2id and bcrypt upgrade paths
This commit is contained in:
openhands committed 2026-08-01 17:09:29 +02:00
1 parent d39738eb0d
commit c601ffbb76
6 files changed
+121 -28

No files matched your search

+3 -1
View File
@@ -32,7 +32,9 @@ NEXT_PUBLIC_IMAGER_URL=http://localhost:3002/imaging
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
APP_KEY=base64:your-app-key-here= APP_KEY=base64:your-app-key-here=
CONVERT_PASSWORDS=true CONVERT_PASSWORDS=true
BCRYPT_ROUNDS=12 ARGON2_MEMORY_KB=65536
ARGON2_ITERATIONS=4
ARGON2_PARALLELISM=1
# --- PATHS --- # --- PATHS ---
BADGE_UPLOAD_DIR=./public/assets/images/badges BADGE_UPLOAD_DIR=./public/assets/images/badges
+1 -1
View File
@@ -2,7 +2,7 @@
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Drizzle ORM** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases. A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Drizzle ORM** 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 (bcrypt with MD5-to-bcrypt 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 (argon2id hashing with legacy md5/bcrypt auto-upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.
--- ---
+6 -3
View File
@@ -50,13 +50,16 @@ const schema = z
// Laravel APP_KEY (base64:...) — needed to read existing 2FA secrets. // Laravel APP_KEY (base64:...) — needed to read existing 2FA secrets.
APP_KEY: z.string().optional(), APP_KEY: z.string().optional(),
// Mirrors Laravel config('habbo.site.convert_passwords') — enables md5->bcrypt upgrade. // Mirrors Laravel config('habbo.site.convert_passwords') — enables
// legacy md5/bcrypt hashes to be upgraded to argon2id on login.
CONVERT_PASSWORDS: z CONVERT_PASSWORDS: z
.string() .string()
.optional() .optional()
.transform((v) => v === "true" || v === "1"), .transform((v) => v === "true" || v === "1"),
// Bcrypt cost factor (rounds). // Argon2id parameters — defaults match the old AtomCMS (Laravel) setup.
BCRYPT_ROUNDS: z.coerce.number().int().positive().default(12), ARGON2_MEMORY_KB: z.coerce.number().int().positive().default(65_536),
ARGON2_ITERATIONS: z.coerce.number().int().positive().default(4),
ARGON2_PARALLELISM: z.coerce.number().int().positive().default(1),
// Filesystem dir the badge uploader writes <code>.gif into (the emulator's // Filesystem dir the badge uploader writes <code>.gif into (the emulator's
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled // badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
// when unset. // when unset.
+1 -1
View File
@@ -176,7 +176,7 @@ export const { handlers, signOut, auth } = NextAuth({
return null; return null;
} }
// Byte-compatible AtomCMS check (bcrypt + md5->bcrypt upgrade). // Byte-compatible AtomCMS check (argon2id + legacy md5/bcrypt upgrade).
if (!user.password) return null; if (!user.password) return null;
const res = await checkLogin(password, user.password, { const res = await checkLogin(password, user.password, {
convertPasswords: env.CONVERT_PASSWORDS, convertPasswords: env.CONVERT_PASSWORDS,
+70 -11
View File
@@ -1,7 +1,9 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
const mockEnv = vi.hoisted(() => ({ const mockEnv = vi.hoisted(() => ({
BCRYPT_ROUNDS: 12, ARGON2_MEMORY_KB: 65_536,
ARGON2_ITERATIONS: 4,
ARGON2_PARALLELISM: 1,
})); }));
vi.mock("@/env", () => ({ vi.mock("@/env", () => ({
@@ -11,6 +13,8 @@ vi.mock("@/env", () => ({
import { import {
checkLogin, checkLogin,
hashPassword, hashPassword,
isArgon2idOf,
isBcryptOf,
isMd5Of, isMd5Of,
md5Hex, md5Hex,
verifyPassword, verifyPassword,
@@ -24,20 +28,37 @@ describe("md5Hex", () => {
}); });
describe("hashPassword", () => { describe("hashPassword", () => {
it("emits a bcrypt hash and round-trips", async () => { it("emits an argon2id hash and round-trips", async () => {
const h = await hashPassword("s3cret!"); const h = await hashPassword("s3cret!");
expect(h).toMatch(/^\$2y\$\d{2}\$/); expect(h).toMatch(/^\$argon2id\$/);
expect(await verifyPassword("s3cret!", h)).toBe(true); expect(await verifyPassword("s3cret!", h)).toBe(true);
expect(await verifyPassword("wrong", h)).toBe(false); expect(await verifyPassword("wrong", h)).toBe(false);
}); });
}); });
describe("verifyPassword", () => { describe("isArgon2idOf", () => {
it("verifies legacy bcrypt hashes ($2y$)", async () => { it("verifies an argon2id hash (AtomCMS/Laravel)", async () => {
const h = await hashPassword("hunter2"); const stored =
expect(h).toMatch(/^\$2y\$/); "$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0";
expect(await verifyPassword("hunter2", h)).toBe(true); expect(await isArgon2idOf("test-password-123", stored)).toBe(true);
expect(await verifyPassword("nope", h)).toBe(false); expect(await isArgon2idOf("wrong", stored)).toBe(false);
expect(await isArgon2idOf("anything", "$2y$12$ABC")).toBe(false);
});
});
describe("isBcryptOf", () => {
it("verifies a legacy bcrypt hash", async () => {
const { bcrypt } = await import("hash-wasm");
const { randomBytes } = await import("node:crypto");
const stored = await bcrypt({
password: "hunter2",
salt: randomBytes(16),
costFactor: 10,
outputType: "encoded",
});
expect(await isBcryptOf("hunter2", stored)).toBe(true);
expect(await isBcryptOf("wrong", stored)).toBe(false);
expect(await isBcryptOf("anything", "$argon2id$v=19$")).toBe(false);
}); });
}); });
@@ -49,12 +70,21 @@ describe("isMd5Of", () => {
}); });
}); });
describe("verifyPassword", () => {
it("verifies argon2id hashes", async () => {
const h = await hashPassword("hunter2");
expect(h).toMatch(/^\$argon2id\$/);
expect(await verifyPassword("hunter2", h)).toBe(true);
expect(await verifyPassword("nope", h)).toBe(false);
});
});
describe("checkLogin", () => { describe("checkLogin", () => {
it("upgrades a legacy md5 hash to bcrypt when conversion is enabled", async () => { it("upgrades a legacy md5 hash to argon2id when conversion is enabled", async () => {
const stored = await md5Hex("oldpass"); const stored = await md5Hex("oldpass");
const res = await checkLogin("oldpass", stored, { convertPasswords: true }); const res = await checkLogin("oldpass", stored, { convertPasswords: true });
expect(res.valid).toBe(true); expect(res.valid).toBe(true);
expect(res.upgradedHash).toMatch(/^\$2y\$/); expect(res.upgradedHash).toMatch(/^\$argon2id\$/);
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe( expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
true, true,
); );
@@ -69,6 +99,35 @@ describe("checkLogin", () => {
expect(res.upgradedHash).toBeUndefined(); expect(res.upgradedHash).toBeUndefined();
}); });
it("accepts an argon2id hash with no rehash", async () => {
const stored =
"$argon2id$v=19$m=1024,t=1,p=1$pTCeoGfX788sH7Z3ju9rJw$4awmR4yciu2L+xDNQJ/NesWX3Kio+fwN8wSCtp4XUp0";
const res = await checkLogin("test-password-123", stored, {
convertPasswords: true,
});
expect(res.valid).toBe(true);
expect(res.upgradedHash).toBeUndefined();
});
it("upgrades a legacy bcrypt hash to argon2id when conversion is enabled", async () => {
// Generate a real bcrypt hash via hash-wasm and verify the upgrade path.
const { bcrypt } = await import("hash-wasm");
const stored = await bcrypt({
password: "oldbcrypt",
salt: await import("node:crypto").then((c) => c.randomBytes(16)),
costFactor: 10,
outputType: "encoded",
});
const res = await checkLogin("oldbcrypt", stored, {
convertPasswords: true,
});
expect(res.valid).toBe(true);
expect(res.upgradedHash).toMatch(/^\$argon2id\$/);
expect(await verifyPassword("oldbcrypt", res.upgradedHash as string)).toBe(
true,
);
});
it("validates an existing modern hash with no upgrade", async () => { it("validates an existing modern hash with no upgrade", async () => {
const stored = await hashPassword("modern"); const stored = await hashPassword("modern");
const res = await checkLogin("modern", stored, { convertPasswords: true }); const res = await checkLogin("modern", stored, { convertPasswords: true });
+40 -11
View File
@@ -1,16 +1,18 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { bcrypt, bcryptVerify, md5 } from "hash-wasm"; import { argon2id, argon2Verify, bcryptVerify, md5 } from "hash-wasm";
import { env } from "@/env"; import { env } from "@/env";
export async function hashPassword(password: string): Promise<string> { export async function hashPassword(password: string): Promise<string> {
const h = await bcrypt({ return await argon2id({
password, password,
salt: randomBytes(16), salt: randomBytes(16),
costFactor: env.BCRYPT_ROUNDS, parallelism: env.ARGON2_PARALLELISM,
iterations: env.ARGON2_ITERATIONS,
memorySize: env.ARGON2_MEMORY_KB,
hashLength: 32,
outputType: "encoded", outputType: "encoded",
}); });
return h.replace(/^\$2[ab]\$/, "$2y$");
} }
export async function md5Hex(input: string): Promise<string> { export async function md5Hex(input: string): Promise<string> {
@@ -27,18 +29,39 @@ export async function isMd5Of(
); );
} }
export async function isArgon2idOf(
password: string,
stored: string,
): Promise<boolean> {
if (!/^\$argon2id\$/.test(stored)) return false;
try {
return await argon2Verify({ password, hash: stored });
} catch {
return false;
}
}
/** Legacy bcrypt support — only kept to verify & auto-upgrade old accounts. */
export async function isBcryptOf(
password: string,
stored: string,
): Promise<boolean> {
if (!/^\$2[aby]\$/.test(stored)) return false;
try {
return await bcryptVerify({ password, hash: stored });
} catch {
return false;
}
}
export async function verifyPassword( export async function verifyPassword(
password: string, password: string,
stored: string, stored: string,
): Promise<boolean> { ): Promise<boolean> {
if (/^\$2[aby]\$/.test(stored)) { if (/^\$argon2id\$/.test(stored)) {
try { return isArgon2idOf(password, stored);
return await bcryptVerify({ password, hash: stored });
} catch {
return false;
}
} }
return false; return isBcryptOf(password, stored);
} }
export interface LoginCheck { export interface LoginCheck {
@@ -54,5 +77,11 @@ export async function checkLogin(
if (opts.convertPasswords && (await isMd5Of(password, stored))) { if (opts.convertPasswords && (await isMd5Of(password, stored))) {
return { valid: true, upgradedHash: await hashPassword(password) }; return { valid: true, upgradedHash: await hashPassword(password) };
} }
if (opts.convertPasswords && (await isArgon2idOf(password, stored))) {
return { valid: true };
}
if (opts.convertPasswords && (await isBcryptOf(password, stored))) {
return { valid: true, upgradedHash: await hashPassword(password) };
}
return { valid: await verifyPassword(password, stored) }; return { valid: await verifyPassword(password, stored) };
} }