// Package middleware Gin 中间件:会话鉴权、admin 校验。 package middleware import ( "net/http" "strings" "github.com/gin-gonic/gin" "github.com/openteam/server/internal/app" "github.com/openteam/server/internal/pkg/resp" "github.com/openteam/server/internal/store" ) const CtxSessionUser = "session_user" // SessionAuth 会话鉴权:Authorization: Bearer 。 func SessionAuth(a *app.App) gin.HandlerFunc { return func(c *gin.Context) { auth := c.GetHeader("Authorization") if len(auth) < 8 || auth[:7] != "Bearer " { resp.Fail(c, http.StatusUnauthorized, "missing or invalid access token") c.Abort() return } claims, err := a.JWT.Parse(auth[7:]) if err != nil || claims.Subject != "access" { resp.Fail(c, http.StatusUnauthorized, "invalid or expired access token") c.Abort() return } var u store.User if err := a.DB.First(&u, claims.UserID).Error; err != nil { resp.Fail(c, http.StatusUnauthorized, "user not found") c.Abort() return } if u.Status != store.UserStatusActive { resp.Fail(c, http.StatusForbidden, "user account disabled") c.Abort() return } // 会话版本不匹配 = 改密/登出后签发的旧 token,立即失效 if claims.Ver != u.TokenVersion { resp.Fail(c, http.StatusUnauthorized, "session revoked, please sign in again") c.Abort() return } c.Set(CtxSessionUser, &u) c.Next() } } // AdminOnly 仅管理员。 func AdminOnly(c *gin.Context) { u, ok := c.Get(CtxSessionUser) if !ok { resp.Fail(c, http.StatusUnauthorized, "authentication required") c.Abort() return } if u.(*store.User).Role != store.RoleAdmin { resp.Fail(c, http.StatusForbidden, "admin permission required") c.Abort() return } c.Next() } // CORS 按显式 origin 白名单放行跨域;allow 为空则完全不输出 CORS 头。 // // 同源部署(网关自己托管 web/dist)和 vite dev 的 proxy 都不需要 CORS,所以默认关是安全的。 // 之前无条件反射 Origin 并带 Allow-Credentials: true,唯一挡住跨站凭据读取的是 // refresh cookie 的 SameSite=Lax——那是巧合而非防线,一旦有人为了嵌 iframe 把它改成 // None,任意站点就能带着 cookie 调 /auth/refresh 读回新 token。 func CORS(allow []string) gin.HandlerFunc { allowed := make(map[string]bool, len(allow)) wildcard := false for _, o := range allow { if o == "*" { wildcard = true continue } allowed[strings.TrimRight(o, "/")] = true } return func(c *gin.Context) { origin := c.GetHeader("Origin") if origin != "" && (wildcard || allowed[strings.TrimRight(origin, "/")]) { if wildcard { // 通配不配凭据:/v1 靠 Authorization / x-api-key 头认证,不吃 cookie c.Header("Access-Control-Allow-Origin", "*") } else { c.Header("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Credentials", "true") } c.Header("Vary", "Origin") c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With, X-Api-Key, Anthropic-Version, OpenAI-Beta, OpenAI-Organization, OpenAI-Project") c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") c.Header("Access-Control-Max-Age", "600") } if c.Request.Method == http.MethodOptions { c.AbortWithStatus(http.StatusNoContent) return } c.Next() } } // SecurityHeaders 基础安全响应头 + SPA 的 CSP。 // script-src 'self':Vite 构建产物无内联脚本;style 保留 'unsafe-inline' // (Vue 运行时 style 绑定与 Tailwind 注入需要);access token 不再落 localStorage 后, // XSS 即使发生也拿不到持久凭据,CSP 是纵深防御的一层。 func SecurityHeaders() gin.HandlerFunc { return func(c *gin.Context) { h := c.Writer.Header() h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Frame-Options", "DENY") h.Set("Referrer-Policy", "strict-origin-when-cross-origin") h.Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+ "img-src 'self' data:; font-src 'self' data:; connect-src 'self'; "+ "frame-ancestors 'none'; base-uri 'self'; form-action 'self'") c.Next() } } // BodyLimit 限制请求体字节数。超限后 ReadAll 会拿到 *http.MaxBytesError, // 由代理层按客户端协议返回 413;0 表示不限制。 func BodyLimit(maxBytes int64) gin.HandlerFunc { return func(c *gin.Context) { if maxBytes > 0 { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes) } c.Next() } }