修复停用邮箱无法重新绑定并强制重新验证
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-20 03:11:48 +08:00
parent 950a6b7f1d
commit 310ad0136e
3 changed files with 108 additions and 4 deletions

View File

@ -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 {

View File

@ -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,

View File

@ -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)
}
}
})
}
}