diff --git a/PROJECT_DIFF.md b/PROJECT_DIFF.md index 206540be..76679f8b 100644 --- a/PROJECT_DIFF.md +++ b/PROJECT_DIFF.md @@ -405,7 +405,7 @@ opsGroup := admin.Group("/ops") | 🟡 #3 | config.go 文件过大 (原2497行) | ✅ 已确认,已拆分 | 中 | | 🟡 #4 | GatewayHandler 职责过重 | ✅ 已拆分完成 | 中 | | 🟡 #5 | usage_logs 表分区策略 | ⚠️ 需确认生产配置 | 中 | -| 🟡 #6 | JWT Secret 自动生成风险 | ⚠️ 需运维关注 | 中 | +| 🟡 #6 | JWT Secret 自动生成风险 | ✅ 已补充状态告警,仍需运维轮换 | 中 | | 🟡 #7 | Admin API Key header 冲突 | ⚠️ 需验证路由顺序 | 低-中 | ### 8.3 信息级发现验证 @@ -425,7 +425,7 @@ opsGroup := admin.Group("/ops") **中优先级** (建议近期处理): 2. [x] 拆分 config.go (已完成) 3. [ ] 确认 usage_logs 分区在生产环境已启用 -4. [ ] 添加 JWT Secret 轮换告警机制 +4. [x] 添加 JWT Secret 轮换告警机制 **低优先级** (可选改进): 5. [ ] 统一密码复杂度要求 diff --git a/backend/internal/config/auth.go b/backend/internal/config/auth.go index c3f2f914..7dbf191c 100644 --- a/backend/internal/config/auth.go +++ b/backend/internal/config/auth.go @@ -2,8 +2,9 @@ package config // JWTConfig JWT 认证配置 type JWTConfig struct { - Secret string `mapstructure:"secret"` - ExpireHour int `mapstructure:"expire_hour"` + Secret string `mapstructure:"secret"` + SecretConfigured bool `mapstructure:"-"` // 是否手动配置(非启动期自动生成/补齐) + ExpireHour int `mapstructure:"expire_hour"` // AccessTokenExpireMinutes: Access Token有效期(分钟) // - >0: 使用分钟配置(优先级高于 ExpireHour) // - =0: 回退使用 ExpireHour(向后兼容旧配置) @@ -16,8 +17,8 @@ type JWTConfig struct { // TotpConfig TOTP 双因素认证配置 type TotpConfig struct { - EncryptionKey string `mapstructure:"encryption_key"` // AES-256 密钥(32 字节 hex 编码) - EncryptionKeyConfigured bool `mapstructure:"-"` // 是否手动配置(非自动生成) + EncryptionKey string `mapstructure:"encryption_key"` // AES-256 密钥(32 字节 hex 编码) + EncryptionKeyConfigured bool `mapstructure:"-"` // 是否手动配置(非自动生成) } // TurnstileConfig Cloudflare Turnstile 验证配置 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 26389b39..a76f1fb0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -43,8 +43,8 @@ const ( // 连接池隔离策略常量 const ( ConnectionPoolIsolationProxy = "proxy" - ConnectionPoolIsolationAccount = "account" - ConnectionPoolIsolationAccountProxy = "account_proxy" + ConnectionPoolIsolationAccount = "account" + ConnectionPoolIsolationAccountProxy = "account_proxy" ) // DefaultCSPPolicy is the default Content-Security-Policy with nonce support. @@ -106,7 +106,9 @@ func load(allowMissingJWTSecret bool) (*Config, error) { viper.SetConfigType("yaml") // Add config paths in priority order - if dataDir := os.Getenv("DATA_DIR"); dataDir != "" { viper.AddConfigPath(dataDir) } + if dataDir := os.Getenv("DATA_DIR"); dataDir != "" { + viper.AddConfigPath(dataDir) + } viper.AddConfigPath("/app/data") viper.AddConfigPath(".") viper.AddConfigPath("./config") @@ -129,11 +131,15 @@ func load(allowMissingJWTSecret bool) (*Config, error) { cfg.RunMode = NormalizeRunMode(cfg.RunMode) cfg.Server.Mode = strings.ToLower(strings.TrimSpace(cfg.Server.Mode)) - if cfg.Server.Mode == "" { cfg.Server.Mode = "debug" } + if cfg.Server.Mode == "" { + cfg.Server.Mode = "debug" + } cfg.Server.FrontendURL = strings.TrimSpace(cfg.Server.FrontendURL) normalizeAllStringFields(&cfg) - if err := loadCodexTemplate(&cfg); err != nil { return nil, err } + if err := loadCodexTemplate(&cfg); err != nil { + return nil, err + } // 兼容旧键 sticky_previous_response_ttl_seconds if cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds <= 0 && cfg.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds > 0 { @@ -151,7 +157,9 @@ func load(allowMissingJWTSecret bool) (*Config, error) { cfg.Totp.EncryptionKey = strings.TrimSpace(cfg.Totp.EncryptionKey) if cfg.Totp.EncryptionKey == "" { key, err := generateJWTSecret(32) - if err != nil { return nil, fmt.Errorf("generate totp encryption key error: %w", err) } + if err != nil { + return nil, fmt.Errorf("generate totp encryption key error: %w", err) + } cfg.Totp.EncryptionKey = key cfg.Totp.EncryptionKeyConfigured = false slog.Warn("TOTP encryption key auto-generated. Consider setting a fixed key for production.") @@ -160,6 +168,7 @@ func load(allowMissingJWTSecret bool) (*Config, error) { } originalJWTSecret := cfg.JWT.Secret + cfg.JWT.SecretConfigured = strings.TrimSpace(originalJWTSecret) != "" if allowMissingJWTSecret && originalJWTSecret == "" { cfg.JWT.Secret = strings.Repeat("0", 32) } @@ -168,7 +177,9 @@ func load(allowMissingJWTSecret bool) (*Config, error) { return nil, fmt.Errorf("validate config error: %w", err) } - if allowMissingJWTSecret && originalJWTSecret == "" { cfg.JWT.Secret = "" } + if allowMissingJWTSecret && originalJWTSecret == "" { + cfg.JWT.Secret = "" + } logSecurityWarnings(&cfg) return &cfg, nil diff --git a/backend/internal/config/config_helpers.go b/backend/internal/config/config_helpers.go index 0d5d7c9d..bbf414bd 100644 --- a/backend/internal/config/config_helpers.go +++ b/backend/internal/config/config_helpers.go @@ -13,17 +13,23 @@ import ( // normalizeStringSlice normalizes a string slice by trimming empties. func normalizeStringSlice(values []string) []string { - if len(values) == 0 { return values } + if len(values) == 0 { + return values + } out := make([]string, 0, len(values)) for _, v := range values { - if t := strings.TrimSpace(v); t != "" { out = append(out, t) } + if t := strings.TrimSpace(v); t != "" { + out = append(out, t) + } } return out } func isWeakJWTSecret(secret string) bool { lower := strings.ToLower(strings.TrimSpace(secret)) - if lower == "" { return true } + if lower == "" { + return true + } weak := map[string]struct{}{ "change-me-in-production": {}, "changeme": {}, "secret": {}, "password": {}, "123456": {}, "12345678": {}, "admin": {}, "jwt-secret": {}, @@ -33,9 +39,13 @@ func isWeakJWTSecret(secret string) bool { } func generateJWTSecret(byteLength int) (string, error) { - if byteLength <= 0 { byteLength = 32 } + if byteLength <= 0 { + byteLength = 32 + } buf := make([]byte, byteLength) - if _, err := rand.Read(buf); err != nil { return "", err } + if _, err := rand.Read(buf); err != nil { + return "", err + } return hex.EncodeToString(buf), nil } @@ -44,7 +54,9 @@ func GetServerAddress() string { v := viper.New() v.SetConfigName("config") v.SetConfigType("yaml") - v.AddConfigPath("."); v.AddConfigPath("./config"); v.AddConfigPath("/etc/sub2api") + v.AddConfigPath(".") + v.AddConfigPath("./config") + v.AddConfigPath("/etc/sub2api") v.AutomaticEnv() v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.SetDefault("server.host", "0.0.0.0") @@ -56,48 +68,84 @@ func GetServerAddress() string { // ValidateAbsoluteHTTPURL validates an absolute HTTP(S) URL. func ValidateAbsoluteHTTPURL(raw string) error { raw = strings.TrimSpace(raw) - if raw == "" { return fmt.Errorf("empty url") } + if raw == "" { + return fmt.Errorf("empty url") + } u, err := url.Parse(raw) - if err != nil { return err } - if !u.IsAbs() { return fmt.Errorf("must be absolute") } - if !isHTTPScheme(u.Scheme) { return fmt.Errorf("unsupported scheme: %s", u.Scheme) } - if strings.TrimSpace(u.Host) == "" { return fmt.Errorf("missing host") } - if u.Fragment != "" { return fmt.Errorf("must not include fragment") } + if err != nil { + return err + } + if !u.IsAbs() { + return fmt.Errorf("must be absolute") + } + if !isHTTPScheme(u.Scheme) { + return fmt.Errorf("unsupported scheme: %s", u.Scheme) + } + if strings.TrimSpace(u.Host) == "" { + return fmt.Errorf("missing host") + } + if u.Fragment != "" { + return fmt.Errorf("must not include fragment") + } return nil } // ValidateFrontendRedirectURL validates frontend redirect URL (absolute http(s) or relative path). func ValidateFrontendRedirectURL(raw string) error { raw = strings.TrimSpace(raw) - if raw == "" { return fmt.Errorf("empty url") } - if strings.ContainsAny(raw, "\r\n") { return fmt.Errorf("contains invalid characters") } + if raw == "" { + return fmt.Errorf("empty url") + } + if strings.ContainsAny(raw, "\r\n") { + return fmt.Errorf("contains invalid characters") + } if strings.HasPrefix(raw, "/") { - if strings.HasPrefix(raw, "//") { return fmt.Errorf("must not start with //") } + if strings.HasPrefix(raw, "//") { + return fmt.Errorf("must not start with //") + } return nil } u, err := url.Parse(raw) - if err != nil { return err } - if !u.IsAbs() { return fmt.Errorf("must be absolute http(s) url or relative path") } - if !isHTTPScheme(u.Scheme) { return fmt.Errorf("unsupported scheme: %s", u.Scheme) } - if strings.TrimSpace(u.Host) == "" { return fmt.Errorf("missing host") } - if u.Fragment != "" { return fmt.Errorf("must not include fragment") } + if err != nil { + return err + } + if !u.IsAbs() { + return fmt.Errorf("must be absolute http(s) url or relative path") + } + if !isHTTPScheme(u.Scheme) { + return fmt.Errorf("unsupported scheme: %s", u.Scheme) + } + if strings.TrimSpace(u.Host) == "" { + return fmt.Errorf("missing host") + } + if u.Fragment != "" { + return fmt.Errorf("must not include fragment") + } return nil } func scopeContainsOpenID(scopes string) bool { for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) { - if scope == "openid" { return true } + if scope == "openid" { + return true + } } return false } +func IsWeakJWTSecret(secret string) bool { + return isWeakJWTSecret(secret) +} + func isHTTPScheme(scheme string) bool { return strings.EqualFold(scheme, "http") || strings.EqualFold(scheme, "https") } func warnIfInsecureURL(field, raw string) { u, err := url.Parse(strings.TrimSpace(raw)) - if err != nil { return } + if err != nil { + return + } if strings.EqualFold(u.Scheme, "http") { slog.Warn("url uses http scheme; use https in production to avoid token leakage", "field", field) } diff --git a/backend/internal/handler/admin/ops_handler.go b/backend/internal/handler/admin/ops_handler.go index 7c9cfc6f..08a50e4e 100644 --- a/backend/internal/handler/admin/ops_handler.go +++ b/backend/internal/handler/admin/ops_handler.go @@ -942,3 +942,20 @@ func (h *OpsHandler) GetUsageLogsPartitionStatus(c *gin.Context) { response.Success(c, status) } + +// GetJWTSecretStatus returns operational status of the JWT signing secret. +// GET /api/v1/admin/ops/jwt-secret-status +func (h *OpsHandler) GetJWTSecretStatus(c *gin.Context) { + if h.opsService == nil { + response.Error(c, http.StatusServiceUnavailable, "Ops service not available") + return + } + + status, err := h.opsService.GetJWTSecretStatus(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, status) +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 47ea3a67..0d9fedc1 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -203,6 +203,7 @@ func registerOpsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { // Usage logs partition management ops.GET("/partition-status", h.Admin.Ops.GetUsageLogsPartitionStatus) + ops.GET("/jwt-secret-status", h.Admin.Ops.GetJWTSecretStatus) } } diff --git a/backend/internal/service/ops_jwt_secret_status_test.go b/backend/internal/service/ops_jwt_secret_status_test.go new file mode 100644 index 00000000..3d742212 --- /dev/null +++ b/backend/internal/service/ops_jwt_secret_status_test.go @@ -0,0 +1,112 @@ +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" +) + +func TestGetJWTSecretStatus_Missing(t *testing.T) { + svc := &OpsService{ + cfg: &config.Config{ + JWT: config.JWTConfig{}, + }, + } + + status, err := svc.GetJWTSecretStatus(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if status.Source != "missing" { + t.Fatalf("source = %q, want missing", status.Source) + } + if status.WarningLevel != "warning" { + t.Fatalf("warning_level = %q, want warning", status.WarningLevel) + } + if !status.NeedsRotation { + t.Fatal("expected needs_rotation to be true") + } +} + +func TestGetJWTSecretStatus_AutoGeneratedFallback(t *testing.T) { + svc := &OpsService{ + cfg: &config.Config{ + JWT: config.JWTConfig{ + Secret: "generated-jwt-secret-32bytes-long!!", + SecretConfigured: false, + }, + }, + } + + status, err := svc.GetJWTSecretStatus(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if status.Source != "generated_or_persisted" { + t.Fatalf("source = %q, want generated_or_persisted", status.Source) + } + if !status.AutoGenerated { + t.Fatal("expected auto_generated to be true") + } + if !status.NeedsRotation { + t.Fatal("expected needs_rotation to be true") + } +} + +func TestGetJWTSecretStatus_ConfiguredWeak(t *testing.T) { + svc := &OpsService{ + cfg: &config.Config{ + JWT: config.JWTConfig{ + Secret: "change-me-in-production", + SecretConfigured: true, + }, + }, + } + + status, err := svc.GetJWTSecretStatus(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if status.Source != "configured" { + t.Fatalf("source = %q, want configured", status.Source) + } + if !status.Weak { + t.Fatal("expected weak to be true") + } + if !status.NeedsRotation { + t.Fatal("expected needs_rotation to be true") + } +} + +func TestGetJWTSecretStatus_ConfiguredStrong(t *testing.T) { + svc := &OpsService{ + cfg: &config.Config{ + JWT: config.JWTConfig{ + Secret: "configured-jwt-secret-32bytes-long!!", + SecretConfigured: true, + }, + }, + } + + status, err := svc.GetJWTSecretStatus(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if status.Source != "configured" { + t.Fatalf("source = %q, want configured", status.Source) + } + if status.WarningLevel != "none" { + t.Fatalf("warning_level = %q, want none", status.WarningLevel) + } + if status.NeedsRotation { + t.Fatal("expected needs_rotation to be false") + } + if status.Weak { + t.Fatal("expected weak to be false") + } +} diff --git a/backend/internal/service/ops_service.go b/backend/internal/service/ops_service.go index e6d1ad5a..472da6e9 100644 --- a/backend/internal/service/ops_service.go +++ b/backend/internal/service/ops_service.go @@ -729,13 +729,27 @@ func sanitizeErrorBodyForStorage(raw string, maxBytes int) (sanitized string, tr // UsageLogsPartitionStatus represents the partition status of usage_logs table. type UsageLogsPartitionStatus struct { - IsPartitioned bool `json:"is_partitioned"` - RowCount int64 `json:"row_count"` - PartitionCount int `json:"partition_count"` - ThresholdRows int64 `json:"threshold_rows"` // 100000 - NeedsPartitioning bool `json:"needs_partitioning"` // rowCount >= threshold && !isPartitioned - WarningLevel string `json:"warning_level"` // "none", "info", "warning" - LastCheckedAt string `json:"last_checked_at"` + IsPartitioned bool `json:"is_partitioned"` + RowCount int64 `json:"row_count"` + PartitionCount int `json:"partition_count"` + ThresholdRows int64 `json:"threshold_rows"` // 100000 + NeedsPartitioning bool `json:"needs_partitioning"` // rowCount >= threshold && !isPartitioned + WarningLevel string `json:"warning_level"` // "none", "info", "warning" + LastCheckedAt string `json:"last_checked_at"` +} + +// JWTSecretStatus represents the operational status of the JWT signing secret. +type JWTSecretStatus struct { + Configured bool `json:"configured"` + AutoGenerated bool `json:"auto_generated"` + Weak bool `json:"weak"` + NeedsRotation bool `json:"needs_rotation"` + LengthBytes int `json:"length_bytes"` + WarningLevel string `json:"warning_level"` // "none", "warning" + Source string `json:"source"` // "configured" | "generated_or_persisted" | "missing" + Message string `json:"message"` + Recommendation string `json:"recommendation"` + LastCheckedAt string `json:"last_checked_at"` } // GetUsageLogsPartitionStatus returns the current partition status of usage_logs table. @@ -745,8 +759,8 @@ func (s *OpsService) GetUsageLogsPartitionStatus(ctx context.Context) (*UsageLog } status := &UsageLogsPartitionStatus{ - ThresholdRows: 100000, - LastCheckedAt: time.Now().UTC().Format(time.RFC3339), + ThresholdRows: 100000, + LastCheckedAt: time.Now().UTC().Format(time.RFC3339), } // Check if usage_logs is partitioned @@ -793,3 +807,51 @@ func (s *OpsService) GetUsageLogsPartitionStatus(ctx context.Context) (*UsageLog return status, nil } + +// GetJWTSecretStatus returns the current operational status of the JWT signing secret. +func (s *OpsService) GetJWTSecretStatus(ctx context.Context) (*JWTSecretStatus, error) { + _ = ctx + if s == nil || s.cfg == nil { + return nil, errors.New("config not available") + } + + secret := strings.TrimSpace(s.cfg.JWT.Secret) + configured := s.cfg.JWT.SecretConfigured + weak := config.IsWeakJWTSecret(secret) + + status := &JWTSecretStatus{ + Configured: configured, + AutoGenerated: !configured && secret != "", + Weak: weak, + NeedsRotation: false, + LengthBytes: len([]byte(secret)), + WarningLevel: "none", + Source: "configured", + LastCheckedAt: time.Now().UTC().Format(time.RFC3339), + } + + switch { + case secret == "": + status.Source = "missing" + status.WarningLevel = "warning" + status.NeedsRotation = true + status.Message = "JWT secret is missing." + status.Recommendation = "Set JWT_SECRET or jwt.secret before serving production traffic." + case !configured: + status.Source = "generated_or_persisted" + status.WarningLevel = "warning" + status.NeedsRotation = true + status.Message = "JWT secret was not explicitly configured and is using a generated or persisted fallback." + status.Recommendation = "Rotate to a managed secret via JWT_SECRET or jwt.secret and restart all instances in a controlled window." + case weak: + status.WarningLevel = "warning" + status.NeedsRotation = true + status.Message = "JWT secret is explicitly configured but appears weak." + status.Recommendation = "Rotate to a 32+ byte random secret and restart all instances in a controlled window." + default: + status.Message = "JWT secret is explicitly configured and passes current strength checks." + status.Recommendation = "Keep the secret in managed storage and rotate it during planned maintenance windows." + } + + return status, nil +}