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
必须记住两件事:
- payload 是 base64 编码,不是加密。任何人拿到令牌就能解出里面的
sub、tid、roles。绝不要往 claims 里放密码、密钥、身份证号。 - 签名保证的是「没被篡改」,不是「保密」。它的作用是让服务端能确认这段 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 | 主体,用户 ID | usr_1 |
tid | 租户 ID(自定义) | tnt_1 |
roles | 角色列表(自定义) | ["member"] |
iss | 签发者 | https://auth.taskhub.example.com |
aud | 受众 | taskhub-api |
exp | 过期时间 | 签发后 15 分钟 |
iat / nbf | 签发时间 / 生效时间 | 同一时刻 |
jti | 令牌唯一 ID | UUID,吊销用 |
签发代码:
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 返回的密钥隐式完成。逐条说为什么不能省:
WithValidMethods是防算法混淆的第一道闸。不锁算法,攻击者可以把 header 里的alg改成none或换成别的算法。iss不校验,别人自建一个签发服务发的令牌也能通过。aud不校验,给低权限系统的令牌能拿去调高权限接口。WithExpirationRequired:jwt/v5默认允许没有exp的令牌通过。不显式要求,一个永不过期的令牌就是永久后门。- 签名是唯一能证明「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=none | header 写 {"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 与多租户隔离 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。