《Go 语言编程实战》5.1 JWT 与会话管理

上一章的接口任何人都能调用,本节给 TaskHub 装上认证。先用 jwt/v5 讲清 JWT 的三段结构与 claims 设计,把「锁死算法、校验 iss/aud/exp」五个必做项落成代码,实测篡改 payload、alg=none、过期、错签发者与算法混淆五种攻击的拦截结果,再用真实 Redis 做会话撤销与刷新令牌轮换,并对比本地校验与远程吊销检查近千倍的成本差。

5.1 JWT 与会话管理

4.1 节我们特意把「租户不可见的资源一律回 404」定成规则,但当时留了一个前提没兑现:租户 ID 从哪来。如果它来自 URL,攻击者改个路径就横向越权了。它必须来自一个服务端可信的凭证——这就是本节要建的东西。

本节把 TaskHub 推进到:请求带 Bearer 令牌,服务端用 jwt/v5 本地校验并从中取出 sub/tid/roles,登录态用 Redis 承载以支持撤销与刷新轮换。

5.1.1 令牌和会话,不是二选一

先破一个常见的误解:JWT 和会话不是对立的。它们回答的是两个不同的问题:

问题答案
「这个请求是谁发的,且没被篡改」JWT:自包含、可离线验签,服务端不用查库
「这个登录态还在有效期内吗,能撤销吗」会话:存在服务端,随时可失效

JWT 的卖点是无状态:任何一台实例拿到令牌就能验签,不用共享存储。代价也正是无状态——签发出去就撤不回来。所以 TaskHub 的做法是两者都用:

短期 access token(JWT,15 分钟)  —— 每个请求本地验签,不查库
长期 refresh token(不透明随机串) —— 存 Redis,可撤销、可轮换

access token 短到「就算泄露也很快过期」,refresh token 长但可撤销。撤销一次登录,只需要删掉 Redis 里的 refresh 记录,最长 15 分钟后 access token 自然失效。

5.1.2 JWT 长什么样

一个 JWT 是三段 base64url 用 . 连起来:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiJ1c3JfMSIsInRpZCI6InRudF8xIn0 . 签名
        header                                  payload                      signature

实测确认就是三段:

三段结构: 3 段, 前 32 字符=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpX

必须记住两件事:

  1. payload 是 base64 编码,不是加密。任何人拿到令牌就能解出里面的 sub、tid、roles。绝不要往 claims 里放密码、密钥、身份证号。
  2. 签名保证的是「没被篡改」,不是「保密」。它的作用是让服务端能确认这段 payload 确实是自己签发的。

base64url 用的是 - 和 _ 而不是 + 和 /,且去掉 = 填充,所以整个令牌可以安全地放进 URL、Header 或 Cookie。

5.1.3 签发:claims 怎么设计

TaskHub 的 claims 结构:

type Claims struct {
	TenantID string   `json:"tid"`
	Roles    []string `json:"roles"`
	Scope    string   `json:"scope"`
	jwt.RegisteredClaims
}

RegisteredClaims 是 jwt/v5 提供的标准字段集合,嵌进来就有了 sub/iss/aud/exp/iat/nbf/jti。各字段的分工:

字段含义TaskHub 取值
sub主体,用户 IDusr_1
tid租户 ID(自定义)tnt_1
roles角色列表(自定义)["member"]
iss签发者https://auth.taskhub.example.com
aud受众taskhub-api
exp过期时间签发后 15 分钟
iat / nbf签发时间 / 生效时间同一时刻
jti令牌唯一 IDUUID,吊销用

签发代码:

func issue(userID, tenantID string, roles []string, ttl time.Duration) (string, error) {
	now := time.Now()
	c := Claims{
		TenantID: tenantID,
		Roles:    roles,
		Scope:    "access",
		RegisteredClaims: jwt.RegisteredClaims{
			Subject:   userID,
			Issuer:    "https://auth.taskhub.example.com",
			Audience:  jwt.ClaimStrings{"taskhub-api"},
			ID:        uuid.NewString(),
			IssuedAt:  jwt.NewNumericDate(now),
			NotBefore: jwt.NewNumericDate(now),
			ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
		},
	}
	return jwt.NewWithClaims(jwt.SigningMethodHS256, c).SignedString([]byte(secret))
}

