fix(auth): 注册唯一性检查识别 Gmail 邮箱别名
合并 #681:注册唯一性检查识别 Gmail 邮箱别名
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
48
backend/internal/auth/gmail_registration_test.go
Normal file
48
backend/internal/auth/gmail_registration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
16
backend/internal/emailidentity/email.go
Normal file
16
backend/internal/emailidentity/email.go
Normal 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"
|
||||
}
|
||||
18
backend/internal/emailidentity/email_test.go
Normal file
18
backend/internal/emailidentity/email_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
58
backend/internal/repository/email_identity.go
Normal file
58
backend/internal/repository/email_identity.go
Normal 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
|
||||
})
|
||||
}
|
||||
137
backend/internal/repository/email_identity_test.go
Normal file
137
backend/internal/repository/email_identity_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` 为空字符串,横竖与方形由完整分辨率标签表达。
|
||||
|
||||
Reference in New Issue
Block a user