From e05e6f590360b5d99c38d0f279c5d10fa865cb67 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Thu, 18 Jun 2026 12:55:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(gateway):=20=E4=B8=89=E5=A4=84=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E5=AE=89=E5=85=A8=E7=A1=AC=E5=8C=96=EF=BC=88=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=AF=86=E9=92=A5/admin=E8=A3=B8=E5=A5=94/CORS?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) JWT 默认密钥:生产模式(APP_ENV=production|prod 或 GIN_MODE=release)下若未设 JWT_SECRET 直接 log.Fatal,杜绝用开发默认值签发可伪造令牌;开发期警告并放行。 2) /admin 运维控制面(含模型 API 密钥管理)改挂 RequireAdmin:必须登录 + (设了 ADMIN_USER_IDS 则)uid 须在白名单;生产期未配置管理员直接 403。 3) CORS Allow-Origin 由 CORS_ALLOW_ORIGIN 配置(缺省 * 仅开发),非 * 时加 Vary。 build + auth 单测通过。仍属"小范围灰度"级,TLS/可观测/集成测试/HA 见 PROGRESS。 Co-Authored-By: Claude Opus 4.8 --- PROGRESS.md | 1 + sundynix-gateway/internal/auth/auth.go | 26 +++++++++++- sundynix-gateway/internal/middleware/auth.go | 43 ++++++++++++++++++++ sundynix-gateway/internal/router/router.go | 18 ++++++-- 4 files changed, 82 insertions(+), 6 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index c3f9dfa..9d85c84 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -82,6 +82,7 @@ - [x] 文件主表,文档间关联用雪花 ID(弃用按名关联) - [x] 后端首批单测(19 纯逻辑用例:引擎/DSL/docx/报告)+ mcp-go 集成测试(Profile 迁移) - [x] **真实鉴权(JWT)闭环**:后端注册/登录/校验 + RequireAuth 保护路由 + owner=已验证 uid(去掉 header 兜底);前端登录/注册门 + 存 token + Bearer + 401 自动登出 + 顶栏用户/登出。实跑验证(含 CORS Authorization 修复) +- [x] 生产安全硬化:JWT 默认密钥生产 fail-fast · /admin 加 RequireAdmin(ADMIN_USER_IDS 白名单)· CORS 来源可配(CORS_ALLOW_ORIGIN) - [ ] 集成/前端测试(`runGraph` / `handleReport` 需 mock pool/tools/sink;前端无测试) --- diff --git a/sundynix-gateway/internal/auth/auth.go b/sundynix-gateway/internal/auth/auth.go index 17474d1..b19f53d 100644 --- a/sundynix-gateway/internal/auth/auth.go +++ b/sundynix-gateway/internal/auth/auth.go @@ -3,7 +3,9 @@ package auth import ( "errors" + "log" "os" + "strings" "time" "github.com/golang-jwt/jwt/v5" @@ -13,8 +15,28 @@ import ( // TokenTTL 是访问令牌有效期。 const TokenTTL = 24 * time.Hour -// secret 是 JWT 签名密钥。生产经环境变量 JWT_SECRET 注入;缺省仅供开发(务必覆盖)。 -var secret = []byte(envOr("JWT_SECRET", "sundynix-dev-secret-change-me")) +const devSecret = "sundynix-dev-secret-change-me" + +// secret 是 JWT 签名密钥。生产必须经 JWT_SECRET 注入强密钥; +// 生产模式(APP_ENV=production/prod 或 GIN_MODE=release)下未设则直接 fatal,杜绝可伪造令牌。 +var secret = []byte(resolveSecret()) + +func resolveSecret() string { + if s := os.Getenv("JWT_SECRET"); s != "" { + return s + } + if isProd() { + log.Fatal("[auth] 生产模式必须设置 JWT_SECRET(强随机密钥),拒绝使用开发默认值") + } + log.Println("[auth] ⚠️ 使用开发默认 JWT 密钥,生产务必设置 JWT_SECRET") + return devSecret +} + +// isProd 判定是否生产环境。 +func isProd() bool { + env := strings.ToLower(os.Getenv("APP_ENV")) + return env == "production" || env == "prod" || strings.ToLower(os.Getenv("GIN_MODE")) == "release" +} // ErrInvalidToken 表示令牌无效/过期/签名不符。 var ErrInvalidToken = errors.New("invalid token") diff --git a/sundynix-gateway/internal/middleware/auth.go b/sundynix-gateway/internal/middleware/auth.go index 077f78c..8ad1b47 100644 --- a/sundynix-gateway/internal/middleware/auth.go +++ b/sundynix-gateway/internal/middleware/auth.go @@ -2,6 +2,7 @@ package middleware import ( "net/http" + "os" "strings" "github.com/gin-gonic/gin" @@ -40,3 +41,45 @@ func RequireAuth() gin.HandlerFunc { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "需要登录"}) } } + +// RequireAdmin 保护运维控制面:必须登录,且(设了 ADMIN_USER_IDS 时)uid 须在白名单内。 +// ADMIN_USER_IDS 为空:开发期放行任意登录用户;生产期(APP_ENV=prod/GIN_MODE=release)直接拒绝 +// ——逼运维显式配置管理员,杜绝"任意账号改模型/密钥配置"。 +func RequireAdmin() gin.HandlerFunc { + allow := splitEnv("ADMIN_USER_IDS") + prod := strings.EqualFold(os.Getenv("APP_ENV"), "production") || strings.EqualFold(os.Getenv("APP_ENV"), "prod") || + strings.EqualFold(os.Getenv("GIN_MODE"), "release") + return func(c *gin.Context) { + uid, _ := c.Get(CtxUserID) + id, _ := uid.(string) + if id == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "需要登录"}) + return + } + if len(allow) == 0 { + if prod { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "未配置管理员(ADMIN_USER_IDS)"}) + return + } + c.Next() // 开发期放行 + return + } + for _, a := range allow { + if a == id { + c.Next() + return + } + } + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"}) + } +} + +func splitEnv(key string) []string { + var out []string + for _, p := range strings.Split(os.Getenv(key), ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 578793b..bdb938c 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -2,6 +2,8 @@ package router import ( + "os" + "github.com/gin-gonic/gin" "github.com/sundynix/sundynix-gateway/internal/blob" @@ -55,8 +57,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. p.GET("/billing", h.Billing) } - // 运维控制面:LLM 模型配置(独立运维控制台调用;鉴权待后续接管理员角色)。 - admin := api.Group("/admin") + // 运维控制面:LLM 模型配置(含 API 密钥管理)—— 必须管理员(RequireAdmin)。 + admin := api.Group("/admin", middleware.RequireAdmin()) { admin.GET("/models", h.ListModels) admin.POST("/models", h.SaveModel) @@ -68,10 +70,18 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. return r } -// cors 放开跨源访问,允许桌面端/浏览器带自定义身份头与 SSE 访问网关(开发期)。 +// cors 控制跨源访问。允许来源经 CORS_ALLOW_ORIGIN 配置(缺省 "*" 仅供开发; +// 生产应设为具体源,如 https://app.example.com)。Vary 保证按 Origin 正确缓存。 func cors() gin.HandlerFunc { + origin := "*" + if v := os.Getenv("CORS_ALLOW_ORIGIN"); v != "" { + origin = v + } return func(c *gin.Context) { - c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Origin", origin) + if origin != "*" { + c.Header("Vary", "Origin") + } c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-ID, X-User-ID") if c.Request.Method == "OPTIONS" {