Files
openteam/server/internal/api/middleware/middleware.go
Sakurasan e3ff0d3329 security: 会话可撤销与认证面加固
- 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 写法不生效)
2026-10-29 02:56:46 +08:00

137 lines
4.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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()
}
}