Files
UHD_Media_Tool/apps/server/test/auth-settings/account-security.test.ts
T
rootandClaude Opus 5.5 b0e5d539ed feat(auth): TOTP setup endpoint, recovery codes, passkeys and master key rotation
- GET /auth/totp/setup returns the pending secret and otpauth URL (created
  on first call, reused on refresh) for the console to render as a QR code.
- Enabling TOTP issues 8 one-time recovery codes (argon2id hashes in
  admin_recovery_code); login accepts recoveryCode in place of totpCode,
  disable accepts either, and recovery_codes regenerates with the password.
- Passkeys via @simplewebauthn/server: register/list/rename/delete under
  the console session, public login options/verify that reuse the login
  rate limit and session cookies. Challenges are one-time rows in
  webauthn_challenge; RP ID and origins come from WEBAUTHN_RP_ID /
  WEBAUTHN_ORIGINS, falling back to the request host. Signature counter
  regressions are rejected. SessionInfo gains passkeyEnabled.
- --role rotate-master-key re-encrypts the secret table from
  SECRET_MASTER_KEY to SECRET_MASTER_KEY_NEW one row per transaction,
  skips rows already on the new key (resumable, swap keys to roll back),
  logs progress and audits secret.master_key_rotate. Secrets now record
  the master key fingerprint in kdf_params.kid.
- Runbook: recovery codes, passkey configuration, rotation steps and
  rollback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 20:22:07 +08:00

233 lines
12 KiB
TypeScript

