← [ BACK_TO_NOTES ]

JWT 认证中间件设计

Access/Refresh 双令牌、签名校验、中间件注入上下文与吊销策略;和 go-auth-and-jwt 概念篇配合的工程设计笔记。

#concept #growing #backend #go

[!info] Related notes

JWT 认证中间件设计

这个概念为什么出现

仅“会用 jwt 库签名”不够。服务要回答:

  • Access 短命如何刷新?
  • 中间件如何把身份注入后续 handler?
  • 注销如何尽快失效?
  • 算法与密钥如何防踩坑?

本篇聚焦中间件与双令牌流程;基础 JWT 结构见 go-auth-and-jwt。

[!abstract] 一句话理解 提取 Bearer access token → 验签并校验必要 Claims → 写入可信身份 context;refresh 换发与吊销是另外的状态校验流程。

双 Token 策略

TokenTTL 经验存放用途
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
}

校验要点

  1. 强制算法:jwt.WithValidMethods([]string{"HS256"}) 或非对称套件,防 alg=none / 算法混淆
  2. 密钥:HS256 至少 32 字节的密码学随机密钥;长度检查不能证明熵足够。持有 HMAC 验证密钥也能签发 token;跨服务只需验签时可考虑非对称方案
  3. Claims:示例强制 exp/iss/aud、非空 sub 和 access 用途;nbf 存在时校验,WithIssuedAt 校验已提供的 iat,但不会要求它存在。30 秒 Leeway 是示例策略,也会延长过期容忍窗口
  4. 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:鉴权(你能否做)
  • 已验签角色仍只是签发时快照;敏感操作要核对当前权限、租户与资源归属,不能用认证通过代替授权

边界

  1. 本文使用签名的 JWT(JWS),载荷可读;JWT 也可使用 JWE 加密封装,不能把两者混为一谈。
  2. 吊销与无状态之间的张力——完全无状态难以立即登出。
  3. 移动端与 Web 存储策略不同(XSS/CSRF)。

常见误区

[!warning] 从 token 解析后不校验签名
等同明文。

[!warning] 密钥写死仓库
用配置/密钥管理。

[!warning] access TTL 过长又无吊销
泄露窗口过大。

工程实践

  1. 统一 401 与 WWW-Authenticate 策略
  2. 审计登录/刷新/注销
  3. 限流登录与刷新接口
  4. 表驱动测:过期、错误签名、错误 alg、黑名单命中
  5. Gin 需适配 Writer/Request,并在拒绝时 Abort + return,避免后续 handler 继续执行;校验规则可复用

验证记录:Go 1.26.8、golang-jwt/v5 v5.3.1 下,24 条请求用例与配置校验通过 race 检查,覆盖成功身份注入、必要 Claims、时间、算法、签名、用途和 Authorization 边界;拒绝请求不会进入后续 handler。未实现的 refresh/吊销流程不在本次代码验证范围内。

自测题

  1. 为何 access 要短、refresh 要可吊销?
  2. 中间件应把什么放入 context?
  3. alg 混淆攻击防什么?
答案
  1. 降低泄露窗口;长会话可服务端作废。
  2. 已验证的稳定身份标识(如 sub);避免把原始 Bearer token 传入日志/审计,通常只记录必要的凭证标识与结果。
  3. 攻击者改 alg 用公钥当 HMAC 密钥等;强制预期算法。

延伸阅读


笔记元信息

  • 文件:jwt-auth-middleware-go.md
  • 状态:已深化
创建于 2026/6/25 更新于 2026/10/7
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本