mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 15:57:53 +08:00
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:
committed by
shaw
co-authored by
Claude Fable 5
parent
2e2638c01d
commit
b6f9277515
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user