diff --git a/package.json b/package.json index 5dc1e619..674e0e4b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "dependencies": { "@prisma/adapter-mariadb": "^7.8.0", "@prisma/client": "^7.8.0", + "bcryptjs": "^3.0.2", + "hash-wasm": "^4.12.0", + "otplib": "^12.0.1", "zod": "^3.24.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d670d71b..afb1b781 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,15 @@ importers: '@prisma/client': specifier: ^7.8.0 version: 7.8.0(prisma@7.8.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.9.3))(typescript@5.9.3) + bcryptjs: + specifier: ^3.0.2 + version: 3.0.3 + hash-wasm: + specifier: ^4.12.0 + version: 4.12.0 + otplib: + specifier: ^12.0.1 + version: 12.0.1 zod: specifier: ^3.24.0 version: 3.25.76 @@ -671,6 +680,24 @@ packages: '@kurkle/color@0.3.4': resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@otplib/core@12.0.1': + resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==} + + '@otplib/plugin-crypto@12.0.1': + resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==} + deprecated: Please upgrade to v13 of otplib. Refer to otplib docs for migration paths + + '@otplib/plugin-thirty-two@12.0.1': + resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==} + deprecated: Please upgrade to v13 of otplib. Refer to otplib docs for migration paths + + '@otplib/preset-default@12.0.1': + resolution: {integrity: sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==} + deprecated: Please upgrade to v13 of otplib. Refer to otplib docs for migration paths + + '@otplib/preset-v11@12.0.1': + resolution: {integrity: sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==} + '@petamoriken/float16@3.9.3': resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} @@ -1007,6 +1034,10 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} + hasBin: true + better-result@2.9.2: resolution: {integrity: sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==} @@ -1277,6 +1308,9 @@ packages: graphmatch@1.1.1: resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} + hash-wasm@4.12.0: + resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==} + hono@4.12.27: resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} engines: {node: '>=16.9.0'} @@ -1354,6 +1388,9 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + otplib@12.0.1: + resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -1500,6 +1537,10 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + thirty-two@1.0.2: + resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==} + engines: {node: '>=0.2.6'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1938,6 +1979,29 @@ snapshots: '@kurkle/color@0.3.4': {} + '@otplib/core@12.0.1': {} + + '@otplib/plugin-crypto@12.0.1': + dependencies: + '@otplib/core': 12.0.1 + + '@otplib/plugin-thirty-two@12.0.1': + dependencies: + '@otplib/core': 12.0.1 + thirty-two: 1.0.2 + + '@otplib/preset-default@12.0.1': + dependencies: + '@otplib/core': 12.0.1 + '@otplib/plugin-crypto': 12.0.1 + '@otplib/plugin-thirty-two': 12.0.1 + + '@otplib/preset-v11@12.0.1': + dependencies: + '@otplib/core': 12.0.1 + '@otplib/plugin-crypto': 12.0.1 + '@otplib/plugin-thirty-two': 12.0.1 + '@petamoriken/float16@3.9.3': {} '@prisma/adapter-mariadb@7.8.0': @@ -2233,6 +2297,8 @@ snapshots: aws-ssl-profiles@1.1.2: {} + bcryptjs@3.0.3: {} + better-result@2.9.2: {} buffer-from@1.1.2: {} @@ -2491,6 +2557,8 @@ snapshots: graphmatch@1.1.1: {} + hash-wasm@4.12.0: {} + hono@4.12.27: {} http-status-codes@2.3.0: {} @@ -2567,6 +2635,12 @@ snapshots: ohash@2.0.11: {} + otplib@12.0.1: + dependencies: + '@otplib/core': 12.0.1 + '@otplib/preset-default': 12.0.1 + '@otplib/preset-v11': 12.0.1 + path-key@3.1.1: {} pathe@1.1.2: {} @@ -2710,6 +2784,8 @@ snapshots: std-env@3.10.0: {} + thirty-two@1.0.2: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts new file mode 100644 index 00000000..d38324e1 --- /dev/null +++ b/src/lib/auth/index.ts @@ -0,0 +1,15 @@ +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 { generateTotp, totpKeyUri, verifyTotp } from "./totp"; diff --git a/src/lib/auth/laravel-encrypter.test.ts b/src/lib/auth/laravel-encrypter.test.ts new file mode 100644 index 00000000..1d8e1adc --- /dev/null +++ b/src/lib/auth/laravel-encrypter.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + LaravelEncrypter, + phpSerializeString, + phpUnserializeString, +} from "./laravel-encrypter"; + +// A deterministic 32-byte key in Laravel's "base64:" form. +const APP_KEY = `base64:${Buffer.from("0123456789abcdef0123456789abcdef").toString("base64")}`; + +describe("LaravelEncrypter", () => { + 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 secret = "JBSWY3DPEHPK3PXP"; // a TOTP secret + const payload = enc.encrypt(secret); + expect(payload).not.toContain(secret); + expect(enc.decrypt(payload)).toBe(secret); + }); + + 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 MAC is tampered", () => { + const enc = new LaravelEncrypter(APP_KEY); + const payload = enc.encrypt("x"); + const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")); + json.mac = "00".repeat(32); + const tampered = Buffer.from(JSON.stringify(json), "utf8").toString("base64"); + expect(() => enc.decrypt(tampered)).toThrow(/MAC is invalid/); + }); + + 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("round-trips including multibyte", () => { + expect(phpUnserializeString(phpSerializeString("café"))).toBe("café"); + expect(phpUnserializeString('s:5:"hello";')).toBe("hello"); + }); +}); diff --git a/src/lib/auth/laravel-encrypter.ts b/src/lib/auth/laravel-encrypter.ts new file mode 100644 index 00000000..f2f595c2 --- /dev/null +++ b/src/lib/auth/laravel-encrypter.ts @@ -0,0 +1,89 @@ +import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +/** + * Re-implementation of Laravel's Illuminate\Encryption\Encrypter for the + * AES-256-CBC cipher (config/app.php cipher = 'AES-256-CBC'). Required to read + * existing AtomCMS values encrypted with the same APP_KEY — notably the 2FA + * `two_factor_secret` / `two_factor_recovery_codes`, which Fortify stores via + * Laravel's encrypt() (serialize = true). + * + * Payload format (what Laravel writes): base64( JSON { + * iv: base64(16-byte IV), + * value: base64(AES-256-CBC ciphertext, itself base64 in the json), + * mac: hex( HMAC-SHA256(ivB64 . valueB64, key) ), + * } ) + */ +export class LaravelEncrypter { + 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-CBC (got ${raw.length})`); + } + this.key = raw; + } + + encrypt(value: string, serialize = true): string { + const iv = randomBytes(16); + const data = serialize ? phpSerializeString(value) : value; + const cipher = createCipheriv("aes-256-cbc", this.key, iv); + const valueB64 = cipher.update(data, "utf8", "base64") + cipher.final("base64"); + const ivB64 = iv.toString("base64"); + const mac = this.hmac(ivB64, valueB64); + const payload = JSON.stringify({ iv: ivB64, value: valueB64, mac }); + 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; + mac: string; + }; + const expected = this.hmac(json.iv, json.value); + const a = Buffer.from(expected, "hex"); + const b = Buffer.from(json.mac, "hex"); + if (a.length !== b.length || !timingSafeEqual(a, b)) { + throw new Error("The MAC is invalid."); + } + const iv = Buffer.from(json.iv, "base64"); + const decipher = createDecipheriv("aes-256-cbc", this.key, iv); + const plain = decipher.update(json.value, "base64", "utf8") + decipher.final("utf8"); + return serialize ? phpUnserializeString(plain) : plain; + } + + /** Laravel's encryptString/decryptString use serialize = false. */ + encryptString(value: string): string { + return this.encrypt(value, false); + } + + decryptString(payload: string): string { + return this.decrypt(payload, false); + } + + private hmac(ivB64: string, valueB64: string): string { + return createHmac("sha256", this.key).update(ivB64 + valueB64).digest("hex"); + } +} + +/** PHP serialize() for a string: s::""; */ +export function phpSerializeString(value: string): string { + 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"); +} diff --git a/src/lib/auth/password.test.ts b/src/lib/auth/password.test.ts new file mode 100644 index 00000000..62f8a502 --- /dev/null +++ b/src/lib/auth/password.test.ts @@ -0,0 +1,69 @@ +import { hash as bcryptHash } from "bcryptjs"; +import { describe, expect, it } from "vitest"; +import { + checkLogin, + hashPassword, + isMd5Of, + md5Hex, + verifyPassword, +} from "./password"; + +describe("md5Hex", () => { + it("matches PHP md5() on canonical vectors", () => { + expect(md5Hex("")).toBe("d41d8cd98f00b204e9800998ecf8427e"); + expect(md5Hex("abc")).toBe("900150983cd24fb0d6963f7d28e17f72"); + }); +}); + +describe("argon2id", () => { + it("hashes with the AtomCMS params (m=65536,t=4,p=1) and round-trips", async () => { + 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); + }); +}); + +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); + }); +}); + +describe("isMd5Of", () => { + it("detects a legacy md5 password", () => { + expect(isMd5Of("habbo", md5Hex("habbo"))).toBe(true); + expect(isMd5Of("habbo", md5Hex("other"))).toBe(false); + expect(isMd5Of("habbo", "not-a-hash")).toBe(false); + }); +}); + +describe("checkLogin", () => { + it("upgrades a legacy md5 hash to argon2id when conversion is enabled", async () => { + const stored = md5Hex("oldpass"); + const res = await checkLogin("oldpass", stored, { convertPasswords: true }); + expect(res.valid).toBe(true); + expect(res.upgradedHash).toMatch(/^\$argon2id\$/); + // 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 = md5Hex("oldpass"); + const res = await checkLogin("oldpass", stored, { convertPasswords: false }); + expect(res.valid).toBe(false); + expect(res.upgradedHash).toBeUndefined(); + }); + + it("validates an existing argon2id 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(); + }); +}); diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts new file mode 100644 index 00000000..4176f324 --- /dev/null +++ b/src/lib/auth/password.ts @@ -0,0 +1,78 @@ +import { createHash, randomBytes } from "node:crypto"; +import { compare as bcryptCompare } from "bcryptjs"; +import { argon2id, argon2Verify } from "hash-wasm"; + +// AtomCMS hashing (config/hashing.php): default driver 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. +const ARGON2_PARAMS = { + parallelism: 1, + iterations: 4, + memorySize: 65536, // KiB + hashLength: 32, +} as const; + +/** Lowercase hex md5 of a UTF-8 string (matches PHP md5()). */ +export function md5Hex(input: string): string { + return createHash("md5").update(input, "utf8").digest("hex"); +} + +/** Produce an argon2id hash in PHC format identical to PHP's PASSWORD_ARGON2ID. */ +export async function hashPassword(password: string): Promise { + return argon2id({ + password, + salt: randomBytes(16), + outputType: "encoded", + ...ARGON2_PARAMS, + }); +} + +/** True when `stored` is exactly the md5 of `password` (legacy AtomCMS accounts). */ +export function isMd5Of(password: string, stored: string): boolean { + return /^[a-f0-9]{32}$/i.test(stored) && md5Hex(password) === stored.toLowerCase(); +} + +/** + * 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 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; +} + +/** + * 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, + opts: { convertPasswords: boolean }, +): Promise { + if (opts.convertPasswords && isMd5Of(password, stored)) { + return { valid: true, upgradedHash: await hashPassword(password) }; + } + return { valid: await verifyPassword(password, stored) }; +} diff --git a/src/lib/auth/sso-ticket.test.ts b/src/lib/auth/sso-ticket.test.ts new file mode 100644 index 00000000..8a1bcbf5 --- /dev/null +++ b/src/lib/auth/sso-ticket.test.ts @@ -0,0 +1,34 @@ +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}$/; + +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("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")); + }); +}); + +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"); + + expect(ticket.startsWith("AtomHotel-")).toBe(true); + expect(update).toHaveBeenCalledWith({ + where: { id: 42 }, + data: { authTicket: ticket, ipCurrent: "1.2.3.4" }, + }); + }); +}); diff --git a/src/lib/auth/sso-ticket.ts b/src/lib/auth/sso-ticket.ts new file mode 100644 index 00000000..0ee81348 --- /dev/null +++ b/src/lib/auth/sso-ticket.ts @@ -0,0 +1,41 @@ +import { randomUUID } from "node:crypto"; + +/** + * Build the SSO ticket exactly like AtomCMS's User::ssoTicket(): + * $hotelName = Str::replace(' ', '', setting('hotel_name')); + * sprintf('%s-%s', $hotelName, Str::uuid()); + * i.e. the hotel name with ALL spaces removed, a dash, then a v4 UUID. + * 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()}`; +} + +/** 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; + }; +} + +/** + * Generate a ticket and persist it like AtomCMS: writes auth_ticket AND + * 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, +): Promise { + const ticket = generateSsoTicket(hotelName); + await db.user.update({ + where: { id: userId }, + data: { authTicket: ticket, ipCurrent: ip }, + }); + return ticket; +} diff --git a/src/lib/auth/totp.test.ts b/src/lib/auth/totp.test.ts new file mode 100644 index 00000000..5dd04f46 --- /dev/null +++ b/src/lib/auth/totp.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { generateTotp, totpKeyUri, verifyTotp } from "./totp"; + +const SECRET = "JBSWY3DPEHPK3PXP"; // standard base32 test secret + +describe("totp", () => { + 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 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"); + }); +}); diff --git a/src/lib/auth/totp.ts b/src/lib/auth/totp.ts new file mode 100644 index 00000000..47c993d6 --- /dev/null +++ b/src/lib/auth/totp.ts @@ -0,0 +1,24 @@ +import { authenticator } from "otplib"; + +// Laravel Fortify uses pragmarx/google2fa: HMAC-SHA1, 6 digits, 30s period. +// otplib already defaults to SHA1/6/30; window=1 tolerates one step of skew. +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; + } +} + +/** Current TOTP code for a secret (used in tests / tooling). */ +export function generateTotp(secret: string): string { + return authenticator.generate(secret); +} + +/** otpauth:// URI for provisioning a QR code. */ +export function totpKeyUri(secret: string, accountName: string, issuer: string): string { + return authenticator.keyuri(accountName, issuer, secret); +}