fix(auth): separate passkey and two-step verification (#583)

This commit is contained in:
InfinityPacer
2026-07-24 06:18:43 +08:00
committed by GitHub
parent e63fc9a8bd
commit dc3f0abf9b
13 changed files with 523 additions and 405 deletions
+193
View File
@@ -0,0 +1,193 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { MfaMethod } from '@/types/auth'
interface Props {
/** 当前验证步骤的错误信息。 */
errorMessage: string
/** 密码验证通过后服务端声明的可用方式。 */
methods: MfaMethod[]
/** OTP 提交状态。 */
otpLoading: boolean
/** 当前输入的 OTP。 */
otpPassword: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
(event: 'back'): void
(event: 'otp'): void
(event: 'update:otpPassword', value: string): void
}>()
const { t } = useI18n()
const hasOtp = computed(() => props.methods.includes('otp'))
</script>
<template>
<section class="mfa-step" :class="{ 'mfa-step--unavailable': !hasOtp }" aria-labelledby="mfa-step-title">
<header class="mfa-step__header">
<VBtn
data-testid="mfa-back"
icon="mdi-arrow-left"
size="small"
variant="text"
:aria-label="t('login.mfa.back')"
:disabled="props.otpLoading"
@click="emit('back')"
/>
<div>
<h2 id="mfa-step-title" class="mfa-step__title">{{ t('login.secondaryVerification') }}</h2>
</div>
</header>
<form v-if="hasOtp" data-testid="mfa-otp-form" class="mfa-step__method" @submit.prevent="emit('otp')">
<p class="mfa-step__description">{{ t('login.mfa.otpPrompt') }}</p>
<div class="mfa-step__field">
<VIcon icon="mdi-shield-key" class="mfa-step__field-icon" aria-hidden="true" />
<input
:value="props.otpPassword"
class="mfa-step__input"
type="text"
name="otp"
autocomplete="one-time-code"
inputmode="numeric"
maxlength="6"
:placeholder="t('login.otpCode')"
:aria-label="t('login.otpCode')"
autofocus
:disabled="props.otpLoading"
@input="emit('update:otpPassword', ($event.target as HTMLInputElement).value)"
/>
</div>
<VBtn
block
type="submit"
color="primary"
class="mfa-step__submit"
prepend-icon="mdi-login"
:loading="props.otpLoading"
:disabled="!props.otpPassword"
>
{{ t('login.loginWithOtp') }}
</VBtn>
</form>
<VAlert v-if="props.errorMessage" class="mfa-step__alert" type="error" variant="tonal" role="alert">
{{ props.errorMessage }}
</VAlert>
</section>
</template>
<style scoped>
.mfa-step {
display: flex;
flex-direction: column;
}
.mfa-step__header {
display: grid;
align-items: center;
grid-template-columns: 40px 1fr 40px;
margin-block-end: 20px;
}
.mfa-step__title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
letter-spacing: 0;
line-height: 1.3;
text-align: center;
}
.mfa-step__method {
display: flex;
flex-direction: column;
gap: 12px;
}
.mfa-step__field {
position: relative;
display: flex;
overflow: hidden;
align-items: center;
border: 1px solid rgba(var(--v-border-color), 0.38);
min-block-size: 52px;
border-radius: 12px;
background: rgba(var(--v-theme-surface), 0.13);
transition:
border-color 150ms ease,
box-shadow 150ms ease,
background 220ms ease;
}
.mfa-step__field:focus-within {
border-color: rgb(var(--v-theme-primary));
box-shadow: inset 0 0 0 1px rgb(var(--v-theme-primary));
}
.mfa-step__field-icon {
position: absolute;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
inset-inline-start: 16px;
pointer-events: none;
}
.mfa-step__input {
border: 0;
appearance: none;
background: transparent;
block-size: 50px;
color: rgb(var(--v-theme-on-surface));
font: inherit;
inline-size: 100%;
outline: none;
padding-block: 0;
padding-inline: 48px 16px;
}
.mfa-step__input::placeholder {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
opacity: 1;
}
.mfa-step__input:disabled {
cursor: not-allowed;
opacity: var(--v-disabled-opacity);
}
.mfa-step__submit {
flex: 0 0 48px !important;
block-size: 48px !important;
max-block-size: 48px !important;
min-block-size: 48px !important;
border-radius: 12px;
font-weight: 600;
}
.mfa-step__description {
margin: 0;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
line-height: 1.6;
text-align: center;
}
.mfa-step__alert {
margin-block-start: 18px;
border-radius: 8px;
}
.mfa-step--unavailable {
min-block-size: 0;
}
.mfa-step--unavailable .mfa-step__header {
margin-block-end: 16px;
}
.mfa-step--unavailable .mfa-step__alert {
margin-block-start: 0;
}
</style>
@@ -0,0 +1,47 @@
import { shallowMount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import LoginMfaStep from '@/components/auth/LoginMfaStep.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
const slotStub = { template: '<div><slot /></div>' }
const buttonStub = {
emits: ['click'],
props: ['loading'],
template: '<button :disabled="loading" @click="$emit(\'click\')"><slot /></button>',
}
function mountStep(methods: Array<'otp'>) {
return shallowMount(LoginMfaStep, {
global: {
stubs: {
VAlert: slotStub,
VBtn: buttonStub,
VIcon: true,
VTextField: true,
},
},
props: {
errorMessage: '',
methods,
otpLoading: false,
otpPassword: '',
},
})
}
describe('LoginMfaStep', () => {
it('shows only the OTP form for an OTP-only account', () => {
const wrapper = mountStep(['otp'])
expect(wrapper.find('[data-testid="mfa-otp-form"]').exists()).toBe(true)
})
it('shows no authentication action when the server declares no supported method', () => {
const wrapper = mountStep([])
expect(wrapper.find('[data-testid="mfa-otp-form"]').exists()).toBe(false)
})
})
-102
View File
@@ -1,102 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = withDefaults(
defineProps<{
errorMessage?: string
modelValue?: boolean
otpPassword?: string
passkeyLoading?: boolean
}>(),
{
errorMessage: '',
modelValue: true,
otpPassword: '',
passkeyLoading: false,
},
)
const emit = defineEmits<{
(event: 'close'): void
(event: 'otp'): void
(event: 'passkey'): void
(event: 'update:modelValue', value: boolean): void
(event: 'update:otpPassword', value: string): void
}>()
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
const otpValue = computed({
get: () => props.otpPassword,
set: value => emit('update:otpPassword', value),
})
// 提交 OTP 登录请求。
function submitOtp() {
emit('otp')
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="400" persistent>
<VCard>
<VCardTitle class="text-h5 text-center mt-4 pb-2">{{ t('login.secondaryVerification') }}</VCardTitle>
<VCardText class="pt-0">
<p class="text-center mb-4">{{ t('login.mfa.selectVerificationMethod') }}</p>
<VCard variant="tonal" class="mb-3">
<VCardText>
<VForm @submit.prevent="submitOtp">
<VTextField
v-model="otpValue"
:label="t('login.otpCode')"
:placeholder="t('login.otpPlaceholder')"
type="text"
name="otp"
id="otp"
autocomplete="one-time-code"
inputmode="numeric"
prepend-inner-icon="mdi-shield-key"
class="mb-2"
/>
<VBtn block type="submit" color="primary" :disabled="!otpValue">
{{ t('login.loginWithOtp') }}
</VBtn>
</VForm>
</VCardText>
</VCard>
<VCard variant="tonal">
<VCardText>
<p class="text-body-2 mb-2">{{ t('login.orUsePasskey') }}</p>
<VBtn
block
variant="tonal"
color="success"
class="passkey-btn"
prepend-icon="material-symbols:passkey"
:loading="props.passkeyLoading"
@click="emit('passkey')"
>
{{ t('login.verifyWithPasskey') }}
</VBtn>
</VCardText>
</VCard>
<VAlert v-if="props.errorMessage" type="error" variant="tonal" class="mt-3">
{{ props.errorMessage }}
</VAlert>
<VBtn block variant="text" class="mt-4" @click="visible = false">{{ t('common.cancel') }}</VBtn>
</VCardText>
</VCard>
</VDialog>
</template>
+12 -18
View File
@@ -4,25 +4,20 @@ import QRCode from 'qrcode'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import type { ApiResponse, PassKey } from '@/api/types'
import { useGlobalSettingsStore } from '@/stores'
import type { ApiResponse } from '@/api/types'
interface Props {
modelValue: boolean
isOtp: boolean
passkeyList?: PassKey[]
}
const props = withDefaults(defineProps<Props>(), {
passkeyList: () => [],
})
const props = defineProps<Props>()
const emit = defineEmits(['update:modelValue', 'update:isOtp', 'verifyPassword'])
const { t } = useI18n()
const display = useDisplay()
const $toast = useToast()
const globalSettingsStore = useGlobalSettingsStore()
// 内部状态
const show = computed({
@@ -36,11 +31,9 @@ const otpUri = ref('')
// otp secret
const secret = ref('')
// 确认双重验证
// 当前二次验证设置流程中输入的 6 位验证码
const otpPassword = ref('')
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
// OTP 初始化加载状态
const otpLoading = ref(false)
@@ -132,14 +125,8 @@ async function judgeOtpPassword() {
}
}
// 关闭当前用户的双重验证
// 关闭当前用户的二次验证
function disableOtp() {
// 如果已绑定PassKey,不允许关闭OTP
if (props.passkeyList && props.passkeyList.length > 0 && !allowPasskeyWithoutOtp.value) {
$toast.error(t('profile.disableOtpWithPasskeyError'))
return
}
emit('verifyPassword', {
title: t('profile.disableTwoFactor'),
text: t('profile.confirmToDisableOtp'),
@@ -241,7 +228,14 @@ watch(
</VBtn>
</div>
</div>
<VAlert v-if="secret" :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
<VAlert
v-if="secret"
:title="secret"
variant="tonal"
type="warning"
class="my-4"
:text="t('profile.secretKeyTip')"
>
<template #prepend />
</VAlert>
<VForm @submit.prevent="judgeOtpPassword">
+7 -23
View File
@@ -6,11 +6,9 @@ import { useI18n } from 'vue-i18n'
import { formatDateDifference } from '@core/utils/formatters'
import api from '@/api'
import type { ApiResponse, PassKey } from '@/api/types'
import { useGlobalSettingsStore } from '@/stores'
interface Props {
modelValue: boolean
isOtp: boolean
}
// WebAuthn 相关接口定义
@@ -27,7 +25,6 @@ const emit = defineEmits(['update:modelValue', 'update:passkeyList', 'verifyPass
const { t, locale } = useI18n()
const display = useDisplay()
const $toast = useToast()
const globalSettingsStore = useGlobalSettingsStore()
// 内部状态
const show = computed({
@@ -44,11 +41,7 @@ const passkeyRegistering = ref(false)
// PassKey名称
const passkeyName = ref('')
// PassKey challenge
const passkeyChallenge = ref('')
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
const canRegisterPasskey = computed(() => props.isOtp || allowPasskeyWithoutOtp.value)
const passkeyTransactionToken = ref('')
// 格式化日期
function formatDate(dateStr: string) {
@@ -90,16 +83,16 @@ async function registerPassKey() {
// 1. 开始注册
const startResult = (await api.post('mfa/passkey/register/start', {
name: passkeyName.value,
})) as ApiResponse<{ options: string; challenge: string }>
})) as ApiResponse<{ options: string; transaction_token: string }>
if (!startResult.success) {
$toast.error(startResult.message || t('profile.passkeyRegisterFailed'))
return
}
const { options, challenge } = startResult.data
const { options, transaction_token: transactionToken } = startResult.data
const publicKeyOptions = JSON.parse(options)
passkeyChallenge.value = challenge
passkeyTransactionToken.value = transactionToken
// 2. 调用WebAuthn API
const credential = (await navigator.credentials.create({
@@ -138,7 +131,7 @@ async function registerPassKey() {
// 4. 完成注册
const finishResult = (await api.post('mfa/passkey/register/finish', {
credential: credentialJSON,
challenge: passkeyChallenge.value,
transaction_token: passkeyTransactionToken.value,
name: passkeyName.value,
})) as ApiResponse
@@ -202,7 +195,7 @@ watch(
} else {
// 弹窗关闭时,清空数据
passkeyName.value = ''
passkeyChallenge.value = ''
passkeyTransactionToken.value = ''
passkeyList.value = []
}
},
@@ -236,7 +229,7 @@ watch(
</VAlert>
<!-- 注册新通行密钥 -->
<VCard v-if="canRegisterPasskey" variant="tonal" class="mb-6">
<VCard variant="tonal" class="mb-6">
<VCardText>
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.registerNewPasskey') }}</h5>
<p class="mb-4">{{ t('profile.passkeyDescription') }}</p>
@@ -256,15 +249,6 @@ watch(
</VCardText>
</VCard>
<!-- 未启用 OTP 提示 -->
<VAlert v-else type="error" variant="tonal" class="mb-6" icon="mdi-shield-lock">
<i18n-t keypath="profile.otpRequiredForPasskey" tag="span">
<template #otp>
<b>{{ t('profile.otpAuthenticator') }}</b>
</template>
</i18n-t>
</VAlert>
<!-- 已注册的通行密钥列表 -->
<div v-if="passkeyList.length > 0" class="mt-6 px-4">
<div