Files
Commilitia-Drop/web/src/features/qr/stepUp.ts
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

129 lines
5.4 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.
// 批准页 step-up 再认证的会话级暂存。step_up 开启时,批准方在批准新设备前必须做
// 一次新鲜的 prompt=login 再登录(provider 若开了 2FA 即在此处过),把这次拿到的
// 授权码交给后端就地核验。这段往返跨一次整页跳转(→ provider → /oauth/callback →
// 批准页),故用 sessionStorage 串起三个状态:
//
// 1. 跳转去 provider 前:记下「待办」标记 + 这次的 code_verifier + 要回到的批准页
// URLPENDING)。
// 2. /oauth/callback 落地(检测到 PENDING):不调 /api/auth/exchange(那会在服务端
// 消费掉 code 并轮换会话),转而把本次回调的 {code, verifier} 暂存进 STASH
// 清掉 PENDING,整页跳回批准页 URL。
// 3. 批准页回到后:取出 STASH 的 {code, verifier} → 调 qrApprove 就地核验 → 用完即清。
//
// sessionStorage(非 localStorage):仅本次再认证往返需要,关标签即清、不跨设备;
// code 是一次性的且与本批准会话强绑定,落 localStorage 反而徒增泄漏面。
const PENDING_KEY = "cdrop.qr.stepup_pending"; // { verifier, returnTo }
const STASH_KEY = "cdrop.qr.stepup_stash"; // { code, verifier }
// 登录会话登出的 step-up:跨整页跳转记住「要登出的是哪条会话」。回到设置页后取出,
// 重试那条 DELETE。批准页的 step-up 不用它(要批准的 request 已编在 returnTo 里)。
const REVOKE_KEY = "cdrop.qr.stepup_revoke"; // sessionIdstring
// 仅允许两个站内回流目标,杜绝 open-redirect(外部 URL / 协议相对地址 / 任意路径):
// - /link[?…] ——批准页(扫码登录的 step-up);
// - /settings ——设置页(登录会话登出的 step-up)。
// step-up 往返回来后的整页跳转目标只能是这两处之一;其余一律拒。校验 path 必须以
// "/link" 或 "/settings" 起头且后随串只能是空或 "?…",挡住 "/linkmalicious" /
// "//evil.com"(协议相对)/ "/settings/../x" 之类绕过。
function isSafeReturnPath(path: string): boolean
{
for (const base of [ "/link", "/settings" ])
{
if (path === base) { return true; }
if (path.startsWith(base + "?")) { return true; }
}
return false;
}
export interface StepUpPending
{
verifier: string;
returnTo: string;
}
export interface StepUpStash
{
code: string;
verifier: string;
}
// stashStepUpPending 在跳转去 provider 前记下「待办」。returnTo 非法(防开放重定向)
// 即不写——调用方据返回值判定是否安全发起跳转。
export function stashStepUpPending(pending: StepUpPending): boolean
{
if (typeof window === "undefined") { return false; }
if (!pending.verifier || !isSafeReturnPath(pending.returnTo)) { return false; }
window.sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending));
return true;
}
// peekStepUpPending 只读地探测是否在 step-up 往返中(/oauth/callback 据此分叉,
// 决定不走 /api/auth/exchange)。不消费。
export function peekStepUpPending(): StepUpPending | null
{
if (typeof window === "undefined") { return null; }
const raw = window.sessionStorage.getItem(PENDING_KEY);
if (!raw) { return null; }
const p = safeParse<StepUpPending>(raw);
if (!p?.verifier || !isSafeReturnPath(p.returnTo)) { return null; }
return p;
}
export function clearStepUpPending(): void
{
if (typeof window === "undefined") { return; }
window.sessionStorage.removeItem(PENDING_KEY);
}
// stashStepUpCode 把回调拿到的一次性 code + 配套 verifier 暂存,留给批准页交给
// 后端 approve 核验。前端绝不自己 POST /exchange 消费它。
export function stashStepUpCode(stash: StepUpStash): void
{
if (typeof window === "undefined") { return; }
if (!stash.code || !stash.verifier) { return; }
window.sessionStorage.setItem(STASH_KEY, JSON.stringify(stash));
}
// consumeStepUpCode 取出并清除暂存的 {code, verifier}(一次性,用完即清,避免泄漏
// 与复用)。无 / 非法返回 null。
export function consumeStepUpCode(): StepUpStash | null
{
if (typeof window === "undefined") { return null; }
const raw = window.sessionStorage.getItem(STASH_KEY);
window.sessionStorage.removeItem(STASH_KEY);
if (!raw) { return null; }
const s = safeParse<StepUpStash>(raw);
if (!s?.code || !s.verifier) { return null; }
return s;
}
export function clearStepUpStash(): void
{
if (typeof window === "undefined") { return; }
window.sessionStorage.removeItem(STASH_KEY);
}
// stashPendingRevoke 在发起会话登出的 step-up 再认证前记下待登出的 sessionId,
// 跨整页跳转留到回到设置页后取出重试。
export function stashPendingRevoke(sessionId: string): void
{
if (typeof window === "undefined") { return; }
if (!sessionId) { return; }
window.sessionStorage.setItem(REVOKE_KEY, sessionId);
}
// consumePendingRevoke 取出并清除待登出的 sessionId(一次性,用完即清)。
export function consumePendingRevoke(): string | null
{
if (typeof window === "undefined") { return null; }
const id = window.sessionStorage.getItem(REVOKE_KEY);
window.sessionStorage.removeItem(REVOKE_KEY);
return id || null;
}
function safeParse<T>(raw: string): T | null
{
try { return JSON.parse(raw) as T; }
catch { return null; }
}