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