Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
229 lines
11 KiB
TypeScript
229 lines
11 KiB
TypeScript
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<string, unknown> = {}) =>
|
|
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);
|
|
// 日志模块「登录」类:失败也留一条审计(用户名、IP、原因,不含密码)
|
|
const failed = await sql<{ actor_type: string; payload: Record<string, unknown> }>`
|
|
select actor_type, payload from audit_event where action = 'admin.login_failed'`.execute(h.db);
|
|
expect(failed.rows).toHaveLength(1);
|
|
expect(failed.rows[0]!.payload).toMatchObject({ username: "admin", ip: "203.0.113.10" });
|
|
expect(JSON.stringify(failed.rows[0]!.payload)).not.toContain("wrong-password-1");
|
|
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");
|
|
});
|
|
});
|