v2.9.1: 统一登录单用户化——删除 JIT 开关与 oidc_identities 映射表(0007),任意 ZITADEL 身份登录唯一用户,上次登录身份存 settings 展示
This commit is contained in:
@@ -338,21 +338,19 @@ export interface SettingsInfo {
|
|||||||
/** 实际请求的 scope(点号分隔,空格分隔多个) */
|
/** 实际请求的 scope(点号分隔,空格分隔多个) */
|
||||||
scope: string;
|
scope: string;
|
||||||
};
|
};
|
||||||
/** ZITADEL 统一登录配置与当前账号绑定情况 */
|
/** ZITADEL 统一登录配置与上次登录身份 */
|
||||||
oidc: {
|
oidc: {
|
||||||
issuer: string;
|
issuer: string;
|
||||||
client_id: string;
|
client_id: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
/** JIT:未绑定的统一登录身份自动创建本地用户(即管理员) */
|
|
||||||
jit: boolean;
|
|
||||||
/** settings = 后台设置;env = 部署变量兜底;none = 未配置 */
|
/** settings = 后台设置;env = 部署变量兜底;none = 未配置 */
|
||||||
source: "settings" | "env" | "none";
|
source: "settings" | "env" | "none";
|
||||||
/** 授权 scope(固定 openid profile) */
|
/** 授权 scope(固定 openid profile) */
|
||||||
scopes: string;
|
scopes: string;
|
||||||
/** 需要在 ZITADEL 控制台应用里登记的回调地址 */
|
/** 需要在 ZITADEL 控制台应用里登记的回调地址 */
|
||||||
redirect_uri: string;
|
redirect_uri: string;
|
||||||
/** 当前账号已绑定的统一登录身份(未绑定为 null) */
|
/** 上次统一登录身份(展示用;单用户系统,登录即当前唯一账号) */
|
||||||
identity: { issuer: string; subject: string; display_name: string | null; email: string | null } | null;
|
last_login: { issuer: string; subject: string; display_name: string; email: string | null; at: string } | null;
|
||||||
};
|
};
|
||||||
/** 需要在 Cloudflare Dashboard 登记的授权回调地址 */
|
/** 需要在 Cloudflare Dashboard 登记的授权回调地址 */
|
||||||
redirect_uri: string;
|
redirect_uri: string;
|
||||||
@@ -362,11 +360,11 @@ export const getSettings = () => api<SettingsInfo>("/api/settings");
|
|||||||
|
|
||||||
/** 保存设置
|
/** 保存设置
|
||||||
* webhook.key / cf_oauth.secret 语义一致:undefined 保留原值、空串清除、非空设置新值
|
* webhook.key / cf_oauth.secret 语义一致:undefined 保留原值、空串清除、非空设置新值
|
||||||
* oidc.enabled / oidc.jit 缺省表示沿用已保存的开关状态 */
|
* oidc.enabled 缺省表示沿用已保存的开关状态 */
|
||||||
export const putSettings = (body: {
|
export const putSettings = (body: {
|
||||||
webhook?: { url: string; headers: Record<string, string>; key?: string };
|
webhook?: { url: string; headers: Record<string, string>; key?: string };
|
||||||
cf_oauth?: { client_id: string; secret?: string; scope?: string };
|
cf_oauth?: { client_id: string; secret?: string; scope?: string };
|
||||||
oidc?: { issuer: string; client_id: string; enabled?: boolean; jit?: boolean };
|
oidc?: { issuer: string; client_id: string; enabled?: boolean };
|
||||||
}) => api("/api/settings", "PUT", body);
|
}) => api("/api/settings", "PUT", body);
|
||||||
|
|
||||||
/** # 成员变量: 授权配置自检结果 **/
|
/** # 成员变量: 授权配置自检结果 **/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* - Cloudflare OAuth 客户端(Client ID / Secret)也在设置里维护,Secret 加密存储只回 last4,
|
* - Cloudflare OAuth 客户端(Client ID / Secret)也在设置里维护,Secret 加密存储只回 last4,
|
||||||
* 保存后立即生效,无需重新部署;未配置时账号页「添加账号」不可用
|
* 保存后立即生效,无需重新部署;未配置时账号页「添加账号」不可用
|
||||||
* - ZITADEL 统一登录:issuer + Client ID(PKCE 公共客户端无 secret),回调地址 {origin}/auth/callback;
|
* - ZITADEL 统一登录:issuer + Client ID(PKCE 公共客户端无 secret),回调地址 {origin}/auth/callback;
|
||||||
* 登录成功即管理员(本平台没有权限设置),JIT 自动建号可开关
|
* 单用户系统(v2.9.1):统一登录成功即登录唯一管理员账号,没有权限设置与账号映射
|
||||||
* - 内容高度不超过屏幕(超出滚动)
|
* - 内容高度不超过屏幕(超出滚动)
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
@@ -620,22 +620,20 @@ interface OidcFormValues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
* ZITADEL 统一登录 tab(v2.9.0):issuer + Client ID(PKCE 无 secret)+ 启用/JIT 开关;
|
* ZITADEL 统一登录 tab(v2.9.0;v2.9.1 单用户化):issuer + Client ID(PKCE 无 secret)+ 启用开关;
|
||||||
* 回调地址需在 ZITADEL 控制台应用里登记;登录成功即管理员(本平台没有权限设置)
|
* 回调地址需在 ZITADEL 控制台应用里登记;单用户系统——统一登录成功即登录本平台唯一账号
|
||||||
*/
|
*/
|
||||||
function ZitadelTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void }) {
|
function ZitadelTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void }) {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const [form] = Form.useForm<OidcFormValues>();
|
const [form] = Form.useForm<OidcFormValues>();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [enabled, setEnabled] = useState(info.oidc.enabled);
|
const [enabled, setEnabled] = useState(info.oidc.enabled);
|
||||||
const [jit, setJit] = useState(info.oidc.jit);
|
|
||||||
const meta = info.oidc;
|
const meta = info.oidc;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.setFieldsValue({ issuer: meta.issuer, client_id: meta.client_id });
|
form.setFieldsValue({ issuer: meta.issuer, client_id: meta.client_id });
|
||||||
setEnabled(meta.enabled);
|
setEnabled(meta.enabled);
|
||||||
setJit(meta.jit);
|
}, [meta.issuer, meta.client_id, meta.enabled, form]);
|
||||||
}, [meta.issuer, meta.client_id, meta.enabled, meta.jit, form]);
|
|
||||||
|
|
||||||
/** 保存统一登录配置 */
|
/** 保存统一登录配置 */
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
@@ -643,7 +641,7 @@ function ZitadelTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await putSettings({
|
await putSettings({
|
||||||
oidc: { issuer: values.issuer.trim(), client_id: values.client_id.trim(), enabled, jit },
|
oidc: { issuer: values.issuer.trim(), client_id: values.client_id.trim(), enabled },
|
||||||
});
|
});
|
||||||
message.success("ZITADEL 统一登录配置已保存");
|
message.success("ZITADEL 统一登录配置已保存");
|
||||||
onSaved();
|
onSaved();
|
||||||
@@ -681,8 +679,8 @@ function ZitadelTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
|
|||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
在 ZITADEL 控制台的应用 Redirect URI 里登记下面的回调地址;所有通过统一登录登录的用户都是
|
在 ZITADEL 控制台的应用 Redirect URI 里登记下面的回调地址。本平台是<b>单用户系统</b>:统一登录成功即登录
|
||||||
<b>管理员</b>(本平台没有权限设置)。
|
唯一的管理员账号,没有权限设置。
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -719,12 +717,6 @@ function ZitadelTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
|
|||||||
启用后登录页显示「ZITADEL 统一登录」按钮;密码登录不受影响(双通道并存)
|
启用后登录页显示「ZITADEL 统一登录」按钮;密码登录不受影响(双通道并存)
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="自动创建本地账号(JIT)" style={{ marginBottom: 8 }}>
|
|
||||||
<Switch checked={jit} onChange={setJit} />
|
|
||||||
<Typography.Text type="secondary" style={{ marginLeft: 12 }}>
|
|
||||||
开启:未绑定的统一登录身份首次登录自动创建本地用户(即管理员);关闭:未绑定身份拒绝登录
|
|
||||||
</Typography.Text>
|
|
||||||
</Form.Item>
|
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||||
保存
|
保存
|
||||||
@@ -732,7 +724,7 @@ function ZitadelTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
|
|||||||
{meta.source === "settings" && (
|
{meta.source === "settings" && (
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="清除后台保存的统一登录配置?"
|
title="清除后台保存的统一登录配置?"
|
||||||
description="若未配置部署环境变量兜底,清除后登录页不再显示统一登录入口;已建立的绑定保留。"
|
description="若未配置部署环境变量兜底,清除后登录页不再显示统一登录入口。"
|
||||||
onConfirm={() => void clear()}
|
onConfirm={() => void clear()}
|
||||||
>
|
>
|
||||||
<Button danger icon={<DeleteOutlined />} disabled={saving}>
|
<Button danger icon={<DeleteOutlined />} disabled={saving}>
|
||||||
@@ -743,27 +735,24 @@ function ZitadelTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
|
|||||||
</Space>
|
</Space>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Card size="small" title="当前账号绑定" style={{ width: "100%" }}>
|
<Card size="small" title="上次统一登录" style={{ width: "100%" }}>
|
||||||
{meta.identity ? (
|
{meta.last_login ? (
|
||||||
<Descriptions size="small" column={1}>
|
<Descriptions size="small" column={1}>
|
||||||
<Descriptions.Item label="身份">
|
<Descriptions.Item label="身份">
|
||||||
<Tag color="green">已绑定</Tag>
|
<span className="mono">{meta.last_login.display_name}</span>
|
||||||
<span className="mono">{meta.identity.display_name ?? "(无展示名)"}</span>
|
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="Subject">
|
<Descriptions.Item label="Subject">
|
||||||
<span className="mono">{meta.identity.subject}</span>
|
<span className="mono">{meta.last_login.subject}</span>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="Issuer">
|
<Descriptions.Item label="Issuer">
|
||||||
<span className="mono">{meta.identity.issuer}</span>
|
<span className="mono">{meta.last_login.issuer}</span>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="邮箱">
|
<Descriptions.Item label="时间">
|
||||||
{meta.identity.email ? <span className="mono">{meta.identity.email}</span> : <Typography.Text type="secondary">未提供(IdP 未配置 SMTP,正常现象)</Typography.Text>}
|
{new Date(meta.last_login.at).toLocaleString()}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
) : (
|
) : (
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">还没有统一登录记录;启用并配置后,在登录页点「ZITADEL 统一登录」即可。</Typography.Text>
|
||||||
当前账号未绑定统一登录身份;绑定后可直接用 ZITADEL 登录本站(启用 JIT 时首次登录会自动建新账号)。
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const OIDC_ERROR_TEXT: Record<string, string> = {
|
|||||||
oidc_unavailable: "统一登录服务暂时不可用,请稍后重试或使用密码登录",
|
oidc_unavailable: "统一登录服务暂时不可用,请稍后重试或使用密码登录",
|
||||||
state_invalid: "登录状态校验失败(可能已过期),请重新登录",
|
state_invalid: "登录状态校验失败(可能已过期),请重新登录",
|
||||||
oidc_exchange_failed: "统一登录兑换失败,请重新登录",
|
oidc_exchange_failed: "统一登录兑换失败,请重新登录",
|
||||||
oidc_not_linked: "该统一登录账号未绑定本站用户,请使用密码登录或联系管理员",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
|
|||||||
+16
-13
@@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* ZITADEL 统一登录路由(docs/12,v2.9.0):
|
* ZITADEL 统一登录路由(docs/12,v2.9.0;v2.9.1 单用户化):
|
||||||
* GET /auth/status 统一登录是否启用(公开,登录页探测;只回 enabled 与 issuer)
|
* GET /auth/status 统一登录是否启用(公开,登录页探测;只回 enabled 与 issuer)
|
||||||
* GET /auth/login 发起授权:生成 state+PKCE 存库,302 到 IdP 授权端点
|
* GET /auth/login 发起授权:生成 state+PKCE 存库,302 到 IdP 授权端点
|
||||||
* GET /auth/callback 授权回调:一次性 state 校验 → 换码 → userinfo → 解析/JIT 建号 → 签发本地会话 cookie
|
* GET /auth/callback 授权回调:一次性 state 校验 → 换码 → userinfo → 登录唯一用户 → 签发本地会话 cookie
|
||||||
*
|
*
|
||||||
|
* 单用户系统:没有身份映射表与 JIT 建号,任何通过 IdP 认证的身份都登录本平台唯一用户。
|
||||||
* 安全:state 一次性(用后即删,10 分钟过期)承担 CSRF 防护——浏览器从 IdP 跨站重定向回来时
|
* 安全:state 一次性(用后即删,10 分钟过期)承担 CSRF 防护——浏览器从 IdP 跨站重定向回来时
|
||||||
* SameSite=Strict 的会话 cookie 不会携带,回调不校验会话;PKCE S256 防授权码拦截。
|
* SameSite=Strict 的会话 cookie 不会携带,回调不校验会话;PKCE S256 防授权码拦截。
|
||||||
* 回跳路径只接受站内路径(防开放重定向);所有失败都 302 回 /login?error=<code> 并附中文文案。
|
* 回跳路径只接受站内路径(防开放重定向);所有失败都 302 回 /login?error=<code> 并附中文文案。
|
||||||
@@ -21,7 +22,8 @@ import {
|
|||||||
exchangeOidcCode,
|
exchangeOidcCode,
|
||||||
getOidcConfig,
|
getOidcConfig,
|
||||||
oidcRedirectUri,
|
oidcRedirectUri,
|
||||||
resolveOrProvisionIdentity,
|
recordLastOidcLogin,
|
||||||
|
resolveSingleUser,
|
||||||
safeLocalPath,
|
safeLocalPath,
|
||||||
} from "../services/oidc.ts";
|
} from "../services/oidc.ts";
|
||||||
import { nowIso } from "../util.ts";
|
import { nowIso } from "../util.ts";
|
||||||
@@ -37,7 +39,6 @@ export const OIDC_ERROR_TEXT: Record<string, string> = {
|
|||||||
oidc_unavailable: "统一登录服务暂时不可用,请稍后重试或使用密码登录",
|
oidc_unavailable: "统一登录服务暂时不可用,请稍后重试或使用密码登录",
|
||||||
state_invalid: "登录状态校验失败(可能已过期),请重新登录",
|
state_invalid: "登录状态校验失败(可能已过期),请重新登录",
|
||||||
oidc_exchange_failed: "统一登录兑换失败,请重新登录",
|
oidc_exchange_failed: "统一登录兑换失败,请重新登录",
|
||||||
oidc_not_linked: "该统一登录账号未绑定本站用户,请使用密码登录或联系管理员",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
@@ -87,7 +88,7 @@ export function oidcRoutes(): App {
|
|||||||
return c.redirect(url, 302);
|
return c.redirect(url, 302);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 授权回调:state → code → userinfo → 本地会话 */
|
/** 授权回调:state → code → userinfo → 唯一用户会话 */
|
||||||
app.get("/callback", async (c) => {
|
app.get("/callback", async (c) => {
|
||||||
const cfg = await getOidcConfig(c.env);
|
const cfg = await getOidcConfig(c.env);
|
||||||
if (!cfg || !cfg.enabled) return redirectError(c, "oidc_disabled");
|
if (!cfg || !cfg.enabled) return redirectError(c, "oidc_disabled");
|
||||||
@@ -119,17 +120,19 @@ export function oidcRoutes(): App {
|
|||||||
verifier: row.code_verifier,
|
verifier: row.code_verifier,
|
||||||
});
|
});
|
||||||
sub = ui.sub;
|
sub = ui.sub;
|
||||||
const resolved = await resolveOrProvisionIdentity(c.env, cfg, cfg.issuer, ui);
|
// 单用户系统:统一登录成功即登录本平台唯一账号(绑定到当前用户)
|
||||||
if (!resolved) return redirectError(c, "oidc_not_linked");
|
const user = await resolveSingleUser(c.env);
|
||||||
const token = await issueSessionToken(c.env, resolved.userId, resolved.username);
|
if (!user) return redirectError(c, "oidc_exchange_failed");
|
||||||
|
await recordLastOidcLogin(c.env, cfg.issuer, ui);
|
||||||
|
const token = await issueSessionToken(c.env, user.userId, user.username);
|
||||||
c.header("Set-Cookie", sessionSetCookie(token));
|
c.header("Set-Cookie", sessionSetCookie(token));
|
||||||
await logOperation(c.env, {
|
await logOperation(c.env, {
|
||||||
action: resolved.created ? "user.oidc_signup" : "user.oidc_login",
|
action: "user.oidc_login",
|
||||||
targetType: "user",
|
targetType: "user",
|
||||||
targetId: resolved.userId,
|
targetId: user.userId,
|
||||||
targetName: resolved.username,
|
targetName: user.username,
|
||||||
detail: { issuer: cfg.issuer, jit: resolved.created },
|
detail: { issuer: cfg.issuer, sso_display_name: ui.name ?? ui.preferred_username ?? "" },
|
||||||
operator: resolved.username,
|
operator: user.username,
|
||||||
});
|
});
|
||||||
return c.redirect(row.return_to, 302);
|
return c.redirect(row.return_to, 302);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
import { getGlobalWebhookKey, normalizeWebhookHeaders, saveGlobalWebhook } from "../services/webhook_delivery.ts";
|
import { getGlobalWebhookKey, normalizeWebhookHeaders, saveGlobalWebhook } from "../services/webhook_delivery.ts";
|
||||||
import {
|
import {
|
||||||
clearOidcConfig,
|
clearOidcConfig,
|
||||||
|
getLastOidcLogin,
|
||||||
getOidcConfig,
|
getOidcConfig,
|
||||||
oidcRedirectUri,
|
oidcRedirectUri,
|
||||||
readOidcSettingRaw,
|
readOidcSettingRaw,
|
||||||
@@ -85,13 +86,9 @@ export function settingsRoutes(): App {
|
|||||||
// settings 损坏时按未配置处理
|
// settings 损坏时按未配置处理
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ZITADEL 统一登录配置(不含 secret 类字段;PKCE 公共客户端本就无 secret)+ 当前账号绑定情况
|
// ZITADEL 统一登录配置(不含 secret 类字段;PKCE 公共客户端本就无 secret)+ 上次统一登录身份
|
||||||
const oidcCfg = await getOidcConfig(c.env);
|
const oidcCfg = await getOidcConfig(c.env);
|
||||||
const identity = await c.env.DB.prepare(
|
const lastLogin = await getLastOidcLogin(c.env);
|
||||||
"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({
|
return c.json({
|
||||||
username: user?.username ?? "",
|
username: user?.username ?? "",
|
||||||
webhook,
|
webhook,
|
||||||
@@ -101,13 +98,12 @@ export function settingsRoutes(): App {
|
|||||||
issuer: oidcCfg?.issuer ?? "",
|
issuer: oidcCfg?.issuer ?? "",
|
||||||
client_id: oidcCfg?.clientId ?? "",
|
client_id: oidcCfg?.clientId ?? "",
|
||||||
enabled: oidcCfg?.enabled ?? false,
|
enabled: oidcCfg?.enabled ?? false,
|
||||||
jit: oidcCfg?.jit ?? true,
|
|
||||||
source: oidcCfg?.source ?? "none",
|
source: oidcCfg?.source ?? "none",
|
||||||
scopes: OIDC_SCOPES,
|
scopes: OIDC_SCOPES,
|
||||||
// 供前端展示:需要在 ZITADEL 控制台应用里登记的回调地址
|
// 供前端展示:需要在 ZITADEL 控制台应用里登记的回调地址
|
||||||
redirect_uri: oidcRedirectUri(new URL(c.req.url).origin),
|
redirect_uri: oidcRedirectUri(new URL(c.req.url).origin),
|
||||||
// 当前登录账号已绑定的统一登录身份(未绑定为 null)
|
// 上次统一登录身份(展示用;单用户系统,登录即绑定当前唯一账号)
|
||||||
identity: identity ?? null,
|
last_login: lastLogin,
|
||||||
},
|
},
|
||||||
// 供前端展示:需要在 Cloudflare Dashboard 登记的回调地址
|
// 供前端展示:需要在 Cloudflare Dashboard 登记的回调地址
|
||||||
redirect_uri: oauthRedirectUri(c.env, new URL(c.req.url).origin),
|
redirect_uri: oauthRedirectUri(c.env, new URL(c.req.url).origin),
|
||||||
@@ -119,7 +115,7 @@ export function settingsRoutes(): App {
|
|||||||
const body = await readJson<{
|
const body = await readJson<{
|
||||||
webhook?: { url?: string; headers?: unknown; key?: string };
|
webhook?: { url?: string; headers?: unknown; key?: string };
|
||||||
cf_oauth?: { client_id?: string; secret?: string; scope?: string };
|
cf_oauth?: { client_id?: string; secret?: string; scope?: string };
|
||||||
oidc?: { issuer?: string; client_id?: string; enabled?: boolean; jit?: boolean };
|
oidc?: { issuer?: string; client_id?: string; enabled?: boolean };
|
||||||
}>(c.req.raw);
|
}>(c.req.raw);
|
||||||
|
|
||||||
if (body.webhook) {
|
if (body.webhook) {
|
||||||
@@ -181,17 +177,16 @@ export function settingsRoutes(): App {
|
|||||||
if (invalid) return c.json({ error: invalid }, 400);
|
if (invalid) return c.json({ error: invalid }, 400);
|
||||||
}
|
}
|
||||||
if (clientId && !issuer) return c.json({ error: "填写 Client ID 时请同时填写 Issuer" }, 400);
|
if (clientId && !issuer) return c.json({ error: "填写 Client ID 时请同时填写 Issuer" }, 400);
|
||||||
// 开关缺省沿用已保存值(再保存表单不丢开关状态);未保存过默认 jit 开、enabled 关
|
// enabled 缺省沿用已保存值(再保存表单不丢开关状态);未保存过默认关
|
||||||
const prev = await readOidcSettingRaw(c.env);
|
const prev = await readOidcSettingRaw(c.env);
|
||||||
const enabled = body.oidc.enabled ?? prev?.enabled ?? false;
|
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);
|
if (enabled && (!issuer || !clientId)) return c.json({ error: "启用统一登录前请完整填写 Issuer 与 Client ID" }, 400);
|
||||||
await saveOidcConfig(c.env, { issuer, client_id: clientId, enabled, jit });
|
await saveOidcConfig(c.env, { issuer, client_id: clientId, enabled });
|
||||||
await logOperation(c.env, {
|
await logOperation(c.env, {
|
||||||
action: "settings.oidc",
|
action: "settings.oidc",
|
||||||
targetType: "settings",
|
targetType: "settings",
|
||||||
targetName: "ZITADEL 统一登录",
|
targetName: "ZITADEL 统一登录",
|
||||||
detail: { enabled, jit, issuer, configured: Boolean(issuer && clientId) },
|
detail: { enabled, issuer, configured: Boolean(issuer && clientId) },
|
||||||
operator: await operatorName(c.env, c.get("userId")),
|
operator: await operatorName(c.env, c.get("userId")),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* ZITADEL 统一登录(docs/12,v2.9.0):OIDC Authorization Code + PKCE(无 client secret)。
|
* ZITADEL 统一登录(docs/12,v2.9.0;v2.9.1 单用户化):OIDC Authorization Code + PKCE(无 client secret)。
|
||||||
*
|
*
|
||||||
* - 配置存 settings 表(设置 → ZITADEL 统一登录),env OIDC_ISSUER/OIDC_CLIENT_ID 兜底;
|
* - 配置存 settings 表(设置 → ZITADEL 统一登录),env OIDC_ISSUER/OIDC_CLIENT_ID 兜底;
|
||||||
* 回调地址固定为 {origin}/auth/callback,不在代码/配置里硬编码 issuer 与 client_id
|
* 回调地址固定为 {origin}/auth/callback,不在代码/配置里硬编码 issuer 与 client_id
|
||||||
* - 身份主键是 (issuer, subject):email 仅作资料展示(实例无 SMTP,可能缺失),绝不参与判定
|
* - **单用户系统**:没有账号映射表与 JIT 建号,统一登录成功即登录本平台唯一用户
|
||||||
|
* (「绑定到当前用户」因此天然成立);上次登录身份记录在 settings(oidc_last_login)供展示
|
||||||
* - 令牌只在服务端瞬时使用(code 换 access_token → userinfo 取身份),不落库、不进前端与日志
|
* - 令牌只在服务端瞬时使用(code 换 access_token → userinfo 取身份),不落库、不进前端与日志
|
||||||
* - JIT(自动建号)可配置;按需求本平台登录成功即管理员,没有权限设置
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Env } from "../env.ts";
|
import type { Env } from "../env.ts";
|
||||||
import { hashPassword, ids } from "../../../shared/src/index.ts";
|
|
||||||
import { getSetting, setSetting } from "../db.ts";
|
import { getSetting, setSetting } from "../db.ts";
|
||||||
import { nowIso } from "../util.ts";
|
import { nowIso } from "../util.ts";
|
||||||
|
|
||||||
/** # 成员变量: settings 表里 OIDC 配置的键 **/
|
/** # 成员变量: settings 表里 OIDC 配置的键 **/
|
||||||
export const OIDC_SETTING_KEY = "oidc_config";
|
export const OIDC_SETTING_KEY = "oidc_config";
|
||||||
|
|
||||||
|
/** # 成员变量: settings 表里上次统一登录身份的键(展示用,非鉴权依据) **/
|
||||||
|
export const OIDC_LAST_LOGIN_KEY = "oidc_last_login";
|
||||||
|
|
||||||
/** # 成员变量: 授权 scope(固定:userinfo 取身份必需 openid;profile 带展示名 claim) **/
|
/** # 成员变量: 授权 scope(固定:userinfo 取身份必需 openid;profile 带展示名 claim) **/
|
||||||
export const OIDC_SCOPES = "openid profile";
|
export const OIDC_SCOPES = "openid profile";
|
||||||
|
|
||||||
@@ -27,8 +29,6 @@ interface OidcConfigSetting {
|
|||||||
client_id: string;
|
client_id: string;
|
||||||
/** 是否启用(登录页显示统一登录入口) **/
|
/** 是否启用(登录页显示统一登录入口) **/
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
/** JIT:未绑定的统一登录身份是否自动创建本地用户(创建后即管理员) **/
|
|
||||||
jit: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** # 成员变量: 生效配置(source 标明来源,同 Cloudflare OAuth 的语义) **/
|
/** # 成员变量: 生效配置(source 标明来源,同 Cloudflare OAuth 的语义) **/
|
||||||
@@ -36,7 +36,6 @@ export interface OidcConfig {
|
|||||||
issuer: string;
|
issuer: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
jit: boolean;
|
|
||||||
/** settings = 后台设置;env = 部署环境变量兜底;none = 未配置 */
|
/** settings = 后台设置;env = 部署环境变量兜底;none = 未配置 */
|
||||||
source: "settings" | "env" | "none";
|
source: "settings" | "env" | "none";
|
||||||
}
|
}
|
||||||
@@ -67,7 +66,6 @@ export async function getOidcConfig(env: Env): Promise<OidcConfig | null> {
|
|||||||
issuer: cfg.issuer,
|
issuer: cfg.issuer,
|
||||||
clientId: cfg.client_id,
|
clientId: cfg.client_id,
|
||||||
enabled: cfg.enabled !== false && Boolean(cfg.issuer && cfg.client_id),
|
enabled: cfg.enabled !== false && Boolean(cfg.issuer && cfg.client_id),
|
||||||
jit: cfg.jit !== false,
|
|
||||||
source: "settings",
|
source: "settings",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -80,7 +78,6 @@ export async function getOidcConfig(env: Env): Promise<OidcConfig | null> {
|
|||||||
issuer: env.OIDC_ISSUER.replace(/\/+$/, ""),
|
issuer: env.OIDC_ISSUER.replace(/\/+$/, ""),
|
||||||
clientId: env.OIDC_CLIENT_ID,
|
clientId: env.OIDC_CLIENT_ID,
|
||||||
enabled: env.OIDC_ENABLED !== "false",
|
enabled: env.OIDC_ENABLED !== "false",
|
||||||
jit: true,
|
|
||||||
source: "env",
|
source: "env",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -243,62 +240,53 @@ export function oidcDisplayName(ui: OidcUserInfo): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
* 解析或按 JIT 建立统一登录身份对应的本地用户
|
* 单用户系统:返回本平台唯一用户(ensureSeeded 保证至少存在种子用户;多行时取最早的)
|
||||||
* 参数: env - 运行环境;cfg - 生效配置;ui - userinfo;issuer - 签发者
|
|
||||||
* 返回值: 本地用户 id 与用户名;未绑定且 JIT 关闭返回 null
|
|
||||||
*/
|
*/
|
||||||
export async function resolveOrProvisionIdentity(
|
export async function resolveSingleUser(env: Env): Promise<{ userId: string; username: string } | null> {
|
||||||
env: Env,
|
return await env.DB.prepare("SELECT id, username FROM users ORDER BY created_at LIMIT 1")
|
||||||
cfg: OidcConfig,
|
.first<{ id: string; username: string }>()
|
||||||
issuer: string,
|
.then((u) => (u ? { userId: u.id, username: u.username } : null));
|
||||||
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…)
|
* 记录上次统一登录身份到 settings(展示用;只存 issuer/subject/展示名/邮箱/时间,不含任何令牌)
|
||||||
*/
|
*/
|
||||||
async function uniqueUsername(env: Env, base: string): Promise<string> {
|
export async function recordLastOidcLogin(env: Env, issuer: string, ui: OidcUserInfo): Promise<void> {
|
||||||
let slug = base.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "").slice(0, 32);
|
await setSetting(
|
||||||
if (slug.length < 2) slug = `sso-${slug}`.slice(0, 32);
|
env,
|
||||||
let candidate = slug;
|
OIDC_LAST_LOGIN_KEY,
|
||||||
for (let i = 2; ; i++) {
|
JSON.stringify({
|
||||||
const dup = await env.DB.prepare("SELECT id FROM users WHERE username = ?").bind(candidate).first();
|
issuer,
|
||||||
if (!dup) return candidate;
|
subject: ui.sub,
|
||||||
candidate = `${slug.slice(0, 29)}-${i}`;
|
display_name: oidcDisplayName(ui),
|
||||||
|
email: ui.email ?? null,
|
||||||
|
at: nowIso(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数
|
||||||
|
* 读取上次统一登录身份(未登录过返回 null)
|
||||||
|
*/
|
||||||
|
export async function getLastOidcLogin(env: Env): Promise<{
|
||||||
|
issuer: string;
|
||||||
|
subject: string;
|
||||||
|
display_name: string;
|
||||||
|
email: string | null;
|
||||||
|
at: string;
|
||||||
|
} | null> {
|
||||||
|
const raw = await getSetting(env, OIDC_LAST_LOGIN_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as {
|
||||||
|
issuer: string;
|
||||||
|
subject: string;
|
||||||
|
display_name: string;
|
||||||
|
email: string | null;
|
||||||
|
at: string;
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +312,7 @@ function base64Url(bytes: Uint8Array): string {
|
|||||||
*/
|
*/
|
||||||
export async function saveOidcConfig(
|
export async function saveOidcConfig(
|
||||||
env: Env,
|
env: Env,
|
||||||
cfg: { issuer: string; client_id: string; enabled: boolean; jit: boolean },
|
cfg: { issuer: string; client_id: string; enabled: boolean },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await setSetting(env, OIDC_SETTING_KEY, JSON.stringify(cfg));
|
await setSetting(env, OIDC_SETTING_KEY, JSON.stringify(cfg));
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-15
@@ -1,4 +1,4 @@
|
|||||||
# 12. ZITADEL 统一登录(v2.9.0)
|
# ZITADEL 统一登录(v2.9.0;v2.9.1 单用户化)
|
||||||
|
|
||||||
Console 支持通过自建 ZITADEL 实例做 SSO 登录:OIDC **Authorization Code + PKCE**(公共客户端,无 client secret),与用户名密码登录双通道并存,互不替代。
|
Console 支持通过自建 ZITADEL 实例做 SSO 登录:OIDC **Authorization Code + PKCE**(公共客户端,无 client secret),与用户名密码登录双通道并存,互不替代。
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ Console 支持通过自建 ZITADEL 实例做 SSO 登录:OIDC **Authorization Cod
|
|||||||
|
|
||||||
| 来源 | 说明 |
|
| 来源 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `settings` | 后台设置保存的 `{ issuer, client_id, enabled, jit }`(主) |
|
| `settings` | 后台设置保存的 `{ issuer, client_id, enabled }`(主) |
|
||||||
| `env` | `OIDC_ISSUER` + `OIDC_CLIENT_ID`(可选 `OIDC_ENABLED=false` 关闭) |
|
| `env` | `OIDC_ISSUER` + `OIDC_CLIENT_ID`(可选 `OIDC_ENABLED=false` 关闭) |
|
||||||
| `none` | 未配置,登录页不显示统一登录入口 |
|
| `none` | 未配置,登录页不显示统一登录入口 |
|
||||||
|
|
||||||
@@ -22,35 +22,32 @@ Console 支持通过自建 ZITADEL 实例做 SSO 登录:OIDC **Authorization Cod
|
|||||||
```
|
```
|
||||||
GET /auth/status 公开探测 { enabled, issuer }(登录页据此显示按钮)
|
GET /auth/status 公开探测 { enabled, issuer }(登录页据此显示按钮)
|
||||||
GET /auth/login 生成 state + PKCE verifier 存 oidc_states(10 分钟,一次性)→ 302 授权端点
|
GET /auth/login 生成 state + PKCE verifier 存 oidc_states(10 分钟,一次性)→ 302 授权端点
|
||||||
GET /auth/callback state 用后即删 → code 换 token → userinfo 取 sub → 解析/JIT 建号 → 签发本地会话 cookie → 302 /
|
GET /auth/callback state 用后即删 → code 换 token → userinfo 取 sub → 登录唯一用户 → 签发本地会话 cookie → 302 /
|
||||||
```
|
```
|
||||||
|
|
||||||
- 身份主键 **(issuer, subject)**,存 `oidc_identities`;email 只是资料展示,**绝不作为身份键**(实例无 SMTP,邮箱可能缺失)。
|
|
||||||
- 令牌只在服务端瞬时使用(换 userinfo),不落库、不进前端、不进日志;身份取 userinfo 的 `sub`,不做 ID Token 签名验证(access_token 由本服务直接从 token 端点换得)。
|
- 令牌只在服务端瞬时使用(换 userinfo),不落库、不进前端、不进日志;身份取 userinfo 的 `sub`,不做 ID Token 签名验证(access_token 由本服务直接从 token 端点换得)。
|
||||||
- 回跳路径只接受站内路径(防开放重定向);所有失败 302 回 `/login?error=<code>`,登录页映射中文文案。
|
- 回跳路径只接受站内路径(防开放重定向);所有失败 302 回 `/login?error=<code>`,登录页映射中文文案。
|
||||||
- 本地会话(HMAC cookie)独立于 IdP:IdP 挂掉时已登录用户不受影响;IdP 不可达时 discovery 报错走 `oidc_unavailable`,进程不 panic。
|
- 本地会话(HMAC cookie)独立于 IdP:IdP 挂掉时已登录用户不受影响;IdP 不可达时 discovery 报错走 `oidc_unavailable`,进程不 panic。
|
||||||
|
|
||||||
## 12.3 账号模型
|
## 12.3 账号模型(v2.9.1 单用户化)
|
||||||
|
|
||||||
- **登录成功即管理员**:本平台没有权限设置,任何能完成统一登录的身份与密码登录用户权限完全一致。
|
- **单用户系统**:没有身份映射表、没有 JIT 建号、没有权限设置。任何能完成统一登录的身份,直接登录本平台唯一用户(种子 admin,用户名/密码均可在设置里改)。
|
||||||
- **JIT(自动建号)**:设置里可开关(默认开)。开启时未绑定的身份首次登录自动创建本地用户(用户名取展示名兜底链 slug 化,冲突加 `-2/-3`;密码哈希为随机串,即该账号不能密码登录);关闭时未绑定身份拒绝登录(`oidc_not_linked`)。
|
- 上次统一登录身份(issuer/subject/展示名/时间)记录在 settings(`oidc_last_login`)供设置页展示,不参与任何判定。
|
||||||
- 展示名兜底链:`name` → `given_name + family_name` → `preferred_username` → `nickname` → 邮箱本地部分 → `统一账号 <sub 前 12 位>`。
|
- 展示名兜底链:`name` → `given_name + family_name` → `preferred_username` → `nickname` → 邮箱本地部分 → `统一账号 <sub 前 12 位>`。
|
||||||
- 重复登录同一 subject:刷新 `display_name/email` 资料,不报冲突。
|
|
||||||
- **登出仅清本地会话**,不做 IdP end_session(避免把整个 ZITADEL SSO 会话登掉,影响其他接入应用)——有意偏离,非遗漏。
|
- **登出仅清本地会话**,不做 IdP end_session(避免把整个 ZITADEL SSO 会话登掉,影响其他接入应用)——有意偏离,非遗漏。
|
||||||
|
|
||||||
## 12.4 数据库(migrations/0006)
|
## 12.4 数据库
|
||||||
|
|
||||||
- `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)`,一次性(0006)
|
||||||
- `oidc_states(state, code_verifier, return_to, created_at, expires_at)`,一次性
|
- ~~`oidc_identities`~~:v2.9.0 引入的身份映射表,**0007 迁移已删除**(单用户系统不需要映射)
|
||||||
|
|
||||||
## 12.5 验收自查(v2.9.0 实测)
|
## 12.5 验收自查(v2.9.1 实测)
|
||||||
|
|
||||||
- [x] Authorization Code + PKCE(S256),非 implicit(token 请求带 `code_verifier` 有测试)
|
- [x] Authorization Code + PKCE(S256),非 implicit(token 请求带 `code_verifier` 有测试)
|
||||||
- [x] state 一次性、10 分钟过期、校验后才换码(重放测试)
|
- [x] state 一次性、10 分钟过期、校验后才换码(重放测试)
|
||||||
- [x] 令牌不落前端、不落库、不进日志
|
- [x] 令牌不落前端、不落库、不进日志
|
||||||
- [x] 身份主键 (issuer, subject),email 仅资料
|
- [x] 单用户:任意 ZITADEL 身份登录唯一用户,不建新账号(有测试);email 仅展示
|
||||||
- [x] 本地会话独立于 IdP;登录页/应用无 token 互踢死循环(前端无 token 概念,纯 cookie 会话)
|
- [x] 本地会话独立于 IdP;登录页/应用无 token 互踢死循环(前端无 token 概念,纯 cookie 会话)
|
||||||
- [x] 登出仅本地会话(有意偏离,见 12.3)
|
- [x] 登出仅本地会话(有意偏离,见 12.3)
|
||||||
- [x] 密码登录通道完整保留(回归测试覆盖)
|
- [x] 密码登录通道完整保留(回归测试覆盖)
|
||||||
- [x] JIT 可配置;开启时建号即管理员(按需求)
|
- [x] 日志无敏感信息(operation_logs 只记动作/用户名/issuer/展示名)
|
||||||
- [x] 日志无敏感信息(operation_logs 只记动作/用户名/issuer/开关布尔)
|
|
||||||
|
|||||||
+5
-5
@@ -60,14 +60,14 @@
|
|||||||
|
|
||||||
## ZITADEL 统一登录(/auth)
|
## ZITADEL 统一登录(/auth)
|
||||||
|
|
||||||
OIDC Authorization Code + PKCE(无 client secret),与密码登录双通道并存;**登录成功即管理员**(本平台没有权限设置)。配置入口:设置 → ZITADEL 统一登录(issuer + client_id;回调地址 `{origin}/auth/callback` 需登记到 ZITADEL 应用)。详细设计见 docs/12。
|
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/login` | 发起授权,302 到 IdP;可选 `?return=/<站内路径>` 指定回跳(默认 `/`)。未启用 302 → `/login?error=oidc_disabled` |
|
||||||
| `GET /auth/callback` | IdP 回调(浏览器重定向,无需调用):一次性 state 校验 → 换码 → userinfo → 建号/匹配 → 下发会话 Cookie → 302 回站内;失败 302 → `/login?error=<code>` |
|
| `GET /auth/callback` | IdP 回调(浏览器重定向,无需调用):一次性 state 校验 → 换码 → userinfo → 登录唯一用户 → 下发会话 Cookie → 302 回站内;失败 302 → `/login?error=<code>` |
|
||||||
|
|
||||||
错误码:`oidc_disabled` / `oidc_unavailable` / `state_invalid` / `oidc_exchange_failed` / `oidc_not_linked`(JIT 关闭且身份未绑定)。
|
错误码:`oidc_disabled` / `oidc_unavailable` / `state_invalid` / `oidc_exchange_failed`。
|
||||||
|
|
||||||
## Console Web API(/api/*,Cookie 会话)
|
## Console Web API(/api/*,Cookie 会话)
|
||||||
|
|
||||||
@@ -129,8 +129,8 @@ Webhook 生效顺序:**邮箱 → 域名 → 全局**,取第一个已配置的
|
|||||||
|
|
||||||
| 方法与路径 | 说明 |
|
| 方法与路径 | 说明 |
|
||||||
|------------|------|
|
|------------|------|
|
||||||
| `GET /api/settings` | 全量读取:当前用户名、全局 Webhook(key 只回 last4)、API Key 元信息、Cloudflare OAuth、ZITADEL 统一登录(含回调地址与当前账号绑定身份) |
|
| `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` | 保存 `{ webhook?, cf_oauth?, oidc? }`;`webhook.key`/`cf_oauth.secret`:undefined 保留、空串清除、非空设置;`oidc: { issuer, client_id, enabled? }`(enabled 缺省沿用已存值) |
|
||||||
| `PUT /api/settings/password` | 改密码 `{ old_password, new_password }`(≥6 位) |
|
| `PUT /api/settings/password` | 改密码 `{ old_password, new_password }`(≥6 位) |
|
||||||
| `PUT /api/settings/username` | 改用户名 `{ new_username, password }`(需当前密码;2-32 位字母/数字/`_ . -`;成功重签会话 Cookie;重名 409) |
|
| `PUT /api/settings/username` | 改用户名 `{ new_username, password }`(需当前密码;2-32 位字母/数字/`_ . -`;成功重签会话 Cookie;重名 409) |
|
||||||
| `POST /api/settings/api-key` | 生成(轮换)API/MCP Key → `201 { key, created_at }`(明文仅此一次) |
|
| `POST /api/settings/api-key` | 生成(轮换)API/MCP Key → `201 { key, created_at }`(明文仅此一次) |
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- v2.9.1 单用户化:统一登录不再做身份↔用户映射(JIT 已移除),
|
||||||
|
-- 任何通过 ZITADEL 认证的身份直接登录本平台唯一用户,该映射表随之删除。
|
||||||
|
-- 一次性 state 表 oidc_states 仍需保留(PKCE verifier 承载)。
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS oidc_identities;
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wmailhub",
|
"name": "wmailhub",
|
||||||
"version": "2.9.0",
|
"version": "2.9.1",
|
||||||
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -34,6 +34,4 @@ export const ids = {
|
|||||||
oauthState: () => newId("st"),
|
oauthState: () => newId("st"),
|
||||||
credential: () => newId("cred"),
|
credential: () => newId("cred"),
|
||||||
log: () => newId("oplog"),
|
log: () => newId("oplog"),
|
||||||
/** OIDC 身份绑定 id **/
|
|
||||||
identity: () => newId("oidc"),
|
|
||||||
};
|
};
|
||||||
|
|||||||
+32
-44
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* ZITADEL 统一登录与用户名修改测试(v2.9.0):
|
* ZITADEL 统一登录(v2.9.1 单用户化)与用户名修改测试:
|
||||||
* - /auth/status、/auth/login(302 到授权端点)、/auth/callback(state 一次性 + PKCE + JIT 建号/复用)
|
* - /auth/status、/auth/login(302 到授权端点)、/auth/callback(state 一次性 + PKCE + 登录唯一用户)
|
||||||
* - 未绑定 + JIT 关闭拒绝;配置校验(issuer 必须 HTTPS);discovery/token/userinfo 在传输层打桩
|
* - 单用户系统:无身份映射表/JIT,任何通过 IdP 认证的身份都登录本平台唯一用户;上次登录身份记录在 settings
|
||||||
|
* - 配置校验(issuer 必须 HTTPS);discovery/token/userinfo 在传输层打桩
|
||||||
* - PUT /api/settings/username:验密、重名 409、改后新旧用户名登录行为、/me 读库返回新名
|
* - PUT /api/settings/username:验密、重名 409、改后新旧用户名登录行为、/me 读库返回新名
|
||||||
*/
|
*/
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
@@ -47,14 +48,14 @@ function installIdpStub() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
* 在测试 env 里启用统一登录(后台 settings 配置,JIT 可选)
|
* 在测试 env 里启用统一登录(后台 settings 配置)
|
||||||
*/
|
*/
|
||||||
async function enableOidc(t: TestEnv, jit = true) {
|
async function enableOidc(t: TestEnv) {
|
||||||
const cookie = await loginCookie(t);
|
const cookie = await loginCookie(t);
|
||||||
const resp = await authedFetch(t, cookie, "http://localhost/api/settings", {
|
const resp = await authedFetch(t, cookie, "http://localhost/api/settings", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ oidc: { issuer: ISSUER, client_id: "cid-123", enabled: true, jit } }),
|
body: JSON.stringify({ oidc: { issuer: ISSUER, client_id: "cid-123", enabled: true } }),
|
||||||
});
|
});
|
||||||
expect(resp.status).toBe(200);
|
expect(resp.status).toBe(200);
|
||||||
return cookie;
|
return cookie;
|
||||||
@@ -63,7 +64,7 @@ async function enableOidc(t: TestEnv, jit = true) {
|
|||||||
/** # 函数
|
/** # 函数
|
||||||
* 走一遍 /auth/login,取回 302 Location 与其中的 state
|
* 走一遍 /auth/login,取回 302 Location 与其中的 state
|
||||||
*/
|
*/
|
||||||
async function startLogin(t: import("./helpers.ts").TestEnv) {
|
async function startLogin(t: TestEnv) {
|
||||||
const resp = await t.app.fetch(new Request("http://localhost/auth/login"), t.env);
|
const resp = await t.app.fetch(new Request("http://localhost/auth/login"), t.env);
|
||||||
const loc = resp.headers.get("Location") ?? "";
|
const loc = resp.headers.get("Location") ?? "";
|
||||||
return { resp, loc, state: new URL(loc).searchParams.get("state") ?? "" };
|
return { resp, loc, state: new URL(loc).searchParams.get("state") ?? "" };
|
||||||
@@ -78,7 +79,7 @@ function cookieOf(resp: Response): string {
|
|||||||
return `wmh_session=${m[1]}`;
|
return `wmh_session=${m[1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ZITADEL 统一登录(/auth)", () => {
|
describe("ZITADEL 统一登录(/auth,单用户)", () => {
|
||||||
test("未配置时 status enabled=false;/auth/login 拒绝并回登录页", async () => {
|
test("未配置时 status enabled=false;/auth/login 拒绝并回登录页", async () => {
|
||||||
const t = await makeTestEnv();
|
const t = await makeTestEnv();
|
||||||
const status = (await (await t.app.fetch(new Request("http://localhost/auth/status"), t.env)).json()) as { enabled: boolean };
|
const status = (await (await t.app.fetch(new Request("http://localhost/auth/status"), t.env)).json()) as { enabled: boolean };
|
||||||
@@ -123,67 +124,54 @@ describe("ZITADEL 统一登录(/auth)", () => {
|
|||||||
expect(row).toBeTruthy();
|
expect(row).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("完整登录链:JIT 自动建号(即管理员)→ 会话可用 → 同 subject 复用同一用户", async () => {
|
test("完整登录链:任意 ZITADEL 身份登录唯一用户(admin)→ 会话可用 → 不建新账号", async () => {
|
||||||
installIdpStub();
|
installIdpStub();
|
||||||
resetOidcDiscoveryCache();
|
resetOidcDiscoveryCache();
|
||||||
const t = await makeTestEnv();
|
const t = await makeTestEnv();
|
||||||
await enableOidc(t, true);
|
await enableOidc(t);
|
||||||
mockUserInfo = { sub: "sub-jit", name: "Zed Zhan", email: "zed@wtox.net" };
|
mockUserInfo = { sub: "sub-jit", name: "Zed Zhan", email: "zed@wtox.net" };
|
||||||
|
|
||||||
// 第一次登录:JIT 建号
|
// 登录:绑定到唯一用户(种子 admin),不建新用户
|
||||||
const first = await startLogin(t);
|
const first = await startLogin(t);
|
||||||
const cb = await t.app.fetch(new Request(`http://localhost/auth/callback?code=good&state=${first.state}`), t.env);
|
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.status).toBe(302);
|
||||||
expect(cb.headers.get("Location")).toBe("/");
|
expect(cb.headers.get("Location")).toBe("/");
|
||||||
const cookie1 = cookieOf(cb);
|
const cookie = cookieOf(cb);
|
||||||
const me = (await (await authedFetch(t, cookie1, "http://localhost/api/auth/me")).json()) as {
|
const me = (await (await authedFetch(t, cookie, "http://localhost/api/auth/me")).json()) as {
|
||||||
user: { username: string };
|
user: { username: string };
|
||||||
};
|
};
|
||||||
expect(me.user.username).toBe("zed-zhan"); // 展示名 slug 化
|
expect(me.user.username).toBe("admin");
|
||||||
|
|
||||||
// state 一次性:重放同一回调被拒
|
// state 一次性:重放同一回调被拒
|
||||||
const replay = await t.app.fetch(new Request(`http://localhost/auth/callback?code=good&state=${first.state}`), t.env);
|
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");
|
expect(replay.headers.get("Location")).toContain("state_invalid");
|
||||||
|
|
||||||
// 库里:用户 + 身份绑定 + token 请求带 code_verifier(PKCE)
|
// 库里:仍只有 1 个用户(无建号);无身份映射表;上次登录身份已记录;token 请求带 code_verifier(PKCE)
|
||||||
const users = await t.env.DB.prepare("SELECT id, username FROM users").all<{ id: string; username: string }>();
|
const users = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>();
|
||||||
expect(users.results.filter((u) => u.username === "zed-zhan")).toHaveLength(1);
|
expect(users?.n).toBe(1);
|
||||||
const ident = await t.env.DB.prepare("SELECT user_id, display_name, email FROM oidc_identities WHERE issuer = ? AND subject = ?")
|
expect(await t.env.DB.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='oidc_identities'").first()).toBeNull();
|
||||||
.bind(ISSUER, "sub-jit")
|
const last = await t.env.DB.prepare("SELECT value FROM settings WHERE key = 'oidc_last_login'").first<{ value: string }>();
|
||||||
.first<{ user_id: string; display_name: string; email: string }>();
|
expect(last && JSON.parse(last.value).subject).toBe("sub-jit");
|
||||||
expect(ident?.email).toBe("zed@wtox.net");
|
|
||||||
expect(idpCalls[0]?.body).toContain("code_verifier=");
|
expect(idpCalls[0]?.body).toContain("code_verifier=");
|
||||||
|
|
||||||
// 第二次登录:同一 subject 复用同一用户,不重复建号
|
// 换一个全新 subject 再登录:仍是同一唯一用户
|
||||||
|
mockUserInfo = { sub: "someone-else", preferred_username: "stranger" };
|
||||||
const second = await startLogin(t);
|
const second = await startLogin(t);
|
||||||
const cb2 = await t.app.fetch(new Request(`http://localhost/auth/callback?code=good&state=${second.state}`), t.env);
|
const cb2 = await t.app.fetch(new Request(`http://localhost/auth/callback?code=good&state=${second.state}`), t.env);
|
||||||
expect(cb2.status).toBe(302);
|
expect(cb2.status).toBe(302);
|
||||||
const ident2 = await t.env.DB.prepare("SELECT user_id FROM oidc_identities WHERE issuer = ? AND subject = ?")
|
const me2 = (await (await authedFetch(t, cookieOf(cb2), "http://localhost/api/auth/me")).json()) as {
|
||||||
.bind(ISSUER, "sub-jit")
|
user: { username: string };
|
||||||
.first<{ user_id: string }>();
|
};
|
||||||
expect(ident2?.user_id).toBe(ident?.user_id);
|
expect(me2.user.username).toBe("admin");
|
||||||
const users2 = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>();
|
const users2 = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>();
|
||||||
expect(users2?.n).toBe(2); // admin + zed-zhan
|
expect(users2?.n).toBe(1);
|
||||||
});
|
|
||||||
|
|
||||||
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 () => {
|
test("篡改 state 拒绝;userinfo 缺 sub 时 fail closed", async () => {
|
||||||
installIdpStub();
|
installIdpStub();
|
||||||
resetOidcDiscoveryCache();
|
resetOidcDiscoveryCache();
|
||||||
const t = await makeTestEnv();
|
const t = await makeTestEnv();
|
||||||
await enableOidc(t, true);
|
await enableOidc(t);
|
||||||
const bad = await t.app.fetch(new Request("http://localhost/auth/callback?code=x&state=bogus"), t.env);
|
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");
|
expect(bad.headers.get("Location")).toContain("state_invalid");
|
||||||
|
|
||||||
@@ -260,7 +248,7 @@ describe("修改用户名(PUT /api/settings/username)", () => {
|
|||||||
expect(loginOld.status).toBe(401);
|
expect(loginOld.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("与其他用户重名返回 409;GET /api/settings 回传 oidc 配置与未绑定身份", async () => {
|
test("与其他用户重名返回 409;GET /api/settings 回传 oidc 配置与上次登录", async () => {
|
||||||
const t = await makeTestEnv();
|
const t = await makeTestEnv();
|
||||||
const cookie = await loginCookie(t);
|
const cookie = await loginCookie(t);
|
||||||
// 建第二个用户直接插库
|
// 建第二个用户直接插库
|
||||||
@@ -277,12 +265,12 @@ describe("修改用户名(PUT /api/settings/username)", () => {
|
|||||||
|
|
||||||
const info = (await (await authedFetch(t, cookie, "http://localhost/api/settings")).json()) as {
|
const info = (await (await authedFetch(t, cookie, "http://localhost/api/settings")).json()) as {
|
||||||
username: string;
|
username: string;
|
||||||
oidc: { enabled: boolean; redirect_uri: string; identity: unknown; source: string };
|
oidc: { enabled: boolean; redirect_uri: string; last_login: unknown; source: string };
|
||||||
};
|
};
|
||||||
expect(info.username).toBe("admin");
|
expect(info.username).toBe("admin");
|
||||||
expect(info.oidc.enabled).toBe(false);
|
expect(info.oidc.enabled).toBe(false);
|
||||||
expect(info.oidc.source).toBe("none");
|
expect(info.oidc.source).toBe("none");
|
||||||
expect(info.oidc.redirect_uri).toBe("http://localhost/auth/callback");
|
expect(info.oidc.redirect_uri).toBe("http://localhost/auth/callback");
|
||||||
expect(info.oidc.identity).toBeNull();
|
expect(info.oidc.last_login).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-4
@@ -122,15 +122,16 @@ const redirectText =
|
|||||||
check("Cloudflare 接入 tab 显示授权回调地址", redirectText.includes("/api/oauth/cloudflare/callback"), redirectText.trim());
|
check("Cloudflare 接入 tab 显示授权回调地址", redirectText.includes("/api/oauth/cloudflare/callback"), redirectText.trim());
|
||||||
await page.screenshot({ path: `${SHOT_DIR}/09b-settings-cloudflare.png` });
|
await page.screenshot({ path: `${SHOT_DIR}/09b-settings-cloudflare.png` });
|
||||||
|
|
||||||
// ZITADEL 统一登录 tab(v2.9.0):回调地址 /auth/callback + Issuer/Client ID 表单 + 启用/JIT 开关
|
// ZITADEL 统一登录 tab(v2.9.1 单用户化):回调地址 /auth/callback + Issuer/Client ID 表单 + 启用开关(无 JIT)
|
||||||
await page.click(".ant-modal .ant-tabs-tab:has-text('ZITADEL 统一登录')");
|
await page.click(".ant-modal .ant-tabs-tab:has-text('ZITADEL 统一登录')");
|
||||||
await page.waitForSelector(".ant-modal :text('Issuer')", { timeout: 8000 });
|
await page.waitForSelector(".ant-modal :text('Issuer')", { timeout: 8000 });
|
||||||
const oidcModalText = (await page.locator(".ant-modal").innerText()) ?? "";
|
const oidcModalText = (await page.locator(".ant-modal").innerText()) ?? "";
|
||||||
check(
|
check(
|
||||||
"ZITADEL tab 显示回调地址 /auth/callback 与启用/JIT 开关",
|
"ZITADEL tab 显示回调地址 /auth/callback、启用开关与单用户说明(无 JIT)",
|
||||||
oidcModalText.includes("/auth/callback") &&
|
oidcModalText.includes("/auth/callback") &&
|
||||||
(await page.locator(".ant-modal .ant-switch").count()) === 2 &&
|
(await page.locator(".ant-modal .ant-switch").count()) === 1 &&
|
||||||
oidcModalText.includes("管理员"),
|
oidcModalText.includes("单用户系统") &&
|
||||||
|
!oidcModalText.includes("JIT"),
|
||||||
oidcModalText.split("\n").slice(0, 6).join(" / "),
|
oidcModalText.split("\n").slice(0, 6).join(" / "),
|
||||||
);
|
);
|
||||||
await page.screenshot({ path: `${SHOT_DIR}/09c-settings-oidc.png` });
|
await page.screenshot({ path: `${SHOT_DIR}/09c-settings-oidc.png` });
|
||||||
|
|||||||
Reference in New Issue
Block a user