Merge pull request #6166 from jyking/fix/email-bind-alias-dedup

fix(auth): 邮箱换绑增加别名与并发守卫
This commit is contained in:
Wesley Liddick
2026-08-25 21:31:39 +08:00
committed by GitHub
3 changed files with 274 additions and 30 deletions
+75 -8
View File
@@ -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 渲染下面的表达式:去掉存量邮箱的大小写、首尾空白(与
+58 -21
View File
@@ -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) {
@@ -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{