BitzOrcas 的 MFA 不是一个登录页复选框。Identity 登录流负责决定“密码通过后是否还要挑战”,MFA 连接器负责 TOTP、FIDO2、邮件/短信 OTP 和受信设备,最终令牌只在挑战完成后签发。
登录状态机
LoginFlow 在用户已启用双因素或风险判断要求 MFA 时创建挑战。挑战 token 的用途为 MFA/Challenge,有效期固定五分钟;挑战态不会签发访问令牌或刷新令牌。客户端必须把它当作短期过渡凭据。
TOTP 设置与登录完成
已登录用户通过 /api/mfa/setup 获取设置材料,确认第一个验证码后才应把因子视为可用。匿名 /api/mfa/verify 用于完成登录挑战,因此安全边界依赖短期 MFA token,而不是已有 access token。
POST /api/mfa/setup HTTP/1.1Authorization: Bearer <access-token>Content-Type: application/json
{ "accountName": "alice@example.com"}POST /api/mfa/verify HTTP/1.1Content-Type: application/json
{ "mfaToken": "<five-minute-challenge>", "code": "123456"}TOTP 依赖服务端和认证器时钟。允许漂移窗口要足够容忍正常误差,但不能用过大的窗口掩盖 NTP 故障。设置 secret 与恢复码只应展示一次;截图、日志和遥测都不应记录它们。
因子与端点
MfaEndpointGroup 当前映射以下能力:
| 因子或操作 | 端点族 | 是否需要现有登录 |
|---|---|---|
| TOTP 设置、禁用 | /api/mfa/setup, /disable | 是 |
| 登录 MFA 验证 | /api/mfa/verify | 否,持挑战 token |
| FIDO2 注册 | /api/mfa/fido2/register/* | 是 |
| FIDO2 断言 | /api/mfa/fido2/assert/* | 否,持挑战上下文 |
| 邮件 OTP | /api/mfa/email-otp/* | 是 |
| 短信 OTP | /api/mfa/sms-otp/* | 是 |
| 受信设备 | /api/mfa/trusted-device/* | 是 |
这些 Endpoint 只注入 IMediator,具体操作经过 Command、Handler 和平台端口。端点存在不等于外部短信、邮件或 FIDO2 生产配置已经就绪;Provider、RP ID、Origin、持久化和限流仍是部署责任。
FIDO2 / WebAuthn
FIDO2 注册分 begin/complete,断言也分 begin/complete。begin 产生的 challenge 必须与 tenant、user、RP 和短期会话绑定,complete 校验客户端数据、origin、RP ID、签名和计数器。
注册:已登录用户 → begin options → 浏览器创建凭据 → complete 持久化断言:登录挑战 → begin options → authenticator 签名 → complete 验证当前实现具有凭据仓储与 sign-count 克隆检测测试。生产域名切换时必须同步更新 RP ID 和 Origin;错误地放宽 Origin 会破坏 WebAuthn 的抗钓鱼边界。
邮件与短信 OTP
邮件/短信 OTP 更适合作为过渡或恢复渠道,其强度受邮箱、SIM 换卡和运营商链路影响。发送端点必须按用户、目标地址、租户和 IP 组合限流,并以统一响应避免账号枚举。
// Endpoint 只把输入送入 Mediator;发送频率和目标归属在应用层校验。var result = await mediator.Send(command, httpContext.RequestAborted);
// 统一 Result 到 HTTP 的映射,不能把 Provider 异常或验证码写入响应。return result.ToHttp(httpContext);验证码应短期有效、尝试次数有限、成功后立即消费。重发应使旧验证码失效或遵守清晰的并存规则。短信/邮件 Provider 失败不能伪装成发送成功后永久等待,需提供可观测的异步状态或可重试错误。
恢复码
恢复码是一次性备用因子,不是第二套密码。生成后只向用户展示一次,持久化只保存哈希;每个码成功使用后原子消费。重新生成恢复码必须使旧集合全部失效,并写安全审计。
当前登录挑战创建流程会通过 IMfaService.GenerateRecoveryCodes(1) 取得一次性挑战材料,再由用户 token provider 生成五分钟 MFA token。这里的内部实现不能被客户端理解为“登录时展示恢复码”;公开契约仍是 MfaToken + Code。
受信设备
低风险登录可允许受信设备减少挑战,但设备指纹不是秘密,也不能单独证明用户身份。登记、过期和撤销必须绑定用户与租户;修改密码、恢复账号、检测到高风险或管理员操作时应忽略信任状态。
设备名称和类型用于用户识别,fingerprint 只作为受控输入参与查找。UI 应显示最近使用时间、设备类型与撤销按钮,而不是暴露完整指纹。
风险驱动策略
当前 RiskDrivenMfaPolicyService 的决策是:低风险要求 TOTP 且可由受信设备跳过;中风险要求 TOTP 且不可跳过;高风险和严重风险要求 TOTP + FIDO2;Block 直接拒绝。
值得特别说明的是,风险引擎缺失、返回失败或抛异常时,当前实现会降级为 TOTP 单因子,并禁止受信设备跳过。这是”保持认证可用但仍要求一个因子”的显式策略,不是完全 fail-open。安全等级更高的部署可以在集成层改为拒绝,但必须同步可用性预案。
需要区分两个层面:登录流程层(LoginFlow)在风险引擎失败时是 fail-closed,直接返回 Identity.Login.RiskAssessmentUnavailable 阻断登录(见 Identity 登录安全);而这里讲的是 MFA 因子选择层(RiskDrivenMfaPolicyService)在已知风险结论后的因子降级。两者不矛盾——前者决定”能不能继续登录”,后者决定”继续登录时用哪些因子”。
租户强制 MFA 策略
除了用户自行开启双因素,平台还有租户级强制 MFA 策略,由 MfaPolicyAggregate(表 SysMfaPolicy,每租户唯一)描述。它把强制要求从”个人选择”提升为”组织策略”。
强制模式 MfaEnforcementMode 有三档:
| 模式 | 含义 |
|---|---|
Optional | 不强制,等同于沿用用户个人选择 |
SelectedSubjects | 强制指定角色或指定用户 |
AllUsers | 强制该租户全部用户 |
认证保证级别 MfaAssuranceLevel 区分 Standard(标准 TOTP/OTP)与 PhishingResistant(FIDO2 等抗钓鱼因子)。策略可以要求更高保证级别,从而把可接受的因子收窄到抗钓鱼集合。
策略来源 MfaPolicySource 记录一次强制要求的归因:PersonalChoice(用户自选)、TenantRole(角色命中)、TenantUser(用户命中)、TenantAllUsers(全租户强制)、HostBaseline(平台基线)。前端可以据此向用户解释”为什么这次登录被要求 MFA”。
IMfaPolicyResolver 把租户策略与用户 Authorization 角色赋值合并,计算出当前用户的有效策略。三条端点暴露策略管理:
GET /api/identity/mfa-policy读取租户策略;GET /api/identity/mfa-policy/effective读取当前用户的有效策略(合并结果);PUT /api/identity/mfa-policy更新租户策略。
更新策略要求乐观并发(ExpectedVersion)、强制 ChangeReason,并对角色目标、用户目标做存在性校验(SelectedSubjects 必须至少指定一个目标,否则 PolicyTargetsRequired)。
与登录流的衔接在前一节已经提到:当有效策略要求 MFA 而用户尚未注册任何因子时,登录返回 MfaEnrollmentRequired 受限分支,引导用户先完成注册,而不是放行无因子登录。
禁用与恢复
/api/mfa/disable 需要现有授权。商业系统还应对禁用动作要求重新输入密码或现有强因子,防止仅凭被盗 access token 解除保护。管理员代用户禁用必须有工单原因、审计和通知。
恢复流程至少验证身份所有权、撤销受信设备、轮换恢复码,并让现有会话失效。客服不能通过修改数据库字段静默绕过这些步骤。
客户端交互
客户端应按登录响应的结果类型渲染 MFA 步骤,而不是解析错误字符串。挑战过期后回到密码登录;验证失败保留有限重试;完成后立即丢弃 MFA token。
// 挑战 token 只保存在当前登录流程内,不写 localStorage。const result = await verifyMfa({ mfaToken: flow.token, code });if (result.ok) { flow.clearChallenge(); session.accept(result.accessToken, result.refreshToken);}测试矩阵
- 密码通过但 MFA 必需时不签发最终 token;
- 挑战签发失败、过期、错误、重复消费全部拒绝;
- TOTP 设置、确认、禁用和恢复码一次性语义;
- FIDO2 Origin/RP、challenge、sign count 和删除凭据;
- 邮件/短信发送限流、Provider 失败和账号枚举防护;
- 受信设备过期、撤销和高风险绕过禁止;
- 风险等级到因子组合的完整参数化测试;
- 多租户下凭据、挑战和设备不能跨租户使用。
生产验收
生产交付需要真实 Provider 的端到端证据、时钟同步告警、FIDO2 域名配置、OTP 限流、恢复流程演练和审计查询。只通过 Handler 单测或看到 Endpoint 出现在 OpenAPI 中,不足以声明 MFA 已 GA。