登录不是一次动作,而是三段信任
北辰工作室的三款产品,登录形态完全不同:
| 产品 | 登录形态 | 主要风险 |
|---|---|---|
| 《裂隙战场》 | 短会话,玩家频繁切换账号 | 旧令牌未失效、顶号 |
| 《群岛纪元》 | 长在线,跨时区挂机 | 令牌长期有效、吊销不及时 |
| 《泡泡工坊》 | 游客登录,买量后快速进入 | 票据被脚本批量重放 |
如果只用一个“登录成功”布尔值表示身份,这三种风险会挤在同一段代码里。更清晰的做法,是把登录拆成三段信任:
- 身份证明:账号服务验证密码、短信或第三方凭据,回答“你是谁”。
- 进入许可:签发票据,回答“你被允许进入哪个网关、多久内有效”。
- 持续授权:签发会话令牌,回答“这条连接接下来还能不能代表该账号”。
账号安全、找回与风控属于第一段,本节从第二段讲起。网关只负责验证凭证并转发,不参与密码校验,也不保存角色数据;网关职责边界在卷一的网关横向扩展一节已有结论,这里不重复,只补充核心系统视角的生命周期设计。
两种凭证,两种生命周期
登录票据和会话令牌最容易被合并成一个 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 网关路由与连接归属 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。