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
-3
View File
@@ -810,9 +810,6 @@
} }
}, },
"src/pages/login.vue": { "src/pages/login.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 10
},
"@typescript-eslint/no-unused-vars": { "@typescript-eslint/no-unused-vars": {
"count": 1 "count": 1
} }
+1 -1
View File
@@ -1000,7 +1000,7 @@ export interface User {
is_superuser: boolean is_superuser: boolean
// 头像 // 头像
avatar: string avatar: string
// 是否开启双重验证 // 是否开启二次验证
is_otp: boolean is_otp: boolean
// 用户权限 json // 用户权限 json
permissions: { [key: string]: any } permissions: { [key: string]: any }
+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 { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import api from '@/api' import api from '@/api'
import type { ApiResponse, PassKey } from '@/api/types' import type { ApiResponse } from '@/api/types'
import { useGlobalSettingsStore } from '@/stores'
interface Props { interface Props {
modelValue: boolean modelValue: boolean
isOtp: boolean isOtp: boolean
passkeyList?: PassKey[]
} }
const props = withDefaults(defineProps<Props>(), { const props = defineProps<Props>()
passkeyList: () => [],
})
const emit = defineEmits(['update:modelValue', 'update:isOtp', 'verifyPassword']) const emit = defineEmits(['update:modelValue', 'update:isOtp', 'verifyPassword'])
const { t } = useI18n() const { t } = useI18n()
const display = useDisplay() const display = useDisplay()
const $toast = useToast() const $toast = useToast()
const globalSettingsStore = useGlobalSettingsStore()
// 内部状态 // 内部状态
const show = computed({ const show = computed({
@@ -36,11 +31,9 @@ const otpUri = ref('')
// otp secret // otp secret
const secret = ref('') const secret = ref('')
// 确认双重验证 // 当前二次验证设置流程中输入的 6 位验证码
const otpPassword = ref('') const otpPassword = ref('')
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
// OTP 初始化加载状态 // OTP 初始化加载状态
const otpLoading = ref(false) const otpLoading = ref(false)
@@ -132,14 +125,8 @@ async function judgeOtpPassword() {
} }
} }
// 关闭当前用户的双重验证 // 关闭当前用户的二次验证
function disableOtp() { function disableOtp() {
// 如果已绑定PassKey,不允许关闭OTP
if (props.passkeyList && props.passkeyList.length > 0 && !allowPasskeyWithoutOtp.value) {
$toast.error(t('profile.disableOtpWithPasskeyError'))
return
}
emit('verifyPassword', { emit('verifyPassword', {
title: t('profile.disableTwoFactor'), title: t('profile.disableTwoFactor'),
text: t('profile.confirmToDisableOtp'), text: t('profile.confirmToDisableOtp'),
@@ -241,7 +228,14 @@ watch(
</VBtn> </VBtn>
</div> </div>
</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 /> <template #prepend />
</VAlert> </VAlert>
<VForm @submit.prevent="judgeOtpPassword"> <VForm @submit.prevent="judgeOtpPassword">
+7 -23
View File
@@ -6,11 +6,9 @@ import { useI18n } from 'vue-i18n'
import { formatDateDifference } from '@core/utils/formatters' import { formatDateDifference } from '@core/utils/formatters'
import api from '@/api' import api from '@/api'
import type { ApiResponse, PassKey } from '@/api/types' import type { ApiResponse, PassKey } from '@/api/types'
import { useGlobalSettingsStore } from '@/stores'
interface Props { interface Props {
modelValue: boolean modelValue: boolean
isOtp: boolean
} }
// WebAuthn 相关接口定义 // WebAuthn 相关接口定义
@@ -27,7 +25,6 @@ const emit = defineEmits(['update:modelValue', 'update:passkeyList', 'verifyPass
const { t, locale } = useI18n() const { t, locale } = useI18n()
const display = useDisplay() const display = useDisplay()
const $toast = useToast() const $toast = useToast()
const globalSettingsStore = useGlobalSettingsStore()
// 内部状态 // 内部状态
const show = computed({ const show = computed({
@@ -44,11 +41,7 @@ const passkeyRegistering = ref(false)
// PassKey名称 // PassKey名称
const passkeyName = ref('') const passkeyName = ref('')
// PassKey challenge const passkeyTransactionToken = ref('')
const passkeyChallenge = ref('')
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
const canRegisterPasskey = computed(() => props.isOtp || allowPasskeyWithoutOtp.value)
// 格式化日期 // 格式化日期
function formatDate(dateStr: string) { function formatDate(dateStr: string) {
@@ -90,16 +83,16 @@ async function registerPassKey() {
// 1. 开始注册 // 1. 开始注册
const startResult = (await api.post('mfa/passkey/register/start', { const startResult = (await api.post('mfa/passkey/register/start', {
name: passkeyName.value, name: passkeyName.value,
})) as ApiResponse<{ options: string; challenge: string }> })) as ApiResponse<{ options: string; transaction_token: string }>
if (!startResult.success) { if (!startResult.success) {
$toast.error(startResult.message || t('profile.passkeyRegisterFailed')) $toast.error(startResult.message || t('profile.passkeyRegisterFailed'))
return return
} }
const { options, challenge } = startResult.data const { options, transaction_token: transactionToken } = startResult.data
const publicKeyOptions = JSON.parse(options) const publicKeyOptions = JSON.parse(options)
passkeyChallenge.value = challenge passkeyTransactionToken.value = transactionToken
// 2. 调用WebAuthn API // 2. 调用WebAuthn API
const credential = (await navigator.credentials.create({ const credential = (await navigator.credentials.create({
@@ -138,7 +131,7 @@ async function registerPassKey() {
// 4. 完成注册 // 4. 完成注册
const finishResult = (await api.post('mfa/passkey/register/finish', { const finishResult = (await api.post('mfa/passkey/register/finish', {
credential: credentialJSON, credential: credentialJSON,
challenge: passkeyChallenge.value, transaction_token: passkeyTransactionToken.value,
name: passkeyName.value, name: passkeyName.value,
})) as ApiResponse })) as ApiResponse
@@ -202,7 +195,7 @@ watch(
} else { } else {
// 弹窗关闭时,清空数据 // 弹窗关闭时,清空数据
passkeyName.value = '' passkeyName.value = ''
passkeyChallenge.value = '' passkeyTransactionToken.value = ''
passkeyList.value = [] passkeyList.value = []
} }
}, },
@@ -236,7 +229,7 @@ watch(
</VAlert> </VAlert>
<!-- 注册新通行密钥 --> <!-- 注册新通行密钥 -->
<VCard v-if="canRegisterPasskey" variant="tonal" class="mb-6"> <VCard variant="tonal" class="mb-6">
<VCardText> <VCardText>
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.registerNewPasskey') }}</h5> <h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.registerNewPasskey') }}</h5>
<p class="mb-4">{{ t('profile.passkeyDescription') }}</p> <p class="mb-4">{{ t('profile.passkeyDescription') }}</p>
@@ -256,15 +249,6 @@ watch(
</VCardText> </VCardText>
</VCard> </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 v-if="passkeyList.length > 0" class="mt-6 px-4">
<div <div
+26 -35
View File
@@ -315,17 +315,15 @@ export default {
stayLoggedIn: 'Stay Logged In', stayLoggedIn: 'Stay Logged In',
login: 'Login', login: 'Login',
networkError: 'Login failed, please check your network connection!', 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!', permissionDenied: 'Login failed, you do not have permission to access!',
noPermission: 'Login failed, you have no functional permissions, please contact the administrator!', noPermission: 'Login failed, you have no functional permissions, please contact the administrator!',
serverError: 'Login failed, server error!', serverError: 'Login failed, server error!',
loginFailed: 'Login Failed', loginFailed: 'Login Failed',
secondaryVerification: 'Secondary Verification', secondaryVerification: 'Two-Step Verification',
orDivider: 'OR', orDivider: 'OR',
loginWithPasskey: 'Login with Passkey', loginWithPasskey: 'Login with Passkey',
loginWithOtp: 'Login with OTP', loginWithOtp: 'Verify and Sign In',
orUsePasskey: 'Or use Passkey for verification',
verifyWithPasskey: 'Verify with Passkey',
otpPlaceholder: 'Enter 6-digit code', otpPlaceholder: 'Enter 6-digit code',
passkeyLoginStartFailed: 'Failed to start Passkey authentication', passkeyLoginStartFailed: 'Failed to start Passkey authentication',
passkeyNotSelected: 'No Passkey selected', passkeyNotSelected: 'No Passkey selected',
@@ -333,10 +331,11 @@ export default {
passkeyAuthCanceled: 'Passkey authentication canceled', passkeyAuthCanceled: 'Passkey authentication canceled',
passkeyNotSupported: 'Current browser does not support Passkeys', passkeyNotSupported: 'Current browser does not support Passkeys',
passkeySecureContextRequired: 'Passkey requires HTTPS secure connection', passkeySecureContextRequired: 'Passkey requires HTTPS secure connection',
passkeyVerifyFailed: 'Passkey verification failed',
passkeyVerifyFailedRetry: 'Passkey verification failed, please try again',
mfa: { 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: { menu: {
@@ -1714,7 +1713,7 @@ export default {
basicSettings: 'Basic Settings', basicSettings: 'Basic Settings',
basicSettingsDesc: 'Configure server global functions.', basicSettingsDesc: 'Configure server global functions.',
appDomain: 'Access Domain', 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', wallpaper: 'Background Wallpaper',
wallpaperHint: 'Choose the source of the login page background', wallpaperHint: 'Choose the source of the login page background',
recognizeSource: 'Recognition Data Source', recognizeSource: 'Recognition Data Source',
@@ -3517,7 +3516,6 @@ export default {
noRecentPlugins: 'None', noRecentPlugins: 'None',
}, },
profile: { profile: {
disableOtpWithPasskeyError: 'Please delete all Passkeys before clearing the authenticator!',
personalInfo: 'Personal Information', personalInfo: 'Personal Information',
uploadNewAvatar: 'Upload New Avatar', uploadNewAvatar: 'Upload New Avatar',
avatarFormatError: 'The uploaded file does not meet requirements, please select a new avatar', avatarFormatError: 'The uploaded file does not meet requirements, please select a new avatar',
@@ -3544,17 +3542,16 @@ export default {
vocechatUser: 'VoceChat User', vocechatUser: 'VoceChat User',
synologychatUser: 'SynologyChat User', synologychatUser: 'SynologyChat User',
doubanUser: 'Douban User', doubanUser: 'Douban User',
setupAuthenticator: 'Setup Authenticator', setupAuthenticator: 'Set Up Two-Step Verification',
authenticatorManagement: 'Authenticator Management', authenticatorManagement: 'Two-Step Verification',
authenticatorEnabled: 'You have enabled authenticator two-factor authentication', authenticatorEnabled: 'Two-step verification is enabled',
clearAuthenticatorTip: 'To set up a new authenticator, please clear the current configuration first.', clearAuthenticatorTip: 'Turn it off before setting up another authenticator.',
clearAuthenticator: 'Clear Authenticator', clearAuthenticator: 'Turn Off Two-Step Verification',
enableTwoFactor: 'Enable Two-Factor Authentication', enableTwoFactor: 'Enable Two-Step Verification',
disableTwoFactor: 'Disable Two-Factor Authentication', disableTwoFactor: 'Turn Off Two-Step Verification',
setupMfa: 'Setup Two-Factor Authentication', accountSecurity: 'Account Security',
enableMfa: 'Enable Two-Factor Authentication', otpSecondFactor: 'Two-step verification for password sign-in',
useAuthenticator: 'Use Authenticator', passkeyPasswordless: 'Sign in directly without a password',
usePasskey: 'Use Passkey',
enabled: 'Enabled', enabled: 'Enabled',
keysCount: '{count} keys', keysCount: '{count} keys',
passkeyManagement: 'Passkey Management', passkeyManagement: 'Passkey Management',
@@ -3577,26 +3574,20 @@ export default {
deletePasskey: 'Delete Passkey', deletePasskey: 'Delete Passkey',
passkeyDomainWarning: 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.', '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', accessDomain: 'access domain name',
otpAuthenticator: 'OTP Authenticator', otpGenerateFailed: 'Failed to load authenticator setup: {message}',
otpGenerateFailed: 'Failed to get OTP URI: {message}!', otpDisableSuccess: 'Two-step verification turned off',
otpDisableSuccess: 'Two-factor authentication disabled successfully!', otpDisableFailed: 'Failed to turn off two-step verification: {message}',
otpDisableFailed: 'Failed to disable OTP: {message}!',
otpCodeRequired: 'Please enter the 6-digit verification code', otpCodeRequired: 'Please enter the 6-digit verification code',
otpEnableSuccess: 'Two-factor authentication enabled successfully!', otpEnableSuccess: 'Two-step verification enabled',
otpEnableFailed: 'Failed to enable OTP: {message}!', otpEnableFailed: 'Failed to enable two-step verification: {message}',
otpDisableRestrictedByPasskey: confirmToDisableOtp: 'Verify your login password before turning off two-step verification.',
'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.',
confirmToDeletePasskey: 'For security reasons, verifying your login password is required to delete a Passkey.', confirmToDeletePasskey: 'For security reasons, verifying your login password is required to delete a Passkey.',
authenticatorAppDescription: authenticatorAppDescription:
'Use an authenticator app like Google Authenticator, Microsoft Authenticator, Authy, or 1Password to scan the QR code and generate a 6-digit code.', 'Use an authenticator app like Google Authenticator, Microsoft Authenticator, Authy, or 1Password to scan the QR code and generate a 6-digit code.',
secretKeyTip: secretKeyTip:
"If you're having trouble with the QR code, select manual entry in your app and enter the code above.", "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.', avatarFormatTip: 'JPG, PNG, GIF, WEBP formats allowed, maximum size 800KB.',
}, },
transferHistory: { transferHistory: {
@@ -4124,7 +4115,7 @@ export default {
title: 'Basic Settings', title: 'Basic Settings',
description: 'Set access domain, username/password and network configuration', description: 'Set access domain, username/password and network configuration',
appDomain: 'App Domain', 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', wallpaper: 'Background Wallpaper',
wallpaperHint: 'Choose the source of the login page background', wallpaperHint: 'Choose the source of the login page background',
recognizeSource: 'Recognize Source', recognizeSource: 'Recognize Source',
+25 -32
View File
@@ -310,7 +310,7 @@ export default {
stayLoggedIn: '保持登录', stayLoggedIn: '保持登录',
login: '登录', login: '登录',
networkError: '登录失败,请检查网络连接!', networkError: '登录失败,请检查网络连接!',
authFailure: '登录失败,请检查用户名、密码或二次验证是否正确!', authFailure: '登录失败,请检查用户名、密码或验证码',
permissionDenied: '登录失败,您没有权限访问!', permissionDenied: '登录失败,您没有权限访问!',
noPermission: '登录失败,您没有任何功能权限,请联系管理员!', noPermission: '登录失败,您没有任何功能权限,请联系管理员!',
serverError: '登录失败,服务器错误!', serverError: '登录失败,服务器错误!',
@@ -318,9 +318,7 @@ export default {
secondaryVerification: '二次验证', secondaryVerification: '二次验证',
orDivider: '或', orDivider: '或',
loginWithPasskey: '使用通行密钥登录', loginWithPasskey: '使用通行密钥登录',
loginWithOtp: '使用验证登录', loginWithOtp: '验证登录',
orUsePasskey: '或使用通行密钥进行验证',
verifyWithPasskey: '使用通行密钥验证',
otpPlaceholder: '请输入6位验证码', otpPlaceholder: '请输入6位验证码',
passkeyLoginStartFailed: '启动通行密钥认证失败', passkeyLoginStartFailed: '启动通行密钥认证失败',
passkeyNotSelected: '未选择通行密钥', passkeyNotSelected: '未选择通行密钥',
@@ -328,10 +326,11 @@ export default {
passkeyAuthCanceled: '通行密钥认证被取消', passkeyAuthCanceled: '通行密钥认证被取消',
passkeyNotSupported: '当前浏览器不支持通行密钥', passkeyNotSupported: '当前浏览器不支持通行密钥',
passkeySecureContextRequired: '通行密钥需要 HTTPS 安全连接', passkeySecureContextRequired: '通行密钥需要 HTTPS 安全连接',
passkeyVerifyFailed: '通行密钥验证失败',
passkeyVerifyFailedRetry: '通行密钥验证失败,请重试',
mfa: { mfa: {
selectVerificationMethod: '请选择验证方式', back: '返回登录',
methodsUnavailable: '无法获取验证方式,请重新登录',
otpPrompt: '输入身份验证器生成的 6 位验证码',
verificationFailed: '验证失败,请检查验证码后重试',
}, },
}, },
menu: { menu: {
@@ -1706,7 +1705,7 @@ export default {
basicSettings: '基础设置', basicSettings: '基础设置',
basicSettingsDesc: '设置服务器的全局功能', basicSettingsDesc: '设置服务器的全局功能',
appDomain: '访问域名', appDomain: '访问域名',
appDomainHint: '用于发送通知时,添加快捷跳转地址', appDomainHint: 'MoviePilot 的访问地址,用于通知快捷跳转和通行密钥校验',
wallpaper: '背景壁纸', wallpaper: '背景壁纸',
wallpaperHint: '选择登陆页面背景来源', wallpaperHint: '选择登陆页面背景来源',
recognizeSource: '识别数据源', recognizeSource: '识别数据源',
@@ -3462,7 +3461,6 @@ export default {
noRecentPlugins: '无', noRecentPlugins: '无',
}, },
profile: { profile: {
disableOtpWithPasskeyError: '请先删除所有通行密钥后再清除身份验证器!',
personalInfo: '个人信息', personalInfo: '个人信息',
uploadNewAvatar: '上传新头像', uploadNewAvatar: '上传新头像',
avatarFormatError: '上传的文件不符合要求,请重新选择头像', avatarFormatError: '上传的文件不符合要求,请重新选择头像',
@@ -3489,17 +3487,16 @@ export default {
vocechatUser: 'VoceChat用户', vocechatUser: 'VoceChat用户',
synologychatUser: 'SynologyChat用户', synologychatUser: 'SynologyChat用户',
doubanUser: '豆瓣用户', doubanUser: '豆瓣用户',
setupAuthenticator: '设置身份验证', setupAuthenticator: '设置二次验证',
authenticatorManagement: '身份验证器管理', authenticatorManagement: '二次验证',
authenticatorEnabled: '您已启用身份验证器双重验证', authenticatorEnabled: '二次验证已启用',
clearAuthenticatorTip: '如需设置新的身份验证器,请先清除当前配置。', clearAuthenticatorTip: '关闭后可以重新设置身份验证器。',
clearAuthenticator: '清除身份验证', clearAuthenticator: '关闭二次验证',
enableTwoFactor: '开启双重验证', enableTwoFactor: '启用二次验证',
disableTwoFactor: '关闭双重验证', disableTwoFactor: '关闭二次验证',
setupMfa: '设置双重验证', accountSecurity: '账号安全',
enableMfa: '开启双重验证', otpSecondFactor: '密码登录需二次验证',
useAuthenticator: '使用身份验证器', passkeyPasswordless: '无需密码直接登录',
usePasskey: '使用通行密钥',
enabled: '已启用', enabled: '已启用',
keysCount: '{count} 个密钥', keysCount: '{count} 个密钥',
passkeyManagement: '通行密钥管理', passkeyManagement: '通行密钥管理',
@@ -3522,23 +3519,19 @@ export default {
deletePasskey: '删除通行密钥', deletePasskey: '删除通行密钥',
passkeyDomainWarning: passkeyDomainWarning:
'通行密钥(PassKey)的可用性与 {domain} 紧密相关。在公网环境下,请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。', '通行密钥(PassKey)的可用性与 {domain} 紧密相关。在公网环境下,请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。',
otpRequiredForPasskey:
'为了安全起见,您必须先启用 {otp} 验证码,然后才能注册通行密钥。这是为了防止在域名配置变动导致 PassKey 失效时,您仍能通过 OTP 码登录账户。',
accessDomain: '访问域名', accessDomain: '访问域名',
otpAuthenticator: 'OTP 身份验证器', otpGenerateFailed: '获取身份验证器设置失败:{message}',
otpGenerateFailed: '获取otp uri失败:{message}', otpDisableSuccess: '二次验证已关闭',
otpDisableSuccess: '关闭登录双重验证成功!', otpDisableFailed: '关闭二次验证失败:{message}',
otpDisableFailed: '关闭otp失败:{message}',
otpCodeRequired: '请填写6位验证码', otpCodeRequired: '请填写6位验证码',
otpEnableSuccess: '开启登录双重验证成功!', otpEnableSuccess: '二次验证已启用',
otpEnableFailed: '开启otp失败:{message}', otpEnableFailed: '启用二次验证失败:{message}',
otpDisableRestrictedByPasskey: '您已注册通行密钥,请先删除所有通行密钥再关闭 OTP 验证。', confirmToDisableOtp: '关闭二次验证前需要验证登录密码。',
confirmToDisableOtp: '为了安全起见,关闭双重验证需要验证您的登录密码。',
confirmToDeletePasskey: '为了安全起见,删除通行密钥需要验证您的登录密码。', confirmToDeletePasskey: '为了安全起见,删除通行密钥需要验证您的登录密码。',
authenticatorAppDescription: authenticatorAppDescription:
'使用 Google Authenticator、Microsoft Authenticator、Authy 或 1Password 等验证器应用扫描二维码,获取 6 位验证码。', '使用 Google Authenticator、Microsoft Authenticator、Authy 或 1Password 等验证器应用扫描二维码,获取 6 位验证码。',
secretKeyTip: '如果您在使用二维码时遇到困难,请在您的应用程序中选择手动输入以上代码。', secretKeyTip: '如果您在使用二维码时遇到困难,请在您的应用程序中选择手动输入以上代码。',
enterVerificationCode: '输入验证码以确认开启双重验证', enterVerificationCode: '输入身份验证器生成的 6 位验证',
avatarFormatTip: '允许 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。', avatarFormatTip: '允许 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。',
}, },
transferHistory: { transferHistory: {
@@ -4063,7 +4056,7 @@ export default {
title: '基础设置', title: '基础设置',
description: '设置访问域名、用户名密码和网络配置', description: '设置访问域名、用户名密码和网络配置',
appDomain: '访问域名', appDomain: '访问域名',
appDomainHint: '用于发送通知时,添加快捷跳转地址', appDomainHint: 'MoviePilot 的访问地址,用于通知快捷跳转和通行密钥校验',
wallpaper: '背景壁纸', wallpaper: '背景壁纸',
wallpaperHint: '选择登录页面背景来源', wallpaperHint: '选择登录页面背景来源',
recognizeSource: '识别数据源', recognizeSource: '识别数据源',
+25 -32
View File
@@ -310,7 +310,7 @@ export default {
stayLoggedIn: '保持登錄', stayLoggedIn: '保持登錄',
login: '登錄', login: '登錄',
networkError: '登錄失敗,請檢查網絡連接!', networkError: '登錄失敗,請檢查網絡連接!',
authFailure: '登錄失敗,請檢查用戶名、密碼或二次驗證是否正確!', authFailure: '登錄失敗,請檢查用戶名、密碼或驗證碼',
permissionDenied: '登錄失敗,您沒有權限訪問!', permissionDenied: '登錄失敗,您沒有權限訪問!',
serverError: '登錄失敗,服務器錯誤!', serverError: '登錄失敗,服務器錯誤!',
noPermission: '登錄失敗,您沒有任何功能權限,請聯繫管理員!', noPermission: '登錄失敗,您沒有任何功能權限,請聯繫管理員!',
@@ -318,9 +318,7 @@ export default {
secondaryVerification: '二次驗證', secondaryVerification: '二次驗證',
orDivider: '或', orDivider: '或',
loginWithPasskey: '使用通行密鑰登錄', loginWithPasskey: '使用通行密鑰登錄',
loginWithOtp: '使用驗證登錄', loginWithOtp: '驗證登錄',
orUsePasskey: '或使用通行密鑰進行驗證',
verifyWithPasskey: '使用通行密鑰驗證',
otpPlaceholder: '請輸入6位驗證碼', otpPlaceholder: '請輸入6位驗證碼',
passkeyLoginStartFailed: '啟動通行密鑰驗證失敗', passkeyLoginStartFailed: '啟動通行密鑰驗證失敗',
passkeyNotSelected: '未選擇通行密鑰', passkeyNotSelected: '未選擇通行密鑰',
@@ -328,10 +326,11 @@ export default {
passkeyAuthCanceled: '通行密鑰驗證被取消', passkeyAuthCanceled: '通行密鑰驗證被取消',
passkeyNotSupported: '當前瀏覽器不支援通行密鑰', passkeyNotSupported: '當前瀏覽器不支援通行密鑰',
passkeySecureContextRequired: '通行密鑰需要 HTTPS 安全連接', passkeySecureContextRequired: '通行密鑰需要 HTTPS 安全連接',
passkeyVerifyFailed: '通行密鑰驗证失敗',
passkeyVerifyFailedRetry: '通行密鑰驗证失敗,請重試',
mfa: { mfa: {
selectVerificationMethod: '請選擇驗证方式', back: '返回登錄',
methodsUnavailable: '無法取得驗證方式,請重新登錄',
otpPrompt: '輸入身份驗證器生成的 6 位驗證碼',
verificationFailed: '驗證失敗,請檢查驗證碼後重試',
}, },
}, },
menu: { menu: {
@@ -1705,7 +1704,7 @@ export default {
basicSettings: '基礎設置', basicSettings: '基礎設置',
basicSettingsDesc: '設置服務器的全局功能', basicSettingsDesc: '設置服務器的全局功能',
appDomain: '訪問域名', appDomain: '訪問域名',
appDomainHint: '用於發送通知時,添加快捷跳轉地址', appDomainHint: 'MoviePilot 的存取網址,用於通知快速跳轉和通行密鑰驗證',
wallpaper: '背景壁紙', wallpaper: '背景壁紙',
wallpaperHint: '選擇登陸頁面背景來源', wallpaperHint: '選擇登陸頁面背景來源',
recognizeSource: '識別數據源', recognizeSource: '識別數據源',
@@ -3459,7 +3458,6 @@ export default {
noRecentPlugins: '無', noRecentPlugins: '無',
}, },
profile: { profile: {
disableOtpWithPasskeyError: '請先刪除所有通行密鑰後再清除身份驗證器!',
personalInfo: '個人信息', personalInfo: '個人信息',
uploadNewAvatar: '上傳新頭像', uploadNewAvatar: '上傳新頭像',
avatarFormatError: '上傳的文件不符合要求,請重新選擇頭像', avatarFormatError: '上傳的文件不符合要求,請重新選擇頭像',
@@ -3486,17 +3484,16 @@ export default {
vocechatUser: 'VoceChat用戶', vocechatUser: 'VoceChat用戶',
synologychatUser: 'SynologyChat用戶', synologychatUser: 'SynologyChat用戶',
doubanUser: '豆瓣用戶', doubanUser: '豆瓣用戶',
setupAuthenticator: '設置身份驗證', setupAuthenticator: '設置二次驗證',
authenticatorManagement: '身份驗證器管理', authenticatorManagement: '二次驗證',
authenticatorEnabled: '您已啟用身份驗證器雙重驗證', authenticatorEnabled: '二次驗證已啟用',
clearAuthenticatorTip: '如需設置新的身份驗證器,請先清除當前配置。', clearAuthenticatorTip: '關閉後可以重新設置身份驗證器。',
clearAuthenticator: '清除身份驗證', clearAuthenticator: '關閉二次驗證',
enableTwoFactor: '開啟雙重驗證', enableTwoFactor: '啟用二次驗證',
disableTwoFactor: '關閉雙重驗證', disableTwoFactor: '關閉二次驗證',
setupMfa: '設置雙重驗證', accountSecurity: '帳號安全',
enableMfa: '開啟雙重驗證', otpSecondFactor: '密碼登錄需二次驗證',
useAuthenticator: '使用身份驗證器', passkeyPasswordless: '無需密碼直接登錄',
usePasskey: '使用通行密鑰',
enabled: '已啟用', enabled: '已啟用',
keysCount: '{count} 個密鑰', keysCount: '{count} 個密鑰',
passkeyManagement: '通行密鑰管理', passkeyManagement: '通行密鑰管理',
@@ -3519,23 +3516,19 @@ export default {
deletePasskey: '刪除通行密鑰', deletePasskey: '刪除通行密鑰',
passkeyDomainWarning: passkeyDomainWarning:
'通行密鑰(PassKey)的可用性與 {domain} 緊密相關。在公網環境下,請務必在「基本設定」中配置正確的訪問域名。域名變更或配置錯誤將導致通行密鑰無法使用。', '通行密鑰(PassKey)的可用性與 {domain} 緊密相關。在公網環境下,請務必在「基本設定」中配置正確的訪問域名。域名變更或配置錯誤將導致通行密鑰無法使用。',
otpRequiredForPasskey:
'為了安全起見,您必須先啟用 {otp} 驗證碼,然後才能註冊通行密鑰。這是為了防止在網域配置變動導致 PassKey 失效時,您仍能通過 OTP 碼登入帳戶。',
accessDomain: '訪問域名', accessDomain: '訪問域名',
otpAuthenticator: 'OTP 身份驗證器', otpGenerateFailed: '獲取身份驗證器設置失敗:{message}',
otpGenerateFailed: '獲取otp uri失敗:{message}', otpDisableSuccess: '二次驗證已關閉',
otpDisableSuccess: '關閉登錄雙重驗證成功!', otpDisableFailed: '關閉二次驗證失敗:{message}',
otpDisableFailed: '關閉otp失敗:{message}',
otpCodeRequired: '請填寫6位驗證碼', otpCodeRequired: '請填寫6位驗證碼',
otpEnableSuccess: '開啟登錄雙重驗證成功!', otpEnableSuccess: '二次驗證已啟用',
otpEnableFailed: '開啟otp失敗:{message}', otpEnableFailed: '啟用二次驗證失敗:{message}',
otpDisableRestrictedByPasskey: '您已註冊通行密鑰,請先刪除所有通行密鑰再關閉 OTP 驗證。', confirmToDisableOtp: '關閉二次驗證前需要驗證登錄密碼。',
confirmToDisableOtp: '為了安全起見,關閉雙重驗證需要驗證您的登錄密碼。',
confirmToDeletePasskey: '為了安全起見,刪除通行密鑰需要驗證您的登錄密碼。', confirmToDeletePasskey: '為了安全起見,刪除通行密鑰需要驗證您的登錄密碼。',
authenticatorAppDescription: authenticatorAppDescription:
'使用 Google Authenticator、Microsoft Authenticator、Authy 或 1Password 等驗證器應用程式掃描 QR Code,取得 6 位數驗證碼。', '使用 Google Authenticator、Microsoft Authenticator、Authy 或 1Password 等驗證器應用程式掃描 QR Code,取得 6 位數驗證碼。',
secretKeyTip: '如果您在使用二維碼時遇到困難,請在您的應用程序中選擇手動輸入以上代碼。', secretKeyTip: '如果您在使用二維碼時遇到困難,請在您的應用程序中選擇手動輸入以上代碼。',
enterVerificationCode: '輸入驗證碼以確認開啟雙重驗證', enterVerificationCode: '輸入身份驗證器生成的 6 位驗證',
avatarFormatTip: '允許 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。', avatarFormatTip: '允許 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。',
}, },
transferHistory: { transferHistory: {
@@ -4060,7 +4053,7 @@ export default {
title: '基礎設定', title: '基礎設定',
description: '設定存取網域、用戶名密碼和網路配置', description: '設定存取網域、用戶名密碼和網路配置',
appDomain: '存取網域', appDomain: '存取網域',
appDomainHint: '用於發送通知時,新增快速跳轉位址', appDomainHint: 'MoviePilot 的存取網址,用於通知快速跳轉和通行密鑰驗證',
wallpaper: '背景桌布', wallpaper: '背景桌布',
wallpaperHint: '選擇登入頁面背景來源', wallpaperHint: '選擇登入頁面背景來源',
recognizeSource: '識別資料來源', recognizeSource: '識別資料來源',
+149 -121
View File
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AxiosError } from 'axios'
import type { Component } from 'vue' import type { Component } from 'vue'
import { useAuthStore, useUserStore } from '@/stores' import { useAuthStore, useUserStore } from '@/stores'
import { authState, userState } from '@/stores/types' import { authState, userState } from '@/stores/types'
import api from '@/api' import api from '@/api'
import router from '@/router' import router from '@/router'
import LoginMfaStep from '@/components/auth/LoginMfaStep.vue'
import OpticalLogoLab from '@/components/misc/OpticalLogoLab.vue' import OpticalLogoLab from '@/components/misc/OpticalLogoLab.vue'
import { bufferToBase64Url, base64UrlToUint8Array, urlBase64ToUint8Array } from '@/@core/utils/navigator' import { bufferToBase64Url, base64UrlToUint8Array, urlBase64ToUint8Array } from '@/@core/utils/navigator'
import { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n' import { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'
@@ -11,10 +13,8 @@ import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
import { getNavMenus } from '@/router/i18n-menu' import { getNavMenus } from '@/router/i18n-menu'
import { buildUserPermissionContext, filterMenusByPermission } from '@/utils/permission' import { buildUserPermissionContext, filterMenusByPermission } from '@/utils/permission'
import type { ApiResponse } from '@/api/types' import type { ApiResponse } from '@/api/types'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { loadRemoteComponentFromModule, type RemoteModule } from '@/utils/federationLoader' import { loadRemoteComponentFromModule, type RemoteModule } from '@/utils/federationLoader'
import type { MfaMethod } from '@/types/auth'
const LoginMfaDialog = defineAsyncComponent(() => import('@/components/dialog/LoginMfaDialog.vue'))
const loginRootRef = ref<HTMLElement | null>(null) const loginRootRef = ref<HTMLElement | null>(null)
type LabTapTarget = 'logo' | 'title' type LabTapTarget = 'logo' | 'title'
@@ -119,15 +119,11 @@ const isPasswordVisible = ref(false)
// //
const errorMessage = ref('') const errorMessage = ref('')
// const mfaStepActive = ref(false)
const isOTP = ref(false)
// const mfaOtpLoading = ref(false)
const mfaDialog = ref(false)
// MFA PassKey loading const mfaMethods = ref<MfaMethod[]>([])
const mfaPasskeyLoading = ref(false)
let mfaDialogController: ReturnType<typeof openSharedDialog> | null = null
// //
const langMenu = ref(false) const langMenu = ref(false)
@@ -169,6 +165,32 @@ interface PluginAuthPayload {
ticket?: string 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 authProviders = ref<LoginAuthProvider[]>([])
const selectedAuthProvider = ref<LoginAuthProvider | null>(null) const selectedAuthProvider = ref<LoginAuthProvider | null>(null)
@@ -208,44 +230,37 @@ function syncLoginCredentialValues() {
} }
} }
// MFA 使 props // MFA
function getMfaDialogProps() { function normalizeMfaMethods(value: unknown): MfaMethod[] {
return { if (!Array.isArray(value)) return []
errorMessage: errorMessage.value,
otpPassword: form.value.otp_password, return [...new Set(value.filter((method): method is MfaMethod => method === 'otp'))]
passkeyLoading: mfaPasskeyLoading.value,
}
} }
// MFA function enterMfaStep(methodsValue: unknown) {
function openMfaDialog() { conditionalAbortController?.abort()
mfaDialog.value = true conditionalAbortController = null
const dialogProps = getMfaDialogProps() mfaMethods.value = normalizeMfaMethods(methodsValue)
if (mfaDialogController) { form.value.otp_password = ''
mfaDialogController.updateProps(dialogProps) if (!mfaMethods.value.length) {
errorMessage.value = t('login.mfa.methodsUnavailable')
mfaStepActive.value = false
return return
} }
mfaDialogController = openSharedDialog( errorMessage.value = ''
LoginMfaDialog, mfaStepActive.value = true
dialogProps,
{
close: closeMfaDialog,
otp: loginWithOTP,
passkey: verifyWithPassKey,
'update:otpPassword': (value: string) => {
form.value.otp_password = value
},
},
{ closeOn: ['close'] },
)
} }
// MFA //
function closeMfaDialog() { function leaveMfaStep() {
mfaDialog.value = false manualAbortController?.abort()
mfaDialogController?.close() manualAbortController = null
mfaDialogController = 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.remote,
provider.component || 'AuthPage', provider.component || 'AuthPage',
)) as Component )) as Component
} catch (error: any) { } catch (error: unknown) {
console.error('加载插件认证页面失败:', error) console.error('加载插件认证页面失败:', error)
pluginAuthError.value = error?.message || t('login.authFailure') pluginAuthError.value = getErrorMessage(error) || t('login.authFailure')
} finally { } finally {
pluginAuthLoading.value = false pluginAuthLoading.value = false
} }
@@ -292,12 +307,15 @@ function closePluginAuth() {
async function exchangePluginAuthTicket(ticket: string) { async function exchangePluginAuthTicket(ticket: string) {
pluginAuthLoading.value = true pluginAuthLoading.value = true
try { try {
const response: any = await api.post('auth/exchange', { ticket }) const response = (await api.post('auth/exchange', { ticket })) as PassKeyFinishResponse
closePluginAuth() closePluginAuth()
await handleLoginSuccess(response) await handleLoginSuccess(response)
} catch (error: any) { } catch (error: unknown) {
console.error('插件认证票据兑换失败:', error) 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 { } finally {
pluginAuthLoading.value = false pluginAuthLoading.value = false
} }
@@ -313,13 +331,13 @@ async function handlePluginAuthenticated(payload: PluginAuthPayload) {
} }
// //
function handlePluginAuthError(error: any) { function handlePluginAuthError(error: unknown) {
pluginAuthError.value = error?.message || String(error || '') || t('login.authFailure') pluginAuthError.value = getErrorMessage(error) || String(error || '') || t('login.authFailure')
} }
// PassKey - WebAuthn // PassKey - WebAuthn
interface PassKeyAuthOptions { interface PassKeyAuthOptions {
username?: string // , MFA username?: string //
isConditional?: boolean // Conditional UI isConditional?: boolean // Conditional UI
signal?: AbortSignal // AbortController signal?: AbortSignal // AbortController
} }
@@ -327,7 +345,7 @@ interface PassKeyAuthOptions {
// PassKey API // PassKey API
interface PassKeyStartResponse { interface PassKeyStartResponse {
options: string // JSON options: string // JSON
challenge: string transaction_token: string
} }
interface PassKeyFinishResponse { interface PassKeyFinishResponse {
@@ -355,15 +373,15 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis
throw new Error(startResponse.message || 'PassKey start failed') throw new Error(startResponse.message || 'PassKey start failed')
} }
const { options: optionsStr, challenge } = startResponse.data const { options: optionsStr, transaction_token: transactionToken } = startResponse.data
const publicKeyOptions = JSON.parse(optionsStr) const publicKeyOptions = JSON.parse(optionsStr) as SerializedPublicKeyRequestOptions
// 2. WebAuthn API // 2. WebAuthn API
const credentialRequestOptions: CredentialRequestOptions = { const credentialRequestOptions: CredentialRequestOptions = {
publicKey: { publicKey: {
...publicKeyOptions, ...publicKeyOptions,
challenge: base64UrlToUint8Array(publicKeyOptions.challenge), challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
allowCredentials: publicKeyOptions.allowCredentials?.map((cred: any) => ({ allowCredentials: publicKeyOptions.allowCredentials?.map(cred => ({
...cred, ...cred,
id: base64UrlToUint8Array(cred.id), id: base64UrlToUint8Array(cred.id),
})), })),
@@ -407,7 +425,7 @@ async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promis
// 4. // 4.
const finishResponse = (await api.post('/mfa/passkey/authenticate/finish', { const finishResponse = (await api.post('/mfa/passkey/authenticate/finish', {
credential: credentialJSON, credential: credentialJSON,
challenge: challenge, transaction_token: transactionToken,
})) as PassKeyFinishResponse })) as PassKeyFinishResponse
if (!finishResponse || !finishResponse.access_token) { if (!finishResponse || !finishResponse.access_token) {
@@ -471,27 +489,30 @@ async function handlePassKeyAuth(
}) })
await onSuccess(finishResponse) await onSuccess(finishResponse)
} catch (error: any) { } catch (error: unknown) {
const errorName = error instanceof Error ? error.name : ''
const message = getErrorMessage(error)
// Conditional UI // Conditional UI
// 1. loading false // 1. loading false
// 2. AbortError // 2. AbortError
if (isConditional && (!passkeyLoading.value || error.name === 'AbortError')) { if (isConditional && (!passkeyLoading.value || errorName === 'AbortError')) {
console.warn('[PassKey] Conditional UI silenced error:', error) console.warn('[PassKey] Conditional UI silenced error:', error)
return return
} }
// AbortError // AbortError
if (!isConditional && error.name === 'AbortError') { if (!isConditional && errorName === 'AbortError') {
console.warn('[PassKey] Manual request aborted (likely due to rapid clicking):', error) console.warn('[PassKey] Manual request aborted (likely due to rapid clicking):', error)
return return
} }
// //
if (error.name === 'NotAllowedError') { if (errorName === 'NotAllowedError') {
errorMessage.value = t('login.passkeyAuthCanceled') errorMessage.value = t('login.passkeyAuthCanceled')
} else if (error.name === 'NotSupportedError') { } else if (errorName === 'NotSupportedError') {
errorMessage.value = t('login.passkeyNotSupported') errorMessage.value = t('login.passkeyNotSupported')
} else if (error.message?.includes('start failed')) { } else if (message?.includes('start failed')) {
errorMessage.value = t('login.passkeyLoginStartFailed') errorMessage.value = t('login.passkeyLoginStartFailed')
} else { } else {
errorMessage.value = t('login.authFailure') 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 const originalPath = authStore.originalPath
authStore.setOriginalPath(null) 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 = { const userPayload: userState = {
superUser: response.super_user, superUser: response.super_user,
userID: response.user_id, userID: response.user_id,
@@ -609,56 +634,29 @@ async function handleLoginSuccess(response: any) {
await afterLogin(userPayload.superUser, userPayload, filteredMenus) await afterLogin(userPayload.superUser, userPayload, filteredMenus)
} }
// token async function requestPasswordLogin(): Promise<PassKeyFinishResponse> {
async function login() {
errorMessage.value = ''
syncLoginCredentialValues()
//
if (!form.value.username || !form.value.password) {
return
}
// loading
loading.value = true
try {
//
const formData = new FormData() const formData = new FormData()
formData.append('username', form.value.username) formData.append('username', form.value.username)
formData.append('password', form.value.password) formData.append('password', form.value.password)
formData.append('otp_password', form.value.otp_password) formData.append('otp_password', form.value.otp_password)
// token return (await api.post('/login/access-token', formData, {
const response: any = await api.post('/login/access-token', formData, {
headers: { headers: {
Accept: 'application/json', // Accept Accept: 'application/json',
}, },
}) })) as PassKeyFinishResponse
}
await handleLoginSuccess(response) function setLoginError(error: unknown) {
} catch (error: any) { const apiError = asApiError(error)
// if (!apiError.response) {
if (!error.response) {
errorMessage.value = t('login.networkError') errorMessage.value = t('login.networkError')
return return
} }
switch (error.response.status) { switch (apiError.response.status) {
case 401: case 401:
// 401MFA
// MFA
if (error.response.headers?.['x-mfa-required'] === 'true' && !form.value.otp_password) {
// MFA
isOTP.value = true
openMfaDialog()
return
}
// MFAOTP
errorMessage.value = t('login.authFailure') errorMessage.value = t('login.authFailure')
// OTP
form.value.otp_password = ''
break break
case 403: case 403:
errorMessage.value = t('login.permissionDenied') errorMessage.value = t('login.permissionDenied')
@@ -667,38 +665,57 @@ async function login() {
errorMessage.value = t('login.serverError') errorMessage.value = t('login.serverError')
break break
default: default:
errorMessage.value = `${t('login.authFailure')} (Status: ${error.response.status})` errorMessage.value = `${t('login.authFailure')} (Status: ${apiError.response.status})`
} }
}
async function login() {
errorMessage.value = ''
syncLoginCredentialValues()
if (!form.value.username || !form.value.password) return
form.value.otp_password = ''
loading.value = true
try {
const response = await requestPasswordLogin()
await handleLoginSuccess(response)
} catch (error: unknown) {
const apiError = asApiError(error)
if (apiError.response?.headers?.['x-mfa-required'] === 'true') {
enterMfaStep(apiError.response.data?.mfa_methods)
return
}
setLoginError(error)
} finally { } finally {
loading.value = false loading.value = false
} }
} }
// 使OTP // OTP
function loginWithOTP() { async function loginWithOTP() {
closeMfaDialog() if (!form.value.otp_password || mfaOtpLoading.value) return
login()
}
// 使PassKeyMFA errorMessage.value = ''
async function verifyWithPassKey() { mfaOtpLoading.value = true
if (!form.value.username) return try {
const response = await requestPasswordLogin()
await handlePassKeyAuth(
{ username: form.value.username },
val => (mfaPasskeyLoading.value = val),
async response => {
// MFA
closeMfaDialog()
await handleLoginSuccess(response) 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
}
} }
watch([mfaPasskeyLoading, errorMessage, () => form.value.otp_password], () => {
mfaDialogController?.updateProps(getMfaDialogProps())
})
// //
onMounted(async () => { onMounted(async () => {
// tokenremember // tokenremember
@@ -818,7 +835,7 @@ onUnmounted(() => {
</VMenu> </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 <VCard
class="auth-card login-card glass-effect no-blur pa-7 pa-sm-9 w-full h-full login-card--enter" class="auth-card login-card glass-effect no-blur pa-7 pa-sm-9 w-full h-full login-card--enter"
max-width="24rem" max-width="24rem"
@@ -838,7 +855,18 @@ onUnmounted(() => {
</div> </div>
<VCardText class="login-body"> <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 <form
v-else
ref="refForm" ref="refForm"
class="login-form" class="login-form"
method="post" method="post"
+2
View File
@@ -0,0 +1,2 @@
/** 密码验证通过后可供当前账号使用的二次验证方式。 */
export type MfaMethod = 'otp'
+21 -23
View File
@@ -59,8 +59,7 @@ const accountInfo = ref<User>({
// PassKey // PassKey
const passkeyList = ref<PassKey[]>([]) const passkeyList = ref<PassKey[]>([])
// const securityMenu = ref(false)
const mfaMenu = ref(false)
// //
const verifyPassword = ref('') const verifyPassword = ref('')
@@ -74,24 +73,18 @@ const verifyTitle = ref('')
// //
const verifyText = ref('') const verifyText = ref('')
//
const hasMfaEnabled = computed(() => {
return accountInfo.value.is_otp || passkeyList.value.length > 0
})
let otpDialogController: ReturnType<typeof openSharedDialog> | null = null let otpDialogController: ReturnType<typeof openSharedDialog> | null = null
let passkeyDialogController: ReturnType<typeof openSharedDialog> | null = null let passkeyDialogController: ReturnType<typeof openSharedDialog> | null = null
let verifyPasswordDialogController: ReturnType<typeof openSharedDialog> | null = null let verifyPasswordDialogController: ReturnType<typeof openSharedDialog> | null = null
// OTP // OTP
function openOtpDialog() { function openOtpDialog() {
mfaMenu.value = false securityMenu.value = false
otpDialogController?.close() otpDialogController?.close()
otpDialogController = openSharedDialog( otpDialogController = openSharedDialog(
OTPAuthDialog, OTPAuthDialog,
{ {
isOtp: accountInfo.value.is_otp, isOtp: accountInfo.value.is_otp,
passkeyList: passkeyList.value,
}, },
{ {
'update:isOtp': (value: boolean) => { 'update:isOtp': (value: boolean) => {
@@ -108,13 +101,11 @@ function openOtpDialog() {
// PassKey PassKey // PassKey PassKey
function openPasskeyDialog() { function openPasskeyDialog() {
mfaMenu.value = false securityMenu.value = false
passkeyDialogController?.close() passkeyDialogController?.close()
passkeyDialogController = openSharedDialog( passkeyDialogController = openSharedDialog(
PasskeyDialog, PasskeyDialog,
{ {},
isOtp: accountInfo.value.is_otp,
},
{ {
'update:modelValue': (value: boolean) => { 'update:modelValue': (value: boolean) => {
if (!value) passkeyDialogController = null if (!value) passkeyDialogController = null
@@ -381,13 +372,12 @@ watch(
<span v-if="display.mdAndUp.value" class="ms-2">{{ t('common.default') }}</span> <span v-if="display.mdAndUp.value" class="ms-2">{{ t('common.default') }}</span>
</VBtn> </VBtn>
<!-- 双重验证菜单按钮 --> <VMenu v-model="securityMenu" :close-on-content-click="false">
<VMenu v-model="mfaMenu" :close-on-content-click="false">
<template #activator="{ props }"> <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" /> <VIcon icon="mdi-shield-key" />
<span v-if="display.mdAndUp.value" class="ms-2"> <span v-if="display.mdAndUp.value" class="ms-2">
{{ hasMfaEnabled ? t('profile.setupMfa') : t('profile.enableMfa') }} {{ t('profile.accountSecurity') }}
</span> </span>
<VIcon icon="mdi-menu-down" class="ms-1" /> <VIcon icon="mdi-menu-down" class="ms-1" />
</VBtn> </VBtn>
@@ -397,19 +387,27 @@ watch(
<template #prepend> <template #prepend>
<VIcon icon="mdi-cellphone-key" /> <VIcon icon="mdi-cellphone-key" />
</template> </template>
<VListItemTitle>{{ t('profile.useAuthenticator') }}</VListItemTitle> <VListItemTitle>{{ t('profile.authenticatorManagement') }}</VListItemTitle>
<VListItemSubtitle v-if="accountInfo.is_otp" class="text-success"> <VListItemSubtitle>
{{ t('profile.enabled') }} {{ t('profile.otpSecondFactor') }}
</VListItemSubtitle> </VListItemSubtitle>
<template #append>
<VChip v-if="accountInfo.is_otp" color="success" size="small">{{ t('profile.enabled') }}</VChip>
</template>
</VListItem> </VListItem>
<VListItem @click="openPasskeyDialog"> <VListItem @click="openPasskeyDialog">
<template #prepend> <template #prepend>
<VIcon icon="material-symbols:passkey" /> <VIcon icon="material-symbols:passkey" />
</template> </template>
<VListItemTitle>{{ t('profile.usePasskey') }}</VListItemTitle> <VListItemTitle>{{ t('profile.passkeyManagement') }}</VListItemTitle>
<VListItemSubtitle v-if="passkeyList.length > 0" class="text-success"> <VListItemSubtitle>
{{ t('profile.keysCount', { count: passkeyList.length }) }} {{ t('profile.passkeyPasswordless') }}
</VListItemSubtitle> </VListItemSubtitle>
<template #append>
<VChip v-if="passkeyList.length > 0" color="success" size="small">
{{ t('profile.keysCount', { count: passkeyList.length }) }}
</VChip>
</template>
</VListItem> </VListItem>
</VList> </VList>
</VMenu> </VMenu>