修复忘记密码按账号类型弹出管理员附加验证
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-14 20:30:53 +08:00
parent 1e2f9befae
commit 19d1338d8a
6 changed files with 383 additions and 47 deletions

View File

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

View 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
}

View File

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

View File

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

View File

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

View File

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