鉴权并入 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:
+24
-120
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user