- users 加 token_version(schema v4):改密/登出递增,该用户全部已发 access/refresh 立即作废;refresh 改轮换式换发新 cookie - 邀请码改精确匹配 + 注册成功后消费(一码一用),修复子串匹配让任意后缀放行、可无限薅体验金 - 生产校验拦下 .env.example 占位密钥;OT_ENV 枚举校验(拼错不再静默跳过断言);生产强制 COOKIE_SECURE;非 development 拒绝默认密码播种管理员 - WebAuthn 会话启用过期校验 + 4096 容量上限(未认证 login/begin 可无限撑爆内存),begin/complete 挂登录级限速 - /api/v1 挂 1MB 请求体上限;login 先限 IP 桶再解析 body;全局安全响应头 + CSP - 连接池配置(PG 30/SQLite 8);SQLite DSN 自动追加 WAL + busy_timeout(驱动只认 _pragma,旧注释的 _journal_mode 写法不生效)
137 lines
4.5 KiB
Go
137 lines
4.5 KiB
Go
// 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 <access token>。
|
||
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()
|
||
}
|
||
}
|