Files
cs-note/hzh/DEV/JWT.md
T
2026-05-24 11:42:38 +08:00

12 KiB
Raw Blame History

tags, create time
tags create time
JWT
token
authentication
security
backend
2026-05-02 16:30

JWT — JSON Web Token

概述

JWT(JSON Web Token, RFC 7519)是一种开放标准,用于在各方之间安全地传输声明(claims)。在 Web 开发中,它最常被用作无状态身份认证的载体——服务器签发一个签名的令牌,客户端在后续请求中携带该令牌来证明自己的身份。

[!tip] 为什么选 JWT 而不是 Session? Session 需要服务端存储用户状态,在分布式或微服务架构中必须借助 Redis 等共享存储;JWT 将所有信息打包在令牌本身,服务端只需验签即可,天然适合横向扩展的无状态场景。

[!question] 思考 如果 JWT 把所有信息(包括敏感数据)都打包在令牌里,那和明文 Cookie 有什么区别? —— 关键差异在于 签名机制。JWT 附带数字签名,服务端可以验证令牌是否被篡改。但注意:默认情况下 Payload 只是 Base64 编码,并非加密!

核心结构

JWT 由三段 Base64Url 编码的字符串用 . 连接而成:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.       header    (头部)
eyJpc3MiOiJleGFtcGxlLmNvbSIsInVzZXJJZCI6NDJ9.      payload  (载荷)
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c   signature  (签名)

Header — 元信息

声明签名算法和令牌类型:

{
  "alg": "HS256",
  "typ": "JWT"
}

常用算法对比:

算法 底层机制 密钥类型 典型场景
HS256 / HS512 HMAC + SHA-256/512 对称(同一密钥签名+验签) 小型项目、快速原型
RS256 RSA + SHA-256 非对称(私钥签名,公钥验签) 生产环境、多服务验签
ES256 ECDSA + P-256 非对称(密钥更短,性能更好) 移动端、IoT 资源受限场景

Payload — 声明数据

承载要传递的信息,分为三类标准字段和自定义字段:

{
  "iss": "auth.example.com",      // Issuer — 签发者
  "iat": 1714627200,              // Issued At — 签发时间(Unix 时间戳)
  "exp": 1714713600,              // Expiration — 过期时间
  "sub": "user_42",               // Subject — 主题(通常是用户 ID)
  "role": "admin",                // 自定义字段
  "permissions": ["read", "write"] // 自定义字段
}

[!warning] 安全红线 Payload 绝对不要存放密码、身份证号等敏感信息! Base64 编码不等于加密,任何人拿到令牌都可以解码看到内容。如需保密,应使用 JWE(JWT 加密格式)或在服务端查库获取。

Signature — 签名验证

签名确保令牌在传输过程中未被篡改:

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

对于非对称算法(RS256),则使用私钥签名、公钥验签。这也是微服务架构中的推荐方案——只有鉴权服务持有私钥,其他服务用公钥验签即可。

flowchart LR
    subgraph Sign["签名阶段"]
        A[Header + Payload] --> B["HMAC(Raw string, Secret)"]
        B --> C[Signature]
        A -.-> D["最终令牌 = Header.Payload.Signature"]
    end

    subgraph Verify["验签阶段"]
        E["收到的令牌"] --> F["提取前两段拼接"]
        F --> G["HMAC(Raw string, Secret)"]
        G --> H["本地签名"]
        I["原始签名段"] --> J{"一致?"}
        H --> J
        J -->|"是"| K["令牌合法"]
        J -->|"否"| L["被篡改"]
    end

    Sign --> |"下发完整令牌"| Client["Client"]
    Client --> |"每次请求携带"| Verify

认证流程

典型的 JWT 认证过程涉及四个角色间的交互:

sequenceDiagram
    participant C as Client
    participant S as Server
    participant DB as Database

    C->>S: 1. POST /login (username + password)
    S->>DB: 2. 校验凭据
    DB-->>S: 3. 用户信息
    alt 凭据正确
        S->>S: 4. 生成 JWT (带 exp, iss, sub)
        S-->>C: 5. 返回 { accessToken, expiresIn }
        C->>S: 6. 后续请求带 Authorization: Bearer <token>
        S->>S: 7. 验签 + 检查 exp
        alt 令牌有效
            S-->>C: 8. 业务数据
        else 令牌无效
            S-->>C: 9. 401 Unauthorized
        end
    else 凭据错误
        S-->>C: 10. 401 Unauthorized
    end

[!note] Access Token vs Refresh Token 实践中通常采用双令牌策略:

  • Access Token:生命周期短(如 15 分钟),放在请求头中,用于访问 API
  • Refresh Token:生命周期长(如 7 天),存储在 HTTP-only Cookie 中,用于换取新的 Access Token

这样即使 Access Token 泄露,影响范围也有限;而 Refresh Token 存放在 Cookie 中不受 XSS 直接窃取。

代码示例

Go:签发与验证 JWT

使用 golang-jwt/jwt/v5:

import "github.com/golang-jwt/jwt/v5"

// 签发令牌
func GenerateToken(userID uint, role string) (string, error) {
    claims := jwt.MapClaims{
        "sub":  userID,
        "role": role,
        "iat":  time.Now().Unix(),
        "exp":  time.Now().Add(15 * time.Minute).Unix(), // 15 分钟过期
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString([]byte("your-secret-key"))
}

// 验证令牌
func ValidateToken(tokenString string) (*jwt.Token, error) {
    return jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
        // 确保算法符合预期,防止算法替换攻击
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
        }
        return []byte("your-secret-key"), nil
    })
}

