《游戏服务端编程实践》4.1.3 账号绑定与安全策略

说明邮箱、手机和第三方账号绑定的表结构与冲突处理,给出登录、绑定、解绑、改密、注销的风险分级、二次验证和冷却期策略,并用可运行 Go 代码验证绑定变更后旧会话全部失效,同时分析盗号后旧令牌仍有效的失败场景、修复顺序与唯一约束设计要点,并给出落地顺序。

绑定邮箱、手机号或第三方账号,表面上是给账号多加一个登录方式,实际上是在改变账号的凭据集合。凭据集合一变,旧会话的可信度、找回密码的路径、客服的处置权限都会跟着变。本节讨论三件事:绑定关系怎么存,哪些操作要二次验证,绑定变更后旧会话怎么处理。

一、绑定关系不是账号表的一列

早期实现经常在账号表上加 email、phone、steam_id 三列。这样在“一个账号最多绑一个邮箱、一个手机、一个 Steam”时能工作,但一旦出现“一个账号绑定多个第三方登录方式”“同一邮箱先绑 A 再绑 B”“需要记录绑定、验证、解绑时间”等需求就会失效。正确做法是独立的绑定关系表,一行表示“一个账号拥有一个外部身份”。

二、绑定关系表设计

字段类型说明
account_iduint64账号 ID,外键到账号表
providerstringemail、phone、steam、apple 等
external_idstring该 provider 下的唯一标识
verified_attimestamp完成验证的时间,未验证为 NULL
statusstringactive、revoked、pending
created_attimestamp绑定发起时间
updated_attimestamp最近状态变更时间

唯一约束应建在 (provider, external_id) 上,而不是 account_id 上:同一个外部身份在同一时刻只能属于一个账号,一个账号却可以有多个外部身份。若业务允许同一邮箱在多个租户下各绑一个账号,唯一键要扩展为 (tenant_id, provider, external_id)。冲突分三种:同一账号重复绑定同一外部身份,幂等成功并更新 verified_at;外部身份已被其他账号绑定,拒绝并返回“已绑定其他账号”,不泄露具体账号;外部身份曾被解绑后重新绑定,允许,但要求重新验证并记录历史。错误信息不能告诉调用方“这个邮箱属于账号 12345”,否则攻击者可以枚举邮箱与账号的对应关系。

三、敏感操作分级

操作风险等级二次验证冷却期说明
登录低通常不需要无新设备、异地登录可提升等级
刷新 Token低不需要无仅延长会话
绑定新凭据高必须绑定后 24 小时防止攻击者加自己的邮箱
解绑凭据高必须解绑后 24 小时防止攻击者移除受害者凭据
修改密码极高必须改密后强制重登影响所有登录方式
注销账号极高必须7 至 30 天冷静期不可逆操作

分级不是静态的。短时间内绑定失败达到阈值、登录地与历史常用地不一致、设备指纹突变、账号近期有充值或交易,都会把低风险操作提升为高风险。本文用简单评分表达:绑定失败 3 次记 3 分,异地登录记 2 分,设备变化记 1 分,总分达到 3 分时,即使操作本身是低风险,也要求二次验证。

二次验证最常见漏洞是“只校验验证码,不校验会话归属”。攻击者可以在自己的会话里完成验证,再拿着验证结果去调用另一个会话的敏感接口。正确做法是把验证结果绑定到 session_id:step_up(session_id, code) 写入 stepup:{session_id} = now,TTL 10 分钟;敏感操作读取同一个键,存在且未过期才放行。验证码只证明“某人能收到这个邮箱或手机”,不能证明“当前会话的主人就是账号主人”。

冷却期不是防止玩家手滑,而是给受害者发现和止损的时间。攻击者绑定自己的邮箱后通常会立刻发起找回密码;如果系统允许绑定后立即改密,受害者可能来不及反应。设置 24 小时冷却,可以让原凭据持有人收到通知并冻结账号。客服人工核验、原设备确认、历史充值验证可以作为例外,但必须留审计日志,不能成为内部人员绕过风控的后门。

四、绑定变更后的会话吊销

