fix(jwt): 修复仅配置小时时会话提前失效问题
- 将 jwt.access_token_expire_minutes 默认值改为 0,未显式配置时回退 expire_hour - 调整配置校验为允许 0,仅拒绝负数并补充优先级注释 - 新增配置与认证服务单元测试,覆盖分钟优先与小时回退场景 - 更新示例配置文档,明确分钟/小时优先级与默认行为
This commit is contained in:
@@ -662,8 +662,9 @@ type OpsMetricsCollectorCacheConfig struct {
|
|||||||
type JWTConfig struct {
|
type JWTConfig struct {
|
||||||
Secret string `mapstructure:"secret"`
|
Secret string `mapstructure:"secret"`
|
||||||
ExpireHour int `mapstructure:"expire_hour"`
|
ExpireHour int `mapstructure:"expire_hour"`
|
||||||
// AccessTokenExpireMinutes: Access Token有效期(分钟),默认15分钟
|
// AccessTokenExpireMinutes: Access Token有效期(分钟)
|
||||||
// 短有效期减少被盗用风险,配合Refresh Token实现无感续期
|
// - >0: 使用分钟配置(优先级高于 ExpireHour)
|
||||||
|
// - =0: 回退使用 ExpireHour(向后兼容旧配置)
|
||||||
AccessTokenExpireMinutes int `mapstructure:"access_token_expire_minutes"`
|
AccessTokenExpireMinutes int `mapstructure:"access_token_expire_minutes"`
|
||||||
// RefreshTokenExpireDays: Refresh Token有效期(天),默认30天
|
// RefreshTokenExpireDays: Refresh Token有效期(天),默认30天
|
||||||
RefreshTokenExpireDays int `mapstructure:"refresh_token_expire_days"`
|
RefreshTokenExpireDays int `mapstructure:"refresh_token_expire_days"`
|
||||||
@@ -1047,9 +1048,9 @@ func setDefaults() {
|
|||||||
// JWT
|
// JWT
|
||||||
viper.SetDefault("jwt.secret", "")
|
viper.SetDefault("jwt.secret", "")
|
||||||
viper.SetDefault("jwt.expire_hour", 24)
|
viper.SetDefault("jwt.expire_hour", 24)
|
||||||
viper.SetDefault("jwt.access_token_expire_minutes", 360) // 6小时Access Token有效期
|
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_token_expire_days", 30) // 30天Refresh Token有效期
|
||||||
viper.SetDefault("jwt.refresh_window_minutes", 2) // 过期前2分钟开始允许刷新
|
viper.SetDefault("jwt.refresh_window_minutes", 2) // 过期前2分钟开始允许刷新
|
||||||
|
|
||||||
// TOTP
|
// TOTP
|
||||||
viper.SetDefault("totp.encryption_key", "")
|
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)
|
slog.Warn("jwt.expire_hour is high; consider shorter expiration for security", "expire_hour", c.JWT.ExpireHour)
|
||||||
}
|
}
|
||||||
// JWT Refresh Token配置验证
|
// JWT Refresh Token配置验证
|
||||||
if c.JWT.AccessTokenExpireMinutes <= 0 {
|
if c.JWT.AccessTokenExpireMinutes < 0 {
|
||||||
return fmt.Errorf("jwt.access_token_expire_minutes must be positive")
|
return fmt.Errorf("jwt.access_token_expire_minutes must be non-negative")
|
||||||
}
|
}
|
||||||
if c.JWT.AccessTokenExpireMinutes > 720 {
|
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)
|
slog.Warn("jwt.access_token_expire_minutes is high; consider shorter expiration for security", "access_token_expire_minutes", c.JWT.AccessTokenExpireMinutes)
|
||||||
|
|||||||
@@ -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) {
|
func TestLoadDefaultDatabaseSSLMode(t *testing.T) {
|
||||||
resetViperWithJWTSecret(t)
|
resetViperWithJWTSecret(t)
|
||||||
|
|
||||||
@@ -735,6 +765,11 @@ func TestValidateConfigErrors(t *testing.T) {
|
|||||||
mutate: func(c *Config) { c.JWT.ExpireHour = 200 },
|
mutate: func(c *Config) { c.JWT.ExpireHour = 200 },
|
||||||
wantErr: "jwt.expire_hour must be <= 168",
|
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",
|
name: "csp policy required",
|
||||||
mutate: func(c *Config) { c.Security.CSP.Enabled = true; c.Security.CSP.Policy = "" },
|
mutate: func(c *Config) { c.Security.CSP.Enabled = true; c.Security.CSP.Policy = "" },
|
||||||
|
|||||||
@@ -315,3 +315,69 @@ func TestAuthService_RefreshToken_ExpiredTokenNoPanic(t *testing.T) {
|
|||||||
require.NotEmpty(t, newToken)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -186,6 +186,11 @@ ADMIN_PASSWORD=
|
|||||||
# Generate a secure secret: openssl rand -hex 32
|
# Generate a secure secret: openssl rand -hex 32
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
JWT_EXPIRE_HOUR=24
|
JWT_EXPIRE_HOUR=24
|
||||||
|
# Access Token 有效期(分钟)
|
||||||
|
# 优先级说明:
|
||||||
|
# - >0: 按分钟生效(优先于 JWT_EXPIRE_HOUR)
|
||||||
|
# - =0: 回退使用 JWT_EXPIRE_HOUR
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=0
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# TOTP (2FA) Configuration
|
# TOTP (2FA) Configuration
|
||||||
|
|||||||
@@ -663,9 +663,14 @@ jwt:
|
|||||||
# 重要:生产环境中请更改为随机字符串!
|
# 重要:生产环境中请更改为随机字符串!
|
||||||
# Generate with / 生成命令: openssl rand -hex 32
|
# Generate with / 生成命令: openssl rand -hex 32
|
||||||
secret: "change-this-to-a-secure-random-string"
|
secret: "change-this-to-a-secure-random-string"
|
||||||
# Token expiration time in hours (max 24)
|
# Token expiration time in hours (max 168)
|
||||||
# 令牌过期时间(小时,最大 24)
|
# 令牌过期时间(小时,最大 168)
|
||||||
expire_hour: 24
|
expire_hour: 24
|
||||||
|
# Access Token 过期时间(分钟)
|
||||||
|
# 优先级说明:
|
||||||
|
# - >0: 按分钟生效(优先于 expire_hour)
|
||||||
|
# - =0: 回退使用 expire_hour
|
||||||
|
access_token_expire_minutes: 0
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# TOTP (2FA) Configuration
|
# TOTP (2FA) Configuration
|
||||||
|
|||||||
Reference in New Issue
Block a user