diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index c072f09b27..3d02d6fbff 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -1145,12 +1145,17 @@ func (r *userRepository) ExistsByEmailAlias(ctx context.Context, email string) ( } func existsByEmailAliasWithClient(ctx context.Context, client *dbent.Client, email string) (bool, error) { + _, exists, err := emailAliasOwnerIDWithClient(ctx, client, email, 0) + return exists, err +} + +func emailAliasOwnerIDWithClient(ctx context.Context, client *dbent.Client, email string, currentUserID int64) (int64, bool, error) { if client == nil { - return false, nil + return 0, false, nil } probes := service.EmailAliasDedupProbes(email) if len(probes) == 0 { - return false, nil + return 0, false, nil } preds := make([]predicate.User, 0, 2*len(probes)) @@ -1164,20 +1169,82 @@ func existsByEmailAliasWithClient(ctx context.Context, client *dbent.Client, ema candidates, err := client.User.Query(). Where(dbuser.Or(preds...)). Limit(emailAliasCandidateLimit). - Select(dbuser.FieldEmail). - Strings(ctx) + Select(dbuser.FieldID, dbuser.FieldEmail). + All(ctx) if err != nil { - return false, err + return 0, false, err } // 探针会有过度匹配(点号只在 Gmail 家族无意义),最终判定必须回到完整归一化规则。 + // 返回“其他用户”优先于当前用户,避免历史重复数据让调用方误判为仅当前用户占用。 identity := service.NormalizeEmailForAliasDedup(email) + var selfID int64 + selfExists := false for _, candidate := range candidates { - if service.NormalizeEmailForAliasDedup(candidate) == identity { - return true, nil + if service.NormalizeEmailForAliasDedup(candidate.Email) != identity { + continue + } + if candidate.ID != 0 && candidate.ID != currentUserID { + return candidate.ID, true, nil + } + if candidate.ID == currentUserID { + selfID = candidate.ID + selfExists = true } } - return false, nil + return selfID, selfExists, nil +} + +// UpdateEmailWithAliasGuard 在调用方事务内更新主邮箱与密码哈希。 +// +// 邮箱换绑不能只依赖服务层前置查重:两个并发请求可能同时看到同一收件箱未被占用。 +// 这里先按“字面邮箱 + 收件箱身份”加锁,复查是否已被其他用户占用,再执行写入; +// PostgreSQL 使用事务级 advisory lock 跨实例互斥,测试内存库则由进程内锁兜底。 +func (r *userRepository) UpdateEmailWithAliasGuard( + ctx context.Context, + userID int64, + email string, + passwordHash string, +) error { + if userID <= 0 { + return service.ErrUserNotFound + } + if strings.TrimSpace(email) == "" || passwordHash == "" { + return fmt.Errorf("email identity update requires email and password hash") + } + tx := dbent.TxFromContext(ctx) + if tx == nil { + return fmt.Errorf("email identity update requires a transaction") + } + client := tx.Client() + + releaseEmailLock, err := lockRepositoryScopedKeys( + ctx, + client, + txAwareSQLExecutor(ctx, r.sql, r.client), + normalizedEmailUniquenessLockKey(email), + emailAliasUniquenessLockKey(email), + ) + if err != nil { + return err + } + defer releaseEmailLock() + + ownerID, exists, err := emailAliasOwnerIDWithClient(ctx, client, email, userID) + if err != nil { + return err + } + if exists && ownerID != userID { + return service.ErrEmailExists + } + + if _, err := client.User.UpdateOneID(userID). + SetEmail(email). + SetPasswordHash(passwordHash). + Save(ctx); err != nil { + return translatePersistenceError(err, service.ErrUserNotFound, service.ErrEmailExists) + } + return nil } // dotStrippedEmailExpr 渲染下面的表达式:去掉存量邮箱的大小写、首尾空白(与 diff --git a/backend/internal/service/auth_email_binding.go b/backend/internal/service/auth_email_binding.go index 4fe3c4e85b..733e76b7fd 100644 --- a/backend/internal/service/auth_email_binding.go +++ b/backend/internal/service/auth_email_binding.go @@ -56,12 +56,8 @@ func (s *AuthService) BindEmailIdentity( return nil, ErrPasswordIncorrect } - existingUser, err := s.userRepo.GetByEmail(ctx, normalizedEmail) - switch { - case err == nil && existingUser != nil && existingUser.ID != userID: - return nil, ErrEmailExists - case err != nil && !errors.Is(err, ErrUserNotFound): - return nil, ErrServiceUnavailable + if err := s.ensureEmailIdentityAvailableForUser(ctx, currentUser, normalizedEmail); err != nil { + return nil, err } hashedPassword, err := s.HashPassword(password) @@ -115,19 +111,16 @@ func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int6 if s.emailService == nil { return ErrServiceUnavailable } - if _, err := s.userRepo.GetByID(ctx, userID); err != nil { + currentUser, err := s.userRepo.GetByID(ctx, userID) + if err != nil { if errors.Is(err, ErrUserNotFound) { return ErrUserNotFound } return ErrServiceUnavailable } - existingUser, err := s.userRepo.GetByEmail(ctx, normalizedEmail) - switch { - case err == nil && existingUser != nil && existingUser.ID != userID: - return ErrEmailExists - case err != nil && !errors.Is(err, ErrUserNotFound): - return ErrServiceUnavailable + if err := s.ensureEmailIdentityAvailableForUser(ctx, currentUser, normalizedEmail); err != nil { + return err } siteName := "Sub2API" @@ -137,6 +130,45 @@ func (s *AuthService) SendEmailIdentityBindCode(ctx context.Context, userID int6 return s.emailService.SendVerifyCode(ctx, normalizedEmail, siteName, firstEmailLocale(locale)) } +// ensureEmailIdentityAvailableForUser 在发码 / 提交换绑前做快速查重。 +// 精确地址或 provider alias 若已指向其他用户的收件箱则直接拒绝; +// 当前用户自己的收件箱允许继续,便于其更换自身的 alias 变体。 +func (s *AuthService) ensureEmailIdentityAvailableForUser( + ctx context.Context, + currentUser *User, + email string, +) error { + if currentUser == nil { + return ErrUserNotFound + } + + existingUser, err := s.userRepo.GetByEmail(ctx, email) + switch { + case err == nil: + if existingUser == nil || existingUser.ID == currentUser.ID { + break + } + return ErrEmailExists + case errors.Is(err, ErrUserNotFound): + // Continue to alias lookup below. + default: + return ErrServiceUnavailable + } + + if NormalizeEmailForAliasDedup(currentUser.Email) == NormalizeEmailForAliasDedup(email) { + return nil + } + + aliasExists, err := s.userRepo.ExistsByEmailAlias(ctx, email) + if err != nil { + return ErrServiceUnavailable + } + if aliasExists { + return ErrEmailExists + } + return nil +} + func normalizeEmailForIdentityBinding(email string) (string, error) { normalized := strings.ToLower(strings.TrimSpace(email)) if normalized == "" || len(normalized) > 255 { @@ -153,6 +185,12 @@ func hasBindableEmailIdentitySubject(email string) bool { return normalized != "" && !isReservedEmail(normalized) } +// emailIdentityAliasGuardRepository 是主邮箱替换所需的事务内原子仓储能力, +// 用于关闭服务层前置查重与实际写入之间的并发窗口。 +type emailIdentityAliasGuardRepository interface { + UpdateEmailWithAliasGuard(ctx context.Context, userID int64, email string, passwordHash string) error +} + func (s *AuthService) updateBoundEmailIdentityTx( ctx context.Context, currentUser *User, @@ -192,16 +230,15 @@ func (s *AuthService) updateBoundEmailIdentityWithClient( return ErrServiceUnavailable } - oldEmail := currentUser.Email - if _, err := client.User.UpdateOneID(currentUser.ID). - SetEmail(email). - SetPasswordHash(hashedPassword). - Save(ctx); err != nil { - if dbent.IsConstraintError(err) { - return ErrEmailExists - } + guard, ok := s.userRepo.(emailIdentityAliasGuardRepository) + if !ok { return ErrServiceUnavailable } + if err := guard.UpdateEmailWithAliasGuard(ctx, currentUser.ID, email, hashedPassword); err != nil { + return err + } + + oldEmail := currentUser.Email if err := replaceBoundEmailAuthIdentityWithClient(ctx, client, currentUser.ID, oldEmail, email, "auth_service_email_bind"); err != nil { if errors.Is(err, ErrEmailExists) { diff --git a/backend/internal/service/auth_service_email_bind_test.go b/backend/internal/service/auth_service_email_bind_test.go index 2d78862ef3..9bdca02f2d 100644 --- a/backend/internal/service/auth_service_email_bind_test.go +++ b/backend/internal/service/auth_service_email_bind_test.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "errors" + "fmt" "sync" "testing" "time" @@ -13,6 +14,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/authidentity" "github.com/Wei-Shaw/sub2api/ent/enttest" + dbuser "github.com/Wei-Shaw/sub2api/ent/user" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/repository" @@ -69,7 +71,8 @@ func newAuthServiceForEmailBindWithRefreshCache( ) (*service.AuthService, service.UserRepository, *dbent.Client) { t.Helper() - db, err := sql.Open("sqlite", "file:auth_service_email_bind?mode=memory&cache=shared") + dbName := fmt.Sprintf("file:auth_service_email_bind_%d?mode=memory&cache=shared", time.Now().UnixNano()) + db, err := sql.Open("sqlite", dbName) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -214,6 +217,143 @@ func TestAuthServiceBindEmailIdentity_RejectsExistingEmailOnAnotherUser(t *testi require.Equal(t, 0, countProviderGrantRecords(t, client, sourceUser.ID, "email", "first_bind")) } +func TestAuthServiceBindEmailIdentity_RejectsAliasOfExistingEmailOnAnotherUser(t *testing.T) { + cache := &emailBindCacheStub{ + data: &service.VerificationCodeData{ + Code: "123456", + CreatedAt: time.Now().UTC().Add(-10 * time.Minute), + ExpiresAt: time.Now().UTC().Add(10 * time.Minute), + }, + } + svc, _, client := newAuthServiceForEmailBind(t, nil, cache, nil) + + ctx := context.Background() + sourceUser := createEmailBindTestUser( + t, + client, + "source-user"+service.OIDCConnectSyntheticEmailDomain, + "source-user", + "old-hash", + ) + createEmailBindTestUser(t, client, "zck.ioio123@gmail.com", "inbox-owner", "hash") + + err := svc.SendEmailIdentityBindCode(ctx, sourceUser.ID, "zckioio123+new@gmail.com") + require.ErrorIs(t, err, service.ErrEmailExists) + require.Empty(t, cache.setEmails) + + updatedUser, err := svc.BindEmailIdentity( + ctx, + sourceUser.ID, + "zckioio123+new@gmail.com", + "123456", + "new-password", + ) + require.ErrorIs(t, err, service.ErrEmailExists) + require.Nil(t, updatedUser) + + storedUser, err := client.User.Get(ctx, sourceUser.ID) + require.NoError(t, err) + require.Equal(t, "source-user"+service.OIDCConnectSyntheticEmailDomain, storedUser.Email) + require.Equal(t, "old-hash", storedUser.PasswordHash) +} + +func TestAuthServiceBindEmailIdentity_AllowsOnlyOneConcurrentAliasVariant(t *testing.T) { + cache := &emailBindCacheStub{ + data: &service.VerificationCodeData{ + Code: "123456", + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(10 * time.Minute), + }, + } + svc, _, client := newAuthServiceForEmailBind(t, nil, cache, nil) + + ctx := context.Background() + unique := fmt.Sprintf("%d", time.Now().UnixNano()) + first := createEmailBindTestUser( + t, + client, + "first-"+unique+service.OIDCConnectSyntheticEmailDomain, + "first-"+unique, + "old-hash", + ) + second := createEmailBindTestUser( + t, + client, + "second-"+unique+service.OIDCConnectSyntheticEmailDomain, + "second-"+unique, + "old-hash", + ) + + start := make(chan struct{}) + results := make(chan error, 2) + go func() { + <-start + _, err := svc.BindEmailIdentity(ctx, first.ID, "inbox-"+unique+"+one@gmail.com", "123456", "new-password") + results <- err + }() + go func() { + <-start + _, err := svc.BindEmailIdentity(ctx, second.ID, "inbox-"+unique+"+two@gmail.com", "123456", "new-password") + results <- err + }() + close(start) + + var successes, conflicts int + for range 2 { + err := <-results + switch { + case err == nil: + successes++ + case errors.Is(err, service.ErrEmailExists): + conflicts++ + default: + t.Fatalf("unexpected bind error: %v", err) + } + } + require.Equal(t, 1, successes) + require.Equal(t, 1, conflicts) + + boundCount, err := client.User.Query(). + Where(dbuser.EmailIn( + "inbox-"+unique+"+one@gmail.com", + "inbox-"+unique+"+two@gmail.com", + )). + Count(ctx) + require.NoError(t, err) + require.Equal(t, 1, boundCount) +} + +func TestAuthServiceBindEmailIdentity_RejectsNewAliasWhenAnotherUserSharesCurrentUserInbox(t *testing.T) { + cache := &emailBindCacheStub{ + data: &service.VerificationCodeData{ + Code: "123456", + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(10 * time.Minute), + }, + } + svc, _, client := newAuthServiceForEmailBind(t, nil, cache, nil) + + ctx := context.Background() + hashedPassword, err := svc.HashPassword("current-password") + require.NoError(t, err) + currentUser := createEmailBindTestUser(t, client, "inbox+own@gmail.com", "current", hashedPassword) + createEmailBindTestUser(t, client, "inbox+legacy@gmail.com", "legacy", "hash") + + updatedUser, err := svc.BindEmailIdentity( + ctx, + currentUser.ID, + "inbox+new@gmail.com", + "123456", + "current-password", + ) + require.ErrorIs(t, err, service.ErrEmailExists) + require.Nil(t, updatedUser) + + storedUser, err := client.User.Get(ctx, currentUser.ID) + require.NoError(t, err) + require.Equal(t, "inbox+own@gmail.com", storedUser.Email) +} + func TestAuthServiceBindEmailIdentity_RollsBackWhenFirstBindDefaultsFail(t *testing.T) { assigner := &flakyEmailBindDefaultSubAssignerStub{err: errors.New("temporary assign failure")} cache := &emailBindCacheStub{