diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 148664b3..203ba690 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -200,13 +200,15 @@ func (h *AuthHandler) SendVerifyCode(c *gin.Context) { return } + clientIP := ip.GetClientIP(c) + // Turnstile 验证 - if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil { + if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, clientIP); err != nil { response.ErrorFrom(c, err) return } - result, err := h.authService.SendVerifyCodeAsync(c.Request.Context(), req.Email) + result, err := h.authService.SendVerifyCodeAsync(c.Request.Context(), req.Email, clientIP) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index ec7d3f86..d07240d0 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -1113,3 +1113,26 @@ func (r *userRepository) DisableTotp(ctx context.Context, userID int64) error { } return nil } + +// CountByRegistrationIP 统计指定 IP 注册的用户数量 +func (r *userRepository) CountByRegistrationIP(ctx context.Context, ip string) (int, error) { + if strings.TrimSpace(ip) == "" { + return 0, nil + } + rows, err := r.sql.QueryContext(ctx, + `SELECT COUNT(*) FROM users WHERE register_ip_address = $1 AND deleted_at IS NULL`, + ip, + ) + if err != nil { + return 0, err + } + defer rows.Close() + + var count int + if rows.Next() { + if err := rows.Scan(&count); err != nil { + return 0, err + } + } + return count, nil +} diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 4f5bc5fc..7e4d1512 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -311,8 +311,9 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error { } // SendVerifyCodeAsync 异步发送邮箱验证码并返回倒计时 -func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*SendVerifyCodeResult, error) { - logger.LegacyPrintf("service.auth", "[Auth] SendVerifyCodeAsync called for email: %s", email) +// clientIP 用于检查同一 IP 注册账号数量限制 +func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string, clientIP string) (*SendVerifyCodeResult, error) { + logger.LegacyPrintf("service.auth", "[Auth] SendVerifyCodeAsync called for email: %s, ip: %s", email, clientIP) // 检查是否开放注册(默认关闭) if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { @@ -338,6 +339,28 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*S return nil, ErrEmailExists } + // 检查 Gmail 别名邮箱(含 + 或本地部分含 . 的),静默假装发送成功 + if isGmailAliasEmail(email) { + logger.LegacyPrintf("service.auth", "[Auth] Gmail alias email detected: %s, returning fake success", email) + return &SendVerifyCodeResult{ + Countdown: 60, + }, nil + } + + // 检查同一 IP 注册账号数量(>=2 则静默假装发送成功,不实际发送) + if clientIP != "" { + ipRegCount, err := s.userRepo.CountByRegistrationIP(ctx, clientIP) + if err != nil { + logger.LegacyPrintf("service.auth", "[Auth] Failed to count users by registration IP: %v", err) + // 查询失败不阻塞,继续正常流程 + } else if ipRegCount >= 2 { + logger.LegacyPrintf("service.auth", "[Auth] IP %s already registered %d accounts, returning fake success", clientIP, ipRegCount) + return &SendVerifyCodeResult{ + Countdown: 60, + }, nil + } + } + // 检查邮件队列服务是否配置 if s.emailQueueService == nil { logger.LegacyPrintf("service.auth", "%s", "[Auth] Email queue service not configured") @@ -1092,6 +1115,22 @@ func isReservedEmail(email string) bool { strings.HasSuffix(normalized, WeChatConnectSyntheticEmailDomain) } +// isGmailAliasEmail 检测 Gmail 别名邮箱 +// Gmail 支持两种别名方式: +// 1. 加号别名:user+anything@gmail.com -> user@gmail.com +// 2. 点号忽略:u.s.e.r@gmail.com -> user@gmail.com +// 为防止滥用注册,检测到这类邮箱时返回 true +func isGmailAliasEmail(email string) bool { + normalized := strings.ToLower(strings.TrimSpace(email)) + if !strings.HasSuffix(normalized, "@gmail.com") { + return false + } + // 提取本地部分(@前面的部分) + localPart := strings.TrimSuffix(normalized, "@gmail.com") + // 检查是否包含 + 或 . + return strings.Contains(localPart, "+") || strings.Contains(localPart, ".") +} + // GenerateToken 生成JWT access token // 使用新的access_token_expire_minutes配置项(如果配置了),否则回退到expire_hour func (s *AuthService) GenerateToken(user *User) (string, error) { diff --git a/backend/internal/service/content_moderation_test.go b/backend/internal/service/content_moderation_test.go index cef5127e..8bbeb9dc 100644 --- a/backend/internal/service/content_moderation_test.go +++ b/backend/internal/service/content_moderation_test.go @@ -231,6 +231,10 @@ func (r *contentModerationTestUserRepo) DisableTotp(ctx context.Context, userID panic("unexpected DisableTotp call") } +func (r *contentModerationTestUserRepo) CountByRegistrationIP(ctx context.Context, ip string) (int, error) { + return 0, nil +} + type contentModerationTestAuthCacheInvalidator struct { userIDs []int64 } diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index f84e6f0a..522627d6 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -111,6 +111,9 @@ type UserRepository interface { UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error EnableTotp(ctx context.Context, userID int64) error DisableTotp(ctx context.Context, userID int64) error + + // CountByRegistrationIP 统计指定 IP 注册的用户数量 + CountByRegistrationIP(ctx context.Context, ip string) (int, error) } type UserAuthIdentityRecord struct {