diff --git a/backend/controllers/security.go b/backend/controllers/security.go index 19e3c01..a45c3eb 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -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 "验证码错误或验证请求已失效" + } +} diff --git a/backend/controllers/security_authentication_test.go b/backend/controllers/security_authentication_test.go index 755dce4..f3c18e8 100644 --- a/backend/controllers/security_authentication_test.go +++ b/backend/controllers/security_authentication_test.go @@ -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) diff --git a/backend/services/externalemail/repository.go b/backend/services/externalemail/repository.go index 44eaae0..64aa6e9 100644 --- a/backend/services/externalemail/repository.go +++ b/backend/services/externalemail/repository.go @@ -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 diff --git a/backend/services/externalemail/repository_test.go b/backend/services/externalemail/repository_test.go index 437e469..aa63535 100644 --- a/backend/services/externalemail/repository_test.go +++ b/backend/services/externalemail/repository_test.go @@ -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) diff --git a/frontend/src/utils/recentAuthentication.js b/frontend/src/utils/recentAuthentication.js index 548dda3..a6d97b5 100644 --- a/frontend/src/utils/recentAuthentication.js +++ b/frontend/src/utils/recentAuthentication.js @@ -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 || '请输入当前密码' diff --git a/frontend/src/views/components/SecuritySettings/ExternalEmailBindingDialog/ExternalEmailBindingDialog.css b/frontend/src/views/components/SecuritySettings/ExternalEmailBindingDialog/ExternalEmailBindingDialog.css new file mode 100644 index 0000000..0208f03 --- /dev/null +++ b/frontend/src/views/components/SecuritySettings/ExternalEmailBindingDialog/ExternalEmailBindingDialog.css @@ -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; + } +} diff --git a/frontend/src/views/components/SecuritySettings/ExternalEmailBindingDialog/ExternalEmailBindingDialog.js b/frontend/src/views/components/SecuritySettings/ExternalEmailBindingDialog/ExternalEmailBindingDialog.js new file mode 100644 index 0000000..58b0735 --- /dev/null +++ b/frontend/src/views/components/SecuritySettings/ExternalEmailBindingDialog/ExternalEmailBindingDialog.js @@ -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, + } +} diff --git a/frontend/src/views/components/SecuritySettings/ExternalEmailBindingDialog/ExternalEmailBindingDialog.vue b/frontend/src/views/components/SecuritySettings/ExternalEmailBindingDialog/ExternalEmailBindingDialog.vue new file mode 100644 index 0000000..42fd70e --- /dev/null +++ b/frontend/src/views/components/SecuritySettings/ExternalEmailBindingDialog/ExternalEmailBindingDialog.vue @@ -0,0 +1,131 @@ + + { if (!value) close() }" + > + + + + + + + + + + + + + + + 验证码已发送至 + {{ challenge.maskedEmail }} + {{ expirationText }} + + + + + + + + + + {{ statusMessage }} + + + {{ errorMessage }} + + + + + 取消 + + 重新发送 + + + 发送验证码 + + + 验证并绑定 + + + + + + + + + diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.css b/frontend/src/views/components/SecuritySettings/SecuritySettings.css index 620c062..d09512d 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.css +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.css @@ -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; } } diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.js b/frontend/src/views/components/SecuritySettings/SecuritySettings.js index 4484076..51ff4a6 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.js +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.js @@ -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, } } diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue index 48619b4..9c606fa 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue @@ -61,19 +61,17 @@ - 外部安全邮箱用于登录二次验证和密码恢复,只显示脱敏地址。 - - - - 绑定并验证 + + 外部安全邮箱用于登录二次验证和密码恢复,只显示脱敏地址。 + 绑定外部邮箱 - + {{ item.email }}{{ item.type === 'primary' ? '主要邮箱' : '备用邮箱' }}{{ item.status === 'verified' ? '已验证' : item.status === 'pending' ? '待验证' : '已停用' }} 停用 - + @@ -92,6 +90,11 @@ {{ lang.logout }}{{ lang.logout_desc }}{{ lang.logout }} + + @@ -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() diff --git a/test/review/frontend-findings.test.mjs b/test/review/frontend-findings.test.mjs index e3cf471..303fe6e 100644 --- a/test/review/frontend-findings.test.mjs +++ b/test/review/frontend-findings.test.mjs @@ -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') }) // 使用真实响应拦截器和页面函数;仅隔离网络、消息提示以及下载所需的浏览器对象。
+ {{ statusMessage }} +
+ {{ errorMessage }} +
用于登录二次验证和密码恢复,只显示脱敏地址。
{{ lang.logout_desc }}