import { sql } from "@umt/db"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { ensureSetupToken, MAX_FAILURES_PER_ACCOUNT, MAX_FAILURES_PER_IP, } from "../../src/modules/auth-settings/auth-service.ts"; import { totpAt, totpCounter } from "../../src/modules/auth-settings/crypto.ts"; import { DEV_KEY, json } from "../helpers.ts"; import { Browser, bearer, createHarness, type Harness, pgAvailable } from "./harness.ts"; const hasPg = await pgAvailable(); const PASSWORD = "correct horse battery"; describe.skipIf(!hasPg)("管理员登录门禁(真实 PG)", () => { let h: Harness; beforeAll(async () => { h = await createHarness(); }, 60_000); afterAll(async () => { await h?.close(); }); beforeEach(async () => { await sql`delete from login_attempt`.execute(h.db); }); const login = (b: Browser, password = PASSWORD, extra: Record = {}) => b.req("POST", "/auth/login", { username: "admin", password, ...extra }); it("未初始化:session 探测免鉴权;setup token 只打印一次到日志;错 token 401;并发初始化只有一个成功", async () => { const b = new Browser(h.app); const s0 = await json(await b.req("GET", "/auth/session")); expect(s0.data).toMatchObject({ initialized: false, authenticated: false, actor: null }); expect((await login(b)).status).toBe(412); const token = await ensureSetupToken(h.ctx); expect(token).toBeTruthy(); expect(h.lines.filter((l) => l.includes(token!))).toHaveLength(1); // 库里只有哈希 const row = await sql<{ setup_token_hash: string }>`select setup_token_hash from admin_user`.execute(h.db); expect(row.rows[0]!.setup_token_hash).not.toContain(token!); const bad = await b.req("POST", "/auth/setup", { setupToken: "x".repeat(32), username: "admin", password: PASSWORD, }); expect(bad.status).toBe(401); // 密码太短 → 422 expect( (await b.req("POST", "/auth/setup", { setupToken: token, username: "admin", password: "short" })).status, ).toBe(422); const [r1, r2] = await Promise.all([ new Browser(h.app, "198.51.100.1").req("POST", "/auth/setup", { setupToken: token, username: "admin", password: PASSWORD, }), new Browser(h.app, "198.51.100.2").req("POST", "/auth/setup", { setupToken: token, username: "admin", password: PASSWORD, }), ]); expect([r1.status, r2.status].sort()).toEqual([200, 409]); const ok = r1.status === 200 ? r1 : r2; const setCookies = ok.headers.getSetCookie(); const sess = setCookies.find((c) => c.startsWith("umt_session="))!; expect(sess).toMatch(/HttpOnly/i); expect(sess).toMatch(/SameSite=Lax/i); expect(setCookies.find((c) => c.startsWith("umt_csrf="))).not.toMatch(/HttpOnly/i); // 已初始化后不再生成 token expect(await ensureSetupToken(h.ctx)).toBeNull(); const audits = await sql<{ n: number; }>`select count(*)::int as n from audit_event where action = 'admin.setup'`.execute(h.db); expect(audits.rows[0]!.n).toBe(1); }); it("登录:错密码 401;正确密码下发 cookie;会话可访问 admin 端点;未登录 401", async () => { const b = new Browser(h.app); expect((await login(b, "wrong-password-1")).status).toBe(401); expect((await b.req("GET", "/settings")).status).toBe(401); const res = await login(b); expect(res.status).toBe(200); expect((await json(res)).data).toMatchObject({ status: "ok", session: { authenticated: true, actor: { type: "admin", name: "admin" } }, }); const s = await json(await b.req("GET", "/auth/session")); expect(s.data.authenticated).toBe(true); expect(s.data.expiresAt).toBeTruthy(); expect((await b.req("GET", "/settings")).status).toBe(200); // whoami 在 cookie 会话下同样可用 expect((await json(await b.req("GET", "/auth/whoami"))).data.kind).toBe("admin_session"); // 库里只存 sha256 const raw = b.cookies.get("umt_session")!; const rows = await sql<{ n: number; }>`select count(*)::int as n from admin_session where session_hash = ${raw}`.execute(h.db); expect(rows.rows[0]!.n).toBe(0); }); it("CSRF:cookie 会话的写请求缺少或伪造 x-csrf-token → 403;带正确值通过;Bearer 不需要", async () => { const b = new Browser(h.app); await login(b); const patch = { automation: { enabled: true } }; expect((await b.req("PATCH", "/settings", patch, { csrf: false })).status).toBe(403); expect( (await b.req("PATCH", "/settings", patch, { csrf: false, headers: { "x-csrf-token": "forged" } })).status, ).toBe(403); expect((await b.req("PATCH", "/settings", patch)).status).toBe(200); expect((await bearer(h.app, DEV_KEY)("PATCH", "/settings", patch)).status).toBe(200); }); it("限速:同 IP 失败 5 次后第 6 次(即使密码正确)→ 429,且被拦请求不落表", async () => { const b = new Browser(h.app, "203.0.113.77"); for (let i = 0; i < MAX_FAILURES_PER_IP; i++) expect((await login(b, `bad-${i}-password`)).status).toBe(401); expect((await login(b)).status).toBe(429); expect((await login(b)).status).toBe(429); const n = await sql<{ n: number }>`select count(*)::int as n from login_attempt where ip = '203.0.113.77'`.execute( h.db, ); expect(n.rows[0]!.n).toBe(MAX_FAILURES_PER_IP); // 其它 IP 不受影响 expect((await login(new Browser(h.app, "203.0.113.78"))).status).toBe(200); }); it("限速:同账号跨 IP 累计失败 15 次 → 新 IP 也 429(防轮换 IP)", async () => { for (let i = 0; i < MAX_FAILURES_PER_ACCOUNT; i++) { expect((await login(new Browser(h.app, `192.0.2.${i + 1}`), `bad-${i}-pw`)).status).toBe(401); } expect((await login(new Browser(h.app, "192.0.2.99"))).status).toBe(429); }); it("登出吊销会话;过期会话 401", async () => { const b = new Browser(h.app); await login(b); const token = b.cookies.get("umt_session")!; expect((await b.req("POST", "/auth/logout")).status).toBe(200); expect(b.cookies.has("umt_session")).toBe(false); b.cookies.set("umt_session", token); expect((await b.req("GET", "/settings")).status).toBe(401); const c = new Browser(h.app); await login(c); await sql`update admin_session set expires_at = now() - interval '1 second' where revoked_at is null`.execute(h.db); expect((await c.req("GET", "/settings")).status).toBe(401); }); it("改密:校验旧密码,吊销其它会话、保留当前会话;API Key 不能改密", async () => { const a = new Browser(h.app); const b = new Browser(h.app); await login(a); await login(b); expect( (await a.req("POST", "/auth/password", { currentPassword: "nope-nope-nope", newPassword: "another password 1" })) .status, ).toBe(401); const r = await a.req("POST", "/auth/password", { currentPassword: PASSWORD, newPassword: "another password 1" }); expect(r.status).toBe(200); expect((await json(r)).data.revokedSessions).toBeGreaterThanOrEqual(1); expect((await a.req("GET", "/settings")).status).toBe(200); expect((await b.req("GET", "/settings")).status).toBe(401); expect((await login(new Browser(h.app))).status).toBe(401); expect((await login(new Browser(h.app), "another password 1")).status).toBe(200); // 改回来,后续用例用 PASSWORD expect( (await a.req("POST", "/auth/password", { currentPassword: "another password 1", newPassword: PASSWORD })).status, ).toBe(200); expect( ( await bearer(h.app, DEV_KEY)("POST", "/auth/password", { currentPassword: PASSWORD, newPassword: "x".repeat(12), }) ).status, ).toBe(403); const audits = await sql<{ n: number; }>`select count(*)::int as n from audit_event where action = 'admin.password_change'`.execute(h.db); expect(audits.rows[0]!.n).toBe(2); }); it("TOTP:绑定 → 启用 → 登录需第二步(不计失败)→ 验证码防重放 → 停用", async () => { const a = new Browser(h.app); await login(a); expect((await json(await a.req("GET", "/auth/totp"))).data).toEqual({ enabled: false, pending: false, recoveryCodesRemaining: 0, }); const begin = (await json(await a.req("POST", "/auth/totp", { action: "begin" }))).data; expect(begin.otpauthUri).toMatch(/^otpauth:\/\/totp\//); const secret: string = begin.secret; expect((await a.req("POST", "/auth/totp", { action: "enable", code: "000000" })).status).toBe(401); const code0 = totpAt(secret, totpCounter()); expect((await a.req("POST", "/auth/totp", { action: "enable", code: code0 })).status).toBe(200); const b = new Browser(h.app, "203.0.113.200"); const step1 = await json(await login(b)); expect(step1.data).toEqual({ status: "totp_required" }); expect(b.cookies.has("umt_session")).toBe(false); // 同一时间窗的码已在 enable 时消费 → 重放失败;下一个窗口的码可用 expect((await login(b, PASSWORD, { totpCode: code0 })).status).toBe(401); const next = totpAt(secret, totpCounter() + 1); expect((await login(b, PASSWORD, { totpCode: next })).status).toBe(200); expect((await login(new Browser(h.app, "203.0.113.201"), PASSWORD, { totpCode: next })).status).toBe(401); const fails = await sql<{ n: number; }>`select count(*)::int as n from login_attempt where reason = 'bad_totp'`.execute(h.db); expect(fails.rows[0]!.n).toBe(2); // 停用需要密码 + 新验证码(计数器已到 now+1,用 now+2 不在窗口内 → 这里直接把计数器回拨模拟时间流逝) await sql`update admin_user set totp_last_counter = totp_last_counter - 5`.execute(h.db); const code = totpAt(secret, totpCounter()); expect((await a.req("POST", "/auth/totp", { action: "disable", password: "bad-password", code })).status).toBe(401); expect((await a.req("POST", "/auth/totp", { action: "disable", password: PASSWORD, code })).status).toBe(200); expect((await json(await login(new Browser(h.app, "203.0.113.202")))).data.status).toBe("ok"); }); });