《游戏服务端编程实践》4.2.2 JWT 认证流程

拆解一次游戏登录从凭证校验到拿到可调用 Token 的完整链路,说明 JWT 三段结构以及 alg、typ、iss、aud、exp、nbf、iat、sub、jti 各自怎么验,比较 HS256 与 RS256、EdDSA 的适用边界,并用 Go 标准库实现可测试的 HS256 签发与校验。

一、一次登录的完整链路

很多文章把“登录成功”等同于“拿到 JWT”,但在游戏服务端,这两件事之间还隔着一层。登录成功只说明账号凭证正确;真正能让网关放行、让场景服接受请求的,是一份可调用凭证。完整链路通常是这样:

  1. 客户端通过 TLS 把账号凭证送到登录服,凭证可以是密码、短信验证码、第三方 OAuth code 或平台票据。
  2. 登录服校验凭证,检查封禁、风控、设备异常,必要时要求二次验证。
  3. 登录服查询账号可用的区服与角色列表,让玩家完成选区、选角。这一步的细节属于身份模型,不在本篇展开。
  4. 登录服签发 access token,并在需要长期会话时同时签发 refresh token。access token 里写入 sub(账号)、aud(允许调用方)、jti(唯一标识)、exp 等声明。
  5. 客户端带着 access token 连接网关。网关验签、验声明、核对区服与角色,然后把连接绑定到会话。
  6. 网关把已认证上下文转发给场景服或世界服。下游可以再次校验 Token,也可以只信任网关并检查内部签名,取决于信任边界。
  7. access token 过期后,客户端用 refresh token 换取新的 access token。刷新、轮换与吊销放在 /game-server-in-action-423/ 展开。

这条链路里有两个容易混淆的点。

第一,JWT 不是登录凭证,而是调用凭证。账号密码只应该发给登录服,绝不能拿去换 JWT 的接口反复提交。JWT 一旦签发,持有者就能在有效期内调用对应受众的服务,所以它的泄露后果和密码不同。

第二,验签通过不等于有权访问。签名只证明“这份 Token 由我们签发、内容没被改过”,不证明“这个账号现在还能玩、这个角色属于这个区、这个设备没有被封”。业务授权必须在验签之后单独做。

二、JWT 的三段结构

JWT 是一个用点号分隔的三段字符串:

header.payload.signature

三段都是 base64url 编码。header 描述算法与类型,payload 是声明集合,signature 是对前两段的签名。base64url 只是编码,不是加密;任何人拿到 Token 都能解开前两段看到明文。因此 payload 里不能放手机号、邮箱、身份证、余额这类敏感信息,也不能放“角色等级”这种会变化、又会被下游当真的事实。

必须校验的字段如下:

字段位置校验规则不校验的后果
algheader只接受配置中允许的算法,明确拒绝 none攻击者改算法或伪造无签名 Token
typheader等于 JWT把其他用途的签名结构当 Token 使用
isspayload等于预期的签发方接受其他系统签发的同密钥 Token
audpayload包含当前服务的受众标识登录服 Token 被拿去调战斗服或后台接口
exppayload当前时间早于 exp 加时钟偏移过期 Token 永久可用
nbfpayload当前时间不早于 nbf 减时钟偏移提前签发的 Token 被立即使用
iatpayload不晚于当前时间加偏移无法识别明显来自未来的伪造 Token
subpayload非空,且能映射到账号匿名或空主体进入业务层
jtipayload非空且全局唯一无法按单枚 Token 拉黑或审计

这张表里最常被跳过的是 aud 和 jti。前者导致“一个 Token 到处能用”,后者导致出事时只能按账号或时间范围封禁,无法精确吊销某一枚泄露的凭证。

三、签名算法怎么选

JWT 常见的签名算法有三类,选择取决于密钥怎么分布、谁来验签。

算法密钥形态验签成本密钥轮换适用场景
HS256所有签发方与验签方共享同一密钥低共享密钥,轮换要同时更新所有服务单体登录服、网关与登录服同信任域
RS256私钥签发,公钥验签较高通过 kid 发布新公钥,旧公钥保留一段时间多服务验签、第三方接入、信任域分离
EdDSA私钥签发,公钥验签低,签名短同 RS256,但密钥与签名更小新系统、移动端、希望减少带宽

