保存外部邮箱验证码绑定当前进度

This commit is contained in:
smail 2026-09-08 01:43:41 +08:00
parent d0f543d413
commit 2a3d3b420e
12 changed files with 681 additions and 106 deletions

View File

@ -653,6 +653,9 @@ func ListExternalEmails(ctx *context.Context, w http.ResponseWriter, req *http.R
}
result := make([]map[string]any, 0, len(items))
for _, item := range items {
if item.Status == models.ExternalEmailStatusPending {
continue
}
email, err := runtime.external.Decrypt(item)
if err != nil {
continue
@ -669,7 +672,7 @@ func BindExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Re
return
}
if !ctx.RecentAuth {
writeSecurityError(w, response.NoAccessPrivileges, "此操作需要最近十分钟内完成登录,请重新登录后再试")
writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "此操作需要最近十分钟内完成登录,请确认当前密码", string(access.CodeRecentAuthRequired))
return
}
var input externalEmailRequest
@ -679,7 +682,7 @@ func BindExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Re
}
normalizedEmail, err := externalemail.NormalizeEmail(input.Email)
if err != nil {
writeSecurityError(w, response.ParamsError, "外部邮箱绑定失败")
writeSecurityError(w, response.ParamsError, "请输入有效的外部邮箱地址")
return
}
owned, err := currentUserPrimaryEmail(ctx.UserID, normalizedEmail)
@ -694,14 +697,31 @@ func BindExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.Re
}
input.Email = normalizedEmail
item, err := runtime.external.Bind(externalemail.BindRequest{UserID: ctx.UserID, Email: input.Email, Type: input.Type})
created := err == nil
if errors.Is(err, externalemail.ErrBindingExists) {
item, err = runtime.external.FindPending(ctx.UserID, input.Email, input.Type)
if errors.Is(err, externalemail.ErrBindingNotFound) {
writeSecurityError(w, response.ParamsError, "该外部邮箱已经绑定")
return
}
}
if err != nil {
writeSecurityError(w, response.ParamsError, "外部邮箱绑定失败")
switch {
case errors.Is(err, externalemail.ErrPrimaryLimit):
writeSecurityError(w, response.ParamsError, "主要外部邮箱已存在,请先停用后再绑定")
case errors.Is(err, externalemail.ErrBackupLimit):
writeSecurityError(w, response.ParamsError, "备用外部邮箱已存在,请先停用后再绑定")
default:
writeSecurityError(w, response.ParamsError, "外部邮箱绑定失败")
}
return
}
challenge, err := runtime.verification.Issue(verification.IssueRequest{Purpose: verification.PurposeExternalEmailBind, UserID: ctx.UserID, Account: ctx.UserAccount, Email: input.Email, ResourceID: item.ID}, remoteIP(req))
if err != nil {
_ = runtime.external.AbandonPending(ctx.UserID, item.ID)
writeSecurityError(w, response.ParamsError, "外部邮箱绑定失败")
if created {
_ = runtime.external.AbandonPending(ctx.UserID, item.ID)
}
writeSecurityError(w, response.ParamsError, externalEmailVerificationIssueMessage(err))
return
}
response.NewSuccessResponse(map[string]any{"id": item.ID, "challenge_id": challenge.ID, "status": item.Status, "email": maskEmail(input.Email), "expires_at": challenge.ExpiresAt.UTC()}).FPrint(w)
@ -727,7 +747,7 @@ func VerifyExternalEmail(ctx *context.Context, w http.ResponseWriter, req *http.
return
}
if err := runtime.verification.VerifyBound(input.ID, ctx.UserAccount, input.Email, input.Code, verification.PurposeExternalEmailBind, input.BindingID, remoteIP(req)); err != nil {
writeSecurityError(w, response.ParamsError, "验证失败")
writeSecurityError(w, response.ParamsError, externalEmailVerificationCheckMessage(err))
return
}
if err := runtime.external.Verify(ctx.UserID, input.BindingID); err != nil {
@ -805,3 +825,31 @@ func maskEmail(value string) string {
func writeSecurityError(w http.ResponseWriter, code int, message string) {
response.NewErrorResponse(code, message, "").FPrint(w)
}
func writeSecurityErrorWithCode(w http.ResponseWriter, errorNo int, message, code string) {
response.NewErrorResponse(errorNo, message, response.ConfigErrorData{Code: code}).FPrint(w)
}
func externalEmailVerificationIssueMessage(err error) string {
switch {
case errors.Is(err, verification.ErrRateLimited):
return "验证码发送过于频繁,请稍后再试"
case errors.Is(err, verification.ErrSMTPRelayUnavailable),
errors.Is(err, verification.ErrInvalidSMTPConfig),
errors.Is(err, verification.ErrUnsupportedSMTP):
return "验证码发信服务尚未配置,请先完成 SMTP 发信配置"
default:
return "验证码发送失败,请检查发信服务后重试"
}
}
func externalEmailVerificationCheckMessage(err error) string {
switch {
case errors.Is(err, verification.ErrExpiredChallenge):
return "验证码已过期,请重新发送"
case errors.Is(err, verification.ErrTooManyAttempts):
return "验证码错误次数过多,请重新发送"
default:
return "验证码错误或验证请求已失效"
}
}

View File

@ -10,6 +10,8 @@ import (
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/credentials"
"git.pcux.cn/xuans/Smail/services/externalemail"
"git.pcux.cn/xuans/Smail/services/verification"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
)
@ -67,6 +69,30 @@ func TestSuperAdminCannotManageRecoveryCodesThroughWeb(t *testing.T) {
}
}
func TestBindExternalEmailReturnsStableRecentAuthenticationCode(t *testing.T) {
previous := securityRuntime
securityRuntime.external = &externalemail.Repository{}
securityRuntime.verification = &verification.Service{}
t.Cleanup(func() { securityRuntime = previous })
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/security/external-email/bind",
strings.NewReader(`{"email":"backup@example.com","type":"backup"}`),
)
BindExternalEmail(&appcontext.Context{UserID: 1, UserAccount: "alice"}, recorder, request)
var result response.Response
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
data, ok := result.Data.(map[string]any)
if result.ErrorNo != response.NoAccessPrivileges || !ok || data["code"] != string(access.CodeRecentAuthRequired) {
t.Fatalf("recent authentication response = %+v", result)
}
}
func TestProtocolCredentialHandlersManageCurrentUserCredential(t *testing.T) {
_, _, engine, user, _ := newPublicSecurityFixture(t)
credentialService, err := credentials.NewService(engine)

View File

@ -93,6 +93,39 @@ func (r *Repository) Bind(request BindRequest) (*models.ExternalEmail, error) {
return item, nil
}
// FindPending 返回当前用户同一地址和用途尚未完成的绑定,供重新发送验证码复用。
func (r *Repository) FindPending(userID int, email, emailType string) (*models.ExternalEmail, error) {
if r == nil || r.engine == nil || r.vault == nil || userID <= 0 {
return nil, ErrInvalidBinding
}
if emailType != models.ExternalEmailTypePrimary && emailType != models.ExternalEmailTypeBackup {
return nil, ErrInvalidBinding
}
normalized, err := NormalizeEmail(email)
if err != nil {
return nil, ErrInvalidBinding
}
fingerprint, err := r.vault.Fingerprint(normalized)
if err != nil {
return nil, err
}
item := &models.ExternalEmail{}
has, err := r.engine.Where(
"user_id = ? AND email_fingerprint = ? AND email_type = ? AND status = ?",
userID,
fingerprint,
emailType,
models.ExternalEmailStatusPending,
).Get(item)
if err != nil {
return nil, fmt.Errorf("find pending external email binding: %w", err)
}
if !has {
return nil, ErrBindingNotFound
}
return item, nil
}
func (r *Repository) List(userID int) ([]models.ExternalEmail, error) {
if r == nil || r.engine == nil || userID <= 0 {
return nil, ErrInvalidBinding

View File

@ -30,6 +30,10 @@ func TestRepositoryBindsVerifiesAndScopesByUser(t *testing.T) {
if item.Status != models.ExternalEmailStatusPending || item.EmailCiphertext == "" {
t.Fatalf("unexpected binding: %+v", item)
}
pending, err := repo.FindPending(1, "OWNER@example.com", models.ExternalEmailTypePrimary)
if err != nil || pending.ID != item.ID {
t.Fatalf("pending binding = %+v, err = %v", pending, err)
}
if _, err := repo.Bind(BindRequest{UserID: 1, Email: "other@example.com", Type: models.ExternalEmailTypePrimary}); err != ErrPrimaryLimit {
t.Fatalf("primary limit error = %v", err)
}
@ -39,6 +43,9 @@ func TestRepositoryBindsVerifiesAndScopesByUser(t *testing.T) {
if err := repo.Verify(1, item.ID); err != nil {
t.Fatal(err)
}
if _, err := repo.FindPending(1, "owner@example.com", models.ExternalEmailTypePrimary); err != ErrBindingNotFound {
t.Fatalf("verified binding remained pending: %v", err)
}
stored, err := repo.List(1)
if err != nil || len(stored) != 1 {
t.Fatalf("list result = %v, %v", stored, err)

View File

@ -9,21 +9,27 @@ export function isRecentAuthenticationCancelled(error) {
}
export async function requestWithRecentAuthentication(request) {
let initialResponse
let requiresRecentAuthentication = false
try {
return await request()
initialResponse = await request()
} catch (error) {
if (error?.apiErrorCode !== recentAuthenticationRequired) throw error
requiresRecentAuthentication = true
}
if (!requiresRecentAuthentication && initialResponse?.data?.code !== recentAuthenticationRequired) {
return initialResponse
}
let password = ''
try {
const result = await ElMessageBox.prompt(
'为保护敏感配置,请确认当前登录密码。确认后将自动完成保存。',
'为保护账户安全,请确认当前登录密码。确认后将自动继续当前操作。',
'确认当前密码',
{
inputType: 'password',
inputPlaceholder: '请输入当前密码',
confirmButtonText: '确认并保存',
confirmButtonText: '确认并继续',
cancelButtonText: '取消',
closeOnClickModal: false,
inputValidator: value => String(value || '').length > 0 || '请输入当前密码'

View File

@ -0,0 +1,78 @@
.external-email-form,
.external-email-verification {
display: grid;
gap: 4px;
}
.external-email-form :deep(.el-select) {
width: 100%;
}
.verification-target {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
margin-bottom: 14px;
padding: 12px 14px;
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-sm);
background: var(--pm-bg-secondary);
color: var(--pm-text-secondary);
font-size: 13px;
}
.verification-target strong {
min-width: 0;
color: var(--pm-text-primary);
overflow-wrap: anywhere;
}
.verification-target .expired {
color: var(--el-color-danger);
}
.dialog-feedback {
margin: 10px 0 0;
padding: 9px 12px;
border-radius: var(--pm-radius-sm);
font-size: 13px;
line-height: 1.5;
}
.dialog-feedback.success {
color: var(--el-color-success-dark-2);
background: var(--el-color-success-light-9);
}
.dialog-feedback.error {
color: var(--el-color-danger-dark-2);
background: var(--el-color-danger-light-9);
}
.external-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.external-dialog-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
@media (max-width: 520px) {
.verification-target {
grid-template-columns: 1fr;
gap: 4px;
}
.external-dialog-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.external-dialog-actions :deep(.el-button) {
width: 100%;
margin: 0;
}
}

View File

@ -0,0 +1,228 @@
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { http } from '@/utils/axios'
import {
isRecentAuthenticationCancelled,
requestWithRecentAuthentication,
} from '@/utils/recentAuthentication'
export const externalEmailBindingDialogProps = {
modelValue: {
type: Boolean,
default: false,
},
}
const verificationCodeLength = 8
function responseError(response, fallback) {
const error = new Error(response?.errorMsg || fallback)
if (typeof response?.data?.code === 'string') {
error.apiErrorCode = response.data.code
}
return error
}
function assertSuccess(response, fallback) {
if (!response || response.errorNo !== 0) {
throw responseError(response, fallback)
}
return response
}
export function useExternalEmailBindingDialog(props, emit) {
const form = reactive({
email: '',
type: 'backup',
code: '',
})
const challenge = reactive({
id: '',
bindingId: 0,
maskedEmail: '',
expiresAt: '',
})
const step = ref('details')
const sending = ref(false)
const verifying = ref(false)
const errorMessage = ref('')
const statusMessage = ref('')
const currentTime = ref(Date.now())
let timer = null
const visible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const busy = computed(() => sending.value || verifying.value)
const hasChallenge = computed(() => Boolean(challenge.id && challenge.bindingId))
const remainingSeconds = computed(() => {
if (!challenge.expiresAt) return 0
const expiresAt = new Date(challenge.expiresAt).getTime()
if (!Number.isFinite(expiresAt)) return 0
return Math.max(0, Math.ceil((expiresAt - currentTime.value) / 1000))
})
const expirationText = computed(() => {
if (!hasChallenge.value) return ''
if (remainingSeconds.value <= 0) return '验证码已过期,请重新发送'
const minutes = Math.floor(remainingSeconds.value / 60)
const seconds = remainingSeconds.value % 60
return `${minutes}:${String(seconds).padStart(2, '0')} 后过期`
})
const startTimer = () => {
if (timer) return
currentTime.value = Date.now()
timer = window.setInterval(() => {
currentTime.value = Date.now()
}, 1000)
}
const stopTimer = () => {
if (!timer) return
window.clearInterval(timer)
timer = null
}
const clearMessages = () => {
errorMessage.value = ''
statusMessage.value = ''
}
const resetChallenge = () => {
Object.assign(challenge, {
id: '',
bindingId: 0,
maskedEmail: '',
expiresAt: '',
})
form.code = ''
step.value = 'details'
}
const resetAll = () => {
Object.assign(form, { email: '', type: 'backup', code: '' })
resetChallenge()
clearMessages()
}
const close = () => {
if (busy.value) return
clearMessages()
visible.value = false
}
const sendVerificationCode = async () => {
if (busy.value) return false
const email = form.email.trim().toLowerCase()
if (!email) {
errorMessage.value = '请输入外部邮箱地址'
return false
}
sending.value = true
clearMessages()
try {
const response = await requestWithRecentAuthentication(async () => {
const result = await http.post('/api/v1/security/external-email/bind', {
email,
type: form.type,
})
return assertSuccess(result, '验证码发送失败')
})
if (!response.data?.challenge_id || !response.data?.id) {
throw new Error('服务器返回的验证信息不完整,请稍后重试')
}
form.email = email
form.code = ''
Object.assign(challenge, {
id: response.data.challenge_id,
bindingId: response.data.id,
maskedEmail: response.data.email || email,
expiresAt: response.data.expires_at || '',
})
currentTime.value = Date.now()
step.value = 'verification'
statusMessage.value = `验证码已发送到 ${challenge.maskedEmail}`
return true
} catch (error) {
if (!isRecentAuthenticationCancelled(error)) {
errorMessage.value = error?.message || '验证码发送失败,请稍后重试'
}
return false
} finally {
sending.value = false
}
}
const verifyAndBind = async () => {
if (busy.value || !hasChallenge.value) return false
const code = form.code.trim()
if (!code) {
errorMessage.value = '请输入邮箱验证码'
return false
}
if (remainingSeconds.value <= 0) {
errorMessage.value = '验证码已过期,请重新发送'
return false
}
verifying.value = true
clearMessages()
try {
const response = await http.post('/api/v1/security/external-email/verify', {
id: challenge.id,
binding_id: challenge.bindingId,
email: form.email,
code,
})
assertSuccess(response, '外部邮箱验证失败')
emit('bound')
visible.value = false
resetAll()
return true
} catch (error) {
errorMessage.value = error?.message || '外部邮箱验证失败,请检查验证码后重试'
form.code = ''
return false
} finally {
verifying.value = false
}
}
const handleCodeInput = value => {
form.code = String(value || '').replace(/\s+/g, '').slice(0, verificationCodeLength)
errorMessage.value = ''
}
watch(() => props.modelValue, value => {
if (value) {
startTimer()
if (hasChallenge.value) step.value = 'verification'
} else {
stopTimer()
}
}, { immediate: true })
onBeforeUnmount(stopTimer)
return {
form,
step,
sending,
verifying,
busy,
challenge,
hasChallenge,
errorMessage,
statusMessage,
expirationText,
remainingSeconds,
verificationCodeLength,
visible,
close,
sendVerificationCode,
verifyAndBind,
handleCodeInput,
}
}

View File

@ -0,0 +1,131 @@
<template>
<el-dialog
:model-value="visible"
title="绑定外部安全邮箱"
width="min(520px, calc(100vw - 32px))"
:close-on-click-modal="false"
:close-on-press-escape="!busy"
:show-close="!busy"
class="external-email-binding-dialog"
@update:model-value="value => { if (!value) close() }"
>
<el-form
v-if="step === 'details'"
:model="form"
label-position="top"
class="external-email-form"
@submit.prevent="sendVerificationCode"
>
<el-form-item label="外部邮箱地址" required>
<el-input
v-model="form.email"
type="email"
autocomplete="email"
placeholder="请输入可接收验证码的外部邮箱"
clearable
:disabled="sending"
@input="errorMessage = ''"
@keyup.enter="sendVerificationCode"
/>
</el-form-item>
<el-form-item label="邮箱用途" required>
<el-select v-model="form.type" aria-label="邮箱用途" :disabled="sending">
<el-option label="备用邮箱" value="backup" />
<el-option label="主要邮箱" value="primary" />
</el-select>
</el-form-item>
</el-form>
<div v-else class="external-email-verification">
<div class="verification-target">
<span>验证码已发送至</span>
<strong>{{ challenge.maskedEmail }}</strong>
<span :class="{ expired: remainingSeconds <= 0 }">{{ expirationText }}</span>
</div>
<el-form label-position="top" @submit.prevent="verifyAndBind">
<el-form-item label="邮箱验证码" required>
<el-input
:model-value="form.code"
inputmode="numeric"
autocomplete="one-time-code"
:maxlength="verificationCodeLength"
placeholder="请输入邮箱中的验证码"
clearable
:disabled="verifying"
@update:model-value="handleCodeInput"
@keyup.enter="verifyAndBind"
/>
</el-form-item>
</el-form>
</div>
<p v-if="statusMessage" class="dialog-feedback success" role="status">
{{ statusMessage }}
</p>
<p v-if="errorMessage" class="dialog-feedback error" role="alert">
{{ errorMessage }}
</p>
<template #footer>
<div class="external-dialog-actions">
<el-button :disabled="busy" @click="close">取消</el-button>
<el-button
v-if="step === 'verification'"
:loading="sending"
:disabled="verifying"
@click="sendVerificationCode"
>
重新发送
</el-button>
<el-button
v-if="step === 'details'"
type="primary"
:loading="sending"
@click="sendVerificationCode"
>
发送验证码
</el-button>
<el-button
v-else
type="primary"
:loading="verifying"
:disabled="sending"
@click="verifyAndBind"
>
验证并绑定
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import {
externalEmailBindingDialogProps,
useExternalEmailBindingDialog,
} from './ExternalEmailBindingDialog.js'
const props = defineProps(externalEmailBindingDialogProps)
const emit = defineEmits(['update:modelValue', 'bound'])
const {
form,
step,
sending,
verifying,
busy,
challenge,
errorMessage,
statusMessage,
expirationText,
remainingSeconds,
verificationCodeLength,
visible,
close,
sendVerificationCode,
verifyAndBind,
handleCodeInput,
} = useExternalEmailBindingDialog(props, emit)
</script>
<style scoped src="./ExternalEmailBindingDialog.css"></style>

View File

@ -26,21 +26,19 @@
.settings-divider { margin: 32px 0; border-color: var(--pm-border-color); }
.security-key-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.security-alert { margin-bottom: 18px; }
.external-bind-form {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
gap: 12px;
margin-bottom: 20px;
}
.external-bind-form :deep(.el-input),
.external-bind-form :deep(.el-select) {
width: 100%;
}
.external-bind-form :deep(.el-button) {
justify-self: start;
margin: 0;
.external-email-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.external-email-header > div { min-width: 0; }
.external-email-header > .el-button { flex: none; margin: 0; }
.external-list { display: grid; gap: 12px; min-height: 72px; }
.external-item { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; border: 1px solid var(--pm-border-color); border-radius: var(--pm-radius-sm); background: var(--pm-bg-secondary); }
.external-item > div { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; min-width: 0; }
.external-item strong { width: 100%; color: var(--pm-text-primary); overflow-wrap: anywhere; }
.external-item span { color: var(--pm-text-secondary); font-size: 13px; }
.credential-list { min-height: 72px; display: grid; gap: 12px; }
.credential-item { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 16px; border: 1px solid var(--pm-border-color); border-radius: var(--pm-radius-sm); background: var(--pm-bg-secondary); }
.credential-info { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; min-width: 0; }
@ -56,7 +54,7 @@
"main";
gap: 16px;
}
.security-key-header, .credential-item { flex-direction: column; align-items: stretch; }
.security-key-header, .credential-item, .external-email-header, .external-item { flex-direction: column; align-items: stretch; }
.credential-actions { width: 100%; }
.credential-actions .el-button { flex: 1; }
}

View File

@ -57,11 +57,10 @@ export function usePage() {
const recoveryConfigured = ref(false)
const generatedCodes = ref([])
const externalEmails = ref([])
const externalLoading = ref(false)
const externalListLoading = ref(false)
const externalDialogVisible = ref(false)
let lastNotificationKey = ''
let lastNotificationAt = 0
const bindForm = reactive({ email: '', type: 'backup', challengeId: '', bindingId: 0, code: '' })
let bindingChallenge = null
const ruleForm = reactive({ current_pwd: '', new_pwd: '', new_pwd2: '' })
const rules = reactive({
@ -118,6 +117,21 @@ export function usePage() {
}
}
const loadExternalEmails = async () => {
externalListLoading.value = true
try {
const response = await http.get('/api/v1/security/external-email/list')
if (response.errorNo !== 0) throw new Error(response.errorMsg || '读取外部邮箱失败')
externalEmails.value = Array.isArray(response.data) ? response.data : []
return true
} catch (error) {
notify(error?.message || '读取外部邮箱失败')
return false
} finally {
externalListLoading.value = false
}
}
const registerCredential = async () => {
if (!supported || !webAuthnEnabled.value || registering.value) return
registering.value = true
@ -215,48 +229,13 @@ export function usePage() {
}
}
const bindExternalEmail = async () => {
if (externalLoading.value) return
if (!bindForm.email.trim()) {
notify('请输入外部安全邮箱')
return
}
externalLoading.value = true
try {
const email = bindForm.email.trim()
const type = bindForm.type
if (!bindingChallenge || bindingChallenge.email !== email || bindingChallenge.type !== type) {
bindingChallenge = null
bindForm.challengeId = ''
bindForm.bindingId = 0
const res = await http.post('/api/v1/security/external-email/bind', { email, type })
if (res.errorNo !== 0) throw new Error(res.errorMsg || '外部邮箱绑定失败')
if (!res.data?.challenge_id || !res.data?.id) throw new Error('外部邮箱验证信息不完整')
bindingChallenge = { email, type, maskedEmail: res.data.email, id: res.data.id, challengeId: res.data.challenge_id }
bindForm.challengeId = bindingChallenge.challengeId
bindForm.bindingId = bindingChallenge.id
}
const challenge = bindingChallenge
const prompt = await ElMessageBox.prompt(`验证码已发送到 ${challenge.maskedEmail},请输入验证码`, '验证外部邮箱', {
confirmButtonText: '验证', cancelButtonText: '取消', inputPlaceholder: '请输入验证码',
})
bindForm.code = String(prompt.value).trim()
const verified = await http.post('/api/v1/security/external-email/verify', {
id: challenge.challengeId, binding_id: challenge.id, email: challenge.email, code: bindForm.code,
})
if (verified.errorNo !== 0) throw new Error(verified.errorMsg || '外部邮箱验证失败')
bindForm.email = ''
bindForm.challengeId = ''
bindForm.bindingId = 0
bindingChallenge = null
await loadSecurityData()
notify('外部安全邮箱绑定成功', 'success')
} catch (error) {
if (error !== 'cancel' && error !== 'close') notify(error?.message || '外部邮箱绑定失败')
} finally {
bindForm.code = ''
externalLoading.value = false
}
const openExternalEmailDialog = () => {
externalDialogVisible.value = true
}
const handleExternalEmailBound = async () => {
await loadExternalEmails()
notify('外部安全邮箱绑定成功', 'success')
}
const disableExternalEmail = async item => {
@ -297,9 +276,10 @@ export function usePage() {
lang, isSuperAdmin, supported, webAuthnEnabled, sectionOptions, selectedSection, selectSection,
globalSecurityPolicyPage,
registering, credentialsLoading, credentials, securityLoading,
securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalLoading,
bindForm, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential,
saveSecurityQuestion, generateRecoveryCodes, revokeRecoveryCodes, bindExternalEmail,
securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalListLoading,
externalDialogVisible, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential,
saveSecurityQuestion, generateRecoveryCodes, revokeRecoveryCodes, openExternalEmailDialog,
handleExternalEmailBound,
disableExternalEmail, formatTime, logout,
}
}

View File

@ -61,19 +61,17 @@
<el-divider v-if="selectedSection === 'external-email'" class="settings-divider" />
<section v-if="selectedSection === 'external-email'" class="security-section">
<div class="settings-header"><h3>外部安全邮箱</h3><p class="settings-desc">用于登录二次验证和密码恢复,只显示脱敏地址。</p></div>
<div class="external-bind-form">
<el-input v-model="bindForm.email" placeholder="请输入完整邮箱地址" clearable />
<el-select v-model="bindForm.type" aria-label="邮箱用途"><el-option label="备用邮箱" value="backup" /><el-option label="主要邮箱" value="primary" /></el-select>
<el-button type="primary" :loading="externalLoading" @click="bindExternalEmail">绑定并验证</el-button>
<div class="settings-header external-email-header">
<div><h3>外部安全邮箱</h3><p class="settings-desc">用于登录二次验证和密码恢复,只显示脱敏地址。</p></div>
<el-button type="primary" @click="openExternalEmailDialog">绑定外部邮箱</el-button>
</div>
<div v-if="externalEmails.length" class="external-list">
<div v-if="externalEmails.length" v-loading="externalListLoading" class="external-list">
<div v-for="item in externalEmails" :key="item.id" class="external-item">
<div><strong>{{ item.email }}</strong><span>{{ item.type === 'primary' ? '主要邮箱' : '备用邮箱' }}</span><el-tag size="small" :type="item.status === 'verified' ? 'success' : 'warning'">{{ item.status === 'verified' ? '已验证' : item.status === 'pending' ? '待验证' : '已停用' }}</el-tag></div>
<el-button v-if="item.status !== 'disabled'" type="danger" plain size="small" @click="disableExternalEmail(item)">停用</el-button>
</div>
</div>
<el-empty v-else description="暂无外部安全邮箱" />
<el-empty v-else v-loading="externalListLoading" description="暂无外部安全邮箱" />
</section>
<el-divider v-if="selectedSection === 'webauthn'" class="settings-divider" />
@ -92,6 +90,11 @@
<el-divider v-if="selectedSection === 'logout'" class="settings-divider" />
<section v-if="selectedSection === 'logout'" class="security-section"><div class="settings-header"><h3 class="danger-text">{{ lang.logout }}</h3><p class="settings-desc">{{ lang.logout_desc }}</p></div><el-button type="danger" plain @click="logout" class="logout-btn">{{ lang.logout }}</el-button></section>
<ExternalEmailBindingDialog
v-model="externalDialogVisible"
@bound="handleExternalEmailBound"
/>
</div>
</div>
</div>
@ -101,14 +104,16 @@
import { usePage } from './SecuritySettings.js'
import SettingsSubmenu from '../SettingsSubmenu/SettingsSubmenu.vue'
import GlobalSecurityPolicy from './GlobalSecurityPolicy/GlobalSecurityPolicy.vue'
import ExternalEmailBindingDialog from './ExternalEmailBindingDialog/ExternalEmailBindingDialog.vue'
const {
lang, isSuperAdmin, supported, webAuthnEnabled, sectionOptions, selectedSection, selectSection,
globalSecurityPolicyPage,
registering, credentialsLoading, credentials, securityLoading,
securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalLoading,
bindForm, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential,
saveSecurityQuestion, generateRecoveryCodes, revokeRecoveryCodes, bindExternalEmail,
securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalListLoading,
externalDialogVisible, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential,
saveSecurityQuestion, generateRecoveryCodes, revokeRecoveryCodes, openExternalEmailDialog,
handleExternalEmailBound,
disableExternalEmail, formatTime, logout,
} = usePage()
</script>

View File

@ -149,11 +149,13 @@ for (const action of ['edit', 'refresh']) {
}
const page = compileFunction(source + '\nreturn usePage("mail")', [
'computed', 'onMounted', 'onBeforeUnmount', 'reactive', 'ref', 'ElMessage', 'ElMessageBox',
'http', 'useGlobalStatusStore', 'readFormConfig', 'updateFormConfig', 'validateFormConfig'
'http', 'useGlobalStatusStore', 'isRecentAuthenticationCancelled',
'requestWithRecentAuthentication', 'readFormConfig', 'updateFormConfig', 'validateFormConfig'
])(
Vue.computed, () => {}, () => {}, Vue.reactive, Vue.ref,
{ success() {}, warning() {}, error: message => errors.push(message) }, { confirm: async () => true },
transport, () => ({ hasCapability: () => true }), readFormConfig, updateFormConfig, validateFormConfig
transport, () => ({ hasCapability: () => true }), () => false, request => request(),
readFormConfig, updateFormConfig, validateFormConfig
)
await page.loadAll()
page.updateField(['policy', 'maxMessageBytes'], 8 << 20)
@ -170,38 +172,71 @@ for (const action of ['edit', 'refresh']) {
// 使用真实响应拦截器和页面函数;仅隔离网络、消息提示以及下载所需的浏览器对象。
test('外部邮箱验证码输入错误后必须能够再次输入,而不是重新绑定同一地址', async () => {
const source = read('src/views/components/SecuritySettings/SecuritySettings.js')
.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage')
let bound = false
let prompts = 0
const source = read('src/views/components/SecuritySettings/ExternalEmailBindingDialog/ExternalEmailBindingDialog.js')
.replace(/^import(?:.|\n)*?from .*$/gm, '')
.replace(/export /g, '')
let binds = 0
let verifies = 0
const notifications = []
const page = compileFunction(source + '\nreturn usePage()', [
'computed', 'onMounted', 'reactive', 'ref', 'watch', 'ElMessageBox', 'ElNotification',
'lang', 'http', 'useGlobalStatusStore', 'webAuthnSupported'
const verifyRequests = []
const events = []
const dialog = compileFunction(source + '\nreturn useExternalEmailBindingDialog({ modelValue: false }, emit)', [
'computed', 'onBeforeUnmount', 'reactive', 'ref', 'watch', 'http',
'isRecentAuthenticationCancelled', 'requestWithRecentAuthentication', 'emit'
])(
Vue.computed, () => {}, Vue.reactive, Vue.ref, () => {},
{ prompt: async () => { prompts++; return { value: prompts === 1 ? 'incorrect' : 'correct' } } },
notification => notifications.push(notification.message), {},
{ get: async () => ({ errorNo: 0, data: [] }), post: async url => {
Vue.computed, () => {}, Vue.reactive, Vue.ref, Vue.watch,
{ post: async (url, body) => {
if (url.endsWith('/bind')) {
if (bound) return { errorNo: 400, errorMsg: '外部邮箱绑定失败' }
bound = true
return { errorNo: 0, data: { id: 1, challenge_id: 'audit-binding', email: 'b***@example.com' } }
binds++
return { errorNo: 0, data: { id: 1, challenge_id: 'audit-binding', email: 'b***@example.com', expires_at: new Date(Date.now() + 600000).toISOString() } }
}
assert.ok(url.endsWith('/verify'))
verifies++
verifyRequests.push(body)
return verifies === 1 ? { errorNo: 400, errorMsg: '验证失败' } : { errorNo: 0 }
} },
() => ({ role: 'user', userInfos: {}, securityActiveSection: 'external-email' }), () => false
() => false,
request => request(),
(name, value) => events.push([name, value])
)
page.bindForm.email = 'binding@example.com'
await page.bindExternalEmail()
assert.equal(prompts, 1)
dialog.form.email = 'binding@example.com'
assert.equal(await dialog.sendVerificationCode(), true)
dialog.form.code = 'incorrect'
assert.equal(await dialog.verifyAndBind(), false)
assert.equal(verifies, 1)
await page.bindExternalEmail()
console.log(`binding_pending=${bound} prompt_count=${prompts} verify_count=${verifies} notifications=${JSON.stringify(notifications)}`)
assert.equal(prompts, 2, '验证码首次输入错误后应允许重试验证现有绑定')
dialog.form.code = 'correct'
assert.equal(await dialog.verifyAndBind(), true)
console.log(`bind_count=${binds} verify_count=${verifies} verify_requests=${JSON.stringify(verifyRequests)} events=${JSON.stringify(events)}`)
assert.equal(binds, 1, '验证码错误后不能重新创建同一绑定')
assert.equal(verifies, 2, '验证码首次输入错误后应允许重试验证现有绑定')
assert.equal(verifyRequests[0].id, 'audit-binding')
assert.equal(verifyRequests[1].id, 'audit-binding')
})
test('近期认证要求由 Axios 异常返回时必须确认密码并重试原请求', async () => {
const source = read('src/utils/recentAuthentication.js')
.replace(/^import .*$/gm, '')
.replace(/export /g, '')
let requests = 0
const posts = []
const result = await compileFunction(source + '\nreturn requestWithRecentAuthentication(request)', [
'ElMessageBox', 'http', 'request'
])(
{ prompt: async () => ({ value: 'CurrentPass12' }) },
{ post: async (url, body) => { posts.push([url, body]); return { errorNo: 0 } } },
async () => {
requests++
if (requests === 1) {
const error = new Error('需要近期认证')
error.apiErrorCode = 'RECENT_AUTH_REQUIRED'
throw error
}
return { errorNo: 0, data: { challenge_id: 'retry-success' } }
}
)
assert.equal(requests, 2)
assert.deepEqual(posts, [['/api/v1/security/reauth', { password: 'CurrentPass12' }]])
assert.equal(result.data.challenge_id, 'retry-success')
})
// 使用真实响应拦截器和页面函数;仅隔离网络、消息提示以及下载所需的浏览器对象。