diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index e08ce2f3a6..31f3261c49 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -904,6 +904,39 @@ func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool, return r.client.User.Query().Where(userEmailLookupPredicate(email)).Exist(ctx) } +// ListEmailsByDomains returns the emails of all users whose domain matches one +// of the given domains (case-insensitive). It is used by registration alias +// dedup (service.existsByEmailOrAlias) to scan the candidate set for plus / +// dot-trick collisions. Soft-delete filtering follows the same default as +// ExistsByEmail (via r.client.User.Query()). +func (r *userRepository) ListEmailsByDomains(ctx context.Context, domains []string) ([]string, error) { + if len(domains) == 0 { + return nil, nil + } + preds := make([]predicate.User, 0, len(domains)) + for _, domain := range domains { + suffix := "@" + strings.ToLower(strings.TrimSpace(domain)) + if suffix == "@" { + continue + } + preds = append(preds, predicate.User(func(s *entsql.Selector) { + s.Where(entsql.P(func(b *entsql.Builder) { + b.WriteString("LOWER("). + Ident(s.C(dbuser.FieldEmail)). + WriteString(") LIKE "). + Arg("%" + suffix) + })) + })) + } + if len(preds) == 0 { + return nil, nil + } + return r.client.User.Query(). + Where(dbuser.Or(preds...)). + Select(dbuser.FieldEmail). + Strings(ctx) +} + func ensureNormalizedEmailAvailableWithClient(ctx context.Context, client *dbent.Client, userID int64, email string) error { client = clientFromContext(ctx, client) if client == nil { diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 6f97b1e85f..45c8e9a092 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -189,8 +189,8 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw } } - // 检查邮箱是否已存在 - existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) + // 检查邮箱是否已存在(含 +别名 / Gmail 点号变体归一化,防止单个收件箱批量派生注册) + existsEmail, err := s.existsByEmailOrAlias(ctx, email) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Database error checking email exists: %v", err) return "", nil, ErrServiceUnavailable @@ -296,8 +296,8 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string, locale . return err } - // 检查邮箱是否已存在 - existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) + // 检查邮箱是否已存在(含 +别名 / Gmail 点号变体归一化,防止单个收件箱批量派生注册) + existsEmail, err := s.existsByEmailOrAlias(ctx, email) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Database error checking email exists: %v", err) return ErrServiceUnavailable @@ -337,8 +337,8 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string, loc return nil, err } - // 检查邮箱是否已存在 - existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) + // 检查邮箱是否已存在(含 +别名 / Gmail 点号变体归一化;在发信前拦截,避免批量脚本消耗发信配额) + existsEmail, err := s.existsByEmailOrAlias(ctx, email) if err != nil { logger.LegacyPrintf("service.auth", "[Auth] Database error checking email exists: %v", err) return nil, ErrServiceUnavailable diff --git a/backend/internal/service/registration_email_alias.go b/backend/internal/service/registration_email_alias.go new file mode 100644 index 0000000000..a7c9a07c2f --- /dev/null +++ b/backend/internal/service/registration_email_alias.go @@ -0,0 +1,109 @@ +package service + +import ( + "context" + "strings" +) + +// Email alias normalization for registration dedup. +// +// Problem: registration duplicate checks compare the email verbatim (lowercase +// + trim only), so a single real inbox can spawn unlimited accounts using +// provider alias features: +// - Plus addressing: user+tag@gmail.com is delivered to user@gmail.com +// (supported by Gmail, Outlook/Hotmail, Yahoo, iCloud, Fastmail, and more). +// - Gmail dot trick: u.s.e.r@gmail.com is delivered to user@gmail.com. +// +// This lets abusers bulk-register accounts (e.g. to farm signup grants) while +// the domain whitelist and email verification see each variant as a distinct, +// deliverable address. NormalizeEmailForAliasDedup collapses these variants to +// a single "inbox identity" so the registration path can reject duplicates. +// +// Normalization rules: +// - All domains: lowercase, trim, and strip the local-part "+suffix". +// Stripping the plus suffix on domains that do not support plus addressing +// is harmless — those exact addresses are virtually never registered. +// - Gmail family (gmail.com / googlemail.com): additionally remove dots from +// the local part and fold the domain to gmail.com. +// +// This only affects registration duplicate detection. It intentionally does not +// change how emails are stored, displayed, or used for login/delivery. + +var gmailFamilyDomains = map[string]struct{}{ + "gmail.com": {}, + "googlemail.com": {}, +} + +// NormalizeEmailForAliasDedup returns the canonical "inbox identity" of an +// email. Malformed input is returned lowercased/trimmed unchanged; format +// validation is the caller's responsibility. +func NormalizeEmailForAliasDedup(email string) string { + local, domain, ok := splitEmailForPolicy(email) + if !ok { + return strings.ToLower(strings.TrimSpace(email)) + } + if idx := strings.IndexByte(local, '+'); idx >= 0 { + local = local[:idx] + } + if _, isGmail := gmailFamilyDomains[domain]; isGmail { + local = strings.ReplaceAll(local, ".", "") + domain = "gmail.com" + } + return local + "@" + domain +} + +// aliasDedupCandidateDomains returns the stored domains to scan when checking +// for an alias collision: gmail-family domains are mutual aliases, every other +// domain only collides with itself. +func aliasDedupCandidateDomains(email string) []string { + _, domain, ok := splitEmailForPolicy(email) + if !ok { + return nil + } + if _, isGmail := gmailFamilyDomains[domain]; isGmail { + return []string{"gmail.com", "googlemail.com"} + } + return []string{domain} +} + +// emailAliasLookupRepo is an optional capability of UserRepository, declared at +// the point of use so the core interface (and its test doubles) stay untouched. +type emailAliasLookupRepo interface { + ListEmailsByDomains(ctx context.Context, domains []string) ([]string, error) +} + +// existsByEmailOrAlias reports whether an email — or any alias variant that +// resolves to the same inbox — is already registered. +// +// It first performs the exact ExistsByEmail check, then, only on a miss, scans +// the candidate domains for an alias collision. Consistent with ExistsByEmail, +// a lookup error is surfaced (fail-closed) so the registration path returns a +// service error rather than letting an attacker bypass the check by inducing +// errors. Repositories without the alias-lookup capability degrade to the exact +// check. +func (s *AuthService) existsByEmailOrAlias(ctx context.Context, email string) (bool, error) { + exists, err := s.userRepo.ExistsByEmail(ctx, email) + if err != nil || exists { + return exists, err + } + + repo, ok := s.userRepo.(emailAliasLookupRepo) + if !ok { + return false, nil + } + domains := aliasDedupCandidateDomains(email) + if len(domains) == 0 { + return false, nil + } + existing, err := repo.ListEmailsByDomains(ctx, domains) + if err != nil { + return false, err + } + normalized := NormalizeEmailForAliasDedup(email) + for _, candidate := range existing { + if NormalizeEmailForAliasDedup(candidate) == normalized { + return true, nil + } + } + return false, nil +} diff --git a/backend/internal/service/registration_email_alias_test.go b/backend/internal/service/registration_email_alias_test.go new file mode 100644 index 0000000000..f93d507aa4 --- /dev/null +++ b/backend/internal/service/registration_email_alias_test.go @@ -0,0 +1,139 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeEmailForAliasDedup(t *testing.T) { + cases := []struct { + name string + email string + want string + }{ + {"plain", "user@example.com", "user@example.com"}, + {"uppercase and spaces", " User@Example.COM ", "user@example.com"}, + {"plus alias stripped", "user+tag@example.com", "user@example.com"}, + {"gmail plus alias", "someone+bulk294@gmail.com", "someone@gmail.com"}, + {"gmail dots removed", "some.one@gmail.com", "someone@gmail.com"}, + {"gmail dots and plus", "s.o.m.e+x@gmail.com", "some@gmail.com"}, + {"googlemail folded to gmail", "user@googlemail.com", "user@gmail.com"}, + {"non-gmail keeps dots", "first.last@qq.com", "first.last@qq.com"}, + {"invalid keeps lowered raw", "not-an-email", "not-an-email"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, NormalizeEmailForAliasDedup(tc.email)) + }) + } +} + +func TestAliasDedupCandidateDomains(t *testing.T) { + require.ElementsMatch(t, []string{"gmail.com", "googlemail.com"}, aliasDedupCandidateDomains("user@gmail.com")) + require.ElementsMatch(t, []string{"gmail.com", "googlemail.com"}, aliasDedupCandidateDomains("user@googlemail.com")) + require.Equal(t, []string{"qq.com"}, aliasDedupCandidateDomains("user@qq.com")) + require.Nil(t, aliasDedupCandidateDomains("not-an-email")) +} + +// aliasDedupRepoStub implements only the methods alias dedup uses; other +// UserRepository methods come from the embedded nil interface (a wrong call +// would panic, failing the test). +type aliasDedupRepoStub struct { + UserRepository + exists bool + existsErr error + emails []string + listErr error + scanned [][]string +} + +func (s *aliasDedupRepoStub) ExistsByEmail(context.Context, string) (bool, error) { + return s.exists, s.existsErr +} + +func (s *aliasDedupRepoStub) ListEmailsByDomains(_ context.Context, domains []string) ([]string, error) { + s.scanned = append(s.scanned, domains) + return s.emails, s.listErr +} + +// exactOnlyRepoStub only supports the exact check (no alias-lookup capability). +type exactOnlyRepoStub struct { + UserRepository + exists bool +} + +func (s *exactOnlyRepoStub) ExistsByEmail(context.Context, string) (bool, error) { + return s.exists, nil +} + +func TestExistsByEmailOrAlias(t *testing.T) { + ctx := context.Background() + + t.Run("exact duplicate short-circuits", func(t *testing.T) { + repo := &aliasDedupRepoStub{exists: true} + svc := &AuthService{userRepo: repo} + got, err := svc.existsByEmailOrAlias(ctx, "user@gmail.com") + require.NoError(t, err) + require.True(t, got) + require.Empty(t, repo.scanned, "no alias scan expected after exact hit") + }) + + t.Run("plus alias variant detected", func(t *testing.T) { + repo := &aliasDedupRepoStub{emails: []string{"someone+bulk294@gmail.com"}} + svc := &AuthService{userRepo: repo} + got, err := svc.existsByEmailOrAlias(ctx, "Someone@gmail.com") + require.NoError(t, err) + require.True(t, got) + }) + + t.Run("gmail dot variant detected", func(t *testing.T) { + repo := &aliasDedupRepoStub{emails: []string{"some.one@gmail.com"}} + svc := &AuthService{userRepo: repo} + got, err := svc.existsByEmailOrAlias(ctx, "someone@gmail.com") + require.NoError(t, err) + require.True(t, got) + }) + + t.Run("gmail scans both gmail-family domains", func(t *testing.T) { + repo := &aliasDedupRepoStub{} + svc := &AuthService{userRepo: repo} + _, err := svc.existsByEmailOrAlias(ctx, "user@googlemail.com") + require.NoError(t, err) + require.Len(t, repo.scanned, 1) + require.ElementsMatch(t, []string{"gmail.com", "googlemail.com"}, repo.scanned[0]) + }) + + t.Run("different inbox allowed", func(t *testing.T) { + repo := &aliasDedupRepoStub{emails: []string{"other@gmail.com"}} + svc := &AuthService{userRepo: repo} + got, err := svc.existsByEmailOrAlias(ctx, "user@gmail.com") + require.NoError(t, err) + require.False(t, got) + }) + + t.Run("list error fails closed", func(t *testing.T) { + repo := &aliasDedupRepoStub{listErr: errors.New("db down")} + svc := &AuthService{userRepo: repo} + _, err := svc.existsByEmailOrAlias(ctx, "user@gmail.com") + require.Error(t, err) + }) + + t.Run("exact check error propagates", func(t *testing.T) { + repo := &aliasDedupRepoStub{existsErr: errors.New("db down")} + svc := &AuthService{userRepo: repo} + _, err := svc.existsByEmailOrAlias(ctx, "user@gmail.com") + require.Error(t, err) + }) + + t.Run("repo without capability falls back to exact check", func(t *testing.T) { + svc := &AuthService{userRepo: &exactOnlyRepoStub{exists: false}} + got, err := svc.existsByEmailOrAlias(ctx, "user@gmail.com") + require.NoError(t, err) + require.False(t, got) + }) +}