初始化项目:基础设施 + 用户认证 + 后台管理系统 + AGPL 3.0 许可

This commit is contained in:
2026-05-26 13:46:33 +08:00
parent 1315df6501
commit 483fdd919f
56 changed files with 5804 additions and 40 deletions

112
internal/middleware/csrf.go Normal file
View File

@ -0,0 +1,112 @@
package middleware
import (
"crypto/rand"
"encoding/hex"
"net/http"
"metazone.cc/metalab/internal/config"
"github.com/gin-gonic/gin"
)
const (
csrfCookieName = "mlb_csrf"
csrfHeaderName = "X-CSRF-Token"
csrfMetaName = "csrf_token"
csrfTokenLen = 32 // 字节
)
// CSRF 中间件Double Submit Cookie 模式
// 前端 JS 从 Cookie 中读取 token 并放入 X-CSRF-Token 请求头,后端验证两者一致
// GET / HEAD / OPTIONS 请求自动放行
func CSRF(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// 安全读取豁免
if c.Request.Method == http.MethodGet ||
c.Request.Method == http.MethodHead ||
c.Request.Method == http.MethodOptions {
c.Next()
return
}
cookieToken, err := c.Cookie(csrfCookieName)
if err != nil || cookieToken == "" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"success": false, "message": "CSRF 验证失败",
})
return
}
headerToken := c.GetHeader(csrfHeaderName)
// Fallback如果请求头没有尝试从表单字段读取纯 HTML form 提交通道)
if headerToken == "" {
headerToken = c.PostForm(csrfMetaName)
}
if headerToken == "" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"success": false, "message": "CSRF 验证失败:缺少令牌",
})
return
}
// 恒定时间比较防时序攻击
if !constantTimeEq(cookieToken, headerToken) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"success": false, "message": "CSRF 验证失败",
})
return
}
// 注入到上下文,供前端 <meta> 使用
c.Set(csrfMetaName, cookieToken)
c.Next()
}
}
// SetCSRFToken 在首次页面访问时下发 CSRF Cookie由页面路由中间件调用
// 注意:此 Cookie HttpOnly=falseJS 可读——这是 Double Submit 模式的必要条件
func SetCSRFToken(c *gin.Context, cfg *config.Config) string {
secure := cfg.Server.Mode != "debug"
// 如果已有 token 且未过期,复用(但必须注入 context供模板 meta 标签使用)
if existing, err := c.Cookie(csrfCookieName); err == nil && existing != "" {
c.Set(csrfMetaName, existing)
return existing
}
token, err := generateCSRFToken()
if err != nil {
// 极端情况:随机数生成失败,使用短 token
token = "fallback-" + hex.EncodeToString([]byte("metazone"))
}
c.SetSameSite(http.SameSiteStrictMode)
// 30 天有效期,与 refresh token 对齐
maxAge := int(cfg.JWT.RememberExpire * 3600)
c.SetCookie(csrfCookieName, token, maxAge, "/", "", secure, false)
c.Set(csrfMetaName, token)
return token
}
// generateCSRFToken 生成 64 字符十六进制随机 CSRF token
func generateCSRFToken() (string, error) {
b := make([]byte, csrfTokenLen)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// constantTimeEq 恒定时间字符串比较(防时序攻击)
func constantTimeEq(a, b string) bool {
if len(a) != len(b) {
return false
}
var result byte
for i := 0; i < len(a); i++ {
result |= a[i] ^ b[i]
}
return result == 0
}