diff --git a/backend/internal/handler/auth_oauth_pending_flow_test.go b/backend/internal/handler/auth_oauth_pending_flow_test.go index 5bbe1267e9..5e78953829 100644 --- a/backend/internal/handler/auth_oauth_pending_flow_test.go +++ b/backend/internal/handler/auth_oauth_pending_flow_test.go @@ -2978,6 +2978,17 @@ func (r *oauthPendingFlowUserRepo) Create(ctx context.Context, user *service.Use return nil } +func (r *oauthPendingFlowUserRepo) CreateWithEmailAliasGuard(ctx context.Context, user *service.User) error { + aliasExists, err := r.ExistsByEmailAlias(ctx, user.Email) + if err != nil { + return err + } + if aliasExists { + return service.ErrEmailExists + } + return r.Create(ctx, user) +} + func (r *oauthPendingFlowUserRepo) GetByID(ctx context.Context, id int64) (*service.User, error) { entity, err := r.client.User.Get(ctx, id) if err != nil { @@ -3185,6 +3196,20 @@ func (r *oauthPendingFlowUserRepo) ExistsByEmail(ctx context.Context, email stri return count > 0, err } +func (r *oauthPendingFlowUserRepo) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) { + identity := service.NormalizeEmailForAliasDedup(email) + emails, err := r.client.User.Query().Select(dbuser.FieldEmail).Strings(ctx) + if err != nil { + return false, err + } + for _, stored := range emails { + if service.NormalizeEmailForAliasDedup(stored) == identity { + return true, nil + } + } + return false, nil +} + func (r *oauthPendingFlowUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { panic("unexpected RemoveGroupFromAllowedGroups call") } diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 1143449f16..322e2dc043 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -26,6 +26,9 @@ type userHandlerRepoStub struct { } func (s *userHandlerRepoStub) Create(context.Context, *service.User) error { return nil } +func (s *userHandlerRepoStub) CreateWithEmailAliasGuard(context.Context, *service.User) error { + return nil +} func (s *userHandlerRepoStub) GetByID(context.Context, int64) (*service.User, error) { cloned := *s.user return &cloned, nil @@ -97,6 +100,9 @@ func (s *userHandlerRepoStub) BatchUpdateLimits(context.Context, []int64, *int, return 0, nil } func (s *userHandlerRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil } +func (s *userHandlerRepoStub) ExistsByEmailAlias(context.Context, string) (bool, error) { + return false, nil +} func (s *userHandlerRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil } diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index e08ce2f3a6..092573f9d7 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -43,6 +43,16 @@ func newUserRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor) *userRepos } func (r *userRepository) Create(ctx context.Context, userIn *service.User) error { + return r.create(ctx, userIn, false) +} + +// CreateWithEmailAliasGuard 见 service.UserRepository:在邮箱唯一性锁内复查收件箱身份, +// 供注册路径使用。 +func (r *userRepository) CreateWithEmailAliasGuard(ctx context.Context, userIn *service.User) error { + return r.create(ctx, userIn, true) +} + +func (r *userRepository) create(ctx context.Context, userIn *service.User, guardEmailAlias bool) error { if userIn == nil { return nil } @@ -69,11 +79,16 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error } } + lockKeys := []string{normalizedEmailUniquenessLockKey(userIn.Email)} + if guardEmailAlias { + // 别名变体的字面量不同,唯一索引无法兜底;用收件箱身份锁把同一收件箱的并发注册串行化。 + lockKeys = append(lockKeys, emailAliasUniquenessLockKey(userIn.Email)) + } releaseEmailLock, err := lockRepositoryScopedKeys( txCtx, txClient, txAwareSQLExecutor(txCtx, r.sql, r.client), - normalizedEmailUniquenessLockKey(userIn.Email), + lockKeys..., ) if err != nil { return err @@ -84,6 +99,16 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error return err } + if guardEmailAlias { + aliasExists, err := existsByEmailAliasWithClient(txCtx, txClient, userIn.Email) + if err != nil { + return err + } + if aliasExists { + return service.ErrEmailExists + } + } + created, err := txClient.User.Create(). SetEmail(userIn.Email). SetUsername(userIn.Username). @@ -904,6 +929,88 @@ func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool, return r.client.User.Query().Where(userEmailLookupPredicate(email)).Exist(ctx) } +// emailAliasCandidateLimit 限制一次别名查重最多取回的候选行数。探针都以去点后的 +// 本地部分为前缀锚定(见 dotStrippedEmailExpr),正常收件箱的变体只有个位数; +// 上限只是兜底,避免公开未鉴权的注册/发码端点把大表整张读进内存。 +const emailAliasCandidateLimit = 50 + +// ExistsByEmailAlias 见 service.UserRepository。软删除过滤沿用 ExistsByEmail 的默认行为。 +func (r *userRepository) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) { + return existsByEmailAliasWithClient(ctx, clientFromContext(ctx, r.client), email) +} + +func existsByEmailAliasWithClient(ctx context.Context, client *dbent.Client, email string) (bool, error) { + if client == nil { + return false, nil + } + probes := service.EmailAliasDedupProbes(email) + if len(probes) == 0 { + return false, nil + } + + preds := make([]predicate.User, 0, 2*len(probes)) + for _, probe := range probes { + preds = append(preds, + dotStrippedEmailEQ(probe.Local+"@"+probe.Domain), + // "+后缀"的内容未知,只能按前缀匹配。 + dotStrippedEmailLike(escapeLikeWildcards(probe.Local)+"+%@"+escapeLikeWildcards(probe.Domain)), + ) + } + candidates, err := client.User.Query(). + Where(dbuser.Or(preds...)). + Limit(emailAliasCandidateLimit). + Select(dbuser.FieldEmail). + Strings(ctx) + if err != nil { + return false, err + } + + // 探针会有过度匹配(点号只在 Gmail 家族无意义),最终判定必须回到完整归一化规则。 + identity := service.NormalizeEmailForAliasDedup(email) + for _, candidate := range candidates { + if service.NormalizeEmailForAliasDedup(candidate) == identity { + return true, nil + } + } + return false, nil +} + +// dotStrippedEmailExpr 渲染下面的表达式:去掉存量邮箱的大小写、首尾空白(与 +// userEmailLookupPredicate 的精确匹配口径一致,历史数据存在带空白的行)以及全部点号。 +// +// REPLACE(LOWER(TRIM(email)), '.', '') +// +// 两侧都去点,因此一个域名探针即可同时覆盖 Gmail 点号变体与 FQDN 根点(user@gmail.com.)。 +// migrations/190 为同一表达式建了索引。 +func dotStrippedEmailExpr(b *entsql.Builder, s *entsql.Selector) *entsql.Builder { + return b.WriteString("REPLACE(LOWER(TRIM("). + Ident(s.C(dbuser.FieldEmail)). + WriteString(")), '.', '')") +} + +func dotStrippedEmailEQ(value string) predicate.User { + return predicate.User(func(s *entsql.Selector) { + s.Where(entsql.P(func(b *entsql.Builder) { + dotStrippedEmailExpr(b, s).WriteString(" = ").Arg(value) + })) + }) +} + +func dotStrippedEmailLike(pattern string) predicate.User { + return predicate.User(func(s *entsql.Selector) { + s.Where(entsql.P(func(b *entsql.Builder) { + dotStrippedEmailExpr(b, s).WriteString(" LIKE ").Arg(pattern).WriteString(` ESCAPE '\'`) + })) + }) +} + +// escapeLikeWildcards 转义 LIKE 元字符:本地部分合法可含 % 与 _,不转义会扩大匹配面。 +var likeWildcardEscaper = strings.NewReplacer(`\`, `\\`, "%", `\%`, "_", `\_`) + +func escapeLikeWildcards(value string) string { + return likeWildcardEscaper.Replace(value) +} + func ensureNormalizedEmailAvailableWithClient(ctx context.Context, client *dbent.Client, userID int64, email string) error { client = clientFromContext(ctx, client) if client == nil { @@ -951,6 +1058,16 @@ func normalizedEmailUniquenessLockKey(email string) string { return "users:normalized-email:" + normalized } +// emailAliasUniquenessLockKey 按收件箱身份(而非邮箱字面量)加锁,使同一收件箱的不同 +// 别名变体在注册时互斥。 +func emailAliasUniquenessLockKey(email string) string { + identity := service.NormalizeEmailForAliasDedup(email) + if identity == "" { + return "" + } + return "users:email-alias-identity:" + identity +} + func (r *userRepository) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error { client := clientFromContext(ctx, r.client) err := client.UserAllowedGroup.Create(). diff --git a/backend/internal/repository/user_repo_email_alias_test.go b/backend/internal/repository/user_repo_email_alias_test.go new file mode 100644 index 0000000000..6faf7340d2 --- /dev/null +++ b/backend/internal/repository/user_repo_email_alias_test.go @@ -0,0 +1,100 @@ +package repository + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func seedUserForAliasTest(t *testing.T, repo *userRepository, email string) { + t.Helper() + require.NoError(t, repo.Create(context.Background(), &service.User{ + Email: email, + Username: email, + PasswordHash: "hash", + Role: service.RoleUser, + Status: service.StatusActive, + })) +} + +func TestUserRepositoryExistsByEmailAlias(t *testing.T) { + cases := []struct { + name string + stored string + probe string + want bool + }{ + {"same address", "someone@gmail.com", "someone@gmail.com", true}, + {"gmail plus alias", "someone@gmail.com", "someone+bulk294@gmail.com", true}, + {"gmail dot trick", "d.axis.2026@gmail.com", "daxis2026@gmail.com", true}, + {"gmail dot trick both sides", "d.axis.2026@gmail.com", "da.xis.2026@gmail.com", true}, + {"stored plus alias found by canonical form", "someone+tag@gmail.com", "someone@gmail.com", true}, + {"googlemail is a gmail alias", "someone@googlemail.com", "some.one@gmail.com", true}, + {"fqdn root dot on probe", "d.axis.2026@gmail.com", "da.xis.2026@gmail.com.", true}, + {"fqdn root dot on stored row", "d.axis.2026@gmail.com.", "daxis2026@gmail.com", true}, + {"legacy row with spacing and case", " D.Axis.2026@Gmail.com ", "daxis2026@gmail.com", true}, + {"non-gmail plus alias", "first.last@qq.com", "first.last+tag@qq.com", true}, + {"different gmail inbox", "someone@gmail.com", "someoneelse@gmail.com", false}, + {"non-gmail dots are significant", "first.last@qq.com", "firstlast@qq.com", false}, + {"different domain", "someone@gmail.com", "someone@qq.com", false}, + {"distinct plus-prefixed locals", "+alice@gmail.com", "+bob@gmail.com", false}, + {"underscore is not a wildcard", "user_x@qq.com", "userax@qq.com", false}, + {"percent is not a wildcard", "a%b@qq.com", "axxb@qq.com", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo, _ := newUserEntRepo(t) + seedUserForAliasTest(t, repo, tc.stored) + + got, err := repo.ExistsByEmailAlias(context.Background(), tc.probe) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +func TestUserRepositoryExistsByEmailAliasIgnoresMalformedInput(t *testing.T) { + repo, _ := newUserEntRepo(t) + seedUserForAliasTest(t, repo, "someone@gmail.com") + + got, err := repo.ExistsByEmailAlias(context.Background(), "not-an-email") + require.NoError(t, err) + require.False(t, got) +} + +func TestUserRepositoryCreateWithEmailAliasGuard(t *testing.T) { + repo, _ := newUserEntRepo(t) + ctx := context.Background() + seedUserForAliasTest(t, repo, "d.axis.2026@gmail.com") + + // 注册路径:别名变体在唯一性锁内被拒绝。 + err := repo.CreateWithEmailAliasGuard(ctx, &service.User{ + Email: "da.xis.2026+free@googlemail.com", + Username: "alias-variant", + PasswordHash: "hash", + Role: service.RoleUser, + Status: service.StatusActive, + }) + require.ErrorIs(t, err, service.ErrEmailExists) + + // 不同收件箱仍可注册。 + require.NoError(t, repo.CreateWithEmailAliasGuard(ctx, &service.User{ + Email: "other.person@gmail.com", + Username: "other-person", + PasswordHash: "hash", + Role: service.RoleUser, + Status: service.StatusActive, + })) + + // 管理员建号(Create)不受别名限制。 + require.NoError(t, repo.Create(ctx, &service.User{ + Email: "daxis2026+support@gmail.com", + Username: "admin-created", + PasswordHash: "hash", + Role: service.RoleUser, + Status: service.StatusActive, + })) +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 7ab057ef0a..06c738cd3c 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1495,6 +1495,10 @@ func (r *stubUserRepo) Create(ctx context.Context, user *service.User) error { return errors.New("not implemented") } +func (r *stubUserRepo) CreateWithEmailAliasGuard(ctx context.Context, user *service.User) error { + return errors.New("not implemented") +} + func (r *stubUserRepo) GetByID(ctx context.Context, id int64) (*service.User, error) { user, ok := r.users[id] if !ok { @@ -1574,6 +1578,10 @@ func (r *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, e return false, errors.New("not implemented") } +func (r *stubUserRepo) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) { + return false, errors.New("not implemented") +} + func (r *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) { return 0, errors.New("not implemented") } diff --git a/backend/internal/server/middleware/admin_auth_test.go b/backend/internal/server/middleware/admin_auth_test.go index 3c844c6896..f13b094df1 100644 --- a/backend/internal/server/middleware/admin_auth_test.go +++ b/backend/internal/server/middleware/admin_auth_test.go @@ -131,6 +131,10 @@ func (s *stubUserRepo) Create(ctx context.Context, user *service.User) error { panic("unexpected Create call") } +func (s *stubUserRepo) CreateWithEmailAliasGuard(ctx context.Context, user *service.User) error { + panic("unexpected CreateWithEmailAliasGuard call") +} + func (s *stubUserRepo) GetByID(ctx context.Context, id int64) (*service.User, error) { if s.getByID == nil { panic("GetByID not stubbed") @@ -208,6 +212,10 @@ func (s *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, e panic("unexpected ExistsByEmail call") } +func (s *stubUserRepo) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) { + panic("unexpected ExistsByEmailAlias call") +} + func (s *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) { panic("unexpected RemoveGroupFromAllowedGroups call") } diff --git a/backend/internal/service/admin_service_apikey_test.go b/backend/internal/service/admin_service_apikey_test.go index fbd998cade..f809c68ccf 100644 --- a/backend/internal/service/admin_service_apikey_test.go +++ b/backend/internal/service/admin_service_apikey_test.go @@ -33,6 +33,9 @@ func (s *userRepoStubForGroupUpdate) AddGroupToAllowedGroups(_ context.Context, } func (s *userRepoStubForGroupUpdate) Create(context.Context, *User) error { panic("unexpected") } +func (s *userRepoStubForGroupUpdate) CreateWithEmailAliasGuard(context.Context, *User) error { + panic("unexpected") +} func (s *userRepoStubForGroupUpdate) GetByID(context.Context, int64) (*User, error) { panic("unexpected") } @@ -81,6 +84,9 @@ func (s *userRepoStubForGroupUpdate) BatchUpdateLimits(context.Context, []int64, func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) { panic("unexpected") } +func (s *userRepoStubForGroupUpdate) ExistsByEmailAlias(context.Context, string) (bool, error) { + panic("unexpected") +} func (s *userRepoStubForGroupUpdate) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { panic("unexpected") } diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index be64576d41..c1968c98f2 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -13,18 +13,21 @@ import ( ) type userRepoStub struct { - user *User - getErr error - createErr error - deleteErr error - exists bool - existsErr error - nextID int64 - created []*User - updated []*User - deletedIDs []int64 - usersByEmail map[string]*User - getByEmailErr error + user *User + getErr error + createErr error + deleteErr error + exists bool + existsErr error + aliasExists bool + aliasErr error + guardedCreates int + nextID int64 + created []*User + updated []*User + deletedIDs []int64 + usersByEmail map[string]*User + getByEmailErr error } func (s *userRepoStub) Create(ctx context.Context, user *User) error { @@ -43,6 +46,17 @@ func (s *userRepoStub) Create(ctx context.Context, user *User) error { return nil } +func (s *userRepoStub) CreateWithEmailAliasGuard(ctx context.Context, user *User) error { + s.guardedCreates++ + if s.aliasErr != nil { + return s.aliasErr + } + if s.aliasExists { + return ErrEmailExists + } + return s.Create(ctx, user) +} + func (s *userRepoStub) GetByID(ctx context.Context, id int64) (*User, error) { if s.getErr != nil { return nil, s.getErr @@ -144,6 +158,13 @@ func (s *userRepoStub) ExistsByEmail(ctx context.Context, email string) (bool, e return s.exists, nil } +func (s *userRepoStub) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) { + if s.aliasErr != nil { + return false, s.aliasErr + } + return s.aliasExists, nil +} + func (s *userRepoStub) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) { panic("unexpected RemoveGroupFromAllowedGroups call") } diff --git a/backend/internal/service/admin_service_email_identity_sync_test.go b/backend/internal/service/admin_service_email_identity_sync_test.go index 1c6c474968..806f0293a7 100644 --- a/backend/internal/service/admin_service_email_identity_sync_test.go +++ b/backend/internal/service/admin_service_email_identity_sync_test.go @@ -35,6 +35,10 @@ type emailSyncRepoStub struct { replaceErr error } +func (s *emailSyncRepoStub) CreateWithEmailAliasGuard(ctx context.Context, user *User) error { + return s.Create(ctx, user) +} + func (s *emailSyncRepoStub) Create(_ context.Context, user *User) error { if s.nextID != 0 && user.ID == 0 { user.ID = s.nextID @@ -109,6 +113,10 @@ func (s *emailSyncRepoStub) UpdateConcurrency(context.Context, int64, int) error func (s *emailSyncRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil } +func (s *emailSyncRepoStub) ExistsByEmailAlias(context.Context, string) (bool, error) { + return false, nil +} + func (s *emailSyncRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil } diff --git a/backend/internal/service/auth_oauth_email_flow.go b/backend/internal/service/auth_oauth_email_flow.go index 24d0eeee75..af63f7c433 100644 --- a/backend/internal/service/auth_oauth_email_flow.go +++ b/backend/internal/service/auth_oauth_email_flow.go @@ -132,7 +132,8 @@ func (s *AuthService) RegisterOAuthEmailAccount( return nil, nil, err } - existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) + // 含 +别名 / Gmail 点号 / FQDN 根点变体归一化:该路径同样发放注册赠额,不能被单个收件箱刷号。 + existsEmail, err := s.existsByEmailOrAlias(ctx, email) if err != nil { slog.Error("oauth email register: ExistsByEmail failed", "email", email, "error", err.Error()) return nil, nil, ErrServiceUnavailable @@ -159,7 +160,7 @@ func (s *AuthService) RegisterOAuthEmailAccount( SignupSource: signupSource, } - if err := s.userRepo.Create(ctx, user); err != nil { + if err := s.userRepo.CreateWithEmailAliasGuard(ctx, user); err != nil { if errors.Is(err, ErrEmailExists) { return nil, nil, ErrEmailExists } @@ -211,7 +212,8 @@ func (s *AuthService) RegisterVerifiedOAuthEmailAccount( return nil, nil, err } - existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) + // 与本地注册同口径:同一收件箱的别名变体不能各自建号(该路径也发放注册赠额)。 + existsEmail, err := s.existsByEmailOrAlias(ctx, email) if err != nil { return nil, nil, ErrServiceUnavailable } @@ -241,7 +243,7 @@ func (s *AuthService) RegisterVerifiedOAuthEmailAccount( SignupSource: signupSource, } - if err := s.userRepo.Create(ctx, user); err != nil { + if err := s.userRepo.CreateWithEmailAliasGuard(ctx, user); err != nil { if errors.Is(err, ErrEmailExists) { return nil, nil, ErrEmailExists } diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 6f97b1e85f..8ce1711ce1 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 @@ -224,7 +224,7 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw Status: StatusActive, } - if err := s.userRepo.Create(ctx, user); err != nil { + if err := s.userRepo.CreateWithEmailAliasGuard(ctx, user); err != nil { // 优先检查邮箱冲突错误(竞态条件下可能发生) if errors.Is(err, ErrEmailExists) { return "", nil, ErrEmailExists @@ -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/auth_service_email_bind_test.go b/backend/internal/service/auth_service_email_bind_test.go index eb6a35199b..deeda002df 100644 --- a/backend/internal/service/auth_service_email_bind_test.go +++ b/backend/internal/service/auth_service_email_bind_test.go @@ -874,6 +874,10 @@ func newEmailBindUserRepoStub(user *service.User) *emailBindUserRepoStub { func (s *emailBindUserRepoStub) Create(context.Context, *service.User) error { return nil } +func (s *emailBindUserRepoStub) CreateWithEmailAliasGuard(ctx context.Context, user *service.User) error { + return s.Create(ctx, user) +} + func (s *emailBindUserRepoStub) GetByID(_ context.Context, id int64) (*service.User, error) { s.mu.Lock() defer s.mu.Unlock() @@ -957,6 +961,18 @@ func (s *emailBindUserRepoStub) ExistsByEmail(_ context.Context, email string) ( return ok, nil } +func (s *emailBindUserRepoStub) ExistsByEmailAlias(_ context.Context, email string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + identity := service.NormalizeEmailForAliasDedup(email) + for stored := range s.usersByEmail { + if service.NormalizeEmailForAliasDedup(stored) == identity { + return true, nil + } + } + return false, nil +} + func (s *emailBindUserRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index af5bdff3e6..eeb5f3b0a4 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -365,6 +365,30 @@ func TestAuthService_Register_EmailExists(t *testing.T) { require.ErrorIs(t, err, ErrEmailExists) } +func TestAuthService_Register_AliasDuplicateRejected(t *testing.T) { + repo := &userRepoStub{aliasExists: true} + service := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + }, nil, nil) + + _, _, err := service.Register(context.Background(), "some.one+bulk294@gmail.com", "password") + require.ErrorIs(t, err, ErrEmailExists) + require.Empty(t, repo.created) +} + +func TestAuthService_Register_UsesAliasGuardedCreate(t *testing.T) { + // 注册必须走带别名兜底的创建路径:服务层前置查重与写入之间存在竞态窗口。 + repo := &userRepoStub{nextID: 91} + service := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + }, nil, nil) + + _, user, err := service.Register(context.Background(), "newuser@gmail.com", "password") + require.NoError(t, err) + require.NotNil(t, user) + require.Equal(t, 1, repo.guardedCreates) +} + func TestAuthService_Register_CheckEmailError(t *testing.T) { repo := &userRepoStub{existsErr: errors.New("db down")} service := newAuthService(repo, map[string]string{ diff --git a/backend/internal/service/content_moderation_test.go b/backend/internal/service/content_moderation_test.go index f4e3aeb00d..4dad7c39ee 100644 --- a/backend/internal/service/content_moderation_test.go +++ b/backend/internal/service/content_moderation_test.go @@ -168,6 +168,10 @@ func (r *contentModerationTestUserRepo) Create(ctx context.Context, user *User) panic("unexpected Create call") } +func (r *contentModerationTestUserRepo) CreateWithEmailAliasGuard(ctx context.Context, user *User) error { + panic("unexpected CreateWithEmailAliasGuard call") +} + func (r *contentModerationTestUserRepo) GetByID(ctx context.Context, id int64) (*User, error) { if r.user == nil { return nil, ErrUserNotFound @@ -257,6 +261,10 @@ func (r *contentModerationTestUserRepo) ExistsByEmail(ctx context.Context, email panic("unexpected ExistsByEmail call") } +func (r *contentModerationTestUserRepo) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) { + panic("unexpected ExistsByEmailAlias call") +} + func (r *contentModerationTestUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) { panic("unexpected RemoveGroupFromAllowedGroups call") } diff --git a/backend/internal/service/registration_email_alias.go b/backend/internal/service/registration_email_alias.go new file mode 100644 index 0000000000..297a50dcd5 --- /dev/null +++ b/backend/internal/service/registration_email_alias.go @@ -0,0 +1,142 @@ +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. +// - FQDN root dot: user@gmail.com. is the absolute form of user@gmail.com, +// passes the registration validator, and reaches the same mailbox. +// +// 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, drop the FQDN root dot, 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 := splitEmailForAliasDedup(email) + if !ok { + return strings.ToLower(strings.TrimSpace(email)) + } + local = stripEmailPlusSuffix(local) + if isGmailFamilyDomain(domain) { + local = stripEmailLocalDots(local) + domain = "gmail.com" + } + return local + "@" + domain +} + +// EmailAliasProbe describes a shape a stored duplicate can have, expressed on the +// dot-stripped email form used by UserRepository.ExistsByEmailAlias: Local is the +// plus-stripped and dot-stripped local part, Domain the dot-stripped candidate +// domain. Dots are removed on both sides of that comparison so one probe per +// domain also covers the Gmail dot trick and the FQDN root dot. The over-matching +// this introduces (dots stay significant outside the Gmail family) is filtered by +// re-checking every candidate with NormalizeEmailForAliasDedup. +type EmailAliasProbe struct { + Local string + Domain string +} + +// EmailAliasDedupProbes returns the probes covering every stored address that +// could resolve to the same inbox as email: gmail-family domains are mutual +// aliases, every other domain only collides with itself. It returns nil when +// there is nothing to probe (malformed address, or a local part made of dots +// only, which no provider delivers). +func EmailAliasDedupProbes(email string) []EmailAliasProbe { + local, domain, ok := splitEmailForAliasDedup(email) + if !ok { + return nil + } + probeLocal := strings.ReplaceAll(stripEmailPlusSuffix(local), ".", "") + if probeLocal == "" { + return nil + } + domains := []string{domain} + if isGmailFamilyDomain(domain) { + domains = []string{"gmail.com", "googlemail.com"} + } + probes := make([]EmailAliasProbe, 0, len(domains)) + for _, candidate := range domains { + probes = append(probes, EmailAliasProbe{ + Local: probeLocal, + Domain: strings.ReplaceAll(candidate, ".", ""), + }) + } + return probes +} + +func splitEmailForAliasDedup(email string) (local string, domain string, ok bool) { + local, domain, ok = splitEmailForPolicy(email) + if !ok { + return "", "", false + } + domain = strings.TrimRight(domain, ".") + if domain == "" { + return "", "", false + } + return local, domain, true +} + +func stripEmailPlusSuffix(local string) string { + // idx > 0 only: "+tag@host" has no local part left to keep, and folding every + // "+x@host" into "@host" would lock unrelated senders out of that domain. + if idx := strings.IndexByte(local, '+'); idx > 0 { + return local[:idx] + } + return local +} + +func stripEmailLocalDots(local string) string { + if stripped := strings.ReplaceAll(local, ".", ""); stripped != "" { + return stripped + } + return local +} + +func isGmailFamilyDomain(domain string) bool { + _, ok := gmailFamilyDomains[domain] + return ok +} + +// 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, probes +// for an alias collision. Consistent with ExistsByEmail, lookup errors are +// surfaced (fail-closed) so the registration path returns a service error instead +// of letting an attacker bypass the check by inducing errors. +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 + } + return s.userRepo.ExistsByEmailAlias(ctx, email) +} 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..75ef42902b --- /dev/null +++ b/backend/internal/service/registration_email_alias_test.go @@ -0,0 +1,164 @@ +//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"}, + {"fqdn root dot dropped", "d.axis.2026@gmail.com.", "daxis2026@gmail.com"}, + {"fqdn root dot on other domain", "first.last@qq.com.", "first.last@qq.com"}, + {"leading plus keeps local part", "+alice@gmail.com", "+alice@gmail.com"}, + {"dot-only local part kept", "...@gmail.com", "...@gmail.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 TestNormalizeEmailForAliasDedupKeepsDistinctInboxes(t *testing.T) { + // 剥离 "+后缀" 不能把同域下不同用户折叠成同一身份。 + require.NotEqual(t, + NormalizeEmailForAliasDedup("+alice@gmail.com"), + NormalizeEmailForAliasDedup("+bob@gmail.com"), + ) + require.NotEqual(t, + NormalizeEmailForAliasDedup("alice@gmail.com"), + NormalizeEmailForAliasDedup("bob@gmail.com"), + ) +} + +func TestEmailAliasDedupProbes(t *testing.T) { + require.ElementsMatch(t, + []EmailAliasProbe{{Local: "someone", Domain: "gmailcom"}, {Local: "someone", Domain: "googlemailcom"}}, + EmailAliasDedupProbes("Some.One+tag@gmail.com"), + ) + require.ElementsMatch(t, + []EmailAliasProbe{{Local: "daxis2026", Domain: "gmailcom"}, {Local: "daxis2026", Domain: "googlemailcom"}}, + EmailAliasDedupProbes("d.axis.2026@googlemail.com."), + ) + require.Equal(t, + []EmailAliasProbe{{Local: "firstlast", Domain: "qqcom"}}, + EmailAliasDedupProbes("first.last+tag@qq.com"), + ) + require.Nil(t, EmailAliasDedupProbes("not-an-email")) + require.Nil(t, EmailAliasDedupProbes("...@gmail.com")) +} + +// 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 + stored []string + aliasErr error + aliasChecks []string +} + +func (s *aliasDedupRepoStub) ExistsByEmail(context.Context, string) (bool, error) { + return s.exists, s.existsErr +} + +func (s *aliasDedupRepoStub) ExistsByEmailAlias(_ context.Context, email string) (bool, error) { + s.aliasChecks = append(s.aliasChecks, email) + if s.aliasErr != nil { + return false, s.aliasErr + } + identity := NormalizeEmailForAliasDedup(email) + for _, candidate := range s.stored { + if NormalizeEmailForAliasDedup(candidate) == identity { + return true, nil + } + } + return false, 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.aliasChecks, "no alias probe expected after exact hit") + }) + + t.Run("plus alias variant detected", func(t *testing.T) { + repo := &aliasDedupRepoStub{stored: []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{stored: []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("fqdn root dot variant detected", func(t *testing.T) { + repo := &aliasDedupRepoStub{stored: []string{"d.axis.2026@gmail.com"}} + svc := &AuthService{userRepo: repo} + got, err := svc.existsByEmailOrAlias(ctx, "da.xis.2026@gmail.com.") + require.NoError(t, err) + require.True(t, got) + }) + + t.Run("different inbox allowed", func(t *testing.T) { + repo := &aliasDedupRepoStub{stored: []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("distinct plus-prefixed locals allowed", func(t *testing.T) { + repo := &aliasDedupRepoStub{stored: []string{"+alice@gmail.com"}} + svc := &AuthService{userRepo: repo} + got, err := svc.existsByEmailOrAlias(ctx, "+bob@gmail.com") + require.NoError(t, err) + require.False(t, got) + }) + + t.Run("alias probe error fails closed", func(t *testing.T) { + repo := &aliasDedupRepoStub{aliasErr: 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) + }) +} diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index ae630559fb..27c2db8edc 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -85,6 +85,11 @@ type UserListFilters struct { type UserRepository interface { Create(ctx context.Context, user *User) error + // CreateWithEmailAliasGuard 创建用户,并在邮箱唯一性锁内复查"收件箱身份"是否已被占用 + // (+别名 / Gmail 点号 / FQDN 根点变体,见 NormalizeEmailForAliasDedup), + // 冲突时返回 ErrEmailExists。仅注册路径使用:同一收件箱的多个别名变体并发注册时, + // 服务层的前置查重会同时通过,必须由这里串行化兜底。管理员建号仍走 Create,不受限制。 + CreateWithEmailAliasGuard(ctx context.Context, user *User) error GetByID(ctx context.Context, id int64) (*User, error) // GetByIDIncludeDeleted 绕过软删除过滤按 ID 取用户(含已删)。仅供管理员审计/usage 点击使用。 GetByIDIncludeDeleted(ctx context.Context, id int64) (*User, error) @@ -109,6 +114,9 @@ type UserRepository interface { BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) ExistsByEmail(ctx context.Context, email string) (bool, error) + // ExistsByEmailAlias 判断是否已有账号与该邮箱指向同一收件箱(+别名 / Gmail 点号 / + // FQDN 根点变体,见 NormalizeEmailForAliasDedup)。用于注册与发送验证码前的查重。 + ExistsByEmailAlias(ctx context.Context, email string) (bool, error) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) // AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups(幂等,冲突忽略) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index e667377d55..adbb54ed3a 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -90,7 +90,8 @@ func (m *mockUserSettingRepo) Delete(context.Context, string) error { panic("unexpected Delete call") } -func (m *mockUserRepo) Create(context.Context, *User) error { return nil } +func (m *mockUserRepo) Create(context.Context, *User) error { return nil } +func (m *mockUserRepo) CreateWithEmailAliasGuard(context.Context, *User) error { return nil } func (m *mockUserRepo) GetByID(ctx context.Context, _ int64) (*User, error) { if m.getByIDErr != nil { return nil, m.getByIDErr @@ -202,6 +203,9 @@ func (m *mockUserRepo) DeductBalance(ctx context.Context, id int64, amount float } func (m *mockUserRepo) UpdateConcurrency(context.Context, int64, int) error { return nil } func (m *mockUserRepo) ExistsByEmail(context.Context, string) (bool, error) { return false, nil } +func (m *mockUserRepo) ExistsByEmailAlias(context.Context, string) (bool, error) { + return false, nil +} func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil } diff --git a/backend/migrations/190_add_users_email_alias_dedup_index_notx.sql b/backend/migrations/190_add_users_email_alias_dedup_index_notx.sql new file mode 100644 index 0000000000..8606618c81 --- /dev/null +++ b/backend/migrations/190_add_users_email_alias_dedup_index_notx.sql @@ -0,0 +1,8 @@ +-- Registration alias dedup (repository.existsByEmailAliasWithClient) probes users +-- by the dot-stripped email form, on the public register / send-verify-code paths. +-- Index that exact expression so the probes stay index lookups instead of a +-- sequential scan. text_pattern_ops serves both the equality probe and the +-- "local+%@domain" prefix probe regardless of database collation. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email_dot_stripped + ON users ((REPLACE(LOWER(TRIM(email)), '.', '')) text_pattern_ops) + WHERE deleted_at IS NULL;