JWT 的好处是服务端不用存会话状态,特别适合前后端分离和多实例部署。代价是签发之后就收不回来——用户被封禁了,他手里的 token 在过期前照样能用。所以用之前得想清楚这个取舍。
这份 demo 用 Go 实现一套常见的双令牌方案:短命的 access token + 长命的 refresh token。
依赖
go get github.com/golang-jwt/jwt/v5
go get golang.org/x/crypto/bcrypt
密钥与配置
package auth
import (
"errors"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
// 密钥从环境变量读,别硬编码进代码
var (
accessSecret = []byte(getenv("JWT_ACCESS_SECRET", "change-me-access"))
refreshSecret = []byte(getenv("JWT_REFRESH_SECRET", "change-me-refresh"))
)
const (
AccessTTL = 15 * time.Minute // access token 短一点
RefreshTTL = 7 * 24 * time.Hour // refresh token 可以长
Issuer = "myapp"
)
func getenv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
为什么 access 和 refresh 用两个不同的密钥:万一 access 密钥泄露,攻击者拿它签不出 refresh token,损失可控。
签发令牌
type Claims struct {
UserID int64 `json:"uid"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func GenerateTokens(userID int64, username, role string) (access, refresh string, err error) {
now := time.Now()
accessClaims := Claims{
UserID: userID,
Username: username,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: Issuer,
Subject: fmt.Sprint(userID),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(AccessTTL)),
},
}
access, err = jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).
SignedString(accessSecret)
if err != nil {
return "", "", err
}
// refresh token 只放最少的信息,不放角色等可能变化的字段
refreshClaims := jwt.RegisteredClaims{
Issuer: Issuer,
Subject: fmt.Sprint(userID),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(RefreshTTL)),
}
refresh, err = jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).
SignedString(refreshSecret)
return access, refresh, err
}
refresh token 里别塞角色、权限这些字段。它活 7 天,期间用户权限可能变了,但旧 token 里的角色还是旧的,会造成权限不一致。access token 因为只有 15 分钟,这个问题不明显。
校验与中间件
func ParseAccessToken(tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
// 一定要校验签名算法,防止 alg 被改成 none 或 RS256 的降级攻击
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("非预期的签名算法")
}
return accessSecret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("无效令牌")
}
return claims, nil
}
// HTTP 中间件
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 支持 Authorization: Bearer xxx
h := r.Header.Get("Authorization")
if len(h) < 7 || h[:7] != "Bearer " {
http.Error(w, `{"error":"缺少令牌"}`, http.StatusUnauthorized)
return
}
claims, err := ParseAccessToken(h[7:])
if err != nil {
// 过期和其他错误给不同提示,前端据此决定是刷新还是让用户重新登录
if errors.Is(err, jwt.ErrTokenExpired) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"令牌已过期","code":"TOKEN_EXPIRED"}`))
return
}
http.Error(w, `{"error":"无效令牌"}`, http.StatusUnauthorized)
return
}
// 把用户信息塞进 context 传给后续 handler
ctx := context.WithValue(r.Context(), "claims", claims)
next(w, r.WithContext(ctx))
}
}
用法:
http.HandleFunc("/api/profile", AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value("claims").(*Claims)
fmt.Fprintf(w, `{"user":"%s","role":"%s"}`, claims.Username, claims.Role)
}))
刷新令牌(含轮转)
func Refresh(refreshToken string) (newAccess, newRefresh string, err error) {
token, err := jwt.ParseWithClaims(refreshToken, &jwt.RegisteredClaims{}, func(t *jwt.Token) (interface{}, error) {
return refreshSecret, nil
})
if err != nil || !token.Valid {
return "", "", errors.New("refresh 令牌无效")
}
claims := token.Claims.(*jwt.RegisteredClaims)
uid, err := strconv.ParseInt(claims.Subject, 10, 64)
if err != nil {
return "", "", err
}
// 这里应该查库确认用户还存在、没被封禁
user, err := db.GetUserByID(uid)
if err != nil || user.Banned {
return "", "", errors.New("用户不可用")
}
return GenerateTokens(user.ID, user.Username, user.Role)
}
刷新时一定要查库。refresh token 本身是自包含的,如果只看签名不看用户状态,被封禁的用户还能一直换到新的 access token。这是 JWT 方案里最常被漏掉的一步。
轮转(rotation):每次刷新都签发一个新的 refresh token 并让旧的失效。这样如果旧 refresh token 泄露被用,服务端能检测到"一个已用过的 refresh token 又来了",判定为盗用并强制下线。要实现这个得在服务端记 refresh token 的状态(比如 Redis 存 jti 白名单),代价是又引入了状态——但安全收益值得。
登录:密码怎么存
import "golang.org/x/crypto/bcrypt"
func Register(username, password string) error {
// cost 12 大约 250ms,按机器性能调
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return err
}
return db.SaveUser(username, string(hash))
}
func Login(username, password string) (access, refresh string, err error) {
user, err := db.GetUserByUsername(username)
if err != nil {
// 用户不存在时也做一次 bcrypt 比对,避免通过响应时间枚举用户名
bcrypt.CompareHashAndPassword([]byte("$2a$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinv"), []byte(password))
return "", "", errors.New("用户名或密码错误")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return "", "", errors.New("用户名或密码错误")
}
return GenerateTokens(user.ID, user.Username, user.Role)
}
绝不能存明文密码,也不能用 MD5/SHA 直接哈希密码——彩虹表一查就出来。bcrypt/argon2 这类慢哈希是必须的,它们内置盐且刻意算得慢。
JWT 的三个先天短板
- 无法主动失效:签出去就收不回。要"踢人下线"就得维护黑名单(Redis 存 jti,中间件里查一次),等于又加回状态。
- payload 是 base64 不是加密:任何人都能解开看到内容,别往里放敏感信息。
- 体积比 session id 大:每个请求都带着,放太多字段会拖慢请求。
如果业务强依赖"实时踢下线""强制改密后立即失效所有会话",老实的 session + Redis 方案反而更省事。
部署检查清单
- 密钥走环境变量/密钥管理服务,不进代码库
- 全站 HTTPS,否则 token 在明文链路里裸奔
- access token 放内存(变量),不要放 localStorage(XSS 一打就偷走);refresh token 放 httpOnly cookie
- 校验签名算法,防
alg: none降级攻击 - 刷新时查库确认用户状态
- 密钥有轮换机制,别一个密钥用三年