From 799b01063119db53aec5e0a283df176ff093cf69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E7=8C=BFMT?= <32916545+mt21625457@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:37:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E4=BF=AE=E5=A4=8D=20RefreshToken?= =?UTF-8?q?=20=E4=BD=BF=E7=94=A8=E8=BF=87=E6=9C=9F=20token=20=E6=97=B6?= =?UTF-8?q?=E7=9A=84=20nil=20pointer=20panic=20(#214)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(auth): 修复 RefreshToken 使用过期 token 时的 nil pointer panic 问题分析: - RefreshToken 允许过期 token 继续流程(用于无感刷新) - 但 ValidateToken 在 token 过期时返回 nil claims - 导致后续访问 claims.UserID 时触发 panic 修复方案: - 修改 ValidateToken,在检测到 ErrTokenExpired 时仍然返回 claims - jwt-go 在解析时即使遇到过期错误,token.Claims 仍会被填充 - 这样 RefreshToken 可以正常获取用户信息并生成新 token 新增测试: - TestAuthService_ValidateToken_ExpiredReturnsClaimsWithError - TestAuthService_RefreshToken_ExpiredTokenNoPanic Co-Authored-By: Claude Opus 4.5 * fix(auth): 修复邮件验证服务未配置时可绕过验证的安全漏洞 当邮件验证开启但 emailService 未配置时,原逻辑允许用户绕过验证直接注册。 现在会返回 ErrServiceUnavailable 拒绝注册,确保配置错误不会导致安全问题。 - 在验证码检查前先检查 emailService 是否配置 - 添加日志记录帮助发现配置问题 - 新增单元测试覆盖该场景 Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: yangjianbo Co-authored-by: Claude Opus 4.5 --- backend/internal/service/auth_service.go | 17 ++++- .../service/auth_service_register_test.go | 76 ++++++++++++++++++- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 85772e75..5a5ca03d 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -82,14 +82,18 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw // 检查是否需要邮件验证 if s.settingService != nil && s.settingService.IsEmailVerifyEnabled(ctx) { + // 如果邮件验证已开启但邮件服务未配置,拒绝注册 + // 这是一个配置错误,不应该允许绕过验证 + if s.emailService == nil { + log.Println("[Auth] Email verification enabled but email service not configured, rejecting registration") + return "", nil, ErrServiceUnavailable + } if verifyCode == "" { return "", nil, ErrEmailVerifyRequired } // 验证邮箱验证码 - if s.emailService != nil { - if err := s.emailService.VerifyCode(ctx, email, verifyCode); err != nil { - return "", nil, fmt.Errorf("verify code: %w", err) - } + if err := s.emailService.VerifyCode(ctx, email, verifyCode); err != nil { + return "", nil, fmt.Errorf("verify code: %w", err) } } @@ -336,6 +340,11 @@ func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) { if err != nil { if errors.Is(err, jwt.ErrTokenExpired) { + // token 过期但仍返回 claims(用于 RefreshToken 等场景) + // jwt-go 在解析时即使遇到过期错误,token.Claims 仍会被填充 + if claims, ok := token.Claims.(*JWTClaims); ok { + return claims, ErrTokenExpired + } return nil, ErrTokenExpired } return nil, ErrInvalidToken diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index cd6e2808..a31267ab 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -113,13 +113,27 @@ func TestAuthService_Register_Disabled(t *testing.T) { require.ErrorIs(t, err, ErrRegDisabled) } -func TestAuthService_Register_EmailVerifyRequired(t *testing.T) { +func TestAuthService_Register_EmailVerifyEnabledButServiceNotConfigured(t *testing.T) { repo := &userRepoStub{} + // 邮件验证开启但 emailCache 为 nil(emailService 未配置) service := newAuthService(repo, map[string]string{ SettingKeyRegistrationEnabled: "true", SettingKeyEmailVerifyEnabled: "true", }, nil) + // 应返回服务不可用错误,而不是允许绕过验证 + _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "any-code") + require.ErrorIs(t, err, ErrServiceUnavailable) +} + +func TestAuthService_Register_EmailVerifyRequired(t *testing.T) { + repo := &userRepoStub{} + cache := &emailCacheStub{} // 配置 emailService + service := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyEmailVerifyEnabled: "true", + }, cache) + _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "") require.ErrorIs(t, err, ErrEmailVerifyRequired) } @@ -180,3 +194,63 @@ func TestAuthService_Register_Success(t *testing.T) { require.Len(t, repo.created, 1) require.True(t, user.CheckPassword("password")) } + +func TestAuthService_ValidateToken_ExpiredReturnsClaimsWithError(t *testing.T) { + repo := &userRepoStub{} + service := newAuthService(repo, nil, nil) + + // 创建用户并生成 token + user := &User{ + ID: 1, + Email: "test@test.com", + Role: RoleUser, + Status: StatusActive, + TokenVersion: 1, + } + token, err := service.GenerateToken(user) + require.NoError(t, err) + + // 验证有效 token + claims, err := service.ValidateToken(token) + require.NoError(t, err) + require.NotNil(t, claims) + require.Equal(t, int64(1), claims.UserID) + + // 模拟过期 token(通过创建一个过期很久的 token) + service.cfg.JWT.ExpireHour = -1 // 设置为负数使 token 立即过期 + expiredToken, err := service.GenerateToken(user) + require.NoError(t, err) + service.cfg.JWT.ExpireHour = 1 // 恢复 + + // 验证过期 token 应返回 claims 和 ErrTokenExpired + claims, err = service.ValidateToken(expiredToken) + require.ErrorIs(t, err, ErrTokenExpired) + require.NotNil(t, claims, "claims should not be nil when token is expired") + require.Equal(t, int64(1), claims.UserID) + require.Equal(t, "test@test.com", claims.Email) +} + +func TestAuthService_RefreshToken_ExpiredTokenNoPanic(t *testing.T) { + user := &User{ + ID: 1, + Email: "test@test.com", + Role: RoleUser, + Status: StatusActive, + TokenVersion: 1, + } + repo := &userRepoStub{user: user} + service := newAuthService(repo, nil, nil) + + // 创建过期 token + service.cfg.JWT.ExpireHour = -1 + expiredToken, err := service.GenerateToken(user) + require.NoError(t, err) + service.cfg.JWT.ExpireHour = 1 + + // RefreshToken 使用过期 token 不应 panic + require.NotPanics(t, func() { + newToken, err := service.RefreshToken(context.Background(), expiredToken) + require.NoError(t, err) + require.NotEmpty(t, newToken) + }) +}