Files
go_blog/middleware/auth.go
T
kevin 2e3888d5b1 fix: 完成 P0 安全修复 #26——请求体大小限制防未认证内存耗尽 DoS
- 新增 middleware/bodylimit.go:BodyLimit 中间件,非 multipart 统一
  4 MiB(覆盖文章正文上限);multipart 按平台上传策略派生(启用类型
  限制与全局默认取最大 +1 MiB 开销,下限不低于编译期默认)。
  Content-Length 已知且超限读体前直接 413,其余经 MaxBytesReader 截断
- handlers/api.go:bindJSON 识别 *http.MaxBytesError → 413 +
  request_too_large(i18n 中英新增)
- main.go:中间件顺序调整为 SetUserContext → BodyLimit → CSRF——
  必须先于 CSRF(其解析 multipart 会读取整个请求体),SetUserContext
  提前使 413 文案可按请求语言翻译;测试环境链同步(security_test.go)
- middleware/auth.go:apiAuthError 更名 apiError(BodyLimit 复用)
- 新增 handlers/bodylimit_test.go:超限 JSON 两种形态(已知长度/
  chunked)413、正常体放行至认证层、3MB multipart 拒绝且附件表零写入、
  GET 不受影响
- SECURITY_TODO.md:#26 勾选完成并记录验证;API 化复审新增 #26–#32
  待办清单(P1 注册/评论限流、P2 favicon 魔数校验/最后管理员竞态/
  置顶权限、P3 零碎项)与执行顺序

go build / vet / test -race ./... 全绿
2026-08-27 21:23:19 +08:00

234 lines
6.6 KiB
Go

