Files
Commilitia-Drop/web/src/routes/link_.scan.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

260 lines
10 KiB
TypeScript

import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { AlertTriangle, ArrowLeft, Camera, CameraOff, ScanLine } from "lucide-react";
import jsQR from "jsqr";
import { useCallback, useEffect, useRef, useState } from "react";
import { AuthShell } from "../features/auth/AuthShell";
import { parseLinkApprovalUrl } from "../net/qr";
import { useAppStore } from "../store";
import { t } from "../i18n";
import { Button, Callout } from "../ui/primitives";
import s from "./link_.scan.module.css";
// 应用内扫码器:在已登录的 cdrop 里直接调相机扫码,解出 /link?r=&c= 后在同一已鉴权
// 会话内导航到批准页——绕开「系统相机打开链接落到未登录浏览器」的断流。批准走既有
// /link 页(step-up 开启时该页会自动要求再认证),本组件只负责「扫到合法码 → 导航」。
export const Route = createFileRoute("/link_/scan")({
component: LinkScanPage,
// 守卫:受限访客(scope=guest)不能授权新设备(后端 approve 亦 403),故连扫码
// 授权页都不让进——直接弹回首页,避免误触。未登录交由批准页处理,这里只挡访客。
beforeLoad: () =>
{
if (useAppStore.getState().sessionScope === "guest")
{
throw redirect({ to: "/" });
}
},
});
// 扫码器的几种态:请求相机中 / 正在扫 / 命中(淡出过渡)/ 无摄像头 / 权限被拒 / 其他错误。
type ScanState = "starting" | "scanning" | "hit" | "noCamera" | "denied" | "error";
function LinkScanPage()
{
const navigate = useNavigate();
const videoRef = useRef<HTMLVideoElement | null>(null);
// 解码用的离屏 canvas,逐帧把视频画上去再喂给 jsQR。
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const rafRef = useRef<number | null>(null);
// 命中后置位:阻止 rAF 循环继续解码 / 重复导航。
const doneRef = useRef(false);
const [ state, setState ] = useState<ScanState>("starting");
const [ errorMsg, setErrorMsg ] = useState<string | null>(null);
// 扫到无关 / 非本站二维码:提示「继续对准」,但相机继续扫,不打断。
const [ sawForeignCode, setSawForeignCode ] = useState(false);
// stop 释放相机:停轨道 + 取消 rAF。卸载、命中、出错时都调用,确保相机指示灯熄灭。
const stop = useCallback(() =>
{
if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; }
const stream = streamRef.current;
if (stream) { stream.getTracks().forEach((tr) => tr.stop()); streamRef.current = null; }
}, []);
// tick:单帧解码循环。把当前视频帧画到离屏 canvas,jsQR 解一次;解出有效本站
// /link 码即停机 + 应用内导航,否则下一帧继续。
const tick = useCallback(() =>
{
if (doneRef.current) { return; }
const video = videoRef.current;
const canvas = canvasRef.current;
if (!video || !canvas || video.readyState < video.HAVE_ENOUGH_DATA)
{
rafRef.current = requestAnimationFrame(tick);
return;
}
const w = video.videoWidth;
const h = video.videoHeight;
if (!w || !h)
{
rafRef.current = requestAnimationFrame(tick);
return;
}
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx)
{
rafRef.current = requestAnimationFrame(tick);
return;
}
ctx.drawImage(video, 0, 0, w, h);
const image = ctx.getImageData(0, 0, w, h);
const result = jsQR(image.data, w, h, { inversionAttempts: "dontInvert" });
if (result && result.data)
{
const link = parseLinkApprovalUrl(result.data);
if (link)
{
// 命中合法本站码:停机、淡出,应用内导航到批准页(留在已登录 SPA 会话)。
doneRef.current = true;
stop();
setState("hit");
// p 不带:批准页会回落到更安全的默认「仅此次」(受限访客)。
navigate({ to: "/link", search: { r: link.requestId, c: link.code, p: undefined } });
return;
}
// 扫到了码,但不是本站 /link 码(别的二维码 / 钓鱼链接):提示继续对准。
setSawForeignCode(true);
}
rafRef.current = requestAnimationFrame(tick);
}, [ navigate, stop ]);
// 进页面即请求相机并起解码循环。无摄像头 / 权限被拒分别落到清晰的引导态。
useEffect(() =>
{
doneRef.current = false;
let cancelled = false;
const start = async () =>
{
if (!navigator.mediaDevices?.getUserMedia)
{
setState("noCamera");
return;
}
try
{
const stream = await navigator.mediaDevices.getUserMedia({
// 优先后置相机(手机扫码自然朝外);桌面无后置则退到任意可用相机。
video: { facingMode: { ideal: "environment" } },
audio: false,
});
if (cancelled) { stream.getTracks().forEach((tr) => tr.stop()); return; }
streamRef.current = stream;
const video = videoRef.current;
if (video)
{
video.srcObject = stream;
// iOS Safari 要求 playsInline + 显式 play(),否则不出画面。
await video.play().catch(() => { /* 自动播放被拦:画面仍随轨道更新 */ });
}
setState("scanning");
rafRef.current = requestAnimationFrame(tick);
}
catch (e)
{
if (cancelled) { return; }
const name = e instanceof DOMException ? e.name : "";
if (name === "NotAllowedError" || name === "SecurityError")
{
setState("denied");
}
else if (name === "NotFoundError" || name === "OverconstrainedError")
{
setState("noCamera");
}
else
{
setErrorMsg(e instanceof Error ? e.message : String(e));
setState("error");
}
}
};
void start();
return () => { cancelled = true; stop(); };
}, [ tick, stop ]);
const goBack = () => navigate({ to: "/" });
// ---- 渲染分支 --------------------------------------------------------
// 无摄像头(桌面常见):优雅降级,引导改用手机扫,不报错。
if (state === "noCamera")
{
return (
<AuthShell title={t("qr.scan.title")} hideBrand skipAutospace>
<Callout tone="info" icon={<CameraOff size={16} />}>
{t("qr.scan.noCamera")}
</Callout>
<Button variant="secondary" size="lg" block leftIcon={<ArrowLeft size={16} />} onClick={goBack}>
{t("qr.scan.back")}
</Button>
</AuthShell>
);
}
// 权限被拒:明确告诉用户去哪儿改回来。
if (state === "denied")
{
return (
<AuthShell title={t("qr.scan.title")} hideBrand skipAutospace>
<Callout tone="warn" icon={<CameraOff size={16} />}>
{t("qr.scan.denied")}
</Callout>
<Button variant="secondary" size="lg" block leftIcon={<ArrowLeft size={16} />} onClick={goBack}>
{t("qr.scan.back")}
</Button>
</AuthShell>
);
}
if (state === "error")
{
return (
<AuthShell title={t("qr.scan.title")} hideBrand skipAutospace>
<Callout tone="error" icon={<AlertTriangle size={16} />}>
{errorMsg ?? t("qr.scan.error")}
</Callout>
<Button variant="secondary" size="lg" block leftIcon={<ArrowLeft size={16} />} onClick={goBack}>
{t("qr.scan.back")}
</Button>
</AuthShell>
);
}
return (
<AuthShell title={t("qr.scan.title")} subtitle={t("qr.scan.subtitle")} hideBrand skipAutospace>
<div className={s.stage}>
<div className={`${s.viewport} ${state === "hit" ? s.viewportHit : ""}`}>
<video
ref={videoRef}
className={s.video}
muted
playsInline
// 仅装饰性预览,扫码内容不读屏;语义流程在批准页。
aria-hidden="true"
/>
{state === "scanning" && <span className={s.beam} aria-hidden="true" />}
<span className={`${s.tick} ${s.tl}`} aria-hidden="true" />
<span className={`${s.tick} ${s.tr}`} aria-hidden="true" />
<span className={`${s.tick} ${s.bl}`} aria-hidden="true" />
<span className={`${s.tick} ${s.br}`} aria-hidden="true" />
</div>
<canvas ref={canvasRef} style={{ display: "none" }} />
<div className={s.statusLine}>
{state === "starting"
? <><Camera size={14} />{t("qr.scan.starting")}</>
: <><ScanLine size={14} />{t("qr.scan.aiming")}</>}
</div>
</div>
{/* 扫到非本站码:安静提示继续对准,相机不中断。 */}
{sawForeignCode && state === "scanning" && (
<Callout tone="info" icon={<ScanLine size={16} />}>
{t("qr.scan.foreign")}
</Callout>
)}
<ol className={s.steps} data-jz-level="paragraph">
<li>{t("qr.scan.howto1")}</li>
<li>{t("qr.scan.howto2")}</li>
</ol>
<div className={s.actions}>
<Button variant="ghost" size="lg" block leftIcon={<ArrowLeft size={16} />} onClick={goBack}>
{t("qr.scan.back")}
</Button>
</div>
</AuthShell>
);
}