From 310ad0136e9794ccb67e2c92aec05e8c66f151ad Mon Sep 17 00:00:00 2001 From: smail Date: Sun, 20 Sep 2026 03:11:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=81=9C=E7=94=A8=E9=82=AE?= =?UTF-8?q?=E7=AE=B1=E6=97=A0=E6=B3=95=E9=87=8D=E6=96=B0=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E5=B9=B6=E5=BC=BA=E5=88=B6=E9=87=8D=E6=96=B0=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/security.go | 1 + backend/services/externalemail/repository.go | 31 ++++++- .../services/externalemail/repository_test.go | 80 +++++++++++++++++++ 3 files changed, 108 insertions(+), 4 deletions(-) diff --git a/backend/controllers/security.go b/backend/controllers/security.go index edf576f..73f815e 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -1076,6 +1076,7 @@ func DisableExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http } if runtime.verification != nil && runtime.verification.Store != nil { runtime.verification.Store.InvalidateResource(input.ID, verification.PurposeExternalEmailBind) + runtime.verification.Store.InvalidateResource(input.ID, verification.PurposeExternalEmailChange) runtime.verification.Store.InvalidateAccount(ctx.UserAccount) } if runtime.recovery != nil { diff --git a/backend/services/externalemail/repository.go b/backend/services/externalemail/repository.go index 410edc2..1746313 100644 --- a/backend/services/externalemail/repository.go +++ b/backend/services/externalemail/repository.go @@ -61,7 +61,7 @@ func (r *Repository) BeginReplacement(request BindRequest) (*models.ExternalEmai if sameAddress.UserID != request.UserID || sameAddress.Status == models.ExternalEmailStatusVerified { return nil, ErrBindingExists } - if sameAddress.EmailType != request.Type { + if sameAddress.Status != models.ExternalEmailStatusDisabled && sameAddress.EmailType != request.Type { return nil, ErrBindingExists } if sameAddress.Status == models.ExternalEmailStatusPending { @@ -71,13 +71,19 @@ func (r *Repository) BeginReplacement(request BindRequest) (*models.ExternalEmai now := r.now().UTC() if hasSameAddress && sameAddress.Status == models.ExternalEmailStatusDisabled { + sameAddress.EmailType = request.Type sameAddress.Status = models.ExternalEmailStatusPending sameAddress.VerifiedAt = nil sameAddress.CredentialVersion++ sameAddress.UpdatedAt = now - if _, err := r.engine.ID(sameAddress.ID).Cols("status", "verified_at", "credential_version", "updated_at").Update(&sameAddress); err != nil { + updated, err := r.engine.ID(sameAddress.ID).Where("status = ?", models.ExternalEmailStatusDisabled). + Cols("email_type", "status", "verified_at", "credential_version", "updated_at").Update(&sameAddress) + if err != nil { return nil, fmt.Errorf("reuse disabled replacement external email: %w", err) } + if updated != 1 { + return nil, ErrBindingExists + } return &sameAddress, nil } @@ -145,11 +151,11 @@ func (r *Repository) Bind(request BindRequest) (*models.ExternalEmail, error) { return nil, err } var existing models.ExternalEmail - has, err := r.engine.Where("user_id = ? AND email_fingerprint = ?", request.UserID, fingerprint).Get(&existing) + has, err := r.engine.Where("email_fingerprint = ?", fingerprint).Get(&existing) if err != nil { return nil, fmt.Errorf("check external email binding: %w", err) } - if has { + if has && (existing.UserID != request.UserID || existing.Status != models.ExternalEmailStatusDisabled) { return nil, ErrBindingExists } var count int64 @@ -164,6 +170,23 @@ func (r *Repository) Bind(request BindRequest) (*models.ExternalEmail, error) { return nil, ErrBackupLimit } now := r.now().UTC() + if has { + // 停用记录可以重新绑定,但必须重新验证,不能沿用之前的验证状态。 + existing.EmailType = request.Type + existing.Status = models.ExternalEmailStatusPending + existing.VerifiedAt = nil + existing.CredentialVersion++ + existing.UpdatedAt = now + updated, err := r.engine.ID(existing.ID).Where("status = ?", models.ExternalEmailStatusDisabled). + Cols("email_type", "status", "verified_at", "credential_version", "updated_at").Update(&existing) + if err != nil { + return nil, fmt.Errorf("reuse disabled external email binding: %w", err) + } + if updated != 1 { + return nil, ErrBindingExists + } + return &existing, nil + } item := &models.ExternalEmail{ UserID: request.UserID, EmailCiphertext: ciphertext, diff --git a/backend/services/externalemail/repository_test.go b/backend/services/externalemail/repository_test.go index aeb8767..d975f43 100644 --- a/backend/services/externalemail/repository_test.go +++ b/backend/services/externalemail/repository_test.go @@ -165,3 +165,83 @@ func TestRepositoryReplacesExternalEmailOnlyAfterVerification(t *testing.T) { t.Fatalf("unexpected replacement statuses: %+v", statuses) } } + +func TestRepositoryRebindsDisabledEmailWithFreshVerification(t *testing.T) { + for _, replacement := range []bool{false, true} { + name := "bind" + if replacement { + name = "replacement" + } + t.Run(name, func(t *testing.T) { + engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "external.db")) + if err != nil { + t.Fatal(err) + } + defer engine.Close() + if err := engine.Sync2(&models.ExternalEmail{}); err != nil { + t.Fatal(err) + } + vault := NewVaultWithKeys([]byte("01234567890123456789012345678901"), []byte("abcdefabcdefabcdefabcdefabcdefab")) + repo, err := NewRepository(engine, vault) + if err != nil { + t.Fatal(err) + } + item, err := repo.Bind(BindRequest{UserID: 1, Email: "owner@example.com", Type: models.ExternalEmailTypeBackup}) + if err != nil { + t.Fatal(err) + } + if err := repo.Verify(1, item.ID); err != nil { + t.Fatal(err) + } + bind := repo.Bind + verify := repo.Verify + if replacement { + bind = repo.BeginReplacement + verify = repo.VerifyReplacement + } + request := BindRequest{UserID: 1, Email: "OWNER@example.com", Type: models.ExternalEmailTypePrimary} + if _, err := bind(request); err != ErrBindingExists { + t.Fatalf("已验证邮箱不应直接更改用途:%v", err) + } + if err := repo.Disable(1, item.ID); err != nil { + t.Fatal(err) + } + other := request + other.UserID = 2 + if _, err := bind(other); err != ErrBindingExists { + t.Fatalf("不能复用其他用户的停用记录:%v", err) + } + pending, err := bind(request) + if err != nil { + t.Fatal(err) + } + if pending.ID != item.ID || pending.Status != models.ExternalEmailStatusPending || pending.VerifiedAt != nil || pending.EmailType != models.ExternalEmailTypePrimary || pending.CredentialVersion <= item.CredentialVersion { + t.Fatalf("重新绑定必须保留记录并重新验证:%+v", pending) + } + if _, err := repo.FindVerifiedPrimaryByUser(1); err != ErrBindingNotFound { + t.Fatalf("未验证邮箱不能参与登录:%v", err) + } + stored, err := repo.List(1) + if err != nil || len(stored) != 1 || stored[0].VerifiedAt != nil || stored[0].Status != models.ExternalEmailStatusPending { + t.Fatalf("旧验证状态未清除:%+v,%v", stored, err) + } + if err := verify(1, pending.ID); err != nil { + t.Fatal(err) + } + if _, err := repo.FindVerifiedPrimaryByUser(1); err != nil { + t.Fatalf("重新验证后应可用于登录:%v", err) + } + if !replacement { + if err := repo.Disable(1, item.ID); err != nil { + t.Fatal(err) + } + if _, err := repo.Bind(BindRequest{UserID: 1, Email: "other@example.com", Type: models.ExternalEmailTypePrimary}); err != nil { + t.Fatal(err) + } + if _, err := bind(request); err != ErrPrimaryLimit { + t.Fatalf("重新绑定不能绕过主要邮箱数量限制:%v", err) + } + } + }) + } +}