实战:博客后台的 JWT 中间件、权限与上传校验

Article

实战:博客后台的 JWT 中间件、权限与上传校验

管理员技术分享65 阅读

实战:博客后台的 JWT 中间件、权限与上传校验

公开博客可以匿名读;后台一动就涉及身份。这篇文章按实现顺序,把「登录签发 Token → 中间件拦截 → 角色校验 → 上传收敛」走通。代码示意贴近 Gin 风格,你完全可以对照自己的仓库替换包名。

目标行为

做成这样就算过关:

  • POST /api/auth/login 成功返回 JWT
  • 不带 Token 访问 /api/admin/* → 401
  • Token 过期或签名错 → 401
  • 非 admin 角色 → 403
  • 上传接口拒绝超大文件与奇怪 Content-Type
  • 公开 GET /api/articles 永远不要求登录

先写验收用例,再写代码,能避免「中间件写了一半又放开」。

1. 登录与签发

密码用 bcrypt 校验。成功后签发短期 Token,claims 至少包含:user_idroleexp。密钥来自环境变量,例如 JWT_SECRET,不要写死在仓库。

伪代码:

token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
    "user_id": user.ID,
    "role":    user.Role,
    "exp":     time.Now().Add(48 * time.Hour).Unix(),
})
signed, err := token.SignedString([]byte(os.Getenv("JWT_SECRET")))

登录失败不要区分「用户不存在」还是「密码错」,统一返回模糊错误,降低枚举用户名的收益。另外给登录接口加简单频率限制:同 IP 短期内失败太多次就暂拒。

2. 中间件:一条链路验到底

管理路由统一挂中间件,业务 handler 里不要再散落鉴权 if。

func AuthRequired() gin.HandlerFunc {
    return func(c *gin.Context) {
        h := c.GetHeader("Authorization")
        if !strings.HasPrefix(h, "Bearer ") {
            c.AbortWithStatusJSON(401, gin.H{"message": "unauthorized"})
            return
        }
        raw := strings.TrimPrefix(h, "Bearer ")
        claims, err := ParseAndValidate(raw) // 验签 + exp + alg 白名单
        if err != nil {
            c.AbortWithStatusJSON(401, gin.H{"message": "unauthorized"})
            return
        }
        if claims.Role != "admin" {
            c.AbortWithStatusJSON(403, gin.H{"message": "forbidden"})
            return
        }
        c.Set("user_id", claims.UserID)
        c.Next()
    }
}

要点:

  • 固定允许的签名算法,拒绝 none
  • 失败对外文案收敛,对内日志可区分过期/签名失败
  • user_id 放进 context,供创建文章等写作者字段使用

路由注册示例:

api := r.Group("/api")
api.POST("/auth/login", h.Login)
api.GET("/articles", h.ListArticles) // 公开

admin := api.Group("/admin")
admin.Use(AuthRequired())
admin.POST("/articles", h.CreateArticle)
admin.POST("/upload", h.Upload)

3. 前端怎么带 Token

后台登录成功后存 Token(localStorage 或内存 + 持久化策略按你接受的 XSS 风险定)。所有 admin 请求加头:

Authorization: Bearer <token>

请求封装遇到 401 应清 Token 并跳登录页,避免每个页面各自处理。同源反代时,API 路径用相对 /api,少踩 CORS 与混合内容。

注意:退出登录清前端存储,不等于服务端吊销。个人博客用 24–72 小时 TTL 通常够用;改密后若要让旧 Token 立刻失效,需要 token_version 或黑名单。

4. 上传校验:网关与应用一起拦

只靠前端 accept="image/*" 等于没拦。服务端至少做:

  1. 大小:例如封面最大 5MB
  2. MIME / 扩展名白名单:jpeg、png、webp、gif
  3. 落盘文件名:用随机名,不要用用户原始文件名直接拼路径
  4. 目录:固定上传根目录,禁止 ../

Gin 示例思路:

file, err := c.FormFile("file")
if err != nil || file.Size > 5<<20 {
    // 400
}
ext := strings.ToLower(filepath.Ext(file.Filename))
allow := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".webp": true}
if !allow[ext] {
    // 400
}

同时把 Nginx client_max_body_size 调到不小于应用限制。否则应用日志里根本看不见请求,浏览器只是上传失败。

返回给前端的 URL 建议是站点相对路径,例如 /uploads/xxxx.png,由 Nginx 同源反代。不要返回 http://backend:8080/... 这种容器内地址。

5. 评论审核:另一条写路径

前台评论可以匿名或半匿名,但要进「待审」状态,后台审核后再公开。这条链路同样走 admin 鉴权:

  • 公开:POST /api/comments 创建,状态固定 pending,并做长度/频率限制
  • 管理:PUT /api/admin/comments/:id/status 改为 approved/rejected

不要给公开接口任何「直接 approved」的参数口子。客户端传 status 也必须忽略。

6. 实战验收脚本

用 curl 自己打一遍(把 Token 换掉):

# 应 401
curl -s -o /dev/null -w "%{http_code}\n" \
  -X POST https://你的域名/api/admin/articles \
  -H 'Content-Type: application/json' \
  -d '{"title":"x"}'

# 登录
TOKEN=$(curl -s -X POST https://你的域名/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"你的密码"}' | jq -r '.data.token')

# 应 200/0 业务码
curl -s -X POST https://你的域名/api/admin/articles \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"title":"实战草稿","content":"md","status":"draft"}'

把这组命令留在仓库 scripts/ 里,改鉴权后回归会很省事。

小结

后台安全实战不是堆中间件文件,而是钉死行为:公开只读、管理必鉴权、角色显式、上传双端限制、评论默认待审。先写验收,再写实现;上线后用 curl 再打一遍。等这套稳了,再考虑刷新令牌或二次验证,挂载点已经现成。

评论

暂无评论,来聊聊这篇文章吧。