diff --git a/backend/controllers/security.go b/backend/controllers/security.go index 378fff7..539395f 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -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) diff --git a/backend/services/recovery/email_step.go b/backend/services/recovery/email_step.go new file mode 100644 index 0000000..c484853 --- /dev/null +++ b/backend/services/recovery/email_step.go @@ -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 +} diff --git a/backend/services/recovery/service.go b/backend/services/recovery/service.go index e169d96..401c577 100644 --- a/backend/services/recovery/service.go +++ b/backend/services/recovery/service.go @@ -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) { diff --git a/frontend/src/views/PasswordRecovery/PasswordRecovery.css b/frontend/src/views/PasswordRecovery/PasswordRecovery.css index b7867e5..69d2792 100644 --- a/frontend/src/views/PasswordRecovery/PasswordRecovery.css +++ b/frontend/src/views/PasswordRecovery/PasswordRecovery.css @@ -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; diff --git a/frontend/src/views/PasswordRecovery/PasswordRecovery.js b/frontend/src/views/PasswordRecovery/PasswordRecovery.js index 459f7f8..177fa72 100644 --- a/frontend/src/views/PasswordRecovery/PasswordRecovery.js +++ b/frontend/src/views/PasswordRecovery/PasswordRecovery.js @@ -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, diff --git a/frontend/src/views/PasswordRecovery/PasswordRecovery.vue b/frontend/src/views/PasswordRecovery/PasswordRecovery.vue index a977471..4ea89c2 100644 --- a/frontend/src/views/PasswordRecovery/PasswordRecovery.vue +++ b/frontend/src/views/PasswordRecovery/PasswordRecovery.vue @@ -63,6 +63,7 @@ @@ -81,7 +82,6 @@ v-model="recovery.identifier" autocomplete="username" placeholder="请输入用户名或完整邮箱" - @input="onIdentifierChange" /> @@ -116,13 +116,6 @@ - - - @@ -137,7 +130,6 @@ v-model="recovery.identifier" autocomplete="username" placeholder="请输入用户名或完整邮箱" - @input="onIdentifierChange" /> @@ -145,7 +137,7 @@ @@ -296,6 +288,39 @@ + + +
+

邮箱验证已通过,请输入此管理员账号的离线恢复码。

+ + + + +
+ 取消 + 验证并继续 +
+
+
+
@@ -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()