fix(gateway): 三处生产安全硬化(默认密钥/admin裸奔/CORS)

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 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-18 12:55:04 +08:00
parent 9c19bb44f1
commit e05e6f5903
4 changed files with 82 additions and 6 deletions
+1
View File
@@ -82,6 +82,7 @@
- [x] 文件主表,文档间关联用雪花 ID(弃用按名关联) - [x] 文件主表,文档间关联用雪花 ID(弃用按名关联)
- [x] 后端首批单测(19 纯逻辑用例:引擎/DSL/docx/报告)+ mcp-go 集成测试(Profile 迁移) - [x] 后端首批单测(19 纯逻辑用例:引擎/DSL/docx/报告)+ mcp-go 集成测试(Profile 迁移)
- [x] **真实鉴权(JWT)闭环**:后端注册/登录/校验 + RequireAuth 保护路由 + owner=已验证 uid(去掉 header 兜底);前端登录/注册门 + 存 token + Bearer + 401 自动登出 + 顶栏用户/登出。实跑验证(含 CORS Authorization 修复) - [x] **真实鉴权(JWT)闭环**:后端注册/登录/校验 + RequireAuth 保护路由 + owner=已验证 uid(去掉 header 兜底);前端登录/注册门 + 存 token + Bearer + 401 自动登出 + 顶栏用户/登出。实跑验证(含 CORS Authorization 修复)
- [x] 生产安全硬化:JWT 默认密钥生产 fail-fast · /admin 加 RequireAdminADMIN_USER_IDS 白名单)· CORS 来源可配(CORS_ALLOW_ORIGIN
- [ ] 集成/前端测试(`runGraph` / `handleReport` 需 mock pool/tools/sink;前端无测试) - [ ] 集成/前端测试(`runGraph` / `handleReport` 需 mock pool/tools/sink;前端无测试)
--- ---
+24 -2
View File
@@ -3,7 +3,9 @@ package auth
import ( import (
"errors" "errors"
"log"
"os" "os"
"strings"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@@ -13,8 +15,28 @@ import (
// TokenTTL 是访问令牌有效期。 // TokenTTL 是访问令牌有效期。
const TokenTTL = 24 * time.Hour const TokenTTL = 24 * time.Hour
// secret 是 JWT 签名密钥。生产经环境变量 JWT_SECRET 注入;缺省仅供开发(务必覆盖)。 const devSecret = "sundynix-dev-secret-change-me"
var secret = []byte(envOr("JWT_SECRET", "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 表示令牌无效/过期/签名不符。 // ErrInvalidToken 表示令牌无效/过期/签名不符。
var ErrInvalidToken = errors.New("invalid token") var ErrInvalidToken = errors.New("invalid token")
@@ -2,6 +2,7 @@ package middleware
import ( import (
"net/http" "net/http"
"os"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -40,3 +41,45 @@ func RequireAuth() gin.HandlerFunc {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "需要登录"}) 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
}
+14 -4
View File
@@ -2,6 +2,8 @@
package router package router
import ( import (
"os"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/blob" "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) p.GET("/billing", h.Billing)
} }
// 运维控制面:LLM 模型配置(独立运维控制台调用;鉴权待后续接管理员角色)。 // 运维控制面:LLM 模型配置(含 API 密钥管理)—— 必须管理员(RequireAdmin)。
admin := api.Group("/admin") admin := api.Group("/admin", middleware.RequireAdmin())
{ {
admin.GET("/models", h.ListModels) admin.GET("/models", h.ListModels)
admin.POST("/models", h.SaveModel) admin.POST("/models", h.SaveModel)
@@ -68,10 +70,18 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
return r return r
} }
// cors 放开跨源访问允许桌面端/浏览器带自定义身份头与 SSE 访问网关(开发期)。 // cors 控制跨源访问允许来源经 CORS_ALLOW_ORIGIN 配置(缺省 "*" 仅供开发;
// 生产应设为具体源,如 https://app.example.com)。Vary 保证按 Origin 正确缓存。
func cors() gin.HandlerFunc { func cors() gin.HandlerFunc {
origin := "*"
if v := os.Getenv("CORS_ALLOW_ORIGIN"); v != "" {
origin = v
}
return func(c *gin.Context) { 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-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-ID, X-User-ID") c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-ID, X-User-ID")
if c.Request.Method == "OPTIONS" { if c.Request.Method == "OPTIONS" {