4、JWT原理:从结构到实战

JWT 这东西,说白了就是一段自包含的令牌。我刚开始接触时也觉得玄乎,后来拆开一看,其实就三部分:Header、Payload、Signature。嗯,咱们一个一个说。

4.1 JWT结构长什么样?

一个完整的 JWT 长这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

三个部分用点号隔开。你想想看,这其实就是三段 Base64URL 编码的字符串。我习惯把它叫做「三段式令牌」。

Header(头部)

Header 通常包含两部分:令牌类型(typ)和签名算法(alg)。

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

alg 字段很关键。我在项目中遇到过有人把 alg 设成 "none",结果安全审计直接挂了。记住,永远不要用 "none" 算法。

Payload(载荷)

Payload 是 JWT 的核心,里面放的是声明(claims)。声明分三种:

类型 说明 示例
注册声明 预定义的标准字段 iss(签发者)、exp(过期时间)、sub(主题)
公共声明 自定义字段,需避免冲突 name、role、permissions
私有声明 双方约定的字段 user_id、department
注意:Payload 只是 Base64URL 编码,不是加密的。千万别把密码、信用卡号等敏感信息放进去。我曾经见过有人把数据库连接字符串塞进 JWT……嗯,那场面挺尴尬的。

Signature(签名)

签名是 JWT 的安全基石。它用 Header 里指定的算法,对前两部分进行签名。流程是这样的:

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

签名的作用很简单:防止篡改。只要密钥不泄露,没人能伪造合法的 JWT。

4.2 JWT签发与验证

签发和验证,说白了就是「造令牌」和「验令牌」两个动作。我习惯把它们封装成两个独立函数。

签发流程

func GenerateToken(userID string, secret []byte) (string, error) {
    claims := jwt.MapClaims{
        "sub": userID,
        "iat": time.Now().Unix(),
        "exp": time.Now().Add(2 * time.Hour).Unix(),
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(secret)
}

这里有个细节:iat(签发时间)和 exp(过期时间)一定要设。我见过有人不设过期时间,结果令牌永远有效,出了安全问题都查不到。

验证流程

func ValidateToken(tokenString string, secret []byte) (*jwt.Token, error) {
    token, err := 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 secret, nil
    })
    if err != nil {
        return nil, err
    }
    return token, nil
}
关键点:验证时一定要检查签名算法。我曾经遇到过攻击者把 HS256 改成 none,直接绕过签名验证。所以,算法白名单是必须的。

4.3 Access Token 与 Refresh Token

为什么需要两个令牌?说白了就是安全与体验的平衡。

特性 Access Token Refresh Token
有效期 短(15分钟-2小时) 长(7天-30天)
存储位置 内存/本地存储 HttpOnly Cookie/安全存储
使用场景 每次API请求 获取新的Access Token
泄露风险 高(频繁传输) 低(不常传输)

我建议的实践方案是这样的:

  1. Access Token 有效期设 15 分钟,放在请求头里
  2. Refresh Token 有效期设 7 天,放在 HttpOnly Cookie 里
  3. Access Token 过期后,用 Refresh Token 静默续期
  4. Refresh Token 泄露时,可以吊销它
我的习惯:Refresh Token 一定要做服务端存储。我见过有人把 Refresh Token 也做成无状态的,结果吊销时根本没法操作。存数据库里,加个 revoked 字段,这才是正道。

4.4 Go中jwt-go库使用

jwt-go 是目前 Go 生态里最流行的 JWT 库。我用了好几年,踩过不少坑,分享几个关键点。

安装

go get github.com/golang-jwt/jwt/v5

注意版本号。v5 是最新版,API 和 v4 有些变化。我建议直接用 v5。

完整示例

package main

import (
    "fmt"
    "time"
    "github.com/golang-jwt/jwt/v5"
)

type CustomClaims struct {
    UserID   string   `json:"user_id"`
    Roles    []string `json:"roles"`
    jwt.RegisteredClaims
}

func main() {
    // 密钥
    secret := []byte("your-256-bit-secret")

    // 签发
    claims := CustomClaims{
        UserID: "u_123456",
        Roles:  []string{"admin", "editor"},
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(2 * time.Hour)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            Issuer:    "my-app",
        },
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    tokenString, err := token.SignedString(secret)
    if err != nil {
        panic(err)
    }
    fmt.Println("Token:", tokenString)

    // 验证
    parsedToken, err := jwt.ParseWithClaims(tokenString, &CustomClaims{}, 
        func(token *jwt.Token) (interface{}, error) {
            return secret, nil
        })
    if err != nil {
        panic(err)
    }
    if claims, ok := parsedToken.Claims.(*CustomClaims); ok && parsedToken.Valid {
        fmt.Printf("UserID: %s, Roles: %v\n", claims.UserID, claims.Roles)
    }
}
避坑指南:我曾经犯过一个错误——把密钥硬编码在代码里。后来被安全团队约谈,才改成从环境变量读取。密钥管理一定要用安全方案,比如 Vault 或 AWS Secrets Manager。

常见错误处理

// 错误:忘记检查签名算法
token, _ := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
    return secret, nil
})

// 正确:检查算法白名单
token, err := 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 secret, nil
})

嗯,这里要注意:Parse 方法默认不会检查算法。你必须自己写校验逻辑。这是 jwt-go 的设计哲学——给你最大的灵活性,但也把安全责任交给你。

小结

JWT 的核心就三件事:结构、签发、验证。Access Token 和 Refresh Token 的组合是业界最佳实践。jwt-go 库用起来不难,但安全细节一定要处理好。记住,JWT 不是银弹,它只是鉴权体系里的一块拼图。

下一章,咱们聊聊如何把 JWT 集成到 Gin 框架里,实现一个完整的鉴权中间件。