Files

4.7 KiB
Raw Permalink Blame History

id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week, order
id title type domain stack tags level prerequisites est_hours related week order
backend.gin.middleware-auth 中间件、JWT 鉴权和统一错误处理 lesson backend
gin
Gin
中间件
JWT
鉴权
错误处理
intermediate
backend.gin.routing-binding
backend.go.errors-concurrency
3
backend.gin.scenario.unified-api-response
backend.gin.interview
2
3
2

中间件、JWT 鉴权和统一错误处理

解释说明

  • 中间件就是一个 gin.HandlerFunc,在处理函数前后执行公共逻辑:日志、恢复 panic、跨域、鉴权、限流、请求 ID。和 Koa / Express 的洋葱模型一样:c.Next() 之前是“进入”,之后是“返回”;c.Abort() 终止后续处理函数(当前函数还会继续执行到结束,所以 Abort 后要 return)。
  • 注册范围:r.Use(...) 全局;group.Use(...) 只对一组路由;也可以写在单个路由上。
  • 在中间件之间传值:c.Set("userID", id) / c.GetString("userID");跨到 service 层时,放进 context.Context 传递。
  • JWT 鉴权:登录成功后签发令牌(包含用户 ID sub、过期时间 exp),之后每次请求带 Authorization: Bearer <token>。中间件校验签名和过期时间,必须指定允许的签名算法,防止“算法替换”攻击。
  • 统一错误处理:业务代码返回带错误码的错误,由一个地方转换成统一的响应格式,避免每个处理函数自己拼 JSON、格式不一致。

案例

企业后台的公共要求:所有接口返回 {code, message, data, request_id};未登录 401、无权限 403、参数错误 400 都有固定的业务错误码;出问题时客服把 request_id 发给开发,开发在日志里一搜就能找到完整的请求记录。下一节的场景文档给出了完整设计。

代码示例

package main

import (
	"errors"
	"log"
	"net/http"
	"strings"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v5"
)

var secret = []byte("dev-only-secret-change-me-at-least-32-bytes")

func Sign(userID string, ttl time.Duration) (string, error) {
	claims := jwt.RegisteredClaims{Subject: userID, ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl))}
	return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}

// Auth 校验 Bearer 令牌,把用户 ID 放进上下文
func Auth() gin.HandlerFunc {
	return func(c *gin.Context) {
		raw, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
		if !ok {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40100, "message": "请先登录"})
			return
		}
		var claims jwt.RegisteredClaims
		_, err := jwt.ParseWithClaims(raw, &claims, func(*jwt.Token) (any, error) { return secret, nil },
			jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired())
		if err != nil {
			msg := "令牌无效"
			if errors.Is(err, jwt.ErrTokenExpired) {
				msg = "登录已过期"
			}
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40101, "message": msg})
			return
		}
		c.Set("userID", claims.Subject)
		c.Next()
	}
}

// Timing 演示“洋葱模型”:Next 之前和之后都能执行代码
func Timing() gin.HandlerFunc {
	return func(c *gin.Context) {
		start := time.Now()
		c.Next() // 执行后面的中间件和处理函数
		log.Printf("%s %s %d %v", c.Request.Method, c.FullPath(), c.Writer.Status(), time.Since(start))
	}
}

func main() {
	r := gin.New()
	r.Use(gin.Recovery(), Timing())
	r.POST("/login", func(c *gin.Context) {
		token, _ := Sign("u1", 2*time.Hour)
		c.JSON(http.StatusOK, gin.H{"token": token})
	})
	authed := r.Group("/api", Auth())
	authed.GET("/me", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"user_id": c.GetString("userID")})
	})
	_ = r.Run(":8080")
}

完整的“统一响应 + 错误码 + 请求 ID”实现和测试见 examples/unified-api。

易错点

  • Abort 后不 return:当前中间件后面的代码还会执行。
  • 不指定签名算法:攻击者可能把算法改成 none 或用公钥当 HMAC 密钥;用 jwt.WithValidMethods 限定。
  • 密钥写死在代码里:从环境变量读取,长度至少 32 字节,不同环境不同密钥。
  • 在 c.Next() 之后改响应头:处理函数已经写出响应时,再设置头部不会生效;需要在写响应前设置。
  • 在 goroutine 里直接用 c:请求结束后 gin.Context 会被复用,后台 goroutine 要用 c.Copy()。
  • 令牌无法主动失效:JWT 是无状态的,退出登录、改密码后旧令牌仍有效;需要短有效期 + 刷新令牌,或服务端维护令牌版本号 / 黑名单。

面试常问

见 Gin 面试题:中间件执行顺序、JWT 和 Session 的区别、令牌失效方案。