《MMO 服务器开发》6.3 断线重连与重连票据

玩家掉线后必须能秒回游戏,但又不能凭一个账号名就免密登入。本节给 aegis 设计一张重连票据:用 HMAC-SHA256 签发、绑定账号与连接序号、带过期时间、且只能使用一次。我们用标准库把它从零写出来,并用测试逐一证明正常校验、签名被篡改、过期、以及同一票据二次使用这四种路径的行为。

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 会话表与连接管理 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标