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:
@@ -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
@@ -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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user