package middleware
import (
"net/http"
"strings"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"go_blog/buildinfo"
"go_blog/i18n"
"go_blog/models"
)
// isAPIRequest 报告请求是否命中 /api 前缀的 JSON 接口。
// 认证失败时 API 返回 JSON 错误,页面则保持 302 重定向。
func isAPIRequest(c *gin.Context) bool {
return strings.HasPrefix(c.Request.URL.Path, "/api")
}
// apiError 以 API 错误格式终止请求(401 未认证 / 403 无权限 / 413 请求体
// 过大等)。文案按请求语言翻译(SetUserContext 已在全局中间件中注入 tr)。
func apiError(c *gin.Context, status int, trKey string) {
tr, _ := c.Get("tr")
m, _ := tr.(map[string]string)
code := trKey
msg := m[code]
if msg == "" {
code = "api_error"
msg = m["api_error"]
}
c.AbortWithStatusJSON(status, gin.H{
"ok": false,
"code": code,
"error": msg,
})
}
// sessionUserID 从会话中提取已登录用户的数值 ID,
// 兼容 int/uint/int64/float64 的存储类型。若不存在或类型不符,ok=false。
func sessionUserID(session sessions.Session) (uint, bool) {
userID := session.Get("user_id")
if userID == nil {
return 0, false
}
switch v := userID.(type) {
case uint:
return v, true
case int:
return uint(v), true
case int64:
return uint(v), true
case float64:
return uint(v), true
default:
return 0, false
}
}
// clearUserSession 从会话中清除认证状态,仅保留无害的 UI 偏好
// (语言与 CSRF 令牌,与登录处理器的轮换逻辑保持一致),
// 以确保其他标签页中已渲染的表单仍然有效。
func clearUserSession(session sessions.Session) {
lang, _ := session.Get("lang").(string)
csrfTok, _ := session.Get(CSRFSessionKey).(string)
session.Clear()
if lang != "" {
session.Set("lang", lang)
}
if csrfTok != "" {
session.Set(CSRFSessionKey, csrfTok)
}
session.Save()
}
// AuthRequired 是保护路由的中间件。若用户未登录,则重定向到 /login。
// 会话用户还会在每次请求时重新对数据库校验:已停用、已锁定或已软删除的
// 账户会立即失去访问权限,而无需等到 Cookie 过期(SECURITY_TODO #20)。
func AuthRequired(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
uid, ok := sessionUserID(session)
if !ok {
if isAPIRequest(c) {
apiError(c, http.StatusUnauthorized, "api_unauthorized")
return
}
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
var user models.User
if err := db.First(&user, uid).Error; err != nil || user.Status != models.StatusNormal {
// 账户已不可用——销毁会话,防止过期 Cookie 被重放。
clearUserSession(session)
if isAPIRequest(c) {
apiError(c, http.StatusUnauthorized, "api_unauthorized")
return
}
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
c.Next()
}
}
// AdminRequired 是仅允许管理员角色用户访问路由的中间件。它必须在
// AuthRequired 之后运行(后者保证会话用户存在且状态正常)。
// 非管理员用户会被重定向回管理后台。
func AdminRequired(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
uid, ok := sessionUserID(session)
if !ok {
if isAPIRequest(c) {
apiError(c, http.StatusUnauthorized, "api_unauthorized")
return
}
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
var user models.User
if err := db.First(&user, uid).Error; err != nil || user.Role != models.RoleAdmin {
if isAPIRequest(c) {
apiError(c, http.StatusForbidden, "api_forbidden")
return
}
c.Redirect(http.StatusFound, "/admin")
c.Abort()
return
}
c.Next()
}
}
// SetUserContext 是全局中间件,读取会话并为所有页面设置
// 便于模板使用的上下文值(语言、认证状态等)。
func SetUserContext(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
// --- 语言检测 ---
// 优先级:查询参数 > 会话 > Accept-Language 请求头 > 默认 EN
var lang i18n.Lang
queryLang := c.Query("lang")
switch queryLang {
case "zh":
lang = i18n.ZH
case "en":
lang = i18n.EN
case "":
// 尝试从会话中读取
if saved, ok := session.Get("lang").(string); ok {
lang = i18n.Lang(saved)
}
if lang == "" {
// 尝试从 Accept-Language 请求头检测
lang = i18n.DetectLang(c.GetHeader("Accept-Language"))
}
default:
// 查询参数包含不支持的语言——回退到英语。
lang = i18n.EN
}
// 将会话中的语言持久化。
session.Set("lang", string(lang))
session.Save()
// 将翻译字典放入 Gin 上下文。
c.Set("tr", i18n.T(lang))
c.Set("lang", string(lang))
// 为语言切换链接设置相反的语言代码。
switchLang := "zh"
if lang == i18n.ZH {
switchLang = "en"
}
c.Set("switch_lang", switchLang)
// --- 认证状态 ---
// 仅当账户仍然存在且状态正常时,才认为用户已登录:
// 被停用/锁定/软删除的账户,在其会话失效后
// 不得继续保留模板级权限(如评论自动通过)(SECURITY_TODO #20)。
isLoggedIn := false
var username string
var avatar string
var displayName string
var role string
if uid, ok := sessionUserID(session); ok {
var user models.User
if err := db.First(&user, uid).Error; err == nil && user.Status == models.StatusNormal {
isLoggedIn = true
username = user.Username
avatar = user.Avatar
displayName = user.DisplayName
role = user.Role
}
}
c.Set("is_logged_in", isLoggedIn)
c.Set("username", username)
c.Set("avatar", avatar)
c.Set("display_name", displayName)
c.Set("role", role)
// --- 站点平台配置(来自数据库缓存)---
site := models.GetSiteSetting()
c.Set("site_setting", site)
c.Set("site_logo", site.Logo)
c.Set("site_logo_is_url", site.LogoIsURL())
c.Set("site_favicon", site.Favicon)
c.Set("site_favicon_is_url", site.FaviconIsURL())
c.Set("site_logo_text", site.LogoText(string(lang)))
c.Set("site_header_text", site.HeaderText(string(lang)))
c.Set("site_home_welcome", site.HomeWelcome(string(lang)))
c.Set("site_home_subtitle", site.HomeSubtitle(string(lang)))
c.Set("site_footer_text", site.FooterText(string(lang)))
// --- 导航链接(来自数据库缓存)---
navLinks := models.GetNavLinks()
c.Set("nav_links", navLinks)
// --- 构建信息(编译时注入的 Git 版本与编译时间)---
c.Set("build_info", buildinfo.String())
c.Next()
}
}