应使用 github.com/golang-jwt/jwt/v5 替代旧版 jwt-go,因其修复了 alg=none 的严重安全漏洞,移除了 SigningMethodNone,并强制限定合法算法;需嵌入 jwt.RegisteredClaims、用 jwt.WithValidMethods 校验、密钥从环境变量加载、区分 access/refresh Token 时效与存储,中间件统一返回标准 http 状态码。

为什么用 github.com/golang-jwt/jwt/v5 而不是旧版 jwt-go
旧版 jwt-go(v3 及之前)在 2023 年被发现存在严重安全缺陷:当 alg 字段为 none 且签名为空时,部分配置下会跳过验签直接返回 nil 错误,导致伪造 token 可绕过认证。v5 版本彻底移除了 SigningMethodNone,并强制要求显式指定合法算法列表。
实操建议:
- 必须使用
github.com/golang-jwt/jwt/v5,别用github.com/dgrijalva/jwt-go或github.com/golang-jwt/jwt(无 /v5 后缀的默认是 v4,已归档) - 初始化
jwt.Parse时,keyFunc必须返回具体算法(如jwt.SigningMethodHS256)和密钥,不能返回nil或泛型Interface{} - 验证失败时,检查错误是否为
jwt.ErrTokenExpired、jwt.ErrTokenUnverifiable等具体类型,而非仅判空
ParseWithClaims 中如何安全提取用户 ID 和权限字段
JWT payload 是可读的,敏感字段(如密码、手机号)绝不能塞进 token;只放最小必要标识,比如 user_id 和 role。自定义 claims 需嵌入 jwt.RegisteredClaims,否则无法触发标准校验(如过期、签发时间)。
示例结构定义:
type CustomClaims struct { UserID uint `json:"user_id"` Role string `json:"role"` jwt.RegisteredClaims }
解析时必须传入该结构指针,并用 jwt.WithValidMethods 限定算法:
token, err := jwt.ParseWithClaims(tokenString, &CustomClaims{}, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return []byte(os.Getenv("JWT_SECRET")), nil }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
常见错误:
- 把
CustomClaims定义成非指针传给ParseWithClaims→ 解析成功但字段为空 - 忘记嵌入
jwt.RegisteredClaims→ExpiresAt等字段不参与自动校验 - 密钥硬编码在代码里 → 应从环境变量或 secret manager 加载
立即学习“go语言免费学习笔记(深入)”;
登录接口生成 token 时怎么控制有效期和刷新逻辑
不要用固定 24 小时;生产环境应区分 access token(短时效,15–60 分钟)和 refresh token(长时效,7 天,且需存 DB 绑定设备/IP/指纹)。access token 过期后,前端用 refresh token 换新 access token,而不是重新登录。
生成 access token 示例:
claims := &CustomClaims{ UserID: user.ID, Role: user.Role, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)), IssuedAt: jwt.NewNumericDate(time.Now()), Subject: strconv.FormatUint(uint64(user.ID), 10), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signedToken, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))
关键点:
-
ExpiresAt必须用jwt.NewNumericDate包装time.Time,直接赋值 int64 会导致解析失败 - 不要在 token 里存密码哈希、session ID 等可逆信息
- refresh token 必须服务端存储(redis + 随机 UUID),验证时比对是否匹配且未被撤回
中间件里怎么统一拦截未授权请求并返回标准错误
Go HTTP 中间件应直接操作 http.ResponseWriter 并提前 return,避免后续 handler 执行。JWT 验证失败时,状态码必须是 401 Unauthorized(未登录)或 403 Forbidden(权限不足),不能混用 400 或 500。
典型中间件写法:
func AuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") if authHeader == "" { http.Error(w, "missing Authorization header", http.StatusUnauthorized) return } parts := strings.Split(authHeader, " ") if len(parts) != 2 || parts[0] != "Bearer" { http.Error(w, "invalid Authorization format", http.StatusUnauthorized) return } tokenString := parts[1] claims := &CustomClaims{} _, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) { return []byte(os.Getenv("JWT_SECRET")), nil }) if err != nil { switch { case errors.Is(err, jwt.ErrTokenExpired): http.Error(w, "token expired", http.StatusUnauthorized) case errors.Is(err, jwt.ErrTokenInvalidClaim): http.Error(w, "invalid claim", http.StatusUnauthorized) default: http.Error(w, "invalid token", http.StatusUnauthorized) } return } ctx := context.WithValue(r.Context(), "user_id", claims.UserID) r = r.WithContext(ctx) next.ServeHTTP(w, r) }) }
容易忽略的坑:
- 没检查
authHeader是否为空就strings.Split→ panic - 验证通过后没把用户信息注入
context,下游 handler 只能重复解析 token - 错误响应没设
Content-Type: application/json,前端难解析
refresh token 的吊销和轮换逻辑比 access token 复杂得多,尤其是分布式部署时,务必用 Redis 的原子操作(SET key val EX 604800 NX)保证唯一性和时效性。别依赖内存 map 或本地文件。