HS256 的问题不是“不安全”,而是密钥扩散。每一个验签服务都持有签发密钥,任何一个服务被攻破,攻击者都能伪造全站 Token。RS256 与 EdDSA 把签发能力收在登录服,验签方只拿公钥,泄露公钥无法伪造签名,代价是密钥管理和 kid 轮换更复杂。

无论选哪种,都必须显式拒绝两件事:

  • alg: none。部分库在没有配置算法时会接受无签名 Token,这是历史遗留的经典漏洞。
  • 算法混淆。攻击者把 header 改成 RS256,再用公钥内容当作 HMAC 密钥签名;如果验签方按 header 动态选择算法,就可能把公钥当共享密钥验过。正确做法是由服务端配置决定算法,header 里的 alg 只用于比对,不用于选择。

kid 轮换时,验签方要按 kid 查公钥,找不到就拒绝,不能回退到“默认密钥”。旧公钥要保留到所有由它签发的 Token 都过期,通常是 access token 寿命加时钟偏移。

四、有效期、时钟与 payload 边界

access token 的有效期通常设为 10 到 15 分钟。太短会让刷新请求变多,太长会让泄露窗口变大。刷新令牌的有效期可以到几天或几周,但必须有轮换和吊销机制,否则只是把长寿命凭证换了个名字。

时钟偏移是分布式系统里绕不开的问题。登录服、网关、场景服的时钟不可能完全一致,验签方应允许 30 到 60 秒的偏移,并且所有节点接同一套 NTP。偏移量不能拍脑袋放大到几十分钟,那等于变相延长 Token 寿命。

payload 的边界可以用一句话概括:只放验签时必须知道、且在整个有效期内不会变化的事实。账号 ID、受众、签发方、过期时间、jti 都合适;角色等级、背包摘要、VIP 状态不合适,因为它们会变,而 Token 不会跟着变。下游如果需要这些数据,应该按 sub 回查,而不是信任 Token 里的旧值。

还有一个游戏场景特有的边界:Token 会出现在 WebSocket 握手 URL、HTTP 请求头、网关访问日志和客户端崩溃报告里。握手 URL 尤其危险,因为 URL 容易被代理、浏览器历史和监控系统记录。更稳妥的做法是握手阶段只传一次性票据,连接建立后再用票据换 Token,或者把 Token 放在 Authorization 头里而不是查询串。日志侧要对 Token 做脱敏,只保留 jti、sub 和过期时间,不能整段打印。

五、标准库实现 HS256

下面这份实现不依赖第三方库,把签发、验签、声明校验和错误分类放在一个文件里。重点看三处:base64url 必须用无填充编码,签名比较必须用常量时间函数,验签顺序必须是先签名后声明。

// file: ch04/token/jwt.go
// tier: L1
// verify: go test ./ch04/token/
package token

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"errors"
	"strings"
	"time"
)

// Claims 是登录服放进 access token 的最小事实集合。
// 不要在这里放手机号、邮箱、余额等敏感或易变数据。
type Claims struct {
	Issuer    string `json:"iss"`
	Subject   string `json:"sub"`
	Audience  string `json:"aud"`
	ExpiresAt int64  `json:"exp"`
	NotBefore int64  `json:"nbf,omitempty"`
	IssuedAt  int64  `json:"iat"`
	JWTID     string `json:"jti"`
}

type jwtHeader struct {
	Alg string `json:"alg"`
	Typ string `json:"typ"`
}

// VerifyOptions 决定“谁能签发、签给谁、允许多少时钟偏移”。
type VerifyOptions struct {
	Issuer   string
	Audience string
	Leeway   time.Duration
	Now      func() time.Time
}

var (
	ErrJWTMalformed   = errors.New("token: jwt malformed")
	ErrJWTAlg         = errors.New("token: jwt unsupported alg")
	ErrJWTType        = errors.New("token: jwt unsupported typ")
	ErrJWTSignature   = errors.New("token: jwt bad signature")
	ErrJWTExpired     = errors.New("token: jwt expired")
	ErrJWTNotYetValid = errors.New("token: jwt not yet valid")
	ErrJWTIssuer      = errors.New("token: jwt issuer mismatch")
	ErrJWTAudience    = errors.New("token: jwt audience mismatch")
	ErrJWTClaims      = errors.New("token: jwt required claim missing")
	ErrJWTKey         = errors.New("token: empty signing key")
)

