Files
zhuangang/topics/backend/gin/02-middleware-auth.md
T

115 lines
4.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 <token>`。中间件校验签名和过期时间,**必须指定允许的签名算法**,防止“算法替换”攻击。
- **统一错误处理**:业务代码返回带错误码的错误,由一个地方转换成统一的响应格式,避免每个处理函数自己拼 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 的区别、令牌失效方案。