绑定、解绑、改密、注销都会改变凭据集合。变更完成后,旧会话必须失效,至少包括刷新令牌和长连接两类:不吊销刷新令牌,攻击者可以继续换新的访问令牌;不踢长连接,攻击者已经建立的游戏连接可以继续操作角色。正确的吊销顺序是:在事务中更新绑定关系或密码;提升账号的 session_version;删除该账号所有 refresh token;通知网关断开该账号的所有长连接;写审计日志记录操作类型、账号、会话数和时间。session_version 是兜底:即使某台网关没收到断开通知,它下次校验会话时会发现版本不一致,从而拒绝继续使用。只依赖“通知网关”不够,网络分区和进程重启都会让通知丢失。

五、风控信号

信号采集方式用途
绑定失败次数按账号、IP、设备聚合识别枚举和撞库
验证码发送频率按手机号、邮箱、IP 限流防止短信轰炸
登录地变化IP 归属地 + 常用地画像异地绑定提升风险
设备变化安装 ID、指纹相似度新设备绑定加验证
绑定后立即改密操作序列分析盗号接管特征
解绑后立即绑新凭据操作序列分析账号买卖或洗号

这些信号不应直接触发封号。更稳妥的策略是“提升验证等级 + 延迟生效 + 人工复核”:异地绑定不直接拒绝,而是要求原设备确认;原设备不可用时进入 24 小时冷却,并通知所有已绑定渠道。

六、Go 最小实现

下面的 policy.go 把风险分级、二次验证、解绑约束和会话吊销写成可测试的策略层。它不依赖 Redis 或数据库,重点展示判定顺序和失败语义。

// file: ch04/identity/policy.go
// tier: L1
// verify: go test ./ch04/identity/

package identity

import (
	"errors"
	"sort"
	"strings"
	"time"
)

type Provider string

const (
	ProviderEmail Provider = "email"
	ProviderPhone Provider = "phone"
	ProviderSteam Provider = "steam"
)

type BindingStatus string

const (
	BindingActive  BindingStatus = "active"
	BindingRevoked BindingStatus = "revoked"
)

// Binding is one verified external identity owned by an account.
type Binding struct {
	AccountID  AccountID
	Provider   Provider
	ExternalID string
	VerifiedAt time.Time
	Status     BindingStatus
}

var (
	ErrInvalidBinding = errors.New("identity: provider or external id is empty")
	ErrExternalBound  = errors.New("identity: external identity is bound to another account")
	ErrBindingMissing = errors.New("identity: binding not found")
	ErrBindingRevoked = errors.New("identity: binding is not active")
	ErrLastCredential = errors.New("identity: cannot unbind the last verified credential")
	ErrCooldown       = errors.New("identity: binding change is in cooldown")
	ErrStepUpRequired = errors.New("identity: step-up verification required")
)

type RiskLevel int

const (
	RiskLow RiskLevel = iota
	RiskMedium
	RiskHigh
	RiskCritical
)

type Operation string

const (
	OpLogin          Operation = "login"
	OpRefresh        Operation = "refresh"
	OpBind           Operation = "bind"
	OpUnbind         Operation = "unbind"
	OpChangePassword Operation = "change_password"
	OpDeleteAccount  Operation = "delete_account"
)

func RiskOf(op Operation) RiskLevel {
	switch op {
	case OpLogin, OpRefresh:
		return RiskLow
	case OpBind, OpUnbind:
		return RiskHigh
	case OpChangePassword, OpDeleteAccount:
		return RiskCritical
	default:
		return RiskHigh
	}
}

// RiskSignals are cheap hints collected before a sensitive decision. They
// raise the bar; they never replace authentication.
type RiskSignals struct {
	BindFailures  int
	NewCountry    bool
	DeviceChanged bool
}

func (s RiskSignals) Score() int {
	score := 0
	if s.BindFailures >= 3 {
		score += 3
	}
	if s.NewCountry {
		score += 2
	}
	if s.DeviceChanged {
		score++
	}
	return score
}

func RequireStepUp(op Operation, sig RiskSignals) bool {
	if RiskOf(op) >= RiskHigh {
		return true
	}
	return sig.Score() >= 3
}

