mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 09:16:58 +08:00
fix(auth): separate passkey and two-step verification (#583)
This commit is contained in:
@@ -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
@@ -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 }
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import type { MfaMethod } from '@/types/auth'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 当前验证步骤的错误信息。 */
|
||||||
|
errorMessage: string
|
||||||
|
/** 密码验证通过后服务端声明的可用方式。 */
|
||||||
|
methods: MfaMethod[]
|
||||||
|
/** OTP 提交状态。 */
|
||||||
|
otpLoading: boolean
|
||||||
|
/** 当前输入的 OTP。 */
|
||||||
|
otpPassword: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'back'): void
|
||||||
|
(event: 'otp'): void
|
||||||
|
(event: 'update:otpPassword', value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const hasOtp = computed(() => props.methods.includes('otp'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="mfa-step" :class="{ 'mfa-step--unavailable': !hasOtp }" aria-labelledby="mfa-step-title">
|
||||||
|
<header class="mfa-step__header">
|
||||||
|
<VBtn
|
||||||
|
data-testid="mfa-back"
|
||||||
|
icon="mdi-arrow-left"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
:aria-label="t('login.mfa.back')"
|
||||||
|
:disabled="props.otpLoading"
|
||||||
|
@click="emit('back')"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h2 id="mfa-step-title" class="mfa-step__title">{{ t('login.secondaryVerification') }}</h2>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form v-if="hasOtp" data-testid="mfa-otp-form" class="mfa-step__method" @submit.prevent="emit('otp')">
|
||||||
|
<p class="mfa-step__description">{{ t('login.mfa.otpPrompt') }}</p>
|
||||||
|
<div class="mfa-step__field">
|
||||||
|
<VIcon icon="mdi-shield-key" class="mfa-step__field-icon" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
:value="props.otpPassword"
|
||||||
|
class="mfa-step__input"
|
||||||
|
type="text"
|
||||||
|
name="otp"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="6"
|
||||||
|
:placeholder="t('login.otpCode')"
|
||||||
|
:aria-label="t('login.otpCode')"
|
||||||
|
autofocus
|
||||||
|
:disabled="props.otpLoading"
|
||||||
|
@input="emit('update:otpPassword', ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<VBtn
|
||||||
|
block
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
class="mfa-step__submit"
|
||||||
|
prepend-icon="mdi-login"
|
||||||
|
:loading="props.otpLoading"
|
||||||
|
:disabled="!props.otpPassword"
|
||||||
|
>
|
||||||
|
{{ t('login.loginWithOtp') }}
|
||||||
|
</VBtn>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<VAlert v-if="props.errorMessage" class="mfa-step__alert" type="error" variant="tonal" role="alert">
|
||||||
|
{{ props.errorMessage }}
|
||||||
|
</VAlert>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mfa-step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mfa-step__header {
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
grid-template-columns: 40px 1fr 40px;
|
||||||
|
margin-block-end: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mfa-step__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mfa-step__method {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mfa-step__field {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid rgba(var(--v-border-color), 0.38);
|
||||||
|
min-block-size: 52px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(var(--v-theme-surface), 0.13);
|
||||||
|
transition:
|
||||||
|
border-color 150ms ease,
|
||||||
|
box-shadow 150ms ease,
|
||||||
|
background 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mfa-step__field:focus-within {
|
||||||
|
border-color: rgb(var(--v-theme-primary));
|
||||||
|
box-shadow: inset 0 0 0 1px rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mfa-step__field-icon {
|
||||||
|
position: absolute;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
inset-inline-start: 16px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mfa-step__input {
|
||||||
|
border: 0;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
block-size: 50px;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
font: inherit;
|
||||||
|
inline-size: 100%;
|
||||||
|
outline: none;
|
||||||
|
padding-block: 0;
|
||||||
|
padding-inline: 48px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mfa-step__input::placeholder {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mfa-step__input:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: var(--v-disabled-opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mfa-step__submit {
|
||||||
|
flex: 0 0 48px !important;
|
||||||
|
block-size: 48px !important;
|
||||||
|
max-block-size: 48px !important;
|
||||||
|
min-block-size: 48px !important;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mfa-step__description {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
line-height: 1.6;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mfa-step__alert {
|
||||||
|
margin-block-start: 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mfa-step--unavailable {
|
||||||
|
min-block-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mfa-step--unavailable .mfa-step__header {
|
||||||
|
margin-block-end: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mfa-step--unavailable .mfa-step__alert {
|
||||||
|
margin-block-start: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { shallowMount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import LoginMfaStep from '@/components/auth/LoginMfaStep.vue'
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({ t: (key: string) => key }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const slotStub = { template: '<div><slot /></div>' }
|
||||||
|
const buttonStub = {
|
||||||
|
emits: ['click'],
|
||||||
|
props: ['loading'],
|
||||||
|
template: '<button :disabled="loading" @click="$emit(\'click\')"><slot /></button>',
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountStep(methods: Array<'otp'>) {
|
||||||
|
return shallowMount(LoginMfaStep, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
VAlert: slotStub,
|
||||||
|
VBtn: buttonStub,
|
||||||
|
VIcon: true,
|
||||||
|
VTextField: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
errorMessage: '',
|
||||||
|
methods,
|
||||||
|
otpLoading: false,
|
||||||
|
otpPassword: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LoginMfaStep', () => {
|
||||||
|
it('shows only the OTP form for an OTP-only account', () => {
|
||||||
|
const wrapper = mountStep(['otp'])
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="mfa-otp-form"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows no authentication action when the server declares no supported method', () => {
|
||||||
|
const wrapper = mountStep([])
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="mfa-otp-form"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
errorMessage?: string
|
|
||||||
modelValue?: boolean
|
|
||||||
otpPassword?: string
|
|
||||||
passkeyLoading?: boolean
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
errorMessage: '',
|
|
||||||
modelValue: true,
|
|
||||||
otpPassword: '',
|
|
||||||
passkeyLoading: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(event: 'close'): void
|
|
||||||
(event: 'otp'): void
|
|
||||||
(event: 'passkey'): void
|
|
||||||
(event: 'update:modelValue', value: boolean): void
|
|
||||||
(event: 'update:otpPassword', value: string): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const visible = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: value => {
|
|
||||||
emit('update:modelValue', value)
|
|
||||||
if (!value) emit('close')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const otpValue = computed({
|
|
||||||
get: () => props.otpPassword,
|
|
||||||
set: value => emit('update:otpPassword', value),
|
|
||||||
})
|
|
||||||
|
|
||||||
// 提交 OTP 登录请求。
|
|
||||||
function submitOtp() {
|
|
||||||
emit('otp')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<VDialog v-if="visible" v-model="visible" max-width="400" persistent>
|
|
||||||
<VCard>
|
|
||||||
<VCardTitle class="text-h5 text-center mt-4 pb-2">{{ t('login.secondaryVerification') }}</VCardTitle>
|
|
||||||
<VCardText class="pt-0">
|
|
||||||
<p class="text-center mb-4">{{ t('login.mfa.selectVerificationMethod') }}</p>
|
|
||||||
|
|
||||||
<VCard variant="tonal" class="mb-3">
|
|
||||||
<VCardText>
|
|
||||||
<VForm @submit.prevent="submitOtp">
|
|
||||||
<VTextField
|
|
||||||
v-model="otpValue"
|
|
||||||
:label="t('login.otpCode')"
|
|
||||||
:placeholder="t('login.otpPlaceholder')"
|
|
||||||
type="text"
|
|
||||||
name="otp"
|
|
||||||
id="otp"
|
|
||||||
autocomplete="one-time-code"
|
|
||||||
inputmode="numeric"
|
|
||||||
prepend-inner-icon="mdi-shield-key"
|
|
||||||
class="mb-2"
|
|
||||||
/>
|
|
||||||
<VBtn block type="submit" color="primary" :disabled="!otpValue">
|
|
||||||
{{ t('login.loginWithOtp') }}
|
|
||||||
</VBtn>
|
|
||||||
</VForm>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
|
|
||||||
<VCard variant="tonal">
|
|
||||||
<VCardText>
|
|
||||||
<p class="text-body-2 mb-2">{{ t('login.orUsePasskey') }}</p>
|
|
||||||
<VBtn
|
|
||||||
block
|
|
||||||
variant="tonal"
|
|
||||||
color="success"
|
|
||||||
class="passkey-btn"
|
|
||||||
prepend-icon="material-symbols:passkey"
|
|
||||||
:loading="props.passkeyLoading"
|
|
||||||
@click="emit('passkey')"
|
|
||||||
>
|
|
||||||
{{ t('login.verifyWithPasskey') }}
|
|
||||||
</VBtn>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
|
|
||||||
<VAlert v-if="props.errorMessage" type="error" variant="tonal" class="mt-3">
|
|
||||||
{{ props.errorMessage }}
|
|
||||||
</VAlert>
|
|
||||||
|
|
||||||
<VBtn block variant="text" class="mt-4" @click="visible = false">{{ t('common.cancel') }}</VBtn>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
</VDialog>
|
|
||||||
</template>
|
|
||||||
@@ -4,25 +4,20 @@ import QRCode from 'qrcode'
|
|||||||
import { useDisplay } from 'vuetify'
|
import { 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">
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
// 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')
|
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用PassKey进行MFA验证
|
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 () => {
|
||||||
// 获取token和remember状态
|
// 获取token和remember状态
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** 密码验证通过后可供当前账号使用的二次验证方式。 */
|
||||||
|
export type MfaMethod = 'otp'
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user