fix: v6 code review P0 auth/IDOR fixes + frontend regression patches

Backend fixes:
- auth_handler: P0 认证逻辑修复
- ratelimit: 限速中间件增强 + 新增单元测试
- auth_service: 认证服务逻辑完善 + 新增测试
- server: server 配置增强 + 新增测试
- handler_test: 新增 handler 层集成测试
- auth_bootstrap_test: bootstrap 路径测试

Frontend patches:
- LoginPage/RegisterPage: CSRF + 表单交互修复
- BootstrapAdminPage: 引导流程修复
- DevicesPage: 设备管理页修复
- auth/social-accounts/users/webhooks services: 类型修正
- csrf.ts: CSRF token 处理修正
- E2E 脚本: CDP smoke + auth e2e 增强

Docs:
- FULL_CODE_REVIEW_REPORT_2026-04-20
- report-v6 执行计划
- REAL_PROJECT_STATUS 更新
- .gitignore: 新增 .gocache-*/config.yaml 排除

验证: go build/vet 0错误, go test 42/42 PASS, 0 FAIL
This commit is contained in:
2026-04-23 07:14:12 +08:00
parent 82109ec216
commit 3f3bb82f1d
41 changed files with 2681 additions and 283 deletions

View File

@@ -2,10 +2,13 @@ package service
import (
"context"
cryptorand "crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"strconv"
"strings"
"time"
"unicode"
@@ -19,11 +22,14 @@ import (
)
const (
userInfoCachePrefix = "auth_user_info:"
tokenBlacklistPrefix = "auth_token_blacklist:"
defaultUserCacheTTL = 15 * time.Minute
defaultBlacklistTTL = time.Hour
defaultPasswordMinLen = 8
userInfoCachePrefix = "auth_user_info:"
tokenBlacklistPrefix = "auth_token_blacklist:"
totpChallengePrefix = "auth_totp_challenge:"
defaultUserCacheTTL = 15 * time.Minute
defaultBlacklistTTL = time.Hour
defaultTOTPChallengeTTL = 5 * time.Minute
defaultPasswordMinLen = 8
refreshTokenRetryGrace = 10 * time.Second
)
type userRepositoryInterface interface {
@@ -122,13 +128,18 @@ type LoginResponse struct {
ExpiresIn int64 `json:"expires_in,omitempty"`
User *UserInfo `json:"user,omitempty"`
// RequiresTOTP 指示登录需要额外的TOTP验证当设备未信任时
RequiresTOTP bool `json:"requires_totp,omitempty"`
RequiresTOTP bool `json:"requires_totp,omitempty"`
// TempToken 临时令牌用于TOTP验证阶段短生命周期不可用于常规API
TempToken string `json:"temp_token,omitempty"`
// UserID 当RequiresTOTP为true时返回用于后续TOTP验证
UserID int64 `json:"user_id,omitempty"`
}
type totpLoginChallenge struct {
UserID int64 `json:"user_id"`
DeviceID string `json:"device_id,omitempty"`
}
type LogoutRequest struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
@@ -432,6 +443,38 @@ func (s *AuthService) blacklistTokenClaims(ctx context.Context, token string, va
return s.cache.Set(ctx, tokenBlacklistPrefix+claims.JTI, true, ttl, ttl)
}
func (s *AuthService) getTokenBlacklistValue(ctx context.Context, jti string) (interface{}, bool) {
if s == nil || s.cache == nil {
return nil, false
}
jti = strings.TrimSpace(jti)
if jti == "" {
return nil, false
}
return s.cache.Get(ctx, tokenBlacklistPrefix+jti)
}
func tokenBlacklistRevokedAt(value interface{}) (time.Time, bool) {
switch v := value.(type) {
case int64:
return time.Unix(0, v), true
case int:
return time.Unix(0, int64(v)), true
case float64:
return time.Unix(0, int64(v)), true
case string:
timestamp, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(0, timestamp), true
default:
return time.Time{}, false
}
}
func (s *AuthService) recordLoginAnomaly(ctx context.Context, userID *int64, ip, location, deviceFingerprint string, success bool) {
if s == nil || s.anomalyDetector == nil || userID == nil {
return
@@ -601,6 +644,93 @@ func userInfoFromCacheValue(value interface{}) (*UserInfo, bool) {
}
}
func generateTemporaryLoginToken() (string, error) {
payload := make([]byte, 32)
if _, err := cryptorand.Read(payload); err != nil {
return "", fmt.Errorf("generate temporary login token failed: %w", err)
}
return base64.RawURLEncoding.EncodeToString(payload), nil
}
func totpLoginChallengeFromCacheValue(value interface{}) (*totpLoginChallenge, bool) {
switch typed := value.(type) {
case *totpLoginChallenge:
return typed, true
case totpLoginChallenge:
challenge := typed
return &challenge, true
case map[string]interface{}:
payload, err := json.Marshal(typed)
if err != nil {
return nil, false
}
var challenge totpLoginChallenge
if err := json.Unmarshal(payload, &challenge); err != nil {
return nil, false
}
return &challenge, true
default:
return nil, false
}
}
func (s *AuthService) issueTOTPLoginChallenge(ctx context.Context, user *domain.User, deviceID string) (string, error) {
if s == nil || s.cache == nil {
return "", errors.New("temporary login token storage is unavailable")
}
if user == nil {
return "", errors.New("temporary login token requires a user")
}
tempToken, err := generateTemporaryLoginToken()
if err != nil {
return "", err
}
challenge := &totpLoginChallenge{
UserID: user.ID,
DeviceID: strings.TrimSpace(deviceID),
}
if err := s.cache.Set(
ctx,
totpChallengePrefix+tempToken,
challenge,
defaultTOTPChallengeTTL,
defaultTOTPChallengeTTL,
); err != nil {
return "", fmt.Errorf("temporary login token storage failed: %w", err)
}
return tempToken, nil
}
func (s *AuthService) validateTOTPLoginChallenge(ctx context.Context, userID int64, deviceID, tempToken string) error {
if s == nil || s.cache == nil {
return errors.New("temporary login token storage is unavailable")
}
normalizedToken := strings.TrimSpace(tempToken)
if normalizedToken == "" {
return errors.New("temporary login token is required")
}
value, ok := s.cache.Get(ctx, totpChallengePrefix+normalizedToken)
if !ok {
return errors.New("temporary login token is invalid or expired")
}
challenge, ok := totpLoginChallengeFromCacheValue(value)
if !ok || challenge == nil {
return errors.New("temporary login token is invalid or expired")
}
if challenge.UserID != userID || strings.TrimSpace(challenge.DeviceID) != strings.TrimSpace(deviceID) {
return errors.New("temporary login token does not match the requested login flow")
}
return nil
}
func (s *AuthService) Register(ctx context.Context, req *RegisterRequest) (*UserInfo, error) {
if req == nil {
return nil, errors.New("注册请求不能为空")
@@ -628,6 +758,9 @@ func (s *AuthService) Register(ctx context.Context, req *RegisterRequest) (*User
if err := s.verifyPhoneRegistration(ctx, req); err != nil {
return nil, err
}
if s.emailActivationSvc != nil && req.Email != "" {
return s.RegisterWithActivation(ctx, req)
}
exists, err := s.userRepo.ExistsByUsername(ctx, req.Username)
if err != nil {
@@ -759,11 +892,17 @@ func (s *AuthService) Login(ctx context.Context, req *LoginRequest, ip string) (
// P0-07 安全修复检查是否需要TOTP验证用户启用了TOTP且设备未信任
if s.isTOTPRequiredForLogin(ctx, user, req.DeviceID) {
tempToken, err := s.issueTOTPLoginChallenge(ctx, user, req.DeviceID)
if err != nil {
return nil, err
}
// 返回RequiresTOTP指示前端需要完成TOTP验证
// 前端应调用 /auth/login/totp-verify 接口完成验证
return &LoginResponse{
RequiresTOTP: true,
UserID: user.ID,
TempToken: tempToken,
UserID: user.ID,
}, nil
}
@@ -808,10 +947,13 @@ func (s *AuthService) isTOTPRequiredForLogin(ctx context.Context, user *domain.U
// VerifyTOTPAfterPasswordLogin 完成密码登录后的TOTP验证
// 当用户启用了TOTP但设备未信任时密码登录会返回RequiresTOTP=true
// 前端需要调用此接口完成TOTP验证以获取令牌
func (s *AuthService) VerifyTOTPAfterPasswordLogin(ctx context.Context, userID int64, totpCode, deviceID string) (*LoginResponse, error) {
func (s *AuthService) VerifyTOTPAfterPasswordLogin(ctx context.Context, userID int64, totpCode, deviceID, tempToken string) (*LoginResponse, error) {
if s == nil {
return nil, errors.New("auth service is not initialized")
}
if err := s.validateTOTPLoginChallenge(ctx, userID, deviceID, tempToken); err != nil {
return nil, err
}
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
@@ -827,6 +969,10 @@ func (s *AuthService) VerifyTOTPAfterPasswordLogin(ctx context.Context, userID i
return nil, err
}
if err := s.cache.Delete(ctx, totpChallengePrefix+strings.TrimSpace(tempToken)); err != nil {
return nil, fmt.Errorf("temporary login token cleanup failed: %w", err)
}
// TOTP验证成功返回完整登录响应
return s.generateLoginResponseWithoutRemember(ctx, user)
}
@@ -841,8 +987,11 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*L
if err != nil {
return nil, err
}
if s.IsTokenBlacklisted(ctx, claims.JTI) {
return nil, errors.New("refresh token has been revoked")
if blacklistValue, blacklisted := s.getTokenBlacklistValue(ctx, claims.JTI); blacklisted {
revokedAt, hasRevocationTimestamp := tokenBlacklistRevokedAt(blacklistValue)
if !hasRevocationTimestamp || time.Since(revokedAt) > refreshTokenRetryGrace {
return nil, errors.New("refresh token has been revoked")
}
}
user, err := s.userRepo.GetByID(ctx, claims.UserID)
@@ -861,7 +1010,7 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*L
if claims.ExpiresAt != nil {
remaining := time.Until(claims.ExpiresAt.Time)
if remaining > 0 {
if err := s.cache.Set(ctx, blacklistKey, "1", 5*time.Minute, remaining); err != nil {
if err := s.cache.Set(ctx, blacklistKey, time.Now().UnixNano(), 5*time.Minute, remaining); err != nil {
return nil, fmt.Errorf("token revocation failed: %w", err)
}
}

View File

@@ -69,13 +69,17 @@ func (s *AuthService) RegisterWithActivation(ctx context.Context, req *RegisterR
if s.emailActivationSvc != nil && req.Email != "" {
initialStatus = domain.UserStatusInactive
}
nickname := req.Nickname
if nickname == "" {
nickname = req.Username
}
user := &domain.User{
Username: req.Username,
Email: domain.StrPtr(req.Email),
Phone: domain.StrPtr(req.Phone),
Password: hashedPassword,
Nickname: req.Nickname,
Nickname: nickname,
Status: initialStatus,
}
if err := s.userRepo.Create(ctx, user); err != nil {
@@ -85,10 +89,6 @@ func (s *AuthService) RegisterWithActivation(ctx context.Context, req *RegisterR
s.bestEffortAssignDefaultRoles(ctx, user.ID, "register_with_activation")
if s.emailActivationSvc != nil && req.Email != "" {
nickname := req.Nickname
if nickname == "" {
nickname = req.Username
}
// #nosec G118 - 使用独立上下文避免请求结束后被取消
go func() { // #nosec G118
bgCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)

View File

@@ -375,6 +375,51 @@ func TestAuthService_RegisterWithActivation(t *testing.T) {
})
}
func TestAuthService_Register_UsesEmailActivationFlowWhenConfigured(t *testing.T) {
svc, db := setupAuthEmailTestEnv(t)
ctx := context.Background()
l1Cache := cache.NewL1Cache()
l2Cache := cache.NewRedisCache(false)
cacheManager := cache.NewCacheManager(l1Cache, l2Cache)
emailActivationSvc := service.NewEmailActivationService(
&service.MockEmailProvider{},
cacheManager,
"http://localhost:8080",
"TestSite",
)
svc.SetEmailActivationService(emailActivationSvc)
userInfo, err := svc.Register(ctx, &service.RegisterRequest{
Username: "register_activation_enabled",
Password: "Password123!",
Email: "register-activation-enabled@test.com",
})
if err != nil {
t.Fatalf("Register failed: %v", err)
}
if userInfo == nil {
t.Fatal("Register returned nil user info")
}
if userInfo.Status != domain.UserStatusInactive {
t.Fatalf("Register status = %d, want %d", userInfo.Status, domain.UserStatusInactive)
}
if userInfo.Nickname != "register_activation_enabled" {
t.Fatalf("Register nickname = %q, want %q", userInfo.Nickname, "register_activation_enabled")
}
var storedUser domain.User
if err := db.WithContext(ctx).Where("username = ?", "register_activation_enabled").First(&storedUser).Error; err != nil {
t.Fatalf("load stored user: %v", err)
}
if storedUser.Status != domain.UserStatusInactive {
t.Fatalf("stored user status = %d, want %d", storedUser.Status, domain.UserStatusInactive)
}
if storedUser.Nickname != "register_activation_enabled" {
t.Fatalf("stored user nickname = %q, want %q", storedUser.Nickname, "register_activation_enabled")
}
}
// =============================================================================
// Login By Email Code Extended Tests
// =============================================================================

View File

@@ -3,10 +3,12 @@ package service
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/user-management-system/internal/auth"
"github.com/user-management-system/internal/cache"
"github.com/user-management-system/internal/domain"
"github.com/user-management-system/internal/repository"
"github.com/user-management-system/internal/security"
@@ -359,6 +361,73 @@ func TestBuildDeviceFingerprint(t *testing.T) {
}
}
func TestLogin_IssuesTOTPChallengeTokenWhenSecondFactorIsRequired(t *testing.T) {
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
DriverName: "sqlite",
DSN: fmt.Sprintf("file:login_totp_challenge_%d?mode=memory&cache=shared", time.Now().UnixNano()),
}), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("failed to connect database: %v", err)
}
if err := db.AutoMigrate(&domain.User{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
jwtManager, err := auth.NewJWTWithOptions(auth.JWTOptions{
HS256Secret: "totp-challenge-secret",
AccessTokenExpire: 15 * time.Minute,
RefreshTokenExpire: 7 * 24 * time.Hour,
})
if err != nil {
t.Fatalf("failed to create jwt manager: %v", err)
}
cacheManager := cache.NewCacheManager(cache.NewL1Cache(), cache.NewRedisCache(false))
userRepo := repository.NewUserRepository(db)
svc := NewAuthService(userRepo, nil, jwtManager, cacheManager, 8, 5, 15*time.Minute)
hashedPassword, err := auth.HashPassword("Password123!")
if err != nil {
t.Fatalf("failed to hash password: %v", err)
}
user := &domain.User{
Username: "totpchallenge",
Password: hashedPassword,
Status: domain.UserStatusActive,
TOTPEnabled: true,
TOTPSecret: "JBSWY3DPEHPK3PXP",
}
if err := db.Create(user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
resp, err := svc.Login(context.Background(), &LoginRequest{
Account: "totpchallenge",
Password: "Password123!",
DeviceID: "device-1",
}, "127.0.0.1")
if err != nil {
t.Fatalf("login failed: %v", err)
}
if !resp.RequiresTOTP {
t.Fatalf("expected requires_totp response, got %+v", resp)
}
if resp.UserID != user.ID {
t.Fatalf("expected user id %d, got %d", user.ID, resp.UserID)
}
if strings.TrimSpace(resp.TempToken) == "" {
t.Fatalf("expected temp token when TOTP is required, got %+v", resp)
}
if resp.AccessToken != "" || resp.RefreshToken != "" {
t.Fatalf("expected no full session tokens before TOTP verification, got %+v", resp)
}
}
func TestAuthServiceDefaultConfig(t *testing.T) {
// Test that default configuration is applied correctly
svc := NewAuthService(nil, nil, nil, nil, 0, 0, 0)