后端把「要不要再认证」压缩成一个结构化 403;前端把它变成一次不打断业务代码的验证体验。设计目标是业务代码零感知:页面调用 client.request(...),SDK 拦截器处理全部协议细节——收到引导型 403、拉起全局验证对话框、验证成功后携带凭据重放、把重放结果当作第一次调用的结果返回。
实现分布在两个包(BitzOrcas.Modern 仓库):协议拦截在 packages/platform-sdk/src/client/request-pipeline.ts,凭据缓存与并发去重在 packages/platform-sdk/src/security/step-up-session.ts,验证对话框在 apps/app/src/components/step-up-dialog.tsx。
拦截重放守卫四条
拦截器不是对一切 403 起作用,四条规则共同决定”拦不拦、放不放”:
- 命中条件收紧:仅当调用方已认证、HTTP 状态 403、ProblemDetails
type以step-up-required结尾、且响应根携带非空purpose字符串时才拦截。普通 403(无权限)、step-up-invalid、step-up-policy-unavailable一律原样上抛——前端不弹窗、不重试,把裁决留给调用方。 - 单请求只重放一跳:首次 403 触发验证后重放一次;若重放仍返回引导型 403(例如策略在两跳之间被关闭),该 403 直接作为调用结果返回,不再进入拦截分支——避免无限循环。
- 窗口凭据内存缓存:验证成功产出的窗口凭据(
windowSeconds > 0)按用途存入标签页内存 Map,窗口期内同用途请求命中缓存直接携带重放、不再弹窗;一次性凭据(windowSeconds = 0)不入缓存。禁止写入localStorage/sessionStorage——页面刷新自然清空,XSS 暴露面最小化,这是红线而非建议。 - 并发去重与登出清空:同用途并发触发的多个 403 共享一次进行中的验证(Promise 去重,失败不污染重试);登出、刷新失败、401 终态时清空全部缓存与进行中状态。跨标签页不共享凭据——多标签各自验证,窗口语义由服务端裁决。
// 业务代码视角:没有任何 step-up 痕迹,一切由拦截器完成。const result = await api.removeTeamMember(teamId, userId);
if (result.ok) { // 可能是"本来就不需要再认证",也可能是"拦截器已完成验证并重放"。 render(result.data);} else if (result.error.errorCode === "Identity.StepUp.GrantPurposeMismatch") { // 拒绝型 403 会原样到达业务代码:这是攻击信号级别的错误,提示并上报。 reportAnomaly(result.error);}// 普通 403 / step-up-invalid / policy-unavailable 同样原样到达,按需分别处理。测试这些语义不需要真实后端:vi.stubGlobal("fetch", …) 构造 403 响应即可驱动拦截器,BitzOrcas.Modern 仓库 packages/platform-sdk/src/client/request-pipeline.stepup.test.ts 是完整样例(重放头注入、重放上限、并发共享、取消语义、四类直通响应)。
验证对话框状态机
全局唯一对话框挂载在认证壳层,由 stepup:required / stepup:completed / stepup:cancelled 三个 window 事件驱动(不跨标签页共享)。状态机覆盖挑战闭环的全部失败分支:
三个状态机要点,都对应真实失败分支而不是理想路径:
- 因子切换复用同一挑战:挑战记录持有整个允许因子集,选邮箱 OTP(已发送)后返回改选 TOTP,直接换
verify入参即可,不重建挑战、不重置已消耗的频控配额。有效期内的切换同理。 - 取消必须归还原 403:用户按 Esc 或点击取消,进行中的验证 Promise reject,拦截器把最初那次 403 原样返回给业务调用方——用户取消不能被伪装成成功或超时。
- 挑战过期显式重开:
ChallengeExpired时对话框回到因子选择并签发新challengeId;重建失败时清空进行中任务,避免业务请求悬挂。
SDK 用法与挂载
对话框由应用壳层注册一次,业务路由无需任何接线:
// 文件:apps/app 认证壳层组件(与模拟登录确认框并排挂载)。// handler 即验证编排:收到 purpose 后拉起对话框,验证成功 resolve 凭据产出。usePlatform().registerStepUpChallengeHandler((payload) => openStepUpDialog({ purpose: payload.purpose, // 未映射的用途码回退显示码本身 }),);// 挑战闭环端点封装自带 skipStepUpIntercept(防递归);// 这些方法一般不直接调用——拦截器内部的编排就是调用它们完成的。import { createStepUpApi } from "@bitz/platform-sdk";
const stepUpApi = createStepUpApi(client);const preview = await stepUpApi.listStepUpFactors("identity.user.remove");// preview.data.isRequired 为 false 时(Feature 关闭/机器调用者/用途未强制),// 调用该用途端点不会被要求再认证——可用于账号安全页的解释性展示。用途到操作名的显示映射维护在 apps/app/src/pages/identity/components/step-up-messages.ts(中英成对),未映射的用途回退显示码本身——新增敏感操作时同步补一行映射,避免用户看到生硬的权限码。
测试与自查
前端接入的可测性设计:验证编排经 registerStepUpChallengeHandler 注入,测试可用假 handler 绕过对话框直测协议语义。自查清单:
- 断言首次请求不携带凭据头,重放请求携带且能覆盖调用方的陈旧头;
- 断言重放仍 403 时业务只收到一次 403(重放上限);
- 断言取消后调用方收到原始 403 且无残留弹窗;
- 断言 DevTools 的 Local/Session Storage 无任何凭据记录(红线检查);
- 断言登出后同用途操作重新弹窗(前端清缓存 + 后端版本撤销双保险)。
对应自动化测试位于 packages/platform-sdk/src/client/request-pipeline.stepup.test.ts(15 例)与 apps/app/tests/step-up-policy-panel-runtime.test.tsx(管理页交互),可按需扩展。