From 99b41945980ee632031e98338affd92a37210364 Mon Sep 17 00:00:00 2001 From: smail Date: Tue, 15 Sep 2026 20:00:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=80=E5=A7=8B=E5=AF=86=E7=A0=81=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E9=AA=8C=E6=94=B6=EF=BC=9A=E4=BF=9D=E5=AD=98=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E9=82=AE=E7=AE=B1=E6=94=B9=E5=AF=86=E7=8E=B0=E6=9C=89?= =?UTF-8?q?=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/password_change.go | 25 ++++++++++-- .../password_reset_permissions_test.go | 40 +++++++++++++++++-- backend/controllers/settings.go | 2 +- .../SecuritySettings/SecuritySettings.js | 17 ++++---- .../SecuritySettings/SecuritySettings.vue | 16 ++++---- test/frontend/password-change.test.mjs | 16 +++++++- 6 files changed, 91 insertions(+), 25 deletions(-) diff --git a/backend/controllers/password_change.go b/backend/controllers/password_change.go index bd394de..6fd8823 100644 --- a/backend/controllers/password_change.go +++ b/backend/controllers/password_change.go @@ -16,7 +16,7 @@ import ( var errPasswordChangeVerification = errors.New("password change verification unavailable") -// 从数据库读取当前身份和主要安全邮箱,不允许客户端指定验证码接收人。 +// 从数据库读取当前身份和已验证安全邮箱,不允许客户端指定验证码接收人。 func passwordChangeIdentity(ctx *context.Context, runtime securityRuntimeState) (*models.User, *models.ExternalEmail, string, error) { if ctx == nil || db.Instance == nil || runtime.external == nil || runtime.verification == nil { return nil, nil, "", errPasswordChangeVerification @@ -26,10 +26,27 @@ func passwordChangeIdentity(ctx *context.Context, runtime securityRuntimeState) if err != nil || !found || user.Disabled != 0 || user.EffectiveRole() != access.RoleSuperAdmin || user.Account != ctx.UserAccount { return nil, nil, "", errPasswordChangeVerification } - binding, err := runtime.external.FindVerifiedPrimaryByUser(user.ID) + bindings, err := runtime.external.List(user.ID) if err != nil { return nil, nil, "", errPasswordChangeVerification } + var binding *models.ExternalEmail + for i := range bindings { + item := &bindings[i] + if item.Status != models.ExternalEmailStatusVerified { + continue + } + if item.EmailType == models.ExternalEmailTypePrimary { + binding = item + break + } + if binding == nil && item.EmailType == models.ExternalEmailTypeBackup { + binding = item + } + } + if binding == nil { + return nil, nil, "", errPasswordChangeVerification + } email, err := runtime.external.Decrypt(*binding) if err != nil || strings.TrimSpace(email) == "" { return nil, nil, "", errPasswordChangeVerification @@ -40,11 +57,11 @@ func passwordChangeIdentity(ctx *context.Context, runtime securityRuntimeState) func requestPasswordChangeVerification(ctx *context.Context, w http.ResponseWriter, req *http.Request, input verificationRequest, runtime securityRuntimeState) { user, binding, email, err := passwordChangeIdentity(ctx, runtime) if err != nil { - writeSecurityError(w, response.ParamsError, "请确认超级管理员已绑定并验证主要外部安全邮箱") + writeSecurityError(w, response.ParamsError, "请确认超级管理员已绑定并验证外部安全邮箱") return } if input.Email != "" && !strings.EqualFold(strings.TrimSpace(input.Email), email) { - writeSecurityError(w, response.ParamsError, "邮箱地址与绑定的主安全邮箱不匹配") + writeSecurityError(w, response.ParamsError, "邮箱地址与当前验证邮箱不匹配") return } challenge, err := runtime.verification.Issue(verification.IssueRequest{ diff --git a/backend/controllers/password_reset_permissions_test.go b/backend/controllers/password_reset_permissions_test.go index 158ac14..2c92bd0 100644 --- a/backend/controllers/password_reset_permissions_test.go +++ b/backend/controllers/password_reset_permissions_test.go @@ -219,7 +219,7 @@ func TestSuperAdminCannotChangePasswordWithoutEmailVerification(t *testing.T) { } func TestSuperAdminPasswordChangeVerification(t *testing.T) { - for _, scenario := range []string{"success", "missing", "wrong-code", "wrong-purpose", "wrong-email", "unverified", "expired", "security-version", "disabled", "invalid-password", "replay"} { + for _, scenario := range []string{"success", "backup", "primary-priority", "binding-disabled", "binding-changed", "missing", "wrong-code", "wrong-purpose", "wrong-email", "unverified", "expired", "security-version", "disabled", "invalid-password", "replay"} { t.Run(scenario, func(t *testing.T) { testRoot := t.TempDir() oldConfig, oldDB, oldSession := config.Instance, db.Instance, session.Instance @@ -249,7 +249,11 @@ func TestSuperAdminPasswordChangeVerification(t *testing.T) { if err != nil { t.Fatal(err) } - binding, err := repo.Bind(externalemail.BindRequest{UserID: user.ID, Email: "owner@example.com", Type: models.ExternalEmailTypePrimary}) + bindingType := models.ExternalEmailTypePrimary + if scenario == "backup" || scenario == "primary-priority" || scenario == "binding-changed" { + bindingType = models.ExternalEmailTypeBackup + } + binding, err := repo.Bind(externalemail.BindRequest{UserID: user.ID, Email: "owner@example.com", Type: bindingType}) if err != nil { t.Fatal(err) } @@ -274,6 +278,30 @@ func TestSuperAdminPasswordChangeVerification(t *testing.T) { } ConfigureSecurityRuntime(service, repo, nil, nil) ctx := &appcontext.Context{UserID: user.ID, UserAccount: user.Account, Role: access.RoleSuperAdmin, Lang: "zhCn"} + // 客户端既不能替换收件地址,也不能为其他账号申请改密验证码。 + for _, body := range []string{ + `{"purpose":"password_change","account":"verified-super","email":"attacker@example.com"}`, + `{"purpose":"password_change","account":"another-user"}`, + } { + RequestVerification(ctx, httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))) + if delivered.ID != "" { + t.Fatal("issued verification for client-controlled recipient or account") + } + } + addPrimary := func() { + primary, err := repo.Bind(externalemail.BindRequest{UserID: user.ID, Email: "primary@example.com", Type: models.ExternalEmailTypePrimary}) + if err != nil { + t.Fatal(err) + } + if err := repo.Verify(user.ID, primary.ID); err != nil { + t.Fatal(err) + } + } + expectedEmail := "owner@example.com" + if scenario == "primary-priority" { + addPrimary() + expectedEmail = "primary@example.com" + } issueRecorder := httptest.NewRecorder() RequestVerification(ctx, issueRecorder, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"purpose":"password_change","account":"verified-super"}`))) if scenario == "unverified" { @@ -282,11 +310,15 @@ func TestSuperAdminPasswordChangeVerification(t *testing.T) { } return } - if delivered.ID == "" || delivered.Email != "owner@example.com" { + if delivered.ID == "" || delivered.Email != expectedEmail { t.Fatalf("issue failed: %s", issueRecorder.Body.String()) } input := modifyPasswordRequest{Password: "NewPass123", Code: code, ChallengeID: delivered.ID} switch scenario { + case "binding-disabled": + err = repo.Disable(user.ID, binding.ID) + case "binding-changed": + addPrimary() case "missing": input.Code = "" case "wrong-code": @@ -340,7 +372,7 @@ func TestSuperAdminPasswordChangeVerification(t *testing.T) { } return result.ErrorNo } - succeeded := scenario == "success" || scenario == "replay" + succeeded := scenario == "success" || scenario == "backup" || scenario == "primary-priority" || scenario == "replay" if result := change(); (result == 0) != succeeded { t.Fatalf("unexpected result: %d", result) } diff --git a/backend/controllers/settings.go b/backend/controllers/settings.go index 4e95a57..c2f915c 100644 --- a/backend/controllers/settings.go +++ b/backend/controllers/settings.go @@ -67,7 +67,7 @@ func ModifyPassword(ctx *context.Context, w http.ResponseWriter, req *http.Reque loginIPAttempts.recordFailure(ipKey) log.WithContext(ctx).Info("password change authentication failed") if ctx.EffectiveRole() == access.RoleSuperAdmin { - writeSecurityError(w, response.ParamsError, "请使用已绑定主安全邮箱的有效验证码,失败后可重新获取") + writeSecurityError(w, response.ParamsError, "请使用已绑定安全邮箱的有效验证码,失败后可重新获取") } else { response.NewErrorResponse(response.ParamsError, i18n.GetText(ctx.Lang, "aperror"), "").FPrint(w) } diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.js b/frontend/src/views/components/SecuritySettings/SecuritySettings.js index 9f87ea5..40d3777 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.js +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.js @@ -58,7 +58,10 @@ export function usePage() { const externalEmails = ref([]) const externalListLoading = ref(false) const externalDialogVisible = ref(false) - const primaryEmail = computed(() => externalEmails.value.find(item => item.type === 'primary' && item.status === 'verified')) + const passwordEmail = computed(() => { + const verified = externalEmails.value.filter(item => item.status === 'verified') + return verified.find(item => item.type === 'primary') || verified.find(item => item.type === 'backup') + }) const passwordSending = ref(false) const passwordSubmitting = ref(false) const passwordChallenge = ref('') @@ -273,8 +276,8 @@ export function usePage() { notify(lang.err_pwd_diff) return } - if (isSuperAdmin.value && (!primaryEmail.value || !passwordChallenge.value || !/^\d{8}$/.test(ruleForm.code.trim()))) { - notify('请获取并输入主安全邮箱中的 8 位验证码') + if (isSuperAdmin.value && (!passwordEmail.value || !passwordChallenge.value || !/^\d{8}$/.test(ruleForm.code.trim()))) { + notify('请获取并输入安全邮箱中的 8 位验证码') return } passwordSubmitting.value = true @@ -297,7 +300,7 @@ export function usePage() { } const sendPasswordCode = async () => { - if (passwordSending.value || passwordSubmitting.value || passwordCountdown.value > 0 || !primaryEmail.value) return + if (passwordSending.value || passwordSubmitting.value || passwordCountdown.value > 0 || !passwordEmail.value) return passwordSending.value = true passwordChallenge.value = '' ruleForm.code = '' @@ -314,7 +317,7 @@ export function usePage() { passwordCountdown.value = Math.max(0, Math.ceil((deadline - Date.now()) / 1000)) if (!passwordCountdown.value) clearInterval(passwordTimer) }, 1000) - notify(`验证码已发送至 ${res.data.email || primaryEmail.value.email}`, 'success') + notify(`验证码已发送至 ${res.data.email || passwordEmail.value.email}`, 'success') } catch (error) { notify(error?.message || '验证码发送失败') } finally { @@ -322,7 +325,7 @@ export function usePage() { } } - watch(() => primaryEmail.value?.id, () => { + watch(() => passwordEmail.value?.id, () => { passwordChallenge.value = '' ruleForm.code = '' }) @@ -339,7 +342,7 @@ export function usePage() { return { lang, isSuperAdmin, supported, webAuthnEnabled, sectionOptions, selectedSection, selectSection, globalSecurityPolicyPage, - primaryEmail, passwordSending, passwordSubmitting, passwordCountdown, sendPasswordCode, + passwordEmail, passwordSending, passwordSubmitting, passwordCountdown, sendPasswordCode, registering, credentialsLoading, credentials, securityLoading, securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalListLoading, externalDialogVisible, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential, diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue index 49a22d1..bb5bdfc 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue @@ -18,14 +18,14 @@

{{ lang.modify_pwd }}

-

{{ isSuperAdmin ? '通过主安全邮箱验证码修改密码,完成后需重新登录。' : lang.modify_pwd_desc }}

+

{{ isSuperAdmin ? '通过已验证安全邮箱修改密码,优先主要邮箱;完成后需重新登录。' : lang.modify_pwd_desc }}

@@ -59,7 +59,7 @@
- + {{ lang.submit }}
@@ -146,7 +146,7 @@ import ExternalEmailBindingDialog from './ExternalEmailBindingDialog/ExternalEma const { lang, isSuperAdmin, supported, webAuthnEnabled, sectionOptions, selectedSection, selectSection, globalSecurityPolicyPage, - primaryEmail, passwordSending, passwordSubmitting, passwordCountdown, sendPasswordCode, + passwordEmail, passwordSending, passwordSubmitting, passwordCountdown, sendPasswordCode, registering, credentialsLoading, credentials, securityLoading, securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalListLoading, externalDialogVisible, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential, diff --git a/test/frontend/password-change.test.mjs b/test/frontend/password-change.test.mjs index 61084b8..e74f11b 100644 --- a/test/frontend/password-change.test.mjs +++ b/test/frontend/password-change.test.mjs @@ -59,7 +59,7 @@ test('普通用户和管理员维持当前密码验证', async () => { } }) -test('无主安全邮箱、未获取验证码、密码不一致时不能提交', async () => { +test('无已验证安全邮箱、未获取验证码、密码不一致时不能提交', async () => { const { page, calls } = fixture() await page.submit() page.externalEmails.value = [] @@ -69,6 +69,20 @@ test('无主安全邮箱、未获取验证码、密码不一致时不能提交', assert.equal(calls.length, 0) }) +test('安全邮箱优先主要邮箱,没有主要邮箱时允许已验证备用邮箱', async () => { + const { page, calls } = fixture() + const backup = { id: 2, type: 'backup', status: 'verified', email: 'b***@example.com' } + const primary = page.externalEmails.value[0] + page.externalEmails.value = [backup, primary] + assert.equal(page.passwordEmail.value.id, 1) + page.externalEmails.value = [backup, { ...primary, status: 'disabled' }] + assert.equal(page.passwordEmail.value.id, 2) + await page.sendPasswordCode() + assert.equal(calls.length, 1) + page.externalEmails.value = [{ ...backup, status: 'pending' }] + assert.equal(page.passwordEmail.value, undefined) +}) + test('请求失败不虚报成功、不登出且释放提交状态', async () => { const { page, messages, redirects, store } = fixture('admin', async () => { throw new Error('服务暂不可用') }) page.ruleForm.current_pwd = 'OldPass123'