实现安全中心邮箱验证码修改密码并补充测试
This commit is contained in:
parent
14e045a826
commit
b777b2b101
80
backend/controllers/password_change.go
Normal file
80
backend/controllers/password_change.go
Normal file
@ -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
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -15,28 +15,63 @@
|
||||
ref="globalSecurityPolicyPage"
|
||||
/>
|
||||
|
||||
<section v-if="selectedSection === 'password' && !isSuperAdmin" class="security-section">
|
||||
<section v-if="selectedSection === 'password'" class="security-section">
|
||||
<div class="settings-header">
|
||||
<h3>{{ lang.modify_pwd }}</h3>
|
||||
<p class="settings-desc">{{ 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">
|
||||
<el-form-item :label="lang.current_pwd" prop="current_pwd"><el-input v-model="ruleForm.current_pwd" type="password" :placeholder="lang.enter_current_pwd" show-password class="premium-input" /></el-form-item>
|
||||
<el-form-item :label="lang.modify_pwd" prop="new_pwd"><el-input v-model="ruleForm.new_pwd" type="password" :placeholder="lang.enter_new_pwd" show-password class="premium-input" /></el-form-item>
|
||||
<el-form-item :label="lang.enter_again" prop="new_pwd2"><el-input v-model="ruleForm.new_pwd2" type="password" :placeholder="lang.confirm_new_pwd" show-password class="premium-input" /></el-form-item>
|
||||
<div class="form-actions"><el-button type="primary" @click="submit" class="submit-btn">{{ lang.submit }}</el-button></div>
|
||||
<template v-if="isSuperAdmin">
|
||||
<el-form-item label="主要外部安全邮箱">
|
||||
<div v-if="primaryEmail" class="password-verification-row">
|
||||
<strong>{{ primaryEmail.email }}</strong>
|
||||
<el-button
|
||||
:loading="passwordSending"
|
||||
:disabled="passwordCountdown > 0 || passwordSubmitting"
|
||||
@click="sendPasswordCode"
|
||||
>
|
||||
{{ passwordCountdown > 0 ? `${passwordCountdown} 秒后重发` : '获取验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button v-else type="primary" link @click="selectSection('external-email')">
|
||||
请先绑定并验证主要外部安全邮箱
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱验证码" required>
|
||||
<el-input
|
||||
v-model="ruleForm.code"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="8"
|
||||
placeholder="请输入邮箱中的 8 位验证码"
|
||||
:disabled="!primaryEmail || passwordSubmitting"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item v-else :label="lang.current_pwd" prop="current_pwd">
|
||||
<el-input v-model="ruleForm.current_pwd" type="password" autocomplete="current-password" :placeholder="lang.enter_current_pwd" show-password class="premium-input" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="lang.modify_pwd" prop="new_pwd">
|
||||
<el-input v-model="ruleForm.new_pwd" type="password" autocomplete="new-password" :placeholder="lang.enter_new_pwd" show-password class="premium-input" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="lang.enter_again" prop="new_pwd2">
|
||||
<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">
|
||||
{{ lang.submit }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-divider class="settings-divider" />
|
||||
</section>
|
||||
|
||||
<el-alert v-if="selectedSection === 'password' && isSuperAdmin" title="超级管理员密码只能通过部署端专用本机通道重置,网页不会提供修改入口。" type="warning" :closable="false" show-icon class="security-alert" />
|
||||
|
||||
<section v-if="selectedSection === 'question'" class="security-section">
|
||||
<div class="settings-header">
|
||||
<h3>密保问题</h3>
|
||||
<p v-if="isSuperAdmin" class="settings-desc">此设置不会开放超级管理员网页密码恢复,密码仍须通过部署端本机通道重置。</p>
|
||||
<p v-if="isSuperAdmin" class="settings-desc">密保不用于超级管理员重置密码,请使用修改密码页面的邮箱验证或部署端本机恢复通道。</p>
|
||||
<p v-else class="settings-desc">密保问题不能单独重置密码,必须与离线恢复码组合使用。</p>
|
||||
</div>
|
||||
<el-form :model="questionForm" label-position="top" class="settings-form">
|
||||
@ -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,
|
||||
|
||||
80
test/frontend/password-change.test.mjs
Normal file
80
test/frontend/password-change.test.mjs
Normal file
@ -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')
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user