Go进阶次阅读

Go JWT 登录鉴权:中间件 + 刷新令牌

Session 和 JWT 怎么选?这份 demo 用 Go 写一个可直接用的 JWT 鉴权:登录签发 access/refresh 双令牌、HTTP 中间件校验、刷新令牌轮转、以及把「不能存密码明文」「密钥别硬编码」「登出怎么办」这些坑一次说清。

JWT鉴权中间件GoAccessToken实战Demo

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 的三个先天短板

  1. 无法主动失效:签出去就收不回。要"踢人下线"就得维护黑名单(Redis 存 jti,中间件里查一次),等于又加回状态。
  2. payload 是 base64 不是加密:任何人都能解开看到内容,别往里放敏感信息。
  3. 体积比 session id 大:每个请求都带着,放太多字段会拖慢请求。

如果业务强依赖"实时踢下线""强制改密后立即失效所有会话",老实的 session + Redis 方案反而更省事。

部署检查清单

  • 密钥走环境变量/密钥管理服务,不进代码库
  • 全站 HTTPS,否则 token 在明文链路里裸奔
  • access token 放内存(变量),不要放 localStorage(XSS 一打就偷走);refresh token 放 httpOnly cookie
  • 校验签名算法,防 alg: none 降级攻击
  • 刷新时查库确认用户状态
  • 密钥有轮换机制,别一个密钥用三年