Files
Commilitia-Drop/web/src/routes/link_.new.tsx
T
admin 018a77b13a 登录子系统:OIDC 统一自签 token + 吊销即时生效 + 设备并入会话列表 + 应用内扫码 + 禁则修复
接续 90a3790(扫码 A0+A1+B + step-up 雏形)。蓝本见 AUTH.md。

OIDC 统一到 cdrop 自签 token
- /auth/exchange 先 JWKS 验 id_token 签名(自签 token 的唯一信任锚)再签 cdrop
  自签 HS256 access token(绑 sid),不再把 IdP RS256 下发浏览器;验签失败仅告警
  并回退 RS256(会话仍建、下次 refresh 自愈),登录不中断
- /auth/refresh oidc 路径仍打 IdP 轮换 refresh_token + 探测 IdP 侧吊销,但同样回
  自签 token;createWebSession 改返回行 id
- verify 链保留 RS256 分支仅供桌面端 loopback(其即时吊销留后续)

吊销即时生效 + 被吊销设备自知回退
- verifySelfToken 每请求按 sid 查 web_sessions 行,行删即拒 → 吊销在被吊销设备
  下一次请求即生效(不等 TTL),oidc / self / guest 一致
- 前端仅在「服务端以 401 确证会话失效」时 forceLogout 清登录态、回登录页;短期连接
  失败(5xx / 网络)一律保留登录态(refreshTokens 改三态 refreshed/invalid/transient,
  cookieRefresh 改 discriminated 结果)
- __root 反应式守卫:prod 下失登录态且非认证路由即跳登录页;新增 auth.sessionLost.* 三语

设备列表并入会话列表 + 孤儿自动清除
- GET /api/auth/sessions 统一 web_sessions 与原生客户端设备(桌面 / iOS,自 devices
  登记表呈现,native=true,离线也列);同名不重复列出,排除 shortcut 与无会话 browser
- 吊销网页会话连带删其设备登记(无同名在用会话时);原生「登出」走
  DELETE /api/devices/{name}(同要求 step-up)
- 新增 DeleteOrphanBrowserDevices(browser 型且无存活 web_session),接入设备清扫器(1h)

应用内扫码 + 聚珍崩溃 / CJK 禁则修复
- 登录页内置 getUserMedia + jsqr 扫码,不再外跳系统应用(避免开错浏览器)
- 修聚珍(cjk-autospace)MutationObserver 与 React 重渲染同子树冲突致的 insertBefore
  崩溃:扫码 / 显码 / 批准三状态机页整页跳过聚珍(data-jz-skip)
- 修流动正文未跑 CJK 禁则(jinze 为段落级、须 opt-in):设置 / 快捷指令 / 桌面页一批
  hint 补 data-jz-level="paragraph" + justify,短标签 / 状态仍留文本级

step-up 完善
- auth_time 改可选(Casdoor 不下发,原强制致死循环);stepped_up_at 按会话绑定、
  StepUpMaxAgeSeconds 窗口内不复要求;新增 POST /api/auth/stepup
- 批准页 persist 编进 step-up returnTo 防整页跳转丢失;scope 随档绑定(信任=full /
  仅此次=guest),默认偏安全的「仅此次」

文档与测试
- AUTH.md:折中架构、accounts 薄表、令牌架构、扫码流程、step-up、§4.4 统一会话列表
- 新增 Go 测试:OIDC 自签验证、verifySelfToken 吊销即拒、会话吊销连带删设备、
  统一会话列表(含原生 / 不含 shortcut / 不重复)、孤儿清扫、删设备需 step-up
2026-06-22 11:55:02 +08:00

