绑定邮箱、手机号或第三方账号,表面上是给账号多加一个登录方式,实际上是在改变账号的凭据集合。凭据集合一变,旧会话的可信度、找回密码的路径、客服的处置权限都会跟着变。本节讨论三件事:绑定关系怎么存,哪些操作要二次验证,绑定变更后旧会话怎么处理。
一、绑定关系不是账号表的一列
早期实现经常在账号表上加 email、phone、steam_id 三列。这样在“一个账号最多绑一个邮箱、一个手机、一个 Steam”时能工作,但一旦出现“一个账号绑定多个第三方登录方式”“同一邮箱先绑 A 再绑 B”“需要记录绑定、验证、解绑时间”等需求就会失效。正确做法是独立的绑定关系表,一行表示“一个账号拥有一个外部身份”。
二、绑定关系表设计
| 字段 | 类型 | 说明 |
|---|---|---|
account_id | uint64 | 账号 ID,外键到账号表 |
provider | string | email、phone、steam、apple 等 |
external_id | string | 该 provider 下的唯一标识 |
verified_at | timestamp | 完成验证的时间,未验证为 NULL |
status | string | active、revoked、pending |
created_at | timestamp | 绑定发起时间 |
updated_at | timestamp | 最近状态变更时间 |
唯一约束应建在 (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 | 拒绝 |
| 解绑最后一个凭据 | TestCanUnbind | ErrLastCredential |
| 冷却期内解绑 | TestCanUnbind | ErrCooldown |
| 外部身份重复绑定 / 解绑后重绑 | TestBindingStoreOneOwnerAndReactivation | ErrExternalBound / 成功 |
| 绑定变更吊销全部会话 | 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 的登录回调”。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。