一、本篇只补工程细节
4.2.2 已经讲过 JWT 的三段结构、签名算法与必须校验的字段,这里不再重复。本篇回答的是另一个问题:把它放进登录服和网关后,密钥怎么轮换、claims 怎么定、中间件放在哪一层、失败时怎么区分“过期”和“伪造”。
二、密钥管理与 kid 轮换
密钥不能写进代码库,也不能打进客户端。服务端从环境变量、KMS 或密钥管理服务加载,启动时加载失败就拒绝启动,不能带着空密钥降级运行。
| 方案 | 适用场景 | 优点 | 代价 |
|---|---|---|---|
| HS256 | 单服务或可信内网 | 实现简单、验签快 | 所有验签方都持有签名密钥 |
| EdDSA | 多服务、多语言网关 | 私钥只在一处,公钥可分发 | 密钥与库的运维更复杂 |
多实例必须支持 kid 轮换:新 token 用新 kid 签发,旧 kid 在重叠窗口内只验不签。窗口长度至少覆盖“最长 token 有效期 + 时钟偏移”,否则轮换瞬间会有大量合法用户被 401。窗口结束后旧 kid 必须删除,不能永久保留。
三、Claims 设计
| 字段 | 含义 | 校验要点 |
|---|---|---|
sub | account_id | 非空、可解析为 uint64 |
aid | 区服或角色域 | 与目标接口一致才放行 |
sid | 会话 ID | 用于查会话版本与踢下线 |
ver | 会话版本 | 必须等于服务端当前版本 |
jti | token 唯一 ID | 便于审计与精确拉黑 |
aud | 受众 | 必须是本网关,不能接受任意值 |
iat / exp | 签发/过期时间 | 过期拒绝,未来签发时间拒绝 |
payload 不是保险箱:任何拿到 token 的人都能 base64 解码。不要放手机号、身份证、渠道票据或内部权限列表。ver 与会话表配合,才能让“踢下线”立即生效,而不是等 token 自然过期。
四、校验中间件的顺序
顺序必须是:取 Bearer → 解析三段 → 验签 → 校验 claims → 查会话版本 → 注入 context。验签之前不要相信 payload 里的任何字段,包括 exp;否则攻击者可以伪造一个“未过期”的 claims 绕过时间判断。
| 失败分支 | 对外错误 | 说明 |
|---|---|---|
| 没有 Authorization | missing_token | 客户端应跳转登录 |
| 格式错误、签名不符、未知 kid | invalid_token | 不暴露具体原因 |
| 已过期(签名有效) | token_expired | 客户端可尝试刷新 |
aud 不匹配 | invalid_token | 可能是串了环境 |
| 会话版本不符 | token_revoked | 被顶号或管理员踢下线 |
过期与伪造必须分开:过期是正常生命周期,客户端可以走刷新;伪造是安全事件,应该告警并拒绝刷新。允许 30 秒左右的时钟偏移,但不能无限放宽。
五、失败场景:轮换窗口算错
最常见的事故是轮换顺序错了:先删旧 kid,再切新 kid,中间几秒所有旧 token 都变成“未知 kid”。正确顺序是“先加新 kid 并切为 active,保留旧 kid 验签,等超过最长 TTL 后再删旧 kid”。
第二个坑是窗口太短。若 token 有效期 15 分钟、轮换窗口只给 5 分钟,第 6 分钟开始的旧 token 全部 401,玩家表现为“随机掉登录”。窗口太长则旧密钥泄露后的止损变慢。把窗口写进配置,并在监控里记录每个 kid 的验签量,确认旧 kid 归零后再下线。
六、接入 net/http
只包住受保护的业务路由,不要包登录和刷新接口;否则未登录请求连换票都进不来。中间件应放在鉴权之后、业务之前,recovery 和访问日志放在最外层,确保 panic 也能带上 request_id;中间件顺序错误是隐蔽事故:如果业务 handler 直接注册到 mux、没有经过 Middleware,攻击者不带 token 也能进入;如果先查会话版本再验签,攻击者可以用任意 sid 探测会话是否存在。日志里只记录 jti、sub 和 request_id,不要打印完整 token。
七、Go 实现
Issuer 持有多个 kid,Rotate 负责切换 active 并给旧 kid 设置过期时间;Verify 先验签再校验 claims;Middleware 把失败映射成稳定的错误码,并把 claims 注入 context。
// file: ch04/login/middleware.go
// tier: L1
// verify: go test ./ch04/login/
package login
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
type Claims struct {
Sub string `json:"sub"`
AID string `json:"aid,omitempty"`
SID string `json:"sid"`
Ver uint64 `json:"ver"`
JTI string `json:"jti"`
Aud string `json:"aud"`
Iat int64 `json:"iat"`
Exp int64 `json:"exp"`
}
var (
ErrMalformed = errors.New("token malformed")
ErrAlgorithm = errors.New("token algorithm rejected")
ErrUnknownKID = errors.New("token kid unknown")
ErrSignature = errors.New("token signature mismatch")
ErrExpired = errors.New("token expired")
ErrAudience = errors.New("token audience mismatch")
)
type key struct {
secret []byte
expires time.Time // 零值表示仍在签发窗口
}
type Issuer struct {
mu sync.RWMutex
keys map[string]key
active string
ttl time.Duration
now func() time.Time
}
const tokenLeeway = 30 * time.Second
func NewIssuer(activeKID string, keys map[string][]byte) *Issuer {
m := make(map[string]key, len(keys))
for kid, secret := range keys {
m[kid] = key{secret: append([]byte(nil), secret...)}
}
return &Issuer{keys: m, active: activeKID, ttl: 15 * time.Minute, now: time.Now}
}
func (i *Issuer) Rotate(kid string, secret []byte, overlap time.Duration) {
i.mu.Lock()
defer i.mu.Unlock()
if old, ok := i.keys[i.active]; ok {
old.expires = i.now().Add(overlap)
i.keys[i.active] = old
}
i.keys[kid] = key{secret: append([]byte(nil), secret...)}
i.active = kid
}
func (i *Issuer) Issue(accountID uint64, sessionID string, version uint64, audience string) (string, int, error) {
i.mu.RLock()
kid, active := i.active, i.keys[i.active]
i.mu.RUnlock()
if active.secret == nil {
return "", 0, ErrUnknownKID
}
now := i.now()
claims := Claims{Sub: strconv.FormatUint(accountID, 10), SID: sessionID, Ver: version, JTI: newID("jti_"), Aud: audience, Iat: now.Unix(), Exp: now.Add(i.ttl).Unix()}
return signHS256(kid, active.secret, claims), int(i.ttl.Seconds()), nil
}
func (i *Issuer) Verify(token, audience string) (*Claims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, ErrMalformed
}
var header struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
}
rawHeader, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil || json.Unmarshal(rawHeader, &header) != nil || header.Alg != "HS256" {
return nil, ErrAlgorithm
}
i.mu.RLock()
k, ok := i.keys[header.Kid]
now := i.now()
i.mu.RUnlock()
if !ok || (!k.expires.IsZero() && now.After(k.expires)) {
return nil, ErrUnknownKID
}
mac := hmac.New(sha256.New, k.secret)
mac.Write([]byte(parts[0] + "." + parts[1]))
got, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || !hmac.Equal(got, mac.Sum(nil)) {
return nil, ErrSignature
}
rawClaims, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, ErrMalformed
}
var c Claims
if json.Unmarshal(rawClaims, &c) != nil || c.Sub == "" || c.SID == "" {
return nil, ErrMalformed
}
if c.Aud != audience {
return nil, ErrAudience
}
if now.After(time.Unix(c.Exp, 0).Add(tokenLeeway)) {
return nil, ErrExpired
}
if c.Iat > now.Add(tokenLeeway).Unix() {
return nil, ErrMalformed
}
return &c, nil
}
func signHS256(kid string, secret []byte, claims Claims) string {
header, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT", "kid": kid})
payload, _ := json.Marshal(claims)
input := b64(header) + "." + b64(payload)
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(input))
return input + "." + b64(mac.Sum(nil))
}
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func Middleware(issuer *Issuer, lookup func(context.Context, *Claims) (uint64, bool), next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := bearer(r.Header.Get("Authorization"))
if raw == "" {
writeAuthError(w, "missing_token")
return
}
claims, err := issuer.Verify(raw, tokenAudience)
if err != nil {
code := "invalid_token"
if errors.Is(err, ErrExpired) {
code = "token_expired"
}
writeAuthError(w, code)
return
}
if lookup != nil {
if version, ok := lookup(r.Context(), claims); !ok || version != claims.Ver {
writeAuthError(w, "token_revoked")
return
}
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), contextKey{}, claims)))
})
}
type contextKey struct{}
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
c, ok := ctx.Value(contextKey{}).(*Claims)
return c, ok
}
func bearer(h string) string {
const prefix = "Bearer "
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
return ""
}
return strings.TrimSpace(h[len(prefix):])
}
func writeAuthError(w http.ResponseWriter, code string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("WWW-Authenticate", `Bearer error="`+code+`"`)
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]string{"error": code})
}
测试覆盖合法 token、篡改 payload、过期、未知 kid、alg=none、会话版本失效,以及轮换重叠窗口:
// file: ch04/login/middleware_test.go
// tier: L1
// verify: go test ./ch04/login/
package login
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func testIssuer() (*Issuer, *time.Time) {
now := time.Date(2026, 11, 4, 1, 0, 0, 0, time.UTC)
i := NewIssuer("k1", map[string][]byte{"k1": []byte("secret-one-32-bytes-long-000000"), "k2": []byte("secret-two-32-bytes-long-000000")})
i.now = func() time.Time { return now }
return i, &now
}
func protected() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, ok := ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "missing claims", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"sub": c.Sub, "sid": c.SID})
})
}
func serve(h http.Handler, token string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/v1/player", nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestMiddlewareRejectsAndAccepts(t *testing.T) {
issuer, now := testIssuer()
lookup := func(_ context.Context, c *Claims) (uint64, bool) { return c.Ver, true }
h := Middleware(issuer, lookup, protected())
valid, _, _ := issuer.Issue(42, "sess_1", 7, tokenAudience)
if rec := serve(h, valid); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"sub":"42"`) {
t.Fatalf("valid status=%d body=%s", rec.Code, rec.Body.String())
}
parts := strings.Split(valid, ".")
parts[1] = "x" + parts[1]
issuer.ttl = time.Minute
expired, _, _ := issuer.Issue(42, "sess_1", 7, tokenAudience)
*now = now.Add(2 * time.Minute)
unknown := signHS256("k9", []byte("whatever"), Claims{Sub: "42", SID: "sess_1", Aud: tokenAudience, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix()})
noneHeader, _ := json.Marshal(map[string]string{"alg": "none", "kid": "k1"})
none := b64(noneHeader) + "." + b64([]byte(`{"sub":"42","sid":"sess_1","aud":"game-gateway"}`)) + "."
cases := []struct{ name, token, want string }{
{"tampered", strings.Join(parts, "."), "invalid_token"},
{"expired", expired, "token_expired"},
{"unknown kid", unknown, "invalid_token"},
{"alg none", none, "invalid_token"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if rec := serve(h, tc.token); rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), tc.want) {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
})
}
revoked := Middleware(issuer, func(_ context.Context, c *Claims) (uint64, bool) { return c.Ver + 1, true }, protected())
if rec := serve(revoked, valid); rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "token_revoked") {
t.Fatalf("revoked status=%d body=%s", rec.Code, rec.Body.String())
}
issuer.Rotate("k2", []byte("secret-two-32-bytes-long-000000"), 5*time.Minute)
if _, err := issuer.Verify(valid, tokenAudience); err != nil {
t.Fatalf("old kid should survive overlap: %v", err)
}
*now = now.Add(6 * time.Minute)
if _, err := issuer.Verify(valid, tokenAudience); !errors.Is(err, ErrUnknownKID) {
t.Fatalf("old kid error=%v, want ErrUnknownKID", err)
}
}
八、运行测试
cd .local/checks/gs-practice-lab && gofmt -l ch04/login && go vet ./ch04/login/ && go test ./ch04/login/ -count=1
ok gspractice/ch04/login 0.405s
小结
JWT 的安全边界不在算法本身,而在密钥生命周期和校验顺序。kid 轮换要有重叠窗口,claims 只放身份定位信息,验签必须发生在任何 claims 判断之前;中间件要把“过期”“伪造”“被踢”映射成不同错误:客户端据此决定刷新、重新登录还是提示下线。日志只留定位信息,不留凭证。
常见错误与边界
- 中间件只包了一部分业务路由,漏掉的 handler 变成免认证入口。
- 先解析 claims 再验签,或把过期 token 当伪造处理,都会让校验语义失真。
- 日志打印完整 token 或 Authorization 头,凭证随日志扩散。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。