鉴权并入 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
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- cdrop 无头传输引擎页(arch A,见 ios/PLAN.md §2)。无 UI / 字体 / 图标——只承载
传输引擎。仅由原生 iOS 壳的离屏 WKWebView 从安全源(https)加载;原生须在本
模块执行前经 WKUserScript 注入 window.__CDROP_BOOT__session / device_name /
api_base / device_type:"ios"),见 src/engine/main.ts。 -->
<title>cdrop engine</title>
</head>
<body>
<script type="module" src="/src/engine/main.ts"></script>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
import { build } from "esbuild";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
// 把 src/i18n/locales/*.ts 的扁平字典产出为 JSON,供 iOS 原生侧读取(强约束②:i18n 单一
// 真源=web catalog,见 ios/PLAN.md §5)。locale 变更后重跑:node web/scripts/emit-i18n.mjs
// 各 locale 产出完整字典(zh-TW / en-US 缺失键回退 zh-CN),故 iOS loader 无需再做回退。
const webDir = fileURLToPath(new URL("..", import.meta.url));
const repoRoot = join(webDir, "..");
const outDir = join(repoRoot, "ios", "CDrop", "Sources", "Resources", "i18n");
const cacheDir = join(webDir, "node_modules", ".cache");
const tmp = join(cacheDir, "cdrop-i18n-emit.mjs");
const entry = [
'import { zhCN } from "./src/i18n/locales/zh-CN";',
'import { zhTW } from "./src/i18n/locales/zh-TW";',
'import { enUS } from "./src/i18n/locales/en-US";',
"export const all = {",
' "zh-CN": zhCN,',
' "zh-TW": { ...zhCN, ...zhTW },',
' "en-US": { ...zhCN, ...enUS },',
"};",
].join("\n");
const res = await build({
stdin: { contents: entry, resolveDir: webDir, loader: "ts" },
bundle: true,
format: "esm",
write: false,
platform: "neutral",
});
await mkdir(cacheDir, { recursive: true });
await writeFile(tmp, res.outputFiles[0].text);
const mod = await import(pathToFileURL(tmp).href);
await mkdir(outDir, { recursive: true });
for (const [loc, dict] of Object.entries(mod.all))
{
await writeFile(join(outDir, `${loc}.json`), `${JSON.stringify(dict, null, 2)}\n`);
}
console.log("emitted i18n:", Object.keys(mod.all).join(", "), "->", outDir);
+357
View File
@@ -0,0 +1,357 @@
// cdrop 无头传输引擎入口(arch A,见 ios/PLAN.md §2 / §6)。
//
// 不加载 React / 路由 / UI——只复用 web 的传输引擎(P2P + relay + 信令 + 会话),由原生
// iOS 壳经桥(net/ios.ts)驱动。原生在加载本 bundle 前经 WKUserScript 注入
// window.__CDROP_BOOT__session / device_name / api_base / device_type:"ios"),store 在
// import 时即从中水合(见 store/helpers.ts)。本页须从 https 安全源加载——WebRTC 的
// RTCPeerConnection 在非安全 origin 下可能被禁(见 ios/PLAN.md R-iOS-3)。
//
// 命令(原生 → 引擎,经 onNativeEvent):
// sendFile { target, url, name, size, type? } —— 取回 url 指向的待发文件并发起传输
// cancelTransfer { sessionId }
// switchToRelay { sessionId } —— 手动切中继
// session { access_token, refresh_token?, user } —— 原生重登 / 续期后推新令牌对
// shutdown —— 断开 hub、停 ICE 刷新
// 通知(引擎 → 原生,经 notifyNative):
// ready { device } / presence { devices[] } / transfers { active[] } / transferDone {...}
// / sendStarted {...} / error { stage, message }
// sessionRotated { access_token, refresh_token } —— 引擎自刷致 broker 轮换后,回报原生
// 更新 Keychain(重启免用失效旧 refresh
// authExpired {} —— 续期被 401 拒(refresh 过期 / 吊销),原生清 Keychain 回登录页
import { refreshSessionScope } from "../features/auth/auth";
import { fetchClipboard, uploadClipboard } from "../features/clipboard/clipboard";
import { apiFetch } from "../net/api";
import { startHub } from "../features/transfer/hub";
import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers";
import { cancelTransfer, skipWaitRelay, startOutgoingTransfer } from "../features/transfer/transfer";
import { isIOSShell, notifyNative, onNativeEvent } from "../net/ios";
import { useAppStore } from "../store";
import type { TransferRecord } from "../store/types";
interface SendFilePayload
{
target: string;
url: string;
name: string;
size: number;
type?: string;
}
// 推给原生的精简传输视图:去掉 iceStats 大对象,只留原生 UI 需要的字段 + 一条压缩的
// ICE 摘要(连接态 + 选中候选对),供详情页诊断 P2P 是否走 TURN 中继(pair 含 "relay"
// 即中继路径,解释慢速)。
function toWire(r: TransferRecord)
{
const ice = r.iceStats;
return {
sessionId: r.sessionId,
direction: r.direction,
fileName: r.fileName,
fileSize: r.fileSize,
state: r.state,
mode: r.mode,
peerName: r.peerName,
phase: r.phase,
bytesTransferred: r.bytesTransferred,
bytesPerSec: r.bytesPerSec,
ice: ice
? {
conn: ice.connection,
local: ice.selectedPair?.local,
remote: ice.selectedPair?.remote,
}
: undefined,
};
}
// hub 的生命周期句柄;shutdown 命令据此断开 SSE。
const hubCtrl = new AbortController();
// 活跃传输快照推送节流:接收端每个 64KB chunk 都会更新 storeemitProgress),若每次都
// 过桥推原生,会(1)让速度数字 ~10Hz 闪烁失去参考价值、(2)主线程被 postMessage/序列化抖
// 动堆满拖慢 dc.onmessage(见 p2p.ts 收方看门狗注释)→ 反噬 P2P 吞吐。故合并到 ~3Hz
// 尾沿推送:进度条仍流畅、速度可读,主线程负载大幅下降。终态另由 transferDone 即时推。
const TRANSFERS_MIN_INTERVAL_MS = 350;
let transfersTimer: ReturnType<typeof setTimeout> | null = null;
let pendingActive: unknown[] | null = null;
let lastTransfersAt = 0;
function pushTransfersThrottled(active: unknown[]): void
{
pendingActive = active;
if (transfersTimer !== null) { return; }
const wait = Math.max(0, TRANSFERS_MIN_INTERVAL_MS - (Date.now() - lastTransfersAt));
transfersTimer = setTimeout(() =>
{
transfersTimer = null;
lastTransfersAt = Date.now();
notifyNative("transfers", { active: pendingActive });
pendingActive = null;
}, wait);
}
// 订阅 storeactiveTransfers 引用变化推快照、history 头部新增推完成事件、devices 引用
// 变化推在线列表。原生据 sessionId / name 幂等更新自己的 UI。setDevices / setActive 每次
// 都换数组 / 对象引用,故 !== 即可判变更。
function subscribeStore(): void
{
const st0 = useAppStore.getState();
let prevActive = st0.activeTransfers;
let prevDoneId = st0.history[0]?.sessionId;
let prevDevices = st0.devices;
let prevSse = st0.sseConnected;
let prevAuthed = st0.accessToken !== null;
let prevRefresh = st0.refreshToken;
let presenceCount = 0;
// 把 SSE 连接态 + 收到的 presence 计数推给原生,供设置页诊断「设备空」到底卡在哪:
// SSE 未连接=鉴权/连接问题;已连接但 presence=0=后端没发;presence>0 但列表空=原生侧
// (已用本地隔离证伪——原生侧 OK)。
const pushHubState = (s: ReturnType<typeof useAppStore.getState>): void =>
{
notifyNative("hubState", {
connected: s.sseConnected,
reconnecting: s.sseReconnecting,
presenceCount,
devices: s.devices.length,
});
};
useAppStore.subscribe((s) =>
{
if (s.activeTransfers !== prevActive)
{
prevActive = s.activeTransfers;
pushTransfersThrottled(Object.values(s.activeTransfers).map(toWire));
}
const head = s.history[0];
if (head && head.sessionId !== prevDoneId)
{
prevDoneId = head.sessionId;
notifyNative("transferDone", toWire(head));
}
if (s.devices !== prevDevices)
{
prevDevices = s.devices;
presenceCount += 1;
notifyNative("presence", { devices: s.devices });
pushHubState(s);
}
if (s.sseConnected !== prevSse)
{
prevSse = s.sseConnected;
pushHubState(s);
}
// 引擎自刷(apiFetch 401 → refreshTokens 用注入的 refresh_token 经 /api/auth/refresh
// 续期)后 broker 轮换了 refresh_token。把新令牌对回报原生更新 Keychain,使重启后不再
// 用已轮换失效的旧 refresh。
if (s.refreshToken !== prevRefresh && s.refreshToken)
{
prevRefresh = s.refreshToken;
notifyNative("sessionRotated", {
access_token: s.accessToken,
refresh_token: s.refreshToken,
});
}
// 续期被服务端以 401 拒绝(refresh_token 已过期 / 被吊销)后 forceLogout 清空登录态
// → 通知原生清 Keychain 回登录页,避免「貌似已登录但拉不到任何数据」的死态。仅在确证
// 失效(非瞬时)时触发。
const authed = s.accessToken !== null;
if (prevAuthed && !authed)
{
notifyNative("authExpired", {});
}
prevAuthed = authed;
});
}
// handleSendFile:原生把待发文件经 WKURLSchemeHandler 以 payload.url 暴露,这里取回为
// File 交给引擎发起传输。
//
// R-iOS-4:整文件 fetch 进内存——小 / 中文件可行;大文件在 iOS 上有 jetsam 风险,按
// slice 向原生拉取(不整文件入内存)是真机验证后的优化点,待与原生发送端同期落地。
async function handleSendFile(p: SendFilePayload): Promise<void>
{
try
{
const resp = await fetch(p.url);
if (!resp.ok) { throw new Error(`fetch staged file failed: ${resp.status}`); }
const blob = await resp.blob();
const file = new File([ blob ], p.name, {
type: p.type || blob.type || "application/octet-stream",
});
const sessionId = await startOutgoingTransfer(p.target, file);
notifyNative("sendStarted", { sessionId, name: p.name });
}
catch (e)
{
notifyNative("error", { stage: "send", message: e instanceof Error ? e.message : String(e) });
}
}
// bindCommands:把原生命令接到引擎函数。
function bindCommands(): void
{
onNativeEvent("sendFile", (payload) =>
{
void handleSendFile(payload as SendFilePayload);
});
onNativeEvent("cancelTransfer", (payload) =>
{
void cancelTransfer((payload as { sessionId: string }).sessionId);
});
onNativeEvent("switchToRelay", (payload) =>
{
void skipWaitRelay((payload as { sessionId: string }).sessionId);
});
// 原生 Keychain 续期后推来的新会话令牌 → 写回 store,让 apiFetch 用新令牌(iOS 无
// 浏览器 cookie / Go 进程,token 续期由原生侧持有 refresh_token 后驱动)。
onNativeEvent("session", (payload) =>
{
const p = payload as {
access_token?: string;
refresh_token?: string;
user?: { id: string; name: string; avatar?: string };
};
if (p.access_token && p.user)
{
useAppStore.getState().setAuth({
accessToken: p.access_token,
refreshToken: p.refresh_token ?? null,
user: p.user,
});
}
});
// 剪贴板上行:原生读 UIPasteboard 后把文本送来,经云剪贴板上传(PUT /api/clipboard)。
onNativeEvent("clipboardUpload", (payload) =>
{
const content = (payload as { content?: string }).content ?? "";
if (!content) { return; }
void uploadClipboard(content)
.then(() => notifyNative("clipboardUploaded", {}))
.catch((e) => notifyNative("error", { stage: "clipboard", message: String(e) }));
});
// 剪贴板下行:拉一次云剪贴板,把最新内容推回原生写入 UIPasteboard。
onNativeEvent("clipboardPull", () =>
{
void fetchClipboard()
.then(() =>
{
const c = useAppStore.getState().clipboard;
notifyNative("clipboard", { content: c?.content ?? "", sourceDevice: c?.sourceDevice ?? "" });
})
.catch((e) => notifyNative("error", { stage: "clipboard", message: String(e) }));
});
// 设备管理:移除 / 吊销一台设备(DELETE /api/devices/{name})。需完整会话;若服务端要求
// step-up(403)这里拿不到浏览器再认证流程,回错给原生提示「请在网页端完成」。
onNativeEvent("revokeDevice", (payload) =>
{
const name = (payload as { name?: string }).name ?? "";
if (!name) { return; }
void revokeDevice(name);
});
onNativeEvent("shutdown", () =>
{
hubCtrl.abort();
stopICEServerRefresh();
});
}
async function revokeDevice(name: string): Promise<void>
{
try
{
const r = await apiFetch(`/api/devices/${encodeURIComponent(name)}`, { method: "DELETE" });
if (r.status === 403)
{
notifyNative("error", { stage: "revoke", message: "step_up_required" });
return;
}
if (!r.ok)
{
notifyNative("error", { stage: "revoke", message: `HTTP ${r.status}` });
return;
}
notifyNative("deviceRevoked", { name });
}
catch (e)
{
notifyNative("error", { stage: "revoke", message: String(e) });
}
}
// installLogBridge:把 console.warn / error 过桥给原生(设置页显示「最近日志」)。SSE 失败
// 等都走 console.warn,这样不接 Web Inspector 也能在真机看到失败原因(401 / 网络 / 静默挂起)。
function installLogBridge(): void
{
const safeStr = (a: unknown): string =>
a instanceof Error
? `${a.name}: ${a.message}`
: typeof a === "object" && a !== null
? (() => { try { return JSON.stringify(a); } catch { return String(a); } })()
: String(a);
const wrap = (level: string, orig: (...a: unknown[]) => void) =>
(...args: unknown[]): void =>
{
try { notifyNative("log", { level, msg: args.map(safeStr).join(" ") }); }
catch { /* 永不让日志桥拖垮引擎 */ }
orig(...args);
};
console.warn = wrap("warn", console.warn.bind(console));
console.error = wrap("error", console.error.bind(console));
}
// boot:原生壳内启动引擎。store 已于 import 时从 __CDROP_BOOT__ 水合,故此处同步读取
// 注入的登录态;缺失即报错(原生须在加载本 bundle 前注入)。
function boot(): void
{
if (!isIOSShell())
{
// 非 iOS 壳(误加载 engine.html)——保持惰性,不触发任何网络 / 引擎行为。
// eslint-disable-next-line no-console
console.warn("cdrop engine: not running inside the iOS shell, idle");
return;
}
const { user, selfDeviceName } = useAppStore.getState();
if (!user || !selfDeviceName)
{
notifyNative("error", {
stage: "boot",
message: "missing injected session / device_name (window.__CDROP_BOOT__)",
});
return;
}
installLogBridge(); // 把引擎 console.warn/error 过桥给原生,设置页显示,便于诊断 SSE 失败原因
bindCommands();
subscribeStore();
void startHub(hubCtrl.signal); // SSE 信令循环:presence / 传入 offer / 状态 / 信令
void refreshICEServers(); // 预取 TURN / STUN,首次 WebRTC 即可用
void refreshSessionScope(); // 校正 /api/me 的权限级别
// 启动即推一次当前快照:subscribe 只在「之后」的变更触发,初始态(多为空,但桌面壳
// 复用同入口时可能已有)须主动补发,免原生 UI 等到下一次变更才填。
const init = useAppStore.getState();
notifyNative("ready", { device: selfDeviceName });
notifyNative("presence", { devices: init.devices });
notifyNative("transfers", { active: Object.values(init.activeTransfers).map(toWire) });
// 诊断隔离(仅 CDROP_DEBUG_SESSION 下、prod 用户无此 flag):经真 postMessage 桥推一
// 条假 presence,验证「桥 → 原生解析 → UI 渲染」整条链路是否工作(不依赖真 SSE/鉴权,
// 因 debug token 必 401 拿不到真 presence)。若原生「设备」据此显示出来 → 链路 OK,空
// 列表的真因在上游(SSE 没连 / 后端空);若仍不显示 → 原生侧 bug。
const boot = (window as unknown as { __CDROP_BOOT__?: { debug?: boolean } }).__CDROP_BOOT__;
if (boot?.debug)
{
const nowSec = Math.floor(Date.now() / 1000);
notifyNative("presence", { devices: [
{ name: "Debug-MacBook", type: "macos", online: true, lastSeen: nowSec },
{ name: "Debug-PC", type: "windows", online: false, lastSeen: nowSec - 600 },
] });
}
}
boot();
+258 -304
View File
@@ -3,15 +3,21 @@ import { t } from "../../i18n";
import { apiFetch } from "../../net/api";
import { isDesktop, desktopRefresh, clearDesktopSession } from "../../net/desktop";
import { fetchMeScope } from "../../net/sessions";
import { stashStepUpPending } from "../qr/stepUp";
import { toast } from "../../ui/feedback";
// Auth, post Auth Broker migration (path A). cdrop no longer runs OIDC itself:
// - a brand-new browser logs in via the broker's global SSO (loginRedirect → cdrop
// 302s to the broker login page; on return the edge injects X-Auth from the broker
// domain cookie, so this browser holds NO token — the cookie carries identity);
// - a QR-paired device holds the broker's access + refresh pair directly and renews
// them via /api/auth/refresh (a thin same-origin proxy to the broker).
// ---- dev mode -------------------------------------------------------------
// Dev-mode "login": read ?dev_user=alice (or fall back to localStorage),
// stamp the store with a synthetic User and the build-time dev token.
// This bypasses Casdoor entirely. The middleware on the backend accepts
// any X-Dev-User as long as the bearer matches CDROP_DEV_TOKEN.
// Dev-mode "login": read ?dev_user=alice (or fall back to localStorage), stamp the
// store with a synthetic User and the build-time dev token. Bypasses the broker; the
// backend dev middleware accepts any X-Dev-User as long as the bearer matches
// CDROP_DEV_TOKEN.
export function loginDev(searchParams: URLSearchParams): User
{
const devUser = searchParams.get("dev_user")
@@ -26,292 +32,108 @@ export function loginDev(searchParams: URLSearchParams): User
}
const user: User = { id: devUser, name: devUser };
useAppStore.getState().setAuth({ accessToken: token, user });
useAppStore.getState().setAuth({ accessToken: token, refreshToken: null, user });
return user;
}
// ---- prod login (broker global SSO) ---------------------------------------
// loginRedirect sends the browser to the broker's global-SSO login via cdrop's
// server-side 302 (which holds the broker's public URL). After login the browser
// returns here with a broker domain cookie the edge turns into X-Auth.
export function loginRedirect(): void
{
const rd = encodeURIComponent(window.location.href);
window.location.href = `/api/auth/login?rd=${rd}`;
}
// ---- logout ---------------------------------------------------------------
export function logout()
{
// Fire-and-forget 通知后端立刻把本设备从 Hub 踢掉 + 广播 presence,让对端
// 不必等 30s 宽限期。apiFetch 同步抓取 access_token 后才让出(fetch 已发出),
// 即使紧随其后 clearAuth 也不会污染这次请求的 Authorization 头。失败忽略
// ——只是体验降级回宽限期路径,不影响登出本身。
// Fire-and-forget: tell the backend to kick this device from the Hub immediately
// (so peers don't wait out the 30s grace). apiFetch reads the access token before
// yielding, so the subsequent clearAuth can't strip this request's Authorization.
void apiFetch("/api/me/disconnect", { method: "POST" }).catch(() => { /* ignore */ });
// 浏览器端:删除服务端 session 并清除 HttpOnly cookiecookie 自动随同源请求),
// 否则下次开机 bootstrapAuth 仍会静默免重登回来。桌面端无 cookie session,跳过。
if (!isDesktop() && useAppStore.getState().authMode === "prod")
// Revoke this device's own broker session server-side (self-service logout), so it
// can't be refreshed back. For a global-SSO browser this is a no-op (nothing to
// revoke). Best-effort.
if (useAppStore.getState().authMode === "prod")
{
void fetch("/api/auth/logout", { method: "POST" }).catch(() => { /* ignore */ });
void apiFetch("/api/auth/logout", { method: "POST" }).catch(() => { /* ignore */ });
}
useAppStore.getState().clearAuth();
// 桌面端:删除 Go 侧持久化 session 文件,否则下次启动仍会注入已登录态。
// Desktop: drop the Go-side persisted session, else the next launch re-injects it.
if (isDesktop()) { clearDesktopSession(); }
}
// forceLogout 在「服务端确证会话已失效」时清掉本地登录态——触发点仅一处:
// access token 401 后续期也被服务端以 401 拒绝(refreshTokens 返回 "invalid"
// 即被远程吊销或已过期)。短期连接失败(网络抖动 / 5xx)绝不走到这里(见
// net/api.ts)。与 logout() 不同,它不发任何服务端请求(会话已死,调用只会再次
// 401),只就地清空 + 提示,让 __root 的反应式守卫把界面送回登录页
// (离线设备「下一次使用即得知」)。
//
// 幂等:靠「已登出即跳过」实现——首个 401 同步清空 user/accessToken 后,并发涌入
// 的其余 401 看到已为空便直接返回,不会重复弹 toast、不重复清桌面 session。
// forceLogout clears local auth state when the server authoritatively confirms the
// session is gone (refresh → 401). It sends no request (the session is dead) and just
// clears + toasts; the __root guard then returns the UI to the login page. Idempotent:
// concurrent 401s after the first clear see an empty state and return early.
export function forceLogout(): void
{
const st = useAppStore.getState();
if (!st.user && !st.accessToken)
{
// 已被并发的某个 401 清空,或本就处于登出态:不重复处理(避免 toast 刷屏)。
return;
}
st.clearAuth();
// 桌面端:删除 Go 侧持久化 session,否则下次启动仍注入这枚已失效的登录态。
if (isDesktop()) { clearDesktopSession(); }
toast.error(t("auth.sessionLost.title"), t("auth.sessionLost.body"));
}
// ---- prod (OIDC PKCE) -----------------------------------------------------
// ---- token refresh (broker proxy) -----------------------------------------
interface AuthConfig
{
auth_mode: "dev" | "prod";
authorize_url: string;
client_id: string;
redirect_uri: string;
scopes: string;
}
let cachedConfig: AuthConfig | null = null;
export async function fetchAuthConfig(): Promise<AuthConfig>
{
if (cachedConfig) { return cachedConfig; }
const r = await fetch("/api/auth/config");
if (!r.ok) { throw new Error(`auth config ${r.status}`); }
cachedConfig = await r.json() as AuthConfig;
return cachedConfig;
}
const PKCE_VERIFIER_KEY = "cdrop.pkce_verifier";
const OAUTH_STATE_KEY = "cdrop.oauth_state";
// step-up 再认证用一对独立的 state / verifier 句柄,绝不复用上面那对常规登录键——
// 否则 /oauth/callback 在 step-up 往返里会被当成普通登录、误调 /api/auth/exchange。
// state 走 sessionStorage(仅本次往返、关标签即清,与 step-up 暂存物同生命周期);
// verifier 不单独落键,而是塞进 stepUp.ts 的 PENDING{verifier, returnTo})。
const STEPUP_STATE_KEY = "cdrop.qr.stepup_state";
// loginProd kicks off OIDC PKCE: generate verifier+challenge+state, stash them
// in localStorage, navigate the browser to the provider's /authorize endpoint.
// The callback page completes the flow.
//
// localStorage (not sessionStorage): a standalone PWA on iOS may run the
// provider round-trip in a separate context and relaunch the PWA on the redirect
// back, which wipes sessionStorage and breaks login. localStorage survives that.
// The verifier is consumed + removed immediately on callback and is useless
// without the matching auth code, so persisting it for the round-trip is fine.
export async function loginProd(): Promise<void>
{
const cfg = await fetchAuthConfig();
if (cfg.auth_mode !== "prod")
{
throw new Error(`server reports auth_mode=${cfg.auth_mode}, expected prod`);
}
if (!cfg.authorize_url || !cfg.client_id || !cfg.redirect_uri)
{
throw new Error("OIDC config incomplete (authorize_url / client_id / redirect_uri)");
}
const verifier = generateRandomBase64url(32);
const challenge = await sha256Base64url(verifier);
const state = generateRandomBase64url(16);
localStorage.setItem(PKCE_VERIFIER_KEY, verifier);
localStorage.setItem(OAUTH_STATE_KEY, state);
const params = new URLSearchParams({
client_id: cfg.client_id,
redirect_uri: cfg.redirect_uri,
response_type: "code",
scope: cfg.scopes || "openid profile email",
state,
code_challenge: challenge,
code_challenge_method: "S256",
});
window.location.href = `${cfg.authorize_url}?${params}`;
}
// startStepUpReauth 发起批准页 step-up 再认证:一次新鲜 PKCE 往返,但追加
// prompt=login + max_age=0 强制 provider 现场重证(2FA 即在此处过)。复用既有
// /oauth/callback 作 redirect_uri;跳转前用 sessionStorage 记下「待办」标记 +
// 这次的 verifier + 要回到的批准页 URLreturnTo 走 stepUp.ts 的同款 open-redirect
// 校验,仅允许 /link)。回调检测到「待办」即分叉,不消费 code(见 oauth.callback)。
//
// 注意:这里不写常规 PKCE_VERIFIER_KEY / OAUTH_STATE_KEY,否则普通登录回调会误判。
// verifier 进 PENDING、state 进 STEPUP_STATE_KEY,二者均会在回调与批准页里清掉。
export async function startStepUpReauth(returnTo: string): Promise<void>
{
const cfg = await fetchAuthConfig();
if (cfg.auth_mode !== "prod")
{
throw new Error(`server reports auth_mode=${cfg.auth_mode}, expected prod`);
}
if (!cfg.authorize_url || !cfg.client_id || !cfg.redirect_uri)
{
throw new Error("OIDC config incomplete (authorize_url / client_id / redirect_uri)");
}
const verifier = generateRandomBase64url(32);
const challenge = await sha256Base64url(verifier);
const state = generateRandomBase64url(16);
// 先把「待办」落定(含 returnTo 的 open-redirect 校验);非法即拒,不发起跳转。
if (!stashStepUpPending({ verifier, returnTo }))
{
throw new Error("invalid step-up return path");
}
sessionStorage.setItem(STEPUP_STATE_KEY, state);
const params = new URLSearchParams({
client_id: cfg.client_id,
redirect_uri: cfg.redirect_uri,
response_type: "code",
scope: cfg.scopes || "openid profile email",
state,
code_challenge: challenge,
code_challenge_method: "S256",
prompt: "login",
max_age: "0",
});
window.location.href = `${cfg.authorize_url}?${params}`;
}
// verifyStepUpState 在 /oauth/callback 的 step-up 分叉里校 state(防 CSRF,与常规
// 登录回调同口径)。匹配即消费掉 STEPUP_STATE_KEY 并返回 true;不匹配返回 false。
export function verifyStepUpState(state: string): boolean
{
const expected = sessionStorage.getItem(STEPUP_STATE_KEY);
sessionStorage.removeItem(STEPUP_STATE_KEY);
return !!expected && state === expected;
}
// clearStepUpState 兜底清掉 step-up state(异常路径,避免残留)。
export function clearStepUpState(): void
{
sessionStorage.removeItem(STEPUP_STATE_KEY);
}
interface ExchangeResp
interface BrokerRefreshResp
{
access_token: string;
refresh_token: string;
expires_in: number;
user: { id: string; name: string; avatar?: string };
// refresh_token 不再回传:服务端把它加密存进 web_sessions,浏览器只拿到一枚
// HttpOnly 的 session cookie(随 /api/auth/exchange 响应一并下发)。
}
// completeOAuthLogin runs on /oauth/callback after the provider redirects back.
// Verifies state (CSRF), trades code+verifier for tokens via the backend
// proxy, and stamps the store with the user identity.
//
// Idempotent:若 store 中已有 user + accessToken(典型场景:useEffect 在 React
// 渲染管线中重入,首次已成功 setAuth + 消耗 localStorage,二次入场不能再被
// "PKCE verifier missing" / "state mismatch" 当作失败处理)→ 当作 noop 直接
// resolve,由上层 navigate 接管。
export async function completeOAuthLogin(code: string, state: string): Promise<void>
{
const cur = useAppStore.getState();
if (cur.user && cur.accessToken)
{
return;
}
const expectedState = localStorage.getItem(OAUTH_STATE_KEY);
if (!expectedState || state !== expectedState)
{
throw new Error("OAuth state mismatch (possible CSRF or stale tab)");
}
const verifier = localStorage.getItem(PKCE_VERIFIER_KEY);
if (!verifier)
{
throw new Error("PKCE verifier missing — restart the login flow");
}
const r = await fetch("/api/auth/exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, code_verifier: verifier }),
});
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`token exchange ${r.status}: ${text}`);
}
const data = await r.json() as ExchangeResp;
localStorage.removeItem(PKCE_VERIFIER_KEY);
localStorage.removeItem(OAUTH_STATE_KEY);
useAppStore.getState().setAuth({
accessToken: data.access_token,
user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar },
});
}
interface CookieRefreshResp
{
access_token: string;
expires_in: number;
user: { id: string; name: string; avatar?: string };
device_name?: string;
}
// CookieRefreshResult 区分「服务端确证会话失效」与「短期失败」,使上层只在前者
// 清空登录态:
// ok —— 换到新 access token
// invalid —— /api/auth/refresh 返回 401:服务端查证会话已失效 / 被吊销 / 过期
// (其处理器同时清掉 cookie),凭据确凿不可恢复
// transient —— 5xx / 同源校验 403 / 响应残缺等非确证失败(网络层抛错则根本不进此
// 函数、照常向上抛)——一律保留登录态,按短期错误处理
type CookieRefreshResult =
| { kind: "ok"; accessToken: string; user: User; deviceName: string }
type BrokerRefreshResult =
| { kind: "ok"; accessToken: string; refreshToken: string }
| { kind: "invalid" }
| { kind: "transient" };
// cookieRefresh 用 HttpOnly session cookie 静默换一枚新 access_token。无 body——
// cookie 自动随同源请求发出(Path=/api/auth)。返回见 CookieRefreshResult:只有
// 服务端明确以 401 拒绝才算 invalid,短期失败一律 transient,不误伤登录态。
async function cookieRefresh(): Promise<CookieRefreshResult>
// brokerRefresh exchanges the stored refresh token for a fresh access + rotated
// refresh via cdrop's same-origin proxy to the broker. 401 → invalid (expired /
// rotated / revoked); other non-OK / malformed → transient (keep the session).
async function brokerRefresh(refreshToken: string): Promise<BrokerRefreshResult>
{
const r = await fetch("/api/auth/refresh", { method: "POST" });
if (!r.ok)
let r: Response;
try
{
return { kind: r.status === 401 ? "invalid" : "transient" };
r = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
}
const data = await r.json().catch(() => null) as CookieRefreshResp | null;
if (!data?.access_token || !data.user?.id) { return { kind: "transient" }; }
return {
kind: "ok",
accessToken: data.access_token,
user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar },
deviceName: data.device_name ?? "",
};
catch
{
return { kind: "transient" };
}
if (!r.ok) { return { kind: r.status === 401 ? "invalid" : "transient" }; }
const data = await r.json().catch(() => null) as BrokerRefreshResp | null;
if (!data?.access_token || !data.refresh_token) { return { kind: "transient" }; }
return { kind: "ok", accessToken: data.access_token, refreshToken: data.refresh_token };
}
// RefreshOutcome 是续期对上层的三态结论;只有 "invalid"(服务端确证失效)才允许
// 上层 forceLogout"transient" 必须保留登录态(短期连接失败不清状态)。
// RefreshOutcome is the three-state conclusion refresh reports upward; only "invalid"
// (server-confirmed dead) lets the caller forceLogout, "transient" must keep state.
export type RefreshOutcome = "refreshed" | "invalid" | "transient";
// refreshTokens 换新 access_tokenapi.ts 在请求拿到 401 时惰性调用。
// refreshTokens renews the access token; api.ts calls it lazily on a 401.
export async function refreshTokens(): Promise<RefreshOutcome>
{
const store = useAppStore.getState();
if (store.authMode !== "prod") { return "transient"; }
// 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 GoJS 不持有),
// 直接无参调用。Go 侧暂不区分「被吊销」与「短期失败」,故失败一律按 transient
// 处理(不清空登录态)——桌面端即时吊销留后续(AUTH.md §3.1)。
// Desktop: refresh happens entirely inside Go (the refresh token lives in the Go
// process, never in JS). Go does not yet distinguish revoked from transient, so a
// failure is treated as transient (keeps state).
if (isDesktop())
{
const res = await desktopRefresh();
@@ -322,78 +144,210 @@ export async function refreshTokens(): Promise<RefreshOutcome>
return "refreshed";
}
// 浏览器端:走 HttpOnly cookie sessionrefresh_token 在服务端,JS 不持有)。
const res = await cookieRefresh();
if (res.kind !== "ok") { return res.kind; } // "invalid" | "transient",透传给上层
// After 代铸 a browser holds a device-session refresh token and renews via the broker
// proxy below. A browser still on cookie-only identity (代铸 not yet run / failed) holds
// none; a 401 then means the broker cookie is gone too: invalid → re-login.
const refresh = store.refreshToken;
if (!refresh) { return "invalid"; }
const res = await brokerRefresh(refresh);
if (res.kind !== "ok") { return res.kind; }
const cur = useAppStore.getState();
cur.setAuth({ accessToken: res.accessToken, user: res.user });
// 设备名以服务端为权威;本地缺失(PWA 存储被清)时回填。
if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); }
if (!cur.user) { return "transient"; }
cur.setAuth({ accessToken: res.accessToken, refreshToken: res.refreshToken, user: cur.user });
return "refreshed";
}
// bootstrapAuth 在 App 挂载前尝试「免重登」:浏览器端若本会话尚无 access_token
// 用 HttpOnly cookie 静默续期,成功即直接进入已登录态(并回填服务端所记设备名),
// 失败则照常落到登录页。桌面端由注入式水合负责、dev 无 cookie 流程,均直接跳过。
export async function bootstrapAuth(): Promise<void>
{
if (isDesktop()) { return; }
const store = useAppStore.getState();
if (store.authMode !== "prod") { return; }
if (store.accessToken && store.user) { return; } // 同会话已登录(sessionStorage 命中)
// ---- session bootstrap (/api/me) ------------------------------------------
const res = await cookieRefresh();
if (res.kind !== "ok") { return; } // 开机水合失败(含 invalid / transient)→ 照常落登录页
const cur = useAppStore.getState();
cur.setAuth({ accessToken: res.accessToken, user: res.user });
if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); }
interface MeResp
{
user_id: string;
name: string;
avatar?: string;
device_name?: string;
scope?: string;
}
// refreshSessionScope 拉一次 /api/me 校正本会话权限级别(full / guest)并写进
// store。在登录成功 / 开机水合后调用一次,使受限访客的账号管理入口被隐藏。失败
// 静默回退 "full"——只多展示入口,后端仍 403 兜底。dev 模式无 guest 概念,跳过。
// fetchMe confirms the session with the server: a QR device sends its stored Bearer, a
// global-SSO browser relies on the broker cookie (no header). Returns null on any
// non-200 / error, so the caller falls to the login page.
async function fetchMe(): Promise<MeResp | null>
{
const token = useAppStore.getState().accessToken;
const headers: Record<string, string> = {};
if (token) { headers.Authorization = `Bearer ${token}`; }
let r: Response;
try { r = await fetch("/api/me", { headers }); }
catch { return null; }
if (!r.ok) { return null; }
return await r.json().catch(() => null) as MeResp | null;
}
// bootstrapAuth runs before the app mounts: it confirms the session via /api/me and
// hydrates the identity (a stored device token is preserved; a global-SSO browser has
// none). Failure leaves the app logged out → login page. Desktop hydrates from boot
// injection and dev has no broker session; both skip.
export async function bootstrapAuth(): Promise<void>
{
const store = useAppStore.getState();
if (store.authMode !== "prod") { return; }
if (isDesktop())
{
// Desktop identity (name + avatar) comes from the Go boot injection, which heals a stale
// persisted identity from /api/me — refreshing the token if needed and re-persisting —
// BEFORE injecting it (see desktop HealIdentity). Doing it Go-side, at the source, is why
// it no longer depends on a fragile per-launch WebView /api/me. Nothing to do here.
return;
}
const me = await fetchMe();
if (!me?.user_id) { return; }
const cur = useAppStore.getState();
// Use /api/me's name only when it is a real display name. The server falls back to the
// subject UUID when X-Auth-Name is absent; in that case keep an already-known name (e.g.
// rehydrated from a prior login) rather than clobbering it with the UUID.
const verifiedName = me.name && me.name !== me.user_id ? me.name : "";
cur.setAuth({
accessToken: cur.accessToken, // preserve a stored device token, or null (cookie)
user: {
id: me.user_id,
name: verifiedName || cur.user?.name || me.user_id,
avatar: me.avatar || cur.user?.avatar,
},
});
cur.setSessionScope(me.scope === "guest" ? "guest" : "full");
if (me.device_name && !cur.selfDeviceName) { cur.setSelfDeviceName(me.device_name); }
// Upgrade a global-SSO browser into a cdrop-managed device session (代铸) so it joins
// the unified device list and rides a Bearer token like a QR-paired device. No-op if it
// already holds a token or has no device name yet (first-time setup mints it on naming).
await ensureDeviceSession();
}
// ---- 代铸 (managed device session) ----------------------------------------
// DEVICE_ID_KEY persists this browser's stable cdrop device_id (the broker `meta` /
// session<->device join key). It survives sessionStorage token loss so a re-login rotates
// the same session (broker R2 idempotency) instead of spawning a duplicate device.
const DEVICE_ID_KEY = "cdrop.device_id";
interface DeviceSessionResp
{
access_token: string;
refresh_token: string;
expires_in: number;
device_id: string;
device_name: string;
name: string;
}
// ensureDeviceSession turns a global-SSO browser (cookie identity, no token) into a
// cdrop-managed device session: the backend 代铸s a session bound to this browser's stable
// device_id, so the browser appears in the unified device list exactly like a QR-paired
// device and rides a Bearer token thereafter. Idempotent and best-effort:
// - dev / desktop / not-logged-in → skip;
// - already holds a token (QR device or a prior 代铸) → skip;
// - no device name yet → skip (first-time setup calls this again after naming);
// - on any failure the browser keeps working via the broker cookie (degraded: it just
// won't show in the device list until a later 代铸 succeeds).
export async function ensureDeviceSession(): Promise<void>
{
const st = useAppStore.getState();
if (st.authMode !== "prod" || isDesktop()) { return; }
if (!st.user) { return; }
if (st.accessToken) { return; }
if (!st.selfDeviceName) { return; }
// Serialize 代铸 across all same-origin tabs. Without this, two tabs (or a reload before the
// first mint persists device_id) each 代铸 with an EMPTY device_id, and the server mints a
// fresh id for each → duplicate phantom devices (the very regression this rework fixes). The
// first lock holder mints and persists device_id; later holders read the seeded id and the
// broker R2-rotates the one session. Web Locks coordinate across tabs; absent the API, fall
// back to a best-effort single call.
// Wrap in an arrow so the LockManager callback's Lock argument is not passed on as the
// mintDeviceSession `force` flag.
if (typeof navigator !== "undefined" && navigator.locks)
{
await navigator.locks.request("cdrop.device-session", () => mintDeviceSession());
}
else
{
await mintDeviceSession();
}
}
// renameDeviceSession re-代铸s with the (already-updated) selfDeviceName and the same stable
// device_id, so the broker label — hence the unified device list and presence — reflects the
// new name. R2 rotates the one session in place (no duplicate device row); the rotated tokens
// replace the browser's current ones. No-op unless this browser already holds a device session
// (a cookie-only browser adopts the name at its first 代铸; desktop renames in its own client).
export async function renameDeviceSession(): Promise<void>
{
const st = useAppStore.getState();
if (st.authMode !== "prod" || isDesktop()) { return; }
if (!st.user || !st.accessToken || !st.selfDeviceName) { return; }
await mintDeviceSession(true);
}
async function mintDeviceSession(force = false): Promise<void>
{
// Re-read inside the lock: another tab may have just seeded device_id (or this tab may have
// gained a token meanwhile). force=true is the rename path: re-mint even though a token is
// already held, to push the new label to the broker.
const st = useAppStore.getState();
if (!force && st.accessToken) { return; }
const deviceName = st.selfDeviceName;
if (!deviceName) { return; }
const storedId = window.localStorage.getItem(DEVICE_ID_KEY) ?? "";
let r: Response;
try
{
// Raw fetch (not apiFetch): identity rides the broker cookie, and a 401 here must
// not trigger the refresh→forceLogout path (there is no refresh token yet).
r = await fetch("/api/auth/device-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device_id: storedId,
device_name: deviceName,
device_type: "browser",
}),
});
}
catch { return; }
if (!r.ok) { return; }
const data = await r.json().catch(() => null) as DeviceSessionResp | null;
if (!data?.access_token || !data.device_id) { return; }
window.localStorage.setItem(DEVICE_ID_KEY, data.device_id);
const cur = useAppStore.getState();
if (!cur.user) { return; }
cur.setAuth({
accessToken: data.access_token,
refreshToken: data.refresh_token,
user: cur.user,
});
}
// refreshSessionScope re-reads /api/me to correct the session's capability level
// (full / guest) into the store, so a restricted guest's account-management entries
// stay hidden. Best-effort; failure falls back to "full" (entries only show, backend
// still 403s). dev has no guest concept; skip.
export async function refreshSessionScope(): Promise<void>
{
const store = useAppStore.getState();
if (store.authMode !== "prod") { return; }
if (!store.user || !store.accessToken) { return; }
if (!store.user) { return; }
const scope = await fetchMeScope();
useAppStore.getState().setSessionScope(scope);
}
// syncWebDeviceName 把本机设备名推到服务端 session(cookie 鉴权),让 PWA 存储被清
// 后开机仍能从 cookie session 恢复设备名、不再误跳 /setup。仅浏览器 prod;桌面端走
// persistDesktopDeviceNameGo 持久化),dev 无 session。失败静默——只是体验降级。
export function syncWebDeviceName(name: string): void
// syncWebDeviceName is retained as a no-op after the migration: a QR-paired device's
// name is set server-side at pairing (and renamed via the device API); a global-SSO
// browser keeps no managed device row. The local selfDeviceName still drives presence.
export function syncWebDeviceName(_name: string): void
{
if (isDesktop()) { return; }
if (useAppStore.getState().authMode !== "prod") { return; }
void fetch("/api/auth/device", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_name: name }),
}).catch(() => { /* ignore */ });
}
// ---- PKCE helpers ---------------------------------------------------------
function generateRandomBase64url(numBytes: number): string
{
const arr = new Uint8Array(numBytes);
crypto.getRandomValues(arr);
return base64urlEncode(arr);
}
async function sha256Base64url(input: string): Promise<string>
{
const buf = new TextEncoder().encode(input);
const hash = await crypto.subtle.digest("SHA-256", buf);
return base64urlEncode(new Uint8Array(hash));
}
function base64urlEncode(bytes: Uint8Array): string
{
let s = "";
for (let i = 0; i < bytes.length; i += 1) { s += String.fromCharCode(bytes[i]); }
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
/* no-op: see comment above */
}
-128
View File
@@ -1,128 +0,0 @@
// 批准页 step-up 再认证的会话级暂存。step_up 开启时,批准方在批准新设备前必须做
// 一次新鲜的 prompt=login 再登录(provider 若开了 2FA 即在此处过),把这次拿到的
// 授权码交给后端就地核验。这段往返跨一次整页跳转(→ provider → /oauth/callback →
// 批准页),故用 sessionStorage 串起三个状态:
//
// 1. 跳转去 provider 前:记下「待办」标记 + 这次的 code_verifier + 要回到的批准页
// URLPENDING)。
// 2. /oauth/callback 落地(检测到 PENDING):不调 /api/auth/exchange(那会在服务端
// 消费掉 code 并轮换会话),转而把本次回调的 {code, verifier} 暂存进 STASH
// 清掉 PENDING,整页跳回批准页 URL。
// 3. 批准页回到后:取出 STASH 的 {code, verifier} → 调 qrApprove 就地核验 → 用完即清。
//
// sessionStorage(非 localStorage):仅本次再认证往返需要,关标签即清、不跨设备;
// code 是一次性的且与本批准会话强绑定,落 localStorage 反而徒增泄漏面。
const PENDING_KEY = "cdrop.qr.stepup_pending"; // { verifier, returnTo }
const STASH_KEY = "cdrop.qr.stepup_stash"; // { code, verifier }
// 登录会话登出的 step-up:跨整页跳转记住「要登出的是哪条会话」。回到设置页后取出,
// 重试那条 DELETE。批准页的 step-up 不用它(要批准的 request 已编在 returnTo 里)。
const REVOKE_KEY = "cdrop.qr.stepup_revoke"; // sessionIdstring
// 仅允许两个站内回流目标,杜绝 open-redirect(外部 URL / 协议相对地址 / 任意路径):
// - /link[?…] ——批准页(扫码登录的 step-up);
// - /settings ——设置页(登录会话登出的 step-up)。
// step-up 往返回来后的整页跳转目标只能是这两处之一;其余一律拒。校验 path 必须以
// "/link" 或 "/settings" 起头且后随串只能是空或 "?…",挡住 "/linkmalicious" /
// "//evil.com"(协议相对)/ "/settings/../x" 之类绕过。
function isSafeReturnPath(path: string): boolean
{
for (const base of [ "/link", "/settings" ])
{
if (path === base) { return true; }
if (path.startsWith(base + "?")) { return true; }
}
return false;
}
export interface StepUpPending
{
verifier: string;
returnTo: string;
}
export interface StepUpStash
{
code: string;
verifier: string;
}
// stashStepUpPending 在跳转去 provider 前记下「待办」。returnTo 非法(防开放重定向)
// 即不写——调用方据返回值判定是否安全发起跳转。
export function stashStepUpPending(pending: StepUpPending): boolean
{
if (typeof window === "undefined") { return false; }
if (!pending.verifier || !isSafeReturnPath(pending.returnTo)) { return false; }
window.sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending));
return true;
}
// peekStepUpPending 只读地探测是否在 step-up 往返中(/oauth/callback 据此分叉,
// 决定不走 /api/auth/exchange)。不消费。
export function peekStepUpPending(): StepUpPending | null
{
if (typeof window === "undefined") { return null; }
const raw = window.sessionStorage.getItem(PENDING_KEY);
if (!raw) { return null; }
const p = safeParse<StepUpPending>(raw);
if (!p?.verifier || !isSafeReturnPath(p.returnTo)) { return null; }
return p;
}
export function clearStepUpPending(): void
{
if (typeof window === "undefined") { return; }
window.sessionStorage.removeItem(PENDING_KEY);
}
// stashStepUpCode 把回调拿到的一次性 code + 配套 verifier 暂存,留给批准页交给
// 后端 approve 核验。前端绝不自己 POST /exchange 消费它。
export function stashStepUpCode(stash: StepUpStash): void
{
if (typeof window === "undefined") { return; }
if (!stash.code || !stash.verifier) { return; }
window.sessionStorage.setItem(STASH_KEY, JSON.stringify(stash));
}
// consumeStepUpCode 取出并清除暂存的 {code, verifier}(一次性,用完即清,避免泄漏
// 与复用)。无 / 非法返回 null。
export function consumeStepUpCode(): StepUpStash | null
{
if (typeof window === "undefined") { return null; }
const raw = window.sessionStorage.getItem(STASH_KEY);
window.sessionStorage.removeItem(STASH_KEY);
if (!raw) { return null; }
const s = safeParse<StepUpStash>(raw);
if (!s?.code || !s.verifier) { return null; }
return s;
}
export function clearStepUpStash(): void
{
if (typeof window === "undefined") { return; }
window.sessionStorage.removeItem(STASH_KEY);
}
// stashPendingRevoke 在发起会话登出的 step-up 再认证前记下待登出的 sessionId,
// 跨整页跳转留到回到设置页后取出重试。
export function stashPendingRevoke(sessionId: string): void
{
if (typeof window === "undefined") { return; }
if (!sessionId) { return; }
window.sessionStorage.setItem(REVOKE_KEY, sessionId);
}
// consumePendingRevoke 取出并清除待登出的 sessionId(一次性,用完即清)。
export function consumePendingRevoke(): string | null
{
if (typeof window === "undefined") { return null; }
const id = window.sessionStorage.getItem(REVOKE_KEY);
window.sessionStorage.removeItem(REVOKE_KEY);
return id || null;
}
function safeParse<T>(raw: string): T | null
{
try { return JSON.parse(raw) as T; }
catch { return null; }
}
+93 -15
View File
@@ -27,6 +27,14 @@ import {
isDesktop,
saveIncomingFileDesktop,
} from "../../net/desktop";
import {
abortIncomingDownloadIOS,
appendIncomingDownloadIOS,
beginIncomingDownloadIOS,
finalizeIncomingDownloadIOS,
isIOSShell,
saveIncomingFileIOS,
} from "../../net/ios";
import { toast } from "../../ui/feedback";
// close() 两态结果:blob=整文件在内存,交给 downloadBlobsaved=桌面大文件已流式
@@ -45,9 +53,13 @@ export interface IncomingSink
cancel(): Promise<void>;
}
// 桌面混合 sink 的内存上限与 spill 批大小:暂存达 CAP 即转流式落盘,之后每满一批刷。
// 混合 sink 的内存上限与 spill 批大小:暂存达 CAP 即转流式落盘,之后每满一批刷。
// 桌面 RAM 充裕、上限放宽;iOS 受 jetsam 限制(无预警直接 kill 进程),更早 spill、
// 批更小,避免接收大文件时内存峰值触发 kill。
const DESKTOP_MEMORY_CAP = 256 * 1024 * 1024;
const DESKTOP_FLUSH_BATCH = 64 * 1024 * 1024;
const IOS_MEMORY_CAP = 64 * 1024 * 1024;
const IOS_FLUSH_BATCH = 16 * 1024 * 1024;
/**
* 按环境选 sink:桌面走混合有界内存槽(见文件头注释),浏览器优先 OPFS、失败回退
@@ -60,6 +72,10 @@ export async function openIncomingSink(sessionId: string, fileName: string): Pro
{
return openHybridDesktopSink(sessionId, fileName);
}
if (isIOSShell())
{
return openHybridIOSSink(sessionId, fileName);
}
// 注意:直接 navigator.storage?.getDirectory 在 TS 下永远 truthyoptional
// chaining 取的是函数引用,不会调用)。要真探测必须 try-catch 调用。
if (typeof navigator !== "undefined" && navigator.storage)
@@ -74,38 +90,55 @@ export async function openIncomingSink(sessionId: string, fileName: string): Pro
return openMemorySink();
}
// 混合有界内存槽的存储后端配置:内存上限 / spill 批大小 + 四个落盘桥方法(begin /
// append / finalize / abort,与桌面、iOS 各自的原生桥同形)。
interface StreamingSinkConfig
{
cap: number;
batch: number;
begin: (sessionId: string) => Promise<void>;
append: (sessionId: string, bytes: Uint8Array) => Promise<void>;
finalize: (sessionId: string, name: string) => Promise<string>;
abort: (sessionId: string) => Promise<void>;
}
/**
* 桌面混合有界内存槽。小文件(暂存 < CAP)纯内存累积,close 时返回 Blob 交给
* downloadBlob= 既有桌面整文件过 Go 写盘路径);一旦暂存越过 CAP,转流式落盘——
* 把已暂存的全部按 ≤批大小刷给 Go、释放内存,之后每满一批再刷,内存恒定有界。
* 混合有界内存槽(桌面与 iOS 共用,只换落盘后端)。小文件(暂存 < cap)纯内存累积,
* close 时返回 Blob 交给 downloadBlob= 整文件过原生桥写盘路径);一旦暂存越过 cap
* 转流式落盘——把已暂存的全部按 ≤批大小刷给原生、释放内存,之后每满一批再刷,内存
* 恒定有界。
*
* write() 由调用方(p2p / relay 的 receiveQueue promise 链)严格串行调用,故内部
* 无需再加锁。
*/
function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSink
function openHybridStreamingSink(
sessionId: string,
fileName: string,
cfg: StreamingSinkConfig,
): IncomingSink
{
let pending: ArrayBuffer[] = [];
let pendingBytes = 0;
let spilled = false;
let closed = false;
// 从 pending 头部取出至多 DESKTOP_FLUSH_BATCH 字节拼成一块 append 给 Go。
// drainAll=true 把剩余全部刷完(spill 起始 / close);否则只在满一批时刷。
// 从 pending 头部取出至多 cfg.batch 字节拼成一块 append 给原生。drainAll=true 把
// 剩余全部刷完(spill 起始 / close);否则只在满一批时刷。
const flush = async (drainAll: boolean): Promise<void> =>
{
while (pendingBytes >= DESKTOP_FLUSH_BATCH || (drainAll && pendingBytes > 0))
while (pendingBytes >= cfg.batch || (drainAll && pendingBytes > 0))
{
let take = 0;
const batch: ArrayBuffer[] = [];
while (pending.length > 0
&& (take === 0 || take + pending[0].byteLength <= DESKTOP_FLUSH_BATCH))
&& (take === 0 || take + pending[0].byteLength <= cfg.batch))
{
const c = pending.shift() as ArrayBuffer;
batch.push(c);
take += c.byteLength;
}
pendingBytes -= take;
await appendIncomingDownloadDesktop(sessionId, concatChunks(batch, take));
await cfg.append(sessionId, concatChunks(batch, take));
}
};
@@ -120,11 +153,11 @@ function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSin
if (!spilled)
{
if (pendingBytes >= DESKTOP_MEMORY_CAP)
if (pendingBytes >= cfg.cap)
{
// 越过内存上限:开始流式落盘,把已暂存的全部刷给 Go,转滚动批。
// 越过内存上限:开始流式落盘,把已暂存的全部刷给原生,转滚动批。
spilled = true;
await beginIncomingDownloadDesktop(sessionId);
await cfg.begin(sessionId);
await flush(true);
}
return;
@@ -141,7 +174,7 @@ function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSin
return { kind: "blob", blob: new Blob(pending) };
}
await flush(true);
const path = await finalizeIncomingDownloadDesktop(sessionId, fileName);
const path = await cfg.finalize(sessionId, fileName);
return { kind: "saved", path };
},
async cancel()
@@ -149,11 +182,37 @@ function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSin
closed = true;
pending = [];
pendingBytes = 0;
if (spilled) { await abortIncomingDownloadDesktop(sessionId); }
if (spilled) { await cfg.abort(sessionId); }
},
};
}
// 桌面后端(Wails → Go)。
function openHybridDesktopSink(sessionId: string, fileName: string): IncomingSink
{
return openHybridStreamingSink(sessionId, fileName, {
cap: DESKTOP_MEMORY_CAP,
batch: DESKTOP_FLUSH_BATCH,
begin: beginIncomingDownloadDesktop,
append: appendIncomingDownloadDesktop,
finalize: finalizeIncomingDownloadDesktop,
abort: abortIncomingDownloadDesktop,
});
}
// iOS 后端(WKWebView 桥 → 原生沙盒)。内存上限更小,避免 jetsam(见 IOS_MEMORY_CAP)。
function openHybridIOSSink(sessionId: string, fileName: string): IncomingSink
{
return openHybridStreamingSink(sessionId, fileName, {
cap: IOS_MEMORY_CAP,
batch: IOS_FLUSH_BATCH,
begin: beginIncomingDownloadIOS,
append: appendIncomingDownloadIOS,
finalize: finalizeIncomingDownloadIOS,
abort: abortIncomingDownloadIOS,
});
}
// concatChunks 把若干 ArrayBuffer 拼成一个 Uint8Array(供 base64 过桥)。
function concatChunks(parts: ArrayBuffer[], totalBytes: number): Uint8Array
{
@@ -275,6 +334,25 @@ export function downloadBlob(blob: Blob, fileName: string, sessionId?: string):
});
return;
}
// iOS 无头壳同理:整文件经桥交原生写入沙盒下载目录;WKWebView 无浏览器下载回退,
// 故失败必须显式报错、绝不静默丢文件(同桌面)。
if (isIOSShell())
{
void saveIncomingFileIOS(fileName, blob)
.then((savedPath) =>
{
toast.ok(t("transfer.savedTo", { path: savedPath }));
if (sessionId) { void cleanupOPFS(sessionId); }
})
.catch((e) =>
{
// eslint-disable-next-line no-console
console.error("ios save failed", e);
toast.error(t("transfer.saveFailed", { error: e instanceof Error ? e.message : String(e) }));
if (sessionId) { void cleanupOPFS(sessionId); }
});
return;
}
browserDownload(blob, fileName, sessionId);
}
+54 -33
View File
@@ -23,6 +23,11 @@ const CHANNEL_NAME = "cdrop-file";
const CHUNK_SIZE = 64 * 1024;
const HIGH_WATERMARK = 16 * 1024 * 1024;
const LOW_WATERMARK = 4 * 1024 * 1024;
// 发送端「读写解耦」:一次读 4 MiB 进内存,再从内存切 CHUNK_SIZE 子片 dc.send。原先
// 每 64 KiB 都 await file.slice().arrayBuffer(),逐片异步文件读在 iOS Safari 上极慢(实测
// 直连 P2P 仅 ~250 KB/s,远低于中继 3 MB/s)。块读把异步文件读次数降 ~64 倍,SCTP 消息
// 大小与流控(HIGH/LOW watermark)完全不变,接收端无感。
const READ_BLOCK_SIZE = 4 * 1024 * 1024;
const ICE_CANDIDATE_POOL = 4;
export interface FileMeta
@@ -498,49 +503,65 @@ class Session
// "已交付字节"随接收端实际收程平滑爬升、lastProgressAt 保持新鲜,不误报"疑似卡住"。
this.startSendProgressPoll();
// 发送块大小固定 64 KiB:实测放大到 256 KiB 在 iOS Safari 上反而把吞吐打到
// <100 KB/sSCTP 大消息分片/重组代价),故维持 CHUNK_SIZE。读写解耦(块读)保留。
const sendChunk = CHUNK_SIZE;
let offset = 0;
while (offset < file.size)
{
// 预测式检查:在 send 之前看"加了这一片之后会不会撞上 HIGH"
// Chrome WebRTC 的 RTCDataChannel.send 在 bufferedAmount + size 超过
// kMaxQueuedSendDataBytes16 MiB)时直接抛 OperationError;我们 HIGH
// 也设 16 MiB,原本的 `bufferedAmount > HIGH` 检查在 buffer = HIGH - 1
// 时不触发,下一片就会跨过硬上限挂掉
if (dc.bufferedAmount + CHUNK_SIZE > HIGH_WATERMARK)
// 一次读一大块进内存(异步文件读从每 64 KiB 一次降到每 4 MiB 一次)
const blockEnd = Math.min(offset + READ_BLOCK_SIZE, file.size);
const block = new Uint8Array(await file.slice(offset, blockEnd).arrayBuffer());
// 从内存块按 sendChunk 子片发送;流控(bufferedAmount/水位)逻辑不变
let inBlock = 0;
while (inBlock < block.byteLength)
{
await waitForBufferLow(dc, LOW_WATERMARK);
}
const slice = file.slice(offset, Math.min(offset + CHUNK_SIZE, file.size));
const buf = await slice.arrayBuffer();
try
{
dc.send(buf);
}
catch (e)
{
// 兜底:极端情况下 bufferedAmount 在 await 与 send 之间漂移,仍可能
// 撞到 OperationError。退一步等 buffer 抽干到 LOW 再重试一次;仍失
// 败说明通道本身有问题,让外层 .catch 走 markServerFail。
if (e instanceof DOMException && e.name === "OperationError")
// 预测式检查:在 send 之前看"加了这一片之后会不会撞上 HIGH"。
// Chrome WebRTC 的 RTCDataChannel.send 在 bufferedAmount + size 超过
// kMaxQueuedSendDataBytes16 MiB)时直接抛 OperationError;我们 HIGH
// 也设 16 MiB,原本的 `bufferedAmount > HIGH` 检查在 buffer = HIGH - 1
// 时不触发,下一片就会跨过硬上限挂掉。
if (dc.bufferedAmount + sendChunk > HIGH_WATERMARK)
{
// eslint-disable-next-line no-console
console.warn("p2p send queue full, backing off then retrying", {
bufferedAmount: dc.bufferedAmount,
readyState: dc.readyState,
bytesSent: this.bytesSent,
});
await waitForBufferLow(dc, LOW_WATERMARK);
dc.send(buf);
}
else
const end = Math.min(inBlock + sendChunk, block.byteLength);
// subarray 是零拷贝视图;dc.send 接受 ArrayBufferView,只发该视图字节范围。
const chunk = block.subarray(inBlock, end);
try
{
throw e;
dc.send(chunk);
}
catch (e)
{
// 兜底:极端情况下 bufferedAmount 在 await 与 send 之间漂移,仍可能
// 撞到 OperationError。退一步等 buffer 抽干到 LOW 再重试一次;仍失
// 败说明通道本身有问题,让外层 .catch 走 markServerFail。
if (e instanceof DOMException && e.name === "OperationError")
{
// eslint-disable-next-line no-console
console.warn("p2p send queue full, backing off then retrying", {
bufferedAmount: dc.bufferedAmount,
readyState: dc.readyState,
bytesSent: this.bytesSent,
});
await waitForBufferLow(dc, LOW_WATERMARK);
dc.send(chunk);
}
else
{
throw e;
}
}
const sent = end - inBlock;
inBlock += sent;
offset += sent;
this.bytesSent = offset;
if (!this.firstByteSent) { this.firstByteSent = true; this.setPhase("transferring"); }
this.emitProgress();
}
offset += buf.byteLength;
this.bytesSent = offset;
if (!this.firstByteSent) { this.firstByteSent = true; this.setPhase("transferring"); }
this.emitProgress();
}
dc.send(JSON.stringify({ type: "done" }));
+66
View File
@@ -145,6 +145,8 @@ export const enUS: Partial<TranslationDict> = {
"qr.approve.once.hint": "Signed in for 1 hour, then scan again.",
"qr.approve.scopeFull": "This device signs fully into your account for 7 days: it can send and receive files and messages, and manage your account and devices.",
"qr.approve.scopeGuest": "Signs in as a limited guest — it can send and receive files and messages, but can't change your account or approve other devices; expires after 1 hour.",
"qr.approve.nativeFull": "This is a native app device — it signs in with full access (trusted, valid for 7 days). Native apps don't support limited guest sessions.",
"qr.approve.trustContinue": "Trust & Continue",
"qr.approve.approve": "Approve sign-in",
"qr.approve.stepUp.notice": "For security, verify your identity before approving — the button below takes you through a fresh sign-in.",
"qr.approve.stepUp.button": "Verify and approve",
@@ -343,4 +345,68 @@ export const enUS: Partial<TranslationDict> = {
"errors.clipboardUnavailable": "Browser clipboard API unavailable (requires HTTPS + permission)",
"errors.clipboardOverflow": "Content too large; exceeds {{max}} byte limit",
"errors.clipboardWriteFailed": "Failed to write to local clipboard: {{message}}",
// ---- iOS client ------------------------------------------------------
"ios.tab.transfer": "Transfers",
"ios.tab.devices": "Devices",
"ios.tab.settings": "Settings",
"ios.login.generating": "Generating code…",
"ios.login.waiting": "Waiting for approval",
"ios.login.guide": "Scan to approve from another signed-in device",
"ios.login.expired": "Code expired — please try again",
"ios.login.failed": "Sign-in failed — please try again",
"ios.login.refresh": "Refresh QR Code",
"ios.login.needFull": "This device needs full access — choose \"Trust this device\" when approving.",
"ios.send.pickDevice": "Choose a device",
"ios.transfer.sending": "Sending",
"ios.settings.engine": "Engine",
"ios.settings.status": "Status",
"ios.settings.device": "Device",
"ios.settings.clipboard": "Clipboard",
"ios.settings.clipboardNote": "Upload pushes this device's clipboard to your others; Pull writes the latest cloud clipboard.",
"ios.clipboard.upload": "Upload Clipboard",
"ios.clipboard.pull": "Pull Clipboard",
"ios.clipboard.uploading": "Uploading…",
"ios.clipboard.pulling": "Pulling…",
"ios.clipboard.uploaded": "Uploaded to cloud clipboard",
"ios.clipboard.pulled": "Written to clipboard",
"ios.clipboard.empty": "Clipboard is empty",
"ios.clipboard.failed": "Clipboard sync failed",
"ios.devices.remove": "Remove Device",
"ios.devices.thisDevice": "This device",
"ios.devices.revoked": "Device removed",
"ios.devices.revokeFailed": "Remove failed",
"ios.devices.revokeStepUp": "Re-verify on the web to remove this device",
"ios.engine.disconnected": "Disconnected",
"ios.engine.ready": "Ready",
"ios.transfer.empty": "No transfers yet",
"ios.transfer.incoming": "Receive",
"ios.transfer.outgoing": "Send",
"ios.send.noDevices": "No online devices to send to",
"ios.devices.empty": "No devices yet",
"ios.detail.title": "Transfer Details",
"ios.detail.direction": "Direction",
"ios.detail.peer": "Peer",
"ios.detail.state": "State",
"ios.detail.phase": "Phase",
"ios.detail.channel": "Channel",
"ios.detail.size": "Size",
"ios.detail.progress": "Progress",
"ios.detail.speed": "Speed",
"ios.settings.account": "Account",
"ios.settings.user": "User",
"ios.settings.logout": "Sign Out",
"ios.settings.deviceName": "Device Name",
"ios.settings.deviceNameNote": "Renaming takes effect on next sign-in",
"ios.settings.deviceCount": "Known Devices",
"ios.settings.signaling": "Signaling",
"ios.settings.presenceEvents": "Presence Events",
"ios.settings.reconnecting": "Reconnecting",
"ios.settings.lastLog": "Last Log",
"ios.tab.files": "Files",
"ios.files.title": "Received Files",
"ios.files.empty": "No files received yet",
"ios.files.share": "Share",
"ios.files.done": "Done",
"ios.detail.openFile": "Open File",
};
+66
View File
@@ -142,6 +142,8 @@ export const zhCN = {
"qr.approve.once.hint": "登录有效 1 小时,到期需重新扫码。",
"qr.approve.scopeFull": "这台设备将完整登录你的账号、7 天内有效:可正常收发文件与消息,并能管理账号与设备。",
"qr.approve.scopeGuest": "以受限访客身份登录——可收发文件与消息,但不能更改账号、不能批准其他设备;1 小时后失效。",
"qr.approve.nativeFull": "这是一台原生应用设备,将以完整权限登录(信任此设备、7 天内有效)。原生应用不支持受限访客。",
"qr.approve.trustContinue": "信任并继续",
"qr.approve.approve": "批准登录",
"qr.approve.stepUp.notice": "为安全起见,批准前需先验证你的身份——点下方按钮会引导你重新登录一次。",
"qr.approve.stepUp.button": "验证并批准",
@@ -339,4 +341,68 @@ export const zhCN = {
"errors.clipboardUnavailable": "浏览器不支持剪贴板API(需HTTPS与权限)",
"errors.clipboardOverflow": "内容过大,超过 {{max}} 字节上限",
"errors.clipboardWriteFailed": "写入本机剪贴板失败:{{message}}",
// ---- iOS 客户端 ------------------------------------------------------
"ios.tab.transfer": "传输",
"ios.tab.devices": "设备",
"ios.tab.settings": "设置",
"ios.login.generating": "正在生成二维码…",
"ios.login.waiting": "等待扫码批准",
"ios.login.guide": "用另一台已登录的设备扫码批准",
"ios.login.expired": "二维码已失效,请重试",
"ios.login.failed": "登录失败,请重试",
"ios.login.refresh": "刷新二维码",
"ios.login.needFull": "此设备需要完整权限,批准时请选择“信任此设备”",
"ios.send.pickDevice": "选择接收设备",
"ios.transfer.sending": "发送中",
"ios.settings.engine": "引擎",
"ios.settings.status": "状态",
"ios.settings.device": "设备",
"ios.settings.clipboard": "剪贴板",
"ios.settings.clipboardNote": "上传把本机剪贴板推到其他设备;拉取写入最新云剪贴板。",
"ios.clipboard.upload": "上传剪贴板",
"ios.clipboard.pull": "拉取剪贴板",
"ios.clipboard.uploading": "正在上传…",
"ios.clipboard.pulling": "正在拉取…",
"ios.clipboard.uploaded": "已上传到云剪贴板",
"ios.clipboard.pulled": "已写入本机剪贴板",
"ios.clipboard.empty": "剪贴板为空",
"ios.clipboard.failed": "剪贴板同步失败",
"ios.devices.remove": "移除设备",
"ios.devices.thisDevice": "本机",
"ios.devices.revoked": "已移除设备",
"ios.devices.revokeFailed": "移除失败",
"ios.devices.revokeStepUp": "需在网页端重新验证后才能移除",
"ios.engine.disconnected": "未连接",
"ios.engine.ready": "已就绪",
"ios.transfer.empty": "暂无传输",
"ios.transfer.incoming": "接收",
"ios.transfer.outgoing": "发送",
"ios.send.noDevices": "没有在线设备,无法发送",
"ios.devices.empty": "暂无设备",
"ios.detail.title": "传输详情",
"ios.detail.direction": "方向",
"ios.detail.peer": "对端",
"ios.detail.state": "状态",
"ios.detail.phase": "阶段",
"ios.detail.channel": "通道",
"ios.detail.size": "大小",
"ios.detail.progress": "进度",
"ios.detail.speed": "速度",
"ios.settings.account": "账户",
"ios.settings.user": "用户",
"ios.settings.logout": "退出登录",
"ios.settings.deviceName": "设备名称",
"ios.settings.deviceNameNote": "改名将在下次登录后生效",
"ios.settings.deviceCount": "已知设备",
"ios.settings.signaling": "信令连接",
"ios.settings.presenceEvents": "在线事件",
"ios.settings.reconnecting": "重连中",
"ios.settings.lastLog": "最近日志",
"ios.tab.files": "文件",
"ios.files.title": "收到的文件",
"ios.files.empty": "还没有收到文件",
"ios.files.share": "分享",
"ios.files.done": "完成",
"ios.detail.openFile": "打开文件",
} as const;
+66
View File
@@ -146,6 +146,8 @@ export const zhTW: Partial<TranslationDict> = {
"qr.approve.once.hint": "登入有效 1 小時,逾時需重新掃碼。",
"qr.approve.scopeFull": "這部裝置將完整登入你的帳號、7 天內有效:可正常收發檔案與訊息,並能管理帳號與裝置。",
"qr.approve.scopeGuest": "以受限訪客身分登入——可收發檔案與訊息,但不能變更帳號、不能批准其他裝置;1 小時後失效。",
"qr.approve.nativeFull": "這是一台原生應用裝置,將以完整權限登入(信任此裝置、7 天內有效)。原生應用不支援受限訪客。",
"qr.approve.trustContinue": "信任並繼續",
"qr.approve.approve": "批准登入",
"qr.approve.stepUp.notice": "為安全起見,批准前需先驗證你的身分——點下方按鈕會引導你重新登入一次。",
"qr.approve.stepUp.button": "驗證並批准",
@@ -343,4 +345,68 @@ export const zhTW: Partial<TranslationDict> = {
"errors.clipboardUnavailable": "瀏覽器不支援剪貼簿API(需HTTPS與權限)",
"errors.clipboardOverflow": "內容過大,超過 {{max}} 位元組上限",
"errors.clipboardWriteFailed": "寫入本機剪貼簿失敗:{{message}}",
// ---- iOS 客戶端 ------------------------------------------------------
"ios.tab.transfer": "傳輸",
"ios.tab.devices": "裝置",
"ios.tab.settings": "設定",
"ios.login.generating": "正在產生 QR 碼…",
"ios.login.waiting": "等待掃碼核准",
"ios.login.guide": "用另一台已登入的裝置掃碼核准",
"ios.login.expired": "QR 碼已失效,請重試",
"ios.login.failed": "登入失敗,請重試",
"ios.login.refresh": "重新整理 QR 碼",
"ios.login.needFull": "此裝置需要完整權限,批准時請選擇「信任此裝置」",
"ios.send.pickDevice": "選擇接收裝置",
"ios.transfer.sending": "傳送中",
"ios.settings.engine": "引擎",
"ios.settings.status": "狀態",
"ios.settings.device": "裝置",
"ios.settings.clipboard": "剪貼簿",
"ios.settings.clipboardNote": "上傳把本機剪貼簿推到其他裝置;拉取寫入最新雲端剪貼簿。",
"ios.clipboard.upload": "上傳剪貼簿",
"ios.clipboard.pull": "拉取剪貼簿",
"ios.clipboard.uploading": "正在上傳…",
"ios.clipboard.pulling": "正在拉取…",
"ios.clipboard.uploaded": "已上傳到雲端剪貼簿",
"ios.clipboard.pulled": "已寫入本機剪貼簿",
"ios.clipboard.empty": "剪貼簿是空的",
"ios.clipboard.failed": "剪貼簿同步失敗",
"ios.devices.remove": "移除裝置",
"ios.devices.thisDevice": "本機",
"ios.devices.revoked": "已移除裝置",
"ios.devices.revokeFailed": "移除失敗",
"ios.devices.revokeStepUp": "需在網頁端重新驗證後才能移除",
"ios.engine.disconnected": "未連線",
"ios.engine.ready": "已就緒",
"ios.transfer.empty": "尚無傳輸",
"ios.transfer.incoming": "接收",
"ios.transfer.outgoing": "傳送",
"ios.send.noDevices": "沒有上線裝置,無法傳送",
"ios.devices.empty": "尚無裝置",
"ios.detail.title": "傳輸詳情",
"ios.detail.direction": "方向",
"ios.detail.peer": "對端",
"ios.detail.state": "狀態",
"ios.detail.phase": "階段",
"ios.detail.channel": "通道",
"ios.detail.size": "大小",
"ios.detail.progress": "進度",
"ios.detail.speed": "速度",
"ios.settings.account": "帳戶",
"ios.settings.user": "使用者",
"ios.settings.logout": "登出",
"ios.settings.deviceName": "裝置名稱",
"ios.settings.deviceNameNote": "改名將在下次登入後生效",
"ios.settings.deviceCount": "已知裝置",
"ios.settings.signaling": "信令連線",
"ios.settings.presenceEvents": "上線事件",
"ios.settings.reconnecting": "重新連線中",
"ios.settings.lastLog": "最近日誌",
"ios.tab.files": "檔案",
"ios.files.title": "收到的檔案",
"ios.files.empty": "尚未收到檔案",
"ios.files.share": "分享",
"ios.files.done": "完成",
"ios.detail.openFile": "開啟檔案",
};
+5 -3
View File
@@ -79,11 +79,13 @@ async function doFetch(input: RequestInfo, init?: RequestInit): Promise<Response
}
else
{
if (!state.accessToken)
// A QR-paired device sends its broker access token as Bearer. A global-SSO
// browser has none — the broker's domain cookie carries identity, so we send
// no Authorization header and let the edge inject X-Auth from that cookie.
if (state.accessToken)
{
throw new Error(t("errors.noAccessToken"));
headers.set("Authorization", `Bearer ${state.accessToken}`);
}
headers.set("Authorization", `Bearer ${state.accessToken}`);
}
if (state.selfDeviceName)
+179
View File
@@ -0,0 +1,179 @@
// iOS 原生壳的桥接层:仅在 iOS WKWebView「无头引擎」壳中可用。浏览器 / 桌面里
// isIOSShell() 返回 false,所有调用点回退既有 Web 行为,因此本模块对纯 Web 与桌面
// 构建零影响——同一份引擎代码同时部署到浏览器、桌面与 iOS 无头壳。
//
// 方向与桌面(Wails)相反,是本模块存在的根由:
// - 桌面:React UI 在 WebView 顶层,调 DOWN 到 Go 绑定方法(window.go.main.App.*
// 直接返回 Promise)。
// - iOSarch A,见 ios/PLAN.md §2):原生 SwiftUI 在顶层,本 WebView 是「无头
// 传输引擎」。JS→原生只能 postMessage 到 WKScriptMessageHandler(无返回值),
// 原生→JS 用 evaluateJavaScript 回调全局函数。故 callNative 以「请求 id + 全局
// resolver」把单向 postMessage 拼回请求 / 响应语义。
//
// 原生侧须实现的契约(Swift 实现据此对齐):
// - 注册名为 "cdropEngine" 的 WKScriptMessageHandler,收 { id, method, payload }。
// - 处理完一次 callNative 后,evaluateJavaScript 调
// window.__cdropEngineResolve(id, ok, value)ok=false 时 value 为错误串)。
// - 推事件(进度 / 状态 / 传入 offer 等)用 evaluateJavaScript 调
// window.__cdropEngineEvent(name, payload)。
// - 接收落盘四方法 beginDownload / appendDownload / finalizeDownload /
// abortDownload 与桌面 desktop.ts 同形(data 为 base64),落 iOS 沙盒文件。
// WKScriptMessageHandler 的 JS 侧投递接口(仅 postMessage)。
interface WebKitMessageHandler
{
postMessage: (msg: unknown) => void;
}
// 原生注册的引擎消息处理器。其在场即判定为 iOS 无头壳。
function handler(): WebKitMessageHandler | null
{
const wk = (window as unknown as {
webkit?: { messageHandlers?: { cdropEngine?: WebKitMessageHandler } };
}).webkit;
return wk?.messageHandlers?.cdropEngine ?? null;
}
// isIOSShell 仅要求消息处理器在场——原生注入它即代表无头壳就绪。浏览器 / 桌面里
// 不存在 window.webkit.messageHandlers.cdropEngine,故恒为 false。
export function isIOSShell(): boolean
{
return typeof window !== "undefined" && handler() !== null;
}
// ── 请求 / 响应关联(callNative) ──────────────────────────────────────────
interface Pending
{
resolve: (value: unknown) => void;
reject: (err: Error) => void;
}
const pending = new Map<number, Pending>();
let nextRequestId = 1;
// 安装全局 resolver(幂等):原生完成一次调用后经 evaluateJavaScript 调它回交结果。
function installResolver(): void
{
const w = window as unknown as {
__cdropEngineResolve?: (id: number, ok: boolean, value: unknown) => void;
};
if (w.__cdropEngineResolve) { return; }
w.__cdropEngineResolve = (id, ok, value) =>
{
const p = pending.get(id);
if (!p) { return; }
pending.delete(id);
if (ok) { p.resolve(value); }
else { p.reject(new Error(typeof value === "string" ? value : "native error")); }
};
}
// callNative 投递一次 { id, method, payload } 给原生,返回待原生回调 resolve 的
// Promise。桥缺失(非 iOS 壳)直接 reject——调用点应先以 isIOSShell() 守卫。
function callNative<T>(method: string, payload?: unknown): Promise<T>
{
const h = handler();
if (!h) { return Promise.reject(new Error("ios bridge unavailable")); }
installResolver();
const id = nextRequestId;
nextRequestId += 1;
return new Promise<T>((resolve, reject) =>
{
pending.set(id, { resolve: resolve as (value: unknown) => void, reject });
h.postMessage({ id, method, payload });
});
}
// notifyNative 单向投递一条通知给原生(进度 / 状态 / 就绪 / 错误等,无需回执)。原生
// 据消息形状区分:含 id 为 callNative 请求、含 notify 为单向通知。非 iOS 壳为 no-op,
// 故引擎可无条件调用、无需在每个发射点加 isIOSShell() 分支。
export function notifyNative(name: string, payload?: unknown): void
{
const h = handler();
if (!h) { return; }
h.postMessage({ notify: name, payload });
}
// ── 原生 → JS 事件 ─────────────────────────────────────────────────────────
type EventHandler = (payload: unknown) => void;
const eventHandlers = new Map<string, Set<EventHandler>>();
// 安装全局事件入口(幂等):原生用 evaluateJavaScript 调它派发事件。
function installEventSink(): void
{
const w = window as unknown as {
__cdropEngineEvent?: (name: string, payload: unknown) => void;
};
if (w.__cdropEngineEvent) { return; }
w.__cdropEngineEvent = (name, payload) =>
{
const set = eventHandlers.get(name);
if (!set) { return; }
for (const fn of set) { fn(payload); }
};
}
// onNativeEvent 订阅一类原生事件,返回取消订阅函数。非 iOS 壳里仍可调用(只是原生
// 永不派发),保持调用点无需分支。
export function onNativeEvent(name: string, fn: EventHandler): () => void
{
installEventSink();
let set = eventHandlers.get(name);
if (!set)
{
set = new Set();
eventHandlers.set(name, set);
}
set.add(fn);
return () => { set?.delete(fn); };
}
// ── 接收落盘桥(与桌面 desktop.ts 同形,落 iOS 沙盒) ──────────────────────
// bytesToBase64 分块走 btoa,避免一次 String.fromCharCode(...大数组) 触发调用栈溢出。
// 与 desktop.ts 同实现;两边各持一份以保持桥模块互不依赖。
function bytesToBase64(bytes: Uint8Array): string
{
let binary = "";
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK)
{
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
}
// saveIncomingFileIOS:整文件(接收端小文件路径)经 base64 过桥交原生写入沙盒下载
// 目录,返回最终绝对路径。桥缺失或写盘失败抛错,由调用方回退。
export async function saveIncomingFileIOS(name: string, blob: Blob): Promise<string>
{
const buf = await blob.arrayBuffer();
return callNative<string>("saveDownload", { name, data: bytesToBase64(new Uint8Array(buf)) });
}
// 流式落盘封装:接收端混合 sink 越过内存上限后,把溢出批次交原生写临时文件,finalize
// 时改名落定。与桌面 Begin/Append/Finalize/Abort 同语义(见 incomingSink.ts)。
export async function beginIncomingDownloadIOS(sessionId: string): Promise<void>
{
await callNative<void>("beginDownload", { sessionId });
}
export async function appendIncomingDownloadIOS(sessionId: string, bytes: Uint8Array): Promise<void>
{
await callNative<void>("appendDownload", { sessionId, data: bytesToBase64(bytes) });
}
export async function finalizeIncomingDownloadIOS(sessionId: string, name: string): Promise<string>
{
return callNative<string>("finalizeDownload", { sessionId, name });
}
export async function abortIncomingDownloadIOS(sessionId: string): Promise<void>
{
if (!handler()) { return; }
try { await callNative<void>("abortDownload", { sessionId }); }
catch { /* 取消 / 失败路径,吞掉 */ }
}
+9 -34
View File
@@ -67,8 +67,10 @@ export interface QrApprovedUser
export interface QrStatus
{
status: QrStatusPhase;
// status === "approved" 时一并带回:
// status === "approved" 时一并带回 broker 令牌对 + 设备身份
accessToken?: string;
refreshToken?: string;
deviceId?: string;
expiresIn?: number;
user?: QrApprovedUser;
deviceName?: string;
@@ -78,6 +80,8 @@ interface QrStatusRaw
{
status: QrStatusPhase;
access_token?: string;
refresh_token?: string;
device_id?: string;
expires_in?: number;
user?: QrApprovedUser;
device_name?: string;
@@ -102,6 +106,8 @@ async function qrStatusOnce(
return {
status: data.status,
accessToken: data.access_token,
refreshToken: data.refresh_token,
deviceId: data.device_id,
expiresIn: data.expires_in,
user: data.user,
deviceName: data.device_name,
@@ -174,9 +180,6 @@ export interface QrRequestInfo
requestIp: string;
expiresAt: number;
status: QrStatusPhase;
// step_up 开启时为 true:批准前必须做一次新鲜的 prompt=login 再认证,把这次
// 拿到的授权码 + verifier 交给 approve 就地核验(见 features/qr/stepUp.ts)。
stepUp: boolean;
}
interface QrRequestInfoRaw
@@ -186,7 +189,6 @@ interface QrRequestInfoRaw
request_ip: string;
expires_at: number;
status: QrStatusPhase;
step_up?: boolean;
}
// qrRequest 取待批准设备的信息,批准页据此渲染安全确认。需完整登录(apiFetch 带
@@ -202,35 +204,17 @@ export async function qrRequest(requestId: string, code: string): Promise<QrRequ
requestIp: data.request_ip,
expiresAt: data.expires_at,
status: data.status,
stepUp: data.step_up === true,
};
}
// stepUpRequired 标记 approve 因 step-up 再认证缺失 / 过期被后端拒(403
// {error:"step_up_required"})。批准页据此把用户带回再认证一遭,而非当成普通错误。
export class StepUpRequiredError extends Error
{
constructor()
{
super("step_up_required");
this.name = "StepUpRequiredError";
}
}
// qrApprove 批准该设备登入本账号。scope 决定授予的会话级别:full=完整登录、
// guest=受限访客;与 persist 时长一一对应(full+persist=7 天滑动、guest+once=1 小时)。
// 成功 204
//
// step_up 开启时须带上一次新鲜 prompt=login 再认证拿到的 stepUpCode + stepUpVerifier
// 后端就地向 IdP 换 id_token、验签 + 校 auth_time 新鲜 + sub 匹配;缺失 / 过期 →
// 403 step_up_required(抛 StepUpRequiredError)。step_up 关时这俩字段被后端忽略。
// guest=受限访客(经 broker 委托签发对应 tier 的会话)。成功 204。step-up 二次认证
// 已移除(full 档即视为可信,路由层 requireFullSession 守门)
export async function qrApprove(
requestId: string,
code: string,
scope: QrScope,
persist: QrPersist,
stepUpCode?: string,
stepUpVerifier?: string,
): Promise<void>
{
const body: Record<string, string> = {
@@ -239,11 +223,6 @@ export async function qrApprove(
scope,
persist,
};
if (stepUpCode && stepUpVerifier)
{
body.step_up_code = stepUpCode;
body.step_up_verifier = stepUpVerifier;
}
const r = await apiFetch("/api/auth/qr/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -252,10 +231,6 @@ export async function qrApprove(
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
if (r.status === 403 && /step_up_required/.test(text))
{
throw new StepUpRequiredError();
}
throw new Error(`qr approve ${r.status}: ${text}`);
}
}
+7 -53
View File
@@ -33,11 +33,11 @@ export async function fetchMeScope(): Promise<SessionScopeValue>
// 会话「种类」:oidc / self / guest 为网页 / 扫码会话;macos / windows / linux / ios
// 为原生客户端(桌面 / 移动)——它们用 IdP 令牌、不入 web_sessions,由设备登记表呈现。
export type SessionKind =
| "oidc" | "self" | "guest"
| "browser"
| "macos" | "windows" | "linux" | "ios";
const KNOWN_KINDS: readonly SessionKind[] = [
"oidc", "self", "guest", "macos", "windows", "linux", "ios",
"browser", "macos", "windows", "linux", "ios",
];
export interface AuthSession
@@ -50,25 +50,21 @@ export interface AuthSession
current: boolean;
// online=true:该设备当前在线(有活跃 SSE 连接)。用于列表排序与在线指示。
online: boolean;
// native=true:原生客户端(无 web_session),登出走设备登记端点而非会话端点。
native: boolean;
createdAt: number;
lastUsedAt: number;
expiresAt: number;
}
interface AuthSessionRaw
{
id: string;
device_id?: string;
device_name?: string;
kind?: string;
scope?: string;
current?: boolean;
online?: boolean;
native?: boolean;
created_at?: number;
last_used_at?: number;
expires_at?: number;
}
interface SessionsResp
@@ -78,7 +74,7 @@ interface SessionsResp
function normalizeKind(kind: string | undefined): SessionKind
{
return KNOWN_KINDS.includes(kind as SessionKind) ? (kind as SessionKind) : "oidc";
return KNOWN_KINDS.includes(kind as SessionKind) ? (kind as SessionKind) : "browser";
}
// listSessions 拉取当前账号下的全部登录会话(含本机),每条带 online(当前是否在线)。
@@ -96,61 +92,19 @@ export async function listSessions(): Promise<AuthSession[]>
scope: r.scope === "guest" ? "guest" : "full",
current: r.current === true,
online: r.online === true,
native: r.native === true,
createdAt: r.created_at ?? 0,
lastUsedAt: r.last_used_at ?? 0,
expiresAt: r.expires_at ?? 0,
}));
}
// StepUpRequiredErrorDELETE /api/auth/sessions/{id} 因再认证缺失 / 过期被后端拒
// 403 {error:"step_up_required"})。调用方据此发起一次 step-up 再认证而非当成
// 普通错误。
export class StepUpRequiredError extends Error
{
constructor()
{
super("step_up_required");
this.name = "StepUpRequiredError";
}
}
// revokeSession 真正吊销一条登录会话。网页 / 扫码会话走 /auth/sessions/{id}(删会话
// 行 + 连带删设备);原生客户端(id 形如 "device:设备名")无 web_session,改走
// /devices/{name} 按设备名注销。两者均:成功 204;后端要求再认证时抛
// StepUpRequiredError403 step_up_required),其余非 2xx 抛普通错误。
const NATIVE_ID_PREFIX = "device:";
// revokeSession 吊销一条会话(= 一台设备)DELETE /api/auth/sessions/{id}id=device_id)。
// 后端连带 broker 吊销该设备的会话 + 删本地设备行。成功 204。step-up 二次认证已移除。
export async function revokeSession(id: string): Promise<void>
{
const path = id.startsWith(NATIVE_ID_PREFIX)
? `/api/devices/${encodeURIComponent(id.slice(NATIVE_ID_PREFIX.length))}`
: `/api/auth/sessions/${encodeURIComponent(id)}`;
const r = await apiFetch(path, { method: "DELETE" });
const r = await apiFetch(`/api/auth/sessions/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
if (r.status === 403 && /step_up_required/.test(text))
{
throw new StepUpRequiredError();
}
throw new Error(`revoke session ${r.status}: ${text}`);
}
}
// postStepUp 把一次新鲜 prompt=login 再认证拿到的 {code, verifier} 交给后端就地
// 核验,成功后服务端在本会话记下 stepped_up_at(5 分钟窗口内复用,不再反复要求)。
// 成功 204step-up 未开后端亦 204(视作通过);失败 403。
export async function postStepUp(code: string, verifier: string): Promise<void>
{
const r = await apiFetch("/api/auth/stepup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, verifier }),
});
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`step-up ${r.status}: ${text}`);
}
}
+1 -2
View File
@@ -339,8 +339,7 @@ function isAuthRoute(pathname: string): boolean
|| pathname === "/setup"
|| pathname === "/link"
|| pathname === "/link/new"
|| pathname === "/link/scan"
|| pathname.startsWith("/oauth/");
|| pathname === "/link/scan";
}
function initial(name: string): string
+47 -93
View File
@@ -2,12 +2,11 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertTriangle, Check, Clock, LogIn, ShieldCheck, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { AuthShell } from "../features/auth/AuthShell";
import { loginProd, startStepUpReauth } from "../features/auth/auth";
import { loginRedirect } from "../features/auth/auth";
import { stashLinkReturn } from "../features/qr/returnTo";
import { consumeStepUpCode, type StepUpStash } from "../features/qr/stepUp";
import { isDesktop, startDesktopLogin } from "../net/desktop";
import {
qrApprove, qrDeny, qrRequest, StepUpRequiredError,
qrApprove, qrDeny, qrRequest,
type QrPersist, type QrRequestInfo,
} from "../net/qr";
import { useAppStore } from "../store";
@@ -44,12 +43,8 @@ function LinkApprovePage()
const [ submitting, setSubmitting ] = useState(false);
const [ outcome, setOutcome ] = useState<Outcome>(null);
const [ actionError, setActionError ] = useState<string | null>(null);
// step-uptrue=后端要求批准前先做新鲜 prompt=login 再认证(provider 2FA 即此处过)。
const [ stepUpRequired, setStepUpRequired ] = useState(false);
// 再认证往返回来后由后端二次拒(403 step_up_required),文案提示并允许重试。
const [ stepUpRejected, setStepUpRejected ] = useState(false);
// 未登录:暂存当前 URL 后走标准登录,登录后由首页 beforeLoad 弹回这里。
// 未登录:暂存当前 URL 后走 broker 全局 SSO 登录,登录后由首页 beforeLoad 弹回这里。
const handleSignIn = useCallback(async () =>
{
stashLinkReturn(window.location.pathname + window.location.search);
@@ -60,12 +55,12 @@ function LinkApprovePage()
await startDesktopLogin();
navigate({ to: "/" });
}
else { await loginProd(); }
else { loginRedirect(); }
}
catch (e) { setLoadError(e instanceof Error ? e.message : String(e)); }
}, [ navigate ]);
// 已登录且参数齐全:拉取待批准设备信息(含 step_up 标记)
// 已登录且参数齐全:拉取待批准设备信息。
useEffect(() =>
{
if (!user || !requestId || !code) { return; }
@@ -75,7 +70,6 @@ function LinkApprovePage()
{
if (cancelled) { return; }
setInfo(data);
setStepUpRequired(data.stepUp);
if (data.status === "expired") { setOutcome("expired"); }
else if (data.status === "denied") { setOutcome("denied"); }
else if (data.status === "approved") { setOutcome("approved"); }
@@ -85,69 +79,40 @@ function LinkApprovePage()
return () => { cancelled = true; };
}, [ user, requestId, code ]);
// doApprove 执行批准stash 非空时把 step-up 再认证拿到的 code+verifier 一并交给
// 后端就地核验。403 step_up_requiredStepUpRequiredError)单独走「需重新验证」态,
// 不当成普通错误。
const doApprove = useCallback(async (stash?: StepUpStash) =>
// doApprove 执行批准。step-up 二次认证已移除(full 档即可信,路由层守门)。
const doApprove = useCallback(async () =>
{
if (!requestId || !code) { return; }
setSubmitting(true);
setActionError(null);
setStepUpRejected(false);
try
{
// persist=「信任此设备」→ 完整会话(scope=full);once=「仅此次」→ 受限访客
// scope=guest)。后端按 scope 决定授予的会话级别,二者与 persist 时长一一对应
// scope=guest)。后端按 scope 经 broker 委托签发对应 tier 的会话
const scope = persist === "persist" ? "full" : "guest";
await qrApprove(requestId, code, scope, persist, stash?.code, stash?.verifier);
await qrApprove(requestId, code, scope, persist);
setOutcome("approved");
}
catch (e)
{
if (e instanceof StepUpRequiredError) { setStepUpRejected(true); }
else { setActionError(e instanceof Error ? e.message : String(e)); }
setActionError(e instanceof Error ? e.message : String(e));
}
finally { setSubmitting(false); }
}, [ requestId, code, persist ]);
// 再认证往返回来:检测到暂存的 step-up {code, verifier} → 自动调 approve 完成批准
// → 用完即清(consumeStepUpCode 取出即清)。仅在已登录、设备信息已就绪后跑一次。
// 原生客户端(非浏览器:iOS / 桌面)只接受完整权限会话——用户原则:原生 App 不允许受限
// 访客(仅 Web / PWA 可 guest)。故批准端对原生请求强制信任时长=persistscope=full)、
// 隐藏「仅此次」选项,只给「信任并继续 / 拒绝」。后端 handleQRApprove 亦兜底强制。
const requiresFull = info != null && info.deviceType !== "browser";
useEffect(() =>
{
if (!user || !info || outcome) { return; }
const stash = consumeStepUpCode();
if (!stash) { return; }
void doApprove(stash);
}, [ user, info, outcome, doApprove ]);
if (requiresFull) { setPersist("persist"); }
}, [ requiresFull ]);
// handleApprove:批准按钮step-up 要求且尚无再认证凭证时,先把用户带去做一次
// 新鲜 prompt=login 再认证(往返回来后由上面的 effect 自动接力 approve);否则
// 维持原有直接批准行为不变。
// handleApprove:批准按钮,直接提交(step-up 已移除)。
const handleApprove = async () =>
{
if (!requestId || !code) { return; }
if (stepUpRequired)
{
setSubmitting(true);
setActionError(null);
setStepUpRejected(false);
try
{
// 把当前「信任时长」选择编进 returnTo:step-up 整页跳转回来后页面重载、
// React state 复位,必须从 URL 恢复 persist,否则会回落默认、令本应受限
// 的设备被建成完整会话(#2 根因)。
const back = new URLSearchParams(window.location.search);
back.set("p", persist);
await startStepUpReauth(window.location.pathname + "?" + back.toString());
}
catch (e)
{
setActionError(e instanceof Error ? e.message : String(e));
setSubmitting(false);
}
// 成功则整页跳转去 provider,本组件随之卸载,不再 setSubmitting。
return;
}
await doApprove();
};
@@ -241,46 +206,37 @@ function LinkApprovePage()
</div>
</div>
{/* 信任时长:两选项分段,选中项得 accent 左色条(同 Callout / Activity 语汇)。 */}
<div className={s.choices} role="radiogroup" aria-label={t("qr.approve.trustLabel")}>
<TrustOption
value="persist"
selected={persist === "persist"}
onSelect={setPersist}
title={t("qr.approve.trust.title")}
hint={t("qr.approve.trust.hint")}
/>
<TrustOption
value="once"
selected={persist === "once"}
onSelect={setPersist}
title={t("qr.approve.once.title")}
hint={t("qr.approve.once.hint")}
/>
</div>
{/* 信任时长:两选项分段,选中项得 accent 左色条(同 Callout / Activity 语汇)。
原生客户端不给选择——强制完整登录,故隐藏分段,仅留下方完整权限说明。 */}
{!requiresFull && (
<div className={s.choices} role="radiogroup" aria-label={t("qr.approve.trustLabel")}>
<TrustOption
value="persist"
selected={persist === "persist"}
onSelect={setPersist}
title={t("qr.approve.trust.title")}
hint={t("qr.approve.trust.hint")}
/>
<TrustOption
value="once"
selected={persist === "once"}
onSelect={setPersist}
title={t("qr.approve.once.title")}
hint={t("qr.approve.once.hint")}
/>
</div>
)}
{/* 权限范围说明:随选中项切换——信任=完整登录、仅此次=受限访客,明确告知
这台设备将以什么身份登录(非装饰)。 */}
{/* 权限范围说明:原生客户端固定完整登录;浏览器随选中项切换信任=完整、仅此次=
受限访客),明确告知这台设备将以什么身份登录(非装饰)。 */}
<Callout tone="info" icon={<ShieldCheck size={16} />}>
{persist === "persist"
? t("qr.approve.scopeFull")
: t("qr.approve.scopeGuest")}
{requiresFull
? t("qr.approve.nativeFull")
: persist === "persist"
? t("qr.approve.scopeFull")
: t("qr.approve.scopeGuest")}
</Callout>
{/* step-up:批准前的明确安全步骤——需先验证身份(provider 2FA 即此处过)。 */}
{stepUpRequired && !stepUpRejected && (
<Callout tone="info" icon={<ShieldCheck size={16} />}>
{t("qr.approve.stepUp.notice")}
</Callout>
)}
{/* 再认证回来仍被后端拒:给方向(凭证过期 / 验证未通过)并允许重试。 */}
{stepUpRejected && (
<Callout tone="warn" icon={<AlertTriangle size={16} />}>
{t("qr.approve.stepUp.rejected")}
</Callout>
)}
{actionError && (
<Callout tone="error" icon={<AlertTriangle size={16} />}>{actionError}</Callout>
)}
@@ -291,13 +247,11 @@ function LinkApprovePage()
size="lg"
block
loading={submitting}
leftIcon={
stepUpRequired ? <ShieldCheck size={16} /> : <Check size={16} />
}
leftIcon={<Check size={16} />}
onClick={() => { void handleApprove(); }}
>
{stepUpRequired
? t("qr.approve.stepUp.button")
{requiresFull
? t("qr.approve.trustContinue")
: t("qr.approve.approve")}
</Button>
<Button
+1
View File
@@ -43,6 +43,7 @@ function LinkNewPage()
if (st.status !== "approved" || !st.accessToken || !st.user) { return; }
setAuth({
accessToken: st.accessToken,
refreshToken: st.refreshToken ?? null,
user: { id: st.user.id, name: st.user.name, avatar: st.user.avatar },
});
// 设备名以批准方下发的为权威(服务端登记的名字),回退到本地输入。
+14 -13
View File
@@ -1,7 +1,7 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertTriangle, LogIn, QrCode } from "lucide-react";
import { useEffect, useState } from "react";
import { fetchAuthConfig, loginDev, loginProd } from "../features/auth/auth";
import { loginDev, loginRedirect } from "../features/auth/auth";
import { isDesktop, startDesktopLogin } from "../net/desktop";
import { AuthShell } from "../features/auth/AuthShell";
import { useAppStore } from "../store";
@@ -21,24 +21,23 @@ function LoginPage()
const navigate = useNavigate();
const search = Route.useSearch();
const localAuthMode = useAppStore((s) => s.authMode);
const user = useAppStore((s) => s.user);
const [ devUser, setDevUser ] = useState(search.dev_user ?? "");
const [ error, setError ] = useState<string | null>(null);
// 前端编译期推断的 mode(DEV/PROD)可能与后端实际部署不一致
// (例如把 prod 构建打到 dev 后端测试),因此以服务器返回的为准。
const [ serverMode, setServerMode ] = useState<"dev" | "prod" | null>(null);
// 已登录态落在 /login 时反应式跳首页。全局 SSO 登录回跳后 rd 指回本页(登录前用户
// 被 __root 守卫从原路由送到了 /login),bootstrapAuth 据 broker cookie 注入身份后
// 若不主动离开,就停在登录页形成“登录成功却又见登录页”的回环。__root 的守卫只把
// 未登录态送进 /login,登录态的反向跳转由此补齐(含 dev 同步登录后的兜底)。
useEffect(() =>
{
let cancelled = false;
fetchAuthConfig().then(
(cfg) => { if (!cancelled) { setServerMode(cfg.auth_mode); } },
() => { /* 回退到编译期 mode */ },
);
return () => { cancelled = true; };
}, []);
if (user) { navigate({ to: "/" }); }
}, [ user, navigate ]);
const effectiveMode = serverMode ?? localAuthMode;
// 后端不再暴露 /api/auth/configOIDC 已委托给 broker);登录模式以前端编译期
// 注入的 authMode 为准(dev / prod)。
const effectiveMode = localAuthMode;
const handleDevLogin = () =>
{
@@ -68,7 +67,9 @@ function LoginPage()
}
else
{
await loginProd();
// 跳到 broker 全局 SSO 登录(cdrop 后端 302 代跳);登录后回跳本页,
// 边缘 human 分支据 broker cookie 注入身份。
loginRedirect();
}
}
catch (e) { setError(e instanceof Error ? e.message : String(e)); }
-127
View File
@@ -1,127 +0,0 @@
import { Loader } from "@mantine/core";
import { createFileRoute } from "@tanstack/react-router";
import { AlertTriangle } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
clearStepUpState, completeOAuthLogin, verifyStepUpState,
} from "../features/auth/auth";
import { AuthShell } from "../features/auth/AuthShell";
import {
clearStepUpPending, peekStepUpPending, stashStepUpCode,
} from "../features/qr/stepUp";
import { t } from "../i18n";
import { Callout } from "../ui/primitives";
export const Route = createFileRoute("/oauth/callback")({
component: CallbackPage,
validateSearch: (search: Record<string, unknown>) =>
({
code: typeof search.code === "string" ? search.code : undefined,
state: typeof search.state === "string" ? search.state : undefined,
error: typeof search.error === "string" ? search.error : undefined,
error_description: typeof search.error_description === "string"
? search.error_description
: undefined,
}),
});
function CallbackPage()
{
const search = Route.useSearch();
const [ err, setErr ] = useState<string | null>(null);
// ref guardcompleteOAuthLogin 内部会消费 localStorage 中的 PKCE_VERIFIER /
// OAUTH_STATE,二次进入必然 throw "state mismatch / verifier missing",在
// navigate(/) 真正完成之前的那一帧引发"登录失败"闪现。
//
// navigate 是异步的:promise resolve → setErr/navigatenavigate 实际生效之间
// 仍可能发生一次 re-renderuseNavigate 返回值在某些版本里非稳定引用,会让
// useEffect deps 抖动重跑)。ref guard 杜绝逻辑重入,cancelled flag 兜底防止
// unmount 后 setErr。
const startedRef = useRef(false);
useEffect(() =>
{
if (startedRef.current) { return; }
startedRef.current = true;
let cancelled = false;
// 把 setErr 推迟一拍:navigate({to:"/"}) 是异步的,promise resolve
// 后 callback 真正 unmount 前可能渲染一帧 err 状态(即使 success path
// 不该走这里,但 setAuth → store 更新 → 父级 re-render → useNavigate
// 引用波动 → effect 二度入场是过去的真实路径,残留闪现)。
//
// 失败时延迟 800ms 显示,给 success 路径足够时间触发卸载;真正失败
// 800ms 内 cleanup 不会被调用,setErr 仍能渲染失败态。
const showErrDeferred = (msg: string) =>
{
console.error("[oauth/callback]", msg);
window.setTimeout(() =>
{
if (!cancelled) { setErr(msg); }
}, 800);
};
if (search.error)
{
showErrDeferred(`${search.error}${search.error_description ?? ""}`);
return;
}
if (!search.code || !search.state)
{
showErrDeferred(t("oauth.missingParams"));
return;
}
// step-up 分叉:批准页发起的 prompt=login 再认证回到这里。此时 code 是要
// 交给后端 approve 去 IdP 换的一次性凭证——前端绝不调 /api/auth/exchange
// 消费它(那会在服务端轮换会话)。只校 state(防 CSRF,同常规登录口径)、
// 把 {code, verifier} 暂存给批准页、清掉「待办」,整页跳回批准页 URL。
const pending = peekStepUpPending();
if (pending)
{
if (!verifyStepUpState(search.state))
{
clearStepUpPending();
showErrDeferred("OAuth state mismatch (possible CSRF or stale tab)");
return;
}
stashStepUpCode({ code: search.code, verifier: pending.verifier });
clearStepUpPending();
// 整页跳转回批准页(returnTo 已在 stash 时通过 open-redirect 校验)。
window.location.replace(pending.returnTo);
return;
}
clearStepUpState(); // 非 step-up 路径兜底:清掉可能残留的 step-up state。
completeOAuthLogin(search.code, search.state).then(
// 整页跳转(window.location.replace),不是路由软导航。Safari 下,从
// Casdoor 回跳后那条客户端导航会复用 OAuth 流程遗留的 HTTP 连接,而这些
// 连接在 Safari 里会卡死后续资源(setup.js / 头像)约 2 分钟——手动刷新即
// 恢复,印证是连接状态问题。整页重载拿到全新连接,绕开该 bug;auth 已在
// sessionStorage,同标签刷新存活。replace 不入历史,避免后退重触已消费的 code。
() => { if (!cancelled) { window.location.replace("/"); } },
(e) => { showErrDeferred(e instanceof Error ? e.message : String(e)); },
);
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅在 mount 时跑一次;search 由 URL 决定,mount 时即定。
if (err)
{
return (
<AuthShell title={t("oauth.failed")} hideBrand>
<Callout tone="error" icon={<AlertTriangle size={16} />}>{err}</Callout>
</AuthShell>
);
}
return (
<AuthShell title={t("oauth.signingIn")} subtitle="正在向 Casdoor 交换访问令牌…" hideBrand>
<div style={{ display: "flex", justifyContent: "center", padding: "var(--space-4) 0" }}>
<Loader color="var(--accent)" />
</div>
</AuthShell>
);
}
+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);
}
+20 -7
View File
@@ -2,7 +2,7 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { Check, Monitor } from "lucide-react";
import { useState } from "react";
import { AuthShell } from "../features/auth/AuthShell";
import { syncWebDeviceName } from "../features/auth/auth";
import { ensureDeviceSession, syncWebDeviceName } from "../features/auth/auth";
import { persistDesktopDeviceName } from "../net/desktop";
import { useAppStore } from "../store";
import { t } from "../i18n";
@@ -26,9 +26,11 @@ function SetupPage()
const navigate = useNavigate();
const setSelfDeviceName = useAppStore((s) => s.setSelfDeviceName);
const [ name, setName ] = useState(suggestDefault());
const [ saving, setSaving ] = useState(false);
const handleSave = () =>
const handleSave = async () =>
{
if (saving) { return; } // 防重复提交:代铸期间再次点击会多铸一条设备会话
const trimmed = name.trim();
if (!trimmed) { return; }
if (!isAsciiDeviceName(trimmed))
@@ -36,10 +38,21 @@ function SetupPage()
toast.warn(t("settings.deviceName.asciiOnly"));
return;
}
setSelfDeviceName(trimmed);
persistDesktopDeviceName(trimmed); // 桌面:持久化到 Go 配置,跨重启存活
syncWebDeviceName(trimmed); // 浏览器:持久化到服务端 session,抗 PWA 存储清除
navigate({ to: "/" });
setSaving(true);
try
{
setSelfDeviceName(trimmed);
persistDesktopDeviceName(trimmed); // 桌面:持久化到 Go 配置,跨重启存活
syncWebDeviceName(trimmed); // 浏览器:持久化到服务端 session,抗 PWA 存储清除
// 浏览器:名字既定,立即代铸一条托管设备会话(桌面 / 已持令牌者为 no-op),
// 使本机以此名进入统一设备列表后再进入应用。
await ensureDeviceSession();
navigate({ to: "/" });
}
finally
{
setSaving(false);
}
};
return (
@@ -74,7 +87,7 @@ function SetupPage()
block
leftIcon={<Check size={16} />}
onClick={handleSave}
disabled={!name.trim()}
disabled={!name.trim() || saving}
>
{t("setup.save")}
</Button>
+20
View File
@@ -22,6 +22,11 @@ export function normalizeTerminal(cur: TransferRecord, finalState: string): Tran
// ---- storage keys & readers ----
export const ACCESS_TOKEN_KEY = "cdrop.access_token";
// refresh_token now lives in JS too (sessionStorage): after the Auth Broker migration
// a QR-paired device holds the broker's access + refresh pair directly and refreshes
// via /api/auth/refresh. A global-SSO browser holds neither (the broker domain cookie
// carries it). Desktop keeps its refresh in the Go process, never injected.
export const REFRESH_TOKEN_KEY = "cdrop.refresh_token";
export const USER_KEY = "cdrop.user";
export const SESSION_SCOPE_KEY = "cdrop.session_scope";
export const SELF_DEVICE_KEY = "cdrop.self_device";
@@ -37,6 +42,10 @@ export const THEME_KEY = "cdrop.theme";
interface InjectedSession
{
access_token: string;
// refresh_token 现随注入会话一并下发(iOS 引擎自刷需要它):引擎用它经
// /api/auth/refresh 续期,并在 broker 轮换后回报原生更新 Keychain。桌面注入仍不带
// (桌面 refresh 在 Go 进程内)。
refresh_token?: string;
user: { id: string; name: string; avatar?: string };
}
@@ -94,6 +103,17 @@ export function readSessionAccess(): string | null
return window.sessionStorage.getItem(ACCESS_TOKEN_KEY);
}
// readSessionRefresh restores the broker refresh token across a same-tab reload.
// Desktop injection never carries it (refresh lives in the Go process); a global-SSO
// browser has none. Null when absent.
export function readSessionRefresh(): string | null
{
const inj = injectedSession();
if (inj?.refresh_token) { return inj.refresh_token; }
if (typeof window === "undefined") { return null; }
return window.sessionStorage.getItem(REFRESH_TOKEN_KEY);
}
// user.id / user.name 不是机密(公开显示在 UI、JWT 里可解码),随 access_token
// 一并持久化,才能让刷新 / 重启后路由守卫不再误跳 /login。
export function readSessionUser(): User | null
+21 -5
View File
@@ -2,16 +2,18 @@ import type { StateCreator } from "zustand";
import type { AppState, AuthMode, SessionScope } from "../types";
import {
ACCESS_TOKEN_KEY,
REFRESH_TOKEN_KEY,
SESSION_SCOPE_KEY,
USER_KEY,
readSessionAccess,
readSessionRefresh,
readSessionScope,
readSessionUser,
} from "../helpers";
export type AuthSlice = Pick<
AppState,
"authMode" | "accessToken" | "user" | "sessionScope"
"authMode" | "accessToken" | "refreshToken" | "user" | "sessionScope"
| "setAuth" | "setSessionScope" | "clearAuth"
>;
@@ -25,20 +27,33 @@ export const createAuthSlice: StateCreator<AppState, [], [], AuthSlice> = (set)
({
authMode: initialAuthMode,
accessToken: readSessionAccess(),
refreshToken: readSessionRefresh(),
user: readSessionUser(),
sessionScope: readSessionScope(),
// setAuth records the session. accessToken may be null (global-SSO browser — the
// broker cookie carries identity). refreshToken is optional: omit it to leave a
// previously stored one untouched (e.g. a /api/me bootstrap that only confirms the
// user); pass null to clear it, a string to replace it.
setAuth: (a) =>
{
if (typeof window !== "undefined")
{
window.sessionStorage.setItem(ACCESS_TOKEN_KEY, a.accessToken);
if (a.accessToken) { window.sessionStorage.setItem(ACCESS_TOKEN_KEY, a.accessToken); }
else { window.sessionStorage.removeItem(ACCESS_TOKEN_KEY); }
if (a.refreshToken !== undefined)
{
if (a.refreshToken) { window.sessionStorage.setItem(REFRESH_TOKEN_KEY, a.refreshToken); }
else { window.sessionStorage.removeItem(REFRESH_TOKEN_KEY); }
}
window.sessionStorage.setItem(USER_KEY, JSON.stringify(a.user));
}
set({
set((s) =>
({
accessToken: a.accessToken,
refreshToken: a.refreshToken !== undefined ? a.refreshToken : s.refreshToken,
user: a.user,
});
}));
},
// setSessionScope 由 /api/me 解析结果驱动(登录成功 / 开机水合后调用)。
@@ -58,10 +73,11 @@ export const createAuthSlice: StateCreator<AppState, [], [], AuthSlice> = (set)
if (typeof window !== "undefined")
{
window.sessionStorage.removeItem(ACCESS_TOKEN_KEY);
window.sessionStorage.removeItem(REFRESH_TOKEN_KEY);
window.sessionStorage.removeItem(USER_KEY);
window.sessionStorage.removeItem(SESSION_SCOPE_KEY);
}
// scope 回 "full":登出后下一个登录者默认完整态,再由其自己的 /api/me 校正。
set({ accessToken: null, user: null, sessionScope: "full" });
set({ accessToken: null, refreshToken: null, user: null, sessionScope: "full" });
},
});
+5 -1
View File
@@ -123,13 +123,17 @@ export interface AppState
{
// ---- auth slice ----
authMode: AuthMode;
// accessToken is null for a global-SSO browser (the broker domain cookie carries
// identity; requests go without a Bearer). A QR-paired device holds the broker
// access token here and the refresh token alongside.
accessToken: string | null;
refreshToken: string | null;
user: User | null;
// 本会话权限级别(/api/me 的 scope)。默认 "full";登录成功 / 开机水合后据
// /api/me 校正为 "guest" 时,UI 隐藏「扫码登录新设备」「移除设备」「登出他人」
// 等账号管理入口,并展示「受限访客」标识。
sessionScope: SessionScope;
setAuth: (a: { accessToken: string; user: User }) => void;
setAuth: (a: { accessToken: string | null; refreshToken?: string | null; user: User }) => void;
setSessionScope: (scope: SessionScope) => void;
clearAuth: () => void;
+11
View File
@@ -17,6 +17,17 @@ export default defineConfig({
),
},
},
build: {
// 多入口:main = 完整 React 应用(index.html);engine = iOS 无头传输引擎
// engine.html,不含 React / UI,见 src/engine/main.ts、ios/PLAN.md arch A)。
// 两个 bundle 互不污染——engine 不把 React / 路由拖进 main,main 也不含引擎入口。
rollupOptions: {
input: {
main: fileURLToPath(new URL("./index.html", import.meta.url)),
engine: fileURLToPath(new URL("./engine.html", import.meta.url)),
},
},
},
server: {
proxy: {
"/api": "http://localhost:8080",