v2.9.1: 统一登录单用户化——删除 JIT 开关与 oidc_identities 映射表(0007),任意 ZITADEL 身份登录唯一用户,上次登录身份存 settings 展示

This commit is contained in:
bigcode
2026-10-01 21:30:46 +08:00
parent fabafbd513
commit 52d5827932
13 changed files with 155 additions and 194 deletions
+5 -7
View File
@@ -338,21 +338,19 @@ export interface SettingsInfo {
/** 实际请求的 scope(点号分隔,空格分隔多个) */
scope: string;
};
/** ZITADEL 统一登录配置与当前账号绑定情况 */
/** 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;
/** 上次统一登录身份(展示用;单用户系统,登录即当前唯一账号) */
last_login: { issuer: string; subject: string; display_name: string; email: string | null; at: string } | null;
};
/** 需要在 Cloudflare Dashboard 登记的授权回调地址 */
redirect_uri: string;
@@ -362,11 +360,11 @@ export const getSettings = () => api<SettingsInfo>("/api/settings");
/** 保存设置
* webhook.key / cf_oauth.secret 语义一致:undefined 保留原值、空串清除、非空设置新值
* oidc.enabled / oidc.jit 缺省表示沿用已保存的开关状态 */
* oidc.enabled 缺省表示沿用已保存的开关状态 */
export const putSettings = (body: {
webhook?: { url: string; headers: Record<string, string>; key?: 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);
/** # 成员变量: 授权配置自检结果 **/
@@ -5,7 +5,7 @@
* - Cloudflare OAuth 客户端(Client ID / Secret)也在设置里维护,Secret 加密存储只回 last4,
* 保存后立即生效,无需重新部署;未配置时账号页「添加账号」不可用
* - ZITADEL 统一登录:issuer + Client ID(PKCE 公共客户端无 secret),回调地址 {origin}/auth/callback;
* 登录成功即管理员(本平台没有权限设置),JIT 自动建号可开关
* 单用户系统(v2.9.1):统一登录成功即登录唯一管理员账号,没有权限设置与账号映射
* - 内容高度不超过屏幕(超出滚动)
*/
import { useCallback, useEffect, useState } from "react";
@@ -620,22 +620,20 @@ interface OidcFormValues {
}
/** # 函数
* ZITADEL 统一登录 tab(v2.9.0):issuer + Client ID(PKCE 无 secret)+ 启用/JIT 开关;
* 回调地址需在 ZITADEL 控制台应用里登记;登录成功即管理员(本平台没有权限设置)
* ZITADEL 统一登录 tab(v2.9.0;v2.9.1 单用户化):issuer + Client ID(PKCE 无 secret)+ 启用开关;
* 回调地址需在 ZITADEL 控制台应用里登记;单用户系统——统一登录成功即登录本平台唯一账号
*/
function ZitadelTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void }) {
const { message } = App.useApp();
const [form] = Form.useForm<OidcFormValues>();
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]);
}, [meta.issuer, meta.client_id, meta.enabled, form]);
/** 保存统一登录配置 */
const save = async () => {
@@ -643,7 +641,7 @@ function ZitadelTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
setSaving(true);
try {
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 统一登录配置已保存");
onSaved();
@@ -681,8 +679,8 @@ function ZitadelTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
}
description={
<>
在 ZITADEL 控制台的应用 Redirect URI 里登记下面的回调地址;所有通过统一登录登录的用户都是
<b>管理员</b>(本平台没有权限设置)。
在 ZITADEL 控制台的应用 Redirect URI 里登记下面的回调地址。本平台是<b>单用户系统</b>:统一登录成功即登录
唯一的管理员账号,没有权限设置。
</>
}
/>
@@ -719,12 +717,6 @@ function ZitadelTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
启用后登录页显示「ZITADEL 统一登录」按钮;密码登录不受影响(双通道并存)
</Typography.Text>
</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>
<Button type="primary" loading={saving} onClick={() => void save()}>
保存
@@ -732,7 +724,7 @@ function ZitadelTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
{meta.source === "settings" && (
<Popconfirm
title="清除后台保存的统一登录配置?"
description="若未配置部署环境变量兜底,清除后登录页不再显示统一登录入口;已建立的绑定保留。"
description="若未配置部署环境变量兜底,清除后登录页不再显示统一登录入口。"
onConfirm={() => void clear()}
>
<Button danger icon={<DeleteOutlined />} disabled={saving}>
@@ -743,27 +735,24 @@ function ZitadelTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
</Space>
</Form>
<Card size="small" title="当前账号绑定" style={{ width: "100%" }}>
{meta.identity ? (
<Card size="small" title="上次统一登录" style={{ width: "100%" }}>
{meta.last_login ? (
<Descriptions size="small" column={1}>
<Descriptions.Item label="身份">
<Tag color="green">已绑定</Tag>
<span className="mono">{meta.identity.display_name ?? "(无展示名)"}</span>
<span className="mono">{meta.last_login.display_name}</span>
</Descriptions.Item>
<Descriptions.Item label="Subject">
<span className="mono">{meta.identity.subject}</span>
<span className="mono">{meta.last_login.subject}</span>
</Descriptions.Item>
<Descriptions.Item label="Issuer">
<span className="mono">{meta.identity.issuer}</span>
<span className="mono">{meta.last_login.issuer}</span>
</Descriptions.Item>
<Descriptions.Item label="邮箱">
{meta.identity.email ? <span className="mono">{meta.identity.email}</span> : <Typography.Text type="secondary">未提供(IdP 未配置 SMTP,正常现象)</Typography.Text>}
<Descriptions.Item label="时间">
{new Date(meta.last_login.at).toLocaleString()}
</Descriptions.Item>
</Descriptions>
) : (
<Typography.Text type="secondary">
当前账号未绑定统一登录身份;绑定后可直接用 ZITADEL 登录本站(启用 JIT 时首次登录会自动建新账号)。
</Typography.Text>
<Typography.Text type="secondary">还没有统一登录记录;启用并配置后,在登录页点「ZITADEL 统一登录」即可。</Typography.Text>
)}
</Card>
</Space>
-1
View File
@@ -11,7 +11,6 @@ const OIDC_ERROR_TEXT: Record<string, string> = {
oidc_unavailable: "统一登录服务暂时不可用,请稍后重试或使用密码登录",
state_invalid: "登录状态校验失败(可能已过期),请重新登录",
oidc_exchange_failed: "统一登录兑换失败,请重新登录",
oidc_not_linked: "该统一登录账号未绑定本站用户,请使用密码登录或联系管理员",
};
/** # 函数
+16 -13
View File
@@ -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/login 发起授权:生成 state+PKCE 存库,302 到 IdP 授权端点
* GET /auth/callback 授权回调:一次性 state 校验 → 换码 → userinfo → 解析/JIT 建号 → 签发本地会话 cookie
* GET /auth/callback 授权回调:一次性 state 校验 → 换码 → userinfo → 登录唯一用户 → 签发本地会话 cookie
*
* 单用户系统:没有身份映射表与 JIT 建号,任何通过 IdP 认证的身份都登录本平台唯一用户。
* 安全:state 一次性(用后即删,10 分钟过期)承担 CSRF 防护——浏览器从 IdP 跨站重定向回来时
* SameSite=Strict 的会话 cookie 不会携带,回调不校验会话;PKCE S256 防授权码拦截。
* 回跳路径只接受站内路径(防开放重定向);所有失败都 302 回 /login?error=<code> 并附中文文案。
@@ -21,7 +22,8 @@ import {
exchangeOidcCode,
getOidcConfig,
oidcRedirectUri,
resolveOrProvisionIdentity,
recordLastOidcLogin,
resolveSingleUser,
safeLocalPath,
} from "../services/oidc.ts";
import { nowIso } from "../util.ts";
@@ -37,7 +39,6 @@ export const OIDC_ERROR_TEXT: Record<string, string> = {
oidc_unavailable: "统一登录服务暂时不可用,请稍后重试或使用密码登录",
state_invalid: "登录状态校验失败(可能已过期),请重新登录",
oidc_exchange_failed: "统一登录兑换失败,请重新登录",
oidc_not_linked: "该统一登录账号未绑定本站用户,请使用密码登录或联系管理员",
};
/** # 函数
@@ -87,7 +88,7 @@ export function oidcRoutes(): App {
return c.redirect(url, 302);
});
/** 授权回调:state → code → userinfo → 本地会话 */
/** 授权回调:state → code → userinfo → 唯一用户会话 */
app.get("/callback", async (c) => {
const cfg = await getOidcConfig(c.env);
if (!cfg || !cfg.enabled) return redirectError(c, "oidc_disabled");
@@ -119,17 +120,19 @@ export function oidcRoutes(): App {
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);
// 单用户系统:统一登录成功即登录本平台唯一账号(绑定到当前用户)
const user = await resolveSingleUser(c.env);
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));
await logOperation(c.env, {
action: resolved.created ? "user.oidc_signup" : "user.oidc_login",
action: "user.oidc_login",
targetType: "user",
targetId: resolved.userId,
targetName: resolved.username,
detail: { issuer: cfg.issuer, jit: resolved.created },
operator: resolved.username,
targetId: user.userId,
targetName: user.username,
detail: { issuer: cfg.issuer, sso_display_name: ui.name ?? ui.preferred_username ?? "" },
operator: user.username,
});
return c.redirect(row.return_to, 302);
} catch (err) {
+9 -14
View File
@@ -35,6 +35,7 @@ import {
import { getGlobalWebhookKey, normalizeWebhookHeaders, saveGlobalWebhook } from "../services/webhook_delivery.ts";
import {
clearOidcConfig,
getLastOidcLogin,
getOidcConfig,
oidcRedirectUri,
readOidcSettingRaw,
@@ -85,13 +86,9 @@ export function settingsRoutes(): App {
// settings 损坏时按未配置处理
}
}
// ZITADEL 统一登录配置(不含 secret 类字段;PKCE 公共客户端本就无 secret)+ 当前账号绑定情况
// 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 }>();
const lastLogin = await getLastOidcLogin(c.env);
return c.json({
username: user?.username ?? "",
webhook,
@@ -101,13 +98,12 @@ export function settingsRoutes(): App {
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,
// 上次统一登录身份(展示用;单用户系统,登录即绑定当前唯一账号)
last_login: lastLogin,
},
// 供前端展示:需要在 Cloudflare Dashboard 登记的回调地址
redirect_uri: oauthRedirectUri(c.env, new URL(c.req.url).origin),
@@ -119,7 +115,7 @@ export function settingsRoutes(): App {
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 };
oidc?: { issuer?: string; client_id?: string; enabled?: boolean };
}>(c.req.raw);
if (body.webhook) {
@@ -181,17 +177,16 @@ export function settingsRoutes(): App {
if (invalid) return c.json({ error: invalid }, 400);
}
if (clientId && !issuer) return c.json({ error: "填写 Client ID 时请同时填写 Issuer" }, 400);
// 开关缺省沿用已保存值(再保存表单不丢开关状态);未保存过默认 jit 开、enabled 关
// 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 saveOidcConfig(c.env, { issuer, client_id: clientId, enabled });
await logOperation(c.env, {
action: "settings.oidc",
targetType: "settings",
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")),
});
}
+49 -61
View File
@@ -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 兜底;
* 回调地址固定为 {origin}/auth/callback,不在代码/配置里硬编码 issuer 与 client_id
* - 身份主键是 (issuer, subject):email 仅作资料展示(实例无 SMTP,可能缺失),绝不参与判定
* - **单用户系统**:没有账号映射表与 JIT 建号,统一登录成功即登录本平台唯一用户
* (「绑定到当前用户」因此天然成立);上次登录身份记录在 settings(oidc_last_login)供展示
* - 令牌只在服务端瞬时使用(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";
/** # 成员变量: settings 表里上次统一登录身份的键(展示用,非鉴权依据) **/
export const OIDC_LAST_LOGIN_KEY = "oidc_last_login";
/** # 成员变量: 授权 scope(固定:userinfo 取身份必需 openid;profile 带展示名 claim) **/
export const OIDC_SCOPES = "openid profile";
@@ -27,8 +29,6 @@ interface OidcConfigSetting {
client_id: string;
/** 是否启用(登录页显示统一登录入口) **/
enabled: boolean;
/** JIT:未绑定的统一登录身份是否自动创建本地用户(创建后即管理员) **/
jit: boolean;
}
/** # 成员变量: 生效配置(source 标明来源,同 Cloudflare OAuth 的语义) **/
@@ -36,7 +36,6 @@ export interface OidcConfig {
issuer: string;
clientId: string;
enabled: boolean;
jit: boolean;
/** settings = 后台设置;env = 部署环境变量兜底;none = 未配置 */
source: "settings" | "env" | "none";
}
@@ -67,7 +66,6 @@ export async function getOidcConfig(env: Env): Promise<OidcConfig | null> {
issuer: cfg.issuer,
clientId: cfg.client_id,
enabled: cfg.enabled !== false && Boolean(cfg.issuer && cfg.client_id),
jit: cfg.jit !== false,
source: "settings",
};
}
@@ -80,7 +78,6 @@ export async function getOidcConfig(env: Env): Promise<OidcConfig | null> {
issuer: env.OIDC_ISSUER.replace(/\/+$/, ""),
clientId: env.OIDC_CLIENT_ID,
enabled: env.OIDC_ENABLED !== "false",
jit: true,
source: "env",
};
}
@@ -243,62 +240,53 @@ export function oidcDisplayName(ui: OidcUserInfo): string {
}
/** # 函数
* 解析或按 JIT 建立统一登录身份对应的本地用户
* 参数: env - 运行环境;cfg - 生效配置;ui - userinfo;issuer - 签发者
* 返回值: 本地用户 id 与用户名;未绑定且 JIT 关闭返回 null
* 单用户系统:返回本平台唯一用户(ensureSeeded 保证至少存在种子用户;多行时取最早的)
*/
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 };
export async function resolveSingleUser(env: Env): Promise<{ userId: string; username: string } | null> {
return await env.DB.prepare("SELECT id, username FROM users ORDER BY created_at LIMIT 1")
.first<{ id: string; username: string }>()
.then((u) => (u ? { userId: u.id, username: u.username } : null));
}
/** # 函数
* 由展示名生成不冲突的本地用户名(小写字母/数字/_.-,2-32 位,冲突追加 -2/-3…)
* 记录上次统一登录身份到 settings(展示用;只存 issuer/subject/展示名/邮箱/时间,不含任何令牌)
*/
async function uniqueUsername(env: Env, base: string): Promise<string> {
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 async function recordLastOidcLogin(env: Env, issuer: string, ui: OidcUserInfo): Promise<void> {
await setSetting(
env,
OIDC_LAST_LOGIN_KEY,
JSON.stringify({
issuer,
subject: ui.sub,
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(
env: Env,
cfg: { issuer: string; client_id: string; enabled: boolean; jit: boolean },
cfg: { issuer: string; client_id: string; enabled: boolean },
): Promise<void> {
await setSetting(env, OIDC_SETTING_KEY, JSON.stringify(cfg));
}