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 |
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
}
4.3 Access Token 与 Refresh Token
为什么需要两个令牌?说白了就是安全与体验的平衡。
| 特性 | Access Token | Refresh Token |
|---|---|---|
| 有效期 | 短(15分钟-2小时) | 长(7天-30天) |
| 存储位置 | 内存/本地存储 | HttpOnly Cookie/安全存储 |
| 使用场景 | 每次API请求 | 获取新的Access Token |
| 泄露风险 | 高(频繁传输) | 低(不常传输) |
我建议的实践方案是这样的:
- Access Token 有效期设 15 分钟,放在请求头里
- Refresh Token 有效期设 7 天,放在 HttpOnly Cookie 里
- Access Token 过期后,用 Refresh Token 静默续期
- Refresh Token 泄露时,可以吊销它
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)
}
}
常见错误处理
// 错误:忘记检查签名算法
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 框架里,实现一个完整的鉴权中间件。