018a77b13a
接续 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
260 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|