传统的认证系统是”一次通过,终身信任”:用户输入密码、刷一次二次因子,然后在接下来的数小时甚至数天里,所有请求都被当作同一身份处理。这种模型在 2005 年的企业内网还算够用,但在今天,一条被钓鱼的密码、一台被植入木马的笔记本、一次 Session Cookie 被盗,就足以让攻击者在合法身份的掩护下横向移动数周。风险感知认证(Risk-Based Authentication,RBA)以及由此衍生的 Adaptive MFA,正是为了解决这个问题:它不再把认证当作一个二元决策(通过 / 失败),而是把每一次登录、每一次高危操作都视为一个带风险评分的概率事件,根据评分动态决定是否需要追加挑战(challenge),挑战到什么强度。本篇把这套机制拆开讲清楚——信号从哪里采、分怎么打、阈值怎么切、挑战怎么升,以及在生产里会踩到哪些坑。
本系列的上一篇《MFA、TOTP、WebAuthn、Passkey 工程实践》介绍了各类二次因子本身的密码学与用户体验权衡;本篇讨论的是何时、以何种强度触发这些因子,是上层的策略决策引擎。与此同时,RBA 是零信任架构里”持续验证(continuous verification)“原则在登录边界的具体落地,也是API 安全中动态访问控制的关键输入信号。
一、RBA 与 Adaptive MFA 的定位
1.1 NIST SP 800-63-3 的 AAL 分级
NIST SP 800-63-3 把认证强度分为三个 Authenticator Assurance Level(AAL):
- AAL1:单因子认证即可。典型代表是密码 + Cookie,允许记住设备 30 天。适用于低敏感场景,如读公开资料、查阅非敏感的个人信息。
- AAL2:必须两因子,其中至少一个是 cryptographic authenticator(TOTP、Push、Smart Card、FIDO2 等),不允许 SMS 作为主要因子(FIPS-140 层面认可 SMS,但 800-63B 明确标注其为 restricted)。Session 最长 12 小时,或 30 分钟空闲超时。
- AAL3:必须使用硬件型 cryptographic authenticator,且要求 verifier impersonation resistance(即必须用公钥协议,不能是共享密钥的 OTP)。FIDO2/WebAuthn 平台认证器 + 用户验证(UV=true)是最常见的 AAL3 实现。Session 最长 12 小时,15 分钟空闲超时。
RBA 的核心职责是:根据风险分数动态决定本次请求需要达到的 AAL。一个用户在公司办公室、用自己的 Mac、访问内部 wiki,AAL1 足够;同一个用户在陌生 IP 用新设备访问财务后台,就必须被拉升到 AAL2 甚至 AAL3。
1.2 RBA 与 Adaptive MFA 的关系
两个术语经常混用,但精细区分如下:
- RBA(Risk-Based Authentication):在登录时刻对本次认证请求打分,决定是否放行、是否挑战。侧重登录边界。
- Adaptive MFA:把”是否触发 MFA”变成基于上下文动态的决策,是 RBA 的子集。典型场景是”登录时看起来很安全,放行;进入支付页时再检测一次风险,再决定是否触发 MFA”。
- Continuous Authentication:更进一步,把风险评估贯穿整个会话生命周期,任何一次 API 调用都带上当时的风险上下文,和零信任网关(ZTNA / SASE)结合。
本文主要讨论 RBA 与 Adaptive MFA,continuous authentication 留到 workload identity 与零信任篇展开。
1.3 为什么值得花力气做
一个很直接的对比:只有密码时,Google 的研究显示泄露密码的账户被接管率约 0.01%/天;加上设备信任检查后下降约 10 倍;加上知识题挑战后再下降约 10 倍;加上 TOTP 或 Push 下降约 100 倍;加上 FIDO2 几乎清零。但 100% 对所有人强制 FIDO2 在企业里并不现实——用户体验劣化、硬件成本、灾备复杂度都会被无限放大。RBA 的价值在于把这 100 倍的收益只施加在需要的那 2%~5% 高风险流量上,剩下的流量继续走低摩擦路径。这是一条典型的 ROC 曲线优化问题:横轴误杀率,纵轴拦截率,目标是找到那个让业务 KPI 与安全 KPI 同时可接受的点。
二、信号源详解
RBA 的精度几乎完全取决于信号质量。下面把工业界常用的信号维度逐一拆开。
2.1 IP Reputation
IP 信誉是最便宜也最常被过度依赖的信号。核心子信号包括:
- 威胁情报黑名单:来源如 Spamhaus、AbuseIPDB、Cisco Talos、商业厂商(Recorded Future、GreyNoise)。命中黑名单是强信号,但注意 TTL——很多 IP 在被 cleanup 后仍然在黑名单里挂半年。
- TOR exit node:Tor Project 本身发布 https://check.torproject.org/exit-addresses,每小时更新一次。建议订阅拉取而不是在线查询。
- 数据中心 IP(hosting / datacenter ASN):对面向 C 端用户的产品,来自 AWS、GCP、DigitalOcean 的登录几乎全是爬虫或自动化工具;但对 B 端产品(尤其是 API 调用)则是正常流量。判断要结合业务。
- 住宅代理(residential proxy):911、Luminati/Bright Data 类服务把真实用户的家庭宽带当作出口,识别难度高,需要结合 ASN、TLS 指纹、行为模式组合判断。
- 移动运营商 NAT:整个省份的移动用户共用几百个出口 IP,绝不能把”IP 突变”直接当作高风险信号。
一个常见的工程实现是维护一张三态 IP 评分表:
{
"ip": "198.51.100.42",
"asn": 16509,
"asn_name": "AMAZON-02",
"country": "US",
"is_tor": false,
"is_datacenter": true,
"is_residential_proxy": false,
"is_mobile_carrier": false,
"blacklist_hits": ["spamhaus_drop"],
"last_seen_good": "2026-03-10T12:00:00Z",
"score": 72
}刷新频率:TOR/黑名单每小时,ASN 归属每天,地理库(MaxMind GeoIP2)每周。
2.2 Geolocation 与 Impossible Travel
地理信号的核心是把 IP 转换成 country / city / lat-lng,然后与该账户最近一次成功登录位置比较。如果两次登录的时间差除以大圆距离大于”物理可达速度”(通常取 900 km/h,覆盖商业航班),就是 impossible travel,强信号触发挑战升级。
一个 Python 实现:
import math
from datetime import datetime, timezone
EARTH_RADIUS_KM = 6371.0
MAX_PLAUSIBLE_SPEED_KMH = 900.0 # 商业航班上限,留足裕量
GRACE_DISTANCE_KM = 50.0 # GeoIP 精度兜底,市内波动不算
MIN_TIME_DELTA_SECONDS = 60 # 一分钟内不判定,避免 NTP 抖动
def haversine_km(lat1, lon1, lat2, lon2):
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlambda/2)**2
return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(a))
def is_impossible_travel(prev_login, curr_login):
"""
prev_login, curr_login 均为 dict:
{"ts": datetime, "lat": float, "lon": float, "ip": str, "asn": int}
"""
dt = (curr_login["ts"] - prev_login["ts"]).total_seconds()
if dt < MIN_TIME_DELTA_SECONDS:
return False, "time_delta_too_small"
dist = haversine_km(prev_login["lat"], prev_login["lon"],
curr_login["lat"], curr_login["lon"])
if dist < GRACE_DISTANCE_KM:
return False, "within_city_noise"
required_speed = dist / (dt / 3600.0) # km/h
if required_speed > MAX_PLAUSIBLE_SPEED_KMH:
return True, f"need {required_speed:.0f} km/h for {dist:.0f} km in {dt/60:.1f} min"
return False, f"speed {required_speed:.0f} km/h acceptable"Go 版本大同小异,重点是同样三个参数:最大可达速度、市内抖动阈值、最小时间间隔。实际部署时还要:
- 排除同一 ASN 内部切换(同一运营商切 NAT 池很常见)。
- 排除已知 VPN 出口列表(用户主动开 VPN 导致 IP 跳国是正常现象,不应直接当作高风险,但应作为中等风险参与总分)。
- 用户授权的 GPS 定位(如果是移动 App)优先于 IP 定位。
2.3 Device Fingerprint
设备指纹是在浏览器或 App 侧采集一组能够稳定识别同一台设备的信号,哪怕用户清 Cookie、换网络也能继续追踪。常见维度:
- Canvas fingerprint:让浏览器渲染一段包含 emoji、抗锯齿文字的 canvas,读取像素哈希。同一 GPU + 驱动 + 字体组合渲染出的结果高度稳定。
- WebGL fingerprint:读取
UNMASKED_RENDERER_WEBGL、UNMASKED_VENDOR_WEBGL,以及渲染一个固定场景的像素哈希。 - 字体列表:用 JS 测量特定字符串在不同字体下的宽度,推断安装了哪些字体。
- 屏幕参数:分辨率、色深、devicePixelRatio、availWidth/availHeight(注意 devtools 打开会改变)。
- Audio fingerprint:用 OfflineAudioContext 跑一段固定信号,读输出的频谱哈希。
- TLS JA3/JA4 指纹:服务端侧信号,根据 TLS ClientHello 的 cipher suites、extensions、elliptic curves 组合生成 128-bit 哈希。不同浏览器、不同版本的指纹差异显著;用 Go http 客户端伪装成 Chrome 时,JA3 会露馅。
一个浏览器侧采集片段(仅示意,生产环境建议用 FingerprintJS 或自研 worker):
async function collectFingerprint() {
const canvas = document.createElement("canvas");
canvas.width = 240; canvas.height = 60;
const ctx = canvas.getContext("2d");
ctx.textBaseline = "alphabetic";
ctx.fillStyle = "#f60"; ctx.fillRect(125, 1, 62, 20);
ctx.fillStyle = "#069"; ctx.font = "11pt Arial";
ctx.fillText("Cw😀zltl-fp-v1", 2, 15);
const canvasHash = await sha256(canvas.toDataURL());
const gl = document.createElement("canvas").getContext("webgl");
const dbg = gl.getExtension("WEBGL_debug_renderer_info");
const gpu = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : "unknown";
return {
canvas: canvasHash,
gpu,
ua: navigator.userAgent,
lang: navigator.language,
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
screen: `${screen.width}x${screen.height}x${screen.colorDepth}`,
dpr: devicePixelRatio,
platform: navigator.platform,
hw_concurrency: navigator.hardwareConcurrency || 0,
mem: navigator.deviceMemory || 0,
};
}采集到的原始字段通过 SimHash 或 LSH 得到一个稳定 ID,配合服务端记录的 JA3/JA4 和 Cookie 上的 deviceId 三路交叉比对:
- 三路都匹配:高置信度已知设备。
- Cookie 不一致但 JA3/指纹都匹配:可能是用户清 Cookie,不是新设备。
- JA3 与指纹都变了:新设备,需要 enrollment 流程。
- JA3 与 UA 不匹配(典型的 curl 伪装 Chrome):bot signal,大幅加分。
2.4 行为生物特征
Behavior biometrics 是一个容易被厂商神化的领域,务实地讲:
- 击键节奏(typing cadence):记录用户输入密码时每个按键的 dwell time(按下到抬起)和 flight time(抬起到下一次按下)。同一个人输入同一串字符的节奏相当稳定,可以作为辅助信号。但只能识别很可能是同一个人或很可能不是,不能当主因子,因为用户在不同键盘、不同设备、感冒时打字节奏都会变化。
- 鼠标轨迹:在页面上记录 mousemove 事件的坐标和时间戳,提取速度、加速度、曲率、停顿点分布,训练分类器区分人与 bot。对抗信用卡欺诈很有效,对个人账户接管帮助有限。
- 触屏手势:移动端的滑动压力、面积、角速度,原理同上。
现实中的一个经验:行为生物特征最适合作为 anti-bot 信号(区分人和自动化脚本),而不是 anti-impersonation 信号(区分 Alice 和 Bob)。对后者的准确率在公开数据集上大约在 80%~90%,离独立决策差得远,只能作为 3~10 分的加权项。
2.5 新设备 vs 已知设备
“新设备检测”是 RBA 最朴素也是最有效的信号之一。逻辑很简单:
- 用户首次在某设备上登录成功、并通过 MFA
挑战后,服务端签发一个 device binding
token(通常是一个 HttpOnly + Secure +
SameSite=Strict 的 Cookie,内容是一个对账户绑定的随机
128-bit ID + HMAC 签名,或者是一个存在
IndexedDB里的 WebAuthn device-bound key)。 - 下次该设备登录时,客户端把 token 带上,服务端验证通过即认为是已知设备,分数大幅降低。
- token 自带 TTL(典型 30~90 天)和可撤销性(用户在”我的登录设备”页面能单独踢出某台设备)。
注意几个细节:
- 不要把 device token 绑定到 IP 或 UA,否则用户换网或者浏览器自动升级就失效。
- token 存 localStorage 的方案已经过时,XSS 可偷;正规做法是 HttpOnly Cookie 或者硬件绑定的 passkey。
- 企业场景下的 managed device(通过 MDM 或 Intune 下发的设备证书)比 Cookie-based device binding 可信得多,见第四节。
2.6 凭证信号:Credential Stuffing 与 HIBP
密码本身也是一个信号源。攻击者从其他站点拖库,拿到
email:password 对后批量对本站撞库(credential
stuffing),这是账户接管的头号来源。相关信号:
- Have I Been Pwned(HIBP) Passwords API:以 k-anonymity 模式查询密码的 SHA-1 前 5 位,返回后缀列表及命中次数。命中即强信号。
- 内部 ATO 事件密码库:历史上确认被盗账户用过的密码,密码重用时加分。
- 撞库特征:短时间内大量账户同一 IP 登录、每个账户只试 1~2 次密码、UA 高度一致。这在 IP 或 ASN 维度聚合后很显眼。
示例:密码校验时异步调用 HIBP:
func checkBreached(password string) (int, error) {
h := sha1.Sum([]byte(password))
hex := strings.ToUpper(fmt.Sprintf("%x", h))
prefix, suffix := hex[:5], hex[5:]
resp, err := http.Get("https://api.pwnedpasswords.com/range/" + prefix)
if err != nil { return 0, err }
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
for _, line := range strings.Split(string(body), "\n") {
parts := strings.SplitN(strings.TrimSpace(line), ":", 2)
if len(parts) == 2 && parts[0] == suffix {
count, _ := strconv.Atoi(parts[1])
return count, nil
}
}
return 0, nil
}注意:HIBP 查询的是密码本身的泄露次数,对 credential stuffing 有参考价值;真正防撞库还需要速率限制 + 设备指纹聚合。
2.7 信号可信度与加权
不同信号的噪声水平差异巨大。工程中建议为每个信号标注三个元属性:
- Precision(命中即是真攻击的概率):TOR=0.9,impossible travel=0.7,new device=0.3。
- Recall(能识别多少真攻击):new device~0.6,IP reputation~0.4,behavior biometrics~0.2。
- Latency(采集到评分的时延):IP 查询<5ms,device fingerprint 客户端采集~50ms,behavior biometrics 需要等用户完成交互。
规则权重不是拍脑袋,而是
weight ∝ precision * log(1/base_rate)——越少见的信号一旦命中信息量越大。
三、风险评分模型架构
3.1 规则引擎 vs ML 模型
两种范式各有优劣,生产系统几乎都是混合的:
| 维度 | 规则引擎 | ML 模型 |
|---|---|---|
| 延迟 | P99 常在 1ms 量级 | 10~50ms,需考虑超时兜底 |
| 可解释性 | 天然可解释,每条规则都能指出触发原因 | 黑盒,需 SHAP 事后解释 |
| 维护成本 | 规则量大了容易互相冲突 | 需要持续标注、再训练、监控数据漂移 |
| 新攻击响应 | 改一条规则就能生效,分钟级 | 需重新训练或在线学习,天级 |
| 误报率 | 规则过粗时误报高 | 能捕捉复杂组合,误报通常更低 |
| 合规审计 | 容易通过审计 | 需要提供模型卡和可解释性报告 |
3.2 分层结构
实际落地建议分两层:
┌───────────────────────────────────────────────────────┐
│ 第一层:Hard Rules(一票否决 / 一票放行) │
│ - 账户在密码泄露库里命中 → +50 │
│ - IP 在 TOR 出口表里 → +40 │
│ - 设备绑定 token 有效 → -30 │
│ - 来自 VIP 白名单 IP → -20 │
└───────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────┐
│ 第二层:ML Model(组合特征打分,0~100) │
│ - 输入:上面的所有信号维度 │
│ - 模型:GBDT(XGBoost/LightGBM)或 Isolation Forest │
│ - 输出:anomaly_score(相对该用户历史的异常程度) │
└───────────────────────────────────────────────────────┘
│
▼
最终分数 = clamp(rule_score + ml_score, 0, 100)
这样做的好处是:Hard Rules 兜住可解释性与合规,ML 层负责拟合复杂组合,两者相加既快又稳。
3.3 规则引擎 DSL 示例
一个可序列化为 JSON 的规则格式(类似 Open Policy Agent / Cedar 的思路):
{
"version": 3,
"rules": [
{
"id": "rule.tor_exit",
"desc": "来自 TOR 出口节点",
"when": {"signal.ip.is_tor": true},
"action": {"add_score": 40, "tag": "tor"}
},
{
"id": "rule.impossible_travel",
"desc": "不可能旅行",
"when": {"signal.travel.impossible": true},
"action": {"add_score": 35, "tag": "impossible_travel"}
},
{
"id": "rule.new_device",
"desc": "新设备",
"when": {"signal.device.is_known": false},
"action": {"add_score": 20, "tag": "new_device"}
},
{
"id": "rule.known_device_discount",
"desc": "已知设备折扣",
"when": {
"all": [
{"signal.device.is_known": true},
{"signal.device.bound_age_days": {"gte": 7}}
]
},
"action": {"add_score": -25, "tag": "trusted_device"}
},
{
"id": "rule.breached_password",
"desc": "密码命中 HIBP",
"when": {"signal.creds.breach_count": {"gt": 0}},
"action": {"add_score": 50, "tag": "breached_password", "force_ladder": "high"}
},
{
"id": "rule.office_ip_whitelist",
"desc": "办公网白名单",
"when": {"signal.ip.cidr_in": ["203.0.113.0/24", "198.51.100.64/26"]},
"action": {"add_score": -15, "tag": "office_ip"}
},
{
"id": "rule.admin_role_floor",
"desc": "管理员角色最低挑战 AAL2",
"when": {"signal.user.roles": {"contains": "admin"}},
"action": {"floor_ladder": "medium", "tag": "admin_floor"}
}
]
}要点:
- 每条规则都有
id和desc,审计日志直接写id就能溯源。 add_score正负都可以,便于表达”发现可信信号,降风险”。force_ladder/floor_ladder提供覆盖能力:无论 ML 打多少分,管理员至少走 MFA。- 规则修改走配置变更流水线(GitOps),有 diff / review / canary 发布。
3.4 ML 特征工程
常用的特征族(按 FE 成本从低到高排序):
- 上下文 one-hot:country_code、asn、os_family、browser_family。
- 速率类:该 IP 最近 1/5/60 分钟登录尝试数、该账户最近失败次数、该指纹对多少账户发起过登录。
- 历史匹配:当前国家与历史 top-3 国家的匹配度、设备与历史设备集合的 Jaccard 相似度。
- 时间类:登录时刻在用户习惯时段内的分位数、距上次登录的小时数的对数。
- 交叉特征:country × device_is_known、asn × user_role。
标签获取是最难的。几种常见做法:
- 强标签:确认的账户接管工单、用户发起的”不是我”申诉。量少但精。
- 弱标签:登录后 24 小时内触发异常行为(改密码 → 改邮箱 → 转账);用这些作为正样本训练,但会引入 label noise。
- 对抗样本:红队定期跑模拟攻击,生成可控正样本。
生产模型上线后,关注三个指标:
- Recall@FPR=1%:固定 1% 误报率下,召回多少真实攻击。
- Coverage:被 ML 模型打出 >=60 分的流量占比;太高说明模型过于神经质。
- PSI(Population Stability Index):监控特征分布漂移,>0.25 触发告警。
四、Challenge Ladder 与阈值
4.1 分档与响应
典型的四档设计(数字是经验值,业务需按自己数据调):
| 分数 | 等级 | 响应 | AAL | 典型场景 |
|---|---|---|---|---|
| 0-30 | Low | 静默放行,记录日志 | AAL1 | 同设备 + 同城 + 同时段 |
| 30-60 | Medium | TOTP 或邮件 OTP | AAL2 | 新设备但同城、VPN 导致 IP 跳变 |
| 60-80 | High | Push + 显示登录地点 / Magic Link | AAL2+ | 不可能旅行、首次海外登录 |
| 80-100 | Critical | FIDO2 or 阻断 + 人工审核 | AAL3 | 密码在泄露库 + 新设备 + TOR |
“挑战升级”不一定只发生在登录时。实际系统会在敏感动作(改密码、改邮箱、绑新支付手段、批量下载、提额转账)时再重新评分一次,分高则 step-up。这对应 OIDC 协议层的 acr_values 参数。
把这件事真正做成系统,还需要把「分数」翻译成用户动作、服务端动作和审计动作:
| 分数段 | 用户看到什么 | 服务端做什么 | 审计侧要记什么 |
|---|---|---|---|
| 0-30 | 正常通过 | 正常建会话 | 信号快照、最终得分 |
| 30-60 | 轻量挑战 | 降低会话寿命、标记观察态 | 触发原因、挑战结果 |
| 60-80 | 强挑战或临时冻结敏感动作 | 只开放低风险页面,等待 step-up 成功 | 规则命中、设备指纹、地理差异 |
| 80-100 | 阻断、人工复核或 break-glass 流程 | 禁止建立高权限会话 | 全量决策轨迹、工单号、处置人 |
误报治理也必须跟着这张表一起设计。否则系统只会不断加规则,却没有能力回答「为什么这个用户被拦了」「这次拦截是否值得」。
4.2 OIDC ACR / AMR 与 Step-up
OIDC Core 第 2 节定义了两个与认证强度相关的 claim:
- acr(Authentication Context Class
Reference):描述本次认证达到的级别。可以是业务自定义值(推荐)或
URN(如
urn:mace:incommon:iap:silver)。 - amr(Authentication Methods
References):描述本次认证实际用到的方法列表,如
["pwd", "otp"]、["pwd", "hwk"]、["mfa", "fido"]。IANA 维护了官方注册表(RFC 8176)。
RP(Relying Party)发起 step-up 的 Authorization Request 示例:
GET /authorize?
response_type=code
&client_id=finance-portal
&redirect_uri=https%3A%2F%2Ffinance.example.com%2Fcb
&scope=openid%20payments
&state=xyz
&code_challenge=...
&code_challenge_method=S256
&acr_values=urn:example:aal2%20urn:example:aal3
&max_age=300
&prompt=login HTTP/1.1
Host: idp.example.com
关键参数解读:
acr_values空格分隔、按优先级排序,IdP 会尝试达到其中至少一个。max_age=300要求本次认证在过去 5 分钟内完成;如果 Session 里最近一次 auth_time 是 10 分钟前,IdP 必须重新挑战。prompt=login强制重新认证,即使 SSO 会话仍有效。
IdP 成功后,ID Token 会回:
{
"iss": "https://idp.example.com",
"sub": "user-42",
"aud": "finance-portal",
"iat": 1745130000,
"exp": 1745133600,
"auth_time": 1745129940,
"acr": "urn:example:aal3",
"amr": ["pwd", "fido"],
"sid": "sess-abc-123"
}RP 的职责是显式校验 acr / amr:
type IDTokenClaims struct {
AuthTime int64 `json:"auth_time"`
ACR string `json:"acr"`
AMR []string `json:"amr"`
// ... 其他字段
}
func requireAAL3(claims *IDTokenClaims, maxAgeSec int64) error {
if claims.ACR != "urn:example:aal3" {
return errors.New("insufficient_acr: need aal3")
}
if time.Now().Unix()-claims.AuthTime > maxAgeSec {
return errors.New("auth_too_old")
}
hasStrong := false
for _, m := range claims.AMR {
if m == "fido" || m == "hwk" {
hasStrong = true
break
}
}
if !hasStrong {
return errors.New("amr_missing_strong_factor")
}
return nil
}常见错误:RP 只校验了 JWT 签名和 exp,没校验
ACR,于是攻击者用 AAL1 登录的 Token 也能调 AAL3
接口。这类问题在审计里经常被揪出来。
4.3 RFC 9470 OAuth Step-up Challenge
2023 年发布的 RFC 9470 定义了一种标准化的 step-up
机制:资源服务器在发现访问令牌不满足所需强度时,返回 401 +
WWW-Authenticate: Bearer error="insufficient_user_authentication",客户端据此发起带
acr_values 的重新授权。示例响应头:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="finance",
error="insufficient_user_authentication",
error_description="AAL3 required for wire transfer",
acr_values="urn:example:aal3",
max_age="300"
客户端 SDK 捕获后发起新一轮 PAR → Authorization → Code → Token,成功后 retry 原请求。比旧的”RP 预判需要 AAL3 再走一次 flow”优雅得多。
4.4 阈值校准
阈值不是拍脑袋定的,而是从历史数据拟合。常用方法:
- 用近 90 天的登录日志构造评测集,正样本是确认的 ATO / 失败后被用户申诉,负样本是随机成功登录。
- 画 ROC 曲线:横轴 FPR(误报率),纵轴 TPR(召回率)。
- 根据业务选点:C 端通常接受 FPR<=2% 换 TPR>=75%;金融后台接受 FPR<=10% 换 TPR>=95%。
- 把选定点的评分作为 medium/high 档的阈值。
阈值上线后,每周跑一次再校准,数据漂移严重时自动抬高 Push 档阈值,避免大面积误拦。
4.5 Challenge 类型的优劣对比
档位定了还不够,每档具体选什么挑战类型也有讲究:
| 挑战类型 | 防钓鱼 | 防 SIM 交换 | 用户摩擦 | 成本 | 说明 |
|---|---|---|---|---|---|
| SMS OTP | 弱 | 否 | 低 | 短信费 | NIST 已不推荐作为主因子 |
| Email OTP | 弱 | 是 | 低 | 低 | 邮箱被控则整条链失守 |
| TOTP | 弱(可被中转) | 是 | 中 | 零 | 经典方案 |
| Push + number matching | 中 | 是 | 低 | APNS/FCM | 必须加数字匹配抗 MFA 疲劳 |
| FIDO2 / Passkey | 强 | 是 | 低(用户熟悉后) | 硬件或内置 | AAL3 首选 |
| Magic Link | 弱 | 是 | 中 | 低 | 邮箱被接管即失守 |
| 生物识别(本地) | 中 | 是 | 极低 | 内置 | 作为 FIDO2 的 UV,不独立使用 |
| 知识题(安全问题) | 极弱 | 是 | 高 | 零 | 几乎所有答案都能社工,不推荐 |
一个经验法则:低风险用熟悉的(TOTP/Push),高风险用强的(FIDO2),从不使用安全问题。
4.6 一个完整的决策伪代码
def decide_challenge(ctx):
score, tags = rule_engine.evaluate(ctx)
if not any(t == "force_ladder_critical" for t in tags):
score += ml_model.predict(ctx.features)
score = max(0, min(100, score))
ladder = "low"
if score >= 30: ladder = "medium"
if score >= 60: ladder = "high"
if score >= 80: ladder = "critical"
# 角色 floor
if "admin" in ctx.user.roles and ladder == "low":
ladder = "medium"
# 敏感操作 floor
if ctx.operation in SENSITIVE_OPS and ladder in ("low", "medium"):
ladder = "high"
challenge = {
"low": {"aal": "aal1", "methods": []},
"medium": {"aal": "aal2", "methods": ["totp", "push_num"]},
"high": {"aal": "aal2", "methods": ["push_num", "magic_link"]},
"critical": {"aal": "aal3", "methods": ["fido2"]},
}[ladder]
audit_log(ctx, score, tags, ladder, challenge)
return challenge五、设备信任与 Posture
5.1 Device Binding
最简单的 device binding
是签发一张设备身份令牌,本质是给设备分配一对密钥对,私钥存在设备可信存储里(iOS
Secure Enclave、Android StrongBox、Windows TPM、macOS
Keychain with
LocalAuthentication),每次登录时设备用私钥签一个 nonce
证明自己持有。这正是 WebAuthn 的工作模式,所以在前端用
navigator.credentials.create({ publicKey: ... })
创建一个 device-bound
passkey(residentKey=discouraged,
userVerification=preferred)即可同时解决 device binding 和
MFA 两个问题。
如果不能用 WebAuthn(老浏览器、自研 App),可以自己实现:
- 首次 enrollment:设备生成 EC P-256 密钥对,私钥存 Keychain;公钥 + 设备元信息 POST 到后端。
- 后端签发一个 device_id(UUID),与账户、公钥绑定入库。
- 下次登录:客户端发起 login,服务端返回 nonce,客户端用私钥签 nonce 回传,服务端验签。
- 撤销:用户在”我的设备”页面点”移除”,后端删除该 device_id 的绑定记录。
5.2 Device Posture
企业场景下,仅仅”这台设备是已知的”还不够,还要证明它处于合规状态。常检查的 Posture 信号:
- OS 与补丁:macOS 14.x、Windows 11 23H2 以上,安全补丁日期在过去 30 天内。
- 磁盘加密:macOS FileVault 开、Windows BitLocker 开、Linux LUKS 开。
- EDR Agent:CrowdStrike / SentinelOne / Microsoft Defender for Endpoint 进程运行、最后心跳在过去 10 分钟内。
- MDM Enrollment:Jamf / Intune / Workspace ONE 报告此设备处于受管状态、配置 profile 版本 >= X。
- 浏览器版本:Chrome、Edge、Firefox 最新 N 个大版本以内。
- 防火墙与杀软:Windows Defender Firewall 打开,防火墙规则未被用户绕过。
采集方式有两条路:
- Agent 上报:EDR / MDM 周期性把设备状态推到 Posture Service,RBA 查询。
- Browser Managed Device SSO:Chrome 企业版通过 device-bound public key attestation 把设备属性打包到登录请求,IdP 验签后即得到可信 posture。
一旦 Posture 失败(例如 FileVault 被关),策略可以是:
- 降低总分 30 分 → 强制 step-up。
- 或直接拒绝访问敏感资源(不是拒绝登录),把用户导向”请先联系 IT”页面。
5.3 设备证书:SCEP 与 ACME for Device
大型企业会给每台受管设备签发一张 x509 证书,登录时走 mTLS,IdP 验证客户端证书即完成设备身份。两种常用签发协议:
- SCEP(Simple Certificate Enrollment Protocol,RFC 8894):Cisco 发起的老协议,MDM 广泛支持,但安全性偏弱(challenge password 共享、算法老),仅适合内部网络。
- ACME for Device(草案 RFC
draft-acme-device-attest):把 Let’s Encrypt 的 ACME 协议扩展到设备场景,结合 WebAuthn / Apple Device Attestation / Android Key Attestation,让设备用硬件证明向 CA 申请证书。未来方向。
证书私钥必须落在硬件可信存储里,不能 export。这样即使攻击者钓走用户密码也拿不到设备证书,从而无法完成 mTLS 握手。
六、工程坑点
6.1 VPN 导致的 impossible travel 大规模误报
典型场景:用户在北京办公室用公司 VPN(出口在香港),然后手机切到 4G 直连,IP 从香港跳到北京。短短 1 分钟内国家从 HK 变 CN,impossible travel 规则触发,全公司都被 TOTP 轰炸。
缓解办法:
- 维护公司出口 IP 白名单(包括所有 VPN 集群、办公网 NAT),命中白名单不参与 impossible travel 计算。
- 维护已知 VPN / 云服务 ASN 列表,跨 VPN 跳 IP 只加中等分(10~15)而不是高分(35+)。
- 用 Client-side 上报的真实 Wi-Fi BSSID / 系统时区替代 IP 地理定位,这两者比 IP 更稳定。
6.2 办公网统一出口 IP 命中 IP Reputation
公司有 5000 人,全都走同一个 NAT 出口,一旦某一个人电脑中毒对外扫描,这个 IP 就会被公开情报打上”恶意”标签,导致全公司被全球网站拦截。
缓解:
- 企业出口 IP 做分用户 NAT 池,至少按部门分。
- 向黑名单机构主动申报并监控,国内如 NSFOCUS、国外如 Spamhaus Removal。
- 内部 RBA 对本公司出口 IP 直接加白名单,不走公开情报。
6.3 跨时区团队的”异常时段”误报
“凌晨 3 点登录”在亚洲团队看是怪,在欧洲同事看是正常工作时间。RBA 模型如果只按服务器本地时间做特征,跨时区团队全部变成高风险。
缓解:以用户设备时区或用户常驻国家为基准计算”本地小时”,而不是用服务器 UTC。
6.4 CAPTCHA / MFA 疲劳攻击
高风险评分触发密集 Push 通知,攻击者可以用这个”消耗”用户——反复尝试登录,直到用户在 100 条通知里随便点一下 Approve。2022 Uber 事件就是这么被攻陷的。
缓解:
- Push 通知加数字匹配(Microsoft Authenticator 的 number matching):用户必须输入登录页上显示的 2 位数字才算通过。
- 同一账户 60 秒内超过 3 次 Push 自动降级为 TOTP + 要求审核。
- Anomaly 检测到 push bombing 模式立即锁账户。
6.5 冷启动数据不足
新用户没有历史登录记录,ML 模型没法判断”这是不是他本人的习惯设备/地点”。如果直接按新用户规则强制 MFA,注册转化率会掉 10~20%。
缓解:
- 新账户 7 天内用弱规则(只看 IP reputation 与设备指纹黑名单),不做行为对比。
- 首次登录后主动引导”信任此设备”按钮,让用户显式 enrollment。
- 利用群体特征:和同一注册渠道 / 同一地域的新用户群体对比。
6.6 Device Fingerprint 的稳定性问题
Safari 17 之后默认启用 Privacy-preserving features,canvas 加噪声、UA 被 freeze;iOS Safari 的指纹会在每个 tab 不同。Chrome 也在逐步推进 Privacy Sandbox。不要把 fingerprint 当长期稳定 ID,它的 TTL 只有 7~30 天,仅作为短期辅助信号。
6.7 ML 模型标签噪声
“用户事后说不是我登录的”里有 30% 实际上是用户自己忘了,另外还有用户主动欺诈的场景(否认自己的操作以免责)。训练数据如果直接用工单标签会有大量噪声。
缓解:标签需要二次核实(如结合设备日志),或者用 PU Learning(Positive-Unlabeled)方法处理高噪声正样本。
6.8 Session 固定 vs 动态降级
一个不小心容易犯的设计错误:Session 建立时评分为 25(低风险),发一个 24 小时 Cookie;但用户跑去咖啡厅连公共 Wi-Fi,IP 变了、网络变了,Session 还在。此时应当动态重新评分并决定是否让这张 Session 降级(要求重新 MFA 才能做敏感操作)而不是无脑信任它。
做法:每次敏感 API 调用前,查询当时的实时风险分数(带 TTL 60 秒的缓存),分高就 WWW-Authenticate 401 触发 step-up。
6.9 用户隐私与合规边界
采集 canvas/WebGL 指纹、鼠标轨迹、键盘节奏,在欧盟 GDPR 和加州 CCPA 下都属于个人数据处理。合规问题常见的几个:
- 合法基础:安全与欺诈防范属于”合法利益”(legitimate interest),但需要 DPIA(数据保护影响评估)记录。
- 保留期限:原始信号 30 天,聚合特征 90 天,最长不超过 180 天。超过即脱敏或删除。
- 数据主体权利:用户有权查询其风险评分(虽然实际上很多产品不开放,但合规部门会问)。给出机制:至少导出”近 30 天登录设备与地点”列表。
- 跨境传输:把中国用户的登录日志同步到境外 SIEM 需要遵守数据出境安全评估(国内《网安法》《数安法》《个保法》三法)。
6.10 误报的业务代价
不要只看模型的 AUC。一次误杀的代价:
- C 端产品:用户在高峰期被挑战,放弃购买,直接损失 GMV。
- B 端 SaaS:管理员被拦,整个租户无法继续工作,可能触发 SLA 违约。
- 内部系统:开发者被拦住,发布流程停滞。
因此在评估策略时应该把”每万次登录的业务损失估值”做进指标:business_cost = false_positive_rate * avg_conversion_value * DAU。策略调整前做
A/B 测试,跟踪转化率、工单量、投诉率三条曲线。
七、落地建议
- 先有日志,再谈评分。很多团队一上来就想买一个 RBA 产品,但连”过去 30 天每个账户的登录 IP / UA / 时间 / 结果”这张基础表都没全。RBA 的第一步是把登录事件标准化落库(字段至少 12 个:ts、user_id、ip、asn、country、city、ua、device_id、ja3、result、mfa_type、session_id),再谈模型。
- 从 Hard Rules 开始,ML 是锦上添花。10~20 条基于 IP reputation、impossible travel、新设备、泄露密码的规则,覆盖 80% 的攻击场景。模型能多拿 10~15 个百分点的召回,但需要 3 个月以上的数据积累。
- 挑战升级务必是可逆的。高分场景下弹 FIDO2,但一定要给降级通道:用户丢了 key,还能通过”客服视频验证 + 24 小时冷静期”恢复,否则会制造大量账户锁死工单。
- 给信任设备一个明确入口。用户登录后显示”这是你常用设备吗?信任 30 天”的勾选框,把主动权交给用户——这比算法猜”这是不是他常用设备”准确得多。
- 把评分过程写进审计日志。每次挑战决策至少记录:总分、触发的规则 ID 列表、被挑战的等级、挑战结果、用户的 UA/IP。合规审计和事后复盘都离不开它。
- 保留 emergency kill switch。模型或规则上线后发生大规模误杀时,能一键回滚到”只做黑名单 + 密码”的最低档。这个按钮必须是 runbook 级别的。
- 与 SOC 团队共享信号。登录时的 IP reputation、device posture 信号对 SIEM 有高价值;反过来 SOC 发现的威胁指标也应当推回 RBA。双向闭环。
- 隐私合规。Canvas/WebGL fingerprint、鼠标轨迹在 GDPR 下属于个人数据,隐私政策里必须声明采集目的为”防止账户接管”,保留期限要明确(建议不超过 90 天)。国内的《个人信息保护法》同样要求明示并获取同意,不要把这些信号当作”技术细节”隐藏。
- UX 与安全的 ROC 平衡。指标上同时追踪”挑战通过率”(安全 KPI)与”挑战放弃率”(UX KPI)。每次策略变更都要评估两者的移动方向,不能单向优化。
- 用真实红蓝对抗迭代。每季度红队用合法采集的凭证 + 云 IP + 新设备跑一次完整的 ATO 演练,看 RBA 能不能拦住。攻防思维驱动策略迭代比单纯看监控指标更有效。
八、参考资料
- NIST SP 800-63-3(Digital Identity Guidelines)与 800-63B(Authentication and Lifecycle Management)
- OpenID Connect Core 1.0,第 2 节 ID Token(ACR/AMR/auth_time)
- RFC 8176,Authentication Method Reference Values
- RFC 9470,OAuth 2.0 Step Up Authentication Challenge Protocol
- RFC 8894,Simple Certificate Enrollment Protocol (SCEP)
- W3C Web Authentication Level 3(WebAuthn)
- FIDO Alliance,Device Attestation 与 Enterprise Attestation 白皮书
- Google Research,“New Research: Lessons from Password Checkup in Action”(2019)
- Microsoft,“Defending against Multi-Factor Authentication (MFA) Fatigue Attacks”(2022)
- Cloudflare,“Introducing the JA4 TLS fingerprint”(2023)
- MaxMind GeoIP2 Accuracy Report(每季度更新)
- Spamhaus DROP / EDROP 列表使用指南
- OASIS Cedar Policy Language(可作为规则 DSL 参考)
- OWASP Authentication Cheat Sheet 与 Session Management Cheat Sheet
- Apple Platform Security Guide,Device Attestation 章节
- Android Keystore System 文档,Key Attestation 章节
- CISA,“Implementing Phishing-Resistant MFA”(2022)
- 本站:MFA、TOTP、WebAuthn、Passkey 工程实践
- 本站:零信任架构与持续验证
- 本站:API 安全中的动态访问控制
上一篇:MFA、TOTP、WebAuthn、Passkey 工程实践
下一篇:服务身份:mTLS、SPIFFE/SPIRE 与 Workload Identity
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【身份与访问控制工程】IAM 全景:为什么这是高价值赛道
身份与访问控制从一个登录框演进为横跨合规、运维、平台工程和安全的系统工程。本文从一家 SaaS 公司被大客户卡在 SOC 2 合规的真实触发器切入,拆解 IAM、CIAM、IGA、PAM、SSO、目录服务六个子领域的边界,分析 Okta、Entra ID、Auth0、Keycloak、Ping 等主流厂商的定位与落差,给出工程师视角的介入判据与选型路径。
【身份与访问控制工程】企业单点登录:OIDC 与现代 SSO
B2B 大客户的安全团队只认自家 Okta,集团五条产品线要统一账号,合规团队要求所有入口都可审计——这些需求最终都落在同一个协议上:OpenID Connect。本文从一个真实的商业谈判场景切入,系统拆解 OIDC 在 OAuth 2.0 之上增加了什么、授权码流程的每一个字段为什么存在、企业集成里最常见的六类实现坑,以及 Discovery、JWKS、Dynamic Client Registration、mTLS Client Auth 的工程细节,帮助你把 SSO 从 demo 做到可以放进 SOC 2 审计报告里。
【身份与访问控制工程】OAuth 2.1 与 PKCE:现代授权主路径
从一次 SPA 安全事故出发,系统梳理 OAuth 2.1 相对 2.0 的收敛动作、PKCE 的密码学原理、授权码流程的完整参数细节,以及 DPoP、PAR、JAR、RAR 等现代扩展与常见攻击面
【身份与访问控制工程】SAML 还值得学吗:企业遗留 SSO 的现实世界
SAML 2.0 是 2005 年的协议,却仍活在每一个和银行、保险、制造业做生意的 B2B SaaS 后台——本文从一封客户邮件开始,拆解 SAML 断言结构、SP-initiated 流程、Metadata、证书轮换与 XML 签名包装攻击等现实工程问题