本教程把一个真实业务端点——「移除团队成员」——从零接入敏感操作再认证。三条路径对应三种接入姿态,按业务敏感度选择:批量运营操作走策略行(零代码)、平台硬约束走声明基线(一行特性)、资金类操作用一次性票据(当前为规划能力,页内给出替代方案)。三条路径可叠加:同一端点可以先声明基线兜底,再由租户策略行按人群收窄。
前置条件:
- BitzOrcas API Host 可运行,Redis 已连接(凭据与撤销版本存储依赖 Redis);
- 演示账号具有
identity.stepup.manage权限(种子默认授予root-admin/tenant-admin/host-admin); - 功能总闸
identity.stepup已开启(Host 管理端「功能分发管理」页,种子默认关闭——关闭时全链路零行为,这是设计上的安全回滚通道)。
最终产物:未携带凭据调用该端点得到结构化 403;完成一次验证后携带凭据重放成功;窗口期内重复调用不再弹验证。
准备:确认总闸与目录
先确认 Feature 总闸与用途目录,避免”配置了却不生效”的困惑。
# 在仓库根目录启动 API Host(环境按部署文档装配,需含 Redis 连接)dotnet run --project src/Hosts/BitzOrcas.Api
# 用管理员令牌查询当前用户会被要求二次验证的用途清单;# 返回空数组通常意味着总闸关闭或尚无任何策略行/声明基线。curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ -X QUERY https://localhost:5001/api/identity/step-up/my-purposes验证点:Host 启动日志无 Step-Up 相关异常;my-purposes 返回 200。若 Host 使用 Testing 环境或未配置 Redis,identity.stepup 会被锁死为关闭——这是无 Redis 部署的防误开启设计,不是故障。
路径一:管理页自建策略行(零代码)
适合”某租户的某类操作需要加验”的运营诉求,全程不改编译产物。
- 以租户管理员登录,进入「组织与访问控制」→「再认证策略」Tab;
- 点击「新增保护」,目录选择器里搜索目标权限码(例如
identity.user.remove)——目录只读、无自由文本,已有行的用途会被置灰并说明原因; - 强制级别先选「仅观察」(AuditOnly,表单常驻此建议提示),窗口 300 秒,允许因子保持
totp+emailOtp,范围「全员」; - 保存。列表出现新行,行属标签为「租户覆盖」,「最近修改」列记录操作人与时间;
- 切到「强制」前,先在观察期收集
StepUpAuditOnlyObserved审计(安全审计页按Module=Identity、动作过滤),确认命中量与用户体验可接受后再切换。
等效的管理面 API 调用(供自动化流水线使用):
# 创建策略行:范围 allUsers、窗口 300 秒、仅观察;# expectedVersion 不需要(新建);冲突场景(同用途同范围已有行)返回 422 PolicyConflict。curl -s -X POST https://localhost:5001/api/identity/step-up/policies \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "purposeCode": "identity.user.remove", "enforcement": "auditOnly", "windowSeconds": 300, "factors": ["totp", "emailOtp"], "scopeType": "allUsers", "scopeValues": [], "unenrolledBehavior": "fallbackFactors" }'验证点:返回 200 与行投影(含 id、version);再次打开管理页能看到该行;随后无凭据调用一次成员移除端点——观察模式下请求放行,但安全审计页出现 StepUpAuditOnlyObserved 记录。
预期失败一(护栏拒绝):把窗口改成 1200 秒再保存,得到 422 Identity.StepUp.PolicyConflict,detail 为「窗口秒数超过 Host 上限 900。」——租户行受 Host 控制面窗口上限约束,这是逐字段拒绝而非整体失败。修复:改回上限内的窗口;确需更长窗口时由平台管理员在 Host「安全基线」页调高上限(上限受 86400 秒技术防呆天花板约束)后再保存。
预期失败二(重复行):同一用途、同范围再建一行,得到 422 PolicyConflict「同租户、同用途、同作用范围已存在策略行。」。修复:编辑既有行而不是新建。
路径二:声明基线(一行特性)
适合”这个操作在任何租户都必须再认证”的平台硬约束。在业务端点上挂 RequireStepUpAttribute——它是纯元数据,强制逻辑仍在全局中间件:
// 最小 API 端点挂声明基线:用途码默认即权限码,无需重复声明。group.MapDelete("/api/business/teams/{teamId}/members/{userId}", ...) .RequirePermission("identity.user.remove") // RBAC 仍然先行:无权限者到不了再认证 .WithMetadata(new RequireStepUpAttribute("identity.user.remove") { // 以下均为"无策略行时的基线默认值",租户策略行可在护栏内覆盖。 Mandatory = false, // 平台强制用途置 true:租户不可关闭或放宽 DefaultEnforcement = StepUpEnforcement.Required, // 基线强制级别(默认即 Required) DefaultWindowSeconds = 300, // 基线窗口;0 表示一次性票据 DefaultFactors = [StepUpFactor.Totp, StepUpFactor.EmailOtp], });# 编译并启动,确认声明被冻结目录收录:dotnet build src/Hosts/BitzOrcas.Api
# 验证点一:无凭据调用得到引导型 403,响应根平铺 purpose/factors/windowSeconds。curl -s -i -X DELETE https://localhost:5001/api/business/teams/t1/members/u9 \ -H "Authorization: Bearer $USER_TOKEN" | grep -E "HTTP|type|purpose|errorCode"预期输出包含 HTTP/1.1 403、type: .../problems/step-up-required、purpose: identity.user.remove、errorCode: Identity.StepUp.GrantRequired。
声明集在组合根启动时冻结:同一用途码多处声明但基线参数不一致会启动失败(快速失败,防止”哪个基线生效”变成玄学)。对于源生成器端点无法携带端点元数据的场景,平台在组合根提供代码基线声明清单(BitzOrcasVNext 仓库 src/Hosts/BitzOrcas.Api/StepUp/StepUpBaselineDeclarations.cs,试点用途为 operations.sql-masking.manage:平台强制 + 一次性票据),清单中的每个用途都要通过接线校验——必须存在以同权限码承载的真实端点,否则宿主拒绝启动,杜绝”声明悬空后静默失去保护”。同一端点没有特性时,中间件自动落到端点权限码用途——所以”权限码即用途”的端点甚至可以零声明,由纯策略行驱动。
完成一次验证闭环
三条路径共用同一个验证闭环。以邮箱因子为例(用户未绑定 TOTP 时的降级通道):
POST /api/identity/step-up/challenge HTTP/1.1Authorization: Bearer <access-token>Content-Type: application/json
{ "purpose": "identity.user.remove" }{ "challengeId": "9fJ2vQ8rT3wL5nX1cZ7bA0dK4mH6pS2yE8uG1iO5aB3", "expiresInSeconds": 120, "windowSeconds": 300, "factors": [ { "type": "emailOtp", "target": "l***@example.com" } ]}POST /api/identity/step-up/otp/send HTTP/1.1Authorization: Bearer <access-token>Content-Type: application/json
{ "challengeId": "9fJ2vQ…", "factorType": "emailOtp" }验证码到手后提交验证,响应中的 stepUpToken 就是再认证凭据:
POST /api/identity/step-up/verify HTTP/1.1Authorization: Bearer <access-token>Content-Type: application/json
{ "challengeId": "9fJ2vQ…", "factorType": "emailOtp", "code": "482913" }{ "stepUpToken": "Ak7Qx2Wm9Rf4Tt1Lz8Nb0Vc5Yh3Jd6Pg2Se9Ua1Wr4X=", "purpose": "identity.user.remove", "windowSeconds": 300, "expiresAt": "2026-09-02T08:15:00Z"}# 重放业务请求:凭据经专用响应头提交,头名可配(出厂 X-Step-Up-Token)。curl -s -X DELETE https://localhost:5001/api/business/teams/t1/members/u9 \ -H "Authorization: Bearer $USER_TOKEN" \ -H "X-Step-Up-Token: Ak7Qx2Wm9Rf4Tt1Lz8Nb0Vc5Yh3Jd6Pg2Se9Ua1Wr4X="验证点:
- 窗口期内(300 秒)再次调用同一用途端点,无需再带新验证——窗口凭据可重复使用;
- 第一方 SPA 中以上全部由 SDK 拦截器自动完成,业务代码无感(见前端接入);
- 登出后立刻用旧凭据重放 → 403
GrantInvalid(登出撤销版本递增)。
预期失败三(频控):一分钟内对同一挑战重发 OTP 超过间隔(出厂 60 秒),otp/send 返回 429 Identity.StepUp.RateLimited。修复:按响应 retryAfterSeconds 倒计时后重试;前端弹窗已内置该倒计时。
资金类操作:一次性票据(规划中)
资金类操作的理想形态是”验证一次、执行一次”:凭据为一次性票据(windowSeconds: 0),业务命令内联消费,杜绝校验通过后凭据被复用的检查-执行间隙(TOCTOU)。该命令内联模式当前为规划能力,尚未交付——不要在手册之外的实施里假设它已存在。
当前替代方案(已交付语义):为资金类端点声明 DefaultWindowSeconds = 0 的基线或建一次性策略行,让凭据消费即失效;业务 Handler 内保留金额、状态机与幂等键校验,把”验证通过但业务失败”的票据损失收敛为一次重新验证。命令内联的架构仲裁记录见 BitzOrcasVNext 仓库 ADR 0706;窗口选择权衡的完整讨论见概念页安全语义。