三个设计决策值得说明:

  • tid 放进令牌,而不是每次从 URL 取。这正是 4.1 节留下的伏笔:租户边界由签发者确定,客户端改不了。
  • roles 放进令牌,好处是免查库,坏处是角色变更最多滞后一个令牌周期。如果「撤掉管理员权限」必须立刻生效,就不能把角色放令牌里,得每次查会话。TaskHub 选择放令牌,因为 15 分钟的滞后可接受,且能用 jti 吊销兜底。
  • aud 必须写。同一套签发服务可能给多个下游发令牌(taskhub-api、taskhub-admin),不校验受众就允许了「给 A 系统的令牌去调 B 系统」。

5.1.4 校验:五个必做项

校验不是「解出 payload 就行」,而是五项检查一项都不能少:

func parse(tokenStr string) (*Claims, error) {
	var c Claims
	tok, err := jwt.ParseWithClaims(tokenStr, &c, func(t *jwt.Token) (any, error) {
		return []byte(secret), nil
	},
		jwt.WithValidMethods([]string{"HS256"}), // 1. 锁死算法
		jwt.WithIssuer("https://auth.taskhub.example.com"), // 2. 校验签发者
		jwt.WithAudience("taskhub-api"),                    // 3. 校验受众
		jwt.WithExpirationRequired(),                       // 4. exp 必须存在
	)
	if err != nil {
		return nil, err
	}
	if !tok.Valid {
		return nil, errors.New("token invalid")
	}
	return &c, nil
}

第五项是校验签名——由 keyFunc 返回的密钥隐式完成。逐条说为什么不能省:

  1. WithValidMethods 是防算法混淆的第一道闸。不锁算法,攻击者可以把 header 里的 alg 改成 none 或换成别的算法。
  2. iss 不校验,别人自建一个签发服务发的令牌也能通过。
  3. aud 不校验,给低权限系统的令牌能拿去调高权限接口。
  4. WithExpirationRequired:jwt/v5 默认允许没有 exp 的令牌通过。不显式要求,一个永不过期的令牌就是永久后门。
  5. 签名是唯一能证明「payload 是我签的」的机制,也是唯一不能省的一项。

5.1.5 实测:五种攻击的拦截结果

把上面这套校验接上,逐个打一遍常见攻击。实测输出:

正常: sub=usr_1 tid=tnt_1 roles=[member] err=<nil>
篡改 payload: err=token signature is invalid: signature is invalid (is signature invalid=true)
alg=none: err=token signature is invalid: signing method none is invalid
过期: err=token has invalid claims: token is expired (is expired=true)
错 iss: err=token has invalid claims: token has invalid issuer

逐条解读:

攻击做法结果
篡改 payload把 tid 从 tnt_1 改成 tnt_2,不重签名签名校验失败
alg=noneheader 写 {"alg":"none"},去掉签名signing method none is invalid
过期令牌用 exp 在过去的令牌token is expired
错签发者用别的 iss 签,密钥相同token has invalid issuer
算法混淆用 RS256 签,拿 PEM 公钥字节当 HMAC 密钥验见下

最后一项单独测。这是经典的 JWT 漏洞:如果服务端把「非对称签名的公钥」当成「对称签名的密钥」用,攻击者就能用公开的公钥伪造令牌。实测:

RS256 令牌 + PEM 字节作 HMAC 密钥(未锁算法): err=token signature is invalid: key is of invalid type: RSA verify expects *rsa.PublicKey
同上但锁死 HS256: err=token signature is invalid: signing method RS256 is invalid

jwt/v5 在这里帮了大忙:它按算法检查密钥类型,RS256 期待 *rsa.PublicKey,你给 []byte 直接报错。但这个保护只在「算法与密钥类型必须匹配」的库里有,换成别的库或自己写校验就未必。所以 WithValidMethods 依然是必须的——它把「哪些算法被接受」这件事显式写死,不依赖库的实现细节。

