登录子系统: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
This commit is contained in:
2026-06-22 11:55:02 +08:00
parent 90a3790a98
commit 018a77b13a
45 changed files with 2235 additions and 299 deletions
+289 -107
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import {
Anchor,
Container,
@@ -8,9 +8,12 @@ import {
Title,
} from "@mantine/core";
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
import { Bell, LogOut, Trash2 } from "lucide-react";
import { logout, syncWebDeviceName } from "../features/auth/auth";
import { AlertTriangle, Bell, LogOut, ShieldAlert, Trash2 } from "lucide-react";
import { logout, startStepUpReauth, 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 {
@@ -20,25 +23,15 @@ import {
pushSupported,
type PushState,
} from "../net/push";
import { useAppStore, type DeviceInfo } from "../store";
import {
listSessions, postStepUp, revokeSession, StepUpRequiredError,
type AuthSession,
} from "../net/sessions";
import { useAppStore } from "../store";
import { t } from "../i18n";
import { formatRelative, isAsciiDeviceName } from "../utils/format";
import { Badge, Button, Panel, TextField } from "../ui/primitives";
import { Badge, Button, Callout, Panel, TextField } from "../ui/primitives";
import { StateDot } from "../ui/glyphs";
// deviceTypeLabel 把后端存的设备类型本地化展示(浏览器 / macOS 客户端 / …)。
// 容错匹配(与 DeviceStrip 的 mapKind 一致),未知类型回退原值。
function deviceTypeLabel(type: string): string
{
const k = type.toLowerCase();
if (k.includes("mac") || k === "darwin") { return t("deviceType.macos"); }
if (k.includes("win")) { return t("deviceType.windows"); }
if (k.includes("linux")) { return t("deviceType.linux"); }
if (k === "shortcut") { return t("deviceType.shortcut"); }
if (k.includes("ios") || k === "iphone" || k === "ipad") { return t("deviceType.ios"); }
if (k === "browser") { return t("deviceType.browser"); }
return type;
}
import { toast } from "../ui/feedback";
export const Route = createFileRoute("/settings")({
@@ -55,19 +48,14 @@ function SettingsPage()
{
const navigate = useNavigate();
const user = useAppStore((s) => s.user);
const sessionScope = useAppStore((s) => s.sessionScope);
const isGuest = sessionScope === "guest";
const selfDeviceName = useAppStore((s) => s.selfDeviceName);
const setSelfDeviceName = useAppStore((s) => s.setSelfDeviceName);
const storeDevices = useAppStore((s) => s.devices);
const [ pendingName, setPendingName ] = useState(selfDeviceName ?? "");
const [ removing, setRemoving ] = useState<Set<string>>(new Set());
const [ renaming, setRenaming ] = useState(false);
const peerDevices = useMemo(
() => storeDevices.filter((d) => d.name !== selfDeviceName),
[ storeDevices, selfDeviceName ],
);
const trimmedName = pendingName.trim();
const canSaveName = !!trimmedName && trimmedName !== selfDeviceName;
@@ -118,48 +106,6 @@ function SettingsPage()
}
};
const handleRemovePeer = async (name: string) =>
{
if (!window.confirm(t("settings.unregister.peerConfirm", { name }))) { return; }
setRemoving((prev) =>
{
const next = new Set(prev);
next.add(name);
return next;
});
try
{
const r = await apiFetch(
`/api/devices/${encodeURIComponent(name)}`,
{ method: "DELETE" },
);
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`${r.status}: ${text}`);
}
// 服务端的 publishPresence 会通过 SSE 推送新的设备列表,store
// 会随之更新,这里不需要手动 setDevices。
toast.ok("设备已移除", name);
}
catch (e)
{
toast.error(t("settings.error.delete", {
message: e instanceof Error ? e.message : String(e),
}));
}
finally
{
setRemoving((prev) =>
{
const next = new Set(prev);
next.delete(name);
return next;
});
}
};
const handleUnregisterSelf = async () =>
{
if (!selfDeviceName) { return; }
@@ -194,12 +140,27 @@ function SettingsPage()
<Container size="sm" py="lg">
<Stack gap="lg">
<Group justify="space-between" align="center" wrap="nowrap">
<Title order={2}>{t("settings.title")}</Title>
<Group gap="xs" align="center" wrap="nowrap">
<Title order={2}>{t("settings.title")}</Title>
{isGuest && <Badge tone="warn">{t("settings.guest.badge")}</Badge>}
</Group>
<Anchor component={Link} to="/" size="sm">
{t("nav.backToHome")}
</Anchor>
</Group>
{/* 受限访客标识:说明这台设备不能管理账号 / 授权设备,要完整权限请在
主设备上重新登录。放在最显眼处(标题下第一块)。 */}
{isGuest && (
<Callout
tone="warn"
icon={<ShieldAlert size={16} />}
title={t("settings.guest.title")}
>
{t("settings.guest.body")}
</Callout>
)}
<Panel title={t("settings.currentDevice.title")}>
<Stack gap="sm">
<TextField
@@ -226,7 +187,7 @@ function SettingsPage()
</Button>
</Group>
<div style={{ height: 1, background: "var(--divider)" }} />
<Text size="sm" c="dimmed">
<Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">
{t("settings.unregister.currentHint")}
</Text>
<Group>
@@ -245,22 +206,10 @@ function SettingsPage()
{pushSupported() && <PushPanel />}
<Panel title={t("settings.peers.title")}>
{peerDevices.length === 0 ? (
<Text size="sm" c="dimmed">{t("settings.peers.empty")}</Text>
) : (
<Stack gap="xs">
{peerDevices.map((d) => (
<PeerRow
key={d.name}
device={d}
removing={removing.has(d.name)}
onRemove={() => handleRemovePeer(d.name)}
/>
))}
</Stack>
)}
</Panel>
<SessionsPanel
isGuest={isGuest}
onSignedOutSelf={handleSignOut}
/>
<Panel title={t("settings.account.title")}>
<Stack gap="sm">
@@ -335,9 +284,9 @@ function PushPanel()
return (
<Panel title={t("settings.push.title")}>
<Stack gap="sm">
<Text size="sm" c="dimmed">{t("settings.push.hint")}</Text>
<Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.push.hint")}</Text>
{denied && !subscribed && (
<Text size="sm" style={{ color: "var(--state-error)" }}>
<Text size="sm" className="justify" data-jz-level="paragraph" style={{ color: "var(--state-error)" }}>
{t("settings.push.deniedHint")}
</Text>
)}
@@ -371,37 +320,270 @@ function PushPanel()
);
}
function PeerRow(props: { device: DeviceInfo; removing: boolean; onRemove: () => void })
// sortSessions 把会话排成「在线在上、未活跃(offline)在下」,组内按最近活跃时间降序
// (活跃越近越靠前)。不改变原数组,返回新数组。
function sortSessions(list: AuthSession[]): AuthSession[]
{
const { device, removing, onRemove } = props;
const lastSeenText = device.lastSeen
? t("settings.lastSeen", { time: formatRelative(device.lastSeen) })
: t("settings.lastSeenNever");
return [ ...list ].sort((a, b) =>
{
if (a.online !== b.online) { return a.online ? -1 : 1; }
return b.lastUsedAt - a.lastUsedAt;
});
}
// SessionsPanel:登录会话管理——浏览器 / 扫码登录的设备会话。每条显示在线状态点 +
// 设备名 + 权限级别 Badge + 「本机」标记 + 最近活跃,并提供「登出」(真正吊销该登录
// 的访问令牌)。在线会话排在上、未活跃(offline)的排在下;离线会话仍可登出。
//
// step-upDELETE 返回 403 step_up_required 时,把待登出的 sessionId 暂存 → 发起
// 一次 prompt=login 再认证(returnTo=/settings)→ 整页跳走 → 回来后由本组件 mount
// effect 取出暂存的 {code, verifier} + sessionIdPOST /auth/stepup 记录窗口,再
// 重试那条 DELETE。step-up 窗口内(后端 5 分钟)后续登出不再 403、直接成功。
//
// 受限访客(isGuest):无权管理登录会话、后端 GET 会 403,故根本不发该请求,直接展示
// 一句克制的说明 Callout(不出现任何加载 / 失败态)。
function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
{
const { isGuest, onSignedOutSelf } = props;
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 之后再拉——若登出的正是本机会话,
// 令牌已失效、那次 listSessions 会 401)。在线会话排前、未活跃的排后,组内按最近
// 活跃时间降序。失败回 null。受限访客不会走到这里(调用方在 guest 分支直接返回)。
const reload = useCallback(async (): Promise<AuthSession[] | null> =>
{
setLoadError(false);
try
{
const list = sortSessions(await listSessions());
setSessions(list);
return list;
}
catch { setLoadError(true); setSessions(null); return null; }
}, []);
// doRevoke 真正执行一条登出。current(本机)会话登出后清本地 auth、回登录页
// (等价退出登录);其余刷新列表。403 step_up_required 由调用方分流,不入此处。
const finishRevoke = useCallback((sess: AuthSession) =>
{
if (sess.current) { onSignedOutSelf(); return; }
toast.ok(t("settings.sessions.revoked"));
void reload();
}, [ onSignedOutSelf, reload ]);
// 进页面拉一次。返回时若检测到 step-up 往返暂存({code, verifier} + 待登出 id),
// 先 POST /auth/stepup 记录窗口,再重试那条 DELETE——一次性串起整页跳转两端。
// 受限访客无权管理会话、后端 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 ]);
const handleRevoke = async (sess: AuthSession) =>
{
const confirmMsg = sess.current
? t("settings.sessions.confirmCurrent")
: t("settings.sessions.confirmOther", { name: sess.deviceName });
if (!window.confirm(confirmMsg)) { return; }
setBusyId(sess.id);
setStepUpRejected(false);
try
{
await revokeSession(sess.id);
finishRevoke(sess);
}
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),
}));
}
}
finally { setBusyId(null); }
};
// 受限访客:不展示列表 / 加载 / 失败态,只给一句克制的说明——管理登录会话需在主设备
// 上完整登录。后端 GET 本就 403,前面 effect 也已跳过请求,这里纯文案分支。
if (isGuest)
{
return (
<Panel title={t("settings.sessions.title")}>
<Callout tone="warn" icon={<ShieldAlert size={16} />}>
{t("settings.sessions.guestNote")}
</Callout>
</Panel>
);
}
return (
<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)" }}>
{t("settings.sessions.loadError")}
</Text>
<Button size="sm" variant="ghost" onClick={() => void reload()}>
{t("settings.sessions.retry")}
</Button>
</Group>
) : sessions === null ? (
// 仍在加载(首屏闪一帧):不渲染占位文案,避免「暂无」误闪。
null
) : sessions.length === 0 ? (
<Text size="sm" c="dimmed">{t("settings.sessions.empty")}</Text>
) : (
<Stack gap="xs">
{sessions.map((sess) => (
<SessionRow
key={sess.id}
session={sess}
busy={busyId === sess.id}
// 在线、离线会话都可登出(离线亦走 step-up,流程一致)。
onRevoke={() => handleRevoke(sess)}
/>
))}
</Stack>
)}
</Stack>
</Panel>
);
}
// 会话种类标签:网页 / 扫码会话用 settings.sessions.kind.*;原生客户端
// macos / windows / linux / ios)复用设备类型标签(「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);
}
function SessionRow(props: { session: AuthSession; busy: boolean; onRevoke?: () => void })
{
const { session, busy, onRevoke } = props;
const lastUsedText = session.lastUsedAt
? t("settings.sessions.lastUsed", { time: formatRelative(session.lastUsedAt) })
: t("settings.sessions.neverUsed");
const kindLabel = sessionKindLabel(session.kind);
const onlineLabel = t(session.online ? "settings.online" : "settings.offline");
return (
<Panel variant="sunken" padding="tight">
<Group justify="space-between" wrap="nowrap" align="center">
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
<Group gap={6} wrap="nowrap">
<StateDot tone={device.online ? "online" : "offline"} />
<Text fw={500} size="sm" truncate>{device.name}</Text>
<Badge tone={device.online ? "online" : "neutral"}>
{t(device.online ? "settings.online" : "settings.offline")}
<StateDot
tone={session.online ? "online" : "offline"}
title={onlineLabel}
/>
<Text fw={500} size="sm" truncate>
{session.deviceName || kindLabel}
</Text>
<Badge tone={session.scope === "guest" ? "warn" : "accent"}>
{session.scope === "guest"
? t("settings.sessions.scopeGuest")
: t("settings.sessions.scopeFull")}
</Badge>
{session.current && (
<Badge tone="online">{t("settings.sessions.current")}</Badge>
)}
</Group>
<Text size="xs" c="dimmed" truncate>
{deviceTypeLabel(device.type)} · {lastSeenText}
{onlineLabel} · {kindLabel} · {lastUsedText}
</Text>
</Stack>
<Button
size="sm"
variant="ghost"
loading={removing}
onClick={onRemove}
style={{ color: "var(--state-error)" }}
>
{removing ? t("settings.peers.removing") : t("settings.peers.remove")}
</Button>
{onRevoke && (
<Button
size="sm"
variant="ghost"
loading={busy}
leftIcon={<LogOut size={13} />}
onClick={onRevoke}
style={{ color: "var(--state-error)" }}
>
{busy
? t("settings.sessions.signingOut")
: session.current
? t("settings.sessions.signOutCurrent")
: t("settings.sessions.signOut")}
</Button>
)}
</Group>
</Panel>
);
}