// AuthContext is the session that requests the operation. StepUpSession must
// equal SessionID so a code verified in one session cannot authorize another.
type AuthContext struct {
	AccountID         AccountID
	SessionID         string
	StepUpSession     string
	StepUpAt          time.Time
	LastBindingChange time.Time
}

func StepUpFresh(ctx AuthContext, now time.Time) bool {
	if ctx.StepUpAt.IsZero() || ctx.StepUpSession == "" {
		return false
	}
	if ctx.StepUpSession != ctx.SessionID {
		return false
	}
	return now.Sub(ctx.StepUpAt) <= 10*time.Minute
}

// CanUnbind is the last gate before removing a credential. The caller must
// re-check ownership and step-up even if the UI already asked for a code.
func CanUnbind(b Binding, ctx AuthContext, activeCount int, now time.Time) error {
	if b.Status != BindingActive {
		return ErrBindingRevoked
	}
	if !StepUpFresh(ctx, now) {
		return ErrStepUpRequired
	}
	if activeCount <= 1 {
		return ErrLastCredential
	}
	if !ctx.LastBindingChange.IsZero() && now.Sub(ctx.LastBindingChange) < 24*time.Hour {
		return ErrCooldown
	}
	return nil
}

// BindingStore enforces one owner per (provider, external id).
type BindingStore struct {
	byKey map[string]Binding
}

func NewBindingStore() *BindingStore {
	return &BindingStore{byKey: make(map[string]Binding)}
}

func bindingKey(provider Provider, externalID string) string {
	return string(provider) + "\x00" + externalID
}

func (s *BindingStore) Bind(b Binding, now time.Time) error {
	b.ExternalID = strings.TrimSpace(b.ExternalID)
	if b.Provider == "" || b.ExternalID == "" {
		return ErrInvalidBinding
	}
	key := bindingKey(b.Provider, b.ExternalID)
	if old, ok := s.byKey[key]; ok {
		if old.Status == BindingActive && old.AccountID != b.AccountID {
			return ErrExternalBound
		}
	}
	b.VerifiedAt = now.UTC()
	b.Status = BindingActive
	s.byKey[key] = b
	return nil
}

func (s *BindingStore) Unbind(accountID AccountID, provider Provider, externalID string) error {
	key := bindingKey(provider, externalID)
	b, ok := s.byKey[key]
	if !ok || b.AccountID != accountID {
		return ErrBindingMissing
	}
	b.Status = BindingRevoked
	s.byKey[key] = b
	return nil
}

func (s *BindingStore) Lookup(provider Provider, externalID string) (Binding, bool) {
	b, ok := s.byKey[bindingKey(provider, externalID)]
	return b, ok
}

func (s *BindingStore) ActiveCount(accountID AccountID) int {
	n := 0
	for _, b := range s.byKey {
		if b.AccountID == accountID && b.Status == BindingActive {
			n++
		}
	}
	return n
}

// Session is one login session. RefreshToken and GatewayConn are revoked
// together; leaving either alive after a binding change keeps an attacker in.
type Session struct {
	ID           string
	AccountID    AccountID
	RefreshToken string
	GatewayConn  string
}

type SessionStore struct {
	byAccount map[AccountID]map[string]Session
}

func NewSessionStore() *SessionStore {
	return &SessionStore{byAccount: make(map[AccountID]map[string]Session)}
}

func (s *SessionStore) Add(sess Session) {
	if s.byAccount[sess.AccountID] == nil {
		s.byAccount[sess.AccountID] = make(map[string]Session)
	}
	s.byAccount[sess.AccountID][sess.ID] = sess
}

