一次“证据齐全”的找回
《群岛纪元》的客服收到一份找回申请:对方提供了订单号、支付截图、角色名和大致注册时间,要求把账号换绑到新手机号。证据看起来齐全,客服按流程放行。三天后,原号主投诉账号被盗,订单和角色名是从一张公开的充值截图里抄来的。
复盘时发现两件事。第一,客服拿到的“证据”只能证明申请者见过这些信息,不能证明申请者就是付款人;第二,系统没有记录这次放行是因为哪些规则、谁批准的,事后只能靠聊天记录还原。风控缺的不是一个更复杂的模型,而是三样东西:可解释的信号、分档的动作、可追溯的审计。
另一头,《裂隙战场》的运营做过一次粗暴实验:同一出口 IP 在十分钟内登录超过二十个账号就整段封禁。结果一家网吧的正常玩家全部被挡在门外,投诉量比外挂时期还高。风险判断一旦只有“放行/封禁”两个值,误判就没有缓冲地带。
三种动作,而不是一个布尔
风控的输出应该是三档,而不是“安全/不安全”。
| 动作 | 触发条件(示例值) | 玩家体验 | 误判代价 | 必要兜底 |
|---|---|---|---|---|
| 放行 ALLOW | 风险分低于挑战阈值 | 无感 | 放过一次可疑登录 | 事后审计与离线复核 |
| 挑战 CHALLENGE | 达到挑战阈值、未到阻断阈值 | 多一步验证 | 少量流失 | 可换验证方式、可重试 |
| 阻断 BLOCK | 达到阻断阈值或失败次数超限 | 拒绝登录 | 明显流失与口碑 | 锁定必须有期限、可人工解锁 |
挑战是这套设计的关键。它把“不确定”变成一次可完成的验证,而不是直接拒绝。新设备、异地、游客凭据这些信号单独出现时都不足以证明盗号,组合起来才值得阻断;把它们直接映射成封禁,等于让风控替玩家做最坏假设。
风险分只是解释工具,不是概率。下面的权重是《群岛纪元》上线初期使用的示例值,来自运营和客服共同评审,不是模型训练结果。
| 信号 | 权重 | 说明 |
|---|---|---|
| 新设备 | 30 | 该账号首次在此设备登录 |
| 异地跳变 | 25 | 与上次登录城市不同,且时间上不可达 |
| 失败次数 ≥ 3 | 15 | 窗口内凭证失败累计 |
| 游客凭据 | 10 | 不可找回的登录方式 |
| 账号年龄 < 3 天 | 10 | 新号更容易是批量注册 |
阈值同样是配置:挑战 30、阻断 70。它们必须能按产品调整,《泡泡工坊》的容忍度明显高于《裂隙战场》,用一套全局常量会把休闲玩家和竞技玩家一起误伤。
失败计数、锁定与自动解锁
失败计数和风险分要分开。风险分回答“这次登录像不像本人”,失败计数回答“这个账号正在被尝试多少次”。前者适合挑战,后者适合短时阻断。
计数必须带窗口。一个玩家深夜连续输错五次密码,不应该在第二天仍然被计数;窗口取 15 分钟是常见起点,长到覆盖一次撞库 burst,短到不惩罚正常的记忆失误。窗口内达到硬阈值(示例值 8 次)直接阻断,不再看风险分,因为此时继续允许尝试只会扩大撞库收益。
锁定必须有到期时间。Evaluate 在阻断时写入 LockUntil,到期后自动重新评估;如果风险信号仍在,可以再次阻断,但那是基于新请求的重新判断,而不是无限续期。代码里没有任何路径能产生“永久锁定”,这是刻意的:永久封禁属于处罚系统,不属于登录风控。人工解锁是逃生门,必须带工单号和审批人,审计写在工单系统而不是风控引擎里。
找回:证据能证明什么,不能证明什么
找回流程最常见的错误,是把“能说出账号信息”当成“拥有账号”。证据要按它实际证明的东西分类。
| 证据 | 权重(示例值) | 能证明 | 不能证明 |
|---|---|---|---|
| 邮箱或手机验证码 | 25 | 控制该邮箱或号码 | 控制者就是账号本人 |
| 支付凭证 | 35 | 控制该支付方式 | 当前申请人就是付款人 |
| 常用设备 | 20 | 持有该设备 | 设备的所有权 |
| 身份证件 | 30 | 身份信息真实 | 该身份注册过这个账号 |
自动放行的门槛设为 60 分,30 到 59 分转人工,低于 30 分拒绝。这个门槛的含义是:至少两类互相独立的证据,且其中一类不是设备。设备加邮箱只有 45 分,仍然要人工,因为共用设备太常见;支付凭证加邮箱达到 60 分,可以自动,但仍要受风险分约束。
高风险账号永远不自动放行。风险分达到挑战阈值时,即使证据满分也转人工;达到阻断阈值时,只能由专门的安全团队处理。原因很简单:找回是身份证明,不是授权;证据再强,也不能覆盖“这个账号正在被攻击”的当前状态。
代码:internal/accountrisk
下面的 risk.go 只做三件事:累计窗口内失败、按信号打分、把分数映射成带期限的动作。审计与找回决策在同一个包的 audit.go 里。
// file: internal/accountrisk/risk.go
// tier: L1
// verify: go test ./internal/accountrisk/
package accountrisk
import (
"time"
)
type Action string
const (
Allow Action = "ALLOW"
Challenge Action = "CHALLENGE"
Block Action = "BLOCK"
)
// Request is what the login edge already knows before it asks for a decision.
// It deliberately contains no password, token or provider secret.
type Request struct {
RequestID string
AccountID string
DeviceID string
KnownDevice bool
Geo string
LastGeo string
Guest bool
AccountAgeDays int
}
// Policy is deployment configuration. Thresholds are integers because the
// score is an explanation tool, not a probability.
type Policy struct {
ChallengeAt int
BlockAt int
FailureBlockAt int
FailureWindow time.Duration
LockFor time.Duration
}
func DefaultPolicy() Policy {
return Policy{
ChallengeAt: 30,
BlockAt: 70,
FailureBlockAt: 8,
FailureWindow: 15 * time.Minute,
LockFor: 15 * time.Minute,
}
}
// Decision is both the answer and the audit record. Rules names the exact
// signals that fired, so support can explain a challenge without replaying
// the score by hand.
type Decision struct {
RequestID string
AccountID string
Action Action
Score int
Rules []string
LockUntil time.Time
At time.Time
}
type Engine struct {
policy Policy
clock func() time.Time
locks map[string]time.Time
failures map[string][]time.Time
events []Decision
}
func New(policy Policy, clock func() time.Time) *Engine {
return &Engine{
policy: policy,
clock: clock,
locks: make(map[string]time.Time),
failures: make(map[string][]time.Time),
}
}
// NoteFailure records one failed credential attempt and returns the count
// still inside the window. The window is what keeps a bad night from becoming
// a permanent lock: old failures age out instead of accumulating forever.
func (e *Engine) NoteFailure(accountID string) int {
now := e.clock()
kept := e.trim(accountID, now)
kept = append(kept, now)
e.failures[accountID] = kept
return len(kept)
}
func (e *Engine) ResetFailures(accountID string) {
delete(e.failures, accountID)
}
func (e *Engine) trim(accountID string, now time.Time) []time.Time {
cutoff := now.Add(-e.policy.FailureWindow)
old := e.failures[accountID]
kept := make([]time.Time, 0, len(old))
for _, at := range old {
if at.After(cutoff) {
kept = append(kept, at)
}
}
return kept
}
// Evaluate returns allow, challenge or block. A block always carries an
// expiry; there is no code path that produces an indefinite lock.
func (e *Engine) Evaluate(req Request) Decision {
now := e.clock()
d := Decision{RequestID: req.RequestID, AccountID: req.AccountID, At: now}
if until, ok := e.locks[req.AccountID]; ok {
if now.Before(until) {
d.Action = Block
d.LockUntil = until
d.Rules = []string{"lock.active"}
e.record(d)
return d
}
delete(e.locks, req.AccountID)
}
score, rules := e.score(req, now)
failures := len(e.trim(req.AccountID, now))
d.Score = score
d.Rules = rules
switch {
case failures >= e.policy.FailureBlockAt:
d.Action = Block
d.Rules = append(d.Rules, "auth.failures.block")
case score >= e.policy.BlockAt:
d.Action = Block
case score >= e.policy.ChallengeAt:
d.Action = Challenge
default:
d.Action = Allow
}
if d.Action == Block {
d.LockUntil = now.Add(e.policy.LockFor)
e.locks[req.AccountID] = d.LockUntil
}
e.record(d)
return d
}
func (e *Engine) score(req Request, now time.Time) (int, []string) {
score := 0
rules := make([]string, 0, 5)
add := func(id string, weight int) {
score += weight
rules = append(rules, id)
}
if !req.KnownDevice {
add("device.new", 30)
}
if req.LastGeo != "" && req.LastGeo != req.Geo {
add("geo.jump", 25)
}
if req.Guest {
add("credential.guest", 10)
}
if req.AccountAgeDays >= 0 && req.AccountAgeDays < 3 {
add("account.young", 10)
}
if failures := len(e.trim(req.AccountID, now)); failures >= 3 {
add("auth.failures", 15)
}
return score, rules
}
func (e *Engine) record(d Decision) {
d.Rules = append([]string(nil), d.Rules...)
e.events = append(e.events, d)
}
三个实现细节决定了它能不能上生产。第一,Evaluate 先检查现有锁定,再打分;锁定期间不重复累计失败,避免攻击者用持续请求把锁定时间越拖越长。第二,阻断一定写入 LockUntil,没有“永久”分支。第三,Decision.Rules 记录的是规则 id,不是自然语言;客服看到的是 device.new + geo.jump,可以据此解释,而不是只看到一个分数。
DefaultPolicy 里的数字是起点,不是结论。上线后要按挑战通过率、误判申诉率和阻断后留存回看阈值;把这些值写死在代码里,等于放弃调参能力。
测试覆盖了什么
internal/accountrisk 有 5 个测试函数,其中 4 个是表驱动;它们覆盖打分、窗口、锁定、找回与审计。
| 测试 | 覆盖的失败模式 |
|---|---|
TestEvaluateTable | 干净登录、新设备、异地、组合信号越过阻断线 |
TestFailureWindowTable | 三次与七次失败、八次硬阻断、计数过期与未过期 |
TestLockoutExpiryTable | 锁定中、到期自动解除、人工解锁 |
TestRecoveryTable | 证据组合、重复证据去重、挑战与阻断级风险 |
TestAuditTrail | 事件顺序、按账号过滤、规则列表不被外部篡改 |
本机运行结果:
$ go test -count=1 ./internal/accountrisk/
ok gsarchlab/internal/accountrisk 0.498s
失败模式与兜底
共用出口 IP 被当成异地。 网吧、校园网和运营商 NAT 会让大量玩家共享出口。异地判断应基于城市级或 ASN 级的变化,并要求时间上不可达;只比 IP 段,会把整栋宿舍楼判成一个人。更稳妥的做法是把异地当作挑战信号,不单独阻断。
攻击者故意输错来锁死受害者。 如果失败计数只按账号累计,任何人只要知道账号名就能让对方无法登录。计数键应包含账号、设备和来源网段的组合,并对“只失败、从未成功”的来源提高权重;锁定期间改用挑战而不是完全拒绝,可以降低这种拒绝服务的效果。
找回证据被社工收集。 订单截图、角色名、注册时间都可能从公开渠道获得。自动放行至少要求两类独立证据,支付凭证不能与订单截图混为一谈;高价值账号一律人工,并要求回拨注册手机号或视频核验。
锁定到期后风险仍在。 自动解锁不等于自动放行。到期后必须重新评估,如果失败计数仍在窗口内或信号依旧,允许再次阻断;但每次阻断都要有新的判定记录,不能把上一次的 LockUntil 直接延长。
审计日志本身泄露。 审计要记录账号、时间、规则和动作,但不记录密码、验证码、完整证件号和完整设备指纹。账号 id 在日志中可以加盐哈希,客服通过工单系统反查;把原始凭据写进日志,风控系统就会变成新的泄露源。
常见错误与边界
第一,把风控做成布尔值。没有挑战档,所有不确定都只能二选一,误判代价直接转成流失。
第二,用永久封禁处理登录风险。封禁是处罚,风控是登录决策;两者混在一起,申诉流程会被迫承担安全职责。
第三,只记录阻断,不记录挑战。挑战量远大于阻断量,恰恰是误判和规则调优的主要证据来源。
第四,把证据分数当成身份认证。证据证明的是“控制某样东西”,不是“就是本人”;自动放行必须有上限,高价值账号必须人工。
第五,阈值写死在代码里。不同品类、不同渠道、不同地区的容忍度不同,阈值必须是配置,并且有变更审计。
小结
账号安全的核心不是把风险降到零,而是把不确定性放进一个有期限、可解释、可申诉的流程里。风险分负责解释,三档动作负责分流,窗口计数负责识别撞库,带期限的锁定负责止损,证据分级负责找回,审计负责事后回答“谁在何时因为什么规则被挑战或阻断”。
到这一步,玩家已经被认出、绑定关系已经建立、登录决策也已经做出。下一步要把这个结果变成一张可验证、可续期、可吊销的票据,让游戏服不再重复认证,也让顶号、重连和跨服迁移有统一的凭据。
阅读导航:上一节:1.2 第三方登录与账号绑定 · 下一节:2.1 登录态、票据与续期 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。