《游戏服务端架构·核心系统与契约》2.1 登录态、票据与续期

登录票据与会话令牌常被混为一谈:前者单次、短暂、绑定网关,后者长期、可续期、需要吊销。本节拆开两者的生命周期,比较自包含令牌与查库校验的代价,给出滑动过期加绝对天花板、并发续期版本号、全端下线与时钟偏移处理的可运行实现,并划定令牌泄露与跨游戏复用的边界。

登录不是一次动作,而是三段信任

北辰工作室的三款产品,登录形态完全不同:

产品登录形态主要风险
《裂隙战场》短会话,玩家频繁切换账号旧令牌未失效、顶号
《群岛纪元》长在线,跨时区挂机令牌长期有效、吊销不及时
《泡泡工坊》游客登录,买量后快速进入票据被脚本批量重放

如果只用一个“登录成功”布尔值表示身份,这三种风险会挤在同一段代码里。更清晰的做法,是把登录拆成三段信任:

  1. 身份证明:账号服务验证密码、短信或第三方凭据,回答“你是谁”。
  2. 进入许可:签发票据,回答“你被允许进入哪个网关、多久内有效”。
  3. 持续授权:签发会话令牌,回答“这条连接接下来还能不能代表该账号”。

账号安全、找回与风控属于第一段,本节从第二段讲起。网关只负责验证凭证并转发,不参与密码校验,也不保存角色数据;网关职责边界在卷一的网关横向扩展一节已有结论,这里不重复,只补充核心系统视角的生命周期设计。

两种凭证,两种生命周期

登录票据和会话令牌最容易被合并成一个 JWT,然后在所有接口复用。合并的代价是:票据本该单次使用,却因为“反正签名有效”被重放;会话令牌本该可吊销,却因为无状态而只能等过期。

维度登录票据会话令牌
生命周期秒级,示例值 30 秒分钟到小时,示例值空闲 30 分钟
使用次数严格单次多次,可续期
绑定对象账号、目标网关、登录请求账号、会话 ID、设备或连接
存储位置客户端内存,用完即弃客户端持久化,服务端保留撤销记录
失效手段过期、JTI 重放表过期、吊销、版本号、全端下线
值不值得查库不值得,验签即可续期和敏感操作值得查一次

票据短,所以重放窗口小;令牌长,所以必须有主动失效手段。把两者分开后,系统里就不存在“一张万能凭证”,每个凭证只回答一个问题。

决策:自包含令牌还是每次查账号库

会话校验每帧都可能发生,不能每次都打账号库。可选方案有三种:

方案读取成本吊销时效密钥轮换适用
不透明随机串 + 每次查 Redis一次网络往返立即无影响会话少、强一致要求高
自包含签名令牌本地验签等到过期需要双密钥过渡高频校验、可接受短暂延迟
自包含 + 撤销表 + 版本号本地验签,续期时查秒级需要双密钥过渡大多数在线游戏

《裂隙战场》和《群岛纪元》都选第三种。理由不是“无状态更优雅”,而是校验频率太高:一场十分钟的对局,业务消息校验令牌的次数远高于登录次数。把校验压到本地 HMAC,换来的是吊销从“立即”退化为“秒级”,这个代价可以接受,因为真正敏感的操作(改密、交易、跨服迁移)仍会强制查一次撤销表。

签名算法用 HMAC-SHA256 就够了:签发方和校验方都在内网,共享一把密钥。非对称签名适合跨域或第三方校验,但会带来密钥分发和轮换成本,游戏服务端通常不需要。密钥轮换要预留双密钥窗口:新令牌用新密钥签,旧令牌仍能用旧密钥验,窗口结束后再移除旧密钥。

续期:滑动窗口必须有天花板

滑动过期解决“玩家长时间在线被踢”,绝对过期解决“令牌泄露后永久有效”。两者必须同时存在:

策略行为不做的后果
滑动过期每次续期把空闲截止时间往后推挂机玩家每小时被踢一次
绝对过期从签发起算,续期不能越过泄露令牌可以无限续命
服务端判定过期与否只由服务端时钟决定客户端改系统时间即可绕过

续期触发方式也有取舍:

触发方式优点代价
每条业务消息顺带续期及时,客户端无感写放大,续期请求风暴
客户端临近过期主动续期请求少,易限流客户端时钟不可信,需要服务端时间
网关定时批量续期可控,适合长连接连接迁移时要同步会话

《群岛纪元》选第二种,但客户端不判断“是否过期”,只根据服务端返回的 expires_at 和 server_time 计算剩余时间。服务端始终是唯一裁判。

并发续期、吊销与全端下线

续期不是幂等操作:两个并发请求都拿着版本 0 的令牌,如果都成功,就会产生两张版本 1 的令牌,客户端不知道该用哪张。实现用一个单调递增的 Version 做 compare-and-set:只有 expectedVersion 等于服务端记录的版本时,续期才成功,并把版本加一。

