fix(auth): 注册唯一性检查识别 Gmail 邮箱别名

合并 #681:注册唯一性检查识别 Gmail 邮箱别名
This commit is contained in:
Catlaxy AI
2026-10-06 09:23:04 +08:00
committed by GitHub
parent a71e03b131
commit 19841cb9c5
12 changed files with 316 additions and 8 deletions

View File

@@ -199,9 +199,7 @@ func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) {
return nil, err
}
if email != "" {
if _, err := s.repo.UserByEmail(email); err == nil {
return nil, kernel.BadAuthRequest("邮箱已被注册")
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
if err := s.repo.CheckEmailAvailable(email, ""); err != nil {
return nil, err
}
}
@@ -243,7 +241,7 @@ func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) {
if err := s.repo.CreateUserWithEmailVerification(&user, verifiedCode.ID, time.Now()); err != nil {
return nil, err
}
} else if err := s.repo.Create(&user); err != nil {
} else if err := s.repo.CreateRegisteredUser(&user); err != nil {
return nil, err
}
if err := s.host.EnsureSignupBonus(user.ID); err != nil {

View File

@@ -168,9 +168,7 @@ func (s *Service) SendRegistrationEmailCode(rawEmail string) error {
if !registrationEnabled {
return kernel.Forbidden("管理员未开放新用户注册")
}
if _, err := s.repo.UserByEmail(email); err == nil {
return kernel.BadAuthRequest("邮箱已被注册")
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
if err := s.repo.CheckEmailAvailable(email, ""); err != nil {
return err
}
_, setting, err := s.readEmailSetting()

View File

@@ -0,0 +1,48 @@
package auth
import (
"strings"
"testing"
"time"
"infinite-canvas/backend/internal/model"
)
func TestGmailAliasRegistrationRejectsExistingMailbox(t *testing.T) {
svc, db := newRegistrationTestService(t)
for _, setting := range []model.SystemSetting{
{Key: registrationSettingKey, ValueJSON: `{"enabled":true}`},
} {
if err := db.Create(&setting).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Model(&model.SystemSetting{}).Where("key = ?", emailSettingKey).Update("value_json", `{"enabled":true,"host":"smtp.example.com","port":587,"fromEmail":"sender@gmail.com","registrationAllowedDomains":["gmail.com","googlemail.com"]}`).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.User{ID: "existing", Username: "existing", Email: "a.b+old@googlemail.com", Role: model.UserRoleUser, Status: model.UserStatusActive}).Error; err != nil {
t.Fatal(err)
}
// A valid code isolates the registration uniqueness check from email delivery.
email, code := "ab+new@gmail.com", "123456"
now := time.Now()
hash, err := svc.emailVerificationCodeHash(registrationEmailPurpose, email, code)
if err != nil {
t.Fatal(err)
}
verification := model.EmailVerificationCode{ID: "code", Email: email, Purpose: registrationEmailPurpose, CodeHash: hash, ExpiresAt: now.Add(time.Hour), CreatedAt: now}
if err := db.Create(&verification).Error; err != nil {
t.Fatal(err)
}
_, err = svc.Register(RegisterRequest{Username: "new-member", Email: email, EmailCode: code, Password: "password", AcceptedTerms: true})
if err == nil || !strings.Contains(err.Error(), "邮箱已被注册") {
t.Fatalf("registration accepted a second account for the same Gmail mailbox or failed unexpectedly: %v", err)
}
var count int64
if err := db.Model(&model.User{}).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("users=%d err=%v", count, err)
}
if err := db.First(&verification, "id = ?", "code").Error; err != nil || verification.UsedAt != nil {
t.Fatalf("verification consumed: %#v err=%v", verification, err)
}
}

View File

@@ -210,7 +210,10 @@ func (s *Service) contactAvailable(email, phone, ownID string) error {
if email == "" {
continue
}
user, err = s.repo.UserByEmail(email)
if err := s.repo.CheckEmailAvailable(email, ownID); err != nil {
return err
}
continue
} else {
if phone == "" {
continue

View File

@@ -0,0 +1,16 @@
// Package emailidentity defines mailbox equivalence for uniqueness checks.
package emailidentity
import "strings"
// Canonical preserves provider-specific local parts except for Gmail aliases.
// It is not an account lookup key: existing accounts may share a mailbox.
func Canonical(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
local, domain, ok := strings.Cut(value, "@")
if !ok || (domain != "gmail.com" && domain != "googlemail.com") {
return value
}
local, _, _ = strings.Cut(local, "+")
return strings.ReplaceAll(local, ".", "") + "@gmail.com"
}

View File

@@ -0,0 +1,18 @@
package emailidentity
import "testing"
func TestCanonicalMailbox(t *testing.T) {
for input, want := range map[string]string{
" A.B+x@GMAIL.COM ": "ab@gmail.com",
"a.b+x@googlemail.com": "ab@gmail.com",
"A.B+x@example.com": "a.b+x@example.com",
"a.b+x@gmail.example.com": "a.b+x@gmail.example.com",
"a.b+x@sub.gmail.com": "a.b+x@sub.gmail.com",
"": "",
} {
if got := Canonical(input); got != want {
t.Errorf("Canonical(%q)=%q want %q", input, got, want)
}
}
}

View File

@@ -0,0 +1,58 @@
package repository
import (
"infinite-canvas/backend/internal/emailidentity"
"infinite-canvas/backend/internal/kernel"
"infinite-canvas/backend/internal/model"
"gorm.io/gorm"
)
// CheckEmailAvailable compares legacy rows without rewriting their login address.
func (r *Repository) CheckEmailAvailable(email, ownID string) error {
if email == "" {
return nil
}
value := "lower(trim(email))"
local := "split_part(" + value + ",'@',1)"
if r.Dialect() == "sqlite" {
local = "substr(" + value + ",1,instr(" + value + ",'@')-1)"
}
untagged := "split_part(" + local + ",'+',1)"
if r.Dialect() == "sqlite" {
untagged = "CASE WHEN instr(" + local + ",'+') > 0 THEN substr(" + local + ",1,instr(" + local + ",'+')-1) ELSE " + local + " END"
}
canonical := "CASE WHEN " + value + " LIKE '%@gmail.com' OR " + value + " LIKE '%@googlemail.com' THEN replace((" + untagged + "),'.','') || '@gmail.com' ELSE " + value + " END"
var count int64
if err := r.db.Model(&model.User{}).Where("id <> ? AND ("+canonical+") = ?", ownID, emailidentity.Canonical(email)).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return kernel.BadAuthRequest("邮箱已被注册")
}
return nil
}
// All public registration paths lock before checking and writing, including
// registrations using different verification codes on different instances.
func lockRegistrationEmail(tx *gorm.DB, email string) error {
if tx.Dialector.Name() == "postgres" {
return tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?)::bigint)", "registration-email:"+emailidentity.Canonical(email)).Error
}
if tx.Dialector.Name() == "sqlite" {
return tx.Exec("UPDATE users SET updated_at = updated_at WHERE 1 = 0").Error
}
return gorm.ErrInvalidData
}
func (r *Repository) CreateRegisteredUser(user *model.User) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := lockRegistrationEmail(tx, user.Email); err != nil {
return err
}
if err := New(tx).CheckEmailAvailable(user.Email, ""); err != nil {
return err
}
return tx.Create(user).Error
})
}