// Sign 使用 HS256 签发 token。单服务或共享密钥的小规模部署够用;
// 多服务独立验签时应改用 RS256 或 EdDSA。
func Sign(key []byte, claims Claims) (string, error) {
	if len(key) == 0 {
		return "", ErrJWTKey
	}
	if claims.Subject == "" || claims.JWTID == "" || claims.ExpiresAt == 0 {
		return "", ErrJWTClaims
	}

	headerJSON, err := json.Marshal(jwtHeader{Alg: "HS256", Typ: "JWT"})
	if err != nil {
		return "", err
	}
	claimsJSON, err := json.Marshal(claims)
	if err != nil {
		return "", err
	}

	header := base64.RawURLEncoding.EncodeToString(headerJSON)
	payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
	signingInput := header + "." + payload

	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(signingInput))
	signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
	return signingInput + "." + signature, nil
}

// Verify 先验签名,再验声明。顺序不能反:未验签的 payload 只是攻击者可控的 JSON。
func Verify(raw string, key []byte, opt VerifyOptions) (Claims, error) {
	if len(key) == 0 {
		return Claims{}, ErrJWTKey
	}

	parts := strings.Split(raw, ".")
	if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
		return Claims{}, ErrJWTMalformed
	}

	headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
	if err != nil {
		return Claims{}, ErrJWTMalformed
	}
	var header jwtHeader
	if err := json.Unmarshal(headerJSON, &header); err != nil {
		return Claims{}, ErrJWTMalformed
	}
	if header.Alg != "HS256" {
		return Claims{}, ErrJWTAlg
	}
	if header.Typ != "JWT" {
		return Claims{}, ErrJWTType
	}

	sig, err := base64.RawURLEncoding.DecodeString(parts[2])
	if err != nil {
		return Claims{}, ErrJWTMalformed
	}
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(parts[0] + "." + parts[1]))
	if !hmac.Equal(sig, mac.Sum(nil)) {
		return Claims{}, ErrJWTSignature
	}

	claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
	if err != nil {
		return Claims{}, ErrJWTMalformed
	}
	var claims Claims
	if err := json.Unmarshal(claimsJSON, &claims); err != nil {
		return Claims{}, ErrJWTMalformed
	}
	if claims.Subject == "" || claims.JWTID == "" || claims.ExpiresAt == 0 {
		return Claims{}, ErrJWTClaims
	}

	now := time.Now()
	if opt.Now != nil {
		now = opt.Now()
	}
	leeway := opt.Leeway

	if opt.Issuer != "" && claims.Issuer != opt.Issuer {
		return Claims{}, ErrJWTIssuer
	}
	if opt.Audience != "" && claims.Audience != opt.Audience {
		return Claims{}, ErrJWTAudience
	}
	if !now.Before(time.Unix(claims.ExpiresAt, 0).Add(leeway)) {
		return Claims{}, ErrJWTExpired
	}
	if claims.NotBefore != 0 && now.Add(leeway).Before(time.Unix(claims.NotBefore, 0)) {
		return Claims{}, ErrJWTNotYetValid
	}
	if claims.IssuedAt != 0 && now.Add(leeway).Before(time.Unix(claims.IssuedAt, 0)) {
		return Claims{}, ErrJWTNotYetValid
	}
	return claims, nil
}

几个实现细节:

base64.RawURLEncoding 对应 JWT 规定的无填充 base64url。用带填充的 StdEncoding 会产生 =,导致与其他语言实现的 Token 不兼容。

hmac.Equal 是常量时间比较,避免通过响应时间差逐字节猜签名。自己写 bytes.Equal 或 == 在理论上会泄露比较进度。

Verify 先解码 header 并检查 alg、typ,再验签名,最后才解码 payload 校验声明。这个顺序不能反:未验签的 payload 只是攻击者可控的 JSON,提前读取 sub 并写进上下文,等于把认证交给了攻击者。

VerifyOptions 把签发方、受众、时钟偏移和时钟函数显式传入,测试可以注入固定时钟,生产代码也不会依赖全局时间。ExpiresAt、NotBefore、IssuedAt 用 Unix 秒,和 JWT 规范一致。

六、失败场景:只 decode 不 verify

某项目的网关中间件为了“性能”,只对 Token 做 base64 解码,取出 sub 就写进请求上下文,签名校验放在一个“稍后异步执行”的钩子里。上线后,攻击者把 payload 里的 sub 改成目标账号,重新编码,签名原样保留,网关照样放行。更糟的是,中间件没有检查 alg,攻击者直接把 header 改成 none 并清空签名,也能通过。

