用户上午九点用密码加 TOTP 登录,下午三点手机被同事借用时点了一下「移除成员」。这次点击发生时,登录会话仍然有效——登录时的多因素认证证明的是「登录的那个人是谁」,回答不了「此刻操作的还是你本人吗」。会话有效期越长、设备共享越多,这道缝隙就越大。
敏感操作再认证(Step-Up re-authentication)把身份校验从「每个会话一次」细化为「每个敏感操作一次」:业务端点声明自己需要再认证,全局中间件在请求到达业务代码之前校验一张短时效的「再认证凭据」,凭据缺失就返回结构化的 403,由前端统一拉起验证对话框,验证通过后携带凭据重放请求。
与登录 MFA 的区别
两者共用因子内核(TOTP、OTP、恢复码),但在认证保证语义上是两件事。NIST SP 800-63B(SP 800-63B)将认证保证分级为 AAL1–AAL3(§4.3 “Authentication Assurance Levels”),会话管理与再认证时机的权衡交由部署方按风险决定——标准并未规定「敏感操作再认证不得超过 15 分钟」之类的条款,本文中出现的 300 秒 / 900 秒均为本平台出厂建议值,应按观察期数据调整。
| 维度 | 登录 MFA(多因素认证) | Step-Up 再认证 |
|---|---|---|
| 回答的问题 | 登录的人是谁 | 此刻操作的还是本人吗 |
| 校验时机 | 密码通过后、令牌签发前 | 令牌有效期内、敏感操作执行前 |
| 校验对象 | 五分钟登录挑战 token | 每个操作单独签发的再认证凭据 |
| 校验主体 | LoginFlow / MFA 端点 | 全局 StepUpEnforcementMiddleware |
| 覆盖范围 | 一次登录 | 冻结目录内的任意权限码用途 |
| 失败语义 | 拿不到访问令牌 | 结构化 403,会话本身不受影响 |
两者还有一条协同线:MFA 变更本身是敏感操作。禁用 MFA、删除 FIDO2 凭据、撤销受信设备等端点声明了再认证基线——攻击者就算偷到有效会话,也不能静默拆掉保护;反过来,MFA 变更成功会发布 UserMfaChangedIntegrationEvent,撤销该主体全部在途再认证凭据。
业界对标
| 系统 | 做法 | 与本平台的异同 |
|---|---|---|
| GitHub sudo mode | 敏感操作要求近期输入过密码,否则跳转密码确认页 | 同为「操作级再认证」;本平台用可插拔因子集替代仅密码 |
| Google 敏感操作验证 | 关键变更要求重新输入密码或二次验证 | 同上;Google 按操作枚举,本平台按用途码(权限码)配置 |
| AWS MFA-protected API | API 调用附带 GetSessionToken 签发的临时凭据码 | 同为「凭据随请求提交」;本平台凭据经专用响应头重放 |
| Azure AD Claims Challenge | 服务端下发 claims challenge 要求客户端重新认证 | 同为「服务端驱动的按需再认证」;本平台以 403 扩展字段承载挑战信息 |
OAuth 生态有标准化轨道:RFC 9470(OAuth 2.0 Step-Up Authentication Challenge Protocol)在 §3 “Authentication Requirements Challenge” 定义了资源服务器以 401 + WWW-Authenticate: Bearer error="insufficient_user_authentication" 携带 acr_values / max_age 参数要求客户端重新认证。本平台有意偏离该轨道:面向第一方 SPA,没有重定向回路和多授权服务器互操作需求,选择 403 + RFC 9457 ProblemDetails 扩展字段(purpose、factors、windowSeconds)换取更丰富的引导载荷。互操作边界:第三方若要对接,需要实现本平台的 403 契约而非 RFC 9470 客户端逻辑。
架构:一个强制点,三条来源
核心设计只有一句话:业务端点声明意图,全局中间件执行强制。声明与执行分离,端点零挂载、零感知——不存在任何端点级 Step-Up 过滤器,前端也不会遇到两套行为。
读图要点(对照源码 BitzOrcasVNext 仓库 src/Hosts/BitzOrcas.Api/StepUp/StepUpEnforcementMiddleware.cs):
- 用途解析在内存索引上完成(图中节点 2)。端点元数据里的
RequireStepUpAttribute声明优先;没有声明则取端点权限码,且只有当该权限码”可能被强制”(在冻结目录内)才继续。未命中强制面的请求只剩一次字典查找,没有数据库或 Redis 成本。 - 策略评估可豁免(节点 3)。Feature 总闸关闭、用途未被任何策略行或基线强制、机器调用者、AuditOnly 观察模式都会在这里放行;观察模式放行的同时写
StepUpAuditOnlyObserved安全审计——这是灰度的数据来源。 - 信任边界在凭据校验(节点 18–20)。凭据绑定租户 + 主体 + 用途 + 撤销版本四元组,Redis 中的版本计数器在登出、改密、MFA 变更时递增,使所有在途凭据立即失效。任何存储异常都按”策略不可用”403 拒绝(fail-closed),绝不降级放行。
- 挑战闭环是独立端点族(节点 6–16,
/api/identity/step-up/*),自身跳过再认证拦截,机器调用者与受限会话(强制改密、MFA 登记中的令牌)在该路径上被直接拒绝——防御纵深收口在唯一强制点,而不是散在端点里。
强制中间件在管线中的位置由组合根保证(src/Hosts/BitzOrcas.Api/Program.cs):认证 → 委托令牌 → 强制改密 → MFA 登记 → 租户解析 → 会话校验 → 权限注水 → 授权 → 当前用户作用域 → Step-Up 强制 → 限流。它必须晚于权限注水(要用注水后的权限码)与受限会话中间件(不重复裁决),早于限流(引导型 403 不消耗业务限流配额)。
三层策略模型
一个用途最终”是否强制、窗口多长、允许哪些因子”,由评估器按固定优先级裁决(src/Platform/Identity/BitzOrcas.Identity.Application/Identity/StepUp/StepUpPolicyEvaluator.cs):
机器调用者(Application/System/MCP 个人令牌)→ 一票豁免Feature 总闸 identity.stepup 关闭 → 一票豁免(全链路零行为)第一层:策略行(租户行命中优先于 Host 基线行)第二层:声明基线(端点 [RequireStepUp] 特性)第三层:无行无基线 → no-policy 豁免策略行是管理页可配置的数据面。同一用途可以有多行,解析规则有两条:
- 跨特异性层级:只取命中的最高层。特异性从高到低为 指定成员(Subjects)> 指定角色(Roles)> 指定权限(Permissions)> 全员(AllUsers)。例:某用途同时有”全员 Required 900s”和”成员甲 Off”两行,成员甲命中 Off 行(最高特异性),被豁免——这是”个人豁免压制全员强制”的标准形态。
- 同层级多行命中:取最严。强制级别 Required > AuditOnly > Off;同级同级别再取最小窗口;因子集取交集。
策略行本身受三层写入护栏约束:用途必须在启动冻结目录内;租户行窗口不得超过 Host 控制面的窗口上限;租户可配置面收窄为白名单时,白名单外用途拒绝创建;平台强制(Mandatory)用途只允许加严(不得 Off/AuditOnly、窗口不得超过基线、因子不得超出基线因子集)。违反护栏返回 Identity.StepUp.PolicyConflict,detail 携带逐字段原因。
这个模型已有生产实例:operations.sql-masking.manage(SQL 脱敏规则管理)经组合根代码声明清单挂了平台强制 + 一次性票据基线——任何租户都不能把它降级为观察模式;而 API Key 与 HMAC 客户端生命周期命令则完全由租户策略行驱动,零代码标记,作为”配置驱动覆盖面”的生产演示。
因子与验证内核
Step-Up 不发明新的验证器,六种因子全部复用 Identity 既有的验证内核(各因子的注册、时钟漂移与克隆检测语义见多因素认证):
| 因子 | wire 名 | 验证内核 | 适用面 |
|---|---|---|---|
| TOTP 验证器 | totp | MFA 连接器 TOTP 校验 | 已绑定验证器的用户;默认基线因子 |
| 通行密钥 | fido2 | WebAuthn 断言(W3C Web Authentication Level 3) | 规划中:契约占位保留,管理面与弹窗不渲染,底层断言接通后开放 |
| 邮箱 OTP | emailOtp | 邮件 OTP 服务 | 未绑定 MFA 的降级通道;响应携带脱敏目标 l***@example.com |
| 短信 OTP | smsOtp | 短信 OTP 服务 | 同上,受 SIM 风险约束,建议仅作过渡 |
| 密码 | password | 密码哈希重验 | 有密码的账号;交互成本最低 |
| 恢复码 | recoveryCode | 恢复码原子消费(TryConsume) | 丢失验证器时的自救通道 |
TOTP 底层为 RFC 6238(TOTP: Time-Based One-Time Password Algorithm),其 §5.2 讨论了时钟漂移下的验证窗口处理;HOTP 底层为 RFC 4226。本平台不重复定义这些算法参数,时钟策略与漂移窗口沿用 MFA 连接器实现。
场景走查:丢失验证器的自解锁
产品经理小陈只绑定了 TOTP,手机进水送修。她仍需在系统里发起一次付款确认(策略行允许 recoveryCode):
- 她点击确认,前端收到引导型 403,弹窗因子列表里出现”恢复码”选项;
- 她从密码管理器里取出一枚恢复码输入——恢复码是单行输入、没有重发按钮、大小写不敏感提示;
- 服务端
TryConsume原子消费这一枚(并发下至多成功一次),签发再认证凭据; - 前端提示”恢复码已消费”,请求重放成功;
- 账号安全页的”二次验证”卡片显示剩余枚数;低于 3 枚时出现低存量警示并引导重新生成。
如果恢复码也耗尽,剩下的通道是管理员侧的恢复流程(验证身份所有权、撤销受信设备、轮换恢复码),而不是任何客户端可触达的”绕过验证”。
安全语义
fail-closed 清单——以下情况一律拒绝,绝不降级放行:
- 策略快照读取失败(Redis / 数据库异常)→ 403
Identity.StepUp.PolicyUnavailable; - 凭据存储异常 → 同上(中间件统一捕获,仅豁免取消与内存耗尽);
- 凭据有效但版本键已消失(凭据 TTL 内被外部删除)→ 403
GrantInvalid+ 高危审计reason=versionKeyMissing; - 挑战闭环端点被机器调用者或受限会话访问 → 403
ChallengeNotAllowed。
主体版本撤销与 TTL 边界:每个(租户,主体)有一个撤销版本计数器,凭据签发时记录当时版本,校验时不一致即拒绝。登出、改密、MFA 变更事件把计数器 +1,在途凭据立即失效。计数器 TTL 按”窗口上限 + 挑战 TTL + 安全余量”动态计算(出厂 900 + 120 + 60 = 1080 秒),保证任何仍可能存活的凭据必有存活的版本键可查——这是”版本键缺失”成为攻击信号而非正常态的原因。
TOCTOU 与检查-执行边界:凭据校验发生在中间件、业务执行发生在 Handler,两者之间没有原子事务。一次性票据消费即失效,“校验通过但业务失败”时票据不回滚——这是有意取舍:重放窗口归零优先于业务重试体验,票据烧掉的损失由用户重新验证一次来承担。所以窗口选择建议:低频高危操作(支付、权限变更)用一次性票据(窗口 0)或短窗口;高频批量场景(一条清单里连续多条敏感操作)用 300–900 秒窗口,避免用户被反复打断。
前端红线:再认证凭据只存标签页内存,禁止写入 localStorage / sessionStorage——页面刷新自然清空,XSS 暴露面最小化。详见前端接入。