Files
go_blog/middleware/auth.go
T
kevin b1e9d9cae6 feat: API 基建——/api JSON 接口统一契约与认证分支
- 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 ./... 全绿
2026-08-27 19:24:43 +08:00

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()
}
}