6.3 断线重连与重连票据
上一节的看门狗会在超时后把连接干掉。但「干掉连接」只是服务端视角的清理,玩家视角的故事是:地铁进隧道 8 秒,出来时游戏必须回到刚才那一屏,而不是弹一个登录框让他重新输密码。
这一节解决的是这个落差。我们要的是一张重连票据(reconnect ticket):连接建立时服务端顺手发一张,客户端断线重连时带上它,服务端验完就直接恢复会话,全程不需要账号密码再次出现。
断线之后:为什么不能「再登录一次」
最容易想到的方案是「让客户端重放账号密码」。这在 MMO 里几乎不可接受:登录流程往往要走一次鉴权服务、可能有短信验证码或设备指纹,重放一次要几百毫秒到几秒,玩家在团战里掉线 8 秒已经够糟,再叠一个登录握手就是灾难。
第二个想到的方案是「服务端把会话多留 30 秒,凭账号找回」。这更危险——它等于开了一个免密后门:只要知道账号名,任何人都能接管一个刚掉线的会话。票据方案正是为了堵住这个口子:重连凭证必须由服务端签发、绑定到具体账号、有时效、且只能用一次。
票据里还要带一个连接序号(connSeq)。客户端重连后需要知道「我该从哪条消息继续收」,服务端也需要知道「这条新连接接的是哪条旧连接的班」。序号对不上,就不能盲目恢复。
票据格式与签发
票据的载荷是一段紧凑的二进制,签名用 HMAC-SHA256。选二进制而不是 JSON,是为了让票据尽量短——它要挤进重连请求的头或 query string 里:
body = ver(1) | connSeq(8) | expiryUnix(8) | nonce(16) | accountLen(2) | account(N)
token = base64url(body) + "." + base64url(HMAC-SHA256(secret, body))
nonce 是 16 字节随机数,它的作用是让同一账号在同一秒签出的两张票据也不相同——这正是防重放表的键。
下面这段是 L1,go test 可以直接跑:
// file: internal/ws/ticket.go
// tier: L1
// verify: go test ./internal/ws/ -run TestTicket -v
package ws
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"strings"
"sync"
"time"
)
// Ticket errors stay distinct so the gateway can log why a reconnect was
// refused without telling the client which check failed.
var (
ErrTicketMalformed = errors.New("ws: malformed ticket")
ErrTicketSignature = errors.New("ws: ticket signature mismatch")
ErrTicketExpired = errors.New("ws: ticket expired")
ErrTicketReplayed = errors.New("ws: ticket already used")
)
// Ticket is the verified payload of a reconnection credential.
type Ticket struct {
Account string
ConnSeq uint64
Expiry time.Time
}
// TicketManager issues and verifies short-lived reconnection tickets. The
// signature is HMAC-SHA256 over a compact binary body; single use is enforced
// by remembering each ticket's nonce until it expires.
type TicketManager struct {
secret []byte
mu sync.Mutex
used map[string]time.Time // nonce -> expiry
}
// NewTicketManager builds a manager from a shared secret. In production the
// secret lives in the session/keys service, never on the client.
func NewTicketManager(secret []byte) *TicketManager {
return &TicketManager{secret: secret, used: make(map[string]time.Time)}
}
const ticketVersion byte = 1
// body layout (big endian): ver(1) | connSeq(8) | expiryUnix(8) | nonce(16) |
// accountLen(2) | account. The nonce makes every issued ticket unique even for
// the same account and second, which is what the replay table keys on.
func (m *TicketManager) sign(body []byte) []byte {
h := hmac.New(sha256.New, m.secret)
h.Write(body)
return h.Sum(nil)
}
// Issue mints a ticket bound to an account and the connection sequence number
// the client should resume from.
func (m *TicketManager) Issue(account string, connSeq uint64, ttl time.Duration, now time.Time) (string, error) {
if len(account) > 0xFFFF {
return "", fmt.Errorf("ws: account too long")
}
var nonce [16]byte
if _, err := rand.Read(nonce[:]); err != nil {
return "", err
}
body := make([]byte, 0, 1+8+8+16+2+len(account))
body = append(body, ticketVersion)
var tmp [8]byte
binary.BigEndian.PutUint64(tmp[:], connSeq)
body = append(body, tmp[:]...)
binary.BigEndian.PutUint64(tmp[:], uint64(now.Add(ttl).Unix()))
body = append(body, tmp[:]...)
body = append(body, nonce[:]...)
var l [2]byte
binary.BigEndian.PutUint16(l[:], uint16(len(account)))
body = append(body, l[:]...)
body = append(body, account...)
return base64.RawURLEncoding.EncodeToString(body) + "." +
base64.RawURLEncoding.EncodeToString(m.sign(body)), nil
}
// Verify checks the signature, the expiry, and single use. On success the
// ticket is consumed: a second Verify of the same token returns
// ErrTicketReplayed.
func (m *TicketManager) Verify(token string, now time.Time) (*Ticket, error) {
dot := strings.IndexByte(token, '.')
if dot < 0 {
return nil, ErrTicketMalformed
}
body, err := base64.RawURLEncoding.DecodeString(token[:dot])
if err != nil {
return nil, ErrTicketMalformed
}
sig, err := base64.RawURLEncoding.DecodeString(token[dot+1:])
if err != nil {
return nil, ErrTicketMalformed
}
// Constant-time compare; bytes.Equal would leak how much of the signature
// the attacker guessed right.
if !hmac.Equal(sig, m.sign(body)) {
return nil, ErrTicketSignature
}
if len(body) < 35 || body[0] != ticketVersion {
return nil, ErrTicketMalformed
}
connSeq := binary.BigEndian.Uint64(body[1:9])
expiry := time.Unix(int64(binary.BigEndian.Uint64(body[9:17])), 0)
nonce := string(body[17:33])
alen := int(binary.BigEndian.Uint16(body[33:35]))
if len(body) != 35+alen {
return nil, ErrTicketMalformed
}
if now.After(expiry) {
return nil, ErrTicketExpired
}
// Replay table, locked separately so the crypto above never holds it.
m.mu.Lock()
defer m.mu.Unlock()
m.gcLocked(now)
if _, seen := m.used[nonce]; seen {
return nil, ErrTicketReplayed
}
m.used[nonce] = expiry
return &Ticket{
Account: string(body[35:]),
ConnSeq: connSeq,
Expiry: expiry,
}, nil
}
// gcLocked drops nonces whose tickets can no longer be replayed.
func (m *TicketManager) gcLocked(now time.Time) {
for nonce, exp := range m.used {
if now.After(exp) {
delete(m.used, nonce)
}
}
}
签发时用了 base64.RawURLEncoding 而不是标准 Base64——票据会出现在 URL 里,+ 和 / 需要百分号编码,= 会带来歧义,去掉填充的 URL-safe 变体省掉整类编码事故。
校验:签名、过期、单次使用
Verify 里的检查顺序是有讲究的:先验签名,再验过期,最后查重放表。顺序反了会出问题——如果先查重放表再验签名,攻击者可以拿一堆伪造票据把重放表塞满,把内存打爆;而签名不对的票据根本不该有机会触碰任何状态。
重放表以 nonce 为键、票据过期时间为值。一个细节是 gcLocked 在每次校验时顺手清理过期条目,这样重放表的大小上界就是「有效期窗口内签发的票据数」,不会随运行时长无限增长。
下面这几段测试逐一钉死四条路径:
// snippet: internal/ws/ticket_test.go(节选)
// TestTicketReplay is the anti-replay guarantee: the first Verify consumes the
// ticket, the second must be refused even though the signature is still valid.
func TestTicketReplay(t *testing.T) {
m := NewTicketManager([]byte("k"))
now := time.Unix(1_760_000_000, 0)
tok, _ := m.Issue("alice", 1, time.Minute, now)
if _, err := m.Verify(tok, now); err != nil {
t.Fatalf("first verify: %v", err)
}
if _, err := m.Verify(tok, now.Add(time.Second)); !errors.Is(err, ErrTicketReplayed) {
t.Fatalf("want ErrTicketReplayed, got %v", err)
}
}
func TestTicketExpired(t *testing.T) {
m := NewTicketManager([]byte("k"))
now := time.Unix(1_760_000_000, 0)
tok, _ := m.Issue("alice", 1, 10*time.Second, now)
if _, err := m.Verify(tok, now.Add(11*time.Second)); !errors.Is(err, ErrTicketExpired) {
t.Fatalf("want ErrTicketExpired, got %v", err)
}
}
// TestTicketTamperedBody swaps the account bytes without re-signing; the
// signature check must catch it before the account is ever trusted.
func TestTicketTamperedBody(t *testing.T) {
m := NewTicketManager([]byte("k"))
now := time.Unix(1_760_000_000, 0)
tok, _ := m.Issue("alice", 1, time.Minute, now)
dot := strings.IndexByte(tok, '.')
body := []byte(tok[:dot])
body[len(body)-1] = 'x' // alice -> alicx, still valid base64
forged := string(body) + tok[dot:]
if _, err := m.Verify(forged, now); !errors.Is(err, ErrTicketSignature) {
t.Fatalf("want ErrTicketSignature, got %v", err)
}
}
本节小结
| 攻击 / 故障 | 防线 | 对应错误 |
|---|---|---|
| 篡改载荷冒充他人账号 | HMAC-SHA256 签名 | ErrTicketSignature |
| 票据被长期复用 | 过期时间戳 | ErrTicketExpired |
| 票据被截获后重放 | nonce 重放表 | ErrTicketReplayed |
| 伪造票据耗尽内存 | 先验签名再查表 | 签名失败直接返回 |
票据把「免密重连」和「免密后门」区分开的,正是这三样东西:服务端签发、绑账号有时效、只能用一次。缺任何一样,它都会退化成一张万能钥匙。
阅读导航:上一节:6.2 心跳与超时检测 · 下一节:7.1 会话表与连接管理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。