Skip to content
bitzorcas
中EN

Concept

RiskControl 规则引擎与风险因子

深入登录历史快照、四个内建因子、并行执行、评分可达性、扩展因子和失败语义。

Last updated

RuleBasedRiskEngine 是一个确定性编排器:一次加载历史和策略,并行执行全部 IRiskFactor,把命中因子的权重相加,再由租户策略映射为 Level 和 Challenge。

1. 评估输入不是完整用户

LoginRiskContext 只有 TenantId、可空 UserId、UserName、IP、UserAgent 和 DeviceInfo。LoginFlow 在用户查询和密码校验前调用引擎,因此 UserId 固定为空,既避免暴露用户是否存在,也意味着 LoginHistoryProvider 不会查询 UserDevice 表,只能从同用户名的近期 LoginLog 建已知设备集合。

LoginRiskContext

LoginHistoryProvider

30 分钟 / 最多 50 条快照

Known IPs

Known Devices

5 分钟失败数

最近成功时间

四因子共享

历史查询显式限制 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

组合分数默认结果
无命中0Low / None
NewIp20Low / None
NewIp + NewDevice35Medium / Captcha
HighFrequency40Medium / Captcha
HighFrequency + NewIp60High / MFA
HighFrequency + NewIp + NewDevice75High / MFA
全部四项85Critical / 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. 扩展因子的约束

  1. 同一快照只读,不在因子内更新 LoginLog;
  2. 传播调用方取消,外部 I/O 有明确超时;
  3. 返回 null 表示未命中,不用零权重占位;
  4. Evidence 只用字符串标量,不保存 Secret、Raw Token 或完整设备画像;
  5. 权重必须让所有可能组合保持 0–100;
  6. 因子异常会让整次评估失败,不能假设其他因子仍能给出降级结论。

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. 源码审查

Terminal window
# 列出所有真正注册的风险因子与权重,重算最大可达分数。
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

上一篇:RiskControl 总览 · 下一篇:Captcha Provider

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%