mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-16 11:04:24 +08:00
fix(auth): separate passkey and two-step verification (#583)
This commit is contained in:
@@ -1000,7 +1000,7 @@ export interface User {
|
||||
is_superuser: boolean
|
||||
// 头像
|
||||
avatar: string
|
||||
// 是否开启双重验证
|
||||
// 是否开启二次验证
|
||||
is_otp: boolean
|
||||
// 用户权限 json
|
||||
permissions: { [key: string]: any }
|
||||
|
||||
193
src/components/auth/LoginMfaStep.vue
Normal file
193
src/components/auth/LoginMfaStep.vue
Normal 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>
|
||||
47
src/components/auth/__tests__/LoginMfaStep.spec.ts
Normal file
47
src/components/auth/__tests__/LoginMfaStep.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
errorMessage?: string
|
||||
modelValue?: boolean
|
||||
otpPassword?: string
|
||||
passkeyLoading?: boolean
|
||||
}>(),
|
||||
{
|
||||
errorMessage: '',
|
||||
modelValue: true,
|
||||
otpPassword: '',
|
||||
passkeyLoading: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'otp'): void
|
||||
(event: 'passkey'): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'update:otpPassword', value: string): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
const otpValue = computed({
|
||||
get: () => props.otpPassword,
|
||||
set: value => emit('update:otpPassword', value),
|
||||
})
|
||||
|
||||
// 提交 OTP 登录请求。
|
||||
function submitOtp() {
|
||||
emit('otp')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="400" persistent>
|
||||
<VCard>
|
||||
<VCardTitle class="text-h5 text-center mt-4 pb-2">{{ t('login.secondaryVerification') }}</VCardTitle>
|
||||
<VCardText class="pt-0">
|
||||
<p class="text-center mb-4">{{ t('login.mfa.selectVerificationMethod') }}</p>
|
||||
|
||||
<VCard variant="tonal" class="mb-3">
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="submitOtp">
|
||||
<VTextField
|
||||
v-model="otpValue"
|
||||
:label="t('login.otpCode')"
|
||||
:placeholder="t('login.otpPlaceholder')"
|
||||
type="text"
|
||||
name="otp"
|
||||
id="otp"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
class="mb-2"
|
||||
/>
|
||||
<VBtn block type="submit" color="primary" :disabled="!otpValue">
|
||||
{{ t('login.loginWithOtp') }}
|
||||
</VBtn>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VCard variant="tonal">
|
||||
<VCardText>
|
||||
<p class="text-body-2 mb-2">{{ t('login.orUsePasskey') }}</p>
|
||||
<VBtn
|
||||
block
|
||||
variant="tonal"
|
||||
color="success"
|
||||
class="passkey-btn"
|
||||
prepend-icon="material-symbols:passkey"
|
||||
:loading="props.passkeyLoading"
|
||||
@click="emit('passkey')"
|
||||
>
|
||||
{{ t('login.verifyWithPasskey') }}
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VAlert v-if="props.errorMessage" type="error" variant="tonal" class="mt-3">
|
||||
{{ props.errorMessage }}
|
||||
</VAlert>
|
||||
|
||||
<VBtn block variant="text" class="mt-4" @click="visible = false">{{ t('common.cancel') }}</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -4,25 +4,20 @@ import QRCode from 'qrcode'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, PassKey } from '@/api/types'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import type { ApiResponse } from '@/api/types'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
isOtp: boolean
|
||||
passkeyList?: PassKey[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
passkeyList: () => [],
|
||||
})
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:isOtp', 'verifyPassword'])
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
const $toast = useToast()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 内部状态
|
||||
const show = computed({
|
||||
@@ -36,11 +31,9 @@ const otpUri = ref('')
|
||||
// otp secret
|
||||
const secret = ref('')
|
||||
|
||||
// 确认双重验证密码
|
||||
// 当前二次验证设置流程中输入的 6 位验证码
|
||||
const otpPassword = ref('')
|
||||
|
||||
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
|
||||
|
||||
// OTP 初始化加载状态
|
||||
const otpLoading = ref(false)
|
||||
|
||||
@@ -132,14 +125,8 @@ async function judgeOtpPassword() {
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭当前用户的双重验证
|
||||
// 关闭当前用户的二次验证
|
||||
function disableOtp() {
|
||||
// 如果已绑定PassKey,不允许关闭OTP
|
||||
if (props.passkeyList && props.passkeyList.length > 0 && !allowPasskeyWithoutOtp.value) {
|
||||
$toast.error(t('profile.disableOtpWithPasskeyError'))
|
||||
return
|
||||
}
|
||||
|
||||
emit('verifyPassword', {
|
||||
title: t('profile.disableTwoFactor'),
|
||||
text: t('profile.confirmToDisableOtp'),
|
||||
@@ -241,7 +228,14 @@ watch(
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
<VAlert v-if="secret" :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
|
||||
<VAlert
|
||||
v-if="secret"
|
||||
:title="secret"
|
||||
variant="tonal"
|
||||
type="warning"
|
||||
class="my-4"
|
||||
:text="t('profile.secretKeyTip')"
|
||||
>
|
||||
<template #prepend />
|
||||
</VAlert>
|
||||
<VForm @submit.prevent="judgeOtpPassword">
|
||||
|
||||
@@ -6,11 +6,9 @@ import { useI18n } from 'vue-i18n'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, PassKey } from '@/api/types'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
isOtp: boolean
|
||||
}
|
||||
|
||||
// WebAuthn 相关接口定义
|
||||
@@ -27,7 +25,6 @@ const emit = defineEmits(['update:modelValue', 'update:passkeyList', 'verifyPass
|
||||
const { t, locale } = useI18n()
|
||||
const display = useDisplay()
|
||||
const $toast = useToast()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 内部状态
|
||||
const show = computed({
|
||||
@@ -44,11 +41,7 @@ const passkeyRegistering = ref(false)
|
||||
// PassKey名称
|
||||
const passkeyName = ref('')
|
||||
|
||||
// PassKey challenge
|
||||
const passkeyChallenge = ref('')
|
||||
|
||||
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
|
||||
const canRegisterPasskey = computed(() => props.isOtp || allowPasskeyWithoutOtp.value)
|
||||
const passkeyTransactionToken = ref('')
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string) {
|
||||
@@ -90,16 +83,16 @@ async function registerPassKey() {
|
||||
// 1. 开始注册
|
||||
const startResult = (await api.post('mfa/passkey/register/start', {
|
||||
name: passkeyName.value,
|
||||
})) as ApiResponse<{ options: string; challenge: string }>
|
||||
})) as ApiResponse<{ options: string; transaction_token: string }>
|
||||
|
||||
if (!startResult.success) {
|
||||
$toast.error(startResult.message || t('profile.passkeyRegisterFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
const { options, challenge } = startResult.data
|
||||
const { options, transaction_token: transactionToken } = startResult.data
|
||||
const publicKeyOptions = JSON.parse(options)
|
||||
passkeyChallenge.value = challenge
|
||||
passkeyTransactionToken.value = transactionToken
|
||||
|
||||
// 2. 调用WebAuthn API
|
||||
const credential = (await navigator.credentials.create({
|
||||
@@ -138,7 +131,7 @@ async function registerPassKey() {
|
||||
// 4. 完成注册
|
||||
const finishResult = (await api.post('mfa/passkey/register/finish', {
|
||||
credential: credentialJSON,
|
||||
challenge: passkeyChallenge.value,
|
||||
transaction_token: passkeyTransactionToken.value,
|
||||
name: passkeyName.value,
|
||||
})) as ApiResponse
|
||||
|
||||
@@ -202,7 +195,7 @@ watch(
|
||||
} else {
|
||||
// 弹窗关闭时,清空数据
|
||||
passkeyName.value = ''
|
||||
passkeyChallenge.value = ''
|
||||
passkeyTransactionToken.value = ''
|
||||
passkeyList.value = []
|
||||
}
|
||||
},
|
||||
@@ -236,7 +229,7 @@ watch(
|
||||
</VAlert>
|
||||
|
||||
<!-- 注册新通行密钥 -->
|
||||
<VCard v-if="canRegisterPasskey" variant="tonal" class="mb-6">
|
||||
<VCard variant="tonal" class="mb-6">
|
||||
<VCardText>
|
||||
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.registerNewPasskey') }}</h5>
|
||||
<p class="mb-4">{{ t('profile.passkeyDescription') }}</p>
|
||||
@@ -256,15 +249,6 @@ watch(
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- 未启用 OTP 提示 -->
|
||||
<VAlert v-else type="error" variant="tonal" class="mb-6" icon="mdi-shield-lock">
|
||||
<i18n-t keypath="profile.otpRequiredForPasskey" tag="span">
|
||||
<template #otp>
|
||||
<b>{{ t('profile.otpAuthenticator') }}</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</VAlert>
|
||||
|
||||
<!-- 已注册的通行密钥列表 -->
|
||||
<div v-if="passkeyList.length > 0" class="mt-6 px-4">
|
||||
<div
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '识别数据源',
|
||||
|
||||
@@ -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: '識別資料來源',
|
||||
|
||||
@@ -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
2
src/types/auth.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** 密码验证通过后可供当前账号使用的二次验证方式。 */
|
||||
export type MfaMethod = 'otp'
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user