开始密码恢复验收:保存安全邮箱改密现有调整
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-15 20:00:09 +08:00
parent b777b2b101
commit 99b4194598
6 changed files with 91 additions and 25 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -18,14 +18,14 @@
<section v-if="selectedSection === 'password'" class="security-section">
<div class="settings-header">
<h3>{{ lang.modify_pwd }}</h3>
<p class="settings-desc">{{ isSuperAdmin ? '通过主安全邮箱验证码修改密码,完成后需重新登录。' : lang.modify_pwd_desc }}</p>
<p class="settings-desc">{{ isSuperAdmin ? '通过已验证安全邮箱修改密码,优先主要邮箱;完成后需重新登录。' : lang.modify_pwd_desc }}</p>
</div>
<div class="settings-body">
<el-form :model="ruleForm" :rules="rules" status-icon label-position="top" class="settings-form">
<template v-if="isSuperAdmin">
<el-form-item label="主要外部安全邮箱">
<div v-if="primaryEmail" class="password-verification-row">
<strong>{{ primaryEmail.email }}</strong>
<el-form-item label="外部安全邮箱">
<div v-if="passwordEmail" class="password-verification-row">
<strong>{{ passwordEmail.email }}</strong>
<el-button
:loading="passwordSending"
:disabled="passwordCountdown > 0 || passwordSubmitting"
@ -35,7 +35,7 @@
</el-button>
</div>
<el-button v-else type="primary" link @click="selectSection('external-email')">
请先绑定并验证主要外部安全邮箱
请先绑定并验证外部安全邮箱
</el-button>
</el-form-item>
<el-form-item label="邮箱验证码" required>
@ -45,7 +45,7 @@
autocomplete="one-time-code"
maxlength="8"
placeholder="请输入邮箱中的 8 位验证码"
:disabled="!primaryEmail || passwordSubmitting"
:disabled="!passwordEmail || passwordSubmitting"
/>
</el-form-item>
</template>
@ -59,7 +59,7 @@
<el-input v-model="ruleForm.new_pwd2" type="password" autocomplete="new-password" :placeholder="lang.confirm_new_pwd" show-password class="premium-input" />
</el-form-item>
<div class="form-actions">
<el-button type="primary" :loading="passwordSubmitting" :disabled="passwordSending || (isSuperAdmin && !primaryEmail)" @click="submit" class="submit-btn">
<el-button type="primary" :loading="passwordSubmitting" :disabled="passwordSending || (isSuperAdmin && !passwordEmail)" @click="submit" class="submit-btn">
{{ lang.submit }}
</el-button>
</div>
@ -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,

View File

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