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
271 lines
9.3 KiB
TypeScript
271 lines
9.3 KiB
TypeScript
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>
|
||
);
|
||
}
|
||
|
||
// suggestDeviceName:UA 预填一个可读的默认设备名(与 /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")}`;
|
||
}
|