这里有一个必须明确的边界:旧令牌在空闲过期前仍然可以通过 Verify。这样做是为了不打断在途消息,代价是“同一会话短时间内可能有多张可用令牌”。如果产品要求严格单令牌,就必须在每次 Verify 时也校验版本,代价是续期瞬间的旧请求会被拒绝。两种都合理,关键是写进契约,而不是让网关和业务服各自假设。

吊销用一张带过期时间的撤销表:键是会话 ID,值是绝对过期时间。RevokeAccount 遍历该账号的所有会话,实现“全端下线”。撤销表可以放在本地缓存加短 TTL,也可以通过消息广播;无论哪种,失败时必须关闭失败:查不到撤销状态就拒绝敏感操作,而不是放行。

跨游戏复用令牌是常见越界。令牌必须带受众(游戏 ID 或服务名),校验方必须检查受众匹配。只验签名不验受众,等于把一个游戏的登录态借给了另一个游戏。

代码:续期、吊销与清理

下面的 renew.go 只包含校验、续期、吊销和清理。签发与解析在同一个包的其他文件里:types.go 定义声明和签名逻辑,issue.go 负责票据与会话的首次签发。续期时先校验时间窗口和撤销状态,再校验版本,最后把新的空闲截止时间夹在绝对截止时间之内。

// file: internal/loginticket/renew.go
// tier: L1
// verify: go test ./internal/loginticket/

// Package loginticket issues short-lived, single-use login tickets and
// renewable session tokens. Tickets answer "this client just passed login";
// session tokens answer "this connection may keep acting as the account".
package loginticket

import "fmt"

// Verify checks a session token without extending it.
func (i *Issuer) Verify(token string) (Claims, error) {
	c, err := i.parse(token)
	if err != nil {
		return Claims{}, err
	}
	if c.Kind != KindSession {
		return Claims{}, ErrMalformed
	}
	i.mu.Lock()
	defer i.mu.Unlock()
	now := i.cfg.Now()
	if err := i.checkWindow(c, now); err != nil {
		return Claims{}, err
	}
	if until, ok := i.revoked[c.SessionID]; ok && now.Before(until) {
		return Claims{}, ErrRevoked
	}
	return c, nil
}

// Renew slides the idle window but never past AbsoluteAt. expectedVersion
// makes concurrent renewals race-safe: only the first one advances.
func (i *Issuer) Renew(token string, expectedVersion uint64) (string, Claims, error) {
	c, err := i.parse(token)
	if err != nil {
		return "", Claims{}, err
	}
	if c.Kind != KindSession {
		return "", Claims{}, ErrMalformed
	}
	i.mu.Lock()
	defer i.mu.Unlock()
	now := i.cfg.Now()
	if err := i.checkWindow(c, now); err != nil {
		return "", Claims{}, err
	}
	if until, ok := i.revoked[c.SessionID]; ok && now.Before(until) {
		return "", Claims{}, ErrRevoked
	}
	if i.version[c.SessionID] != expectedVersion || c.Version != expectedVersion {
		return "", Claims{}, ErrStale
	}
	next := c
	next.Version = c.Version + 1
	next.IssuedAt = now
	next.NotBefore = now
	next.ExpiresAt = now.Add(i.cfg.IdleTTL)
	if next.ExpiresAt.After(c.AbsoluteAt) {
		next.ExpiresAt = c.AbsoluteAt
	}
	next.JTI = fmt.Sprintf("%s-%d", c.SessionID, next.Version)
	i.version[c.SessionID] = next.Version
	token, err = i.sign(next)
	return token, next, err
}

// Revoke kills one session until its absolute deadline.
func (i *Issuer) Revoke(sessionID string) {
	i.mu.Lock()
	defer i.mu.Unlock()
	until, ok := i.sessions[sessionID]
	if !ok {
		until = i.cfg.Now().Add(i.cfg.AbsoluteTTL)
	}
	i.revoked[sessionID] = until
}

// RevokeAccount implements "log out everywhere" for one account.
func (i *Issuer) RevokeAccount(accountID string) int {
	i.mu.Lock()
	defer i.mu.Unlock()
	n := 0
	for sid, acct := range i.owner {
		if acct != accountID {
			continue
		}
		until, ok := i.sessions[sid]
		if !ok {
			until = i.cfg.Now().Add(i.cfg.AbsoluteTTL)
		}
		i.revoked[sid] = until
		n++
	}
	return n
}

// Sweep drops bookkeeping that can no longer affect a decision.
func (i *Issuer) Sweep() int {
	now := i.cfg.Now()
	i.mu.Lock()
	defer i.mu.Unlock()
	n := 0
	for jti, exp := range i.used {
		if !now.Before(exp) {
			delete(i.used, jti)
			n++
		}
	}
	for sid, until := range i.revoked {
		if !now.Before(until) {
			delete(i.revoked, sid)
			delete(i.sessions, sid)
			delete(i.owner, sid)
			delete(i.version, sid)
			n++
		}
	}
	for sid, abs := range i.sessions {
		if !now.Before(abs) {
			delete(i.sessions, sid)
			delete(i.owner, sid)
			delete(i.version, sid)
			n++
		}
	}
	return n
}

