fix(auth): normalize email aliases in registration dedup

Registration duplicate checks compared the email verbatim (lowercase +
trim only), so a single inbox could spawn unlimited accounts via provider
alias features: plus addressing (user+tag@gmail.com) and the Gmail dot
trick (u.s.e.r@gmail.com) both deliver to the same mailbox but were seen
as distinct, verifiable addresses. This lets abusers bulk-register to farm
signup grants while the domain whitelist and email verification pass.

Add NormalizeEmailForAliasDedup to collapse these variants to a single
"inbox identity" and use it (via existsByEmailOrAlias) on the three local
email-registration paths: register, send-verify-code, and its async
variant. The exact ExistsByEmail check runs first; only on a miss do we
scan the candidate domains (gmail.com/googlemail.com are mutual aliases)
and compare normalized forms. Lookup errors fail closed, matching the
existing check, so the path cannot be bypassed by inducing errors.

Scoped to registration only — email storage, display, login, and delivery
are unchanged. OAuth-bound emails (already provider-verified) are out of
scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
yian
2026-07-25 19:19:15 +08:00
committed by shaw
co-authored by Claude Fable 5
parent 2e2638c01d
commit b6f9277515
4 changed files with 287 additions and 6 deletions
+33
View File
@@ -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 {
+6 -6
View File
@@ -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
@@ -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
}
@@ -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)
})
}