RuleBasedRiskEngine 是一个确定性编排器:一次加载历史和策略,并行执行全部 IRiskFactor,把命中因子的权重相加,再由租户策略映射为 Level 和 Challenge。
1. 评估输入不是完整用户
LoginRiskContext 只有 TenantId、可空 UserId、UserName、IP、UserAgent 和 DeviceInfo。LoginFlow 在用户查询和密码校验前调用引擎,因此 UserId 固定为空,既避免暴露用户是否存在,也意味着 LoginHistoryProvider 不会查询 UserDevice 表,只能从同用户名的近期 LoginLog 建已知设备集合。
历史查询显式限制 TenantId + UserName,按时间和 Id 降序最多 50 条。失败计数只看其中最近五分钟的失败。
2. 引擎执行顺序
var snapshot = await history.GetSnapshotAsync( context.TenantId, context.UserName, context.UserId, cancellationToken);var policy = await policyProvider.ResolveAsync(context.TenantId, cancellationToken);
// 所有因子共享只读快照并行执行,任何一个异常都会让 Task.WhenAll 失败。var tasks = factors.Select(factor => factor.EvaluateAsync(context, snapshot, cancellationToken).AsTask());var results = await Task.WhenAll(tasks);
// 只累加命中的非空因子,创建时校验总分必须位于 0–100。var assessment = RiskAssessment.Create( context.TenantId, context.UserId, results.OfType<RiskFactor>(), policy, clock);
// Scoped Context 最多捕获一次,交给事务外层 Pipeline 持久化。executionContext.Capture(assessment);引擎并不短路。即使 HighFrequency 已足以触发 Captcha,其他因子仍会运行并进入证据快照,保证决策可解释。
3. 内建因子逐项解释
3.1 NewIp:20 分
IP 为空时不命中。已知集合为空时,任何非空 IP 都视为新地址;已知集合只来自同租户同用户名最近 30 分钟、最多 50 条日志。
这不是长期信誉模型。一个 31 分钟前用过的地址会重新被视为新 IP;代理链配置不正确时,所有请求可能只看到网关 IP。
3.2 NewDevice:15 分
优先使用 DeviceInfo,缺失时回退 UserAgent。只要指纹非空且不在 KnownDevices 就命中。UserId 为空时,长期 UserDevice 不参与当前登录前评估。
UserAgent 不是稳定设备指纹;浏览器升级、隐私模式和自动化客户端都会制造新设备。生产应使用隐私评审后的稳定指纹,并支持哈希或别名化。
3.3 HighFrequency:40 分
同租户同用户名最近五分钟失败达到 3 次即命中。单独命中就进入默认 Medium/Captcha。
该计数来自持久化 LoginLog,不是原子限流器。并发失败在日志提交前可能同时低估次数;真正的爆破限流仍应由 Rate Limiting / Counter Store 处理。
3.4 OffHours:10 分
使用 IAppClock.Now 的业务本地时间,00:00(含)至 06:00(不含)命中。时段当前硬编码,不是租户策略字段。
如果租户跨时区,单一 Host 业务时区会产生误判。扩展时应把租户时区和工作时段作为受校验的策略输入。
4. 分数组合与默认 Challenge
| 组合 | 分数 | 默认结果 |
|---|---|---|
| 无命中 | 0 | Low / None |
| NewIp | 20 | Low / None |
| NewIp + NewDevice | 35 | Medium / Captcha |
| HighFrequency | 40 | Medium / Captcha |
| HighFrequency + NewIp | 60 | High / MFA |
| HighFrequency + NewIp + NewDevice | 75 | High / MFA |
| 全部四项 | 85 | Critical / StepUp |
默认 Block 从 91 分开始,当前四项上限 85。若租户把 Block 下限改到 80,全命中可以直接拒绝;若保持默认,则必须增加新的合法因子。
5. RiskAssessment 不变量
RiskAssessment.Create:
- 拒绝空 TenantId;
- 深复制因子列表与 Evidence 字典;
- 拒绝空 Code/Description、负权重和总分越界;
- 保存 LevelName、ChallengeName、FactorsJson 和 UTC AssessedAt;
- 使用占位 Id
"0",Store 写入前分配最终 Id。
Restore 不重新计算历史策略,而是验证持久化 Level、Challenge、JSON、权重与总分一致。这保证策略变更后旧评估仍保留当时决策。
[RegisterTransient<IRiskFactor>]public sealed class ImpossibleTravelFactor( IGeoVelocityPort geoVelocity): IRiskFactor{ public async ValueTask<RiskFactor?> EvaluateAsync( LoginRiskContext context, LoginHistorySnapshot snapshot, CancellationToken cancellationToken) { // 缺 IP 或历史成功记录时没有足够证据,返回 null 而不是猜测高风险。 if (context.IpAddress is null || snapshot.LastSuccessfulTime is null) return null;
var signal = await geoVelocity.EvaluateAsync( context.IpAddress, snapshot.LastSuccessfulTime.Value, cancellationToken); if (!signal.IsImpossible) return null;
// Evidence 只保存稳定、最小化的字符串标量,避免写入完整地理轨迹。 return new RiskFactor( "ImpossibleTravel", "Travel velocity exceeds policy", 25m, new Dictionary<string, string?> { ["distanceBand"] = signal.DistanceBand, ["elapsedMinutes"] = signal.ElapsedMinutes.ToString(CultureInfo.InvariantCulture), }); }}6. 扩展因子的约束
- 同一快照只读,不在因子内更新 LoginLog;
- 传播调用方取消,外部 I/O 有明确超时;
- 返回
null表示未命中,不用零权重占位; - Evidence 只用字符串标量,不保存 Secret、Raw Token 或完整设备画像;
- 权重必须让所有可能组合保持 0–100;
- 因子异常会让整次评估失败,不能假设其他因子仍能给出降级结论。
7. 当前失败语义
AssessAsync 除调用方取消外捕获所有异常,返回 RiskControl.Assessment.Failed,并且不 Capture 不完整事实。LoginFlow 对这个 Failure 记录 Warning,然后 Challenge=None 继续密码认证。
8. 测试重点
- 一次历史查询被所有因子复用;
- 并行因子结果与注册顺序无关;
- 每个边界分数 30/31、50/51、75/76、90/91;
- 最大内建分数和租户覆盖后的可达 Challenge;
- 因子异常返回稳定 Failure 且 ExecutionContext 为空;
- 取消传播,不被包装为 Failure;
- 新 IP / 设备在空历史、窗口边界和代理配置下的行为;
- Evidence 深快照、AOT JSON 往返和隐私字段限制。
9. 源码审查
# 列出所有真正注册的风险因子与权重,重算最大可达分数。rg -n "RegisterTransient<IRiskFactor>|const decimal Weight|FailureThreshold" \ src/Platform/RiskControl -g '*.cs'
# 审查历史窗口、租户谓词和设备来源。rg -n "RecentWindow|FailureWindow|MaxRecentLogs|KnownDevices|TenantId ==" \ src/Platform/Identity/BitzOrcas.Identity.Infrastructure/Identity/LoginHistoryProvider.cs