《游戏服务端架构·核心系统与契约》1.3 账号安全、找回与风控

账号能被认出来之后,服务端还要回答这次登录是否可信、找回请求能否放行。本文把设备变化、异地、失败频次与行为异常折算成可解释的风险分,给出放行、挑战、阻断三档动作与带期限的锁定策略,讨论找回证据各自能证明什么,并用 internal/accountrisk 的 Go 实现、审计记录和五组表驱动测试说明风控如何避免变成永久封禁。

一次“证据齐全”的找回

《群岛纪元》的客服收到一份找回申请:对方提供了订单号、支付截图、角色名和大致注册时间,要求把账号换绑到新手机号。证据看起来齐全,客服按流程放行。三天后,原号主投诉账号被盗,订单和角色名是从一张公开的充值截图里抄来的。

复盘时发现两件事。第一,客服拿到的“证据”只能证明申请者见过这些信息,不能证明申请者就是付款人;第二,系统没有记录这次放行是因为哪些规则、谁批准的,事后只能靠聊天记录还原。风控缺的不是一个更复杂的模型,而是三样东西:可解释的信号、分档的动作、可追溯的审计。

另一头,《裂隙战场》的运营做过一次粗暴实验:同一出口 IP 在十分钟内登录超过二十个账号就整段封禁。结果一家网吧的正常玩家全部被挡在门外,投诉量比外挂时期还高。风险判断一旦只有“放行/封禁”两个值,误判就没有缓冲地带。

三种动作,而不是一个布尔

风控的输出应该是三档,而不是“安全/不安全”。

动作触发条件(示例值)玩家体验误判代价必要兜底
放行 ALLOW风险分低于挑战阈值无感放过一次可疑登录事后审计与离线复核
挑战 CHALLENGE达到挑战阈值、未到阻断阈值多一步验证少量流失可换验证方式、可重试
阻断 BLOCK达到阻断阈值或失败次数超限拒绝登录明显流失与口碑锁定必须有期限、可人工解锁

挑战是这套设计的关键。它把“不确定”变成一次可完成的验证,而不是直接拒绝。新设备、异地、游客凭据这些信号单独出现时都不足以证明盗号,组合起来才值得阻断;把它们直接映射成封禁,等于让风控替玩家做最坏假设。

风险分只是解释工具,不是概率。下面的权重是《群岛纪元》上线初期使用的示例值,来自运营和客服共同评审,不是模型训练结果。

信号权重说明
新设备30该账号首次在此设备登录
异地跳变25与上次登录城市不同,且时间上不可达
失败次数 ≥ 315窗口内凭证失败累计
游客凭据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 登录态、票据与续期 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查