Article
实战:博客后台的 JWT 中间件、权限与上传校验
实战:博客后台的 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_id、role、exp。密钥来自环境变量,例如 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/*" 等于没拦。服务端至少做:
- 大小:例如封面最大 5MB
- MIME / 扩展名白名单:jpeg、png、webp、gif
- 落盘文件名:用随机名,不要用用户原始文件名直接拼路径
- 目录:固定上传根目录,禁止
../
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 再打一遍。等这套稳了,再考虑刷新令牌或二次验证,挂载点已经现成。