Files
go_blog/middleware/auth.go
T
kevin e314b05670 fix: 修复 P1 复审漏洞 #20 #21(禁用用户会话失效 + 头像 XSS 链)
- #20 AuthRequired 改为 AuthRequired(db):受保护路由每次回库校验
  Status == StatusNormal 且未软删,失败清 session(保留 lang/csrf_token,
  与登录轮换口径一致)并 302 /login;session user_id 先断言为数值再入
  GORM(呼应 #19),AdminRequired 同步加固;SetUserContext 仅在用户
  存在且状态正常时置 is_logged_in——禁用用户发评论不再自动通过,
  回落游客审核策略
- #21 头像两个分支(UploadAvatar / UpdateProfile)强制
  Category == image,解码失败直接拒绝、删除"回退存原始字节"路径,
  统一经 processAvatar 解码→256px 缩放→JPEG 重编码;addUploadFileType
  增加危险扩展黑名单(.html/.htm/.xhtml/.xht/.svg/.xml/.js/.mjs),
  拒绝添加并在上传设置页提示(模板 + 中英 i18n)
- 附带修复:processAvatar 依赖的 png/gif 解码器此前未注册(旧代码靠
  回退存原始字节掩盖,PNG 头像从未真正处理过),补 blank import
- 新增回归测试 session_upload_security_test.go(6 用例:禁用/锁定/
  软删旧 cookie 302、锁定用户评论转 pending、6 组危险扩展拒绝、
  伪装扩展名头像拒绝且磁盘零写入、正常图片转存 .jpg;已变异验证:
  去掉任一修复对应测试即失败)
- SECURITY_TODO.md 勾选 #20/#21 并更新执行顺序
2026-08-27 17:35:46 +08:00

194 lines
5.4 KiB
Go

package middleware
import (
"net/http"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"go_blog/i18n"
"go_blog/models"
)
// sessionUserID extracts the logged-in user's numeric ID from the session,
// defending against int/uint/int64/float64 storage. ok=false if absent or of
// an unexpected type.
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 drops the authentication state from a session, keeping only
// the harmless UI preferences (language and CSRF token, mirroring the login
// handler's rotation) so forms already rendered in other tabs stay valid.
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 is middleware that protects routes. If the user is not logged
// in, they are redirected to /login. The session user is also re-validated
// against the database on every request: an account that has since been
// disabled, locked or soft-deleted loses access immediately instead of when
// its cookie expires (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 {
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 {
// Account no longer usable — kill the session so the stale cookie
// cannot be replayed.
clearUserSession(session)
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
c.Next()
}
}
// AdminRequired is middleware that restricts a route to admin-role users. It
// must run after AuthRequired (which guarantees a live, normal-status session
// user). Non-admin users are redirected back to the admin dashboard.
func AdminRequired(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
uid, ok := sessionUserID(session)
if !ok {
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 {
c.Redirect(http.StatusFound, "/admin")
c.Abort()
return
}
c.Next()
}
}
// SetUserContext is global middleware that reads the session and sets
// template-friendly context values for all pages (language, auth state, etc.).
func SetUserContext(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
// --- Language detection ---
// Priority: query param > session > Accept-Language header > default EN
var lang i18n.Lang
queryLang := c.Query("lang")
switch queryLang {
case "zh":
lang = i18n.ZH
case "en":
lang = i18n.EN
case "":
// Try session
if saved, ok := session.Get("lang").(string); ok {
lang = i18n.Lang(saved)
}
if lang == "" {
// Try Accept-Language header
lang = i18n.DetectLang(c.GetHeader("Accept-Language"))
}
default:
// Unsupported language in query — fall back to English.
lang = i18n.EN
}
// Persist language in session.
session.Set("lang", string(lang))
session.Save()
// Make translations available in the Gin context.
c.Set("tr", i18n.T(lang))
c.Set("lang", string(lang))
// Set the opposite language code for the language switcher link.
switchLang := "zh"
if lang == i18n.ZH {
switchLang = "en"
}
c.Set("switch_lang", switchLang)
// --- Auth state ---
// The user is only considered logged in if the account still exists
// and is in normal status: a disabled/locked/soft-deleted account must
// not keep template-level privileges (e.g. comment auto-approval)
// after its session was invalidated (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 platform configuration (from DB cache) ---
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)))
// --- Navigation links (from DB cache) ---
navLinks := models.GetNavLinks()
c.Set("nav_links", navLinks)
c.Next()
}
}