RiskControl 的价值在消费端体现。同一个 Challenge 在 Login、MFA 策略和匿名 Website 表单里有不同的失败策略、主体信息和业务时序,不能用一段通用伪代码概括。
1. LoginFlow:密码前评估
Login.Command 实现 IRiskAssessedRequest,因此既会调用引擎,也会在外层 Pipeline 持久化成功评估。执行顺序:
- 用请求 TenantId、UserName、IP、UserAgent、DeviceId 构造风险上下文,UserId 为空;
- 引擎失败记录 Warning,并按 None 继续;
- Block 在用户查询和 BCrypt 前拒绝并写失败登录日志;
- Captcha 首次生成挑战并返回无 Token 响应,二次提交验证;
- MFA / StepUp 设置
riskMfaRequired,继续验证密码; - 密码成功后,用户 2FA 与风险 MFA 取并集,再签发 MFA Challenge;
- 只有所有步骤通过才签发访问、刷新 Token 和 UserSession。
2. Login 的 Captcha 合同
LoginResponse 用 RequiresCaptcha、ChallengeId、RenderData 表示第一阶段,不签发 AccessToken/RefreshToken。第二次请求必须带 Id 和 Answer。
var first = await auth.LoginAsync(credentials, cancellationToken);if (first.RequiresCaptcha){ // RenderData 的解释必须依据服务端返回的 Captcha 类型协议。 var answer = await captchaUi.SolveAsync( first.CaptchaChallengeId, first.CaptchaRenderData, cancellationToken);
// 二次提交复用相同 tenant、username 与凭据,并带回一次性 Ticket。 // 第一阶段不得缓存或伪造 Token;只有第二阶段完整通过才接受登录结果。 var second = credentials with { CaptchaChallengeId = first.CaptchaChallengeId, CaptchaAnswer = answer, }; return await auth.LoginAsync(second, cancellationToken);}
return first;当前 LoginResponse Projection 未暴露 CaptchaType,只返回 RenderData;Login 又固定 ResolveDefault,因此目前可按 ImageCode 处理。将来选择 Slider/Behavior 时必须版本化响应协议。
3. RiskDrivenMfaPolicyService 是另一条评估路径
Identity Infrastructure 的 RiskDrivenMfaPolicyService 将 MfaRiskContext 再映射为 LoginRiskContext:
| Level | MFA Requirement |
|---|---|
| Low | TOTP,可由受信设备跳过 |
| Medium | TOTP,不允许受信设备跳过 |
| High / Critical | TOTP + FIDO2 |
| Block | Deny |
| Engine 缺失、异常或 Failure | TOTP,不允许受信设备跳过 |
这里所谓 fail-open 仍要求 TOTP,和 LoginFlow 的“无挑战继续密码”不同。文档与告警必须区分调用点。
同一请求若先由 LoginFlow 评估、后又由 MFA Policy 评估,会尝试向 RiskAssessmentExecutionContext Capture 两次,第二次抛 InvalidOperationException 并被引擎包装为 Failure。接入时应复用第一次 Assessment,而不是重复评估。
4. Website 联系表单
GET /contact/captcha 使用服务端 AnonymousVisitorContext.VisitorId 生成:
captcha:public-website:{visitorId}:{nonce}提交联系表单时,Handler 先验证 ChallengeId 前缀,再调用默认 Provider,并同时检查 Failure 和 Passed。验证码通过后才校验和保存 PII 线索。
这个用例比通用 Endpoint Filter 更完整,但还应叠加 Visitor/IP 限流、挑战生成频率限制和 Contact 去重。Captcha 本身不能阻止攻击者无限生成挑战。
5. 通用 Endpoint Filter 的当前状态
RequireCaptchaAttribute 和 RequireCaptchaEndpointFilter 是 Host 脚手架。源码备注明确“尚未挂载到任何 Endpoint”。Filter 读取 JSON Body 两个字段并使用默认 Provider。
当前判断:
var verifyResult = await resolver.ResolveDefault().VerifyAsync( new CaptchaVerifyRequest(challengeId, answer), httpContext.RequestAborted);
// 当前只拒绝基础设施 Result Failure。if (verifyResult.IsFailure) return Results.BadRequest();
// Provider 对错误答案返回 Success(Passed=false),这里仍会进入业务 Endpoint。return await next(context);修复后还要验证 Challenge 前缀、Body 大小、Content-Type、错误映射,并对真实 Endpoint 做 HTTP 集成测试。仅在类上放 Attribute 不会自动让 Source-Generated Endpoint 挂 Filter。
6. 风险评估查询 API
GetRiskAssessments.Query 生成 GET /api/risk/assessments,声明:
- Resource:module
risk-control,resourceassessment; - Action:
View; - Permission Catalog:
risk-control.assessment.view; - 可选 UserId、Level、From、To 与分页;
- 非法 Level / 倒置时间返回 Validation;
- 非法页码回退 1 / 20,合法上限 100。
Handler 用 ICurrentUser.User.TenantId 建租户基线,Store 再显式 Tenant Predicate。普通用户正确,但正式租户模拟需要审查:
public static class TenantErrors{ public static readonly Error Required = Error.Forbidden("Tenant.Required", "A trusted tenant is required.");}
var effectiveTenantId = currentTenant.Tenant.EffectiveTenantId;if (!TenancyDefaults.IsValid(effectiveTenantId)){ // 缺少可信有效租户时直接拒绝,不能回退到请求参数。 return Result.Failure<RiskAssessmentPage>(TenantErrors.Required);}
// Tenant 不接受 Query 参数,来自多租户运行时的有效数据面。var page = await store.SearchAsync( effectiveTenantId, filter, pageRequest, cancellationToken);是否允许 Host 查看目标租户风险历史还需要独立权限、操作原因和审计,不能只靠 CurrentTenant 切换。
7. Factors DTO 的隐私边界
RiskAssessmentItem 直接包含 IReadOnlyList<RiskFactor>。当前 Evidence 可能暴露:
NewIp.ip原始 IP;NewDevice.deviceFingerprint原始设备信息或 UserAgent;OffHours.businessNow完整本地时间;- 失败次数和历史集合大小。
建议拆成:
- 列表 DTO:Id、UserId 别名、Level、Score、Challenge、Factor Codes、AssessedAt;
- 敏感详情 DTO:经更高权限和审计返回脱敏 Evidence;
- 导出:独立审批、Purpose 和短时下载。
8. 跨模块依赖方向
RiskControl.Contracts 提供窄端口;Identity Infrastructure 实现 LoginHistory Provider,Identity Application 消费 Engine/Captcha。RiskControl Infrastructure 不引用 EF Core 或 SqlSugar,依赖 IEntitySet 和 QueryShape。
依赖方向看似跨模块,实际通过 Contracts 窄端口隔离:RiskControl 不读取 Identity Aggregate,Identity 不依赖 RiskControl Infrastructure。
9. 集成测试清单
- Login 的五种 Challenge 分支与密码/MFA/Token 时序;
- Engine Failure 在 Login 与 MFA Policy 的不同降级;
- Captcha 第一阶段不泄露用户存在性,不签发 Token;
- Login Ticket 跨 tenant/username 盗用拒绝;
- Website Ticket 跨 Visitor 拒绝并保持合法 Ticket 可用;
- Filter 实际挂载、错误答案拒绝、Body 可重复读取;
- 查询 API 权限、Effective Tenant、分页、损坏快照和 Evidence 脱敏;
- 同一请求多次风险评估不覆盖或丢失事实。