From 19841cb9c553db71fca2781f62eff4be9c34f76c Mon Sep 17 00:00:00 2001 From: Catlaxy AI Date: Tue, 6 Oct 2026 09:23:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E6=B3=A8=E5=86=8C=E5=94=AF?= =?UTF-8?q?=E4=B8=80=E6=80=A7=E6=A3=80=E6=9F=A5=E8=AF=86=E5=88=AB=20Gmail?= =?UTF-8?q?=20=E9=82=AE=E7=AE=B1=E5=88=AB=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并 #681:注册唯一性检查识别 Gmail 邮箱别名 --- backend/internal/auth/auth.go | 6 +- backend/internal/auth/email.go | 4 +- .../internal/auth/gmail_registration_test.go | 48 ++++++ backend/internal/auth/verification.go | 5 +- backend/internal/emailidentity/email.go | 16 ++ backend/internal/emailidentity/email_test.go | 18 +++ backend/internal/repository/email_identity.go | 58 ++++++++ .../repository/email_identity_test.go | 137 ++++++++++++++++++ backend/internal/repository/notifications.go | 14 ++ backend/internal/repository/oauth.go | 6 + .../internal/repository/repository_users.go | 6 + docs/content/docs/backend/http-api.mdx | 6 + 12 files changed, 316 insertions(+), 8 deletions(-) create mode 100644 backend/internal/auth/gmail_registration_test.go create mode 100644 backend/internal/emailidentity/email.go create mode 100644 backend/internal/emailidentity/email_test.go create mode 100644 backend/internal/repository/email_identity.go create mode 100644 backend/internal/repository/email_identity_test.go diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index 76a4eda21..b79fc6841 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -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 { diff --git a/backend/internal/auth/email.go b/backend/internal/auth/email.go index 96f893f85..61be251ef 100644 --- a/backend/internal/auth/email.go +++ b/backend/internal/auth/email.go @@ -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() diff --git a/backend/internal/auth/gmail_registration_test.go b/backend/internal/auth/gmail_registration_test.go new file mode 100644 index 000000000..9e396e8d2 --- /dev/null +++ b/backend/internal/auth/gmail_registration_test.go @@ -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) + } +} diff --git a/backend/internal/auth/verification.go b/backend/internal/auth/verification.go index 7c04b671a..b6f3bfa14 100644 --- a/backend/internal/auth/verification.go +++ b/backend/internal/auth/verification.go @@ -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 diff --git a/backend/internal/emailidentity/email.go b/backend/internal/emailidentity/email.go new file mode 100644 index 000000000..9a9e43f7a --- /dev/null +++ b/backend/internal/emailidentity/email.go @@ -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" +} diff --git a/backend/internal/emailidentity/email_test.go b/backend/internal/emailidentity/email_test.go new file mode 100644 index 000000000..d175461fc --- /dev/null +++ b/backend/internal/emailidentity/email_test.go @@ -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) + } + } +} diff --git a/backend/internal/repository/email_identity.go b/backend/internal/repository/email_identity.go new file mode 100644 index 000000000..81e6abe06 --- /dev/null +++ b/backend/internal/repository/email_identity.go @@ -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 + }) +} diff --git a/backend/internal/repository/email_identity_test.go b/backend/internal/repository/email_identity_test.go new file mode 100644 index 000000000..3b1245165 --- /dev/null +++ b/backend/internal/repository/email_identity_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/repository/notifications.go b/backend/internal/repository/notifications.go index 35cc97b0c..2412ee1ae 100644 --- a/backend/internal/repository/notifications.go +++ b/backend/internal/repository/notifications.go @@ -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 diff --git a/backend/internal/repository/oauth.go b/backend/internal/repository/oauth.go index 716a7c60f..2c9828eee 100644 --- a/backend/internal/repository/oauth.go +++ b/backend/internal/repository/oauth.go @@ -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 } diff --git a/backend/internal/repository/repository_users.go b/backend/internal/repository/repository_users.go index fb956738a..e36aa2d70 100644 --- a/backend/internal/repository/repository_users.go +++ b/backend/internal/repository/repository_users.go @@ -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 diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 4e7b94a8e..10df56896 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -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` 为空字符串,横竖与方形由完整分辨率标签表达。