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

View File

@@ -1000,7 +1000,7 @@ export interface User {
is_superuser: boolean
// 头像
avatar: string
// 是否开启双重验证
// 是否开启二次验证
is_otp: boolean
// 用户权限 json
permissions: { [key: string]: any }

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>

View File

@@ -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)
})
})

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>

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">

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

View File

@@ -315,17 +315,15 @@ export default {
stayLoggedIn: 'Stay Logged In',
login: 'Login',
networkError: 'Login failed, please check your network connection!',
authFailure: 'Login failed, please check your username, password or secondary verification!',
authFailure: 'Login failed. Check your username, password, or verification code',
permissionDenied: 'Login failed, you do not have permission to access!',
noPermission: 'Login failed, you have no functional permissions, please contact the administrator!',
serverError: 'Login failed, server error!',
loginFailed: 'Login Failed',
secondaryVerification: 'Secondary Verification',
secondaryVerification: 'Two-Step Verification',
orDivider: 'OR',
loginWithPasskey: 'Login with Passkey',
loginWithOtp: 'Login with OTP',
orUsePasskey: 'Or use Passkey for verification',
verifyWithPasskey: 'Verify with Passkey',
loginWithOtp: 'Verify and Sign In',
otpPlaceholder: 'Enter 6-digit code',
passkeyLoginStartFailed: 'Failed to start Passkey authentication',
passkeyNotSelected: 'No Passkey selected',
@@ -333,10 +331,11 @@ export default {
passkeyAuthCanceled: 'Passkey authentication canceled',
passkeyNotSupported: 'Current browser does not support Passkeys',
passkeySecureContextRequired: 'Passkey requires HTTPS secure connection',
passkeyVerifyFailed: 'Passkey verification failed',
passkeyVerifyFailedRetry: 'Passkey verification failed, please try again',
mfa: {
selectVerificationMethod: 'Please select a verification method',
back: 'Back to login',
methodsUnavailable: 'Verification methods are unavailable. Please sign in again',
otpPrompt: 'Enter the 6-digit code generated by your authenticator',
verificationFailed: 'Verification failed. Check the code and try again',
},
},
menu: {
@@ -1714,7 +1713,7 @@ export default {
basicSettings: 'Basic Settings',
basicSettingsDesc: 'Configure server global functions.',
appDomain: 'Access Domain',
appDomainHint: 'Used to add quick jump links when sending notifications',
appDomainHint: 'MoviePilot access URL used for notification links and passkey verification',
wallpaper: 'Background Wallpaper',
wallpaperHint: 'Choose the source of the login page background',
recognizeSource: 'Recognition Data Source',
@@ -3517,7 +3516,6 @@ export default {
noRecentPlugins: 'None',
},
profile: {
disableOtpWithPasskeyError: 'Please delete all Passkeys before clearing the authenticator!',
personalInfo: 'Personal Information',
uploadNewAvatar: 'Upload New Avatar',
avatarFormatError: 'The uploaded file does not meet requirements, please select a new avatar',
@@ -3544,17 +3542,16 @@ export default {
vocechatUser: 'VoceChat User',
synologychatUser: 'SynologyChat User',
doubanUser: 'Douban User',
setupAuthenticator: 'Setup Authenticator',
authenticatorManagement: 'Authenticator Management',
authenticatorEnabled: 'You have enabled authenticator two-factor authentication',
clearAuthenticatorTip: 'To set up a new authenticator, please clear the current configuration first.',
clearAuthenticator: 'Clear Authenticator',
enableTwoFactor: 'Enable Two-Factor Authentication',
disableTwoFactor: 'Disable Two-Factor Authentication',
setupMfa: 'Setup Two-Factor Authentication',
enableMfa: 'Enable Two-Factor Authentication',
useAuthenticator: 'Use Authenticator',
usePasskey: 'Use Passkey',
setupAuthenticator: 'Set Up Two-Step Verification',
authenticatorManagement: 'Two-Step Verification',
authenticatorEnabled: 'Two-step verification is enabled',
clearAuthenticatorTip: 'Turn it off before setting up another authenticator.',
clearAuthenticator: 'Turn Off Two-Step Verification',
enableTwoFactor: 'Enable Two-Step Verification',
disableTwoFactor: 'Turn Off Two-Step Verification',
accountSecurity: 'Account Security',
otpSecondFactor: 'Two-step verification for password sign-in',
passkeyPasswordless: 'Sign in directly without a password',
enabled: 'Enabled',
keysCount: '{count} keys',
passkeyManagement: 'Passkey Management',
@@ -3577,26 +3574,20 @@ export default {
deletePasskey: 'Delete Passkey',
passkeyDomainWarning:
'The availability of PassKeys is closely related to the {domain}. In a public network environment, please make sure to configure the correct access domain name in "Basic Settings". Domain changes or configuration errors will cause the PassKey to be unusable.',
otpRequiredForPasskey:
'For security reasons, you must first enable {otp} before you can register a PassKey. This is to ensure that you can still log in to your account via OTP code if the PassKey becomes invalid due to domain configuration changes.',
accessDomain: 'access domain name',
otpAuthenticator: 'OTP Authenticator',
otpGenerateFailed: 'Failed to get OTP URI: {message}!',
otpDisableSuccess: 'Two-factor authentication disabled successfully!',
otpDisableFailed: 'Failed to disable OTP: {message}!',
otpGenerateFailed: 'Failed to load authenticator setup: {message}',
otpDisableSuccess: 'Two-step verification turned off',
otpDisableFailed: 'Failed to turn off two-step verification: {message}',
otpCodeRequired: 'Please enter the 6-digit verification code',
otpEnableSuccess: 'Two-factor authentication enabled successfully!',
otpEnableFailed: 'Failed to enable OTP: {message}!',
otpDisableRestrictedByPasskey:
'You have registered Passkeys. Please delete all Passkeys before disabling OTP verification.',
confirmToDisableOtp:
'For security reasons, verifying your login password is required to disable two-factor authentication.',
otpEnableSuccess: 'Two-step verification enabled',
otpEnableFailed: 'Failed to enable two-step verification: {message}',
confirmToDisableOtp: 'Verify your login password before turning off two-step verification.',
confirmToDeletePasskey: 'For security reasons, verifying your login password is required to delete a Passkey.',
authenticatorAppDescription:
'Use an authenticator app like Google Authenticator, Microsoft Authenticator, Authy, or 1Password to scan the QR code and generate a 6-digit code.',
secretKeyTip:
"If you're having trouble with the QR code, select manual entry in your app and enter the code above.",
enterVerificationCode: 'Enter verification code to confirm enabling two-factor authentication',
enterVerificationCode: 'Enter the 6-digit code generated by your authenticator',
avatarFormatTip: 'JPG, PNG, GIF, WEBP formats allowed, maximum size 800KB.',
},
transferHistory: {
@@ -4124,7 +4115,7 @@ export default {
title: 'Basic Settings',
description: 'Set access domain, username/password and network configuration',
appDomain: 'App Domain',
appDomainHint: 'Used to add quick jump links when sending notifications',
appDomainHint: 'MoviePilot access URL used for notification links and passkey verification',
wallpaper: 'Background Wallpaper',
wallpaperHint: 'Choose the source of the login page background',
recognizeSource: 'Recognize Source',

View File

@@ -310,7 +310,7 @@ export default {
stayLoggedIn: '保持登录',
login: '登录',
networkError: '登录失败,请检查网络连接!',
authFailure: '登录失败,请检查用户名、密码或二次验证是否正确!',
authFailure: '登录失败,请检查用户名、密码或验证码',
permissionDenied: '登录失败,您没有权限访问!',
noPermission: '登录失败,您没有任何功能权限,请联系管理员!',
serverError: '登录失败,服务器错误!',
@@ -318,9 +318,7 @@ export default {
secondaryVerification: '二次验证',
orDivider: '或',
loginWithPasskey: '使用通行密钥登录',
loginWithOtp: '使用验证登录',
orUsePasskey: '或使用通行密钥进行验证',
verifyWithPasskey: '使用通行密钥验证',
loginWithOtp: '验证登录',
otpPlaceholder: '请输入6位验证码',
passkeyLoginStartFailed: '启动通行密钥认证失败',
passkeyNotSelected: '未选择通行密钥',
@@ -328,10 +326,11 @@ export default {
passkeyAuthCanceled: '通行密钥认证被取消',
passkeyNotSupported: '当前浏览器不支持通行密钥',
passkeySecureContextRequired: '通行密钥需要 HTTPS 安全连接',
passkeyVerifyFailed: '通行密钥验证失败',
passkeyVerifyFailedRetry: '通行密钥验证失败,请重试',
mfa: {
selectVerificationMethod: '请选择验证方式',
back: '返回登录',
methodsUnavailable: '无法获取验证方式,请重新登录',
otpPrompt: '输入身份验证器生成的 6 位验证码',
verificationFailed: '验证失败,请检查验证码后重试',
},
},
menu: {
@@ -1706,7 +1705,7 @@ export default {
basicSettings: '基础设置',
basicSettingsDesc: '设置服务器的全局功能',
appDomain: '访问域名',
appDomainHint: '用于发送通知时,添加快捷跳转地址',
appDomainHint: 'MoviePilot 的访问地址,用于通知快捷跳转和通行密钥校验',
wallpaper: '背景壁纸',
wallpaperHint: '选择登陆页面背景来源',
recognizeSource: '识别数据源',
@@ -3462,7 +3461,6 @@ export default {
noRecentPlugins: '无',
},
profile: {
disableOtpWithPasskeyError: '请先删除所有通行密钥后再清除身份验证器!',
personalInfo: '个人信息',
uploadNewAvatar: '上传新头像',
avatarFormatError: '上传的文件不符合要求,请重新选择头像',
@@ -3489,17 +3487,16 @@ export default {
vocechatUser: 'VoceChat用户',
synologychatUser: 'SynologyChat用户',
doubanUser: '豆瓣用户',
setupAuthenticator: '设置身份验证',
authenticatorManagement: '身份验证器管理',
authenticatorEnabled: '您已启用身份验证器双重验证',
clearAuthenticatorTip: '如需设置新的身份验证器,请先清除当前配置。',
clearAuthenticator: '清除身份验证',
enableTwoFactor: '开启双重验证',
disableTwoFactor: '关闭双重验证',
setupMfa: '设置双重验证',
enableMfa: '开启双重验证',
useAuthenticator: '使用身份验证器',
usePasskey: '使用通行密钥',
setupAuthenticator: '设置二次验证',
authenticatorManagement: '二次验证',
authenticatorEnabled: '二次验证已启用',
clearAuthenticatorTip: '关闭后可以重新设置身份验证器。',
clearAuthenticator: '关闭二次验证',
enableTwoFactor: '启用二次验证',
disableTwoFactor: '关闭二次验证',
accountSecurity: '账号安全',
otpSecondFactor: '密码登录需二次验证',
passkeyPasswordless: '无需密码直接登录',
enabled: '已启用',
keysCount: '{count} 个密钥',
passkeyManagement: '通行密钥管理',
@@ -3522,23 +3519,19 @@ export default {
deletePasskey: '删除通行密钥',
passkeyDomainWarning:
'通行密钥PassKey的可用性与 {domain} 紧密相关。在公网环境下,请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。',
otpRequiredForPasskey:
'为了安全起见,您必须先启用 {otp} 验证码,然后才能注册通行密钥。这是为了防止在域名配置变动导致 PassKey 失效时,您仍能通过 OTP 码登录账户。',
accessDomain: '访问域名',
otpAuthenticator: 'OTP 身份验证器',
otpGenerateFailed: '获取otp uri失败{message}',
otpDisableSuccess: '关闭登录双重验证成功!',
otpDisableFailed: '关闭otp失败{message}',
otpGenerateFailed: '获取身份验证器设置失败:{message}',
otpDisableSuccess: '二次验证已关闭',
otpDisableFailed: '关闭二次验证失败:{message}',
otpCodeRequired: '请填写6位验证码',
otpEnableSuccess: '开启登录双重验证成功!',
otpEnableFailed: '开启otp失败:{message}',
otpDisableRestrictedByPasskey: '您已注册通行密钥,请先删除所有通行密钥再关闭 OTP 验证。',
confirmToDisableOtp: '为了安全起见,关闭双重验证需要验证您的登录密码。',
otpEnableSuccess: '二次验证已启用',
otpEnableFailed: '启用二次验证失败:{message}',
confirmToDisableOtp: '关闭二次验证前需要验证登录密码。',
confirmToDeletePasskey: '为了安全起见,删除通行密钥需要验证您的登录密码。',
authenticatorAppDescription:
'使用 Google Authenticator、Microsoft Authenticator、Authy 或 1Password 等验证器应用扫描二维码,获取 6 位验证码。',
secretKeyTip: '如果您在使用二维码时遇到困难,请在您的应用程序中选择手动输入以上代码。',
enterVerificationCode: '输入验证码以确认开启双重验证',
enterVerificationCode: '输入身份验证器生成的 6 位验证',
avatarFormatTip: '允许 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。',
},
transferHistory: {
@@ -4063,7 +4056,7 @@ export default {
title: '基础设置',
description: '设置访问域名、用户名密码和网络配置',
appDomain: '访问域名',
appDomainHint: '用于发送通知时,添加快捷跳转地址',
appDomainHint: 'MoviePilot 的访问地址,用于通知快捷跳转和通行密钥校验',
wallpaper: '背景壁纸',
wallpaperHint: '选择登录页面背景来源',
recognizeSource: '识别数据源',

View File

@@ -310,7 +310,7 @@ export default {
stayLoggedIn: '保持登錄',
login: '登錄',
networkError: '登錄失敗,請檢查網絡連接!',
authFailure: '登錄失敗,請檢查用戶名、密碼或二次驗證是否正確!',
authFailure: '登錄失敗,請檢查用戶名、密碼或驗證碼',
permissionDenied: '登錄失敗,您沒有權限訪問!',
serverError: '登錄失敗,服務器錯誤!',
noPermission: '登錄失敗,您沒有任何功能權限,請聯繫管理員!',
@@ -318,9 +318,7 @@ export default {
secondaryVerification: '二次驗證',
orDivider: '或',
loginWithPasskey: '使用通行密鑰登錄',
loginWithOtp: '使用驗證登錄',
orUsePasskey: '或使用通行密鑰進行驗證',
verifyWithPasskey: '使用通行密鑰驗證',
loginWithOtp: '驗證登錄',
otpPlaceholder: '請輸入6位驗證碼',
passkeyLoginStartFailed: '啟動通行密鑰驗證失敗',
passkeyNotSelected: '未選擇通行密鑰',
@@ -328,10 +326,11 @@ export default {
passkeyAuthCanceled: '通行密鑰驗證被取消',
passkeyNotSupported: '當前瀏覽器不支援通行密鑰',
passkeySecureContextRequired: '通行密鑰需要 HTTPS 安全連接',
passkeyVerifyFailed: '通行密鑰驗证失敗',
passkeyVerifyFailedRetry: '通行密鑰驗证失敗,請重試',
mfa: {
selectVerificationMethod: '請選擇驗证方式',
back: '返回登錄',
methodsUnavailable: '無法取得驗證方式,請重新登錄',
otpPrompt: '輸入身份驗證器生成的 6 位驗證碼',
verificationFailed: '驗證失敗,請檢查驗證碼後重試',
},
},
menu: {
@@ -1705,7 +1704,7 @@ export default {
basicSettings: '基礎設置',
basicSettingsDesc: '設置服務器的全局功能',
appDomain: '訪問域名',
appDomainHint: '用於發送通知時,添加快捷跳轉地址',
appDomainHint: 'MoviePilot 的存取網址,用於通知快速跳轉和通行密鑰驗證',
wallpaper: '背景壁紙',
wallpaperHint: '選擇登陸頁面背景來源',
recognizeSource: '識別數據源',
@@ -3459,7 +3458,6 @@ export default {
noRecentPlugins: '無',
},
profile: {
disableOtpWithPasskeyError: '請先刪除所有通行密鑰後再清除身份驗證器!',
personalInfo: '個人信息',
uploadNewAvatar: '上傳新頭像',
avatarFormatError: '上傳的文件不符合要求,請重新選擇頭像',
@@ -3486,17 +3484,16 @@ export default {
vocechatUser: 'VoceChat用戶',
synologychatUser: 'SynologyChat用戶',
doubanUser: '豆瓣用戶',
setupAuthenticator: '設置身份驗證',
authenticatorManagement: '身份驗證器管理',
authenticatorEnabled: '您已啟用身份驗證器雙重驗證',
clearAuthenticatorTip: '如需設置新的身份驗證器,請先清除當前配置。',
clearAuthenticator: '清除身份驗證',
enableTwoFactor: '開啟雙重驗證',
disableTwoFactor: '關閉雙重驗證',
setupMfa: '設置雙重驗證',
enableMfa: '開啟雙重驗證',
useAuthenticator: '使用身份驗證器',
usePasskey: '使用通行密鑰',
setupAuthenticator: '設置二次驗證',
authenticatorManagement: '二次驗證',
authenticatorEnabled: '二次驗證已啟用',
clearAuthenticatorTip: '關閉後可以重新設置身份驗證器。',
clearAuthenticator: '關閉二次驗證',
enableTwoFactor: '啟用二次驗證',
disableTwoFactor: '關閉二次驗證',
accountSecurity: '帳號安全',
otpSecondFactor: '密碼登錄需二次驗證',
passkeyPasswordless: '無需密碼直接登錄',
enabled: '已啟用',
keysCount: '{count} 個密鑰',
passkeyManagement: '通行密鑰管理',
@@ -3519,23 +3516,19 @@ export default {
deletePasskey: '刪除通行密鑰',
passkeyDomainWarning:
'通行密鑰PassKey的可用性與 {domain} 緊密相關。在公網環境下,請務必在「基本設定」中配置正確的訪問域名。域名變更或配置錯誤將導致通行密鑰無法使用。',
otpRequiredForPasskey:
'為了安全起見,您必須先啟用 {otp} 驗證碼,然後才能註冊通行密鑰。這是為了防止在網域配置變動導致 PassKey 失效時,您仍能通過 OTP 碼登入帳戶。',
accessDomain: '訪問域名',
otpAuthenticator: 'OTP 身份驗證器',
otpGenerateFailed: '獲取otp uri失敗{message}',
otpDisableSuccess: '關閉登錄雙重驗證成功!',
otpDisableFailed: '關閉otp失敗{message}',
otpGenerateFailed: '獲取身份驗證器設置失敗:{message}',
otpDisableSuccess: '二次驗證已關閉',
otpDisableFailed: '關閉二次驗證失敗:{message}',
otpCodeRequired: '請填寫6位驗證碼',
otpEnableSuccess: '開啟登錄雙重驗證成功!',
otpEnableFailed: '開啟otp失敗:{message}',
otpDisableRestrictedByPasskey: '您已註冊通行密鑰,請先刪除所有通行密鑰再關閉 OTP 驗證。',
confirmToDisableOtp: '為了安全起見,關閉雙重驗證需要驗證您的登錄密碼。',
otpEnableSuccess: '二次驗證已啟用',
otpEnableFailed: '啟用二次驗證失敗:{message}',
confirmToDisableOtp: '關閉二次驗證前需要驗證登錄密碼。',
confirmToDeletePasskey: '為了安全起見,刪除通行密鑰需要驗證您的登錄密碼。',
authenticatorAppDescription:
'使用 Google Authenticator、Microsoft Authenticator、Authy 或 1Password 等驗證器應用程式掃描 QR Code取得 6 位數驗證碼。',
secretKeyTip: '如果您在使用二維碼時遇到困難,請在您的應用程序中選擇手動輸入以上代碼。',
enterVerificationCode: '輸入驗證碼以確認開啟雙重驗證',
enterVerificationCode: '輸入身份驗證器生成的 6 位驗證',
avatarFormatTip: '允許 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。',
},
transferHistory: {
@@ -4060,7 +4053,7 @@ export default {
title: '基礎設定',
description: '設定存取網域、用戶名密碼和網路配置',
appDomain: '存取網域',
appDomainHint: '用於發送通知時,新增快速跳轉位址',
appDomainHint: 'MoviePilot 的存取網址,用於通知快速跳轉和通行密鑰驗證',
wallpaper: '背景桌布',
wallpaperHint: '選擇登入頁面背景來源',
recognizeSource: '識別資料來源',

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import type { AxiosError } from 'axios'
import type { Component } from 'vue'
import { useAuthStore, useUserStore } from '@/stores'
import { authState, userState } from '@/stores/types'
import api from '@/api'
import router from '@/router'
import LoginMfaStep from '@/components/auth/LoginMfaStep.vue'
import OpticalLogoLab from '@/components/misc/OpticalLogoLab.vue'
import { bufferToBase64Url, base64UrlToUint8Array, urlBase64ToUint8Array } from '@/@core/utils/navigator'
import { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'
@@ -11,10 +13,8 @@ import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
import { getNavMenus } from '@/router/i18n-menu'
import { buildUserPermissionContext, filterMenusByPermission } from '@/utils/permission'
import type { ApiResponse } from '@/api/types'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { loadRemoteComponentFromModule, type RemoteModule } from '@/utils/federationLoader'
const LoginMfaDialog = defineAsyncComponent(() => import('@/components/dialog/LoginMfaDialog.vue'))
import type { MfaMethod } from '@/types/auth'
const loginRootRef = ref<HTMLElement | null>(null)
type LabTapTarget = 'logo' | 'title'
@@ -119,15 +119,11 @@ const isPasswordVisible = ref(false)
// 错误信息
const errorMessage = ref('')
// 是否开启双重验证
const isOTP = ref(false)
const mfaStepActive = ref(false)
// 二次验证对话框
const mfaDialog = ref(false)
const mfaOtpLoading = ref(false)
// MFA PassKey loading
const mfaPasskeyLoading = ref(false)
let mfaDialogController: ReturnType<typeof openSharedDialog> | null = null
const mfaMethods = ref<MfaMethod[]>([])
// 语言选择菜单
const langMenu = ref(false)
@@ -169,6 +165,32 @@ interface PluginAuthPayload {
ticket?: string
}
interface ApiErrorPayload {
detail?: unknown
mfa_methods?: unknown
}
interface SerializedCredentialDescriptor extends Omit<PublicKeyCredentialDescriptor, 'id'> {
id: string
}
interface SerializedPublicKeyRequestOptions extends Omit<
PublicKeyCredentialRequestOptions,
'allowCredentials' | 'challenge'
> {
allowCredentials?: SerializedCredentialDescriptor[]
challenge: string
}
function getErrorMessage(error: unknown): string | undefined {
return error instanceof Error ? error.message : undefined
}
// Axios 请求失败由响应状态和结构化响应体共同决定登录错误语义。
function asApiError(error: unknown): AxiosError<ApiErrorPayload> {
return error as AxiosError<ApiErrorPayload>
}
// 登录认证提供方
const authProviders = ref<LoginAuthProvider[]>([])
const selectedAuthProvider = ref<LoginAuthProvider | null>(null)
@@ -208,44 +230,37 @@ function syncLoginCredentialValues() {
}
}
// 生成 MFA 共享弹窗使用的最新 props
function getMfaDialogProps() {
return {
errorMessage: errorMessage.value,
otpPassword: form.value.otp_password,
passkeyLoading: mfaPasskeyLoading.value,
}
// 只接受服务端明确声明的 MFA 方法,异常响应不得在客户端虚构认证能力
function normalizeMfaMethods(value: unknown): MfaMethod[] {
if (!Array.isArray(value)) return []
return [...new Set(value.filter((method): method is MfaMethod => method === 'otp'))]
}
// 打开 MFA 共享弹窗。
function openMfaDialog() {
mfaDialog.value = true
const dialogProps = getMfaDialogProps()
if (mfaDialogController) {
mfaDialogController.updateProps(dialogProps)
function enterMfaStep(methodsValue: unknown) {
conditionalAbortController?.abort()
conditionalAbortController = null
mfaMethods.value = normalizeMfaMethods(methodsValue)
form.value.otp_password = ''
if (!mfaMethods.value.length) {
errorMessage.value = t('login.mfa.methodsUnavailable')
mfaStepActive.value = false
return
}
mfaDialogController = openSharedDialog(
LoginMfaDialog,
dialogProps,
{
close: closeMfaDialog,
otp: loginWithOTP,
passkey: verifyWithPassKey,
'update:otpPassword': (value: string) => {
form.value.otp_password = value
},
},
{ closeOn: ['close'] },
)
errorMessage.value = ''
mfaStepActive.value = true
}
// 关闭 MFA 共享弹窗
function closeMfaDialog() {
mfaDialog.value = false
mfaDialogController?.close()
mfaDialogController = null
// 用户主动返回账号密码步骤时清理未完成的二次验证
function leaveMfaStep() {
manualAbortController?.abort()
manualAbortController = null
mfaOtpLoading.value = false
mfaMethods.value = []
form.value.otp_password = ''
errorMessage.value = ''
mfaStepActive.value = false
}
// 加载未登录可用的认证提供方。
@@ -272,9 +287,9 @@ async function openPluginAuth(provider: LoginAuthProvider) {
provider.remote,
provider.component || 'AuthPage',
)) as Component
} catch (error: any) {
} catch (error: unknown) {
console.error('加载插件认证页面失败:', error)
pluginAuthError.value = error?.message || t('login.authFailure')
pluginAuthError.value = getErrorMessage(error) || t('login.authFailure')
} finally {
pluginAuthLoading.value = false
}
@@ -292,12 +307,15 @@ function closePluginAuth() {
async function exchangePluginAuthTicket(ticket: string) {
pluginAuthLoading.value = true
try {
const response: any = await api.post('auth/exchange', { ticket })
const response = (await api.post('auth/exchange', { ticket })) as PassKeyFinishResponse
closePluginAuth()
await handleLoginSuccess(response)
} catch (error: any) {
} catch (error: unknown) {
console.error('插件认证票据兑换失败:', error)
pluginAuthError.value = error?.response?.data?.detail || error?.message || t('login.authFailure')
const apiError = asApiError(error)
const detail = apiError.response?.data?.detail
pluginAuthError.value =
(typeof detail === 'string' ? detail : undefined) || getErrorMessage(error) || t('login.authFailure')
} finally {
pluginAuthLoading.value = false
}
@@ -313,13 +331,13 @@ async function handlePluginAuthenticated(payload: PluginAuthPayload) {
}
// 处理插件认证失败事件。
function handlePluginAuthError(error: any) {
pluginAuthError.value = error?.message || String(error || '') || t('login.authFailure')
function handlePluginAuthError(error: unknown) {
pluginAuthError.value = getErrorMessage(error) || String(error || '') || t('login.authFailure')
}
// PassKey 认证核心函数 - 处理 WebAuthn 认证流程
interface PassKeyAuthOptions {
username?: string // 可选用户名,用于 MFA 场景
username?: string // 可选用户名用于限制当前直接登录可选择的凭证
isConditional?: boolean // 是否为 Conditional UI 模式
signal?: AbortSignal // AbortController 信号
}
@@ -327,7 +345,7 @@ interface PassKeyAuthOptions {
// PassKey API 响应类型
interface PassKeyStartResponse {
options: string // JSON 字符串
challenge: string
transaction_token: string
}
interface PassKeyFinishResponse {
@@ -355,15 +373,15 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis
throw new Error(startResponse.message || 'PassKey start failed')
}
const { options: optionsStr, challenge } = startResponse.data
const publicKeyOptions = JSON.parse(optionsStr)
const { options: optionsStr, transaction_token: transactionToken } = startResponse.data
const publicKeyOptions = JSON.parse(optionsStr) as SerializedPublicKeyRequestOptions
// 2. 调用WebAuthn API
const credentialRequestOptions: CredentialRequestOptions = {
publicKey: {
...publicKeyOptions,
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
allowCredentials: publicKeyOptions.allowCredentials?.map((cred: any) => ({
allowCredentials: publicKeyOptions.allowCredentials?.map(cred => ({
...cred,
id: base64UrlToUint8Array(cred.id),
})),
@@ -407,7 +425,7 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis
// 4. 完成认证
const finishResponse = (await api.post('/mfa/passkey/authenticate/finish', {
credential: credentialJSON,
challenge: challenge,
transaction_token: transactionToken,
})) as PassKeyFinishResponse
if (!finishResponse || !finishResponse.access_token) {
@@ -471,27 +489,30 @@ async function handlePassKeyAuth(
})
await onSuccess(finishResponse)
} catch (error: any) {
} catch (error: unknown) {
const errorName = error instanceof Error ? error.name : ''
const message = getErrorMessage(error)
// Conditional UI 模式下:
// 1. 如果 loading 为 false说明错误发生在用户选择密钥之前如初始化失败、用户取消等此时应静默
// 2. 如果是 AbortError始终静默
if (isConditional && (!passkeyLoading.value || error.name === 'AbortError')) {
if (isConditional && (!passkeyLoading.value || errorName === 'AbortError')) {
console.warn('[PassKey] Conditional UI silenced error:', error)
return
}
// 手动模式下的 AbortError 也应该静默(用户重复点击导致)
if (!isConditional && error.name === 'AbortError') {
if (!isConditional && errorName === 'AbortError') {
console.warn('[PassKey] Manual request aborted (likely due to rapid clicking):', error)
return
}
// 设置错误信息
if (error.name === 'NotAllowedError') {
if (errorName === 'NotAllowedError') {
errorMessage.value = t('login.passkeyAuthCanceled')
} else if (error.name === 'NotSupportedError') {
} else if (errorName === 'NotSupportedError') {
errorMessage.value = t('login.passkeyNotSupported')
} else if (error.message?.includes('start failed')) {
} else if (message?.includes('start failed')) {
errorMessage.value = t('login.passkeyLoginStartFailed')
} else {
errorMessage.value = t('login.authFailure')
@@ -557,7 +578,11 @@ async function subscribeForPushNotifications() {
}
// 登录后处理
async function afterLogin(superuser: boolean, userPayload: userState, filteredMenus: any[]) {
async function afterLogin(
superuser: boolean,
userPayload: userState,
filteredMenus: ReturnType<typeof filterMenusByPermission>,
) {
const originalPath = authStore.originalPath
authStore.setOriginalPath(null)
@@ -579,7 +604,7 @@ async function afterLogin(superuser: boolean, userPayload: userState, filteredMe
}
// 处理登录成功
async function handleLoginSuccess(response: any) {
async function handleLoginSuccess(response: PassKeyFinishResponse) {
const userPayload: userState = {
superUser: response.super_user,
userID: response.user_id,
@@ -609,96 +634,88 @@ async function handleLoginSuccess(response: any) {
await afterLogin(userPayload.superUser, userPayload, filteredMenus)
}
// 登录获取token事件
async function requestPasswordLogin(): Promise<PassKeyFinishResponse> {
const formData = new FormData()
formData.append('username', form.value.username)
formData.append('password', form.value.password)
formData.append('otp_password', form.value.otp_password)
return (await api.post('/login/access-token', formData, {
headers: {
Accept: 'application/json',
},
})) as PassKeyFinishResponse
}
function setLoginError(error: unknown) {
const apiError = asApiError(error)
if (!apiError.response) {
errorMessage.value = t('login.networkError')
return
}
switch (apiError.response.status) {
case 401:
errorMessage.value = t('login.authFailure')
break
case 403:
errorMessage.value = t('login.permissionDenied')
break
case 500:
errorMessage.value = t('login.serverError')
break
default:
errorMessage.value = `${t('login.authFailure')} (Status: ${apiError.response.status})`
}
}
async function login() {
errorMessage.value = ''
syncLoginCredentialValues()
// 进行表单校验
if (!form.value.username || !form.value.password) {
return
}
if (!form.value.username || !form.value.password) return
// 登录按钮 loading
form.value.otp_password = ''
loading.value = true
try {
// 用户名密码
const formData = new FormData()
formData.append('username', form.value.username)
formData.append('password', form.value.password)
formData.append('otp_password', form.value.otp_password)
// 请求token
const response: any = await api.post('/login/access-token', formData, {
headers: {
Accept: 'application/json', // 设置 Accept 类型
},
})
const response = await requestPasswordLogin()
await handleLoginSuccess(response)
} catch (error: any) {
// 登录失败,显示错误提示
if (!error.response) {
errorMessage.value = t('login.networkError')
} catch (error: unknown) {
const apiError = asApiError(error)
if (apiError.response?.headers?.['x-mfa-required'] === 'true') {
enterMfaStep(apiError.response.data?.mfa_methods)
return
}
switch (error.response.status) {
case 401:
// 401错误可能是需要MFA或者认证失败
// 检查响应头是否有MFA要求标识
if (error.response.headers?.['x-mfa-required'] === 'true' && !form.value.otp_password) {
// 需要MFA验证弹出对话框
isOTP.value = true
openMfaDialog()
return
}
// 不需要MFA或已填写OTP但认证失败
errorMessage.value = t('login.authFailure')
// 认证失败后清空OTP密码防止下次点击不弹出对话框
form.value.otp_password = ''
break
case 403:
errorMessage.value = t('login.permissionDenied')
break
case 500:
errorMessage.value = t('login.serverError')
break
default:
errorMessage.value = `${t('login.authFailure')} (Status: ${error.response.status})`
}
setLoginError(error)
} finally {
loading.value = false
}
}
// 使用OTP码继续登录
function loginWithOTP() {
closeMfaDialog()
login()
// 在第二步提交 OTP失败时保持当前步骤避免登录表单闪回。
async function loginWithOTP() {
if (!form.value.otp_password || mfaOtpLoading.value) return
errorMessage.value = ''
mfaOtpLoading.value = true
try {
const response = await requestPasswordLogin()
await handleLoginSuccess(response)
} catch (error: unknown) {
const apiError = asApiError(error)
if (!apiError.response) {
errorMessage.value = t('login.networkError')
} else if (apiError.response.status === 401) {
errorMessage.value = t('login.mfa.verificationFailed')
} else {
setLoginError(error)
}
form.value.otp_password = ''
} finally {
mfaOtpLoading.value = false
}
}
// 使用PassKey进行MFA验证
async function verifyWithPassKey() {
if (!form.value.username) return
await handlePassKeyAuth(
{ username: form.value.username },
val => (mfaPasskeyLoading.value = val),
async response => {
// 关闭MFA对话框
closeMfaDialog()
await handleLoginSuccess(response)
},
)
}
watch([mfaPasskeyLoading, errorMessage, () => form.value.otp_password], () => {
mfaDialogController?.updateProps(getMfaDialogProps())
})
// 自动登录
onMounted(async () => {
// 获取token和remember状态
@@ -818,7 +835,7 @@ onUnmounted(() => {
</VMenu>
<!-- 登录表单 -->
<div v-if="!mfaDialog" class="auth-wrapper d-flex align-center justify-center">
<div class="auth-wrapper d-flex align-center justify-center">
<VCard
class="auth-card login-card glass-effect no-blur pa-7 pa-sm-9 w-full h-full login-card--enter"
max-width="24rem"
@@ -838,7 +855,18 @@ onUnmounted(() => {
</div>
<VCardText class="login-body">
<LoginMfaStep
v-if="mfaStepActive"
:methods="mfaMethods"
:otp-password="form.otp_password"
:otp-loading="mfaOtpLoading"
:error-message="errorMessage"
@update:otp-password="form.otp_password = $event"
@back="leaveMfaStep"
@otp="loginWithOTP"
/>
<form
v-else
ref="refForm"
class="login-form"
method="post"

2
src/types/auth.ts Normal file
View File

@@ -0,0 +1,2 @@
/** 密码验证通过后可供当前账号使用的二次验证方式。 */
export type MfaMethod = 'otp'

View File

@@ -59,8 +59,7 @@ const accountInfo = ref<User>({
// PassKey列表
const passkeyList = ref<PassKey[]>([])
// 双重验证菜单
const mfaMenu = ref(false)
const securityMenu = ref(false)
// 验证密码
const verifyPassword = ref('')
@@ -74,24 +73,18 @@ const verifyTitle = ref('')
// 验证对话框提示
const verifyText = ref('')
// 检查是否已启用任何双重验证
const hasMfaEnabled = computed(() => {
return accountInfo.value.is_otp || passkeyList.value.length > 0
})
let otpDialogController: ReturnType<typeof openSharedDialog> | null = null
let passkeyDialogController: ReturnType<typeof openSharedDialog> | null = null
let verifyPasswordDialogController: ReturnType<typeof openSharedDialog> | null = null
// 打开共享 OTP 管理弹窗,并把状态变更回写到用户资料。
function openOtpDialog() {
mfaMenu.value = false
securityMenu.value = false
otpDialogController?.close()
otpDialogController = openSharedDialog(
OTPAuthDialog,
{
isOtp: accountInfo.value.is_otp,
passkeyList: passkeyList.value,
},
{
'update:isOtp': (value: boolean) => {
@@ -108,13 +101,11 @@ function openOtpDialog() {
// 打开共享 PassKey 管理弹窗,并同步最新 PassKey 列表。
function openPasskeyDialog() {
mfaMenu.value = false
securityMenu.value = false
passkeyDialogController?.close()
passkeyDialogController = openSharedDialog(
PasskeyDialog,
{
isOtp: accountInfo.value.is_otp,
},
{},
{
'update:modelValue': (value: boolean) => {
if (!value) passkeyDialogController = null
@@ -381,13 +372,12 @@ watch(
<span v-if="display.mdAndUp.value" class="ms-2">{{ t('common.default') }}</span>
</VBtn>
<!-- 双重验证菜单按钮 -->
<VMenu v-model="mfaMenu" :close-on-content-click="false">
<VMenu v-model="securityMenu" :close-on-content-click="false">
<template #activator="{ props }">
<VBtn :color="hasMfaEnabled ? 'warning' : 'success'" variant="tonal" v-bind="props">
<VBtn color="primary" variant="tonal" v-bind="props" :aria-label="t('profile.accountSecurity')">
<VIcon icon="mdi-shield-key" />
<span v-if="display.mdAndUp.value" class="ms-2">
{{ hasMfaEnabled ? t('profile.setupMfa') : t('profile.enableMfa') }}
{{ t('profile.accountSecurity') }}
</span>
<VIcon icon="mdi-menu-down" class="ms-1" />
</VBtn>
@@ -397,19 +387,27 @@ watch(
<template #prepend>
<VIcon icon="mdi-cellphone-key" />
</template>
<VListItemTitle>{{ t('profile.useAuthenticator') }}</VListItemTitle>
<VListItemSubtitle v-if="accountInfo.is_otp" class="text-success">
{{ t('profile.enabled') }}
<VListItemTitle>{{ t('profile.authenticatorManagement') }}</VListItemTitle>
<VListItemSubtitle>
{{ t('profile.otpSecondFactor') }}
</VListItemSubtitle>
<template #append>
<VChip v-if="accountInfo.is_otp" color="success" size="small">{{ t('profile.enabled') }}</VChip>
</template>
</VListItem>
<VListItem @click="openPasskeyDialog">
<template #prepend>
<VIcon icon="material-symbols:passkey" />
</template>
<VListItemTitle>{{ t('profile.usePasskey') }}</VListItemTitle>
<VListItemSubtitle v-if="passkeyList.length > 0" class="text-success">
{{ t('profile.keysCount', { count: passkeyList.length }) }}
<VListItemTitle>{{ t('profile.passkeyManagement') }}</VListItemTitle>
<VListItemSubtitle>
{{ t('profile.passkeyPasswordless') }}
</VListItemSubtitle>
<template #append>
<VChip v-if="passkeyList.length > 0" color="success" size="small">
{{ t('profile.keysCount', { count: passkeyList.length }) }}
</VChip>
</template>
</VListItem>
</VList>
</VMenu>