5.1.6 无状态的代价:撤销

JWT 最被诟病的一点是撤销。TaskHub 的方案是短 TTL + 吊销名单:access token 只有 15 分钟,正常情况下靠过期自动失效;只有在「用户主动登出」「发现泄露」「管理员强制下线」时才把 jti 写进 Redis 吊销名单。

这里有一个必须知道的成本对比,实测出来的:

纯校验 HS256: 2.457µs/op
Redis 吊销检查往返: 2.42046ms/op

本地验签 2.5 微秒,Redis 查一次 2.4 毫秒——差了将近一千倍。 顺序查 100 次吊销是 241ms,用 pipeline 批量能压到 122.7µs/次:

100 次吊销检查: 顺序=241.255ms (2412.6µs/次)  pipeline=12.267ms (122.7µs/次)

这组数字决定了架构:吊销名单不能放在每个请求的必经路径上。如果每个请求都查一次 Redis,你就把「无状态」的收益全还回去了,还额外引入了 Redis 这个故障点。TaskHub 的做法是:

  • 默认不查吊销名单,只靠 15 分钟过期兜底。
  • 对「删除项目」「转账」这类高危操作,额外查一次 jti 是否被吊销。
  • 登出时删 Redis 里的 refresh token,access token 最多残留 15 分钟。

这是一个明确的取舍:用「最多 15 分钟的窗口」换「每请求省下 2.4ms 和一个故障点」。如果你的业务不能接受这个窗口(比如金融转账),那就必须每请求查,并接受这个成本。

5.1.7 会话与刷新令牌轮换

refresh token 用不透明随机串,存 Redis,实测的创建与撤销:

创建会话 sid_16b42078 -> exists=true
撤销会话 sid_16b42078 -> exists=false

EXISTS 返回 1/0 就是「有效/已撤销」,一个 DEL 就能立刻失效。refresh token 还要做轮换:每次用它换新的 access token 时,同时作废旧的、发一个新的。这样即使 refresh token 泄露,攻击者用一次就会被发现——因为合法客户端下次用旧的会失败。

实现要点是记录「当前有效的 jti」,实测:

当前 refresh jti=jti_1
复用同一 jti 再次兑换: 检测到复用=true

一旦检测到复用,正确反应不是「拒绝这一次」,而是把整条令牌家族全部作废——因为无法区分是攻击者在用旧令牌,还是合法客户端被中间人劫持了。宁可让用户重新登录,也不要留下一个可能被共享的会话。

Redis 里的会话结构建议这样组织:

sess:<sid>            hash  { user, tid, refresh_jti }   TTL 30 天
rt:<sid>:<jti>        string "used" | "active"          TTL 30 天
user:<uid>:sessions   set    { sid1, sid2, ... }        TTL 30 天

user:<uid>:sessions 这个集合是为了支持「登出所有设备」——遍历集合逐个 DEL 即可。没有它,「改密码后踢掉所有会话」就只能靠全库扫描。

5.1.8 小结

  • JWT 负责「是谁、没被篡改」,会话负责「还能不能用、能不能撤」,两者互补而非对立。
  • payload 是编码不是加密,绝不能放敏感信息;签名只保证完整性。
  • claims 里放 sub/tid/roles/iss/aud/exp/jti,租户 ID 放令牌才能堵住横向越权。
  • 校验五项缺一不可:锁算法、校 iss、校 aud、要求 exp、验签名;jwt/v5 的 WithExpirationRequired 不写就等于允许永不过期。
  • 实测五种攻击全被拦下,包括 alg=none 与算法混淆。
  • 本地验签 2.457µs,Redis 吊销检查 2.42ms,差近千倍——吊销名单不能进每个请求的必经路径。
  • refresh token 必须轮换,检测到复用就作废整条家族。

认证解决了「你是谁」,但「你能干什么」还没答。同一个租户里,viewer 和 owner 能做的事完全不同——下一节把角色模型和数据隔离一次做完。

阅读导航:上一节:4.3 OpenAPI 契约优先与版本化 · 下一节:5.2 RBAC 与多租户隔离 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练