import { sql } from "@umt/db";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { ensureSetupToken, MAX_FAILURES_PER_IP } from "../../src/modules/auth-settings/auth-service.ts";
import { totpAt, totpCounter } from "../../src/modules/auth-settings/crypto.ts";
import { resolveRp } from "../../src/modules/auth-settings/passkey.ts";
import { DEV_KEY, json } from "../helpers.ts";
import { Browser, bearer, createHarness, type Harness, pgAvailable } from "./harness.ts";
import { SoftAuthenticator } from "./soft-authenticator.ts";
// C1-B 账户与安全:TOTP 二维码 setup 端点、一次性恢复码、Passkey 注册 / 登录 / 命名 / 删除(真实 @simplewebauthn/server 校验)。
const hasPg = await pgAvailable();
const PASSWORD = "correct horse battery";
describe("resolveRp", () => {
it("配置优先;未配置时取请求主机名(去端口、转小写)", () => {
expect(resolveRp({}, "Media.Example.com:8443")).toMatchObject({ rpID: "media.example.com", origins: null });
expect(
resolveRp(
{ WEBAUTHN_RP_ID: "example.com", WEBAUTHN_ORIGINS: "https://a.example.com/, https://example.com" },
"x",
),
).toMatchObject({ rpID: "example.com", origins: ["https://a.example.com", "https://example.com"] });
expect(resolveRp({}, null).rpID).toBe("localhost");
});
});
describe.skipIf(!hasPg)("账户与安全:恢复码 / Passkey(真实 PG)", () => {
let h: Harness;
beforeAll(async () => {
h = await createHarness();
const token = await ensureSetupToken(h.ctx);
const r = await new Browser(h.app).req("POST", "/auth/setup", {
setupToken: token,
username: "admin",
password: PASSWORD,
});
expect(r.status).toBe(200);
}, 60_000);
afterAll(async () => {
await h?.close();
});
beforeEach(async () => {
await sql`delete from login_attempt`.execute(h.db);
});
const login = (b: Browser, extra: Record<string, unknown> = {}) =>
b.req("POST", "/auth/login", { username: "admin", password: PASSWORD, ...extra });
const audits = async (action: string) =>
(await sql<{ n: number }>`select count(*)::int as n from audit_event where action = ${action}`.execute(h.db))
.rows[0]!.n;
it("TOTP:GET /auth/totp/setup 给 otpauth URL(重复请求不换密钥)→ 启用返回 8 个恢复码 → 恢复码登录一次性 → 重新生成 → 用恢复码停用", async () => {
const a = new Browser(h.app);
expect((await login(a)).status).toBe(200);
// API Key 不能取密钥
expect((await bearer(h.app, DEV_KEY)("GET", "/auth/totp/setup")).status).toBe(403);
const s1 = (await json(await a.req("GET", "/auth/totp/setup"))).data;
expect(s1.otpauthUri).toMatch(/^otpauth:\/\/totp\/UHD%20Media%20Tool%3Aadmin\?secret=[A-Z2-7]+&issuer=/);
expect(s1).toMatchObject({ issuer: "UHD Media Tool", account: "admin" });
const s2 = (await json(await a.req("GET", "/auth/totp/setup"))).data;
expect(s2.secret).toBe(s1.secret);
expect((await json(await a.req("GET", "/auth/totp"))).data).toEqual({
enabled: false,
pending: true,
recoveryCodesRemaining: 0,
});
const code0 = totpAt(s1.secret, totpCounter());
const en = (await json(await a.req("POST", "/auth/totp", { action: "enable", code: code0 }))).data;
expect(en.recoveryCodes).toHaveLength(8);
expect(new Set(en.recoveryCodes).size).toBe(8);
for (const c of en.recoveryCodes) expect(c).toMatch(/^[A-Z2-9]{5}-[A-Z2-9]{5}$/);
expect(en.status).toMatchObject({ enabled: true, pending: false, recoveryCodesRemaining: 8 });
// 已开启:setup 端点 409
expect((await a.req("GET", "/auth/totp/setup")).status).toBe(409);
// 库里只存哈希
const rows = await sql<{ code_hash: string }>`select code_hash from admin_recovery_code`.execute(h.db);
expect(rows.rows).toHaveLength(8);
for (const r of rows.rows) {
expect(r.code_hash).toMatch(/^\$argon2id\$/);
expect(en.recoveryCodes.some((c: string) => r.code_hash.includes(c.replace("-", "")))).toBe(false);
}
// 恢复码登录:小写 + 去掉连字符也认;同一个码第二次失败
const rc: string = en.recoveryCodes[0];
const b = new Browser(h.app, "203.0.113.31");
expect((await json(await login(b))).data).toEqual({ status: "totp_required" });
const ok = await login(b, { recoveryCode: rc.replace("-", "").toLowerCase() });
expect(ok.status).toBe(200);
expect(b.cookies.has("umt_session")).toBe(true);
expect((await login(new Browser(h.app, "203.0.113.32"), { recoveryCode: rc })).status).toBe(401);
expect(await audits("admin.recovery_code_login")).toBe(1);
expect((await json(await a.req("GET", "/auth/totp"))).data.recoveryCodesRemaining).toBe(7);
const fails = await sql<{
n: number;
}>`select count(*)::int as n from login_attempt where reason = 'bad_recovery_code'`.execute(h.db);
expect(fails.rows[0]!.n).toBe(1);
// 重新生成:要密码;旧码全部作废
expect((await a.req("POST", "/auth/totp", { action: "recovery_codes", password: "wrong-password" })).status).toBe(
401,
);
const re = (await json(await a.req("POST", "/auth/totp", { action: "recovery_codes", password: PASSWORD }))).data;
expect(re.recoveryCodes).toHaveLength(8);
expect(re.status.recoveryCodesRemaining).toBe(8);
expect((await login(new Browser(h.app, "203.0.113.33"), { recoveryCode: en.recoveryCodes[1] })).status).toBe(401);
expect(await audits("admin.recovery_codes_regenerate")).toBe(1);
// 验证器丢了:用恢复码停用
const off = await a.req("POST", "/auth/totp", { action: "disable", password: PASSWORD, code: re.recoveryCodes[0] });
expect(off.status).toBe(200);
expect((await json(off)).data.status).toEqual({ enabled: false, pending: false, recoveryCodesRemaining: 0 });
expect(
(await sql<{ n: number }>`select count(*)::int as n from admin_recovery_code`.execute(h.db)).rows[0]!.n,
).toBe(0);
expect((await json(await login(new Browser(h.app, "203.0.113.34")))).data.status).toBe("ok");
});
it("Passkey:注册(挑战一次性)→ 多把、命名 → 登录页入口 → Passkey 登录建会话 → 重放 / 错来源 / 计数器回退拒绝 → 删除", async () => {
const a = new Browser(h.app);
expect((await login(a)).status).toBe(200);
expect((await json(await new Browser(h.app).req("GET", "/auth/session"))).data.passkeyEnabled).toBe(false);
// 没有 Passkey 时登录挑战 412
expect((await new Browser(h.app).req("POST", "/auth/passkeys/login/options")).status).toBe(412);
// API Key 不能登记
expect((await bearer(h.app, DEV_KEY)("POST", "/auth/passkeys/register/options")).status).toBe(403);
const reg = async (auth: SoftAuthenticator, name: string) => {
const o = (await json(await a.req("POST", "/auth/passkeys/register/options"))).data;
expect(o.options).toMatchObject({ rp: { id: "localhost", name: "UHD Media Tool" }, user: { name: "admin" } });
const res = await a.req("POST", "/auth/passkeys/register/verify", {
challengeId: o.challengeId,
name,
response: auth.register(o.options),
});
return { o, res };
};
const k1 = new SoftAuthenticator();
const r1 = await reg(k1, "MacBook 指纹");
expect(r1.res.status).toBe(201);
expect((await json(r1.res)).data).toMatchObject({ name: "MacBook 指纹", lastUsedAt: null });
// 同一个挑战不能再用
const again = await a.req("POST", "/auth/passkeys/register/verify", {
challengeId: r1.o.challengeId,
name: "x",
response: new SoftAuthenticator().register(r1.o.options),
});
expect(again.status).toBe(412);
// 来源不属于 RP ID → 拒绝
const o2 = (await json(await a.req("POST", "/auth/passkeys/register/options"))).data;
expect(o2.options.excludeCredentials.map((c: { id: string }) => c.id)).toEqual([k1.id]);
const evil = await a.req("POST", "/auth/passkeys/register/verify", {
challengeId: o2.challengeId,
name: "evil",
response: new SoftAuthenticator().register(o2.options, { origin: "https://evil.example" }),
});
expect(evil.status).toBe(401);
const k2 = new SoftAuthenticator();
expect((await reg(k2, "YubiKey")).res.status).toBe(201);
const list = (await json(await a.req("GET", "/auth/passkeys"))).data.items;
expect(list.map((p: { name: string }) => p.name)).toEqual(["MacBook 指纹", "YubiKey"]);
const renamed = await a.req("PATCH", `/auth/passkeys/${list[1].id}`, { name: "备用钥匙" });
expect((await json(renamed)).data.name).toBe("备用钥匙");
// 登录页:未登录也能知道有 Passkey
const guest = new Browser(h.app, "203.0.113.41");
expect((await json(await guest.req("GET", "/auth/session"))).data).toMatchObject({
authenticated: false,
passkeyEnabled: true,
});
const lo = (await json(await guest.req("POST", "/auth/passkeys/login/options"))).data;
expect(lo.options.allowCredentials.map((c: { id: string }) => c.id).sort()).toEqual([k1.id, k2.id].sort());
const assertion = k1.assert(lo.options);
const res = await guest.req("POST", "/auth/passkeys/login/verify", {
challengeId: lo.challengeId,
response: assertion,
});
expect(res.status).toBe(200);
expect((await json(res)).data).toMatchObject({
status: "ok",
session: { authenticated: true, passkeyEnabled: true },
});
expect(guest.cookies.has("umt_session")).toBe(true);
expect(guest.cookies.has("umt_csrf")).toBe(true);
expect((await guest.req("GET", "/settings")).status).toBe(200);
expect(await audits("admin.passkey_login")).toBe(1);
const used = (await json(await a.req("GET", "/auth/passkeys"))).data.items[0];
expect(used.lastUsedAt).toBeTruthy();
// 重放同一个断言(挑战已消费)→ 412;新挑战 + 旧计数器 → 401(计数器回退)
const replay = await new Browser(h.app, "203.0.113.42").req("POST", "/auth/passkeys/login/verify", {
challengeId: lo.challengeId,
response: assertion,
});
expect(replay.status).toBe(412);
const lo2 = (await json(await new Browser(h.app, "203.0.113.43").req("POST", "/auth/passkeys/login/options"))).data;
const stale = await new Browser(h.app, "203.0.113.43").req("POST", "/auth/passkeys/login/verify", {
challengeId: lo2.challengeId,
response: k1.assert(lo2.options, { counter: 1 }),
});
expect(stale.status).toBe(401);
// 没登记的钥匙 → 401
const lo3 = (await json(await new Browser(h.app, "203.0.113.44").req("POST", "/auth/passkeys/login/options"))).data;
const unknown = await new Browser(h.app, "203.0.113.44").req("POST", "/auth/passkeys/login/verify", {
challengeId: lo3.challengeId,
response: new SoftAuthenticator().assert(lo3.options),
});
expect(unknown.status).toBe(401);
// 删除:只剩一把
expect((await a.req("DELETE", `/auth/passkeys/${list[0].id}`)).status).toBe(200);
expect((await a.req("DELETE", `/auth/passkeys/${list[0].id}`)).status).toBe(404);
expect((await json(await a.req("GET", "/auth/passkeys"))).data.items).toHaveLength(1);
expect(await audits("admin.passkey_register")).toBe(2);
expect(await audits("admin.passkey_remove")).toBe(1);
});
it("Passkey 登录与密码登录共用 IP 限速", async () => {
const ip = "203.0.113.50";
for (let i = 0; i < MAX_FAILURES_PER_IP; i++) {
const o = (await json(await new Browser(h.app, ip).req("POST", "/auth/passkeys/login/options"))).data;
const r = await new Browser(h.app, ip).req("POST", "/auth/passkeys/login/verify", {
challengeId: o.challengeId,
response: new SoftAuthenticator().assert(o.options),
});
expect(r.status).toBe(401);
}
expect((await new Browser(h.app, ip).req("POST", "/auth/passkeys/login/options")).status).toBe(429);
expect((await login(new Browser(h.app, ip))).status).toBe(429);
});
});