// RevokeSessionsOnChange removes every session of the account and returns a
// snapshot so the caller can disconnect gateway connections and audit them.
func RevokeSessionsOnChange(store *SessionStore, accountID AccountID) []Session {
	if store.byAccount[accountID] == nil {
		return nil
	}
	out := make([]Session, 0, len(store.byAccount[accountID]))
	for _, sess := range store.byAccount[accountID] {
		out = append(out, sess)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
	delete(store.byAccount, accountID)
	return out
}

func (s *SessionStore) Count(accountID AccountID) int {
	return len(s.byAccount[accountID])
}

代码里有四个关键设计:RequireStepUp 先看操作基线风险,再看风控信号;StepUpFresh 同时校验时间窗口和 StepUpSession == SessionID;CanUnbind 的检查顺序是“状态 → 二次验证 → 最后凭据 → 冷却期”,顺序不同会给出不同的错误提示;RevokeSessionsOnChange 一次性删除账号的全部会话并返回快照供网关断开连接和审计。

七、测试覆盖

policy_test.go 用表驱动覆盖策略边界:

场景测试用例期望
普通登录TestRequireStepUp不需要二次验证
绑定/解绑/改密/注销TestRequireStepUp全部要求
连续绑定失败TestRequireStepUp登录也升级为需要验证
异地 + 新设备TestRequireStepUp达到阈值,需要验证
验证码跨会话 / 超过 10 分钟TestStepUpIsSessionBoundAndExpires拒绝
解绑最后一个凭据TestCanUnbindErrLastCredential
冷却期内解绑TestCanUnbindErrCooldown
外部身份重复绑定 / 解绑后重绑TestBindingStoreOneOwnerAndReactivationErrExternalBound / 成功
绑定变更吊销全部会话TestBindingChangeRevokesAllSessionForms会话数为 0,其他账号不受影响

测试里专门保留了“其他账号不受影响”的断言。吊销逻辑最容易写错的地方是条件漏了 account_id,把全服会话一起删掉。

八、失败场景分析:绑定成功但 Token 不失效

某游戏上线“绑定手机号送奖励”活动。玩家绑定成功后,系统更新了绑定表,但刷新令牌仍然有效。攻击者通过钓鱼拿到受害者的旧 refresh token,在受害者绑定新手机号后继续刷新访问令牌,持续登录了三天。

复盘发现三个问题:绑定接口只更新数据库,没有调用会话吊销;访问令牌只有 15 分钟,但刷新令牌有效期 30 天;网关长连接没有版本校验,数据库里的 session_version 变了,旧连接仍可发消息。修复分为三层:写路径上,绑定、解绑、改密、注销统一调用 RevokeSessionsOnChange,在事务提交后执行;令牌层,刷新令牌校验时同时检查 session_version;连接层,网关每次心跳校验会话版本,不一致则断开并要求重新登录。绑定变更的安全边界不只是“绑定表写对了”,而是“旧凭据全部失效”。

小结

账号绑定的本质是维护“账号拥有哪些外部身份”的关系集合,而不是在账号表上加几列。绑定表要有独立的唯一约束和状态字段;敏感操作要按风险分级,绑定、解绑、改密、注销必须二次验证,验证结果要绑定会话;绑定变更后必须吊销刷新令牌、提升会话版本并断开长连接。只改数据不吊销凭据,是盗号后攻击者仍能长期在线的直接原因。

常见错误与边界

错误一:绑定成功但 Token 不失效。 攻击者拿到旧 refresh token 后可以继续刷新。绑定、解绑、改密、注销都必须触发全量会话吊销。

错误二:解绑后第三方仍能登录。 只删除绑定记录、不吊销通过该第三方建立的会话,攻击者仍可凭旧令牌访问。解绑要同时撤销该 provider 关联的会话。

错误三:只校验验证码不校验会话归属。 验证码证明“能收到邮件或短信”,不证明“当前会话是账号主人”。step-up 结果必须绑定 session_id。

错误四:错误信息泄露账号存在性。 “该邮箱已绑定账号 12345”会帮助攻击者枚举。统一提示“已绑定其他账号”即可。

边界一:最后一个凭据能否解绑? 默认不能,否则玩家会失去所有登录方式,只能走客服找回。若产品允许,必须同时要求设置新密码或绑定新凭据。

边界二:冷却期是否绝对? 不是。原设备确认、历史充值验证、客服人工核验都可以作为例外,但必须留审计日志,并限制例外频率。

边界三:第三方账号解绑后,对方平台是否还有授权? 本地解绑不等于第三方撤销授权。若使用 OAuth,解绑时应调用对方的 revoke 接口,至少要在本地记录“不再接受该 provider 的登录回调”。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist