鉴权并入 Auth Broker:委派设备会话统一模型 + 四端迁移

后端(委托 Auth Broker,路径 A):
- 删自建鉴权(OIDC exchange / 自签会话 / step-up / shortcut / web_sessions / accounts),cdrop 不再存任何凭证;鉴权中间件改读边缘注入的 X-Auth-Subject/Scope/Meta/Name/Roles 头(dev 旁路保留);Claims 加 Tier() / Guest()
- internal/brokerclient:mint / revoke(带 X-Broker-App)/ refresh / ListSessions(R1 列举),直连内网、吊销幂等

统一会话模型“委派设备会话”(Delegated Device Sessions):
- 每个客户端(浏览器 / 桌面 / 扫码设备)=一条带 meta(device_id) + label 的 broker 机器会话;Broker 作设备会话唯一注册表(R1 按用户+app 列举 + R2 按 (user,app,meta) 幂等铸造),cdrop 退化为薄覆盖层、不再自存权威会话表
- 新增代铸端点 POST /api/auth/device-session:凭边缘已验明的 X-Auth-Subject 委托 broker 铸 / 轮换设备会话(meta=device_id、按调用方 tier 防越权、sameOrigin CSRF、per-IP 限流);R2 幂等保证同一 device_id 重登原地轮换、不堆重复设备
- 会话列表=R1 权威 + 叠加 type(本地缓存)/ online(Hub presence,按设备名)/ current(meta 匹配本请求 X-Auth-Meta)+ 过滤 meta=""(device-authorize 引导会话残留);devices 表降级为 type/presence 薄缓存(非会话权威),device_id 主键、upsert 按 user 限定
- 吊销按 device_id → 缓存优先 / R1 兜底解析 sid → broker 吊销 + X-Broker-App;扫码登录保留三密钥编排,collect 改委托 broker 铸 + 落缓存行

Web 前端:
- 登录走 broker 全局 SSO 代跳(/api/auth/login 302);bootstrap 经 /api/me 注入身份后代铸设备会话(稳定 device_id 存 localStorage、Web Locks 跨 tab 串行防重复铸造);refresh 走 /api/auth/refresh
- 设备管理按 device_id;改名=同 device_id 重代铸(R2 原地轮换换 label、不产生重复行);登录页反应式守卫修登录回环
- 去 OIDC PKCE / step-up(删 oauth.callback / stepUp)

桌面客户端(Wails):
- loopback PKCE(RFC 8252)改指 broker 设备授权流(/device/authorize + /device/token)拿引导令牌,再代铸出带 meta 的托管设备会话——与浏览器同模型、同管理、同吊销;身份取自代铸响应(修“显示名显示为 UUID”);refresh 保留显示名;稳定 device_id 入桌面配置

iOS 客户端(arch A,原生 SwiftUI + 离屏无头 WebView 引擎 + 原生↔JS 桥):
- 引擎 / 文件管理 / 设备管理 / 应用图标 / 本地化(此前实现,随本次落入版本库)
- 鉴权=引擎自刷(boot 注入 refresh_token)+ broker 轮换经 sessionRotated 回报原生更新 Keychain;去 cookie 同步;Session 加 refreshToken / deviceId

实时 / 健壮性:
- presence 走 Hub union(设备表行 ∪ 表外实时连接,按名去重、live-only 标在线)
- Hub 通道 close 一律在写锁内、非阻塞 send 一律在读锁内,消除 close-vs-send 闭通道 send panic(revoke 每次 Kick 后该路径变热)

