mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
fix(auth): separate passkey and two-step verification (#583)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user