View File

@@ -0,0 +1,137 @@
package repository
import (
"path/filepath"
"sync"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"infinite-canvas/backend/internal/model"
)
func TestGmailMailboxRegistrationConcurrent(t *testing.T) {
dsn := filepath.Join(t.TempDir(), "users.db") + "?_busy_timeout=5000&_journal_mode=WAL"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.EmailVerificationCode{}); err != nil {
t.Fatal(err)
}
now := time.Now()
for _, id := range []string{"one", "two"} {
if err := db.Create(&model.EmailVerificationCode{ID: id, Email: id + "@gmail.com", ExpiresAt: now.Add(time.Hour)}).Error; err != nil {
t.Fatal(err)
}
}
var wg sync.WaitGroup
errs := make(chan error, 2)
start := make(chan struct{})
for i, email := range []string{"a.b+first@gmail.com", "ab+second@googlemail.com"} {
id := []string{"one", "two"}[i]
peer, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := peer.DB()
t.Cleanup(func() { _ = sqlDB.Close() })
wg.Add(1)
go func() {
defer wg.Done()
<-start
errs <- New(peer).CreateUserWithEmailVerification(&model.User{ID: id, Username: id, Email: email}, id, now)
}()
}
close(start)
wg.Wait()
close(errs)
success := 0
for err := range errs {
if err == nil {
success++
} else if err.Error() != "邮箱已被注册" {
t.Fatalf("unexpected error: %v", err)
}
}
if success != 1 {
t.Fatalf("successful registrations=%d want 1", success)
}
var consumed int64
if err := db.Model(&model.EmailVerificationCode{}).Where("used_at IS NOT NULL").Count(&consumed).Error; err != nil || consumed != 1 {
t.Fatalf("consumed=%d err=%v", consumed, err)
}
}
func TestGmailMailboxLegacyLookupsRemainExact(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatal(err)
}
r := New(db)
for _, user := range []model.User{{ID: "one", Username: "one", Email: "a.b+old@gmail.com"}, {ID: "two", Username: "two", Email: "ab@gmail.com"}} {
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
got, err := r.UserByEmail(user.Email)
if err != nil || got.ID != user.ID {
t.Fatalf("lookup %q=%v err=%v", user.Email, got, err)
}
}
if err := r.CheckEmailAvailable("ab+new@googlemail.com", ""); err == nil {
t.Fatal("legacy alias collision not detected")
}
if err := r.CheckEmailAvailable("a.b+new@example.com", ""); err != nil {
t.Fatal(err)
}
}
func TestGmailMailboxVerificationEntryPoints(t *testing.T) {
for _, mode := range []string{"ticket", "oauth", "bind", "first-user"} {
t.Run(mode, func(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.AutoMigrate(&model.User{}, &model.AuthVerification{}, &model.UserIdentity{}, &model.CreditAccount{}); err != nil {
t.Fatal(err)
}
r := New(db)
if err := db.Create(&model.User{ID: "owner", Username: "owner", Email: "a.b@gmail.com"}).Error; err != nil {
t.Fatal(err)
}
user := model.User{ID: "new", Username: "new", Email: "ab+tag@googlemail.com", Status: model.UserStatusActive}
v := model.AuthVerification{ID: "ticket", UserID: "new", Email: user.Email, Ready: true, ExpiresAt: time.Now().Add(time.Hour)}
if err := db.Create(&v).Error; err != nil {
t.Fatal(err)
}
switch mode {
case "ticket":
err = r.CreateUserWithVerification(&user, v.ID)
case "oauth":
err = r.CreateOAuthUser(&user, &model.UserIdentity{ID: "identity", UserID: user.ID, Provider: "linuxdo", Subject: "subject"})
case "first-user":
err = r.CreateRegisteredUser(&user)
case "bind":
user.Email = ""
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
_, err = r.CompleteAuthVerification(&v, true)
}
if err == nil || err.Error() != "邮箱已被注册" {
t.Fatalf("alias accepted or unexpected error: %v", err)
}
stored, err := r.AuthVerification(v.ID)
if err != nil || stored.UsedAt != nil {
t.Fatalf("rejected collision consumed ticket: %v err=%v", stored, err)
}
})
}
}

