This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
+18
-3
@@ -1,4 +1,19 @@
|
||||
export { checkLogin, hashPassword, isMd5Of, md5Hex, verifyPassword, type LoginCheck } from "./password";
|
||||
export { generateSsoTicket, issueSsoTicket, type SsoUserUpdater } from "./sso-ticket";
|
||||
export { LaravelEncrypter, phpSerializeString, phpUnserializeString } from "./laravel-encrypter";
|
||||
export {
|
||||
LaravelEncrypter,
|
||||
phpSerializeString,
|
||||
phpUnserializeString,
|
||||
} from "./laravel-encrypter";
|
||||
export {
|
||||
checkLogin,
|
||||
hashPassword,
|
||||
isMd5Of,
|
||||
type LoginCheck,
|
||||
md5Hex,
|
||||
verifyPassword,
|
||||
} from "./password";
|
||||
export {
|
||||
generateSsoTicket,
|
||||
issueSsoTicket,
|
||||
type SsoUserUpdater,
|
||||
} from "./sso-ticket";
|
||||
export { generateTotp, totpKeyUri, verifyTotp } from "./totp";
|
||||
@@ -1,52 +1,58 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LaravelEncrypter, phpSerializeString, phpUnserializeString } from "./laravel-encrypter";
|
||||
import {
|
||||
LaravelEncrypter,
|
||||
phpSerializeString,
|
||||
phpUnserializeString,
|
||||
} from "./laravel-encrypter";
|
||||
|
||||
// Dynamically generated 32-byte key so no secret is hardcoded in source.
|
||||
const APP_KEY = `base64:${randomBytes(32).toString("base64")}`;
|
||||
|
||||
describe("LaravelEncrypter", () => {
|
||||
it("rejects a key that is not 32 bytes", () => {
|
||||
expect(() => new LaravelEncrypter("base64:c2hvcnQ=")).toThrow(/32 bytes/);
|
||||
});
|
||||
it("rejects a key that is not 32 bytes", () => {
|
||||
expect(() => new LaravelEncrypter("base64:c2hvcnQ=")).toThrow(/32 bytes/);
|
||||
});
|
||||
|
||||
it("round-trips encrypt/decrypt (serialize=true, like Laravel encrypt())", () => {
|
||||
const enc = new LaravelEncrypter(APP_KEY);
|
||||
const plaintext = randomBytes(16).toString("hex");
|
||||
const payload = enc.encrypt(plaintext);
|
||||
expect(payload).not.toContain(plaintext);
|
||||
expect(enc.decrypt(payload)).toBe(plaintext);
|
||||
});
|
||||
it("round-trips encrypt/decrypt (serialize=true, like Laravel encrypt())", () => {
|
||||
const enc = new LaravelEncrypter(APP_KEY);
|
||||
const plaintext = randomBytes(16).toString("hex");
|
||||
const payload = enc.encrypt(plaintext);
|
||||
expect(payload).not.toContain(plaintext);
|
||||
expect(enc.decrypt(payload)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("round-trips encryptString/decryptString (serialize=false)", () => {
|
||||
const enc = new LaravelEncrypter(APP_KEY);
|
||||
const payload = enc.encryptString("hello world");
|
||||
expect(enc.decryptString(payload)).toBe("hello world");
|
||||
});
|
||||
it("round-trips encryptString/decryptString (serialize=false)", () => {
|
||||
const enc = new LaravelEncrypter(APP_KEY);
|
||||
const payload = enc.encryptString("hello world");
|
||||
expect(enc.decryptString(payload)).toBe("hello world");
|
||||
});
|
||||
|
||||
it("fails closed when the auth tag is tampered", () => {
|
||||
const enc = new LaravelEncrypter(APP_KEY);
|
||||
const payload = enc.encrypt("x");
|
||||
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
||||
json.tag = Buffer.alloc(16).toString("base64"); // zeroed auth tag
|
||||
const tampered = Buffer.from(JSON.stringify(json), "utf8").toString("base64");
|
||||
expect(() => enc.decrypt(tampered)).toThrow();
|
||||
});
|
||||
it("fails closed when the auth tag is tampered", () => {
|
||||
const enc = new LaravelEncrypter(APP_KEY);
|
||||
const payload = enc.encrypt("x");
|
||||
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
||||
json.tag = Buffer.alloc(16).toString("base64"); // zeroed auth tag
|
||||
const tampered = Buffer.from(JSON.stringify(json), "utf8").toString(
|
||||
"base64",
|
||||
);
|
||||
expect(() => enc.decrypt(tampered)).toThrow();
|
||||
});
|
||||
|
||||
it("decrypts a payload produced with a fresh instance of the same key", () => {
|
||||
const payload = new LaravelEncrypter(APP_KEY).encrypt("shared");
|
||||
expect(new LaravelEncrypter(APP_KEY).decrypt(payload)).toBe("shared");
|
||||
});
|
||||
it("decrypts a payload produced with a fresh instance of the same key", () => {
|
||||
const payload = new LaravelEncrypter(APP_KEY).encrypt("shared");
|
||||
expect(new LaravelEncrypter(APP_KEY).decrypt(payload)).toBe("shared");
|
||||
});
|
||||
});
|
||||
|
||||
describe("php string (de)serialization", () => {
|
||||
it("serializes by byte length", () => {
|
||||
expect(phpSerializeString("hello")).toBe('s:5:"hello";');
|
||||
expect(phpSerializeString("café")).toBe('s:5:"café";'); // é is 2 bytes
|
||||
});
|
||||
it("serializes by byte length", () => {
|
||||
expect(phpSerializeString("hello")).toBe('s:5:"hello";');
|
||||
expect(phpSerializeString("café")).toBe('s:5:"café";'); // é is 2 bytes
|
||||
});
|
||||
|
||||
it("round-trips including multibyte", () => {
|
||||
expect(phpUnserializeString(phpSerializeString("café"))).toBe("café");
|
||||
expect(phpUnserializeString('s:5:"hello";')).toBe("hello");
|
||||
});
|
||||
it("round-trips including multibyte", () => {
|
||||
expect(phpUnserializeString(phpSerializeString("café"))).toBe("café");
|
||||
expect(phpUnserializeString('s:5:"hello";')).toBe("hello");
|
||||
});
|
||||
});
|
||||
@@ -10,68 +10,74 @@ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
* } )
|
||||
*/
|
||||
export class LaravelEncrypter {
|
||||
private readonly key: Buffer;
|
||||
private readonly key: Buffer;
|
||||
|
||||
/** APP_KEY is "base64:...." (or a raw 32-byte string). */
|
||||
constructor(appKey: string) {
|
||||
const raw = appKey.startsWith("base64:")
|
||||
? Buffer.from(appKey.slice("base64:".length), "base64")
|
||||
: Buffer.from(appKey, "utf8");
|
||||
if (raw.length !== 32) {
|
||||
throw new Error(`APP_KEY must decode to 32 bytes for AES-256-GCM (got ${raw.length})`);
|
||||
}
|
||||
this.key = raw;
|
||||
}
|
||||
/** APP_KEY is "base64:...." (or a raw 32-byte string). */
|
||||
constructor(appKey: string) {
|
||||
const raw = appKey.startsWith("base64:")
|
||||
? Buffer.from(appKey.slice("base64:".length), "base64")
|
||||
: Buffer.from(appKey, "utf8");
|
||||
if (raw.length !== 32) {
|
||||
throw new Error(
|
||||
`APP_KEY must decode to 32 bytes for AES-256-GCM (got ${raw.length})`,
|
||||
);
|
||||
}
|
||||
this.key = raw;
|
||||
}
|
||||
|
||||
encrypt(value: string, serialize = true): string {
|
||||
const iv = randomBytes(12);
|
||||
const data = serialize ? phpSerializeString(value) : value;
|
||||
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
|
||||
const valueB64 = cipher.update(data, "utf8", "base64") + cipher.final("base64");
|
||||
const tag = cipher.getAuthTag();
|
||||
const ivB64 = iv.toString("base64");
|
||||
const tagB64 = tag.toString("base64");
|
||||
const payload = JSON.stringify({ iv: ivB64, value: valueB64, tag: tagB64 });
|
||||
return Buffer.from(payload, "utf8").toString("base64");
|
||||
}
|
||||
encrypt(value: string, serialize = true): string {
|
||||
const iv = randomBytes(12);
|
||||
const data = serialize ? phpSerializeString(value) : value;
|
||||
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
|
||||
const valueB64 =
|
||||
cipher.update(data, "utf8", "base64") + cipher.final("base64");
|
||||
const tag = cipher.getAuthTag();
|
||||
const ivB64 = iv.toString("base64");
|
||||
const tagB64 = tag.toString("base64");
|
||||
const payload = JSON.stringify({ iv: ivB64, value: valueB64, tag: tagB64 });
|
||||
return Buffer.from(payload, "utf8").toString("base64");
|
||||
}
|
||||
|
||||
decrypt(payload: string, serialize = true): string {
|
||||
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as {
|
||||
iv: string;
|
||||
value: string;
|
||||
tag: string;
|
||||
};
|
||||
const iv = Buffer.from(json.iv, "base64");
|
||||
const tag = Buffer.from(json.tag, "base64");
|
||||
const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const plain = decipher.update(json.value, "base64", "utf8") + decipher.final("utf8");
|
||||
return serialize ? phpUnserializeString(plain) : plain;
|
||||
}
|
||||
decrypt(payload: string, serialize = true): string {
|
||||
const json = JSON.parse(
|
||||
Buffer.from(payload, "base64").toString("utf8"),
|
||||
) as {
|
||||
iv: string;
|
||||
value: string;
|
||||
tag: string;
|
||||
};
|
||||
const iv = Buffer.from(json.iv, "base64");
|
||||
const tag = Buffer.from(json.tag, "base64");
|
||||
const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const plain =
|
||||
decipher.update(json.value, "base64", "utf8") + decipher.final("utf8");
|
||||
return serialize ? phpUnserializeString(plain) : plain;
|
||||
}
|
||||
|
||||
encryptString(value: string): string {
|
||||
return this.encrypt(value, false);
|
||||
}
|
||||
encryptString(value: string): string {
|
||||
return this.encrypt(value, false);
|
||||
}
|
||||
|
||||
decryptString(payload: string): string {
|
||||
return this.decrypt(payload, false);
|
||||
}
|
||||
decryptString(payload: string): string {
|
||||
return this.decrypt(payload, false);
|
||||
}
|
||||
}
|
||||
|
||||
/** PHP serialize() for a string: s:<byteLength>:"<value>"; */
|
||||
export function phpSerializeString(value: string): string {
|
||||
return `s:${Buffer.byteLength(value, "utf8")}:"${value}";`;
|
||||
return `s:${Buffer.byteLength(value, "utf8")}:"${value}";`;
|
||||
}
|
||||
|
||||
/** PHP unserialize() for a serialized string payload. */
|
||||
export function phpUnserializeString(serialized: string): string {
|
||||
const m = /^s:(\d+):"/.exec(serialized);
|
||||
if (!m) throw new Error("Not a serialized PHP string");
|
||||
const byteLen = Number(m[1]);
|
||||
const start = m[0].length;
|
||||
// Slice by BYTE length (PHP counts bytes), then back to a JS string.
|
||||
const bytes = Buffer.from(serialized, "utf8").subarray(
|
||||
Buffer.byteLength(serialized.slice(0, start), "utf8"),
|
||||
);
|
||||
return bytes.subarray(0, byteLen).toString("utf8");
|
||||
const m = /^s:(\d+):"/.exec(serialized);
|
||||
if (!m) throw new Error("Not a serialized PHP string");
|
||||
const byteLen = Number(m[1]);
|
||||
const start = m[0].length;
|
||||
// Slice by BYTE length (PHP counts bytes), then back to a JS string.
|
||||
const bytes = Buffer.from(serialized, "utf8").subarray(
|
||||
Buffer.byteLength(serialized.slice(0, start), "utf8"),
|
||||
);
|
||||
return bytes.subarray(0, byteLen).toString("utf8");
|
||||
}
|
||||
@@ -1,89 +1,99 @@
|
||||
import { hash as bcryptHash } from "bcryptjs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkLogin, hashPassword, isMd5Of, md5Hex, verifyPassword } from "./password";
|
||||
import {
|
||||
checkLogin,
|
||||
hashPassword,
|
||||
isMd5Of,
|
||||
md5Hex,
|
||||
verifyPassword,
|
||||
} from "./password";
|
||||
|
||||
describe("md5Hex", () => {
|
||||
it("matches PHP md5() on canonical vectors", async () => {
|
||||
expect(await md5Hex("")).toBe("d41d8cd98f00b204e9800998ecf8427e");
|
||||
expect(await md5Hex("abc")).toBe("900150983cd24fb0d6963f7d28e17f72");
|
||||
});
|
||||
it("matches PHP md5() on canonical vectors", async () => {
|
||||
expect(await md5Hex("")).toBe("d41d8cd98f00b204e9800998ecf8427e");
|
||||
expect(await md5Hex("abc")).toBe("900150983cd24fb0d6963f7d28e17f72");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashPassword (default driver: bcrypt)", () => {
|
||||
it("emits a PHP-style $2y$ bcrypt hash that fits varchar(64) and round-trips", async () => {
|
||||
const prev = process.env.PASSWORD_HASH;
|
||||
delete process.env.PASSWORD_HASH; // exercise the default
|
||||
try {
|
||||
const h = await hashPassword("s3cret!");
|
||||
expect(h).toMatch(/^\$2y\$/);
|
||||
expect(h.length).toBeLessThanOrEqual(60); // fits varchar(64)
|
||||
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.PASSWORD_HASH;
|
||||
else process.env.PASSWORD_HASH = prev;
|
||||
}
|
||||
});
|
||||
it("emits a PHP-style $2y$ bcrypt hash that fits varchar(64) and round-trips", async () => {
|
||||
const prev = process.env.PASSWORD_HASH;
|
||||
delete process.env.PASSWORD_HASH; // exercise the default
|
||||
try {
|
||||
const h = await hashPassword("s3cret!");
|
||||
expect(h).toMatch(/^\$2y\$/);
|
||||
expect(h.length).toBeLessThanOrEqual(60); // fits varchar(64)
|
||||
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.PASSWORD_HASH;
|
||||
else process.env.PASSWORD_HASH = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashPassword (PASSWORD_HASH=argon2id)", () => {
|
||||
it("hashes with the AtomCMS params (m=65536,t=4,p=1) and round-trips", async () => {
|
||||
const prev = process.env.PASSWORD_HASH;
|
||||
process.env.PASSWORD_HASH = "argon2id";
|
||||
try {
|
||||
const h = await hashPassword("s3cret!");
|
||||
expect(h).toMatch(/^\$argon2id\$v=19\$m=65536,t=4,p=1\$/);
|
||||
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.PASSWORD_HASH;
|
||||
else process.env.PASSWORD_HASH = prev;
|
||||
}
|
||||
});
|
||||
it("hashes with the AtomCMS params (m=65536,t=4,p=1) and round-trips", async () => {
|
||||
const prev = process.env.PASSWORD_HASH;
|
||||
process.env.PASSWORD_HASH = "argon2id";
|
||||
try {
|
||||
const h = await hashPassword("s3cret!");
|
||||
expect(h).toMatch(/^\$argon2id\$v=19\$m=65536,t=4,p=1\$/);
|
||||
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.PASSWORD_HASH;
|
||||
else process.env.PASSWORD_HASH = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("bcrypt", () => {
|
||||
it("verifies a bcrypt hash and accepts the PHP $2y$ prefix", async () => {
|
||||
const h = await bcryptHash("hunter2", 10); // bcryptjs emits $2a$
|
||||
expect(await verifyPassword("hunter2", h)).toBe(true);
|
||||
// PHP stores $2y$ — bcryptjs must accept it as equivalent.
|
||||
const phpStyle = h.replace(/^\$2[ab]\$/, "$2y$");
|
||||
expect(await verifyPassword("hunter2", phpStyle)).toBe(true);
|
||||
expect(await verifyPassword("nope", h)).toBe(false);
|
||||
});
|
||||
it("verifies a bcrypt hash and accepts the PHP $2y$ prefix", async () => {
|
||||
const h = await bcryptHash("hunter2", 10); // bcryptjs emits $2a$
|
||||
expect(await verifyPassword("hunter2", h)).toBe(true);
|
||||
// PHP stores $2y$ — bcryptjs must accept it as equivalent.
|
||||
const phpStyle = h.replace(/^\$2[ab]\$/, "$2y$");
|
||||
expect(await verifyPassword("hunter2", phpStyle)).toBe(true);
|
||||
expect(await verifyPassword("nope", h)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMd5Of", () => {
|
||||
it("detects a legacy md5 password", async () => {
|
||||
expect(await isMd5Of("habbo", await md5Hex("habbo"))).toBe(true);
|
||||
expect(await isMd5Of("habbo", await md5Hex("other"))).toBe(false);
|
||||
expect(await isMd5Of("habbo", "not-a-hash")).toBe(false);
|
||||
});
|
||||
it("detects a legacy md5 password", async () => {
|
||||
expect(await isMd5Of("habbo", await md5Hex("habbo"))).toBe(true);
|
||||
expect(await isMd5Of("habbo", await md5Hex("other"))).toBe(false);
|
||||
expect(await isMd5Of("habbo", "not-a-hash")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkLogin", () => {
|
||||
it("upgrades a legacy md5 hash to the configured hash when conversion is enabled", async () => {
|
||||
const stored = await md5Hex("oldpass");
|
||||
const res = await checkLogin("oldpass", stored, { convertPasswords: true });
|
||||
expect(res.valid).toBe(true);
|
||||
// Default driver is bcrypt — the upgraded hash must fit varchar(64).
|
||||
expect(res.upgradedHash).toMatch(/^\$2y\$/);
|
||||
expect((res.upgradedHash as string).length).toBeLessThanOrEqual(60);
|
||||
// The upgraded hash verifies the same password.
|
||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(true);
|
||||
});
|
||||
it("upgrades a legacy md5 hash to the configured hash when conversion is enabled", async () => {
|
||||
const stored = await md5Hex("oldpass");
|
||||
const res = await checkLogin("oldpass", stored, { convertPasswords: true });
|
||||
expect(res.valid).toBe(true);
|
||||
// Default driver is bcrypt — the upgraded hash must fit varchar(64).
|
||||
expect(res.upgradedHash).toMatch(/^\$2y\$/);
|
||||
expect((res.upgradedHash as string).length).toBeLessThanOrEqual(60);
|
||||
// The upgraded hash verifies the same password.
|
||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT upgrade md5 when conversion is disabled", async () => {
|
||||
const stored = await md5Hex("oldpass");
|
||||
const res = await checkLogin("oldpass", stored, { convertPasswords: false });
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.upgradedHash).toBeUndefined();
|
||||
});
|
||||
it("does NOT upgrade md5 when conversion is disabled", async () => {
|
||||
const stored = await md5Hex("oldpass");
|
||||
const res = await checkLogin("oldpass", stored, {
|
||||
convertPasswords: false,
|
||||
});
|
||||
expect(res.valid).toBe(false);
|
||||
expect(res.upgradedHash).toBeUndefined();
|
||||
});
|
||||
|
||||
it("validates an existing modern hash with no upgrade", async () => {
|
||||
const stored = await hashPassword("modern");
|
||||
const res = await checkLogin("modern", stored, { convertPasswords: true });
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toBeUndefined();
|
||||
});
|
||||
it("validates an existing modern hash with no upgrade", async () => {
|
||||
const stored = await hashPassword("modern");
|
||||
const res = await checkLogin("modern", stored, { convertPasswords: true });
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+57
-46
@@ -6,10 +6,10 @@ import { argon2id, argon2Verify, md5 } from "hash-wasm";
|
||||
// threads=1; bcrypt rounds=12 as the legacy fallback. The game emulator
|
||||
// validates the SAME users.password hash, so these must match.
|
||||
const ARGON2_PARAMS = {
|
||||
parallelism: 1,
|
||||
iterations: 4,
|
||||
memorySize: 65536, // KiB
|
||||
hashLength: 32,
|
||||
parallelism: 1,
|
||||
iterations: 4,
|
||||
memorySize: 65536, // KiB
|
||||
hashLength: 32,
|
||||
} as const;
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
|
||||
@@ -18,7 +18,9 @@ const BCRYPT_ROUNDS = 12;
|
||||
// - "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 process.env.PASSWORD_HASH?.toLowerCase() === "argon2id" ? "argon2id" : "bcrypt";
|
||||
return process.env.PASSWORD_HASH?.toLowerCase() === "argon2id"
|
||||
? "argon2id"
|
||||
: "bcrypt";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,7 +32,7 @@ function hashDriver(): "bcrypt" | "argon2id" {
|
||||
* and does NOT affect credential security.
|
||||
*/
|
||||
export async function md5Hex(input: string): Promise<string> {
|
||||
return await md5(input);
|
||||
return await md5(input);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,23 +41,29 @@ export async function md5Hex(input: string): Promise<string> {
|
||||
* for argon2id (requires a wider column). Both are verifiable by verifyPassword.
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
if (hashDriver() === "argon2id") {
|
||||
return argon2id({
|
||||
password,
|
||||
salt: randomBytes(16),
|
||||
outputType: "encoded",
|
||||
...ARGON2_PARAMS,
|
||||
});
|
||||
}
|
||||
// bcryptjs emits $2a$/$2b$; normalise to the PHP-canonical $2y$ the emulator
|
||||
// and existing AtomCMS rows use.
|
||||
const h = await bcryptHash(password, BCRYPT_ROUNDS);
|
||||
return h.replace(/^\$2[ab]\$/, "$2y$");
|
||||
if (hashDriver() === "argon2id") {
|
||||
return argon2id({
|
||||
password,
|
||||
salt: randomBytes(16),
|
||||
outputType: "encoded",
|
||||
...ARGON2_PARAMS,
|
||||
});
|
||||
}
|
||||
// bcryptjs emits $2a$/$2b$; normalise to the PHP-canonical $2y$ the emulator
|
||||
// and existing AtomCMS rows use.
|
||||
const h = await bcryptHash(password, BCRYPT_ROUNDS);
|
||||
return h.replace(/^\$2[ab]\$/, "$2y$");
|
||||
}
|
||||
|
||||
/** True when `stored` is exactly the md5 of `password` (legacy AtomCMS accounts). */
|
||||
export async function isMd5Of(password: string, stored: string): Promise<boolean> {
|
||||
return /^[a-f0-9]{32}$/i.test(stored) && (await md5Hex(password)) === stored.toLowerCase();
|
||||
export async function isMd5Of(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
/^[a-f0-9]{32}$/i.test(stored) &&
|
||||
(await md5Hex(password)) === stored.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,28 +71,31 @@ export async function isMd5Of(password: string, stored: string): Promise<boolean
|
||||
* 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<boolean> {
|
||||
if (stored.startsWith("$argon2")) {
|
||||
try {
|
||||
return await argon2Verify({ password, hash: stored });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (/^\$2[aby]\$/.test(stored)) {
|
||||
try {
|
||||
return await bcryptCompare(password, stored);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
if (stored.startsWith("$argon2")) {
|
||||
try {
|
||||
return await argon2Verify({ password, hash: stored });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (/^\$2[aby]\$/.test(stored)) {
|
||||
try {
|
||||
return await bcryptCompare(password, stored);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface LoginCheck {
|
||||
valid: boolean;
|
||||
/** Set when a legacy md5 hash was upgraded — persist it to users.password. */
|
||||
upgradedHash?: string;
|
||||
valid: boolean;
|
||||
/** Set when a legacy md5 hash was upgraded — persist it to users.password. */
|
||||
upgradedHash?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,12 +104,12 @@ export interface LoginCheck {
|
||||
* Mirrors RedirectIfTwoFactorAuthenticatable::convertUserPassword + validate.
|
||||
*/
|
||||
export async function checkLogin(
|
||||
password: string,
|
||||
stored: string,
|
||||
opts: { convertPasswords: boolean },
|
||||
password: string,
|
||||
stored: string,
|
||||
opts: { convertPasswords: boolean },
|
||||
): Promise<LoginCheck> {
|
||||
if (opts.convertPasswords && (await isMd5Of(password, stored))) {
|
||||
return { valid: true, upgradedHash: await hashPassword(password) };
|
||||
}
|
||||
return { valid: await verifyPassword(password, stored) };
|
||||
if (opts.convertPasswords && (await isMd5Of(password, stored))) {
|
||||
return { valid: true, upgradedHash: await hashPassword(password) };
|
||||
}
|
||||
return { valid: await verifyPassword(password, stored) };
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
export const USER_TOKENABLE_TYPE = "App\\Models\\User";
|
||||
|
||||
export function personalTokenScope(userId: number) {
|
||||
return {
|
||||
tokenableId: BigInt(userId),
|
||||
tokenableType: USER_TOKENABLE_TYPE,
|
||||
} as const;
|
||||
return {
|
||||
tokenableId: BigInt(userId),
|
||||
tokenableType: USER_TOKENABLE_TYPE,
|
||||
} as const;
|
||||
}
|
||||
@@ -2,24 +2,34 @@ import { describe, expect, it } from "vitest";
|
||||
import { databaseUserId, sessionUserId } from "@/lib/auth/session-user";
|
||||
|
||||
describe("sessionUserId", () => {
|
||||
it("returns a positive safe integer from a valid session id", () => {
|
||||
expect(sessionUserId("42")).toBe(42);
|
||||
});
|
||||
it("returns a positive safe integer from a valid session id", () => {
|
||||
expect(sessionUserId("42")).toBe(42);
|
||||
});
|
||||
|
||||
it.each([undefined, null, "", "0", "-1", "1.5", "abc", Number.MAX_SAFE_INTEGER + 1])(
|
||||
"rejects invalid session id %s",
|
||||
(value) => {
|
||||
expect(sessionUserId(value)).toBeNull();
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
undefined,
|
||||
null,
|
||||
"",
|
||||
"0",
|
||||
"-1",
|
||||
"1.5",
|
||||
"abc",
|
||||
Number.MAX_SAFE_INTEGER + 1,
|
||||
])("rejects invalid session id %s", (value) => {
|
||||
expect(sessionUserId(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("databaseUserId", () => {
|
||||
it("converts a safe positive database id", () => {
|
||||
expect(databaseUserId(42n)).toBe(42);
|
||||
});
|
||||
it("converts a safe positive database id", () => {
|
||||
expect(databaseUserId(42n)).toBe(42);
|
||||
});
|
||||
|
||||
it.each([0n, -1n, BigInt(Number.MAX_SAFE_INTEGER) + 1n])("rejects unsafe database id %s", (value) => {
|
||||
expect(databaseUserId(value)).toBeNull();
|
||||
});
|
||||
it.each([
|
||||
0n,
|
||||
-1n,
|
||||
BigInt(Number.MAX_SAFE_INTEGER) + 1n,
|
||||
])("rejects unsafe database id %s", (value) => {
|
||||
expect(databaseUserId(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
export function sessionUserId(value: unknown): number | null {
|
||||
const id = typeof value === "string" && /^\d+$/.test(value) ? Number(value) : NaN;
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
const id =
|
||||
typeof value === "string" && /^\d+$/.test(value) ? Number(value) : NaN;
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
export function databaseUserId(value: bigint): number | null {
|
||||
const id = Number(value);
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
const id = Number(value);
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
@@ -1,34 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { generateSsoTicket, issueSsoTicket } from "./sso-ticket";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
describe("generateSsoTicket", () => {
|
||||
it("uses '{hotelName-without-spaces}-{uuidv4}'", () => {
|
||||
const t = generateSsoTicket("Atom Hotel");
|
||||
expect(t.startsWith("AtomHotel-")).toBe(true);
|
||||
expect(UUID_RE.test(t.slice("AtomHotel-".length))).toBe(true);
|
||||
});
|
||||
it("uses '{hotelName-without-spaces}-{uuidv4}'", () => {
|
||||
const t = generateSsoTicket("Atom Hotel");
|
||||
expect(t.startsWith("AtomHotel-")).toBe(true);
|
||||
expect(UUID_RE.test(t.slice("AtomHotel-".length))).toBe(true);
|
||||
});
|
||||
|
||||
it("strips every space in the hotel name", () => {
|
||||
expect(generateSsoTicket("My Cool Hotel").startsWith("MyCoolHotel-")).toBe(true);
|
||||
});
|
||||
it("strips every space in the hotel name", () => {
|
||||
expect(generateSsoTicket("My Cool Hotel").startsWith("MyCoolHotel-")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("produces a fresh ticket each call", () => {
|
||||
expect(generateSsoTicket("Atom")).not.toBe(generateSsoTicket("Atom"));
|
||||
});
|
||||
it("produces a fresh ticket each call", () => {
|
||||
expect(generateSsoTicket("Atom")).not.toBe(generateSsoTicket("Atom"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("issueSsoTicket", () => {
|
||||
it("writes auth_ticket AND ip_current and returns the ticket", async () => {
|
||||
const update = vi.fn().mockResolvedValue(undefined);
|
||||
const db = { user: { update } };
|
||||
const ticket = await issueSsoTicket(db, 42, "Atom Hotel", "1.2.3.4");
|
||||
it("writes auth_ticket AND ip_current and returns the ticket", async () => {
|
||||
const update = vi.fn().mockResolvedValue(undefined);
|
||||
const db = { user: { update } };
|
||||
const ticket = await issueSsoTicket(db, 42, "Atom Hotel", "1.2.3.4");
|
||||
|
||||
expect(ticket.startsWith("AtomHotel-")).toBe(true);
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: 42 },
|
||||
data: { authTicket: ticket, ipCurrent: "1.2.3.4" },
|
||||
});
|
||||
});
|
||||
expect(ticket.startsWith("AtomHotel-")).toBe(true);
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: 42 },
|
||||
data: { authTicket: ticket, ipCurrent: "1.2.3.4" },
|
||||
});
|
||||
});
|
||||
});
|
||||
+18
-18
@@ -8,18 +8,18 @@ import { randomUUID } from "node:crypto";
|
||||
* The emulator validates this exact value when the Nitro/Flash client connects.
|
||||
*/
|
||||
export function generateSsoTicket(hotelName: string): string {
|
||||
const normalized = hotelName.replace(/ /g, "");
|
||||
return `${normalized}-${randomUUID()}`;
|
||||
const normalized = hotelName.replace(/ /g, "");
|
||||
return `${normalized}-${randomUUID()}`;
|
||||
}
|
||||
|
||||
/** Minimal shape of the Prisma client this needs (keeps it unit-testable). */
|
||||
export interface SsoUserUpdater {
|
||||
user: {
|
||||
update(args: {
|
||||
where: { id: number };
|
||||
data: { authTicket: string; ipCurrent: string };
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
user: {
|
||||
update(args: {
|
||||
where: { id: number };
|
||||
data: { authTicket: string; ipCurrent: string };
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,15 +27,15 @@ export interface SsoUserUpdater {
|
||||
* ip_current on the user, then returns the ticket for the client launcher.
|
||||
*/
|
||||
export async function issueSsoTicket(
|
||||
db: SsoUserUpdater,
|
||||
userId: number,
|
||||
hotelName: string,
|
||||
ip: string,
|
||||
db: SsoUserUpdater,
|
||||
userId: number,
|
||||
hotelName: string,
|
||||
ip: string,
|
||||
): Promise<string> {
|
||||
const ticket = generateSsoTicket(hotelName);
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: { authTicket: ticket, ipCurrent: ip },
|
||||
});
|
||||
return ticket;
|
||||
const ticket = generateSsoTicket(hotelName);
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: { authTicket: ticket, ipCurrent: ip },
|
||||
});
|
||||
return ticket;
|
||||
}
|
||||
+24
-19
@@ -1,26 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateTotp, generateTotpSecret, totpKeyUri, verifyTotp } from "./totp";
|
||||
import {
|
||||
generateTotp,
|
||||
generateTotpSecret,
|
||||
totpKeyUri,
|
||||
verifyTotp,
|
||||
} from "./totp";
|
||||
|
||||
describe("totp", () => {
|
||||
const SECRET = generateTotpSecret();
|
||||
it("verifies the current generated code", () => {
|
||||
const code = generateTotp(SECRET);
|
||||
expect(code).toMatch(/^\d{6}$/);
|
||||
expect(verifyTotp(code, SECRET)).toBe(true);
|
||||
});
|
||||
const SECRET = generateTotpSecret();
|
||||
it("verifies the current generated code", () => {
|
||||
const code = generateTotp(SECRET);
|
||||
expect(code).toMatch(/^\d{6}$/);
|
||||
expect(verifyTotp(code, SECRET)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a wrong code", () => {
|
||||
expect(verifyTotp("000000", SECRET)).toBe(false);
|
||||
});
|
||||
it("rejects a wrong code", () => {
|
||||
expect(verifyTotp("000000", SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects malformed input without throwing", () => {
|
||||
expect(verifyTotp("not-a-code", SECRET)).toBe(false);
|
||||
});
|
||||
it("rejects malformed input without throwing", () => {
|
||||
expect(verifyTotp("not-a-code", SECRET)).toBe(false);
|
||||
});
|
||||
|
||||
it("builds an otpauth provisioning URI", () => {
|
||||
const uri = totpKeyUri(SECRET, "alice", "AtomHotel");
|
||||
expect(uri.startsWith("otpauth://totp/")).toBe(true);
|
||||
expect(uri).toContain("secret=" + SECRET);
|
||||
expect(uri).toContain("issuer=AtomHotel");
|
||||
});
|
||||
it("builds an otpauth provisioning URI", () => {
|
||||
const uri = totpKeyUri(SECRET, "alice", "AtomHotel");
|
||||
expect(uri.startsWith("otpauth://totp/")).toBe(true);
|
||||
expect(uri).toContain(`secret=${SECRET}`);
|
||||
expect(uri).toContain("issuer=AtomHotel");
|
||||
});
|
||||
});
|
||||
+13
-9
@@ -6,24 +6,28 @@ authenticator.options = { window: 1 };
|
||||
|
||||
/** Verify a 6-digit TOTP code against a base32 secret. */
|
||||
export function verifyTotp(token: string, secret: string): boolean {
|
||||
try {
|
||||
return authenticator.check(token, secret);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return authenticator.check(token, secret);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Current TOTP code for a secret (used in tests / tooling). */
|
||||
export function generateTotp(secret: string): string {
|
||||
return authenticator.generate(secret);
|
||||
return authenticator.generate(secret);
|
||||
}
|
||||
|
||||
/** Generate a fresh base32 secret for enrolling a new authenticator. */
|
||||
export function generateTotpSecret(): string {
|
||||
return authenticator.generateSecret();
|
||||
return authenticator.generateSecret();
|
||||
}
|
||||
|
||||
/** otpauth:// URI for provisioning a QR code. */
|
||||
export function totpKeyUri(secret: string, accountName: string, issuer: string): string {
|
||||
return authenticator.keyuri(accountName, issuer, secret);
|
||||
export function totpKeyUri(
|
||||
secret: string,
|
||||
accountName: string,
|
||||
issuer: string,
|
||||
): string {
|
||||
return authenticator.keyuri(accountName, issuer, secret);
|
||||
}
|
||||
Reference in new issue
Block a user