feat add jwt secret ops status
This commit is contained in:
@@ -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. [ ] 统一密码复杂度要求
|
||||
|
||||
@@ -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 验证配置
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
112
backend/internal/service/ops_jwt_secret_status_test.go
Normal file
112
backend/internal/service/ops_jwt_secret_status_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user