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

This commit is contained in:
bigcode
2026-10-01 21:09:55 +08:00
parent 6e40f555f3
commit 2363c77425
20 changed files with 1645 additions and 52 deletions
+1 -1
View File
@@ -161,7 +161,7 @@ export function App() {
</Content>
</Layout>
</Layout>
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} onUsernameChanged={setUsername} />
</>
);
}
+30 -2
View File
@@ -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<SettingsInfo>("/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<string, string>; 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:明文仅本次返回 */
+255 -33
View File
@@ -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<SettingsInfo | null>(null);
@@ -77,7 +92,8 @@ export function SettingsModal({ open, onClose }: { open: boolean; onClose: () =>
items={[
{ key: "webhook", label: "全局 Webhook", children: info ? <WebhookTab info={info} onSaved={load} /> : null },
{ key: "cloudflare", label: <span><CloudServerOutlined style={{ marginRight: 6 }} />Cloudflare 接入</span>, children: info ? <CloudflareTab info={info} onSaved={load} /> : null },
{ key: "password", label: "账号密码", children: <PasswordTab /> },
{ key: "oidc", label: <span><SafetyOutlined style={{ marginRight: 6 }} />ZITADEL 统一登录</span>, children: info ? <ZitadelTab info={info} onSaved={load} /> : null },
{ key: "password", label: "账号密码", children: <AccountTab onUsernameChanged={onUsernameChanged} /> },
]}
/>
</Modal>
@@ -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<UsernameFormValues>();
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 (
<Form form={pwdForm} layout="vertical" onFinish={() => void savePassword()}>
<Form.Item name="old_password" label="原密码" rules={[{ required: true, message: "请输入原密码" }]}>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Form.Item name="new_password" label="新密码(至少 6 位)" rules={[{ required: true, min: 6, message: "至少 6 位" }]}>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item
name="confirm"
label="确认新密码"
dependencies={["new_password"]}
rules={[
{ required: true, message: "请再次输入" },
({ getFieldValue }) => ({
validator: (_, v) =>
!v || v === getFieldValue("new_password")
? Promise.resolve()
: Promise.reject(new Error("两次输入不一致")),
}),
]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Button type="primary" htmlType="submit">
更新密码
</Button>
</Form>
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<Form form={nameForm} layout="vertical" onFinish={() => void saveUsername()}>
<Form.Item
name="new_username"
label="新用户名"
rules={[
{ required: true, message: "请输入新用户名" },
{ pattern: /^[a-zA-Z0-9_.-]{2,32}$/, message: "2-32 位,仅字母、数字、_ . -" },
]}
>
<Input placeholder="字母 / 数字 / _ . -" autoComplete="username" />
</Form.Item>
<Form.Item
name="password"
label="当前密码(确认身份)"
rules={[{ required: true, message: "请输入当前密码" }]}
>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={savingName}>
更新用户名
</Button>
</Form>
<Form form={pwdForm} layout="vertical" onFinish={() => void savePassword()}>
<Form.Item name="old_password" label="原密码" rules={[{ required: true, message: "请输入原密码" }]}>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Form.Item name="new_password" label="新密码(至少 6 位)" rules={[{ required: true, min: 6, message: "至少 6 位" }]}>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item
name="confirm"
label="确认新密码"
dependencies={["new_password"]}
rules={[
{ required: true, message: "请再次输入" },
({ getFieldValue }) => ({
validator: (_, v) =>
!v || v === getFieldValue("new_password")
? Promise.resolve()
: Promise.reject(new Error("两次输入不一致")),
}),
]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Button type="primary" htmlType="submit">
更新密码
</Button>
</Form>
</Space>
);
}
/** # 成员变量: 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<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]);
/** 保存统一登录配置 */
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 (
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<Alert
type={meta.source === "none" ? "info" : meta.enabled ? "success" : "info"}
showIcon
message={
meta.source === "none"
? "尚未配置:在 ZITADEL 控制台(Qiaonao Internal Apps 项目)为 WmailHub 建一个应用(Auth Code + PKCE,无 secret),把 Client ID 填到这里。"
: `已配置(来源:${meta.source === "settings" ? "后台设置" : "部署环境变量"})${meta.enabled ? ",登录页已显示「ZITADEL 统一登录」入口。" : ",当前未启用。"}`
}
description={
<>
在 ZITADEL 控制台的应用 Redirect URI 里登记下面的回调地址;所有通过统一登录登录的用户都是
<b>管理员</b>(本平台没有权限设置)。
</>
}
/>
<Form form={form} layout="vertical">
<Form.Item label="回调地址(Redirect URI)">
<Typography.Paragraph copyable={{ text: meta.redirect_uri }} className="mono" style={{ marginBottom: 0 }}>
{meta.redirect_uri}
</Typography.Paragraph>
</Form.Item>
<Form.Item
name="issuer"
label="Issuer"
rules={[{ required: true, message: "请填写 Issuer" }]}
extra="如 https://auth.my.qiaonao.net(HTTPS;本机联调允许 http://localhost)"
>
<Input placeholder="https://auth.my.qiaonao.net" autoComplete="off" />
</Form.Item>
<Form.Item
name="client_id"
label="Client ID"
rules={[{ required: true, message: "请填写 Client ID" }]}
extra="ZITADEL 应用的 Client ID;认证方式是 Authorization Code + PKCE,不需要 Client Secret"
>
<Input placeholder="ZITADEL 应用 Client ID" autoComplete="off" />
</Form.Item>
<Form.Item label="授权 Scope">
<span className="mono">{meta.scopes}</span>{" "}
<Typography.Text type="secondary">(固定;userinfo 取身份,实例无 SMTP 拿不到邮箱 claim 也不影响登录)</Typography.Text>
</Form.Item>
<Form.Item label="启用统一登录" style={{ marginBottom: 8 }}>
<Switch checked={enabled} onChange={setEnabled} />
<Typography.Text type="secondary" style={{ marginLeft: 12 }}>
启用后登录页显示「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()}>
保存
</Button>
{meta.source === "settings" && (
<Popconfirm
title="清除后台保存的统一登录配置?"
description="若未配置部署环境变量兜底,清除后登录页不再显示统一登录入口;已建立的绑定保留。"
onConfirm={() => void clear()}
>
<Button danger icon={<DeleteOutlined />} disabled={saving}>
清除
</Button>
</Popconfirm>
)}
</Space>
</Form>
<Card size="small" title="当前账号绑定" style={{ width: "100%" }}>
{meta.identity ? (
<Descriptions size="small" column={1}>
<Descriptions.Item label="身份">
<Tag color="green">已绑定</Tag>
<span className="mono">{meta.identity.display_name ?? "(无展示名)"}</span>
</Descriptions.Item>
<Descriptions.Item label="Subject">
<span className="mono">{meta.identity.subject}</span>
</Descriptions.Item>
<Descriptions.Item label="Issuer">
<span className="mono">{meta.identity.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>
</Descriptions>
) : (
<Typography.Text type="secondary">
当前账号未绑定统一登录身份;绑定后可直接用 ZITADEL 登录本站(启用 JIT 时首次登录会自动建新账号)。
</Typography.Text>
)}
</Card>
</Space>
);
}
+52 -6
View File
@@ -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<string, string> = {
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 (
<div className="login-page">
@@ -23,6 +42,14 @@ export function LoginPage() {
<div style={{ textAlign: "center", marginBottom: 20 }} className="muted">
Cloudflare Email Workers 邮件接收平台
</div>
{oidcError && (
<Alert
type="error"
showIcon
style={{ marginBottom: 16 }}
message={OIDC_ERROR_TEXT[oidcError] ?? `统一登录失败(${oidcError})`}
/>
)}
<Form
layout="vertical"
onFinish={async (values) => {
@@ -48,6 +75,25 @@ export function LoginPage() {
登录
</Button>
</Form>
{oidc.enabled && (
<>
<Divider plain style={{ margin: "18px 0 14px" }}>
或
</Divider>
<Button
block
size="large"
icon={<SafetyOutlined />}
title={oidc.issuer ? `通过 ${oidc.issuer} 统一登录` : "ZITADEL 统一登录"}
onClick={() => {
// 登录成功后统一回 /(当前在登录页,必然是未登录状态;return 缺省即 /)
window.location.href = "/auth/login";
}}
>
ZITADEL 统一登录
</Button>
</>
)}
<div style={{ marginTop: 14, textAlign: "center" }} className="muted">
默认账号 admin / admin,登录后请尽快修改
</div>
+7
View File
@@ -16,6 +16,8 @@ const TYPE_META: Record<string, { label: string; color: string }> = {
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<string, string> = {
"webhook.clear": "清除 Webhook",
"webhook.deliver": "Webhook 投递成功",
"webhook.deliver_failed": "Webhook 投递失败",
"user.rename": "修改用户名",
"user.oidc_login": "统一登录",
"user.oidc_signup": "统一登录(自动建号)",
"settings.oidc": "配置统一登录",
"settings.oidc_clear": "清除统一登录配置",
};
/** # 函数
+3
View File
@@ -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());
+5 -2
View File
@@ -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;
+5
View File
@@ -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 执行上下文最小接口 **/
+152
View File
@@ -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=<code> 并附中文文案。
*/
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<string, string> = {
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);
}
+105 -4
View File
@@ -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;
}
+1 -1
View File
@@ -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 {
+350
View File
@@ -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<string, { fetchedAt: number; endpoints: OidcEndpoints }>();
/** # 成员变量: discovery 缓存有效期(毫秒) **/
const DISCOVERY_TTL_MS = 3600 * 1000;
/** # 函数
* 读取生效的 OIDC 配置:settings 优先,env 兜底;两处都没有返回 null
*/
export async function getOidcConfig(env: Env): Promise<OidcConfig | null> {
const raw = await getSetting(env, OIDC_SETTING_KEY);
if (raw) {
try {
const cfg = JSON.parse(raw) as Partial<OidcConfigSetting>;
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<OidcEndpoints> {
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<OidcEndpoints>;
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<OidcUserInfo> {
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<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 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<void> {
await setSetting(env, OIDC_SETTING_KEY, JSON.stringify(cfg));
}
/** # 函数
* 读取后台保存的原始 OIDC 配置(未保存返回 null;用于保存时合并开关)
*/
export async function readOidcSettingRaw(env: Env): Promise<OidcConfigSetting | null> {
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<void> {
await env.DB.prepare("DELETE FROM settings WHERE key = ?").bind(OIDC_SETTING_KEY).run();
}