271 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertTriangle, ArrowLeft, ArrowRight, RefreshCw, ScanLine } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { AuthShell } from "../features/auth/AuthShell";
import { syncWebDeviceName } from "../features/auth/auth";
import { QrCanvas } from "../features/qr/QrCanvas";
import { persistDesktopDeviceName } from "../net/desktop";
import { pollQrStatus, qrStart, type QrStartResp, type QrStatus } from "../net/qr";
import { readInjectedDeviceType } from "../store/helpers";
import { useAppStore } from "../store";
import { t } from "../i18n";
import { isAsciiDeviceName } from "../utils/format";
import { StateDot } from "../ui/glyphs";
import { Button, Callout, TextField } from "../ui/primitives";
import s from "./link_.new.module.css";
// 显码页:有人借了一台陌生设备,扫码把它接入自己的 cdrop。它本身公开(start /
// status 无需登录),故不设路由守卫——已登录用户也可经此把另一设备接入。
export const Route = createFileRoute("/link_/new")({
component: LinkNewPage,
});
type Step = "name" | "show";
function LinkNewPage()
{
const navigate = useNavigate();
const setAuth = useAppStore((s) => s.setAuth);
const setSelfDeviceName = useAppStore((s) => s.setSelfDeviceName);
const [ step, setStep ] = useState<Step>("name");
const [ name, setName ] = useState(suggestDeviceName);
const [ session, setSession ] = useState<QrStartResp | null>(null);
const [ phase, setPhase ] = useState<QrStatus["status"]>("pending");
const [ error, setError ] = useState<string | null>(null);
const [ starting, setStarting ] = useState(false);
// 轮询的取消句柄。换二维码 / 卸载时调用即停。
const cancelPollRef = useRef<(() => void) | null>(null);
const onApproved = useCallback((st: QrStatus) =>
{
if (st.status !== "approved" || !st.accessToken || !st.user) { return; }
setAuth({
accessToken: st.accessToken,
user: { id: st.user.id, name: st.user.name, avatar: st.user.avatar },
});
// 设备名以批准方下发的为权威(服务端登记的名字),回退到本地输入。
const finalName = st.deviceName?.trim() || name.trim();
setSelfDeviceName(finalName);
persistDesktopDeviceName(finalName);
syncWebDeviceName(finalName);
navigate({ to: "/" });
}, [ name, navigate, setAuth, setSelfDeviceName ]);
const begin = useCallback(async (deviceName: string) =>
{
setError(null);
setStarting(true);
try
{
const resp = await qrStart({
deviceName,
deviceType: readInjectedDeviceType(),
});
setSession(resp);
setPhase("pending");
setStep("show");
}
catch (e)
{
setError(e instanceof Error ? e.message : String(e));
}
finally { setStarting(false); }
}, []);
// 拿到 session 即起长轮询;session 变化(重新生成)时换新轮询。
useEffect(() =>
{
if (!session) { return; }
cancelPollRef.current?.();
const cancel = pollQrStatus(session.requestId, session.pollSecret, {
onResolved: (st) =>
{
setPhase(st.status);
if (st.status === "approved") { onApproved(st); }
},
onError: () => { /* 单轮抖动:轮询循环自重试,UI 保持等待态 */ },
});
cancelPollRef.current = cancel;
return () => { cancel(); cancelPollRef.current = null; };
}, [ session, onApproved ]);
const handleContinue = () =>
{
const trimmed = name.trim();
if (!trimmed) { return; }
if (!isAsciiDeviceName(trimmed))
{
setError(t("settings.deviceName.asciiOnly"));
return;
}
void begin(trimmed);
};
const handleRegenerate = () =>
{
setError(null);
void begin(name.trim());
};
// 回上一步:弃掉当前二维码会话,退回命名步骤(轮询随 session 清空由 effect 清理)。
const handleBackToName = () =>
{
cancelPollRef.current?.();
cancelPollRef.current = null;
setSession(null);
setError(null);
setStep("name");
};
if (step === "name")
{
return (
<AuthShell
title={t("qr.show.title")}
subtitle={t("qr.show.subtitle")}
hideBrand
skipAutospace
>
{error && (
<Callout tone="error" icon={<AlertTriangle size={16} />}>{error}</Callout>
)}
<TextField
label={t("setup.field")}
hint={t("qr.show.nameHint")}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
onKeyDown={(e) =>
{
if (e.key === "Enter" && name.trim())
{
e.preventDefault();
handleContinue();
}
}}
autoFocus
/>
<Button
variant="primary"
size="lg"
block
loading={starting}
rightIcon={<ArrowRight size={16} />}
onClick={handleContinue}
disabled={!name.trim()}
>
{t("qr.show.generate")}
</Button>
<Button
variant="ghost"
size="lg"
block
leftIcon={<ArrowLeft size={16} />}
onClick={() => navigate({ to: "/" })}
>
{t("qr.show.back")}
</Button>
</AuthShell>
);
}
return (
<AuthShell title={t("qr.show.scanTitle")} hideBrand skipAutospace>
{error && (
<Callout tone="error" icon={<AlertTriangle size={16} />}>{error}</Callout>
)}
<div className={s.stage}>
<div className={s.plate}>
{session && <QrCanvas value={session.qrPayload} />}
</div>
<StatusLine phase={phase} />
</div>
<ol className={s.steps} data-jz-level="paragraph">
<li>{t("qr.show.howto1")}</li>
<li>{t("qr.show.howto2")}</li>
</ol>
{(phase === "expired" || phase === "denied") && (
<Callout
tone={phase === "denied" ? "error" : "warn"}
icon={<AlertTriangle size={16} />}
>
{phase === "denied" ? t("qr.show.denied") : t("qr.show.expired")}
</Callout>
)}
{(phase === "expired" || phase === "denied") && (
<Button
variant="secondary"
size="lg"
block
leftIcon={<RefreshCw size={16} />}
onClick={handleRegenerate}
>
{t("qr.show.regenerate")}
</Button>
)}
{/* 始终给一个明确退出口:回到命名步骤,避免卡在显码 / 终态页。 */}
{phase !== "approved" && (
<Button
variant="ghost"
size="lg"
block
leftIcon={<ArrowLeft size={16} />}
onClick={handleBackToName}
>
{t("qr.show.backToName")}
</Button>
)}
</AuthShell>
);
}
// 显码页的等待态:一行平静的状态,pending 时 accent 脉冲点 + 「等待批准」。
function StatusLine(props: { phase: QrStatus["status"] })
{
const { phase } = props;
if (phase === "approved")
{
return (
<div className={s.statusLine}>
<StateDot tone="online" size={8} />
<span>{t("qr.show.approved")}</span>
</div>
);
}
if (phase === "expired" || phase === "denied") { return null; }
return (
<div className={s.statusLine}>
<span className={s.pulse} aria-hidden="true" />
<span className={s.waitText}>
<ScanLine size={14} />
{t("qr.show.waiting")}
</span>
</div>
);
}
// suggestDeviceNameUA 预填一个可读的默认设备名(与 /setup 同口径),可编辑。
function suggestDeviceName(): string
{
const ua = navigator.userAgent;
let browser = "Browser";
if (ua.includes("Firefox/")) { browser = "Firefox"; }
else if (ua.includes("Edg/")) { browser = "Edge"; }
else if (ua.includes("Chrome/")) { browser = "Chrome"; }
else if (ua.includes("Safari/")) { browser = "Safari"; }
let os = "Desktop";
if (ua.includes("Mac")) { os = "macOS"; }
else if (ua.includes("Windows")) { os = "Windows"; }
else if (ua.includes("Linux")) { os = "Linux"; }
return `${browser}-${os}-${Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, "0")}`;
}