保存外部邮箱验证码绑定当前进度
This commit is contained in:
parent
d0f543d413
commit
2a3d3b420e
@ -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 "验证码错误或验证请求已失效"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 || '请输入当前密码'
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
// 使用真实响应拦截器和页面函数;仅隔离网络、消息提示以及下载所需的浏览器对象。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user