From c67f02eaf0f9a45150562791a3628749d548e600 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Sun, 22 Feb 2026 17:37:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(jwt):=20=E4=BF=AE=E5=A4=8D=E4=BB=85?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=B0=8F=E6=97=B6=E6=97=B6=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=8F=90=E5=89=8D=E5=A4=B1=E6=95=88=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 jwt.access_token_expire_minutes 默认值改为 0,未显式配置时回退 expire_hour - 调整配置校验为允许 0,仅拒绝负数并补充优先级注释 - 新增配置与认证服务单元测试,覆盖分钟优先与小时回退场景 - 更新示例配置文档,明确分钟/小时优先级与默认行为 --- backend/internal/config/config.go | 15 +++-- backend/internal/config/config_test.go | 35 ++++++++++ .../service/auth_service_register_test.go | 66 +++++++++++++++++++ deploy/.env.example | 5 ++ deploy/config.example.yaml | 9 ++- 5 files changed, 121 insertions(+), 9 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 1ddd3d14..3ccf6b3b 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -662,8 +662,9 @@ type OpsMetricsCollectorCacheConfig struct { type JWTConfig struct { Secret string `mapstructure:"secret"` ExpireHour int `mapstructure:"expire_hour"` - // AccessTokenExpireMinutes: Access Token有效期(分钟),默认15分钟 - // 短有效期减少被盗用风险,配合Refresh Token实现无感续期 + // AccessTokenExpireMinutes: Access Token有效期(分钟) + // - >0: 使用分钟配置(优先级高于 ExpireHour) + // - =0: 回退使用 ExpireHour(向后兼容旧配置) AccessTokenExpireMinutes int `mapstructure:"access_token_expire_minutes"` // RefreshTokenExpireDays: Refresh Token有效期(天),默认30天 RefreshTokenExpireDays int `mapstructure:"refresh_token_expire_days"` @@ -1047,9 +1048,9 @@ func setDefaults() { // JWT viper.SetDefault("jwt.secret", "") viper.SetDefault("jwt.expire_hour", 24) - viper.SetDefault("jwt.access_token_expire_minutes", 360) // 6小时Access Token有效期 - viper.SetDefault("jwt.refresh_token_expire_days", 30) // 30天Refresh Token有效期 - viper.SetDefault("jwt.refresh_window_minutes", 2) // 过期前2分钟开始允许刷新 + viper.SetDefault("jwt.access_token_expire_minutes", 0) // 0 表示回退到 expire_hour + viper.SetDefault("jwt.refresh_token_expire_days", 30) // 30天Refresh Token有效期 + viper.SetDefault("jwt.refresh_window_minutes", 2) // 过期前2分钟开始允许刷新 // TOTP viper.SetDefault("totp.encryption_key", "") @@ -1343,8 +1344,8 @@ func (c *Config) Validate() error { slog.Warn("jwt.expire_hour is high; consider shorter expiration for security", "expire_hour", c.JWT.ExpireHour) } // JWT Refresh Token配置验证 - if c.JWT.AccessTokenExpireMinutes <= 0 { - return fmt.Errorf("jwt.access_token_expire_minutes must be positive") + if c.JWT.AccessTokenExpireMinutes < 0 { + return fmt.Errorf("jwt.access_token_expire_minutes must be non-negative") } if c.JWT.AccessTokenExpireMinutes > 720 { slog.Warn("jwt.access_token_expire_minutes is high; consider shorter expiration for security", "access_token_expire_minutes", c.JWT.AccessTokenExpireMinutes) diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 1bba2f9d..88aa62fa 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -124,6 +124,36 @@ func TestLoadDefaultServerMode(t *testing.T) { } } +func TestLoadDefaultJWTAccessTokenExpireMinutes(t *testing.T) { + resetViperWithJWTSecret(t) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + if cfg.JWT.ExpireHour != 24 { + t.Fatalf("JWT.ExpireHour = %d, want 24", cfg.JWT.ExpireHour) + } + if cfg.JWT.AccessTokenExpireMinutes != 0 { + t.Fatalf("JWT.AccessTokenExpireMinutes = %d, want 0", cfg.JWT.AccessTokenExpireMinutes) + } +} + +func TestLoadJWTAccessTokenExpireMinutesFromEnv(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "90") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + if cfg.JWT.AccessTokenExpireMinutes != 90 { + t.Fatalf("JWT.AccessTokenExpireMinutes = %d, want 90", cfg.JWT.AccessTokenExpireMinutes) + } +} + func TestLoadDefaultDatabaseSSLMode(t *testing.T) { resetViperWithJWTSecret(t) @@ -735,6 +765,11 @@ func TestValidateConfigErrors(t *testing.T) { mutate: func(c *Config) { c.JWT.ExpireHour = 200 }, wantErr: "jwt.expire_hour must be <= 168", }, + { + name: "jwt access token expire minutes non-negative", + mutate: func(c *Config) { c.JWT.AccessTokenExpireMinutes = -1 }, + wantErr: "jwt.access_token_expire_minutes must be non-negative", + }, { name: "csp policy required", mutate: func(c *Config) { c.Security.CSP.Enabled = true; c.Security.CSP.Policy = "" }, diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index f1685be5..93659743 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -315,3 +315,69 @@ func TestAuthService_RefreshToken_ExpiredTokenNoPanic(t *testing.T) { require.NotEmpty(t, newToken) }) } + +func TestAuthService_GetAccessTokenExpiresIn_FallbackToExpireHour(t *testing.T) { + service := newAuthService(&userRepoStub{}, nil, nil) + service.cfg.JWT.ExpireHour = 24 + service.cfg.JWT.AccessTokenExpireMinutes = 0 + + require.Equal(t, 24*3600, service.GetAccessTokenExpiresIn()) +} + +func TestAuthService_GetAccessTokenExpiresIn_MinutesHasPriority(t *testing.T) { + service := newAuthService(&userRepoStub{}, nil, nil) + service.cfg.JWT.ExpireHour = 24 + service.cfg.JWT.AccessTokenExpireMinutes = 90 + + require.Equal(t, 90*60, service.GetAccessTokenExpiresIn()) +} + +func TestAuthService_GenerateToken_UsesExpireHourWhenMinutesZero(t *testing.T) { + service := newAuthService(&userRepoStub{}, nil, nil) + service.cfg.JWT.ExpireHour = 24 + service.cfg.JWT.AccessTokenExpireMinutes = 0 + + user := &User{ + ID: 1, + Email: "test@test.com", + Role: RoleUser, + Status: StatusActive, + TokenVersion: 1, + } + + token, err := service.GenerateToken(user) + require.NoError(t, err) + + claims, err := service.ValidateToken(token) + require.NoError(t, err) + require.NotNil(t, claims) + require.NotNil(t, claims.IssuedAt) + require.NotNil(t, claims.ExpiresAt) + + require.WithinDuration(t, claims.IssuedAt.Time.Add(24*time.Hour), claims.ExpiresAt.Time, 2*time.Second) +} + +func TestAuthService_GenerateToken_UsesMinutesWhenConfigured(t *testing.T) { + service := newAuthService(&userRepoStub{}, nil, nil) + service.cfg.JWT.ExpireHour = 24 + service.cfg.JWT.AccessTokenExpireMinutes = 90 + + user := &User{ + ID: 2, + Email: "test2@test.com", + Role: RoleUser, + Status: StatusActive, + TokenVersion: 1, + } + + token, err := service.GenerateToken(user) + require.NoError(t, err) + + claims, err := service.ValidateToken(token) + require.NoError(t, err) + require.NotNil(t, claims) + require.NotNil(t, claims.IssuedAt) + require.NotNil(t, claims.ExpiresAt) + + require.WithinDuration(t, claims.IssuedAt.Time.Add(90*time.Minute), claims.ExpiresAt.Time, 2*time.Second) +} diff --git a/deploy/.env.example b/deploy/.env.example index cdc9db37..290f918a 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -186,6 +186,11 @@ ADMIN_PASSWORD= # Generate a secure secret: openssl rand -hex 32 JWT_SECRET= JWT_EXPIRE_HOUR=24 +# Access Token 有效期(分钟) +# 优先级说明: +# - >0: 按分钟生效(优先于 JWT_EXPIRE_HOUR) +# - =0: 回退使用 JWT_EXPIRE_HOUR +JWT_ACCESS_TOKEN_EXPIRE_MINUTES=0 # ----------------------------------------------------------------------------- # TOTP (2FA) Configuration diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index c77ab70e..73bf77c0 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -663,9 +663,14 @@ jwt: # 重要:生产环境中请更改为随机字符串! # Generate with / 生成命令: openssl rand -hex 32 secret: "change-this-to-a-secure-random-string" - # Token expiration time in hours (max 24) - # 令牌过期时间(小时,最大 24) + # Token expiration time in hours (max 168) + # 令牌过期时间(小时,最大 168) expire_hour: 24 + # Access Token 过期时间(分钟) + # 优先级说明: + # - >0: 按分钟生效(优先于 expire_hour) + # - =0: 回退使用 expire_hour + access_token_expire_minutes: 0 # ============================================================================= # TOTP (2FA) Configuration