Gin 中间件:JWT 认证

结合之前 [[hzh/GIN/3-middleware/jwt-auth-qa]] 讨论的模式:

func JWTAuthRequired() gin.HandlerFunc {
    return func(c *gin.Context) {
        authHeader := c.GetHeader("Authorization")
        if authHeader == "" {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
            return
        }

        // 提取 Bearer prefix
        parts := strings.Split(authHeader, " ")
        if len(parts) != 2 || parts[0] != "Bearer" {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid format"})
            return
        }

        token, err := ValidateToken(parts[1])
        if err != nil {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
            return
        }

        // 存入 Context,供下游 handler 使用
        c.Set("userID", token.Claims.(jwt.MapClaims)["sub"])
        c.Next()
    }
}

React:前端使用 JWT

import axios from 'axios';

const api = axios.create({
  baseURL: '/api',
});

// 自动在请求头注入 Token
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('accessToken');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// 统一处理 401,触发刷新或登出
api.interceptors.response.use(
  (res) => res,
  async (err) => {
    if (err.response?.status === 401) {
      // 尝试刷新 Token
      const newToken = await refreshAccessToken();
      if (newToken) {
        localStorage.setItem('accessToken', newToken);
        err.config.headers.Authorization = `Bearer ${newToken}`;
        return api.request(err.config);
      }
      window.location.href = '/login'; // 重定向到登录页
    }
    return Promise.reject(err);
  },
);

[!example] Token 存储方式对比

存储位置 XSS 防护 CSRF 防护 后端可读 适用场景
localStorage ❌ 易被 XSS 读取 ✅ 不自动发送 ❌ 需手动设置 SPA + 有 XSRF 防护
Memory ✅ 不被 XSS 直接读 ❌ — ❌ 需手动设置 高安全要求(刷新即丢失)
HTTP-only Cookie ✅ 不被 JS 访问 ⚠️ 需额外防护 ✅ 自动发送 服务端渲染 / 传统 Web

常见安全问题与防御

1. 算法降级攻击(Alg: None)

攻击者将 alg 改为 none,试图绕过验签:

{"alg": "none", "typ": "JWT"}

防御:始终在白名单中指定允许的算法:

if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
    return nil, fmt.Errorf("unexpected signing method")
}

2. 密钥泄露与弱密钥

使用硬编码或弱密钥会让攻击者轻易伪造令牌。

防御:

  • 密钥长度 ≥ 256 bit(HS256 要求至少 32 字节)
  • 通过环境变量或密钥管理服务(Vault、AWS KMS)注入
  • 定期轮换密钥

3. 过期时间设计不合理

过长的 exp 增加令牌泄露后的风险窗口;过短的 exp 又导致频繁刷新。

建议:

  • Access Token:5 ~ 30 分钟
  • Refresh Token:7 ~ 30 天
  • 配合黑名单机制实现主动注销(见下文)

4. 重放攻击

攻击者截取合法的 JWT 令牌后反复使用。

防御:

  • 短生命周期 Access Token
  • 添加 jti(JWT ID)唯一标识,配合黑名单做一次性校验
  • 在高价值操作中引入二次确认(如修改密码、支付)

进阶:Token 黑名单与撤销

JWT 的一个固有缺陷:一旦签发,无法主动作废(除非查询数据库)。在生产环境中,这意味著用户登出或密码修改后,已泄露的令牌仍可在过期前使用。

flowchart TD
    subgraph Issue["签发"]
        A["登录成功"] --> B["生成 JWT jti + exp + signed"]
        B --> C["返回给客户端"]
    end

    subgraph Use["正常使用"]
        D["请求携带 JWT"] --> E["验签 + 查黑名单"]
        E -->|"未在册"| F["放行"]
        E -->|"已在册"| G["拒绝"]
    end

    subgraph Revoke["撤销操作"]
        H["用户登出或改密"] --> I["将jti写入黑名单Redis"]
        I --> J["Set TTL = exp - iat"]
    end

    C --> D
    H --> I

Go 实现黑名单校验的关键逻辑:

func Blacklisted(ctx context.Context, jti string) bool {
    key := "jwt:blacklist:" + jti
    exists, _ := redis.Get(ctx, key).Result()
    return exists == "true"
}

func InvalidateToken(ctx context.Context, jti string, ttl time.Duration) {
    redis.Set(ctx, "jwt:blacklist:"+jti, "true", ttl)
}

[!tip] 替代方案:短期 Token + 版本控制 为简化实现,可以给每个用户分配一个 tokenVersion(存在数据库中)。签发令牌时将版本号写入 sub 或自定义字段。用户登出时只递增版本号,所有旧版令牌在验签通过后对比数据库版本号即可识别为失效。无需维护黑名单集合。

Session vs JWT:选型决策

维度 Session JWT
服务端存储 需要(内存/Redis) 不需要(仅验签)
实时注销 ✅ 立即可效 ❌ 需黑名单或短轮询
跨域支持 依赖 Cookie,需处理 CORS 任意头,天然跨域
可扩展性 受限于共享存储 天然无状态,易于水平扩展
令牌体积 极小(仅 sessionId) 较大(含 payload)
客户端操控 ❌ 不可见 ✅ 可解码(但不可篡改)

[!info] 如何选型?

  • 单体应用 / 传统 MVC → Session(简单可靠,原生支持好)
  • 微服务 / 前后端分离 / 移动端 → JWT(无状态,多服务共享密钥即可)
  • 混合模式 → JWT 作为内部服务间通信凭证,外部入口仍可搭配 Session

关联笔记