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 + 事后审计三重兜底,授权没有。

关联