diff --git a/src/actions/register.test.ts b/src/actions/register.test.ts index 154a0008..3c4bfe95 100644 --- a/src/actions/register.test.ts +++ b/src/actions/register.test.ts @@ -139,9 +139,9 @@ describe("register", () => { expect(state.hashPassword).toHaveBeenCalledWith("Secret1234!@"); expect(state.insert).toHaveBeenCalledOnce(); expect(state.insert.mock.calls[0][0]).toBe(User); -expect(state.insert.mock.calls[0][1]).toMatchObject({ - username: "Alice_123", - password: "hashed:Secret1234!@", + expect(state.insert.mock.calls[0][1]).toMatchObject({ + username: "Alice_123", + password: "hashed:Secret1234!@", mail: null, accountCreated: expect.any(Number), ipRegister: "203.0.113.9", @@ -214,7 +214,10 @@ expect(state.insert.mock.calls[0][1]).toMatchObject({ it("rejects passwords without an uppercase letter", async () => { const result = await register( PREV, - buildForm({ password: "secret1234!@", password_confirmation: "secret1234!@" }), + buildForm({ + password: "secret1234!@", + password_confirmation: "secret1234!@", + }), ); expect(result.error).toContain("uppercase"); }); diff --git a/src/actions/register.ts b/src/actions/register.ts index 122f90f7..a36e859d 100644 --- a/src/actions/register.ts +++ b/src/actions/register.ts @@ -16,24 +16,63 @@ import { siteSettings } from "@/lib/services/site-settings"; // Reserved usernames that can never be registered (prevent impersonation/admin confusion). const RESERVED_USERNAMES = new Set([ - "admin", "root", "system", "moderator", "mod", "staff", "support", - "help", "service", "api", "webmaster", "postmaster", "hostmaster", - "administrator", "superuser", "sysadmin", "nobody", "anonymous", - "guest", "default", "test", "demo", "example", "info", "security", - "abuse", "noreply", "donotreply", "bot", "crawler", "indexer", + "admin", + "root", + "system", + "moderator", + "mod", + "staff", + "support", + "help", + "service", + "api", + "webmaster", + "postmaster", + "hostmaster", + "administrator", + "superuser", + "sysadmin", + "nobody", + "anonymous", + "guest", + "default", + "test", + "demo", + "example", + "info", + "security", + "abuse", + "noreply", + "donotreply", + "bot", + "crawler", + "indexer", ]); // Disposable/temporary email domains (subset, expandable via settings). const DISPOSABLE_EMAIL_DOMAINS = new Set([ - "10minutemail.com", "guerrillamail.com", "mailinator.com", - "tempmail.com", "throwawaymail.com", "yopmail.com", "trashmail.com", - "fakeinbox.com", "spamgourmet.com", "getnada.com", "maildrop.cc", + "10minutemail.com", + "guerrillamail.com", + "mailinator.com", + "tempmail.com", + "throwawaymail.com", + "yopmail.com", + "trashmail.com", + "fakeinbox.com", + "spamgourmet.com", + "getnada.com", + "maildrop.cc", ]); function isReservedUsername(username: string): boolean { const lower = username.toLowerCase(); if (RESERVED_USERNAMES.has(lower)) return true; - if (lower.startsWith("admin") || lower.startsWith("mod") || lower.startsWith("staff")) return true; + if ( + lower.startsWith("admin") || + lower.startsWith("mod") || + lower.startsWith("staff") + ) + return true; if (/^(x|www|mail|ftp|smtp|pop|imap|dns|ns[0-9]*)$/.test(lower)) return true; return false; } @@ -43,33 +82,44 @@ function hasDisposableEmailDomain(email: string): boolean { return domain ? DISPOSABLE_EMAIL_DOMAINS.has(domain) : false; } -const registerSchema = z.object({ - username: z - .string() - .min(3, "Username must be at least 3 characters") - .max(25, "Username must be at most 25 characters") - .regex(/^[A-Za-z0-9_-]+$/, "Username may only contain letters, numbers, underscore and hyphen") - .refine((u) => !isReservedUsername(u), "This username is reserved"), - mail: z - .string() - .email("Enter a valid email address") - .optional() - .or(z.literal("")) - .refine((e) => !e || !hasDisposableEmailDomain(e), "Temporary email domains are not allowed"), - password: z - .string() - .min(12, "Password must be at least 12 characters") // Increased min length - .max(128, "Password is too long") // Added max length - .regex(/[A-Z]/, "Password must contain at least one uppercase letter") - .regex(/[a-z]/, "Password must contain at least one lowercase letter") - .regex(/[0-9]/, "Password must contain at least one digit") - .regex(/[^A-Za-z0-9]/, "Password must contain at least one special character"), // Added special character requirement - passwordConfirmation: z.string(), - look: z.string().optional(), -}).refine((data) => data.password === data.passwordConfirmation, { - message: "Passwords do not match", - path: ["passwordConfirmation"], -}); +const registerSchema = z + .object({ + username: z + .string() + .min(3, "Username must be at least 3 characters") + .max(25, "Username must be at most 25 characters") + .regex( + /^[A-Za-z0-9_-]+$/, + "Username may only contain letters, numbers, underscore and hyphen", + ) + .refine((u) => !isReservedUsername(u), "This username is reserved"), + mail: z + .string() + .email("Enter a valid email address") + .optional() + .or(z.literal("")) + .refine( + (e) => !e || !hasDisposableEmailDomain(e), + "Temporary email domains are not allowed", + ), + password: z + .string() + .min(12, "Password must be at least 12 characters") // Increased min length + .max(128, "Password is too long") // Added max length + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[0-9]/, "Password must contain at least one digit") + .regex( + /[^A-Za-z0-9]/, + "Password must contain at least one special character", + ), // Added special character requirement + passwordConfirmation: z.string(), + look: z.string().optional(), + }) + .refine((data) => data.password === data.passwordConfirmation, { + message: "Passwords do not match", + path: ["passwordConfirmation"], + }); // A valid starter Habbo figure so the avatar renders in-client immediately. const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62"; diff --git a/src/lib/auth/password.test.ts b/src/lib/auth/password.test.ts index 2c07543d..50b3db25 100644 --- a/src/lib/auth/password.test.ts +++ b/src/lib/auth/password.test.ts @@ -40,9 +40,9 @@ describe("sha512Hex", () => { }); 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!"); - expect(h).toMatch(/^\$2[aby]\$/); + expect(h).toMatch(/^\$argon2id\$/); expect(await verifyPassword("s3cret!", h)).toBe(true); expect(await verifyPassword("wrong", h)).toBe(false); }); @@ -209,31 +209,31 @@ describe("isSaltedDigestOf", () => { }); describe("verifyPassword", () => { - it("verifies bcrypt hashes", async () => { + it("verifies Argon2id hashes", async () => { const h = await hashPassword("hunter2"); - expect(h).toMatch(/^\$2[aby]\$/); + expect(h).toMatch(/^\$argon2id\$/); expect(await verifyPassword("hunter2", h)).toBe(true); expect(await verifyPassword("nope", h)).toBe(false); }); }); describe("checkLogin", () => { - it("upgrades a legacy md5 hash to bcrypt", async () => { + it("upgrades a legacy md5 hash to Argon2id", async () => { const stored = await md5Hex("oldpass"); const res = await checkLogin("oldpass", stored); expect(res.valid).toBe(true); - expect(res.upgradedHash).toMatch(/^\$2[aby]\$/); + expect(res.upgradedHash).toMatch(/^\$argon2id\$/); expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe( true, ); }); - it("migrates a legacy argon2id hash to bcrypt", async () => { + it("migrates a legacy argon2id hash to Argon2id", 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); expect(res.valid).toBe(true); - expect(res.upgradedHash).toMatch(/^\$2[aby]\$/); + expect(res.upgradedHash).toMatch(/^\$argon2id\$/); }); for (const [name, hashThePassword] of [ @@ -241,48 +241,48 @@ describe("checkLogin", () => { ["sha256", sha256Hex], ["sha512", sha512Hex], ] as const) { - it(`migrates a legacy ${name} hash to bcrypt`, async () => { + it(`migrates a legacy ${name} hash to Argon2id`, async () => { const stored = await hashThePassword("oldpass"); const res = await checkLogin("oldpass", stored); expect(res.valid).toBe(true); - expect(res.upgradedHash).toMatch(/^\$2[aby]\$/); + expect(res.upgradedHash).toMatch(/^\$argon2id\$/); expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe( true, ); }); } - it("migrates a combined digest hash to bcrypt (md5(md5(pass)))", async () => { + it("migrates a combined digest hash to Argon2id (md5(md5(pass)))", async () => { const stored = await md5Hex(await md5Hex("oldpass")); const res = await checkLogin("oldpass", stored); expect(res.valid).toBe(true); - expect(res.upgradedHash).toMatch(/^\$2[aby]\$/); + expect(res.upgradedHash).toMatch(/^\$argon2id\$/); }); - it("migrates a combined digest hash to bcrypt (sha1(md5(pass)))", async () => { + it("migrates a combined digest hash to Argon2id (sha1(md5(pass)))", async () => { const stored = await sha1Hex(await md5Hex("oldpass")); const res = await checkLogin("oldpass", stored); expect(res.valid).toBe(true); - expect(res.upgradedHash).toMatch(/^\$2[aby]\$/); + expect(res.upgradedHash).toMatch(/^\$argon2id\$/); }); - it("migrates a salted md5 hash to bcrypt (md5(salt+password))", async () => { + it("migrates a salted md5 hash to Argon2id (md5(salt+password))", async () => { const salt = "pepper123"; const stored = `${await md5Hex(`${salt}oldpass`)}:${salt}`; const res = await checkLogin("oldpass", stored); expect(res.valid).toBe(true); - expect(res.upgradedHash).toMatch(/^\$2[aby]\$/); + expect(res.upgradedHash).toMatch(/^\$argon2id\$/); expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe( true, ); }); - it("migrates a salted sha256 hash to bcrypt (sha256(salt+password))", async () => { + it("migrates a salted sha256 hash to Argon2id (sha256(salt+password))", async () => { const salt = "abc123"; const stored = `${salt}:${await sha256Hex(`${salt}oldpass`)}`; const res = await checkLogin("oldpass", stored); expect(res.valid).toBe(true); - expect(res.upgradedHash).toMatch(/^\$2[aby]\$/); + expect(res.upgradedHash).toMatch(/^\$argon2id\$/); }); it("rejects a wrong password for salted/combined hashes", async () => { @@ -292,10 +292,10 @@ describe("checkLogin", () => { expect((await checkLogin("wrongpass", combined)).valid).toBe(false); }); - it("accepts a raw plaintext password and upgrades it to bcrypt", async () => { + it("accepts a raw plaintext password and upgrades it to Argon2id", async () => { const res = await checkLogin("hunter44", "hunter44"); expect(res.valid).toBe(true); - expect(res.upgradedHash).toMatch(/^\$2[aby]\$/); + expect(res.upgradedHash).toMatch(/^\$argon2id\$/); expect(await verifyPassword("hunter44", res.upgradedHash as string)).toBe( true, ); diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts index 42baaca6..34995f5c 100644 --- a/src/lib/auth/password.ts +++ b/src/lib/auth/password.ts @@ -14,10 +14,10 @@ import { env } from "@/env"; /** Argon2id parameters — memory-hard, GPU-resistant. */ const ARGON2_CONFIG = { - memoryCost: 19456, // ~19 MiB - timeCost: 2, + memorySize: 19456, // ~19 MiB + iterations: 2, parallelism: 1, - outputLen: 32, + hashLength: 32, }; /** Hash new passwords with Argon2id (best practice 2024+). */ @@ -210,6 +210,7 @@ export async function verifyPassword( password: string, stored: string, ): Promise { + if (/^\$argon2/.test(stored)) return isArgon2Of(password, stored); return isBcryptOf(password, stored); }