对应的表驱动测试覆盖四类行为:正常滑动、并发续期导致的旧版本拒绝、绝对天花板、吊销后拒绝续期。测试用假时钟推进时间,不依赖真实等待。

// file: internal/loginticket/renew_test.go
// tier: L1
package loginticket

import (
	"errors"
	"fmt"
	"testing"
	"time"
)

func TestRenew(t *testing.T) {
	tests := []struct {
		name    string
		wantErr error
		run     func(*Issuer, *fakeClock) error
	}{
		{"slides idle window", nil, func(i *Issuer, c *fakeClock) error {
			old, _, _ := i.IssueSession("acc-1", "gw-a")
			c.Advance(25 * time.Minute)
			tok, next, err := i.Renew(old, 0)
			if err != nil {
				return err
			}
			if next.Version != 1 {
				return fmt.Errorf("version = %d, want 1", next.Version)
			}
			c.Advance(25 * time.Minute)
			_, err = i.Verify(tok)
			return err
		}},
		{"stale renewal", ErrStale, func(i *Issuer, c *fakeClock) error {
			old, _, _ := i.IssueSession("acc-1", "gw-a")
			if _, _, err := i.Renew(old, 0); err != nil {
				return err
			}
			_, _, err := i.Renew(old, 0)
			return err
		}},
		{"absolute cap", nil, func(i *Issuer, c *fakeClock) error {
			old, cl, _ := i.IssueSession("acc-1", "gw-a")
			c.Advance(25 * time.Minute)
			tok, _, err := i.Renew(old, 0)
			if err != nil {
				return err
			}
			c.Advance(29 * time.Minute)
			_, next, err := i.Renew(tok, 1)
			if err != nil {
				return err
			}
			if !next.ExpiresAt.Equal(cl.AbsoluteAt) {
				return fmt.Errorf("expiry = %v, want absolute %v", next.ExpiresAt, cl.AbsoluteAt)
			}
			return nil
		}},
		{"revoked cannot renew", ErrRevoked, func(i *Issuer, c *fakeClock) error {
			tok, cl, _ := i.IssueSession("acc-1", "gw-a")
			i.Revoke(cl.SessionID)
			_, _, err := i.Renew(tok, 0)
			return err
		}},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			iss, clk := newTestIssuer(t)
			if err := tc.run(iss, clk); !errors.Is(err, tc.wantErr) {
				t.Fatalf("got %v, want %v", err, tc.wantErr)
			}
		})
	}
}

本机验证

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/loginticket
$ go vet ./internal/loginticket/...
$ go test -count=1 ./internal/loginticket/...
ok  	gsarchlab/internal/loginticket

gofmt -l 与 go vet 均无输出。go test 在本机通过 22 个用例,覆盖票据重放、网关绑定、过期、篡改、滑动续期、绝对天花板、撤销与清理。耗时随机器负载变化,不作为性能结论。

失败模式与兜底

失败表现兜底
票据重放同一 JTI 被第二次兑换记录 JTI 到票据过期,重复即拒绝
令牌泄露攻击者长期冒用绝对过期 + 撤销表 + 设备绑定
时钟漂移刚签发的令牌被判“尚未生效”允许示例值 2 秒偏移,响应携带服务端时间
续期风暴大量客户端同时续期版本 CAS + 客户端随机抖动
撤销表丢失已下线的会话仍可写敏感操作失败关闭,短 TTL 内重建
跨游戏复用A 游戏令牌访问 B 游戏声明受众,校验方强制比对

常见错误与边界

第一,把登录票据当会话令牌长期保存。票据是单次的,第二次使用就该失败。

第二,滑动过期没有绝对上限。玩家在线越久,令牌越接近永久有效,泄露后的损失越大。

第三,并发续期不做版本控制。两张新令牌同时有效,客户端重试后可能拿旧令牌覆盖新令牌。

第四,由客户端判断是否过期。客户端时钟可以改,判断必须放在服务端。

第五,吊销只删本地缓存。多网关部署时,其他网关仍然认旧令牌;撤销表要么共享,要么广播,要么失败关闭。

第六,令牌不带受众。签名正确不代表可以跨游戏使用,受众校验是契约的一部分。

小结

登录态的核心不是“签一个 JWT”,而是把一次性票据、可续期会话、撤销记录三件事分开,并明确每一件的失效边界。票据回答“能不能进门”,会话回答“进门后能待多久”,撤销表回答“现在还能不能代表这个账号”。

下一节把视角从“凭证”移到“连接”:令牌验证通过后,这条连接应该落在哪个网关,归属表放在哪里,节点故障后由谁重新分配。

阅读导航:上一节:1.3 账号安全、找回与风控 · 下一节:2.2 网关路由与连接归属 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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