diff --git a/backend/controllers/password_change.go b/backend/controllers/password_change.go new file mode 100644 index 0000000..bd394de --- /dev/null +++ b/backend/controllers/password_change.go @@ -0,0 +1,80 @@ +package controllers + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/dto/response" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/access" + "git.pcux.cn/xuans/Smail/services/verification" + "git.pcux.cn/xuans/Smail/utils/context" +) + +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 + } + user := &models.User{} + found, err := db.Instance.ID(ctx.UserID).Get(user) + 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) + if err != nil { + return nil, nil, "", errPasswordChangeVerification + } + email, err := runtime.external.Decrypt(*binding) + if err != nil || strings.TrimSpace(email) == "" { + return nil, nil, "", errPasswordChangeVerification + } + return user, binding, email, nil +} + +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, "请确认超级管理员已绑定并验证主要外部安全邮箱") + return + } + if input.Email != "" && !strings.EqualFold(strings.TrimSpace(input.Email), email) { + writeSecurityError(w, response.ParamsError, "邮箱地址与绑定的主安全邮箱不匹配") + return + } + challenge, err := runtime.verification.Issue(verification.IssueRequest{ + Purpose: verification.PurposePasswordChange, UserID: user.ID, + Account: user.Account, Email: email, ResourceID: binding.ID, + BrowserTransaction: strconv.Itoa(user.SecurityVersion), + }, remoteIP(req)) + if err != nil { + writeSecurityError(w, response.ParamsError, "验证码发送失败或请求过于频繁,请稍后重试") + return + } + response.NewSuccessResponse(map[string]any{ + "id": challenge.ID, "expires_at": challenge.ExpiresAt.UTC(), "email": maskEmail(email), + }).FPrint(w) +} + +func verifyPasswordChange(ctx *context.Context, req *http.Request, input modifyPasswordRequest) (*models.User, error) { + runtime := currentSecurityRuntime() + user, binding, email, err := passwordChangeIdentity(ctx, runtime) + if err != nil || strings.TrimSpace(input.Code) == "" || strings.TrimSpace(input.ChallengeID) == "" { + return nil, errPasswordChangeVerification + } + if input.Email != "" && !strings.EqualFold(strings.TrimSpace(input.Email), email) { + return nil, errPasswordChangeVerification + } + // 验证码同时绑定用途、账号、邮箱绑定记录及安全版本,不能跨流程复用。 + if err := runtime.verification.VerifyBoundWithContext(input.ChallengeID, user.Account, email, + strings.TrimSpace(input.Code), verification.PurposePasswordChange, binding.ID, + remoteIP(req), strconv.Itoa(user.SecurityVersion)); err != nil { + return nil, err + } + return user, nil +} diff --git a/backend/controllers/password_reset_permissions_test.go b/backend/controllers/password_reset_permissions_test.go index 9b06849..158ac14 100644 --- a/backend/controllers/password_reset_permissions_test.go +++ b/backend/controllers/password_reset_permissions_test.go @@ -5,8 +5,10 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strconv" "strings" "testing" + "time" "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/db" @@ -15,6 +17,8 @@ import ( "git.pcux.cn/xuans/Smail/services/access" "git.pcux.cn/xuans/Smail/services/auth" "git.pcux.cn/xuans/Smail/services/credentials" + "git.pcux.cn/xuans/Smail/services/externalemail" + "git.pcux.cn/xuans/Smail/services/verification" "git.pcux.cn/xuans/Smail/session" appcontext "git.pcux.cn/xuans/Smail/utils/context" "git.pcux.cn/xuans/Smail/utils/password" @@ -179,7 +183,7 @@ func authPasswordMatches(hash, plain string) bool { return password.Verify(hash, plain) } -func TestSuperAdminCannotChangePasswordThroughWeb(t *testing.T) { +func TestSuperAdminCannotChangePasswordWithoutEmailVerification(t *testing.T) { testRoot := t.TempDir() oldConfig := config.Instance oldDB := db.Instance @@ -208,8 +212,159 @@ func TestSuperAdminCannotChangePasswordThroughWeb(t *testing.T) { if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { t.Fatal(err) } - if result.ErrorNo != response.NoAccessPrivileges { + if result.ErrorNo != response.ParamsError { t.Fatalf("expected web reset to be denied, got %d", result.ErrorNo) } assertUserPassword(t, user.ID, "OldPass123") } + +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"} { + t.Run(scenario, func(t *testing.T) { + testRoot := t.TempDir() + oldConfig, oldDB, oldSession := config.Instance, db.Instance, session.Instance + oldRuntime := currentSecurityRuntime() + oldPair, oldAccount, oldIP := loginPairAttempts, loginAccountAttempts, loginIPAttempts + loginPairAttempts = newLoginLimiter(loginFailureLimit) + loginAccountAttempts = newLoginLimiter(loginAccountFailureLimit) + loginIPAttempts = newLoginLimiter(loginIPFailureLimit) + session.Instance = scs.New() + t.Cleanup(func() { + _ = db.Instance.Close() + config.Instance, db.Instance, session.Instance = oldConfig, oldDB, oldSession + loginPairAttempts, loginAccountAttempts, loginIPAttempts = oldPair, oldAccount, oldIP + ConfigureSecurityRuntime(oldRuntime.verification, oldRuntime.external, oldRuntime.recovery, oldRuntime.credentials) + }) + config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(testRoot, "password.db")} + if err := db.Init(""); err != nil { + t.Fatal(err) + } + user := newControllerTestUser(t, "verified-super", "超级管理员", access.RoleSuperAdmin, "OldPass123") + user.SecurityVersion = 1 + if _, err := db.Instance.Insert(&user); err != nil { + t.Fatal(err) + } + vault := externalemail.NewVaultWithKeys([]byte("01234567890123456789012345678901"), []byte("abcdefabcdefabcdefabcdefabcdefab")) + repo, err := externalemail.NewRepository(db.Instance, vault) + if err != nil { + t.Fatal(err) + } + binding, err := repo.Bind(externalemail.BindRequest{UserID: user.ID, Email: "owner@example.com", Type: models.ExternalEmailTypePrimary}) + if err != nil { + t.Fatal(err) + } + if scenario != "unverified" { + if err := repo.Verify(user.ID, binding.ID); err != nil { + t.Fatal(err) + } + } + store, err := verification.NewStore(32) + if err != nil { + t.Fatal(err) + } + var code string + var delivered verification.Challenge + send := func(challenge verification.Challenge, value string) error { + delivered, code = challenge, value + return nil + } + service, err := verification.NewService(store, send, nil) + if err != nil { + t.Fatal(err) + } + ConfigureSecurityRuntime(service, repo, nil, nil) + ctx := &appcontext.Context{UserID: user.ID, UserAccount: user.Account, Role: access.RoleSuperAdmin, Lang: "zhCn"} + issueRecorder := httptest.NewRecorder() + RequestVerification(ctx, issueRecorder, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"purpose":"password_change","account":"verified-super"}`))) + if scenario == "unverified" { + if delivered.ID != "" { + t.Fatal("sent to unverified email") + } + return + } + if delivered.ID == "" || delivered.Email != "owner@example.com" { + t.Fatalf("issue failed: %s", issueRecorder.Body.String()) + } + input := modifyPasswordRequest{Password: "NewPass123", Code: code, ChallengeID: delivered.ID} + switch scenario { + case "missing": + input.Code = "" + case "wrong-code": + input.Code = "not-a-code" + case "wrong-email": + input.Email = "other@example.com" + case "invalid-password": + input.Password = "short" + case "security-version": + _, err = db.Instance.ID(user.ID).Cols("security_version").Update(&models.User{SecurityVersion: 2}) + case "disabled": + _, err = db.Instance.ID(user.ID).Cols("disabled").Update(&models.User{Disabled: 1}) + case "wrong-purpose", "expired": + purpose, ttl := verification.PurposeLogin, time.Minute + if scenario == "expired" { + purpose, ttl = verification.PurposePasswordChange, time.Nanosecond + } + challenge, issueErr := store.Issue(verification.IssueRequest{Purpose: purpose, Account: user.Account, Email: delivered.Email, ResourceID: binding.ID, BrowserTransaction: strconv.Itoa(user.SecurityVersion), TTL: ttl}, send) + if issueErr != nil { + t.Fatal(issueErr) + } + input.ChallengeID, input.Code = challenge.ID, code + if scenario == "expired" { + time.Sleep(time.Millisecond) + } + } + if err != nil { + t.Fatal(err) + } + change := func() int { + body, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body))) + loaded, err := session.Instance.Load(req.Context(), "") + if err != nil { + t.Fatal(err) + } + session.Instance.Put(loaded, "user_id", user.ID) + recorder := httptest.NewRecorder() + ModifyPassword(ctx, recorder, req.WithContext(loaded)) + var result struct { + ErrorNo int `json:"errorNo"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.ErrorNo == 0 && session.Instance.Exists(loaded, "user_id") { + t.Fatal("session survived password change") + } + return result.ErrorNo + } + succeeded := scenario == "success" || scenario == "replay" + if result := change(); (result == 0) != succeeded { + t.Fatalf("unexpected result: %d", result) + } + if !succeeded { + assertUserPassword(t, user.ID, "OldPass123") + if scenario == "invalid-password" { + input.Password = "NewPass123" + if change() != 0 { + t.Fatal("invalid password consumed verification") + } + } + return + } + assertUserPassword(t, user.ID, "NewPass123") + var updated models.User + if _, err := db.Instance.ID(user.ID).Get(&updated); err != nil { + t.Fatal(err) + } + if updated.SessionVersion != 2 || updated.SecurityVersion != 2 { + t.Fatal("security versions not rotated") + } + if scenario == "replay" && change() == 0 { + t.Fatal("verification replay accepted") + } + }) + } +} diff --git a/backend/controllers/security.go b/backend/controllers/security.go index 539395f..4bf63b6 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -482,6 +482,10 @@ func RequestVerification(ctx *context.Context, w http.ResponseWriter, req *http. writeSecurityError(w, response.ParamsError, "请求无效") return } + if input.Purpose == verification.PurposePasswordChange { + requestPasswordChangeVerification(ctx, w, req, input, runtime) + return + } if input.Email == "" || strings.TrimSpace(input.Account) == "" || !verification.IsSupportedPurpose(input.Purpose) || runtime.verification == nil { writeSecurityError(w, response.ParamsError, "请求已受理") return diff --git a/backend/controllers/settings.go b/backend/controllers/settings.go index f425e82..4e95a57 100644 --- a/backend/controllers/settings.go +++ b/backend/controllers/settings.go @@ -22,6 +22,9 @@ import ( type modifyPasswordRequest struct { CurrentPassword string `json:"current_password"` Password string `json:"password"` + Code string `json:"code"` + ChallengeID string `json:"challenge_id"` + Email string `json:"email"` } func ModifyPassword(ctx *context.Context, w http.ResponseWriter, req *http.Request) { @@ -29,17 +32,13 @@ func ModifyPassword(ctx *context.Context, w http.ResponseWriter, req *http.Reque response.NewErrorResponse(response.NoAccessPrivileges, "未登录用户不能修改密码", "").FPrint(w) return } - if ctx.EffectiveRole() == access.RoleSuperAdmin { - response.NewErrorResponse(response.NoAccessPrivileges, "超级管理员必须通过部署端专用通道重置密码", "").FPrint(w) - return - } var retData modifyPasswordRequest if err := decodeJSONRequest(w, req, &retData); err != nil { log.WithContext(ctx).Debug("invalid password change request") response.NewErrorResponse(response.ParamsError, i18n.GetText(ctx.Lang, "unknowError"), "").FPrint(w) return } - if retData.CurrentPassword == "" || retData.Password == "" { + if retData.Password == "" || (ctx.EffectiveRole() != access.RoleSuperAdmin && retData.CurrentPassword == "") { response.NewErrorResponse(response.ParamsError, i18n.GetText(ctx.Lang, "unknowError"), "").FPrint(w) return } @@ -50,13 +49,28 @@ func ModifyPassword(ctx *context.Context, w http.ResponseWriter, req *http.Reque response.NewErrorResponse(response.ParamsError, i18n.GetText(ctx.Lang, "aperror"), "").FPrint(w) return } - currentUser, err := auth.Authenticate(ctx.UserAccount, retData.CurrentPassword) - if err != nil || currentUser.ID != ctx.UserID { + var currentUser *models.User + var err error + if ctx.EffectiveRole() == access.RoleSuperAdmin { + // 先校验新密码格式,避免无效输入消耗一次性验证码。 + if password.ValidateUserPassword(retData.Password) != nil { + writeSecurityError(w, response.ParamsError, "密码长度必须为 8-12 字节") + return + } + currentUser, err = verifyPasswordChange(ctx, req, retData) + } else { + currentUser, err = auth.Authenticate(ctx.UserAccount, retData.CurrentPassword) + } + if err != nil || currentUser == nil || currentUser.ID != ctx.UserID { loginPairAttempts.recordFailure(pairKey) loginAccountAttempts.recordFailure(accountKey) loginIPAttempts.recordFailure(ipKey) log.WithContext(ctx).Info("password change authentication failed") - response.NewErrorResponse(response.ParamsError, i18n.GetText(ctx.Lang, "aperror"), "").FPrint(w) + if ctx.EffectiveRole() == access.RoleSuperAdmin { + writeSecurityError(w, response.ParamsError, "请使用已绑定主安全邮箱的有效验证码,失败后可重新获取") + } else { + response.NewErrorResponse(response.ParamsError, i18n.GetText(ctx.Lang, "aperror"), "").FPrint(w) + } return } loginPairAttempts.clear(pairKey) @@ -107,7 +121,7 @@ func ModifyPassword(ctx *context.Context, w http.ResponseWriter, req *http.Reque } }() - affected, err := tx.Where("id = ? and password = ? and session_version = ?", ctx.UserID, currentUser.Password, currentUser.SessionVersion).Cols("password", "session_version", "security_version", "must_change_password", "password_changed_at").Update(&user) + affected, err := tx.Where("id = ? and password = ? and session_version = ? and security_version = ? and disabled = 0", ctx.UserID, currentUser.Password, currentUser.SessionVersion, currentUser.SecurityVersion).Cols("password", "session_version", "security_version", "must_change_password", "password_changed_at").Update(&user) if err != nil { log.WithContext(ctx).WithError(err).Error("update password failed") response.NewErrorResponse(response.ServerError, i18n.GetText(ctx.Lang, "unknowError"), "").FPrint(w) @@ -129,6 +143,7 @@ func ModifyPassword(ctx *context.Context, w http.ResponseWriter, req *http.Reque return } committed = true + InvalidateSecurityRuntimeUser(ctx.UserID, ctx.UserAccount) if err := session.Instance.Destroy(req.Context()); err != nil { log.WithContext(ctx).WithError(err).Error("session destruction after password change failed") response.NewErrorResponse(response.ServerError, i18n.GetText(ctx.Lang, "unknowError"), "").FPrint(w) diff --git a/backend/listen/http_server/password_change_route_test.go b/backend/listen/http_server/password_change_route_test.go index d6cec32..8f586c6 100644 --- a/backend/listen/http_server/password_change_route_test.go +++ b/backend/listen/http_server/password_change_route_test.go @@ -55,7 +55,7 @@ func TestModifyPasswordRouteRejectsUnauthenticatedRequest(t *testing.T) { } } -func TestSuperAdminCannotChangeOwnPasswordAfterLogin(t *testing.T) { +func TestSuperAdminCannotChangeOwnPasswordWithoutEmailVerification(t *testing.T) { oldConfig := config.Instance oldInit := config.IsInit oldDB := db.Instance @@ -104,7 +104,7 @@ func TestSuperAdminCannotChangeOwnPasswordAfterLogin(t *testing.T) { if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { t.Fatal(err) } - if result.ErrorNo != response.NoAccessPrivileges { + if result.ErrorNo != response.ParamsError { t.Fatalf("super admin web password change was not rejected: %s", recorder.Body.String()) } diff --git a/backend/services/verification/delivery_test.go b/backend/services/verification/delivery_test.go index a331ead..fb4e211 100644 --- a/backend/services/verification/delivery_test.go +++ b/backend/services/verification/delivery_test.go @@ -6,7 +6,7 @@ import ( ) func TestSupportedPurposes(t *testing.T) { - for _, purpose := range []Purpose{PurposeLogin, PurposePasswordRecover, PurposeExternalEmailBind, PurposeExternalEmailChange} { + for _, purpose := range []Purpose{PurposeLogin, PurposePasswordRecover, PurposePasswordChange, PurposeExternalEmailBind, PurposeExternalEmailChange} { if !IsSupportedPurpose(purpose) { t.Fatalf("purpose %q was rejected", purpose) } diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.css b/frontend/src/views/components/SecuritySettings/SecuritySettings.css index d09512d..fb8f11d 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.css +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.css @@ -58,3 +58,15 @@ .credential-actions { width: 100%; } .credential-actions .el-button { flex: 1; } } +.password-verification-row { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + width: 100%; +} + +.password-verification-row strong { + overflow-wrap: anywhere; +} diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.js b/frontend/src/views/components/SecuritySettings/SecuritySettings.js index 0827559..9f87ea5 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.js +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.js @@ -1,4 +1,4 @@ -import { computed, onMounted, reactive, ref, watch } from 'vue' +import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue' import { ElMessageBox, ElNotification } from 'element-plus' @@ -19,9 +19,7 @@ export function usePage() { if (canReadGlobalPolicy.value) { options.push({ value: 'security-policy', label: '安全策略' }) } - if (!isSuperAdmin.value) { - options.push({ value: 'password', label: lang.modify_pwd }) - } + options.push({ value: 'password', label: lang.modify_pwd }) options.push({ value: 'question', label: '密保问题' }) if (!isSuperAdmin.value) { options.push({ value: 'recovery', label: '离线恢复码' }) @@ -60,10 +58,16 @@ 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 passwordSending = ref(false) + const passwordSubmitting = ref(false) + const passwordChallenge = ref('') + const passwordCountdown = ref(0) + let passwordTimer = null let lastNotificationKey = '' let lastNotificationAt = 0 - const ruleForm = reactive({ current_pwd: '', new_pwd: '', new_pwd2: '' }) + const ruleForm = reactive({ current_pwd: '', new_pwd: '', new_pwd2: '', code: '' }) const rules = reactive({ current_pwd: [{ required: true, message: lang.err_required_pwd, trigger: 'blur' }], new_pwd: [{ required: true, message: lang.err_required_pwd, trigger: 'blur' }], @@ -255,16 +259,75 @@ export function usePage() { const logout = () => http.post('/api/v1/logout', {}).then(() => location.reload()) const submit = async () => { - if (!ruleForm.current_pwd || !ruleForm.new_pwd) return + if (passwordSubmitting.value || passwordSending.value) return + if (!ruleForm.new_pwd || (!isSuperAdmin.value && !ruleForm.current_pwd)) { + notify('请填写完整的密码信息') + return + } + const passwordBytes = new TextEncoder().encode(ruleForm.new_pwd).length + if (passwordBytes < 8 || passwordBytes > 12) { + notify('密码长度必须为 8-12 字节') + return + } if (ruleForm.new_pwd !== ruleForm.new_pwd2) { notify(lang.err_pwd_diff) return } - const res = await http.post('/api/v1/settings/modify_password', { current_password: ruleForm.current_pwd, password: ruleForm.new_pwd }) - notify(res.errorNo === 0 ? '密码修改成功' : res.errorMsg, res.errorNo === 0 ? 'success' : 'error') - if (res.errorNo === 0) Object.assign(ruleForm, { current_pwd: '', new_pwd: '', new_pwd2: '' }) + if (isSuperAdmin.value && (!primaryEmail.value || !passwordChallenge.value || !/^\d{8}$/.test(ruleForm.code.trim()))) { + notify('请获取并输入主安全邮箱中的 8 位验证码') + return + } + passwordSubmitting.value = true + try { + const payload = isSuperAdmin.value + ? { password: ruleForm.new_pwd, challenge_id: passwordChallenge.value, code: ruleForm.code.trim() } + : { current_password: ruleForm.current_pwd, password: ruleForm.new_pwd } + const res = await http.post('/api/v1/settings/modify_password', payload) + if (res.errorNo !== 0) throw new Error(res.errorMsg || '密码修改失败') + Object.assign(ruleForm, { current_pwd: '', new_pwd: '', new_pwd2: '', code: '' }) + passwordChallenge.value = '' + notify('密码修改成功,请重新登录;原安全凭据已失效', 'success') + globalStatus.clear() + location.assign('/login') + } catch (error) { + notify(error?.message || '密码修改失败,请重新获取验证码后重试') + } finally { + passwordSubmitting.value = false + } } + const sendPasswordCode = async () => { + if (passwordSending.value || passwordSubmitting.value || passwordCountdown.value > 0 || !primaryEmail.value) return + passwordSending.value = true + passwordChallenge.value = '' + ruleForm.code = '' + try { + const res = await http.post('/api/v1/security/verification/request', { + purpose: 'password_change', account: globalStatus.userInfos.account, + }) + if (res.errorNo !== 0 || !res.data?.id) throw new Error(res.errorMsg || '验证码发送失败') + passwordChallenge.value = res.data.id + passwordCountdown.value = 60 + const deadline = Date.now() + 60000 + clearInterval(passwordTimer) + passwordTimer = setInterval(() => { + 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') + } catch (error) { + notify(error?.message || '验证码发送失败') + } finally { + passwordSending.value = false + } + } + + watch(() => primaryEmail.value?.id, () => { + passwordChallenge.value = '' + ruleForm.code = '' + }) + onUnmounted(() => clearInterval(passwordTimer)) + onMounted(async () => { await loadSecurityData() }) @@ -276,6 +339,7 @@ export function usePage() { return { lang, isSuperAdmin, supported, webAuthnEnabled, sectionOptions, selectedSection, selectSection, globalSecurityPolicyPage, + primaryEmail, 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 5d5356d..49a22d1 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue @@ -15,28 +15,63 @@ ref="globalSecurityPolicyPage" /> -
+

{{ lang.modify_pwd }}

-

{{ lang.modify_pwd_desc }}

+

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

- - - -
{{ lang.submit }}
+ + + + + + + + + + +
+ + {{ lang.submit }} + +
- -

密保问题

-

此设置不会开放超级管理员网页密码恢复,密码仍须通过部署端本机通道重置。

+

密保不用于超级管理员重置密码,请使用修改密码页面的邮箱验证或部署端本机恢复通道。

密保问题不能单独重置密码,必须与离线恢复码组合使用。

@@ -111,6 +146,7 @@ import ExternalEmailBindingDialog from './ExternalEmailBindingDialog/ExternalEma const { lang, isSuperAdmin, supported, webAuthnEnabled, sectionOptions, selectedSection, selectSection, globalSecurityPolicyPage, + primaryEmail, 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 new file mode 100644 index 0000000..61084b8 --- /dev/null +++ b/test/frontend/password-change.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { compileFunction } from 'node:vm' +import { computed, reactive, ref } from './vue.mjs' + +const source = readFileSync(new URL('../../frontend/src/views/components/SecuritySettings/SecuritySettings.js', import.meta.url), 'utf8') +const createPage = compileFunction( + source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage()', + ['computed', 'reactive', 'ref', 'watch', 'onMounted', 'onUnmounted', 'http', 'useGlobalStatusStore', 'webAuthnSupported', 'lang', 'ElNotification', 'TextEncoder', 'location', 'setInterval', 'clearInterval'] +) + +function fixture(role = 'super_admin', post) { + const calls = [], messages = [], redirects = [] + const store = reactive({ role, userInfos: { account: 'root' }, securityActiveSection: 'password', hasCapability: () => false, clear() { this.userInfos = {} } }) + const page = createPage(computed, reactive, ref, () => {}, () => {}, () => {}, { + post: async (url, body) => { + calls.push({ url, body }) + return post ? post(url, body) : { errorNo: 0, data: { id: 'challenge', email: 'o***@example.com' } } + }, + }, () => store, () => false, {}, value => messages.push(value), TextEncoder, + { assign: value => redirects.push(value) }, () => 1, () => {}) + page.externalEmails.value = [{ id: 1, type: 'primary', status: 'verified', email: 'o***@example.com' }] + Object.assign(page.ruleForm, { new_pwd: 'NewPass123', new_pwd2: 'NewPass123' }) + return { page, calls, messages, redirects, store } +} + +test('超级管理员显示修改密码入口,验证码申请不提交脱敏地址,倒计时阻止重复发送', async () => { + const { page, calls } = fixture() + assert.ok(page.sectionOptions.value.some(item => item.value === 'password')) + await page.sendPasswordCode() + await page.sendPasswordCode() + assert.equal(calls.length, 1) + assert.equal(calls[0].body.purpose, 'password_change') + assert.equal(calls[0].body.account, 'root') + assert.equal(calls[0].body.email, undefined) + assert.equal(page.passwordCountdown.value, 60) +}) + +test('超级管理员凭邮箱验证码修改密码,成功后清除身份并重新登录', async () => { + const { page, calls, redirects, store } = fixture() + await page.sendPasswordCode() + page.ruleForm.code = '12345678' + await page.submit() + assert.equal(calls[1].body.challenge_id, 'challenge') + assert.equal(calls[1].body.current_password, undefined) + assert.equal(calls[1].body.code, '12345678') + assert.deepEqual(redirects, ['/login']) + assert.equal(Object.keys(store.userInfos).length, 0) +}) + +test('普通用户和管理员维持当前密码验证', async () => { + for (const role of ['user', 'admin']) { + const { page, calls } = fixture(role) + page.ruleForm.current_pwd = 'OldPass123' + await page.submit() + assert.equal(calls[0].body.current_password, 'OldPass123') + assert.equal(calls[0].body.code, undefined) + } +}) + +test('无主安全邮箱、未获取验证码、密码不一致时不能提交', async () => { + const { page, calls } = fixture() + await page.submit() + page.externalEmails.value = [] + await page.sendPasswordCode() + page.ruleForm.new_pwd2 = 'OtherPass1' + await page.submit() + assert.equal(calls.length, 0) +}) + +test('请求失败不虚报成功、不登出且释放提交状态', async () => { + const { page, messages, redirects, store } = fixture('admin', async () => { throw new Error('服务暂不可用') }) + page.ruleForm.current_pwd = 'OldPass123' + await page.submit() + assert.equal(messages.at(-1).type, 'error') + assert.equal(page.passwordSubmitting.value, false) + assert.equal(redirects.length, 0) + assert.equal(store.userInfos.account, 'root') +})