Files
openteam/server/internal/api/middleware/middleware.go
T
Sakurasan ad10bdc400 security: 补上认证面与输入边界的四处防护
链路功能已经跑通,缺的是边界。这几处默认值都是仓库里公开已知的,忘配就等于没有认证。

- 认证端点限速:/auth/login、/auth/register 此前完全没接限速器(Limiter 只挂在代理
  Auth 上),爆破与批量建号零成本。新增 AllowWindow 滑动窗口,登录按「单 IP」和
  「单 IP×账号」两个桶(默认 30/min、10/min),注册按 IP(5/min)且在 argon2 之前拦,
  避免光靠建号请求打满 CPU;超限返回 429 + Retry-After。
- 生产启动断言:env=production 时拒绝默认 OT_JWT_SECRET、缺失或短于 16 字节的
  OT_MASTER_KEY(缺失时 NewEncryptor 会用 sha256("") 派生,渠道密文可被公开还原)、
  默认管理员密码;一次列全所有问题,不再改一个试一个。development 不拦。
- 请求体上限:parseBody 原来全量 io.ReadAll 且全项目没有一处 MaxBytesReader,
  合法密钥可把任意大 body 整份压进内存再转发。/v1 组挂 BodyLimit(默认 32MB,
  OT_PROXY_MAX_BODY_MB),超限按客户端协议返回 413 request_too_large 并点名上限。
- CORS:不再无条件反射 Origin + Allow-Credentials: true——那唯一挡住跨站凭据读取的
  是 refresh cookie 的 SameSite=Lax,改成 None 就立刻变成窃取面。改为显式白名单,
  空白名单完全不发跨域头(同源部署与 vite proxy 本来就不需要),通配 * 不配凭据;
  允许头补上 X-Api-Key / Anthropic-Version / OpenAI-*,否则浏览器里跑官方 SDK 预检会挂。
- 配套 OT_SERVER_TRUSTED_PROXIES:不配置就不信 X-Forwarded-For(防伪造头绕过 IP 限速)。

顺手删掉 .env.example 末尾重复的 OT_PROXY_LOG_RAW 段。
2026-10-17 03:25:30 +08:00

113 lines
3.4 KiB
Go
Raw 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
}
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()
}
}
// 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()
}
}