- handlers/api.go:APIOK/APIError/bindJSON,统一 {ok,redirect,data} /
{ok:false,code,error} 响应契约(code=i18n 键,error 按请求语言翻译)
- i18n:新增 api_error/api_unauthorized/api_forbidden/api_invalid_request(中英)
- middleware/auth.go:AuthRequired/AdminRequired 按 /api 前缀分支:
JSON 401/403(页面保持 302),新增 isAPIRequest + apiAuthError
- main.go:路由注册提取为 registerRoutes;建立 /api 分组并搬移附件三件套
(admin/my)与 /api/profile/avatar(旧 /admin|my/articles/attachments、
/profile/avatar 路由移除)
- handlers/login_ratelimit.go:loginRateLimiter 导出为 LoginRateLimiter
- templates/layouts/base.html:blogAPI/blogForm/blogShowError 共享 fetch 助手
- main_test.go:TestRegisterRoutesSmoke 冒烟测试(注册期 gin 静态/参数
冲突即 panic + 关键 /api 路由断言)
- go build/vet/test ./... 全绿
230 lines
6.4 KiB
Go
230 lines
6.4 KiB
Go
package middleware
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-contrib/sessions"
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
|
|
"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")
|
|
}
|
|
|
|
// apiAuthError 以 API 错误格式终止请求(401 未认证 / 403 无权限)。
|
|
// 文案按请求语言翻译(SetUserContext 已在全局中间件中注入 tr)。
|
|
func apiAuthError(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) {
|
|
apiAuthError(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) {
|
|
apiAuthError(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) {
|
|
apiAuthError(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) {
|
|
apiAuthError(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)
|
|
|
|
c.Next()
|
|
}
|
|
}
|