This commit is contained in:
parent
1e2f9befae
commit
19d1338d8a
@ -197,6 +197,7 @@ type verificationCheckRequest struct {
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
RecoveryCode string `json:"recovery_code"`
|
||||
Continuation string `json:"continuation"`
|
||||
BindingID int `json:"binding_id"`
|
||||
}
|
||||
|
||||
@ -587,8 +588,19 @@ func VerifyPasswordRecovery(ctx *context.Context, w http.ResponseWriter, req *ht
|
||||
}
|
||||
if methodOffline {
|
||||
ticket, err = runtime.recovery.VerifyOfflineWithContext(input.Account, input.Question, input.Answer, input.RecoveryCode, remoteIP(req), browserContext)
|
||||
} else if strings.EqualFold(strings.TrimSpace(input.Method), "email_recovery_code") {
|
||||
ticket, err = runtime.recovery.ContinueEmailWithContext(input.Continuation, input.Account, input.Email, input.RecoveryCode, browserContext)
|
||||
} else {
|
||||
ticket, err = runtime.recovery.VerifyWithContext(input.ID, input.Account, input.Email, input.Code, input.RecoveryCode, remoteIP(req), browserContext)
|
||||
var result recovery.EmailVerificationResult
|
||||
result, err = runtime.recovery.VerifyEmailStepWithContext(input.ID, input.Account, input.Email, input.Code, remoteIP(req), browserContext)
|
||||
if err == nil {
|
||||
// 附加验证尚未完成,操作日志不能提前标记为身份验证成功。
|
||||
if observer, ok := w.(interface{ ObservePending(bool) }); ok {
|
||||
observer.ObservePending(result.RequiresRecoveryCode)
|
||||
}
|
||||
response.NewSuccessResponse(result).FPrint(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
emailRecoveryFailureIPAttempts.recordFailure(failureKey)
|
||||
|
||||
136
backend/services/recovery/email_step.go
Normal file
136
backend/services/recovery/email_step.go
Normal file
@ -0,0 +1,136 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"strings"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/services/access"
|
||||
"git.pcux.cn/xuans/Smail/services/auth"
|
||||
)
|
||||
|
||||
const maxEmailContinuationAttempts = 5
|
||||
|
||||
// EmailVerificationResult 仅在邮箱核验通过后告知是否需要管理员附加验证。
|
||||
type EmailVerificationResult struct {
|
||||
Ticket string `json:"ticket,omitempty"`
|
||||
RequiresRecoveryCode bool `json:"requires_recovery_code,omitempty"`
|
||||
Continuation string `json:"continuation,omitempty"`
|
||||
ExpiresIn int `json:"expires_in,omitempty"`
|
||||
}
|
||||
|
||||
// 中间凭证与重置票据分开存储,不能用于直接修改密码。
|
||||
type emailContinuation struct {
|
||||
ticket
|
||||
Account string
|
||||
SecurityVersion int
|
||||
Attempts int
|
||||
Busy bool
|
||||
}
|
||||
|
||||
func (s *Service) VerifyEmailStepWithContext(challengeID, account, email, code, remoteIP, browserContext string) (EmailVerificationResult, error) {
|
||||
if strings.TrimSpace(browserContext) == "" {
|
||||
return EmailVerificationResult{}, ErrRecoveryDenied
|
||||
}
|
||||
user, binding, err := s.verifyEmailIdentity(challengeID, account, email, code, remoteIP, browserContext)
|
||||
if err != nil {
|
||||
return EmailVerificationResult{}, err
|
||||
}
|
||||
if user.EffectiveRole() != access.RoleAdmin {
|
||||
id, err := s.issueTicketFor(user.ID, user.SessionVersion, "email", binding.ID, binding.CredentialVersion, browserContext)
|
||||
return EmailVerificationResult{Ticket: id}, err
|
||||
}
|
||||
id, err := newTicketID()
|
||||
if err != nil {
|
||||
return EmailVerificationResult{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := s.now()
|
||||
s.cleanupLocked(now)
|
||||
if len(s.emailPending) >= maxRecoveryTickets {
|
||||
return EmailVerificationResult{}, ErrUnavailable
|
||||
}
|
||||
s.emailPending[ticketKey(id)] = &emailContinuation{
|
||||
ticket: ticket{
|
||||
UserID: user.ID, SessionVersion: user.SessionVersion,
|
||||
CredentialID: binding.ID, CredentialVersion: binding.CredentialVersion,
|
||||
BrowserContextDigest: sha256.Sum256([]byte(strings.TrimSpace(browserContext))),
|
||||
HasBrowserContextDigest: true, ExpiresAt: now.Add(authorizationTTL),
|
||||
},
|
||||
Account: user.Account, SecurityVersion: user.SecurityVersion,
|
||||
}
|
||||
return EmailVerificationResult{
|
||||
RequiresRecoveryCode: true, Continuation: id, ExpiresIn: int(authorizationTTL.Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ContinueEmailWithContext(continuation, identifier, email, recoveryCode, browserContext string) (string, error) {
|
||||
defer clearString(&recoveryCode)
|
||||
if s == nil || s.engine == nil || s.credentials == nil || s.external == nil {
|
||||
return "", ErrUnavailable
|
||||
}
|
||||
account, err := auth.NormalizeIdentifier(identifier)
|
||||
if err != nil || strings.TrimSpace(continuation) == "" || strings.TrimSpace(browserContext) == "" {
|
||||
return "", ErrRecoveryDenied
|
||||
}
|
||||
key := ticketKey(continuation)
|
||||
contextDigest := sha256.Sum256([]byte(strings.TrimSpace(browserContext)))
|
||||
s.mu.Lock()
|
||||
s.cleanupLocked(s.now())
|
||||
pending, ok := s.emailPending[key]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return "", ErrRecoveryExpired
|
||||
}
|
||||
if pending.Busy || !strings.EqualFold(account, pending.Account) || subtle.ConstantTimeCompare(contextDigest[:], pending.BrowserContextDigest[:]) != 1 {
|
||||
s.mu.Unlock()
|
||||
return "", ErrRecoveryDenied
|
||||
}
|
||||
pending.Attempts++
|
||||
pending.Busy = true
|
||||
s.mu.Unlock()
|
||||
|
||||
// 数据库和恢复码哈希验证不占用全局锁;同一凭证同时只能有一个请求验证。
|
||||
invalidate := false
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.emailPending[key] == pending {
|
||||
if invalidate || pending.Attempts >= maxEmailContinuationAttempts {
|
||||
delete(s.emailPending, key)
|
||||
} else {
|
||||
pending.Busy = false
|
||||
}
|
||||
}
|
||||
}()
|
||||
user, err := s.loadRecoverableUser(account)
|
||||
if err != nil || user.ID != pending.UserID || user.EffectiveRole() != access.RoleAdmin || user.SessionVersion != pending.SessionVersion || user.SecurityVersion != pending.SecurityVersion {
|
||||
invalidate = true
|
||||
return "", ErrRecoveryDenied
|
||||
}
|
||||
binding, err := s.external.FindVerifiedByEmail(email)
|
||||
if err != nil || binding.UserID != pending.UserID || binding.ID != pending.CredentialID || binding.CredentialVersion != pending.CredentialVersion {
|
||||
invalidate = true
|
||||
return "", ErrRecoveryDenied
|
||||
}
|
||||
if err := s.credentials.ConsumeRecoveryCode(user.ID, recoveryCode); err != nil {
|
||||
return "", ErrRecoveryDenied
|
||||
}
|
||||
invalidate = true
|
||||
ticketID, err := s.issueTicketFor(user.ID, user.SessionVersion, "email_admin", binding.ID, binding.CredentialVersion, browserContext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.mu.Lock()
|
||||
active := s.emailPending[key] == pending && s.now().Before(pending.ExpiresAt)
|
||||
delete(s.emailPending, key)
|
||||
if !active {
|
||||
delete(s.tickets, ticketKey(ticketID))
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !active {
|
||||
return "", ErrRecoveryExpired
|
||||
}
|
||||
return ticketID, nil
|
||||
}
|
||||
@ -55,6 +55,7 @@ type Service struct {
|
||||
invalidate func(int, string)
|
||||
mu sync.Mutex
|
||||
tickets map[string]ticket
|
||||
emailPending map[string]*emailContinuation
|
||||
decoys map[string]decoyChallenge
|
||||
now func() time.Time
|
||||
}
|
||||
@ -92,7 +93,8 @@ func NewService(engine *xorm.Engine, verify *verification.Service, external *ext
|
||||
}
|
||||
return &Service{
|
||||
engine: engine, verification: verify, external: external, credentials: credential,
|
||||
tickets: make(map[string]ticket), decoys: make(map[string]decoyChallenge), now: time.Now,
|
||||
tickets: make(map[string]ticket), emailPending: make(map[string]*emailContinuation),
|
||||
decoys: make(map[string]decoyChallenge), now: time.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -224,43 +226,47 @@ func (s *Service) Verify(challengeID, account, email, code, recoveryCode, remote
|
||||
}
|
||||
|
||||
func (s *Service) VerifyWithContext(challengeID, account, email, code, recoveryCode, remoteIP, browserContext string) (string, error) {
|
||||
user, item, err := s.verifyEmailIdentity(challengeID, account, email, code, remoteIP, browserContext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
method := "email"
|
||||
if user.EffectiveRole() == access.RoleAdmin {
|
||||
if strings.TrimSpace(recoveryCode) == "" {
|
||||
return "", ErrRecoveryDenied
|
||||
}
|
||||
if err := s.credentials.ConsumeRecoveryCode(user.ID, recoveryCode); err != nil {
|
||||
return "", ErrRecoveryDenied
|
||||
}
|
||||
method = "email_admin"
|
||||
}
|
||||
return s.issueTicketFor(user.ID, user.SessionVersion, method, item.ID, item.CredentialVersion, browserContext)
|
||||
}
|
||||
|
||||
func (s *Service) verifyEmailIdentity(challengeID, account, email, code, remoteIP, browserContext string) (*models.User, *models.ExternalEmail, error) {
|
||||
if s == nil || s.verification == nil {
|
||||
return "", ErrUnavailable
|
||||
return nil, nil, ErrUnavailable
|
||||
}
|
||||
if s.consumeDecoyChallengeWithContext(challengeID, account, email, code, browserContext) {
|
||||
return "", ErrRecoveryDenied
|
||||
return nil, nil, ErrRecoveryDenied
|
||||
}
|
||||
normalizedAccount, normalizeErr := auth.NormalizeIdentifier(account)
|
||||
if normalizeErr != nil {
|
||||
return "", ErrRecoveryDenied
|
||||
return nil, nil, ErrRecoveryDenied
|
||||
}
|
||||
var user models.User
|
||||
has, err := s.engine.Where("account = ? AND disabled = 0", normalizedAccount).Get(&user)
|
||||
if err != nil || !has || user.EffectiveRole() == access.RoleSuperAdmin || s.external == nil {
|
||||
return "", ErrRecoveryDenied
|
||||
return nil, nil, ErrRecoveryDenied
|
||||
}
|
||||
item, err := s.external.FindVerifiedByEmail(email)
|
||||
if err != nil || item.UserID != user.ID {
|
||||
return "", ErrRecoveryDenied
|
||||
return nil, nil, ErrRecoveryDenied
|
||||
}
|
||||
if err := s.verification.VerifyBoundWithContext(challengeID, normalizedAccount, email, code, verification.PurposePasswordRecover, item.ID, remoteIP, browserContext); err != nil {
|
||||
return "", err
|
||||
return nil, nil, err
|
||||
}
|
||||
ticketID, err := s.issueTicketFor(user.ID, user.SessionVersion, "email", item.ID, item.CredentialVersion, browserContext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if user.EffectiveRole() == access.RoleAdmin {
|
||||
if strings.TrimSpace(recoveryCode) == "" {
|
||||
s.invalidateTicket(ticketID)
|
||||
return "", ErrRecoveryDenied
|
||||
}
|
||||
if err := s.credentials.ConsumeRecoveryCode(user.ID, recoveryCode); err != nil {
|
||||
s.invalidateTicket(ticketID)
|
||||
return "", ErrRecoveryDenied
|
||||
}
|
||||
}
|
||||
return ticketID, nil
|
||||
return &user, item, nil
|
||||
}
|
||||
|
||||
func (s *Service) VerifyOffline(identifier, question, answer, recoveryCode, remoteIP string) (string, error) {
|
||||
@ -343,7 +349,10 @@ func (s *Service) CompleteWithContext(ticketID, newPassword, browserContext stri
|
||||
if err != nil || !has || user.Disabled != 0 || user.EffectiveRole() == access.RoleSuperAdmin || user.SessionVersion != item.SessionVersion {
|
||||
return ErrRecoveryDenied
|
||||
}
|
||||
if item.Method == "email" {
|
||||
if item.Method == "email" || item.Method == "email_admin" {
|
||||
if user.EffectiveRole() == access.RoleAdmin && item.Method != "email_admin" {
|
||||
return ErrRecoveryDenied
|
||||
}
|
||||
if s.external == nil || item.CredentialID <= 0 {
|
||||
return ErrRecoveryDenied
|
||||
}
|
||||
@ -457,6 +466,11 @@ func (s *Service) cleanupLocked(now time.Time) {
|
||||
delete(s.tickets, id)
|
||||
}
|
||||
}
|
||||
for id, item := range s.emailPending {
|
||||
if !now.Before(item.ExpiresAt) {
|
||||
delete(s.emailPending, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) issueDecoyChallenge(account, email string) (string, error) {
|
||||
@ -550,6 +564,11 @@ func (s *Service) InvalidateUser(userID int) {
|
||||
delete(s.tickets, id)
|
||||
}
|
||||
}
|
||||
for id, item := range s.emailPending {
|
||||
if item.UserID == userID {
|
||||
delete(s.emailPending, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) invalidateTicket(ticketID string) {
|
||||
|
||||
@ -8,6 +8,35 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 管理员附加验证弹窗 */
|
||||
.admin-recovery-description {
|
||||
margin: 0 0 20px;
|
||||
color: var(--pm-text-secondary, #64748b);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.admin-recovery-dialog :deep(.el-input__wrapper) {
|
||||
min-height: 42px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.admin-recovery-dialog :deep(.el-form-item__error) {
|
||||
position: static;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.admin-recovery-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.admin-recovery-actions .el-button {
|
||||
min-height: 40px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* 步骤导航条 */
|
||||
.recovery-steps-bar {
|
||||
display: flex;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { computed, reactive, ref, onBeforeUnmount } from 'vue'
|
||||
import { computed, reactive, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { http } from '@/utils/axios'
|
||||
import { isValidPasswordLength } from '@/utils/password'
|
||||
@ -9,6 +9,12 @@ export function usePage() {
|
||||
const sendingCode = ref(false)
|
||||
const countdown = ref(0)
|
||||
let timer = null
|
||||
let continuationTimer = null
|
||||
let requestVersion = 0
|
||||
let continuationAttempts = 0
|
||||
const adminRecoveryVisible = ref(false)
|
||||
const adminRecoveryError = ref('')
|
||||
const emailContinuation = ref('')
|
||||
|
||||
const verificationTicket = ref('')
|
||||
const verificationState = ref('')
|
||||
@ -28,6 +34,8 @@ export function usePage() {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
requestVersion++
|
||||
clearAdminVerification()
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
@ -45,18 +53,40 @@ export function usePage() {
|
||||
const isActionDisabled = computed(() => loading.value || sendingCode.value)
|
||||
|
||||
const isCodeButtonDisabled = computed(() =>
|
||||
sendingCode.value ||
|
||||
isActionDisabled.value ||
|
||||
countdown.value > 0 ||
|
||||
!recovery.identifier ||
|
||||
!recovery.email
|
||||
)
|
||||
|
||||
const clearAdminVerification = () => {
|
||||
adminRecoveryVisible.value = false
|
||||
adminRecoveryError.value = ''
|
||||
emailContinuation.value = ''
|
||||
recovery.recoveryCode = ''
|
||||
continuationAttempts = 0
|
||||
if (continuationTimer) {
|
||||
clearTimeout(continuationTimer)
|
||||
continuationTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const onIdentifierChange = () => {
|
||||
requestVersion++
|
||||
clearAdminVerification()
|
||||
recovery.challengeId = ''
|
||||
recovery.code = ''
|
||||
recovery.newPassword = ''
|
||||
recovery.confirmPassword = ''
|
||||
verificationTicket.value = ''
|
||||
verificationState.value = ''
|
||||
}
|
||||
|
||||
// 账号、邮箱或方式变化后,旧请求和旧凭证均不能推进当前流程。
|
||||
watch(() => [recovery.identifier, recovery.email, recovery.method], onIdentifierChange, { flush: 'sync' })
|
||||
|
||||
const clearMethodState = () => {
|
||||
onIdentifierChange()
|
||||
recovery.email = ''
|
||||
recovery.code = ''
|
||||
recovery.challengeId = ''
|
||||
@ -73,6 +103,7 @@ export function usePage() {
|
||||
}
|
||||
|
||||
const switchRecoveryMethod = () => {
|
||||
if (isActionDisabled.value) return
|
||||
recovery.method = recovery.method === 'email' ? 'offline' : 'email'
|
||||
clearMethodState()
|
||||
}
|
||||
@ -92,28 +123,29 @@ export function usePage() {
|
||||
}
|
||||
|
||||
const requestRecoveryCode = async () => {
|
||||
if (isCodeButtonDisabled.value) return
|
||||
if (!recovery.identifier || !recovery.email) {
|
||||
ElMessage.warning('请填写用户名或完整邮箱以及外部安全邮箱')
|
||||
return
|
||||
}
|
||||
recovery.code = ''
|
||||
recovery.challengeId = ''
|
||||
verificationTicket.value = ''
|
||||
verificationState.value = ''
|
||||
onIdentifierChange()
|
||||
const version = requestVersion
|
||||
sendingCode.value = true
|
||||
try {
|
||||
const result = await http.post('/api/v1/security/recovery/begin', {
|
||||
identifier: recovery.identifier,
|
||||
email: recovery.email
|
||||
})
|
||||
if (version !== requestVersion) return
|
||||
if (result.errorNo !== 0 || !result.data?.id) {
|
||||
throw new Error(result.errorMsg || '请求已受理')
|
||||
}
|
||||
recovery.challengeId = result.data.id
|
||||
verificationState.value = '如果账号和外部安全邮箱匹配,验证码将发送到该邮箱'
|
||||
startCountdown()
|
||||
ElMessage.success('验证码已发送,请检查外部安全邮箱')
|
||||
ElMessage.info('请求已受理,请检查外部安全邮箱')
|
||||
} catch (err) {
|
||||
if (version !== requestVersion) return
|
||||
verificationState.value = '请求已受理,请稍后检查外部安全邮箱'
|
||||
ElMessage.info('请求已受理,请稍后检查外部安全邮箱')
|
||||
startCountdown()
|
||||
@ -158,7 +190,9 @@ export function usePage() {
|
||||
}
|
||||
|
||||
const handleStepOneNext = async () => {
|
||||
if (isActionDisabled.value || adminRecoveryVisible.value) return
|
||||
if (!validateStepOne()) return
|
||||
const version = requestVersion
|
||||
loading.value = true
|
||||
verificationState.value = ''
|
||||
try {
|
||||
@ -168,8 +202,7 @@ export function usePage() {
|
||||
id: recovery.challengeId,
|
||||
identifier: recovery.identifier,
|
||||
email: recovery.email,
|
||||
code: recovery.code,
|
||||
recovery_code: recovery.recoveryCode
|
||||
code: recovery.code
|
||||
}
|
||||
: {
|
||||
method: 'offline',
|
||||
@ -179,13 +212,27 @@ export function usePage() {
|
||||
recovery_code: recovery.recoveryCode
|
||||
}
|
||||
const result = await http.post('/api/v1/security/recovery/verify', payload)
|
||||
if (version !== requestVersion) return
|
||||
if (result.errorNo === 0 && result.data?.requires_recovery_code && result.data?.continuation) {
|
||||
clearAdminVerification()
|
||||
emailContinuation.value = result.data.continuation
|
||||
recovery.code = ''
|
||||
recovery.challengeId = ''
|
||||
adminRecoveryVisible.value = true
|
||||
const expiresIn = Math.min(Number(result.data.expires_in) || 300, 300)
|
||||
continuationTimer = setTimeout(() => {
|
||||
onIdentifierChange()
|
||||
verificationState.value = '附加验证已过期,请重新获取邮箱验证码'
|
||||
ElMessage.warning(verificationState.value)
|
||||
}, Math.max(1, expiresIn) * 1000)
|
||||
return
|
||||
}
|
||||
if (result.errorNo !== 0 || !result.data?.ticket) {
|
||||
throw new Error(result.errorMsg || '身份验证失败')
|
||||
}
|
||||
verificationTicket.value = result.data.ticket
|
||||
currentStep.value = 2
|
||||
ElMessage.success('身份核验通过,请设置新密码')
|
||||
acceptVerificationTicket(result.data.ticket)
|
||||
} catch (err) {
|
||||
if (version !== requestVersion) return
|
||||
verificationTicket.value = ''
|
||||
verificationState.value = err.message || '身份验证失败,请核对信息后重试'
|
||||
ElMessage.error(err.message || '身份验证失败,请核对信息后重试')
|
||||
@ -194,7 +241,62 @@ export function usePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const acceptVerificationTicket = (ticket) => {
|
||||
clearAdminVerification()
|
||||
recovery.code = ''
|
||||
recovery.challengeId = ''
|
||||
recovery.answer = ''
|
||||
verificationTicket.value = ticket
|
||||
currentStep.value = 2
|
||||
ElMessage.success('身份核验通过,请设置新密码')
|
||||
}
|
||||
|
||||
const cancelAdminVerification = () => {
|
||||
if (loading.value) return
|
||||
onIdentifierChange()
|
||||
verificationState.value = '已取消附加验证,继续恢复时请重新获取邮箱验证码'
|
||||
}
|
||||
|
||||
const confirmAdminVerification = async () => {
|
||||
if (isActionDisabled.value || !adminRecoveryVisible.value || !emailContinuation.value) return
|
||||
if (!recovery.recoveryCode.trim()) {
|
||||
adminRecoveryError.value = '请输入未使用的离线恢复码'
|
||||
return
|
||||
}
|
||||
const version = requestVersion
|
||||
loading.value = true
|
||||
adminRecoveryError.value = ''
|
||||
try {
|
||||
const result = await http.post('/api/v1/security/recovery/verify', {
|
||||
method: 'email_recovery_code',
|
||||
continuation: emailContinuation.value,
|
||||
identifier: recovery.identifier,
|
||||
email: recovery.email,
|
||||
recovery_code: recovery.recoveryCode
|
||||
})
|
||||
if (version !== requestVersion) return
|
||||
if (result.errorNo !== 0 || !result.data?.ticket) {
|
||||
throw new Error('恢复码无效或验证已过期,请核对后重试')
|
||||
}
|
||||
acceptVerificationTicket(result.data.ticket)
|
||||
} catch (err) {
|
||||
if (version !== requestVersion) return
|
||||
recovery.recoveryCode = ''
|
||||
continuationAttempts++
|
||||
if (continuationAttempts >= 5) {
|
||||
onIdentifierChange()
|
||||
verificationState.value = '附加验证未通过,请重新获取邮箱验证码'
|
||||
ElMessage.error(verificationState.value)
|
||||
} else {
|
||||
adminRecoveryError.value = err.message || '验证失败,请重试'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetStepOne = () => {
|
||||
if (isActionDisabled.value) return
|
||||
recovery.identifier = ''
|
||||
clearMethodState()
|
||||
}
|
||||
@ -216,6 +318,7 @@ export function usePage() {
|
||||
}
|
||||
|
||||
const handleStepTwoNext = async () => {
|
||||
if (isActionDisabled.value) return
|
||||
if (!validateStepTwo()) return
|
||||
if (!verificationTicket.value) {
|
||||
ElMessage.error('验证凭证已失效,请重新进行身份验证')
|
||||
@ -232,6 +335,8 @@ export function usePage() {
|
||||
throw new Error(result.errorMsg || '密码重置失败')
|
||||
}
|
||||
currentStep.value = 3
|
||||
verificationTicket.value = ''
|
||||
resetStepTwo()
|
||||
ElMessage.success('新登录密码重置成功!')
|
||||
} catch (err) {
|
||||
ElMessage.error(err.message || '密码重置失败,请重试')
|
||||
@ -247,8 +352,11 @@ export function usePage() {
|
||||
}
|
||||
|
||||
const goPreviousStep = () => {
|
||||
if (isActionDisabled.value) return
|
||||
if (currentStep.value === 2) {
|
||||
onIdentifierChange()
|
||||
currentStep.value = 1
|
||||
verificationState.value = '请重新进行身份验证'
|
||||
}
|
||||
}
|
||||
|
||||
@ -266,6 +374,10 @@ export function usePage() {
|
||||
isActionDisabled,
|
||||
isCodeButtonDisabled,
|
||||
passwordMismatchError,
|
||||
adminRecoveryVisible,
|
||||
adminRecoveryError,
|
||||
cancelAdminVerification,
|
||||
confirmAdminVerification,
|
||||
switchRecoveryMethod,
|
||||
requestRecoveryCode,
|
||||
handleStepOneNext,
|
||||
|
||||
@ -63,6 +63,7 @@
|
||||
<el-form
|
||||
class="recovery-form"
|
||||
label-position="top"
|
||||
:disabled="isActionDisabled"
|
||||
@submit.prevent="handleStepOneNext"
|
||||
>
|
||||
<!-- 邮箱验证方式:用户名与外部安全邮箱紧密相邻 -->
|
||||
@ -81,7 +82,6 @@
|
||||
v-model="recovery.identifier"
|
||||
autocomplete="username"
|
||||
placeholder="请输入用户名或完整邮箱"
|
||||
@input="onIdentifierChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
@ -116,13 +116,6 @@
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="管理员离线恢复码(普通用户留空)">
|
||||
<el-input
|
||||
v-model="recovery.recoveryCode"
|
||||
autocomplete="one-time-code"
|
||||
placeholder="管理员账号需额外输入离线恢复码"
|
||||
/>
|
||||
</el-form-item>
|
||||
</section>
|
||||
|
||||
<!-- 离线恢复方式 -->
|
||||
@ -137,7 +130,6 @@
|
||||
v-model="recovery.identifier"
|
||||
autocomplete="username"
|
||||
placeholder="请输入用户名或完整邮箱"
|
||||
@input="onIdentifierChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
@ -145,7 +137,7 @@
|
||||
<el-input
|
||||
v-model="recovery.question"
|
||||
autocomplete="off"
|
||||
placeholder="请输入您访置的密保问题"
|
||||
placeholder="请输入您设置的密保问题"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
@ -296,6 +288,39 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
:model-value="adminRecoveryVisible"
|
||||
title="管理员附加验证"
|
||||
width="min(440px, calc(100vw - 32px))"
|
||||
align-center
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!loading"
|
||||
:show-close="!loading"
|
||||
@update:model-value="cancelAdminVerification"
|
||||
>
|
||||
<div class="admin-recovery-dialog">
|
||||
<p class="admin-recovery-description">邮箱验证已通过,请输入此管理员账号的离线恢复码。</p>
|
||||
<el-form label-position="top" :disabled="loading" @submit.prevent="confirmAdminVerification">
|
||||
<el-form-item label="离线恢复码" required :error="adminRecoveryError">
|
||||
<el-input
|
||||
v-model="recovery.recoveryCode"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="off"
|
||||
maxlength="64"
|
||||
placeholder="请输入未使用的离线恢复码"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="admin-recovery-actions">
|
||||
<el-button :disabled="loading" @click="cancelAdminVerification">取消</el-button>
|
||||
<el-button type="primary" native-type="submit" :loading="loading">验证并继续</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
@ -315,6 +340,10 @@ const {
|
||||
isActionDisabled,
|
||||
isCodeButtonDisabled,
|
||||
passwordMismatchError,
|
||||
adminRecoveryVisible,
|
||||
adminRecoveryError,
|
||||
cancelAdminVerification,
|
||||
confirmAdminVerification,
|
||||
switchRecoveryMethod,
|
||||
requestRecoveryCode,
|
||||
handleStepOneNext,
|
||||
@ -323,7 +352,6 @@ const {
|
||||
resetStepOne,
|
||||
resetStepTwo,
|
||||
validatePasswordMatch,
|
||||
onIdentifierChange,
|
||||
goLogin
|
||||
} = usePage()
|
||||
</script>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user