--- id: backend.gin.middleware-auth title: 中间件、JWT 鉴权和统一错误处理 type: lesson domain: backend stack: [gin] tags: [Gin, 中间件, JWT, 鉴权, 错误处理] level: intermediate prerequisites: [backend.gin.routing-binding, backend.go.errors-concurrency] est_hours: 3 related: [backend.gin.scenario.unified-api-response, backend.gin.interview] week: [2, 3] order: 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 `。中间件校验签名和过期时间,**必须指定允许的签名算法**,防止“算法替换”攻击。 - **统一错误处理**:业务代码返回带错误码的错误,由一个地方转换成统一的响应格式,避免每个处理函数自己拼 JSON、格式不一致。 ## 案例 企业后台的公共要求:所有接口返回 `{code, message, data, request_id}`;未登录 401、无权限 403、参数错误 400 都有固定的业务错误码;出问题时客服把 `request_id` 发给开发,开发在日志里一搜就能找到完整的请求记录。下一节的场景文档给出了完整设计。 ## 代码示例 ```go 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](examples/unified-api/)。 ## 易错点 - **`Abort` 后不 return**:当前中间件后面的代码还会执行。 - **不指定签名算法**:攻击者可能把算法改成 `none` 或用公钥当 HMAC 密钥;用 `jwt.WithValidMethods` 限定。 - **密钥写死在代码里**:从环境变量读取,长度至少 32 字节,不同环境不同密钥。 - **在 `c.Next()` 之后改响应头**:处理函数已经写出响应时,再设置头部不会生效;需要在写响应前设置。 - **在 goroutine 里直接用 `c`**:请求结束后 `gin.Context` 会被复用,后台 goroutine 要用 `c.Copy()`。 - **令牌无法主动失效**:JWT 是无状态的,退出登录、改密码后旧令牌仍有效;需要短有效期 + 刷新令牌,或服务端维护令牌版本号 / 黑名单。 ## 面试常问 见 [Gin 面试题](interview.md):中间件执行顺序、JWT 和 Session 的区别、令牌失效方案。