这个漏洞持续了两周才被发现,因为日志里只记录了解码后的账号,没有记录验签结果。复盘时的根因不是“JWT 不安全”,而是三条实现错误叠加:

  • 把解码当成了验证。解码只是解析,验证必须验签。
  • 没有固定算法白名单,header 里的 alg 被当成了可信输入。
  • 认证结果没有失败即拒绝,而是“先放行、后检查”。

修复方式是:在中间件入口统一调用 Verify,任何错误都返回 401;算法由配置固定,header 只做比对;验签通过后再做账号封禁、区服、角色归属等业务校验;所有拒绝都记录 jti 与错误类型,便于审计。若使用公钥算法,还要检查 kid 是否存在、公钥是否在有效期内,不能静默回退。

另一个常见变体是把签名密钥写进客户端。无论 HS256 还是 RS256,密钥都不能下发到客户端;客户端只持有签发的 Token,不参与签发或验签。把密钥打进安装包,等同于把签发能力交给所有人。

七、测试覆盖

配套测试覆盖了八类输入:正常签发与验签、篡改 payload、过期、alg: none、算法混淆(header 写 RS256)、错误受众、错误签发方、nbf 在偏移内与偏移外。每个用例都断言具体错误类型,而不是只断言“返回 error”,这样以后重构时能发现错误分类被悄悄改掉。

其中“篡改 payload”用例会解开 payload、修改 sub、重新编码,再原样拼接签名,确保验签一定失败;alg: none 用例构造无签名 Token,确保在解码 payload 之前就被算法检查拦下。测试使用固定时钟,不依赖 sleep,因此可以在 CI 里稳定重复运行。

如果项目使用第三方库,也建议保留一组“绕过尝试”的用例,因为库的默认行为会随版本变化。升级依赖时,先跑这组用例,再跑正常链路;比起阅读变更日志,直接验证“无签名、错算法、错受众、过期”四类输入更可靠。

小结

JWT 解决的是“跨服务、无共享会话时如何携带可验证身份”,不是“如何管理登录态”。一次完整的游戏登录要经过凭证校验、选区选角、签发调用凭证、网关绑定连接、下游授权检查五段;JWT 只覆盖其中“携带身份”的一段。

实现上记住三条底线:算法由服务端固定并拒绝 none,签名必须用常量时间比较,声明必须在验签之后逐项校验。payload 只放稳定且非敏感的事实,有效期配合刷新机制,jti 留给吊销与审计。

部署时还有一个细节:验签密钥或公钥应在进程启动时加载,并支持不停机轮换;轮换期间新旧两把密钥同时可用,按 kid 选择,等旧密钥签发的 Token 全部过期后再下线。把密钥读取放进每次请求的代码路径,会在流量高峰放大文件或配置中心的压力,也容易在配置中心抖动时把全站认证打挂。

常见错误与边界

  • 只 decode 不 verify:把 base64 解码当成认证,攻击者可以随意改 sub。解码永远不等于验签。
  • 接受 alg: none 或按 header 选算法:无签名 Token 与算法混淆都由此而来。算法必须来自服务端配置。
  • 不校验 iss 与 aud:同密钥的其他系统 Token、给其他服务签发的 Token 都能混进来。
  • 把密钥写进客户端:客户端只应持有 Token,不应持有签发或验签密钥。
  • 用 JWT 当会话存储:把角色等级、背包、权限列表塞进 payload,数据一变就与 Token 不一致。JWT 只放稳定事实。
  • 忽略 kid 轮换:换密钥后旧 Token 全部失效,或者找不到 kid 时回退到默认密钥,前者造成全服掉线,后者造成验签绕过。
  • 有效期拍脑袋:access token 设成几天,等于取消短寿命带来的止损能力;设成几秒,则刷新风暴拖垮登录服。

边界方面,HS256 适合单一信任域,不适合把验签能力开放给外部合作方;RS256/EdDSA 需要额外的密钥发布、缓存和轮换机制。JWT 的 payload 会随请求反复传输,不适合放大对象;需要撤销能力时,必须配合黑名单或 token_version,这部分在下一篇展开。最后,JWT 不能替代 TLS:没有传输层加密,Token 在链路上就是明文。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist