49.6 风险登录与设备指纹
一句话
风险登录不是"拦",是分级处置:先用便宜且难伪造的信号算一个可解释的分数,再按分数选择放行 / 无感验证 / 二次验证 / 限权 / 阻断;每一档都要有独立开关,"为什么拦"必须能说清。
分工:手法归 38.7,链路归这里
撞库、爆破、验证码选型、不可能旅行的阈值这些攻击侧内容见 38.7 账号安全 (撞库 / 暴力破解 / 防盗号 / 验证码);零信任里"设备 + 上下文"的定位见 38.8 零信任。这里解决三件事:信号怎么采、分数怎么算、动作怎么定,以及设备指纹在 IAM 里到底该干什么、不该干什么。
五类信号:成本和误杀率差三个数量级
| 类别 | 信号 | 采集成本 | 误杀倾向 | 可伪造性 |
|---|---|---|---|---|
| 历史(账号维度) | 首次成功、近期失败次数、改密后立刻登录、异地首次 | 极低(本地计数) | 低 | 低 |
| 设备 | 指纹是否见过、是否可信设备、设备证书 | 极低(本地计算) | 低 | 中(UA 可改,证书难) |
| 网络 | IP 信誉、机房 / 代理 / Tor、ASN 类型 | 中(情报查询约 1 个 RTT) | 中高(NAT 后整栋楼连坐) | 高(换 IP 极便宜) |
| 地理 | 不可能旅行、登录国家突变 | 中(要 GeoIP 库) | 低(库准的前提下) | 高(代理即可) |
| 时间 / 行为 | 凌晨 1–5 点、输入节奏、页面停留 | 极低 | 中(夜班用户被误伤) | 低 |
🔑 优先级只有一条:先用便宜且难伪造的信号(历史 + 设备),情报类信号一律异步或离线批量。把"IP 信誉查询"放进登录主路径,等于给登录接口加了一个第三方依赖:对方 300ms 抖动,你的登录 P99 就跟着抖,对方挂了你就登不进——而它挡住的只是最便宜的攻击者(换个 IP 就绕过)。
设备指纹:三层,越强越贵
| 层 | 构成 | 稳定性 | 成本 | 绕过难度 | 适用 |
|---|---|---|---|---|---|
| L1 弱 | UA + IP + Accept-Language | 极低(浏览器升级、换网即变) | 0 | 极易 | 只做统计,不做判定 |
| L2 中 | canvas / WebGL / 字体 / 时区 / 屏幕 + 服务端下发的 device_id(第一方 Cookie / localStorage) | 中 | 低 | 中(清缓存即变,可被其他信号找回) | 主力 |
| L3 强 | 设备密钥对(Keychain / TEE)+ 平台证明(App Attest / Play Integrity)+ WebAuthn | 高 | 高(要客户端改造,还有兼容性坑) | 难 | 高价值账号、免密登录 |
最关键的一条设计:指纹不是标识。
- 标识(
device_id)由服务端生成并下发,客户端持久化保存;它是随机的,不含任何设备特征,泄漏了也无法反推设备。 - 指纹哈希只有一个用途:当
device_id丢失(清缓存、换浏览器)时,用它把老设备认回来。 - 反过来做(用纯指纹哈希当标识)的代价很具体:浏览器小版本升级会改 UA、字体渲染与 canvas 输出,用户的"设备"会凭空变成新的。按经验量级(判断,没有公开权威数字),纯指纹方案的月漂移率在 5%–15%,意味着每月有 5%–15% 的正常用户被要求二次验证。
分数:规则加权,但必须能解释
风险分不需要机器学习也能做好,关键是每个权重都能指着一条规则说话——出事复盘时,"模型觉得可疑"是没法写进事故报告的。
package main
import (
"fmt"
"math"
"math/rand"
)
type event struct {
newDevice bool // 设备指纹没见过(本地算,零成本)
velocity bool // 两地移动速度 > 900km/h(要 IP 库)
badIP bool // IP 信誉差(要情报源,必须异步或批量)
failed bool // 本账号 10 分钟内失败 ≥3 次(本地计数)
hosting bool // 机房 / 代理 ASN(IP 库附带)
oddHour bool // 本地时间 1–5 点(免费)
fraud bool // 事后标注,仅用于评估
}
// 可解释的加权模型:权重为正 = 该信号出现则风险上升
func score(e event) float64 {
z := -3.2
add := func(on bool, w float64) {
if on {
z += w
}
}
add(e.newDevice, 1.8)
add(e.velocity, 2.0)
add(e.badIP, 2.2)
add(e.failed, 1.2)
add(e.hosting, 1.6)
add(e.oddHour, 0.8)
return 100 / (1 + math.Exp(-z))
}
func gen(r *rand.Rand, fraud bool) event {
p := func(v float64) bool { return r.Float64() < v }
if fraud {
return event{p(.90), p(.50), p(.60), p(.50), p(.50), p(.40), true}
}
return event{p(.10), p(.01), p(.03), p(.10), p(.02), p(.05), false}
}
func main() {
r := rand.New(rand.NewSource(42))
const total, fraudRate = 200000, 0.005
evs := make([]event, 0, total)
var frauds int
for i := 0; i < total; i++ {
f := r.Float64() < fraudRate
if f {
frauds++
}
evs = append(evs, gen(r, f))
}
fmt.Printf("合成样本 %d 条,欺诈 %d 条(%.2f%%)\n\n", total, frauds, float64(frauds)*100/total)
fmt.Println("| 阈值 | 触发处置的登录占比 | 抓到的欺诈占比 | 被误伤的正常登录占比 | 动作 |")
fmt.Println("|---|---|---|---|---|")
for _, x := range []struct {
th float64
action string
}{{30, "二次验证"}, {60, "限权 + 人工复核"}, {85, "阻断 + 告警"}} {
var hitAll, hitFraud, hitLegit int
for _, e := range evs {
if score(e) >= x.th {
hitAll++
if e.fraud {
hitFraud++
} else {
hitLegit++
}
}
}
fmt.Printf("| %.0f | %.2f%% | %.1f%% | %.2f%% | %s |\n", x.th,
100*float64(hitAll)/total,
100*float64(hitFraud)/float64(frauds),
100*float64(hitLegit)/float64(total-frauds), x.action)
}
e := event{true, true, false, false, true, false, false}
fmt.Printf("\n单条示例 score=%.1f,命中:新设备 + 不可能旅行 + 机房 IP\n", score(e))
}
输出(本机实测):
合成样本 200000 条,欺诈 1057 条(0.53%)
| 阈值 | 触发处置的登录占比 | 抓到的欺诈占比 | 被误伤的正常登录占比 | 动作 |
|---|---|---|---|---|
| 30 | 3.32% | 96.8% | 2.82% | 二次验证 |
| 60 | 1.04% | 88.5% | 0.58% | 限权 + 人工复核 |
| 85 | 0.42% | 68.5% | 0.06% | 阻断 + 告警 |
单条示例 score=90.0,命中:新设备 + 不可能旅行 + 机房 IP
⚠️ 这组数字来自代码里生成的合成数据(欺诈率 0.53%、各信号在两类人群中的出现概率均为设定值),用途只有一个:演示阈值如何改变三组指标的关系。真实阈值必须在自己的标注数据上重跑,不能照抄。
读法:阈值从 30 提到 85,触发处置的比例从 3.32% 降到 0.42%(省了 87% 的打扰),但抓到的欺诈从 96.8% 掉到 68.5%(漏掉近三分之一的坏人)。阈值不是"安全 vs 风险"的选择,是"打扰多少人 vs 漏掉多少人"的选择,而这个选择由预算决定,不由安全团队决定。
阈值要用"挑战率预算"倒推
二次验证要花钱(短信按 0.04 元/条估)。以日登录 100 万、阈值 30(挑战率 3.32%)为例:
1,000,000 × 3.32% = 33,200 次挑战/天
其中 20% 升级到短信 = 6,640 条/天 × 0.04 元 ≈ 266 元/天 ≈ 0.8 万元/月
如果挑战率是 10%,同样的口径下短信成本就是 2.4 万元/月,还要加上 10% 的用户被拦在验证码页面的流失。先把"每天愿意打扰多少人 / 花多少钱"定下来,再回来选阈值——反过来的顺序(先定阈值再看成本)几乎总会超预算。
处置动作:六档,每档独立开关
| 风险分 | 动作 | 用户感知 | 独立开关 |
|---|---|---|---|
| 0–29 | 放行,记录设备 | 无 | — |
| 30–59 | 无感挑战(可信设备直接过,否则走滑块) | 低 | challenge.soft |
| 60–84 | 二次验证(TOTP 优先,短信兜底) | 中 | challenge.mfa |
| 85–99 | 限权(只读)+ 通知 + 人工复核 | 中高 | restrict |
| 100 或硬规则命中 | 阻断 + 告警 + 通知账号 owner | 高 | block |
| 任意分数 | 硬规则优先:泄露密码 + 新设备 + 机房 IP → 直接阻断 | — | block.hardrule |
硬规则必须能绕过分数:分数是概率,硬规则是确定性证据(比如该密码出现在最近泄露库里、该设备在平台黑名单里)。让硬规则参与加权,会被其他信号的"负分"稀释掉。
设备信任:30 天,可撤销,有上限
新设备首次成功登录 → 记为可信设备,给 30 天而不是永久(先起临时实例:redis-server --port 6390 --daemonize yes --save ''):
# 记录一台可信设备(30 天 = 2592000 秒),value 里存信任理由,便于审计
$ redis-cli -p 6390 SETEX device:01H8A:d7f3 2592000 "trusted:2026-10-29"
OK
$ redis-cli -p 6390 TTL device:01H8A:d7f3
2592000
$ redis-cli -p 6390 GET device:01H8A:d7f3
trusted:2026-10-29
# 再次登录会刷新有效期(滑动窗口),这是"常用设备保持可信"的实现
$ redis-cli -p 6390 SETEX device:01H8A:d7f3 2592000 "trusted:2026-10-29"
OK
$ redis-cli -p 6390 TTL device:01H8A:d7f3
2592000
# 改密 / 挂失:删掉该用户全部可信设备,旧设备下次登录必须重新验证
$ redis-cli -p 6390 --scan --pattern 'device:01H8A:*' | wc -l
1
$ redis-cli -p 6390 DEL device:01H8A:d7f3
1
$ redis-cli -p 6390 TTL device:01H8A:d7f3
-2 # -2 = 键不存在,撤销生效
三条必须有的约束:
- 可信设备有数量上限(如 10 台),超出踢掉最旧的。不设上限等于给攻击者留了"慢慢养设备"的路:每次登录成功一台,半年后全是可信设备,风控彻底失效。
- 改密 / 挂失 / 检测到泄漏时全量撤销,并与
token_version一起 +1(见 49.5 登录态与令牌生命周期)。只撤设备不撤令牌,攻击者手里的旧令牌仍能用。 - 信任不等于免密。可信设备只降低"无感挑战"的强度,敏感操作(改密、提现、加收款账户)仍要二次验证。
降级:三档模式 + 规则级开关
| 开关 | 状态 | 行为 |
|---|---|---|
risk.mode | observe | 只算分、只记录,不改变登录结果(上线首月的默认值) |
risk.mode | enforce | 按分数处置 |
| 单条规则 | rule.<name>.enabled | 某条规则误杀时单独关掉,不用回滚整个版本 |
| 第三方情报 | intel.available=false | 跳过该信号,剩余信号按权重归一化,不整体 fail-close |
🔑 风控组件与前两节的取舍相反:令牌撤销列表挂掉要 fail-open,风控挂掉也必须 fail-open(放行 + 告警),因为风控是"降低概率"的组件,不是"决定对错"的组件。把风控做成 fail-close,等于给自己造了一个可以被打掉的登录入口——攻击者只需要打满情报服务,全站就登不进来了。
observe 模式不是可选项:没有一个月的历史数据,你不知道每条规则在自己流量上的误杀率,直接 enforce 等于拿真实用户做实验。
反模式
| 反模式 | 后果 |
|---|---|
| 用 IP 黑名单当主力 | NAT 后整栋楼连坐;IPv6 与住宅代理让黑名单几乎无效 |
| 风控静默阻断、不通知、无申诉 | 用户以为账号被盗,客诉与流失比欺诈损失更大 |
| 纯前端判断风险 | 攻击者改一个变量就绕过;前端只能做采集,不能做决策 |
| 把设备指纹当凭证 | 指纹可伪造、可碰撞,且清缓存即失效;凭证必须是密钥 |
| 只用规则、不记录特征 | 无法回测阈值,改规则只能靠猜 |
| 阻断时不写清楚原因 | 客服无法处理,用户无法自救,复盘无数据 |
| 用设备指纹做跨站追踪 | 触犯隐私合规红线(设备指纹属于个人信息) |
| 可信设备无上限、无过期 | 半年的正常登录足够养出一整套"可信"设备,风控失效 |
自检
Q1 为什么"设备指纹哈希"不能直接当 device_id 用?
▸ 答案:因为指纹是不稳定的哈希。浏览器小版本升级会改 UA、改字体渲染、改 canvas 输出,同一个用户同一台机器算出来的哈希会变,系统就把他判成"新设备"。正确做法是服务端生成一个随机 device_id 下发给客户端持久化(它稳定且不泄漏设备特征),指纹只在 device_id 丢失时用于"找回"。纯指纹方案的实际后果是每月 5%–15% 的正常用户被误判为新设备并被迫二次验证(量级为经验判断,需按自己流量实测)。
Q2 阈值 30 时挑战率 3.32%、抓 96.8% 的欺诈;阈值 85 时挑战率 0.42%、抓 68.5%。怎么选?
▸ 答案:先算预算,再选阈值。挑战率决定成本(短信按条计费)与打扰人数,漏杀率决定损失。用 100 万日登录估:阈值 30 是 3.32 万次挑战/天,其中两成走短信 ≈ 0.8 万元/月;阈值 85 省下 87% 的成本,但漏掉近 1/3 的欺诈。可接受的组合通常是分档处置而不是单选一个阈值:30 分以上走"无感/滑块"(几乎零成本),60 分以上才上短信,85 分以上阻断。这样既拿到高覆盖,又不为低风险付短信费。
Q3 硬规则(如"泄露密码 + 新设备")为什么不能参与加权,而要单独判定?
▸ 答案:加权是概率叠加,硬规则是确定性证据。把"密码已泄露"折算成 +2.0 的权重,它在其他信号都为 0 时可能只得到 60 分(限权),而事实上这条证据已经足够断定"这次登录极可能是撞库成功"。硬规则必须是短路判定:命中即阻断,不参与平均。反过来,硬规则的误杀成本也最高,所以它必须最保守(多个条件同时成立才算命中),且要有独立的申诉通道与开关。
Q4 observe 模式为什么是上线必需项?它产出什么,验收标准是什么?
▸ 答案:observe 让风控在真实流量上只记录不干预,产出两样东西:① 每条规则/阈值的误杀率(被标记的正常登录占比);② 被标记样本的可解释性(能不能说清为什么)。没有这一个月的数据,enforce 就是拿真实用户做实验,第一次误杀可能就把大客户挡在门外。验收标准是:被标记样本抽样 200 条,人工复核后"确实可疑"的比例 ≥ 80%,且误杀集中在可解释的规则上(而不是随机分布)。
Q5 风控服务挂了,应该 fail-open 还是 fail-close?为什么和授权判定相反?
▸ 答案:fail-open(放行 + 告警)。风控是概率组件,它的价值是"把欺诈率从 0.5% 压到 0.1%",不是"决定这次请求合不合法";拿它做 fail-close,等于让攻击者通过打挂风控来瘫痪整个登录入口。授权判定必须 fail-close,因为一旦放行,越权就已经发生且不可撤回。判断标准仍然是有没有可退的兜底:登录还有密码 + MFA + 事后审计三重兜底,授权没有。
关联
- 前置:49.5 登录态与令牌生命周期
- 并列:49.1 统一账号体系、38.7 账号安全 (撞库 / 暴力破解 / 防盗号 / 验证码)
- 下游:38.8 零信任、50.5 告警 (分级 / 症状 vs 原因 / runbook / 降噪)
- 待补:68.5 设备指纹(未写,无链接)、68.6 误杀与漏杀的权衡(未写,无链接)