diff --git a/console/frontend/src/App.tsx b/console/frontend/src/App.tsx index 960e176..a067f56 100644 --- a/console/frontend/src/App.tsx +++ b/console/frontend/src/App.tsx @@ -161,7 +161,7 @@ export function App() { - setSettingsOpen(false)} /> + setSettingsOpen(false)} onUsernameChanged={setUsername} /> ); } diff --git a/console/frontend/src/api.ts b/console/frontend/src/api.ts index aff6650..5326f96 100644 --- a/console/frontend/src/api.ts +++ b/console/frontend/src/api.ts @@ -310,7 +310,7 @@ export const retainEmails = (ids: string[]) => export interface OperationLogRow { id: string; action: string; - target_type: "account" | "domain" | "mailbox" | "email" | "webhook"; + target_type: "account" | "domain" | "mailbox" | "email" | "webhook" | "user" | "settings"; target_id: string | null; target_name: string | null; detail: string | null; @@ -338,6 +338,22 @@ export interface SettingsInfo { /** 实际请求的 scope(点号分隔,空格分隔多个) */ scope: string; }; + /** ZITADEL 统一登录配置与当前账号绑定情况 */ + oidc: { + issuer: string; + client_id: string; + enabled: boolean; + /** JIT:未绑定的统一登录身份自动创建本地用户(即管理员) */ + jit: boolean; + /** settings = 后台设置;env = 部署变量兜底;none = 未配置 */ + source: "settings" | "env" | "none"; + /** 授权 scope(固定 openid profile) */ + scopes: string; + /** 需要在 ZITADEL 控制台应用里登记的回调地址 */ + redirect_uri: string; + /** 当前账号已绑定的统一登录身份(未绑定为 null) */ + identity: { issuer: string; subject: string; display_name: string | null; email: string | null } | null; + }; /** 需要在 Cloudflare Dashboard 登记的授权回调地址 */ redirect_uri: string; } @@ -345,10 +361,12 @@ export interface SettingsInfo { export const getSettings = () => api("/api/settings"); /** 保存设置 - * webhook.key / cf_oauth.secret 语义一致:undefined 保留原值、空串清除、非空设置新值 */ + * webhook.key / cf_oauth.secret 语义一致:undefined 保留原值、空串清除、非空设置新值 + * oidc.enabled / oidc.jit 缺省表示沿用已保存的开关状态 */ export const putSettings = (body: { webhook?: { url: string; headers: Record; key?: string }; cf_oauth?: { client_id: string; secret?: string; scope?: string }; + oidc?: { issuer: string; client_id: string; enabled?: boolean; jit?: boolean }; }) => api("/api/settings", "PUT", body); /** # 成员变量: 授权配置自检结果 **/ @@ -376,6 +394,16 @@ export const deleteCfOAuth = () => api("/api/settings/cf-oauth", "DELETE"); export const changePassword = (old_password: string, new_password: string) => api("/api/settings/password", "PUT", { old_password, new_password }); +/** 修改登录用户名(需当前密码确认;成功后服务端重签会话 cookie) */ +export const changeUsername = (new_username: string, password: string) => + api<{ ok: boolean; username: string }>("/api/settings/username", "PUT", { new_username, password }); + +/** 清除后台保存的 ZITADEL 统一登录配置(env 兜底仍可用) */ +export const deleteOidc = () => api("/api/settings/oidc", "DELETE"); + +/** 统一登录探测(公开接口,登录页用):enabled 时显示「ZITADEL 统一登录」按钮 */ +export const oidcStatus = () => api<{ enabled: boolean; issuer: string | null }>("/auth/status"); + /* ---- API/MCP Key(单一 Key,设置模态窗口内使用;API 与 MCP 共用) ---- */ /** 生成(轮换)Key:明文仅本次返回 */ diff --git a/console/frontend/src/components/SettingsModal.tsx b/console/frontend/src/components/SettingsModal.tsx index dec230c..aebf874 100644 --- a/console/frontend/src/components/SettingsModal.tsx +++ b/console/frontend/src/components/SettingsModal.tsx @@ -1,9 +1,11 @@ /** - * 设置模态窗口(v2.3.0): - * - 三个 tab:全局 Webhook(含 API/MCP Key 卡片)、Cloudflare 接入、账号密码 + * 设置模态窗口(v2.3.0;v2.9.0 四 tab): + * - 全局 Webhook(含 API/MCP Key 卡片)、Cloudflare 接入、ZITADEL 统一登录、账号密码(用户名+密码) * - 单一 API/MCP Key(API 与 MCP 共用),凭据存 settings 表,明文仅生成时返回一次 * - Cloudflare OAuth 客户端(Client ID / Secret)也在设置里维护,Secret 加密存储只回 last4, * 保存后立即生效,无需重新部署;未配置时账号页「添加账号」不可用 + * - ZITADEL 统一登录:issuer + Client ID(PKCE 公共客户端无 secret),回调地址 {origin}/auth/callback; + * 登录成功即管理员(本平台没有权限设置),JIT 自动建号可开关 * - 内容高度不超过屏幕(超出滚动) */ import { useCallback, useEffect, useState } from "react"; @@ -18,6 +20,7 @@ import { Modal, Popconfirm, Space, + Switch, Tabs, Tag, Typography, @@ -28,12 +31,15 @@ import { DeleteOutlined, KeyOutlined, PlusOutlined, + SafetyOutlined, } from "@ant-design/icons"; import { changePassword, + changeUsername, createApiKey, deleteApiKey, deleteCfOAuth, + deleteOidc, getSettings, oauthPreflight, putSettings, @@ -45,7 +51,16 @@ import { /** # 函数 * 设置模态窗口组件 */ -export function SettingsModal({ open, onClose }: { open: boolean; onClose: () => void }) { +export function SettingsModal({ + open, + onClose, + onUsernameChanged, +}: { + open: boolean; + onClose: () => void; + /** 用户名修改成功后回调(顶栏展示名同步) */ + onUsernameChanged?: (username: string) => void; +}) { const { message } = App.useApp(); const [info, setInfo] = useState(null); @@ -77,7 +92,8 @@ export function SettingsModal({ open, onClose }: { open: boolean; onClose: () => items={[ { key: "webhook", label: "全局 Webhook", children: info ? : null }, { key: "cloudflare", label: Cloudflare 接入, children: info ? : null }, - { key: "password", label: "账号密码", children: }, + { key: "oidc", label: ZITADEL 统一登录, children: info ? : null }, + { key: "password", label: "账号密码", children: }, ]} /> @@ -494,16 +510,40 @@ function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => v ); } +/** # 成员变量: 账号密码 tab 的表单集合(用户名表单 + 密码表单) **/ +interface UsernameFormValues { + new_username: string; + password: string; +} + /** # 函数 - * 账号密码 tab:修改登录密码 + * 账号密码 tab:修改登录用户名(v2.9.0)+ 修改登录密码;成功后同步顶栏展示名 */ -function PasswordTab() { +function AccountTab({ onUsernameChanged }: { onUsernameChanged?: (username: string) => void }) { const { message } = App.useApp(); + const [nameForm] = Form.useForm(); const [pwdForm] = Form.useForm<{ old_password: string; new_password: string; confirm: string; }>(); + const [savingName, setSavingName] = useState(false); + + /** 修改用户名(需当前密码确认) */ + const saveUsername = async () => { + const values = await nameForm.validateFields(); + setSavingName(true); + try { + const res = await changeUsername(values.new_username.trim(), values.password); + message.success(`用户名已更新为 ${res.username},下次登录请使用新用户名`); + onUsernameChanged?.(res.username); + nameForm.resetFields(); + } catch (err) { + message.error(err instanceof Error ? err.message : "修改失败"); + } finally { + setSavingName(false); + } + }; /** 修改密码 */ const savePassword = async () => { @@ -518,32 +558,214 @@ function PasswordTab() { }; return ( -
void savePassword()}> - - - - - - - ({ - validator: (_, v) => - !v || v === getFieldValue("new_password") - ? Promise.resolve() - : Promise.reject(new Error("两次输入不一致")), - }), - ]} - > - - - -
+ +
void saveUsername()}> + + + + + + + +
+ +
void savePassword()}> + + + + + + + ({ + validator: (_, v) => + !v || v === getFieldValue("new_password") + ? Promise.resolve() + : Promise.reject(new Error("两次输入不一致")), + }), + ]} + > + + + +
+
+ ); +} + +/** # 成员变量: ZITADEL 统一登录表单值(开关单独存 state,避免受控告警) **/ +interface OidcFormValues { + issuer: string; + client_id: string; +} + +/** # 函数 + * ZITADEL 统一登录 tab(v2.9.0):issuer + Client ID(PKCE 无 secret)+ 启用/JIT 开关; + * 回调地址需在 ZITADEL 控制台应用里登记;登录成功即管理员(本平台没有权限设置) + */ +function ZitadelTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void }) { + const { message } = App.useApp(); + const [form] = Form.useForm(); + const [saving, setSaving] = useState(false); + const [enabled, setEnabled] = useState(info.oidc.enabled); + const [jit, setJit] = useState(info.oidc.jit); + const meta = info.oidc; + + useEffect(() => { + form.setFieldsValue({ issuer: meta.issuer, client_id: meta.client_id }); + setEnabled(meta.enabled); + setJit(meta.jit); + }, [meta.issuer, meta.client_id, meta.enabled, meta.jit, form]); + + /** 保存统一登录配置 */ + const save = async () => { + const values = await form.validateFields(); + setSaving(true); + try { + await putSettings({ + oidc: { issuer: values.issuer.trim(), client_id: values.client_id.trim(), enabled, jit }, + }); + message.success("ZITADEL 统一登录配置已保存"); + onSaved(); + } catch (err) { + message.error(err instanceof Error ? err.message : "保存失败"); + } finally { + setSaving(false); + } + }; + + /** 清除后台保存的配置 */ + const clear = async () => { + setSaving(true); + try { + await deleteOidc(); + message.success("已清除后台保存的配置(env 兜底仍可用)"); + form.resetFields(); + onSaved(); + } catch (err) { + message.error(err instanceof Error ? err.message : "清除失败"); + } finally { + setSaving(false); + } + }; + + return ( + + + 在 ZITADEL 控制台的应用 Redirect URI 里登记下面的回调地址;所有通过统一登录登录的用户都是 + 管理员(本平台没有权限设置)。 + + } + /> + +
+ + + {meta.redirect_uri} + + + + + + + + + + {meta.scopes}{" "} + (固定;userinfo 取身份,实例无 SMTP 拿不到邮箱 claim 也不影响登录) + + + + + 启用后登录页显示「ZITADEL 统一登录」按钮;密码登录不受影响(双通道并存) + + + + + + 开启:未绑定的统一登录身份首次登录自动创建本地用户(即管理员);关闭:未绑定身份拒绝登录 + + + + + {meta.source === "settings" && ( + void clear()} + > + + + )} + +
+ + + {meta.identity ? ( + + + 已绑定 + {meta.identity.display_name ?? "(无展示名)"} + + + {meta.identity.subject} + + + {meta.identity.issuer} + + + {meta.identity.email ? {meta.identity.email} : 未提供(IdP 未配置 SMTP,正常现象)} + + + ) : ( + + 当前账号未绑定统一登录身份;绑定后可直接用 ZITADEL 登录本站(启用 JIT 时首次登录会自动建新账号)。 + + )} + +
); } diff --git a/console/frontend/src/pages/Login.tsx b/console/frontend/src/pages/Login.tsx index 3c0ff1b..4756100 100644 --- a/console/frontend/src/pages/Login.tsx +++ b/console/frontend/src/pages/Login.tsx @@ -1,9 +1,18 @@ -/** 登录页:用户名密码(默认 admin/admin) */ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { Button, Card, Form, Input, App } from "antd"; -import { LockOutlined, UserOutlined } from "@ant-design/icons"; -import { login } from "../api.ts"; +/** 登录页:用户名密码(默认 admin/admin)+ ZITADEL 统一登录(启用时显示,v2.9.0) */ +import { useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { Alert, Button, Card, Divider, Form, Input, App } from "antd"; +import { LockOutlined, SafetyOutlined, UserOutlined } from "@ant-design/icons"; +import { login, oidcStatus } from "../api.ts"; + +/** # 成员变量: 统一登录错误码 -> 中文文案(与后端 routes/oidc.ts 的 OIDC_ERROR_TEXT 保持一致) **/ +const OIDC_ERROR_TEXT: Record = { + oidc_disabled: "统一登录未启用", + oidc_unavailable: "统一登录服务暂时不可用,请稍后重试或使用密码登录", + state_invalid: "登录状态校验失败(可能已过期),请重新登录", + oidc_exchange_failed: "统一登录兑换失败,请重新登录", + oidc_not_linked: "该统一登录账号未绑定本站用户,请使用密码登录或联系管理员", +}; /** # 函数 * 登录页组件 @@ -11,7 +20,17 @@ import { login } from "../api.ts"; export function LoginPage() { const navigate = useNavigate(); const { message } = App.useApp(); + const [params] = useSearchParams(); const [loading, setLoading] = useState(false); + const [oidc, setOidc] = useState<{ enabled: boolean; issuer: string | null }>({ enabled: false, issuer: null }); + const oidcError = params.get("error"); + + useEffect(() => { + // 探测统一登录是否启用(公开接口;失败静默,不影响密码登录) + void oidcStatus() + .then(setOidc) + .catch(() => undefined); + }, []); return (
@@ -23,6 +42,14 @@ export function LoginPage() {
Cloudflare Email Workers 邮件接收平台
+ {oidcError && ( + + )}
{ @@ -48,6 +75,25 @@ export function LoginPage() { 登录
+ {oidc.enabled && ( + <> + + 或 + + + + )}
默认账号 admin / admin,登录后请尽快修改
diff --git a/console/frontend/src/pages/Logs.tsx b/console/frontend/src/pages/Logs.tsx index 320ed9e..75d12e1 100644 --- a/console/frontend/src/pages/Logs.tsx +++ b/console/frontend/src/pages/Logs.tsx @@ -16,6 +16,8 @@ const TYPE_META: Record = { mailbox: { label: "邮箱", color: "purple" }, email: { label: "邮件", color: "orange" }, webhook: { label: "Webhook", color: "blue" }, + user: { label: "用户", color: "volcano" }, + settings: { label: "设置", color: "default" }, }; /** # 成员变量: 动作名 -> 中文标签(未收录的动作原样展示) **/ @@ -40,6 +42,11 @@ const ACTION_LABEL: Record = { "webhook.clear": "清除 Webhook", "webhook.deliver": "Webhook 投递成功", "webhook.deliver_failed": "Webhook 投递失败", + "user.rename": "修改用户名", + "user.oidc_login": "统一登录", + "user.oidc_signup": "统一登录(自动建号)", + "settings.oidc": "配置统一登录", + "settings.oidc_clear": "清除统一登录配置", }; /** # 函数 diff --git a/console/src/app.ts b/console/src/app.ts index 0adb2e6..db73e96 100644 --- a/console/src/app.ts +++ b/console/src/app.ts @@ -4,6 +4,7 @@ * 路由分区: * /api/health 健康检查 * /api/auth/* 用户登录/登出/会话(cookie) + * /auth/* ZITADEL 统一登录:status / login / callback(OIDC Authorization Code + PKCE) * /api/accounts|domains|mailboxes|emails 管理资源 CRUD(会话保护) * /api/settings/* 设置:全局 Webhook / 改密码(会话保护) * /api/oauth/* Cloudflare OAuth(会话保护) @@ -23,6 +24,7 @@ import { internalRoutes } from "./routes/internal.ts"; import { resourceRoutes } from "./routes/resources.ts"; import { settingsRoutes } from "./routes/settings.ts"; import { oauthRoutes } from "./routes/oauth.ts"; +import { oidcRoutes } from "./routes/oidc.ts"; import { apiV1Routes } from "./routes/apiv1.ts"; import { mcpRoutes } from "./routes/mcp.ts"; import { adminRoutes } from "./routes/admin.ts"; @@ -50,6 +52,7 @@ export function createApp( app.get("/api/health", (c) => c.json({ ok: true, name: APP_NAME, version: APP_VERSION })); app.route("/api/auth", authRoutes()); + app.route("/auth", oidcRoutes()); app.route("/internal", internalRoutes()); app.route("/api/oauth", oauthRoutes()); app.route("/api/settings", settingsRoutes()); diff --git a/console/src/auth.ts b/console/src/auth.ts index 61a46f8..c4c985b 100644 --- a/console/src/auth.ts +++ b/console/src/auth.ts @@ -133,11 +133,14 @@ export function authRoutes() { return c.json({ ok: true }); }); - /** 当前登录用户信息 */ + /** 当前登录用户信息(用户名以库内为准:v2.9.0 起支持改用户名,旧会话 cookie 的 payload 可能是旧名) */ app.get("/me", async (c) => { const payload = await verifySessionToken(c.env, readSessionCookie(c.req.raw)); if (!payload) return c.json({ error: "unauthorized" }, 401); - return c.json({ user: { id: payload.uid, username: payload.username }, now: nowIso() }); + const row = await c.env.DB.prepare("SELECT username FROM users WHERE id = ?") + .bind(payload.uid) + .first<{ username: string }>(); + return c.json({ user: { id: payload.uid, username: row?.username ?? payload.username }, now: nowIso() }); }); return app; diff --git a/console/src/env.ts b/console/src/env.ts index 69a44c1..428b6c5 100644 --- a/console/src/env.ts +++ b/console/src/env.ts @@ -55,6 +55,11 @@ export interface Env { CF_OAUTH_REDIRECT_URI?: string; /** 本地联调:允许 webhook endpoint 指向私网/localhost("1"),生产严禁开启 **/ WMAILHUB_ALLOW_PRIVATE_WEBHOOKS?: string; + /** ZITADEL 统一登录兜底配置(后台未保存 settings 时生效);issuer 须 HTTPS **/ + OIDC_ISSUER?: string; + OIDC_CLIENT_ID?: string; + /** "false" 关闭,其余视为开启(仅在有 issuer+client_id 兜底时有意义) **/ + OIDC_ENABLED?: string; } /** # 成员变量: Workers 执行上下文最小接口 **/ diff --git a/console/src/routes/oidc.ts b/console/src/routes/oidc.ts new file mode 100644 index 0000000..9cc8860 --- /dev/null +++ b/console/src/routes/oidc.ts @@ -0,0 +1,152 @@ +/** + * ZITADEL 统一登录路由(docs/12,v2.9.0): + * GET /auth/status 统一登录是否启用(公开,登录页探测;只回 enabled 与 issuer) + * GET /auth/login 发起授权:生成 state+PKCE 存库,302 到 IdP 授权端点 + * GET /auth/callback 授权回调:一次性 state 校验 → 换码 → userinfo → 解析/JIT 建号 → 签发本地会话 cookie + * + * 安全:state 一次性(用后即删,10 分钟过期)承担 CSRF 防护——浏览器从 IdP 跨站重定向回来时 + * SameSite=Strict 的会话 cookie 不会携带,回调不校验会话;PKCE S256 防授权码拦截。 + * 回跳路径只接受站内路径(防开放重定向);所有失败都 302 回 /login?error= 并附中文文案。 + */ + +import { Hono, type Context } from "hono"; +import type { Env, AppVars } from "../env.ts"; +import { ids } from "../../../shared/src/index.ts"; +import { issueSessionToken, sessionSetCookie } from "../auth.ts"; +import { logOperation } from "../services/logs.ts"; +import { + buildOidcAuthorizeUrl, + createPkce, + discoverOidcEndpoints, + exchangeOidcCode, + getOidcConfig, + oidcRedirectUri, + resolveOrProvisionIdentity, + safeLocalPath, +} from "../services/oidc.ts"; +import { nowIso } from "../util.ts"; + +type App = Hono<{ Bindings: Env; Variables: AppVars }>; + +/** # 成员变量: OIDC state 有效期(秒) **/ +const OIDC_STATE_TTL_SECONDS = 600; + +/** # 成员变量: 登录页错误码 → 中文文案(与前端 Login.tsx 的映射保持一致) **/ +export const OIDC_ERROR_TEXT: Record = { + oidc_disabled: "统一登录未启用", + oidc_unavailable: "统一登录服务暂时不可用,请稍后重试或使用密码登录", + state_invalid: "登录状态校验失败(可能已过期),请重新登录", + oidc_exchange_failed: "统一登录兑换失败,请重新登录", + oidc_not_linked: "该统一登录账号未绑定本站用户,请使用密码登录或联系管理员", +}; + +/** # 函数 + * 装配统一登录路由(全部公开:status 供登录页探测,login/callback 是重定向链) + */ +export function oidcRoutes(): App { + const app = new Hono<{ Bindings: Env; Variables: AppVars }>(); + + /** 探测:登录页据此显示「ZITADEL 统一登录」按钮 */ + app.get("/status", async (c) => { + const cfg = await getOidcConfig(c.env); + return c.json({ enabled: Boolean(cfg?.enabled), issuer: cfg?.issuer ?? null }); + }); + + /** 发起授权 */ + app.get("/login", async (c) => { + const cfg = await getOidcConfig(c.env); + if (!cfg || !cfg.enabled) return redirectError(c, "oidc_disabled"); + let authorizationEndpoint: string; + try { + authorizationEndpoint = (await discoverOidcEndpoints(c.env, cfg.issuer)).authorization_endpoint; + } catch (err) { + console.error(JSON.stringify({ t: nowIso(), lvl: "error", msg: "oidc discovery failed", error: String(err) })); + return redirectError(c, "oidc_unavailable"); + } + // 顺手清理过期 state(旁路,失败不打断) + try { + await c.env.DB.prepare("DELETE FROM oidc_states WHERE expires_at < ?").bind(nowIso()).run(); + } catch { + // ignore + } + const { verifier, challenge } = await createPkce(); + const state = `oidc_${ids.oauthState()}`; + const returnTo = safeLocalPath(c.req.query("return")); + await c.env.DB.prepare( + "INSERT INTO oidc_states (state, code_verifier, return_to, created_at, expires_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(state, verifier, returnTo, nowIso(), new Date(Date.now() + OIDC_STATE_TTL_SECONDS * 1000).toISOString()) + .run(); + const url = buildOidcAuthorizeUrl({ + authorizationEndpoint, + clientId: cfg.clientId, + redirectUri: oidcRedirectUri(new URL(c.req.url).origin), + state, + codeChallenge: challenge, + }); + return c.redirect(url, 302); + }); + + /** 授权回调:state → code → userinfo → 本地会话 */ + app.get("/callback", async (c) => { + const cfg = await getOidcConfig(c.env); + if (!cfg || !cfg.enabled) return redirectError(c, "oidc_disabled"); + const url = new URL(c.req.url); + // IdP 侧拒绝(access_denied / invalid_scope …):带回错误码,不在回调地址上停留 + const idpError = url.searchParams.get("error"); + if (idpError) { + return redirectError(c, idpError in OIDC_ERROR_TEXT ? idpError : "oidc_exchange_failed"); + } + // 一次性 state:存在且未过期则删除并取回 verifier/return_to(CSRF 防护核心) + const state = url.searchParams.get("state") ?? ""; + const row = state + ? await c.env.DB.prepare("SELECT code_verifier, return_to, expires_at FROM oidc_states WHERE state = ?") + .bind(state) + .first<{ code_verifier: string; return_to: string; expires_at: string }>() + : null; + if (row) await c.env.DB.prepare("DELETE FROM oidc_states WHERE state = ?").bind(state).run(); + if (!row || row.expires_at < nowIso()) return redirectError(c, "state_invalid"); + + const code = url.searchParams.get("code") ?? ""; + let sub = ""; + try { + const endpoints = await discoverOidcEndpoints(c.env, cfg.issuer); + const ui = await exchangeOidcCode({ + endpoints, + clientId: cfg.clientId, + redirectUri: oidcRedirectUri(url.origin), + code, + verifier: row.code_verifier, + }); + sub = ui.sub; + const resolved = await resolveOrProvisionIdentity(c.env, cfg, cfg.issuer, ui); + if (!resolved) return redirectError(c, "oidc_not_linked"); + const token = await issueSessionToken(c.env, resolved.userId, resolved.username); + c.header("Set-Cookie", sessionSetCookie(token)); + await logOperation(c.env, { + action: resolved.created ? "user.oidc_signup" : "user.oidc_login", + targetType: "user", + targetId: resolved.userId, + targetName: resolved.username, + detail: { issuer: cfg.issuer, jit: resolved.created }, + operator: resolved.username, + }); + return c.redirect(row.return_to, 302); + } catch (err) { + // 日志只记错误形态与 sub 前缀,不记 code/token/完整身份 + console.error( + JSON.stringify({ t: nowIso(), lvl: "error", msg: "oidc callback failed", sub_prefix: sub.slice(0, 8), error: String(err) }), + ); + return redirectError(c, "oidc_exchange_failed"); + } + }); + + return app; +} + +/** # 函数 + * 302 回登录页并携带错误码(登录页映射成中文文案) + */ +function redirectError(c: Context<{ Bindings: Env; Variables: AppVars }>, code: string): Response { + return c.redirect(`/login?error=${encodeURIComponent(code)}`, 302); +} diff --git a/console/src/routes/settings.ts b/console/src/routes/settings.ts index 6dba808..8a86754 100644 --- a/console/src/routes/settings.ts +++ b/console/src/routes/settings.ts @@ -1,11 +1,13 @@ /** - * 设置路由(v2.3.0): - * GET /api/settings 读取:全局 Webhook / API Key 元信息 / Cloudflare OAuth 客户端 / 当前用户 - * PUT /api/settings 保存:全局 Webhook、Cloudflare OAuth 客户端 + * 设置路由(v2.3.0 起;v2.9.0 增 ZITADEL 统一登录配置与改用户名): + * GET /api/settings 读取:全局 Webhook / API Key 元信息 / Cloudflare OAuth 客户端 / ZITADEL 统一登录 / 当前用户 + * PUT /api/settings 保存:全局 Webhook、Cloudflare OAuth 客户端、ZITADEL 统一登录 * POST /api/settings/api-key 生成(轮换)单一 API/MCP Key,明文仅返回一次 * DEL /api/settings/api-key 撤销并删除 API Key * DEL /api/settings/cf-oauth 清除后台保存的 Cloudflare OAuth 客户端凭据 + * DEL /api/settings/oidc 清除后台保存的 ZITADEL 统一登录配置(env 兜底仍可用) * PUT /api/settings/password 修改登录密码 + * PUT /api/settings/username 修改登录用户名(验当前密码,成功后重签会话 cookie) * API 与 MCP 共用同一 Key,凭据存 settings 表(不再单独建表); * Cloudflare OAuth Client Secret 同样只存加密密文,接口只回 last4。 */ @@ -13,6 +15,7 @@ import { Hono } from "hono"; import type { Env, AppVars } from "../env.ts"; import { requireSession } from "../middleware.ts"; +import { issueSessionToken, sessionSetCookie } from "../auth.ts"; import { checkWebhookUrl, generateWmailKey, @@ -30,6 +33,15 @@ import { saveOAuthClient, } from "../services/cfapi.ts"; import { getGlobalWebhookKey, normalizeWebhookHeaders, saveGlobalWebhook } from "../services/webhook_delivery.ts"; +import { + clearOidcConfig, + getOidcConfig, + oidcRedirectUri, + readOidcSettingRaw, + saveOidcConfig, + validateIssuerUrl, + OIDC_SCOPES, +} from "../services/oidc.ts"; import { logOperation, operatorName } from "../services/logs.ts"; import { nowIso, readJson } from "../util.ts"; @@ -73,21 +85,41 @@ export function settingsRoutes(): App { // settings 损坏时按未配置处理 } } + // ZITADEL 统一登录配置(不含 secret 类字段;PKCE 公共客户端本就无 secret)+ 当前账号绑定情况 + const oidcCfg = await getOidcConfig(c.env); + const identity = await c.env.DB.prepare( + "SELECT issuer, subject, display_name, email FROM oidc_identities WHERE user_id = ?", + ) + .bind(userId) + .first<{ issuer: string; subject: string; display_name: string | null; email: string | null }>(); return c.json({ username: user?.username ?? "", webhook, api_key: await apiKeyMeta(c.env), cf_oauth: { ...(await oauthClientMeta(c.env)), scope: await getOAuthScopes(c.env) }, + oidc: { + issuer: oidcCfg?.issuer ?? "", + client_id: oidcCfg?.clientId ?? "", + enabled: oidcCfg?.enabled ?? false, + jit: oidcCfg?.jit ?? true, + source: oidcCfg?.source ?? "none", + scopes: OIDC_SCOPES, + // 供前端展示:需要在 ZITADEL 控制台应用里登记的回调地址 + redirect_uri: oidcRedirectUri(new URL(c.req.url).origin), + // 当前登录账号已绑定的统一登录身份(未绑定为 null) + identity: identity ?? null, + }, // 供前端展示:需要在 Cloudflare Dashboard 登记的回调地址 redirect_uri: oauthRedirectUri(c.env, new URL(c.req.url).origin), }); }); - /** 保存设置:全局 Webhook、Cloudflare OAuth 客户端 */ + /** 保存设置:全局 Webhook、Cloudflare OAuth 客户端、ZITADEL 统一登录 */ app.put("/", async (c) => { const body = await readJson<{ webhook?: { url?: string; headers?: unknown; key?: string }; cf_oauth?: { client_id?: string; secret?: string; scope?: string }; + oidc?: { issuer?: string; client_id?: string; enabled?: boolean; jit?: boolean }; }>(c.req.raw); if (body.webhook) { @@ -141,6 +173,29 @@ export function settingsRoutes(): App { } } + if (body.oidc) { + const issuer = (body.oidc.issuer ?? "").trim().replace(/\/+$/, ""); + const clientId = (body.oidc.client_id ?? "").trim(); + if (issuer) { + const invalid = validateIssuerUrl(issuer); + if (invalid) return c.json({ error: invalid }, 400); + } + if (clientId && !issuer) return c.json({ error: "填写 Client ID 时请同时填写 Issuer" }, 400); + // 开关缺省沿用已保存值(再保存表单不丢开关状态);未保存过默认 jit 开、enabled 关 + const prev = await readOidcSettingRaw(c.env); + const enabled = body.oidc.enabled ?? prev?.enabled ?? false; + const jit = body.oidc.jit ?? prev?.jit ?? true; + if (enabled && (!issuer || !clientId)) return c.json({ error: "启用统一登录前请完整填写 Issuer 与 Client ID" }, 400); + await saveOidcConfig(c.env, { issuer, client_id: clientId, enabled, jit }); + await logOperation(c.env, { + action: "settings.oidc", + targetType: "settings", + targetName: "ZITADEL 统一登录", + detail: { enabled, jit, issuer, configured: Boolean(issuer && clientId) }, + operator: await operatorName(c.env, c.get("userId")), + }); + } + return c.json({ ok: true }); }); @@ -189,5 +244,51 @@ export function settingsRoutes(): App { return c.json({ ok: true }); }); + /** 修改登录用户名:需当前密码确认;成功后重签会话 cookie(payload 内含用户名) */ + app.put("/username", async (c) => { + const body = await readJson<{ new_username?: string; password?: string }>(c.req.raw); + const newUsername = (body.new_username ?? "").trim(); + if (!/^[a-zA-Z0-9_.-]{2,32}$/.test(newUsername)) { + return c.json({ error: "用户名需 2-32 位,仅字母、数字、_ . -" }, 400); + } + const userId = c.get("userId") ?? ""; + const user = await c.env.DB.prepare("SELECT id, username, password_hash FROM users WHERE id = ?") + .bind(userId) + .first<{ id: string; username: string; password_hash: string }>(); + if (!user) return c.json({ error: "user not found" }, 404); + if (!(await verifyPassword(body.password ?? "", user.password_hash))) { + return c.json({ error: "密码不正确" }, 400); + } + if (newUsername !== user.username) { + const dup = await c.env.DB.prepare("SELECT id FROM users WHERE username = ?").bind(newUsername).first<{ id: string }>(); + if (dup && dup.id !== userId) return c.json({ error: "用户名已存在" }, 409); + await c.env.DB.prepare("UPDATE users SET username = ? WHERE id = ?").bind(newUsername, userId).run(); + await logOperation(c.env, { + action: "user.rename", + targetType: "user", + targetId: userId, + targetName: newUsername, + detail: { from: user.username }, + operator: newUsername, + }); + } + // 重签会话:旧 cookie 的 payload 里还是旧用户名(/me 会兜底读库,这里让 cookie 也保持一致) + const token = await issueSessionToken(c.env, userId, newUsername); + c.header("Set-Cookie", sessionSetCookie(token)); + return c.json({ ok: true, username: newUsername }); + }); + + /** 清除后台保存的 ZITADEL 统一登录配置(env 兜底仍可用;已建立的绑定不受影响) */ + app.delete("/oidc", async (c) => { + await clearOidcConfig(c.env); + await logOperation(c.env, { + action: "settings.oidc_clear", + targetType: "settings", + targetName: "ZITADEL 统一登录", + operator: await operatorName(c.env, c.get("userId")), + }); + return c.json({ ok: true }); + }); + return app; } diff --git a/console/src/services/logs.ts b/console/src/services/logs.ts index d3cc862..1958c2e 100644 --- a/console/src/services/logs.ts +++ b/console/src/services/logs.ts @@ -10,7 +10,7 @@ import { ids } from "../../../shared/src/index.ts"; import { nowIso } from "../util.ts"; /** # 成员变量: 操作对象类型 **/ -export type OpTargetType = "account" | "domain" | "mailbox" | "email" | "webhook"; +export type OpTargetType = "account" | "domain" | "mailbox" | "email" | "webhook" | "user" | "settings"; /** # 成员变量: 写一条操作日志的参数 **/ export interface OperationLogInput { diff --git a/console/src/services/oidc.ts b/console/src/services/oidc.ts new file mode 100644 index 0000000..791faf8 --- /dev/null +++ b/console/src/services/oidc.ts @@ -0,0 +1,350 @@ +/** + * ZITADEL 统一登录(docs/12,v2.9.0):OIDC Authorization Code + PKCE(无 client secret)。 + * + * - 配置存 settings 表(设置 → ZITADEL 统一登录),env OIDC_ISSUER/OIDC_CLIENT_ID 兜底; + * 回调地址固定为 {origin}/auth/callback,不在代码/配置里硬编码 issuer 与 client_id + * - 身份主键是 (issuer, subject):email 仅作资料展示(实例无 SMTP,可能缺失),绝不参与判定 + * - 令牌只在服务端瞬时使用(code 换 access_token → userinfo 取身份),不落库、不进前端与日志 + * - JIT(自动建号)可配置;按需求本平台登录成功即管理员,没有权限设置 + */ + +import type { Env } from "../env.ts"; +import { hashPassword, ids } from "../../../shared/src/index.ts"; +import { getSetting, setSetting } from "../db.ts"; +import { nowIso } from "../util.ts"; + +/** # 成员变量: settings 表里 OIDC 配置的键 **/ +export const OIDC_SETTING_KEY = "oidc_config"; + +/** # 成员变量: 授权 scope(固定:userinfo 取身份必需 openid;profile 带展示名 claim) **/ +export const OIDC_SCOPES = "openid profile"; + +/** # 成员变量: 后台保存的 OIDC 配置(settings 表 JSON) **/ +interface OidcConfigSetting { + /** IdP 签发者,如 https://auth.my.qiaonao.net(末尾不带斜杠) **/ + issuer: string; + /** ZITADEL 应用的 Client ID(PKCE 公共客户端,无 secret) **/ + client_id: string; + /** 是否启用(登录页显示统一登录入口) **/ + enabled: boolean; + /** JIT:未绑定的统一登录身份是否自动创建本地用户(创建后即管理员) **/ + jit: boolean; +} + +/** # 成员变量: 生效配置(source 标明来源,同 Cloudflare OAuth 的语义) **/ +export interface OidcConfig { + issuer: string; + clientId: string; + enabled: boolean; + jit: boolean; + /** settings = 后台设置;env = 部署环境变量兜底;none = 未配置 */ + source: "settings" | "env" | "none"; +} + +/** # 成员变量: discovery 拿到的端点 **/ +export interface OidcEndpoints { + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; +} + +/** # 成员变量: discovery 缓存(issuer → 端点),IdP 不可达时沿用旧值,不影响已登录会话 **/ +const discoveryCache = new Map(); + +/** # 成员变量: discovery 缓存有效期(毫秒) **/ +const DISCOVERY_TTL_MS = 3600 * 1000; + +/** # 函数 + * 读取生效的 OIDC 配置:settings 优先,env 兜底;两处都没有返回 null + */ +export async function getOidcConfig(env: Env): Promise { + const raw = await getSetting(env, OIDC_SETTING_KEY); + if (raw) { + try { + const cfg = JSON.parse(raw) as Partial; + if (cfg.issuer && cfg.client_id) { + return { + issuer: cfg.issuer, + clientId: cfg.client_id, + enabled: cfg.enabled !== false && Boolean(cfg.issuer && cfg.client_id), + jit: cfg.jit !== false, + source: "settings", + }; + } + } catch { + // settings 损坏时落到 env 兜底 + } + } + if (env.OIDC_ISSUER && env.OIDC_CLIENT_ID) { + return { + issuer: env.OIDC_ISSUER.replace(/\/+$/, ""), + clientId: env.OIDC_CLIENT_ID, + enabled: env.OIDC_ENABLED !== "false", + jit: true, + source: "env", + }; + } + return null; +} + +/** # 函数 + * 校验 issuer URL:必须是 https(本机 localhost 允许 http 便于联调) + * 返回值: 合法返回 null,否则返回中文错误信息 + */ +export function validateIssuerUrl(issuer: string): string | null { + let u: URL; + try { + u = new URL(issuer); + } catch { + return "Issuer 不是合法 URL"; + } + const localhost = ["localhost", "127.0.0.1", "[::1]", "::1"].includes(u.hostname); + if (u.protocol !== "https:" && !localhost) return "Issuer 必须是 HTTPS 地址"; + return null; +} + +/** # 函数 + * 统一登录回调地址(需要在 ZITADEL 控制台应用里登记) + */ +export function oidcRedirectUri(origin: string): string { + return `${origin}/auth/callback`; +} + +/** # 函数 + * 拉取(带缓存)IdP 的 OIDC discovery 端点 + * 参数: env - 运行环境;issuer - 签发者 + * 返回值: authorization/token/userinfo 三端点;IdP 不可达且无缓存时抛错(不 panic,由路由转 502/跳错误) + */ +export async function discoverOidcEndpoints(env: Env, issuer: string): Promise { + const hit = discoveryCache.get(issuer); + if (hit && Date.now() - hit.fetchedAt < DISCOVERY_TTL_MS) return hit.endpoints; + const resp = await fetch(`${issuer}/.well-known/openid-configuration`, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(10_000), + }); + if (!resp.ok) throw new Error(`discovery ${resp.status}`); + const doc = (await resp.json()) as Partial; + if (!doc.authorization_endpoint || !doc.token_endpoint || !doc.userinfo_endpoint) { + throw new Error("discovery missing endpoints"); + } + const endpoints: OidcEndpoints = { + authorization_endpoint: doc.authorization_endpoint, + token_endpoint: doc.token_endpoint, + userinfo_endpoint: doc.userinfo_endpoint, + }; + discoveryCache.set(issuer, { fetchedAt: Date.now(), endpoints }); + return endpoints; +} + +/** # 函数 + * 清空 discovery 缓存(测试用) + */ +export function resetOidcDiscoveryCache(): void { + discoveryCache.clear(); +} + +/** # 函数 + * 生成 PKCE code_verifier 与 S256 code_challenge(RFC 7636) + */ +export async function createPkce(): Promise<{ verifier: string; challenge: string }> { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + const verifier = base64Url(bytes); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + return { verifier, challenge: base64Url(new Uint8Array(digest)) }; +} + +/** # 函数 + * 构造授权端点跳转 URL(Authorization Code + PKCE;state 由调用方生成并存库) + */ +export function buildOidcAuthorizeUrl(args: { + authorizationEndpoint: string; + clientId: string; + redirectUri: string; + state: string; + codeChallenge: string; +}): string { + const q = new URLSearchParams({ + client_id: args.clientId, + redirect_uri: args.redirectUri, + response_type: "code", + scope: OIDC_SCOPES, + state: args.state, + code_challenge: args.codeChallenge, + code_challenge_method: "S256", + }); + return `${args.authorizationEndpoint}?${q.toString()}`; +} + +/** # 成员变量: userinfo 返回的身份字段(全部可为空,除 sub) **/ +export interface OidcUserInfo { + sub: string; + name?: string; + given_name?: string; + family_name?: string; + preferred_username?: string; + nickname?: string; + email?: string; +} + +/** # 函数 + * 用授权码换 token 并访问 userinfo 取身份(服务端瞬时使用,令牌不落库不外传) + * 说明:身份取自 userinfo 的 sub —— access_token 是本服务直接从 token 端点(TLS)换来的, + * 绑定到本次 state 校验通过的会话,无需再做 ID Token 签名验证 + * 参数: endpoints - discovery 端点;clientId/redirectUri - 与发起时一致;code - 授权码;verifier - PKCE verifier + * 返回值: userinfo;换码或 userinfo 失败抛错(调用方 fail closed) + */ +export async function exchangeOidcCode(args: { + endpoints: OidcEndpoints; + clientId: string; + redirectUri: string; + code: string; + verifier: string; +}): Promise { + const body = new URLSearchParams({ + grant_type: "authorization_code", + code: args.code, + redirect_uri: args.redirectUri, + client_id: args.clientId, + code_verifier: args.verifier, + }); + const tokenResp = await fetch(args.endpoints.token_endpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body: body.toString(), + signal: AbortSignal.timeout(10_000), + }); + if (!tokenResp.ok) throw new Error(`token endpoint ${tokenResp.status}`); + const token = (await tokenResp.json()) as { access_token?: string; token_type?: string }; + if (!token.access_token) throw new Error("token response missing access_token"); + const uiResp = await fetch(args.endpoints.userinfo_endpoint, { + headers: { Authorization: `Bearer ${token.access_token}`, Accept: "application/json" }, + signal: AbortSignal.timeout(10_000), + }); + if (!uiResp.ok) throw new Error(`userinfo ${uiResp.status}`); + const ui = (await uiResp.json()) as OidcUserInfo; + if (!ui.sub) throw new Error("userinfo missing sub"); + return ui; +} + +/** # 函数 + * 展示名兜底链(docs/12):name → given+family → preferred_username → nickname → 邮箱本地部分 → "统一账号 " + subject 前 12 位 + */ +export function oidcDisplayName(ui: OidcUserInfo): string { + const chain = [ + ui.name?.trim(), + [ui.given_name?.trim(), ui.family_name?.trim()].filter(Boolean).join(" ").trim() || null, + ui.preferred_username?.trim(), + ui.nickname?.trim(), + ui.email?.includes("@") ? ui.email.split("@")[0] : null, + `统一账号 ${ui.sub.slice(0, 12)}`, + ]; + for (const v of chain) if (v) return v; + return `统一账号 ${ui.sub.slice(0, 12)}`; +} + +/** # 函数 + * 解析或按 JIT 建立统一登录身份对应的本地用户 + * 参数: env - 运行环境;cfg - 生效配置;ui - userinfo;issuer - 签发者 + * 返回值: 本地用户 id 与用户名;未绑定且 JIT 关闭返回 null + */ +export async function resolveOrProvisionIdentity( + env: Env, + cfg: OidcConfig, + issuer: string, + ui: OidcUserInfo, +): Promise<{ userId: string; username: string; created: boolean } | null> { + const existing = await env.DB.prepare("SELECT user_id FROM oidc_identities WHERE issuer = ? AND subject = ?") + .bind(issuer, ui.sub) + .first<{ user_id: string }>(); + const now = nowIso(); + const displayName = oidcDisplayName(ui); + if (existing) { + // 重复绑定同一 subject:刷新资料,不报冲突 + await env.DB.prepare("UPDATE oidc_identities SET display_name = ?, email = ?, updated_at = ? WHERE issuer = ? AND subject = ?") + .bind(displayName, ui.email ?? null, now, issuer, ui.sub) + .run(); + const user = await env.DB.prepare("SELECT id, username FROM users WHERE id = ?") + .bind(existing.user_id) + .first<{ id: string; username: string }>(); + if (user) return { userId: user.id, username: user.username, created: false }; + // 身份指向的用户被删:落回 JIT 流程重建 + } + if (!cfg.jit) return null; + const userId = ids.user(); + const username = await uniqueUsername(env, displayName); + // 密码哈希用随机串:该账号不能被密码登录,只能走统一登录(管理员可在设置里改密) + const randomPassword = base64Url(crypto.getRandomValues(new Uint8Array(24))); + await env.DB.prepare("INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)") + .bind(userId, username, await hashPassword(randomPassword), now) + .run(); + await env.DB.prepare( + `INSERT INTO oidc_identities (id, issuer, subject, user_id, display_name, email, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(issuer, subject) DO UPDATE SET user_id = excluded.user_id, + display_name = excluded.display_name, email = excluded.email, updated_at = excluded.updated_at`, + ) + .bind(ids.identity(), issuer, ui.sub, userId, displayName, ui.email ?? null, now, now) + .run(); + return { userId, username, created: true }; +} + +/** # 函数 + * 由展示名生成不冲突的本地用户名(小写字母/数字/_.-,2-32 位,冲突追加 -2/-3…) + */ +async function uniqueUsername(env: Env, base: string): Promise { + let slug = base.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "").slice(0, 32); + if (slug.length < 2) slug = `sso-${slug}`.slice(0, 32); + let candidate = slug; + for (let i = 2; ; i++) { + const dup = await env.DB.prepare("SELECT id FROM users WHERE username = ?").bind(candidate).first(); + if (!dup) return candidate; + candidate = `${slug.slice(0, 29)}-${i}`; + } +} + +/** # 函数 + * 校验回跳路径:只接受站内路径(防开放重定向) + */ +export function safeLocalPath(p: string | null | undefined): string { + if (p && p.startsWith("/") && !p.startsWith("//") && !p.includes("\\")) return p; + return "/"; +} + +/** # 函数 + * bytes → base64url(无填充) + */ +function base64Url(bytes: Uint8Array): string { + let bin = ""; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** # 函数 + * 保存后台 OIDC 配置(settings 表) + */ +export async function saveOidcConfig( + env: Env, + cfg: { issuer: string; client_id: string; enabled: boolean; jit: boolean }, +): Promise { + await setSetting(env, OIDC_SETTING_KEY, JSON.stringify(cfg)); +} + +/** # 函数 + * 读取后台保存的原始 OIDC 配置(未保存返回 null;用于保存时合并开关) + */ +export async function readOidcSettingRaw(env: Env): Promise { + const raw = await getSetting(env, OIDC_SETTING_KEY); + if (!raw) return null; + try { + return JSON.parse(raw) as OidcConfigSetting; + } catch { + return null; + } +} + +/** # 函数 + * 清除后台保存的 OIDC 配置(env 兜底仍可用) + */ +export async function clearOidcConfig(env: Env): Promise { + await env.DB.prepare("DELETE FROM settings WHERE key = ?").bind(OIDC_SETTING_KEY).run(); +} diff --git a/docs/07_WEBHOOK_API_MCP.md b/docs/07_WEBHOOK_API_MCP.md index 279949c..2a7b46d 100644 --- a/docs/07_WEBHOOK_API_MCP.md +++ b/docs/07_WEBHOOK_API_MCP.md @@ -2,6 +2,8 @@ Webhook、API、MCP 全部属于 Console,Email Worker 不实现这些能力。 +> 全量 HTTP 端点参考(含 Console Web API、/api/v1、MCP、/auth 统一登录)见 [docs/API.md](./API.md);本文只讲设计要点。 + ## Webhook(三级:邮箱 -> 域名 -> 全局) 收到邮件后按 邮箱 -> 域名 -> 全局 的顺序取第一个已配置的 Webhook(无配置则逐级回退), diff --git a/docs/12_ZITADEL_SSO.md b/docs/12_ZITADEL_SSO.md new file mode 100644 index 0000000..8af2bef --- /dev/null +++ b/docs/12_ZITADEL_SSO.md @@ -0,0 +1,56 @@ +# 12. ZITADEL 统一登录(v2.9.0) + +Console 支持通过自建 ZITADEL 实例做 SSO 登录:OIDC **Authorization Code + PKCE**(公共客户端,无 client secret),与用户名密码登录双通道并存,互不替代。 + +## 12.1 配置 + +入口:设置 → **ZITADEL 统一登录** tab。配置存 settings 表,部署环境变量兜底(与 Cloudflare OAuth 同语义): + +| 来源 | 说明 | +|------|------| +| `settings` | 后台设置保存的 `{ issuer, client_id, enabled, jit }`(主) | +| `env` | `OIDC_ISSUER` + `OIDC_CLIENT_ID`(可选 `OIDC_ENABLED=false` 关闭) | +| `none` | 未配置,登录页不显示统一登录入口 | + +- **Issuer**:如 `https://auth.my.qiaonao.net`(必须 HTTPS;本机联调允许 `http://localhost`)。 +- **Client ID**:在 ZITADEL 控制台(项目 → 应用)为 WmailHub 建应用,认证方式选 Code + PKCE,不需要 secret。 +- **回调地址(Redirect URI)**:`{Console origin}/auth/callback`,必须登记进 ZITADEL 应用。 +- **Scope**:固定 `openid profile`(userinfo 取身份;实例未配 SMTP 时拿不到 email claim,不影响登录)。 + +## 12.2 流程与安全 + +``` +GET /auth/status 公开探测 { enabled, issuer }(登录页据此显示按钮) +GET /auth/login 生成 state + PKCE verifier 存 oidc_states(10 分钟,一次性)→ 302 授权端点 +GET /auth/callback state 用后即删 → code 换 token → userinfo 取 sub → 解析/JIT 建号 → 签发本地会话 cookie → 302 / +``` + +- 身份主键 **(issuer, subject)**,存 `oidc_identities`;email 只是资料展示,**绝不作为身份键**(实例无 SMTP,邮箱可能缺失)。 +- 令牌只在服务端瞬时使用(换 userinfo),不落库、不进前端、不进日志;身份取 userinfo 的 `sub`,不做 ID Token 签名验证(access_token 由本服务直接从 token 端点换得)。 +- 回跳路径只接受站内路径(防开放重定向);所有失败 302 回 `/login?error=`,登录页映射中文文案。 +- 本地会话(HMAC cookie)独立于 IdP:IdP 挂掉时已登录用户不受影响;IdP 不可达时 discovery 报错走 `oidc_unavailable`,进程不 panic。 + +## 12.3 账号模型 + +- **登录成功即管理员**:本平台没有权限设置,任何能完成统一登录的身份与密码登录用户权限完全一致。 +- **JIT(自动建号)**:设置里可开关(默认开)。开启时未绑定的身份首次登录自动创建本地用户(用户名取展示名兜底链 slug 化,冲突加 `-2/-3`;密码哈希为随机串,即该账号不能密码登录);关闭时未绑定身份拒绝登录(`oidc_not_linked`)。 +- 展示名兜底链:`name` → `given_name + family_name` → `preferred_username` → `nickname` → 邮箱本地部分 → `统一账号 `。 +- 重复登录同一 subject:刷新 `display_name/email` 资料,不报冲突。 +- **登出仅清本地会话**,不做 IdP end_session(避免把整个 ZITADEL SSO 会话登掉,影响其他接入应用)——有意偏离,非遗漏。 + +## 12.4 数据库(migrations/0006) + +- `oidc_identities(id, issuer, subject, user_id, display_name, email, created_at, updated_at)`,UNIQUE(issuer, subject) +- `oidc_states(state, code_verifier, return_to, created_at, expires_at)`,一次性 + +## 12.5 验收自查(v2.9.0 实测) + +- [x] Authorization Code + PKCE(S256),非 implicit(token 请求带 `code_verifier` 有测试) +- [x] state 一次性、10 分钟过期、校验后才换码(重放测试) +- [x] 令牌不落前端、不落库、不进日志 +- [x] 身份主键 (issuer, subject),email 仅资料 +- [x] 本地会话独立于 IdP;登录页/应用无 token 互踢死循环(前端无 token 概念,纯 cookie 会话) +- [x] 登出仅本地会话(有意偏离,见 12.3) +- [x] 密码登录通道完整保留(回归测试覆盖) +- [x] JIT 可配置;开启时建号即管理员(按需求) +- [x] 日志无敏感信息(operation_logs 只记动作/用户名/issuer/开关布尔) diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..9396827 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,275 @@ +# WmailHub API 文档 + +适用版本:**v2.9.0**(版本号以 `/api/health` 返回为准)。Base URL:生产 `https://wmailhub.adx9.com`,本地开发 `http://127.0.0.1:18787`。 + +## 目录 + +- [认证方式](#认证方式) +- [健康检查与探测](#健康检查与探测) +- [登录会话 API(/api/auth)](#登录会话-apiapiauth) +- [ZITADEL 统一登录(/auth)](#zitadel-统一登录auth) +- [Console Web API(/api/*,Cookie 会话)](#console-web-apiapi-cookie-会话) +- [开放 API(/api/v1/*,Bearer Key)](#开放-apiapiv1bearer-key) +- [MCP(/api/v1/mcp)](#mcpapiv1mcp) +- [Webhook(收件推送)](#webhook收件推送) +- [内部 API(/internal,Email Worker 专用)](#内部-apiinternalemail-worker-专用) +- [错误码约定](#错误码约定) + +--- + +## 认证方式 + +三套通道并存,按端点区分: + +| 通道 | 适用端点 | 凭据 | +|------|----------|------| +| **Cookie 会话** | `/api/auth/*`、`/api/*`(Console Web)、`/api/admin/*`、`/api/settings/*`、`/api/oauth/*` | 登录后 `wmh_session` Cookie(HttpOnly,7 天) | +| **Bearer Key** | `/api/v1/*`(含 `/api/v1/mcp`) | `Authorization: Bearer wmh_…`(32 位;API 与 MCP 共用同一 Key) | +| **HMAC 签名** | `/internal/*` | Email Worker 专用,见 docs/04 / docs/11 | + +- Key 在 **设置 → 全局 Webhook → API / MCP Key** 生成/轮换/撤销;明文仅生成时返回一次,服务端只存 SHA-256;新 Key 生成后旧 Key 立即失效。 +- Cookie 会话的写请求有 CSRF 防护:带 Cookie 时 `Origin`/`Referer` 必须与站点 Host 一致,否则 403。Bearer Key 调用不受影响。 + +## 健康检查与探测 + +### GET /api/health + +公开。返回 `{ ok: true, name: "WmailHub", version: "2.9.0" }`。 + +### GET /auth/status + +公开。统一登录探测:返回 `{ enabled: boolean, issuer: string | null }`,登录页据此显示「ZITADEL 统一登录」按钮。 + +## 登录会话 API(/api/auth) + +### POST /api/auth/login + +```json +{ "username": "admin", "password": "admin" } +``` + +成功 `200 { ok: true, user: { id, username } }` 并下发会话 Cookie;失败 `401 {"error":"invalid username or password"}`。默认账号 `admin / admin`(首次启动自动播种)。 + +### POST /api/auth/logout + +清除会话 Cookie,返回 `{ ok: true }`。(ZITADEL 登录的用户同样只清本地会话,IdP 侧 SSO 会话保留。) + +### GET /api/auth/me + +返回当前用户 `{ user: { id, username }, now }`;未登录 `401`。用户名以库内实时数据为准。 + +## ZITADEL 统一登录(/auth) + +OIDC Authorization Code + PKCE(无 client secret),与密码登录双通道并存;**登录成功即管理员**(本平台没有权限设置)。配置入口:设置 → ZITADEL 统一登录(issuer + client_id;回调地址 `{origin}/auth/callback` 需登记到 ZITADEL 应用)。详细设计见 docs/12。 + +| 端点 | 说明 | +|------|------| +| `GET /auth/login` | 发起授权,302 到 IdP;可选 `?return=/<站内路径>` 指定回跳(默认 `/`)。未启用 302 → `/login?error=oidc_disabled` | +| `GET /auth/callback` | IdP 回调(浏览器重定向,无需调用):一次性 state 校验 → 换码 → userinfo → 建号/匹配 → 下发会话 Cookie → 302 回站内;失败 302 → `/login?error=` | + +错误码:`oidc_disabled` / `oidc_unavailable` / `state_invalid` / `oidc_exchange_failed` / `oidc_not_linked`(JIT 关闭且身份未绑定)。 + +## Console Web API(/api/*,Cookie 会话) + +以下端点未登录一律 `401`。分页参数统一 `limit`(默认 20,最大 100)与 `offset`,列表返回 `{ items, total }`。 + +### Cloudflare 账号 /api/accounts + +| 方法与路径 | 说明 | +|------------|------| +| `GET /api/accounts` | 账号列表 | +| `GET /api/accounts/:id` | 账号详情(接入步骤、OAuth 连接、域名、Worker 凭据) | +| `PATCH /api/accounts/:id` | 改名 `{ name }` | +| `DELETE /api/accounts/:id` | 删除账号(级联清理 Cloudflare 侧 Email Routing/Worker,尽力而为) | +| `POST /api/accounts/batch-delete` | 批量删除 `{ ids: string[] }` → `{ ok, deleted, failed[] }` | +| `POST /api/accounts/token` | API Token 接入 `{ name, token }`(后端先探 Cloudflare 再接入)→ `{ ok, account_id }` | +| `POST /api/accounts/:id/rotate` | 轮换 Worker 凭据;secret 仅本次返回,已配域名自动重部署 | +| `GET /api/accounts/:id/worker-secret` | 本地/运维:读取当前 Worker credential | + +### 域名 /api/domains + +| 方法与路径 | 说明 | +|------------|------| +| `GET /api/domains` | 域名列表(含各域名 Webhook 配置与邮箱计数) | +| `GET /api/domains/filters/accounts` | 账号筛选项 | +| `POST /api/domains/:id/setup` | 配置 Email Workers(部署 Worker + 开 Routing + catch-all)→ `{ ok, error? }` | +| `PUT /api/domains/:id/webhook` | 域名级 Webhook `{ url, headers?, key? }`(url 空串清除;key undefined 保留、空格清除) | +| `DELETE /api/domains/:id/webhook` | 清除域名级 Webhook | +| `DELETE /api/domains/:id` / `POST /api/domains/batch-delete` | 删除(级联邮箱与邮件) | + +### 邮箱 /api/mailboxes + +| 方法与路径 | 说明 | +|------------|------| +| `GET /api/mailboxes?domain_id=` | 邮箱列表 | +| `POST /api/mailboxes` | 创建 `{ domain_id, address, retention_seconds?, catch_all? }` → `{ ok, id, address }`;重名 409 | +| `PATCH /api/mailboxes/:id` | `{ status?: "active"\|"disabled", retention_seconds?: number\|null }`(null=回到全局默认 86400 秒) | +| `PUT /api/mailboxes/:id/webhook` | 邮箱级 Webhook,语义同域名级 | +| `DELETE /api/mailboxes/:id/webhook` | 清除邮箱级 Webhook | +| `DELETE /api/mailboxes/:id` / `POST /api/mailboxes/batch-delete` | 删除 | + +Webhook 生效顺序:**邮箱 → 域名 → 全局**,取第一个已配置的。 + +### 邮件 /api/emails + +| 方法与路径 | 说明 | +|------------|------| +| `GET /api/emails?domain_id=&mailbox_id=&q=&limit=&offset=` | 列表(仅未过期) | +| `GET /api/emails/:id` | 详情(正文 text/html/markdown + 附件列表);过期 `410` | +| `GET /api/emails/:id/attachments/:attId` | 下载附件 | +| `GET /api/emails/:id/raw` | 原始 EML | +| `DELETE /api/emails/:id` / `POST /api/emails/batch-delete` | 删除 | +| `POST /api/emails/retain` | 保留 `{ ids }`(永不过期,Cron 跳过)→ `{ ok, retained }` | + +### 操作日志 /api/logs + +`GET /api/logs?target_type=&action=&q=&limit=&offset=`。`target_type` ∈ `account/domain/mailbox/email/webhook/user/settings`;库里最多保留 10000 条。 + +### 设置 /api/settings + +| 方法与路径 | 说明 | +|------------|------| +| `GET /api/settings` | 全量读取:当前用户名、全局 Webhook(key 只回 last4)、API Key 元信息、Cloudflare OAuth、ZITADEL 统一登录(含回调地址与当前账号绑定身份) | +| `PUT /api/settings` | 保存 `{ webhook?, cf_oauth?, oidc? }`;`webhook.key`/`cf_oauth.secret`:undefined 保留、空串清除、非空设置;`oidc: { issuer, client_id, enabled?, jit? }`(开关缺省沿用已存值) | +| `PUT /api/settings/password` | 改密码 `{ old_password, new_password }`(≥6 位) | +| `PUT /api/settings/username` | 改用户名 `{ new_username, password }`(需当前密码;2-32 位字母/数字/`_ . -`;成功重签会话 Cookie;重名 409) | +| `POST /api/settings/api-key` | 生成(轮换)API/MCP Key → `201 { key, created_at }`(明文仅此一次) | +| `DELETE /api/settings/api-key` | 撤销并删除 Key | +| `DELETE /api/settings/cf-oauth` | 清除 Cloudflare OAuth 凭据(env 兜底仍可用) | +| `DELETE /api/settings/oidc` | 清除统一登录配置(已建立的绑定保留) | + +### Cloudflare OAuth /api/oauth + +| 方法与路径 | 说明 | +|------------|------| +| `GET /api/oauth/mode` | `{ mode: "cloudflare"|"unconfigured", source, scope }` | +| `GET /api/oauth/cloudflare/start` | 生成一次性 state,返回 `{ url, state }`(浏览器跳 url 完成授权) | +| `GET /api/oauth/cloudflare/callback` | 授权回调(浏览器重定向,无需调用) | +| `GET /api/oauth/cloudflare/preflight` | 授权配置自检(探测 Cloudflare 授权端点 + scope 探测) | +| `GET /api/oauth/connections` | 已建立的 OAuth 连接(不含 token) | + +### 管理 /api/admin + +`GET /api/admin/version`;`GET /api/admin/stats`(Dashboard 统计 + 最近 8 封邮件);`POST /api/admin/cron/run`(手动触发 TTL 清理与 Webhook 投递)。 + +## 开放 API(/api/v1/*,Bearer Key) + +认证:`Authorization: Bearer wmh_…`;无 Key/Key 无效 `401`。所有邮件读取强制只返回**未过期**邮件。 + +### GET /api/v1/accounts + +`{ items: [{ id, name, cloudflare_account_id, worker_id, status, created_at }] }` + +### GET /api/v1/domains + +`{ items: [{ id, name, status, account_id, created_at }] }` + +### GET /api/v1/mailboxes + +`{ items: [{ id, address, status, is_catch_all, retention_seconds, domain_id, domain_name }] }` + +### POST /api/v1/mailboxes + +```json +{ "domain_id": "dom_xxx", "address": "hello", "retention_seconds": 86400, "webhook": "https://hooks.example.com/wmail" } +``` + +- `domain_id` **可省略(v2.8.0 起)**:随机分配一个已启用 Email Workers 的域名;此时 `address` 必须为本地部分(不带 `@`)。 +- `webhook` **可选(v2.8.0 起)**:创建时直接配置邮箱级 Webhook(先校验再建邮箱,校验失败不留半截数据;SSRF 规则同 Console——拒绝私网/localhost/元数据地址)。 +- 返回 `201 { ok: true, id, address, webhook? }`;错误:`400 address required` / `404 domain not found` / `409 mailbox already exists` / `409 no available domain (none has Email Workers enabled)`。 + +### DELETE /api/v1/mailboxes/:id + +成功 `{ ok: true }`;不存在 `404`。 + +### GET /api/v1/emails?mailbox_id=&limit=&offset= + +`{ items: [{ id, mailbox_id, mailbox_address, message_id, from_address, to_addresses, subject, size, received_at, expires_at }] }`(仅未过期)。 + +### GET /api/v1/emails/:id + +`{ email: { …全字段含 text_body/html_body/markdown_body }, attachments: [{ id, filename, content_type, size, content_id }] }`;不存在 `404`,已过期 `410 {"error":"email expired"}`。 + +#### curl 示例 + +```bash +KEY="wmh_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +BASE="https://wmailhub.adx9.com" + +# 随机域名建邮箱 + 邮箱级 Webhook +curl -s -X POST "$BASE/api/v1/mailboxes" \ + -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d '{"address":"hello","webhook":"https://hooks.example.com/wmail"}' + +# 拉最近邮件(取第一条详情) +curl -s -H "Authorization: Bearer $KEY" "$BASE/api/v1/emails?limit=1" +``` + +## MCP(/api/v1/mcp) + +Streamable HTTP(JSON-RPC 2.0,协议版本 `2025-06-18`),**仅 POST**(GET → 405),Bearer Key 与 API 共用。方法:`initialize` → `notifications/initialized` → `tools/list` / `tools/call`;`ping` 可用。 + +MCP 客户端配置: + +```json +{ + "mcpServers": { + "wmailhub": { + "type": "http", + "url": "https://wmailhub.adx9.com/api/v1/mcp", + "headers": { "Authorization": "Bearer wmh_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } + } + } +} +``` + +### 工具清单(v2.9.0 共 6 个) + +| 工具 | 参数 | 说明 | +|------|------|------| +| `list_domains` | — | 列出可用域名(创建邮箱的 domain_id 由此获取) | +| `list_mailboxes` | — | 列出邮箱(含域名与保留策略) | +| `list_emails` | `mailbox_id?`, `limit?` | 列出未过期邮件(按收到时间倒序) | +| `get_email` | `email_id` | 邮件详情(正文 + 附件);过期返回资源错误 -32002 | +| `create_mailbox` | `address`, `domain_id?`, `retention_seconds?`, `webhook?` | 创建邮箱;`domain_id` 省略时随机分配(此时 address 须为本地部分);`webhook` 为可选邮箱级 Webhook URL | +| `delete_mailbox` | `mailbox_id` | 删除邮箱 | + +错误约定:`-32601` 未知方法/工具、`-32602` 参数错误、`-32002` 资源不存在(如邮件过期)。 + +## Webhook(收件推送) + +收到邮件时按 邮箱 → 域名 → 全局 取第一个已配置的 Webhook 入队投递。Payload: + +```json +{ + "event": "email.received", + "id": "msg_xxx", + "mailbox": "hello@example.com", + "from": "user@example.org", + "to": ["hello@example.com"], + "subject": "Hello", + "text": "Hello World", + "markdown": "Hello **World**", + "received_at": "2026-09-30T10:00:00Z" +} +``` + +请求头:`X-WmailHub-Event`、`X-WmailHub-Delivery`、`X-WmailHub-Timestamp`、`X-WmailHub-Signature`(签名 = `HMAC-SHA256(key, ".")`,配了 key 才有);配置 key 时默认携带 `Authorization: Bearer `,自定义头优先。`2xx` 为成功;5xx/超时按指数退避重试,耗尽后置 `exhausted`;Cron 周期性重投 `pending/failed`。 + +## 内部 API(/internal,Email Worker 专用) + +`POST /internal/email/authorize` 与 `POST /internal/email/ingest`:Email Worker 取收件凭据与投递邮件,HMAC 签名认证,协议见 docs/04 与 docs/11,不对外暴露。 + +## 错误码约定 + +| 状态码 | 场景 | +|--------|------| +| 400 | 参数缺失/非法(webhook URL 不合法、用户名格式错误等) | +| 401 | 未登录 / Bearer Key 缺失或无效 | +| 403 | CSRF 校验失败(带 Cookie 的跨源写请求) | +| 404 | 资源不存在 | +| 409 | 冲突(邮箱已存在、无可用域名、用户名已存在) | +| 410 | 邮件已过期(Gone) | +| 503 | 依赖未配置(如 Cloudflare OAuth 未配置时添加账号) | + +错误响应统一为 `{"error": "<中文或英文消息>"}`。 diff --git a/migrations/0006_oidc_sso.sql b/migrations/0006_oidc_sso.sql new file mode 100644 index 0000000..81d0dae --- /dev/null +++ b/migrations/0006_oidc_sso.sql @@ -0,0 +1,25 @@ +-- ZITADEL 统一登录(v2.9.0):身份绑定 + 一次性授权 state +-- 身份主键是 (issuer, subject),email 仅作资料展示、绝不参与判定(实例无 SMTP,邮箱可能缺失) + +-- OIDC 身份绑定:统一登录账号 ↔ 本地 users 的映射(重复登录同一 subject 刷新资料,不报冲突) +CREATE TABLE IF NOT EXISTS oidc_identities ( + id TEXT PRIMARY KEY, + issuer TEXT NOT NULL, -- IdP 签发者(如 https://auth.my.qiaonao.net) + subject TEXT NOT NULL, -- IdP 的 sub(身份主键的一部分) + user_id TEXT NOT NULL, -- 本地 users.id + display_name TEXT, -- 展示名(兜底链取值,可为空) + email TEXT, -- 纯资料,可为空,永不作为身份键 + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(issuer, subject) +); +CREATE INDEX IF NOT EXISTS idx_oidc_identities_user ON oidc_identities(user_id); + +-- OIDC 一次性 state:承载 code_verifier(PKCE)与回跳路径,用后即删 +CREATE TABLE IF NOT EXISTS oidc_states ( + state TEXT PRIMARY KEY, + code_verifier TEXT NOT NULL, + return_to TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); diff --git a/package.json b/package.json index 85c2b40..3590a02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wmailhub", - "version": "2.8.0", + "version": "2.9.0", "description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)", "private": true, "type": "module", diff --git a/shared/src/ids.ts b/shared/src/ids.ts index 83acbf5..6213229 100644 --- a/shared/src/ids.ts +++ b/shared/src/ids.ts @@ -34,4 +34,6 @@ export const ids = { oauthState: () => newId("st"), credential: () => newId("cred"), log: () => newId("oplog"), + /** OIDC 身份绑定 id **/ + identity: () => newId("oidc"), }; diff --git a/tests/oidc-sso.test.ts b/tests/oidc-sso.test.ts new file mode 100644 index 0000000..cddfccc --- /dev/null +++ b/tests/oidc-sso.test.ts @@ -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 = { 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(); + }); +}); diff --git a/tests/ui-smoke.ts b/tests/ui-smoke.ts index 48ca0b8..bcb628a 100644 --- a/tests/ui-smoke.ts +++ b/tests/ui-smoke.ts @@ -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 });