v2.9.0: 设置新增 ZITADEL 统一登录(OIDC Authorization Code+PKCE,身份主键 (issuer,subject),JIT 建号即管理员,docs/12);账号密码 tab 支持修改用户名(验密+重签会话);新增 docs/API.md 全量 API 文档;迁移 0006(oidc_identities/oidc_states)

This commit is contained in:
bigcode
2026-10-01 21:09:55 +08:00
parent 6e40f555f3
commit 2363c77425
20 changed files with 1645 additions and 52 deletions
+288
View File
@@ -0,0 +1,288 @@
/**
* ZITADEL 统一登录与用户名修改测试(v2.9.0):
* - /auth/status、/auth/login(302 到授权端点)、/auth/callback(state 一次性 + PKCE + JIT 建号/复用)
* - 未绑定 + JIT 关闭拒绝;配置校验(issuer 必须 HTTPS);discovery/token/userinfo 在传输层打桩
* - PUT /api/settings/username:验密、重名 409、改后新旧用户名登录行为、/me 读库返回新名
*/
import { describe, expect, test } from "bun:test";
import { authedFetch, loginCookie, makeTestEnv, type TestEnv } from "./helpers.ts";
import { resetOidcDiscoveryCache } from "../console/src/services/oidc.ts";
/** # 成员变量: 桩 IdP 的 issuer(测试配置写入 settings) **/
const ISSUER = "https://sso.example.test";
/** # 成员变量: 桩返回的 userinfo(subject 可按用例切换) **/
let mockUserInfo: Record<string, unknown> = { sub: "sub-001", name: "Zed", preferred_username: "zed" };
/** # 成员变量: 桩记录到的 IdP 调用(token 请求体里应能验到 PKCE verifier 的存在) **/
let idpCalls: { url: string; body: string | null }[] = [];
/** # 成员变量: 真实 fetch(用例结束后恢复) **/
const REAL_FETCH = globalThis.fetch;
/** # 函数
* 安装 IdP 传输层打桩:discovery / token / userinfo 三个端点,其余放行
*/
function installIdpStub() {
idpCalls = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === `${ISSUER}/.well-known/openid-configuration`) {
return Response.json({
issuer: ISSUER,
authorization_endpoint: `${ISSUER}/oauth/v2/authorize`,
token_endpoint: `${ISSUER}/oauth/v2/token`,
userinfo_endpoint: `${ISSUER}/oauth/v2/userinfo`,
});
}
if (url === `${ISSUER}/oauth/v2/token`) {
idpCalls.push({ url, body: typeof init?.body === "string" ? init.body : null });
return Response.json({ access_token: "at_test", token_type: "Bearer", expires_in: 600 });
}
if (url === `${ISSUER}/oauth/v2/userinfo`) {
return Response.json(mockUserInfo);
}
return REAL_FETCH(input as Request, init);
}) as typeof fetch;
}
/** # 函数
* 在测试 env 里启用统一登录(后台 settings 配置,JIT 可选)
*/
async function enableOidc(t: TestEnv, jit = true) {
const cookie = await loginCookie(t);
const resp = await authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ oidc: { issuer: ISSUER, client_id: "cid-123", enabled: true, jit } }),
});
expect(resp.status).toBe(200);
return cookie;
}
/** # 函数
* 走一遍 /auth/login,取回 302 Location 与其中的 state
*/
async function startLogin(t: import("./helpers.ts").TestEnv) {
const resp = await t.app.fetch(new Request("http://localhost/auth/login"), t.env);
const loc = resp.headers.get("Location") ?? "";
return { resp, loc, state: new URL(loc).searchParams.get("state") ?? "" };
}
/** # 函数
* 从 Set-Cookie 头取会话 cookie
*/
function cookieOf(resp: Response): string {
const m = /wmh_session=([^;]+)/.exec(resp.headers.get("Set-Cookie") ?? "");
if (!m) throw new Error(`no session cookie: ${resp.headers.get("Set-Cookie")}`);
return `wmh_session=${m[1]}`;
}
describe("ZITADEL 统一登录(/auth)", () => {
test("未配置时 status enabled=false;/auth/login 拒绝并回登录页", async () => {
const t = await makeTestEnv();
const status = (await (await t.app.fetch(new Request("http://localhost/auth/status"), t.env)).json()) as { enabled: boolean };
expect(status.enabled).toBe(false);
const resp = await t.app.fetch(new Request("http://localhost/auth/login"), t.env);
expect(resp.status).toBe(302);
expect(resp.headers.get("Location")).toContain("/login?error=oidc_disabled");
});
test("配置校验:issuer 非 HTTPS 拒绝(localhost 例外);启用缺 Client ID 拒绝", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const put = (body: unknown) =>
authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
expect((await put({ oidc: { issuer: "ftp://x.example", client_id: "c", enabled: false } })).status).toBe(400);
expect((await put({ oidc: { issuer: "", client_id: "c", enabled: false } })).status).toBe(400);
expect(
(await put({ oidc: { issuer: "http://localhost:8080", client_id: "c", enabled: false } })).status,
).toBe(200); // localhost 允许 http(联调)
expect((await put({ oidc: { issuer: "https://sso2.example.test", client_id: "", enabled: true } })).status).toBe(400);
});
test("启用后 status=true;发起授权 302 携带 PKCE 与 state,入库一次性", async () => {
installIdpStub();
resetOidcDiscoveryCache();
const t = await makeTestEnv();
await enableOidc(t);
const status = (await (await t.app.fetch(new Request("http://localhost/auth/status"), t.env)).json()) as { enabled: boolean };
expect(status.enabled).toBe(true);
const { resp, loc, state } = await startLogin(t);
expect(resp.status).toBe(302);
expect(loc.startsWith(`${ISSUER}/oauth/v2/authorize?`)).toBe(true);
expect(loc).toContain("code_challenge_method=S256");
expect(loc).toContain("response_type=code");
expect(state).toBeTruthy();
const row = await t.env.DB.prepare("SELECT code_verifier, return_to FROM oidc_states WHERE state = ?").bind(state).first();
expect(row).toBeTruthy();
});
test("完整登录链:JIT 自动建号(即管理员)→ 会话可用 → 同 subject 复用同一用户", async () => {
installIdpStub();
resetOidcDiscoveryCache();
const t = await makeTestEnv();
await enableOidc(t, true);
mockUserInfo = { sub: "sub-jit", name: "Zed Zhan", email: "zed@wtox.net" };
// 第一次登录:JIT 建号
const first = await startLogin(t);
const cb = await t.app.fetch(new Request(`http://localhost/auth/callback?code=good&state=${first.state}`), t.env);
expect(cb.status).toBe(302);
expect(cb.headers.get("Location")).toBe("/");
const cookie1 = cookieOf(cb);
const me = (await (await authedFetch(t, cookie1, "http://localhost/api/auth/me")).json()) as {
user: { username: string };
};
expect(me.user.username).toBe("zed-zhan"); // 展示名 slug 化
// state 一次性:重放同一回调被拒
const replay = await t.app.fetch(new Request(`http://localhost/auth/callback?code=good&state=${first.state}`), t.env);
expect(replay.headers.get("Location")).toContain("state_invalid");
// 库里:用户 + 身份绑定 + token 请求带 code_verifier(PKCE)
const users = await t.env.DB.prepare("SELECT id, username FROM users").all<{ id: string; username: string }>();
expect(users.results.filter((u) => u.username === "zed-zhan")).toHaveLength(1);
const ident = await t.env.DB.prepare("SELECT user_id, display_name, email FROM oidc_identities WHERE issuer = ? AND subject = ?")
.bind(ISSUER, "sub-jit")
.first<{ user_id: string; display_name: string; email: string }>();
expect(ident?.email).toBe("zed@wtox.net");
expect(idpCalls[0]?.body).toContain("code_verifier=");
// 第二次登录:同一 subject 复用同一用户,不重复建号
const second = await startLogin(t);
const cb2 = await t.app.fetch(new Request(`http://localhost/auth/callback?code=good&state=${second.state}`), t.env);
expect(cb2.status).toBe(302);
const ident2 = await t.env.DB.prepare("SELECT user_id FROM oidc_identities WHERE issuer = ? AND subject = ?")
.bind(ISSUER, "sub-jit")
.first<{ user_id: string }>();
expect(ident2?.user_id).toBe(ident?.user_id);
const users2 = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>();
expect(users2?.n).toBe(2); // admin + zed-zhan
});
test("JIT 关闭:未绑定身份拒绝登录,不建用户", async () => {
installIdpStub();
resetOidcDiscoveryCache();
const t = await makeTestEnv();
await enableOidc(t, false);
mockUserInfo = { sub: "sub-unknown", preferred_username: "stranger" };
const { state } = await startLogin(t);
const cb = await t.app.fetch(new Request(`http://localhost/auth/callback?code=good&state=${state}`), t.env);
expect(cb.headers.get("Location")).toContain("oidc_not_linked");
const users = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>();
expect(users?.n).toBe(1); // 仍只有 admin
});
test("篡改 state 拒绝;userinfo 缺 sub 时 fail closed", async () => {
installIdpStub();
resetOidcDiscoveryCache();
const t = await makeTestEnv();
await enableOidc(t, true);
const bad = await t.app.fetch(new Request("http://localhost/auth/callback?code=x&state=bogus"), t.env);
expect(bad.headers.get("Location")).toContain("state_invalid");
mockUserInfo = { preferred_username: "nosub" };
const { state } = await startLogin(t);
const cb = await t.app.fetch(new Request(`http://localhost/auth/callback?code=good&state=${state}`), t.env);
expect(cb.headers.get("Location")).toContain("oidc_exchange_failed");
});
test("展示名兜底链:name → given+family → preferred_username → subject 前缀", async () => {
const { oidcDisplayName } = await import("../console/src/services/oidc.ts");
expect(oidcDisplayName({ sub: "s1", name: "A" })).toBe("A");
expect(oidcDisplayName({ sub: "s2", given_name: "San", family_name: "Zhang" })).toBe("San Zhang");
expect(oidcDisplayName({ sub: "s3", preferred_username: "pz" })).toBe("pz");
expect(oidcDisplayName({ sub: "1234567890abcdef" })).toBe("统一账号 1234567890ab");
});
});
describe("修改用户名(PUT /api/settings/username)", () => {
test("需当前密码;改后新名可登录、旧名失效、/me 返回新名", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
// 密码错误拒绝
const bad = await authedFetch(t, cookie, "http://localhost/api/settings/username", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_username: "boss", password: "wrong" }),
});
expect(bad.status).toBe(400);
// 非法字符拒绝
const invalid = await authedFetch(t, cookie, "http://localhost/api/settings/username", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_username: "has space", password: "admin" }),
});
expect(invalid.status).toBe(400);
// 正常修改:重签会话
const ok = await authedFetch(t, cookie, "http://localhost/api/settings/username", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_username: "boss", password: "admin" }),
});
expect(ok.status).toBe(200);
const renewed = cookieOf(ok);
// 旧 cookie(旧 payload 用户名)仍可用,但 /me 以库为准返回新名
const meOld = (await (await authedFetch(t, cookie, "http://localhost/api/auth/me")).json()) as { user: { username: string } };
expect(meOld.user.username).toBe("boss");
// 新签 cookie 同样
const meNew = (await (await authedFetch(t, renewed, "http://localhost/api/auth/me")).json()) as { user: { username: string } };
expect(meNew.user.username).toBe("boss");
// 新用户名 + 原密码可登录;旧用户名登录 401
const loginNew = await t.app.fetch(
new Request("http://localhost/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "boss", password: "admin" }),
}),
t.env,
);
expect(loginNew.status).toBe(200);
const loginOld = await t.app.fetch(
new Request("http://localhost/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "admin" }),
}),
t.env,
);
expect(loginOld.status).toBe(401);
});
test("与其他用户重名返回 409;GET /api/settings 回传 oidc 配置与未绑定身份", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
// 建第二个用户直接插库
const { hashPassword, ids } = await import("../shared/src/index.ts");
await t.env.DB.prepare("INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)")
.bind(ids.user(), "taken", await hashPassword("x".repeat(8)), new Date().toISOString())
.run();
const dup = await authedFetch(t, cookie, "http://localhost/api/settings/username", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_username: "taken", password: "admin" }),
});
expect(dup.status).toBe(409);
const info = (await (await authedFetch(t, cookie, "http://localhost/api/settings")).json()) as {
username: string;
oidc: { enabled: boolean; redirect_uri: string; identity: unknown; source: string };
};
expect(info.username).toBe("admin");
expect(info.oidc.enabled).toBe(false);
expect(info.oidc.source).toBe("none");
expect(info.oidc.redirect_uri).toBe("http://localhost/auth/callback");
expect(info.oidc.identity).toBeNull();
});
});
+30 -2
View File
@@ -34,6 +34,12 @@ const check = (step: string, ok: boolean, note?: string) => {
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
await page.waitForSelector("input[placeholder='用户名']", { timeout: 8000 });
check("登录页渲染(含 LOGO/表单)", (await page.title()).includes("WmailHub"));
// 统一登录未启用时(v2.9.0):登录页不显示 ZITADEL 按钮;/auth/status 可探测
await page.waitForTimeout(400); // 等 /auth/status 探测返回
check(
"登录页 ZITADEL 按钮按启用状态显示(默认关闭不显示)",
(await page.locator("button:has-text('ZITADEL 统一登录')").count()) === 0,
);
await page.screenshot({ path: `${SHOT_DIR}/01-login.png` });
// 2. 登录 admin/admin
@@ -101,8 +107,8 @@ await page.click(".ant-dropdown .ant-dropdown-menu-item:has-text('设置')");
await page.waitForSelector(".ant-modal:has-text('全局 Webhook')", { timeout: 8000 });
const tabLabels = await page.locator(".ant-modal .ant-tabs-tab").allInnerTexts();
check(
"设置模态窗口经头像下拉打开(全局 Webhook / Cloudflare 接入 / 账号密码 三 tab)",
tabLabels.length === 3 && tabLabels.some((t) => t.includes("Cloudflare 接入")),
"设置模态窗口经头像下拉打开(全局 Webhook / Cloudflare 接入 / ZITADEL 统一登录 / 账号密码 四 tab)",
tabLabels.length === 4 && tabLabels.some((t) => t.includes("Cloudflare 接入")) && tabLabels.some((t) => t.includes("ZITADEL 统一登录")),
tabLabels.join(" | "),
);
await page.screenshot({ path: `${SHOT_DIR}/09-settings-webhook.png` });
@@ -115,6 +121,28 @@ const redirectText =
(await page.locator(".ant-modal .mono").filter({ hasText: "/api/oauth/cloudflare/callback" }).first().textContent()) ?? "";
check("Cloudflare 接入 tab 显示授权回调地址", redirectText.includes("/api/oauth/cloudflare/callback"), redirectText.trim());
await page.screenshot({ path: `${SHOT_DIR}/09b-settings-cloudflare.png` });
// ZITADEL 统一登录 tab(v2.9.0):回调地址 /auth/callback + Issuer/Client ID 表单 + 启用/JIT 开关
await page.click(".ant-modal .ant-tabs-tab:has-text('ZITADEL 统一登录')");
await page.waitForSelector(".ant-modal :text('Issuer')", { timeout: 8000 });
const oidcModalText = (await page.locator(".ant-modal").innerText()) ?? "";
check(
"ZITADEL tab 显示回调地址 /auth/callback 与启用/JIT 开关",
oidcModalText.includes("/auth/callback") &&
(await page.locator(".ant-modal .ant-switch").count()) === 2 &&
oidcModalText.includes("管理员"),
oidcModalText.split("\n").slice(0, 6).join(" / "),
);
await page.screenshot({ path: `${SHOT_DIR}/09c-settings-oidc.png` });
// 账号密码 tab(v2.9.0):用户名修改表单 + 密码表单
await page.click(".ant-modal .ant-tabs-tab:has-text('账号密码')");
await page.waitForSelector(".ant-modal :text('新用户名')", { timeout: 8000 });
const accountModalText = (await page.locator(".ant-modal").innerText()) ?? "";
check(
"账号密码 tab 含用户名修改(新用户名 + 当前密码)与密码表单",
accountModalText.includes("新用户名") && accountModalText.includes("更新用户名") && accountModalText.includes("更新密码"),
);
await page.click(".ant-modal .ant-tabs-tab:has-text('全局 Webhook')");
await page.waitForSelector(".ant-modal :text('Webhook URL')", { timeout: 8000 });