From bc3acd6e2882da8f594bc610254d6ff1cbd61fae Mon Sep 17 00:00:00 2001 From: shaw Date: Sat, 25 Jul 2026 19:40:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E6=94=B6=E7=B4=A7=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E5=88=AB=E5=90=8D=E6=9F=A5=E9=87=8D=EF=BC=88=E6=A0=B9?= =?UTF-8?q?=E7=82=B9=E7=BB=95=E8=BF=87=20/=20=E8=AF=AF=E6=8B=92=20/=20?= =?UTF-8?q?=E6=97=A0=E7=95=8C=E6=89=AB=E6=8F=8F=20/=20=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E7=AB=9E=E6=80=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对 #4814 的审计跟进修复: - 域名尾随点绕过:user@gmail.com. 的域名不在 gmail 家族名单内,点号折叠与 googlemail 归一被整体跳过,别名刷号原样可复现。归一化入口统一去掉 FQDN 根点。 - 误拒合法用户:剥 "+后缀" 缺空串守卫,+alice@ 与 +bob@ 都折叠成 @domain, 该域后续 "+x@" 注册会永久 EMAIL_EXISTS 且无自助恢复。改为仅当 "+" 不在首位时剥离。 - 无界不可索引全表扫描:原实现按 LOWER(email) LIKE '%@domain' 把整域邮箱读进内存, 且挂在公开未鉴权的 send-verify-code 上。改为按去点邮箱 REPLACE(LOWER(TRIM(email)), '.', '') 做等值 + "local+%@domain" 前缀探针并带 LIMIT, 新增同表达式的部分索引(migrations/190)。TRIM 口径与既有精确匹配一致, 历史带首尾空白的行同样命中;LIKE 元字符转义,% 与 _ 不会扩大匹配面。 - 并发竞态:注册改走 CreateWithEmailAliasGuard,在邮箱唯一性锁上追加收件箱身份锁并在 锁内复查,避免同一收件箱的多个别名变体同时通过服务层前置查重。管理员建号仍走 Create,不受别名限制。 - 能力断言静默 fail-open:别名查重方法上提到 UserRepository 端口(编译期强制), 移除可选接口类型断言与静默降级分支。 - OAuth 邮箱注册的两条建号路径(同样发放注册赠额)纳入同一查重口径;邮箱换绑/绑定 不纳入,否则用户把邮箱改成自己收件箱的别名会被误拒。 --- .../handler/auth_oauth_pending_flow_test.go | 25 ++++ backend/internal/handler/user_handler_test.go | 6 + backend/internal/repository/user_repo.go | 133 ++++++++++++++---- .../repository/user_repo_email_alias_test.go | 100 +++++++++++++ backend/internal/server/api_contract_test.go | 8 ++ .../server/middleware/admin_auth_test.go | 8 ++ .../service/admin_service_apikey_test.go | 6 + .../service/admin_service_delete_test.go | 45 ++++-- .../admin_service_email_identity_sync_test.go | 8 ++ .../internal/service/auth_oauth_email_flow.go | 10 +- backend/internal/service/auth_service.go | 2 +- .../service/auth_service_email_bind_test.go | 16 +++ .../service/auth_service_register_test.go | 24 ++++ .../service/content_moderation_test.go | 8 ++ .../service/registration_email_alias.go | 127 ++++++++++------- .../service/registration_email_alias_test.go | 107 ++++++++------ backend/internal/service/user_service.go | 8 ++ backend/internal/service/user_service_test.go | 6 +- ...add_users_email_alias_dedup_index_notx.sql | 8 ++ 19 files changed, 523 insertions(+), 132 deletions(-) create mode 100644 backend/internal/repository/user_repo_email_alias_test.go create mode 100644 backend/migrations/190_add_users_email_alias_dedup_index_notx.sql 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 31f3261c49..e9a323f5d4 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,37 +929,83 @@ 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 +// 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 } - 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) - })) - })) + probes := service.EmailAliasDedupProbes(email) + if len(probes) == 0 { + return false, nil } - if len(preds) == 0 { - return nil, 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)), + ) } - return r.client.User.Query(). + 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 渲染 REPLACE(LOWER(TRIM(email)), '.', ''):去掉大小写、首尾空白 +// (与 userEmailLookupPredicate 的精确匹配口径一致,历史数据存在带空白的行)以及全部点号。 +// 两侧都去点,因此一个域名探针即可同时覆盖 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 { @@ -984,6 +1055,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 45c8e9a092..8ce1711ce1 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -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 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 index a7c9a07c2f..297a50dcd5 100644 --- a/backend/internal/service/registration_email_alias.go +++ b/backend/internal/service/registration_email_alias.go @@ -13,6 +13,8 @@ import ( // - 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, @@ -20,9 +22,10 @@ import ( // 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. +// - 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. // @@ -38,72 +41,102 @@ var gmailFamilyDomains = map[string]struct{}{ // 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) + local, domain, ok := splitEmailForAliasDedup(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, ".", "") + local = stripEmailPlusSuffix(local) + if isGmailFamilyDomain(domain) { + local = stripEmailLocalDots(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) +// 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 } - if _, isGmail := gmailFamilyDomains[domain]; isGmail { - return []string{"gmail.com", "googlemail.com"} + probeLocal := strings.ReplaceAll(stripEmailPlusSuffix(local), ".", "") + if probeLocal == "" { + return nil } - return []string{domain} + 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 } -// 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) +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, 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. +// 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 } - - 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 + 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 index f93d507aa4..75ef42902b 100644 --- a/backend/internal/service/registration_email_alias_test.go +++ b/backend/internal/service/registration_email_alias_test.go @@ -24,6 +24,10 @@ func TestNormalizeEmailForAliasDedup(t *testing.T) { {"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 { @@ -33,11 +37,33 @@ func TestNormalizeEmailForAliasDedup(t *testing.T) { } } -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")) +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 @@ -45,30 +71,29 @@ func TestAliasDedupCandidateDomains(t *testing.T) { // would panic, failing the test). type aliasDedupRepoStub struct { UserRepository - exists bool - existsErr error - emails []string - listErr error - scanned [][]string + 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) 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 (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) { @@ -80,11 +105,11 @@ func TestExistsByEmailOrAlias(t *testing.T) { 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") + require.Empty(t, repo.aliasChecks, "no alias probe expected after exact hit") }) t.Run("plus alias variant detected", func(t *testing.T) { - repo := &aliasDedupRepoStub{emails: []string{"someone+bulk294@gmail.com"}} + repo := &aliasDedupRepoStub{stored: []string{"someone+bulk294@gmail.com"}} svc := &AuthService{userRepo: repo} got, err := svc.existsByEmailOrAlias(ctx, "Someone@gmail.com") require.NoError(t, err) @@ -92,32 +117,39 @@ func TestExistsByEmailOrAlias(t *testing.T) { }) t.Run("gmail dot variant detected", func(t *testing.T) { - repo := &aliasDedupRepoStub{emails: []string{"some.one@gmail.com"}} + 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("gmail scans both gmail-family domains", func(t *testing.T) { - repo := &aliasDedupRepoStub{} + t.Run("fqdn root dot variant detected", func(t *testing.T) { + repo := &aliasDedupRepoStub{stored: []string{"d.axis.2026@gmail.com"}} svc := &AuthService{userRepo: repo} - _, err := svc.existsByEmailOrAlias(ctx, "user@googlemail.com") + got, err := svc.existsByEmailOrAlias(ctx, "da.xis.2026@gmail.com.") require.NoError(t, err) - require.Len(t, repo.scanned, 1) - require.ElementsMatch(t, []string{"gmail.com", "googlemail.com"}, repo.scanned[0]) + require.True(t, got) }) t.Run("different inbox allowed", func(t *testing.T) { - repo := &aliasDedupRepoStub{emails: []string{"other@gmail.com"}} + 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("list error fails closed", func(t *testing.T) { - repo := &aliasDedupRepoStub{listErr: errors.New("db down")} + 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) @@ -129,11 +161,4 @@ func TestExistsByEmailOrAlias(t *testing.T) { _, 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) - }) } 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;