feat add jwt secret ops status

This commit is contained in:
2026-04-24 08:32:16 +08:00
parent 3c95606195
commit 75d03e4713
8 changed files with 296 additions and 44 deletions

View File

@@ -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. [ ] 统一密码复杂度要求

View File

@@ -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 验证配置

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}

View File

@@ -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
}