View File

@@ -97,6 +97,12 @@ func consumeVerification(tx *gorm.DB, id string, now time.Time) error {
}
func (r *Repository) CreateUserWithVerification(user *model.User, id string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := lockRegistrationEmail(tx, user.Email); err != nil {
return err
}
if err := New(tx).CheckEmailAvailable(user.Email, ""); err != nil {
return err
}
if err := consumeVerification(tx, id, time.Now()); err != nil {
return err
}
@@ -106,6 +112,14 @@ func (r *Repository) CreateUserWithVerification(user *model.User, id string) err
func (r *Repository) CompleteAuthVerification(v *model.AuthVerification, bind bool) (*model.User, error) {
var user model.User
err := r.db.Transaction(func(tx *gorm.DB) error {
if bind && v.Email != "" {
if err := lockRegistrationEmail(tx, v.Email); err != nil {
return err
}
if err := New(tx).CheckEmailAvailable(v.Email, v.UserID); err != nil {
return err
}
}
now := time.Now()
if err := consumeVerification(tx, v.ID, now); err != nil {
return err

View File

@@ -50,6 +50,12 @@ func (r *Repository) ConsumeOAuthState(provider string, stateHash string) (*mode
func (r *Repository) CreateOAuthUser(user *model.User, identity *model.UserIdentity) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := lockRegistrationEmail(tx, user.Email); err != nil {
return err
}
if err := New(tx).CheckEmailAvailable(user.Email, ""); err != nil {
return err
}
if err := tx.Create(user).Error; err != nil {
return err
}

View File

@@ -134,6 +134,12 @@ func (r *Repository) DeleteEmailVerificationCode(id string) error {
func (r *Repository) CreateUserWithEmailVerification(user *model.User, verificationCodeID string, usedAt time.Time) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := lockRegistrationEmail(tx, user.Email); err != nil {
return err
}
if err := New(tx).CheckEmailAvailable(user.Email, ""); err != nil {
return err
}
result := tx.Model(&model.EmailVerificationCode{}).Where("id = ? AND used_at IS NULL AND expires_at > ?", verificationCodeID, usedAt).Update("used_at", usedAt)
if result.Error != nil {
return result.Error

View File

@@ -5,6 +5,12 @@ description: 信封、错误码与 OpenAPI
# HTTP API 合同
## 注册邮箱等价检查
公开注册与邮箱绑定的唯一性检查将 `gmail.com` 和 `googlemail.com` 视为同一域,移除本地部分的点及 `+` 后缀。例如 `a.b+x@googlemail.com` 与 `ab@gmail.com` 不允许创建两个账号。其他域保留点和 `+`;现有域名白名单规则保持不变。
邮箱原值仍按原有小写、去首尾空格规则保存。登录、密码重置、验证码收件地址保持精确邮箱匹配,避免既有重复别名账号被合并或错误授权。已存在的重复账号不迁移、不删除、不重新发放注册奖励;它们会阻止相同规范信箱的新注册。写入前在事务内重新检查,以覆盖不同实例使用不同验证码的并发注册。无数据库结构迁移;唯一性由公开注册与绑定的事务入口保证,直接 SQL 写入不属于该约束入口。
## AutoDL 视频画面规格配置
渠道模型的 `capabilityConfig.video.resolutions` 与 `defaultResolution` 直接表示所选完整分辨率与默认值,始终生效;不再使用或返回 `customSizeEnabled`、`customScreenSpec`。AutoDL v2.3.0 未声明独立比例参数,因此 `ratios` 为空、`defaultRatio` 为空字符串,横竖与方形由完整分辨率标签表达。