Skip to content
bitzorcas
中EN

Guide

再认证前端接入

第一方 SPA 的再认证协议实现:403 拦截重放守卫四条规则、验证对话框状态机、凭据仅内存红线与 SDK 用法。业务代码零感知。

Last updated

后端把「要不要再认证」压缩成一个结构化 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 起作用,四条规则共同决定”拦不拦、放不放”:

  1. 命中条件收紧:仅当调用方已认证、HTTP 状态 403、ProblemDetails type 以 step-up-required 结尾、且响应根携带非空 purpose 字符串时才拦截。普通 403(无权限)、step-up-invalid、step-up-policy-unavailable 一律原样上抛——前端不弹窗、不重试,把裁决留给调用方。
  2. 单请求只重放一跳:首次 403 触发验证后重放一次;若重放仍返回引导型 403(例如策略在两跳之间被关闭),该 403 直接作为调用结果返回,不再进入拦截分支——避免无限循环。
  3. 窗口凭据内存缓存:验证成功产出的窗口凭据(windowSeconds > 0)按用途存入标签页内存 Map,窗口期内同用途请求命中缓存直接携带重放、不再弹窗;一次性凭据(windowSeconds = 0)不入缓存。禁止写入 localStorage / sessionStorage——页面刷新自然清空,XSS 暴露面最小化,这是红线而非建议。
  4. 并发去重与登出清空:同用途并发触发的多个 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 事件驱动(不跨标签页共享)。状态机覆盖挑战闭环的全部失败分支:

stepup:required选邮箱/短信 OTP投递成功(倒计时resendAllow)因子不可用(置灰)选 TOTP/恢复码/密码verify 成功码错(CodeInvalid,余 4次机会)ChallengeExpiredTooManyAttempts /RateLimited重新开始(新 challengeId)锁定倒计时后退出EnrollmentRequired跳转 MFA 绑定引导stepup:completed取消(stepup:cancelled,业务收到原 403)

FactorSelect

OtpSending

CodeInput

Completed

Expired

Locked

Enrollment

三个状态机要点,都对应真实失败分支而不是理想路径:

  • 因子切换复用同一挑战:挑战记录持有整个允许因子集,选邮箱 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(管理页交互),可按需扩展。

相关主题

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%