JWT 认证中间件设计
Access/Refresh 双令牌、签名校验、中间件注入上下文与吊销策略;和 go-auth-and-jwt 概念篇配合的工程设计笔记。
[!info] Related notes
JWT 认证中间件设计
这个概念为什么出现
仅“会用 jwt 库签名”不够。服务要回答:
- Access 短命如何刷新?
- 中间件如何把身份注入后续 handler?
- 注销如何尽快失效?
- 算法与密钥如何防踩坑?
本篇聚焦中间件与双令牌流程;基础 JWT 结构见 go-auth-and-jwt。
[!abstract] 一句话理解 提取 Bearer access token → 验签并校验必要 Claims → 写入可信身份 context;refresh 换发与吊销是另外的状态校验流程。
双 Token 策略
| Token | TTL 经验 | 存放 | 用途 |
|---|---|---|---|
| Access | 短(分钟~小时级,按威胁模型) | 内存/严格存储 | API 鉴权 |
| Refresh | 长 | HttpOnly Cookie 或安全存储 + 服务端可吊销记录 | 换 access |
Refresh 不一定采用 JWT,也可用高熵随机不透明凭证,服务端保存其摘要与会话状态。HttpOnly 降低脚本直接读取 Cookie 的风险,不防止 XSS 代发请求;Cookie 自动携带时还需 SameSite 与适合部署的 CSRF/Origin 校验。
Claims 设计
sub 作为稳定用户标识,避免与自定义 uid 并存而产生不一致。下面的 token_use 是本应用的 token 用途约定,不是 JWT 标准必需字段;签发方也必须按同一约定生成。
- RegisteredClaims 提供 exp/iat/nbf/iss/aud/sub/jti 字段;嵌入这个类型不等于所有字段都会被要求存在
- 若使用黑名单,签发并强制校验非空、足够唯一的 jti;多签发方时用 issuer + jti 隔离
- 不要塞权限全集过大或敏感 PII
示例使用社区库 github.com/golang-jwt/jwt/v5 v5.3.1。只覆盖单一 issuer/audience、HS256 的 Access Token 验证;不实现签发、refresh、吊销、密钥轮换或资源授权。
中间件示例(net/http)
保存为模块中的 auth.go,安装 go get github.com/golang-jwt/jwt/v5@v5.3.1。启动时调用 AuthMiddleware,配置错误应使启动失败;成功后用返回的函数包装受保护 handler。密钥来自密钥管理,不能直接使用演示口令。
package auth
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
TokenUse string `json:"token_use"`
jwt.RegisteredClaims
}
type ctxKey int
const userIDKey ctxKey = 1
func UserIDFrom(ctx context.Context) (string, bool) {
v, ok := ctx.Value(userIDKey).(string)
return v, ok && v != ""
}
// 仅演示 HS256 Access Token 验证;issuer/audience 来自可信服务端配置。
func AuthMiddleware(secret []byte, issuer, audience string) (func(http.Handler) http.Handler, error) {
if len(secret) < 32 || strings.TrimSpace(issuer) == "" || strings.TrimSpace(audience) == "" {
return nil, errors.New("invalid JWT configuration")
}
key := append([]byte(nil), secret...) // 避免调用方后续改动共享密钥切片
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reject := func() {
w.Header().Set("WWW-Authenticate", "Bearer")
http.Error(w, "unauthorized", http.StatusUnauthorized)
}
headers := r.Header.Values("Authorization")
if len(headers) != 1 || len(headers[0]) > 8192 {
reject()
return
}
parts := strings.Fields(headers[0])
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
reject()
return
}
claims := new(Claims)
token, err := jwt.ParseWithClaims(parts[1], claims, func(*jwt.Token) (any, error) {
return key, nil
}, jwt.WithValidMethods([]string{"HS256"}),
jwt.WithExpirationRequired(), jwt.WithIssuer(issuer), jwt.WithAudience(audience),
jwt.WithIssuedAt(), jwt.WithLeeway(30*time.Second))
if err != nil || !token.Valid || strings.TrimSpace(claims.Subject) == "" || claims.TokenUse != "access" {
reject()
return
}
ctx := context.WithValue(r.Context(), userIDKey, claims.Subject)
next.ServeHTTP(w, r.WithContext(ctx))
})
}, nil
}
校验要点
- 强制算法:
jwt.WithValidMethods([]string{"HS256"})或非对称套件,防 alg=none / 算法混淆 - 密钥:HS256 至少 32 字节的密码学随机密钥;长度检查不能证明熵足够。持有 HMAC 验证密钥也能签发 token;跨服务只需验签时可考虑非对称方案
- Claims:示例强制 exp/iss/aud、非空 sub 和 access 用途;nbf 存在时校验,WithIssuedAt 校验已提供的 iat,但不会要求它存在。30 秒 Leeway 是示例策略,也会延长过期容忍窗口
- HTTPS 传输;示例的 8192 字节 Authorization 上限是应用限制,不是 JWT 规范要求
刷新流程
客户端过期 → POST /auth/refresh + refresh token
服务端验 refresh(凭证真实性/摘要匹配、过期、用途、服务端会话状态)
→ 原子消费旧 refresh 并登记新 refresh
→ 返回新 access + refresh
Refresh 轮换要求“确认旧凭证仍有效、标记消费、登记新凭证”原子完成,否则并发请求可能都成功。重用检测可撤销整个 token family,但不能阻止攻击者先于用户使用被盗 token;网络重试、响应丢失和并发刷新需要明确策略,不能只先删后发。
吊销
| 方案 | 说明 |
|---|---|
| 短 access + 等过期 | 简单,窗口内仍有效 |
| jti 黑名单(Redis TTL) | 只有每次验证都查询且传播及时才生效;保留至少到 exp + 时钟容忍窗口 |
| token 版本号存用户表 | 每次验证与权威版本比较才生效;缓存会引入失效延迟 |
本示例未接入这两类检查。需要强制吊销时,状态服务不可用不能当作“未吊销”继续放行,应按服务策略拒绝或返回 503;已进入业务 handler 的请求也不会被黑名单自动撤回。
与分层
- 中间件:认证(你是谁)
- Service:鉴权(你能否做)
- 已验签角色仍只是签发时快照;敏感操作要核对当前权限、租户与资源归属,不能用认证通过代替授权
边界
- 本文使用签名的 JWT(JWS),载荷可读;JWT 也可使用 JWE 加密封装,不能把两者混为一谈。
- 吊销与无状态之间的张力——完全无状态难以立即登出。
- 移动端与 Web 存储策略不同(XSS/CSRF)。
常见误区
[!warning] 从 token 解析后不校验签名
等同明文。
[!warning] 密钥写死仓库
用配置/密钥管理。
[!warning] access TTL 过长又无吊销
泄露窗口过大。
工程实践
- 统一 401 与 WWW-Authenticate 策略
- 审计登录/刷新/注销
- 限流登录与刷新接口
- 表驱动测:过期、错误签名、错误 alg、黑名单命中
- Gin 需适配 Writer/Request,并在拒绝时 Abort + return,避免后续 handler 继续执行;校验规则可复用
验证记录:Go 1.26.8、golang-jwt/v5 v5.3.1 下,24 条请求用例与配置校验通过 race 检查,覆盖成功身份注入、必要 Claims、时间、算法、签名、用途和 Authorization 边界;拒绝请求不会进入后续 handler。未实现的 refresh/吊销流程不在本次代码验证范围内。
自测题
- 为何 access 要短、refresh 要可吊销?
- 中间件应把什么放入 context?
- alg 混淆攻击防什么?
答案
- 降低泄露窗口;长会话可服务端作废。
- 已验证的稳定身份标识(如 sub);避免把原始 Bearer token 传入日志/审计,通常只记录必要的凭证标识与结果。
- 攻击者改 alg 用公钥当 HMAC 密钥等;强制预期算法。
延伸阅读
- RFC 7519 JWT
- golang-jwt v5
- RFC 8725 JWT Best Current Practices
- RFC 9700 Refresh Token Protection
- go-auth-and-jwt · go-http-middleware · redis-session-and-caching-go
笔记元信息
- 文件:
jwt-auth-middleware-go.md - 状态:已深化