feat(auth): switch password hashing to argon2id with legacy auto-upgrade
- 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:
1 parent
d39738eb0d
commit
c601ffbb76
6 files changed
+121
-28
No files matched your search
+3
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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) };
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user