mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 12:57:57 +08:00
Merge pull request #6166 from jyking/fix/email-bind-alias-dedup
fix(auth): 邮箱换绑增加别名与并发守卫
This commit is contained in:
@@ -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 渲染下面的表达式:去掉存量邮箱的大小写、首尾空白(与
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user