配置 / 删旧栈:
- config 改 broker 接入(CDROP_BROKER_* / CDROP_PUBLIC_URL / 按档 TTL),prod 强校验 broker 配置 + PUBLIC_URL(CSRF Origin 守卫不失效)
- 删 auth.go / selftoken.go / shortcut.go / jwks.go + 三表(web_sessions / accounts / shortcut_tokens)及验证链;.env.example / compose.snippet.yaml / Caddyfile.snippet 更新为 broker 模型(人机分流 + 公开端点放行 + X-Auth-Meta 透传)
- 测试全重写:QR / 会话含 mock broker(R1 列举 + R2 幂等);hub 加 close-vs-send 并发回归;config 加 prod 必填校验
This commit is contained in:
2026-06-26 02:07:11 +08:00
parent c79b176b87
commit 10cf36ecee
104 changed files with 7533 additions and 5318 deletions
+24 -120
View File
@@ -8,14 +8,10 @@ import {
Title,
} from "@mantine/core";
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
import { AlertTriangle, Bell, LogOut, ShieldAlert, Trash2 } from "lucide-react";
import { logout, startStepUpReauth, syncWebDeviceName } from "../features/auth/auth";
import { Bell, LogOut, ShieldAlert, Trash2 } from "lucide-react";
import { logout, renameDeviceSession, syncWebDeviceName } from "../features/auth/auth";
import { DesktopSettings } from "../features/desktop/DesktopSettings";
import {
consumePendingRevoke, consumeStepUpCode, stashPendingRevoke,
} from "../features/qr/stepUp";
import { isDesktop, persistDesktopDeviceName } from "../net/desktop";
import { apiFetch } from "../net/api";
import {
currentPushState,
disablePush,
@@ -24,7 +20,7 @@ import {
type PushState,
} from "../net/push";
import {
listSessions, postStepUp, revokeSession, StepUpRequiredError,
listSessions, revokeSession,
type AuthSession,
} from "../net/sessions";
import { useAppStore } from "../store";
@@ -77,21 +73,14 @@ function SettingsPage()
setRenaming(true);
try
{
// 先在后端把旧设备记录删掉。Hub.Kick 会强制关闭旧的 SSE
// 紧接着 root effect 检测到 selfDeviceName 变化会以新名重新注册。
// 404(旧记录已被 sweeper 清理)也按成功处理。
const r = await apiFetch(
`/api/devices/${encodeURIComponent(selfDeviceName)}`,
{ method: "DELETE" },
);
if (!r.ok && r.status !== 404)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`${r.status}: ${text}`);
}
// 改名只换 label,不换身份(device_id 稳定):先落本地名(root effect 会以新名重连
// SSE),再以同一 device_id 重新代铸,把新 label 推给 broker、原地轮换那条会话,
// 故统一设备列表与 presence 都显示新名、不产生重复设备行。桌面在此只更新本地名,
// broker label 于下次登录代铸时同步。
setSelfDeviceName(trimmedName);
persistDesktopDeviceName(trimmedName); // 桌面:持久化到 Go 配置,跨重启存活
syncWebDeviceName(trimmedName); // 浏览器:持久化到服务端 session,抗 PWA 存储清除
await renameDeviceSession();
toast.ok(t("settings.deviceName.success"));
}
catch (e)
@@ -106,28 +95,15 @@ function SettingsPage()
}
};
const handleUnregisterSelf = async () =>
const handleUnregisterSelf = () =>
{
if (!selfDeviceName) { return; }
if (!window.confirm(t("settings.unregister.currentConfirm"))) { return; }
try
{
await apiFetch(
`/api/devices/${encodeURIComponent(selfDeviceName)}`,
{ method: "DELETE" },
);
}
catch
{
// 即便 DELETE 失败也按用户意图清理本地并跳转登录。
}
finally
{
setSelfDeviceName(null);
logout();
navigate({ to: "/login", search: { dev_user: undefined } });
}
// logout() 已自吊销本设备的 broker 会话(/api/auth/logout,按 device_id),故无需再按
// 设备名 DELETE(迁移后设备路由按 device_id,旧的按名删除一律 404、且属冗余)。
setSelfDeviceName(null);
logout();
navigate({ to: "/login", search: { dev_user: undefined } });
};
const handleSignOut = () =>
@@ -348,7 +324,6 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
const [ sessions, setSessions ] = useState<AuthSession[] | null>(null);
const [ loadError, setLoadError ] = useState(false);
const [ busyId, setBusyId ] = useState<string | null>(null);
const [ stepUpRejected, setStepUpRejected ] = useState(false);
// reload 拉取并写入列表,同时把结果回传给调用方(step-up 重试路径需在 revoke
// 前从这份列表里查出 current,不能在 revoke 之后再拉——若登出的正是本机会话,
@@ -375,56 +350,15 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
void reload();
}, [ onSignedOutSelf, reload ]);
// 进页面拉一次。返回时若检测到 step-up 往返暂存({code, verifier} + 待登出 id),
// 先 POST /auth/stepup 记录窗口,再重试那条 DELETE——一次性串起整页跳转两端。
// 受限访客无权管理会话、后端 GET 会 403,故根本不发请求、直接走说明态。
// 进页面拉一次列表。受限访客无权管理会话、后端 GET 会 403,故跳过、走说明态。
useEffect(() =>
{
if (isGuest) { return; }
let cancelled = false;
const run = async () =>
{
// step-up 往返恢复:取出暂存(一次性)。无暂存则普通进页只拉列表。
const stash = consumeStepUpCode();
const pendingId = consumePendingRevoke();
const list = await reload();
if (cancelled) { return; }
if (stash && pendingId)
{
// 在 revoke 前就从这份列表里查出待登出的那条(尤其 current 标记):
// 若登出的正是本机会话,revoke 后令牌即失效,不能再 listSessions。
const target = list?.find((x) => x.id === pendingId);
setBusyId(pendingId);
try
{
await postStepUp(stash.code, stash.verifier);
await revokeSession(pendingId);
if (cancelled) { return; }
if (target) { finishRevoke(target); }
else { toast.ok(t("settings.sessions.revoked")); await reload(); }
}
catch (e)
{
if (cancelled) { return; }
if (e instanceof StepUpRequiredError) { setStepUpRejected(true); }
else
{
toast.error(t("settings.error.delete", {
message: e instanceof Error ? e.message : String(e),
}));
}
void reload();
}
finally { if (!cancelled) { setBusyId(null); } }
}
};
void run();
return () => { cancelled = true; };
}, [ isGuest, reload, finishRevoke ]);
void reload();
}, [ isGuest, reload ]);
// handleRevoke 登出一条会话(= 一台设备)。后端连带 broker 吊销 + 删设备行。
// step-up 二次认证已移除(full 档即可信)。
const handleRevoke = async (sess: AuthSession) =>
{
const confirmMsg = sess.current
@@ -433,7 +367,6 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
if (!window.confirm(confirmMsg)) { return; }
setBusyId(sess.id);
setStepUpRejected(false);
try
{
await revokeSession(sess.id);
@@ -441,29 +374,9 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
}
catch (e)
{
if (e instanceof StepUpRequiredError)
{
// 需再认证:暂存待登出 id,发起 prompt=login 往返,回到 /settings 后
// 由 mount effect 接力重试。成功则整页跳走、本组件卸载。
stashPendingRevoke(sess.id);
try
{
await startStepUpReauth("/settings");
return; // 跳转去 provider,不再 setBusyId。
}
catch (reauthErr)
{
toast.error(t("settings.error.delete", {
message: reauthErr instanceof Error ? reauthErr.message : String(reauthErr),
}));
}
}
else
{
toast.error(t("settings.error.delete", {
message: e instanceof Error ? e.message : String(e),
}));
}
toast.error(t("settings.error.delete", {
message: e instanceof Error ? e.message : String(e),
}));
}
finally { setBusyId(null); }
};
@@ -485,11 +398,6 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
<Panel title={t("settings.sessions.title")}>
<Stack gap="sm">
<Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.sessions.hint")}</Text>
{stepUpRejected && (
<Callout tone="warn" icon={<AlertTriangle size={16} />}>
{t("settings.sessions.stepUpRejected")}
</Callout>
)}
{loadError ? (
<Group>
<Text size="sm" style={{ color: "var(--state-error)" }}>
@@ -522,14 +430,10 @@ function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
);
}
// 会话种类标签:网页 / 扫码会话用 settings.sessions.kind.*;原生客户端
// macos / windows / linux / ios)复用设备类型标签(「macOS 客户端」等)。
// 会话种类标签:迁移后每条会话即一台设备,统一复用设备类型标签
// 「网页」/「macOS 客户端」等)。
function sessionKindLabel(kind: AuthSession["kind"]): string
{
if (kind === "oidc" || kind === "self" || kind === "guest")
{
return t(`settings.sessions.kind.${kind}` as const);
}
return t(`deviceType.${kind}` as const);
}