- 新增 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 ./... 全绿
234 lines
6.6 KiB
Go
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()
|
|
}
|
|
}
|