115 lines
4.7 KiB
Markdown
115 lines
4.7 KiB
Markdown
---
|
||
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 的区别、令牌失效方案。
|