feat: 添加插件认证支持,优化登录流程

This commit is contained in:
jxxghp
2026-06-04 08:24:10 +08:00
parent 841e9479af
commit 3cf5cc24cd
3 changed files with 170 additions and 3 deletions

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { VForm } from 'vuetify/components/VForm'
import { useAuthStore, useUserStore } from '@/stores'
import { authState, userState } from '@/stores/types'
@@ -14,6 +15,7 @@ import { getNavMenus } from '@/router/i18n-menu'
import { 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'))
@@ -87,6 +89,42 @@ let manualAbortController: AbortController | null = null
// 标记当前是否有手动模式的 PassKey 请求正在进行
let isManualPassKeyActive = false
interface LoginAuthProvider {
id: string
type: 'system' | 'plugin'
method?: string
name: string
icon?: string
enabled?: boolean
plugin_id?: string
component?: string
remote?: RemoteModule
}
interface PluginAuthPayload {
ticket?: string
}
// 登录认证提供方
const authProviders = ref<LoginAuthProvider[]>([])
const authProvidersLoaded = ref(false)
const authProvidersFailed = ref(false)
const selectedAuthProvider = ref<LoginAuthProvider | null>(null)
const RemoteAuthView = shallowRef<Component | null>(null)
const pluginAuthDialog = ref(false)
const pluginAuthLoading = ref(false)
const pluginAuthError = ref('')
const systemPasskeyProvider = computed(() =>
authProviders.value.find(provider => provider.type === 'system' && provider.method === 'passkey'),
)
const pluginAuthProviders = computed(() =>
authProviders.value.filter(provider => provider.type === 'plugin' && provider.remote && provider.enabled !== false),
)
const showPasskeyLogin = computed(
() => !authProvidersLoaded.value || authProvidersFailed.value || !!systemPasskeyProvider.value?.enabled,
)
// 生成 MFA 共享弹窗使用的最新 props。
function getMfaDialogProps() {
return {
@@ -127,6 +165,79 @@ function closeMfaDialog() {
mfaDialogController = null
}
// 加载未登录可用的认证提供方。
async function loadAuthProviders() {
try {
const result = (await api.get('auth/providers')) as LoginAuthProvider[]
authProviders.value = Array.isArray(result) ? result : []
authProvidersFailed.value = false
} catch (error) {
console.error('加载认证提供方失败:', error)
authProviders.value = []
authProvidersFailed.value = true
} finally {
authProvidersLoaded.value = true
}
}
// 打开插件认证联邦页面。
async function openPluginAuth(provider: LoginAuthProvider) {
if (!provider.remote) return
selectedAuthProvider.value = provider
RemoteAuthView.value = null
pluginAuthError.value = ''
pluginAuthLoading.value = true
pluginAuthDialog.value = true
try {
RemoteAuthView.value = (await loadRemoteComponentFromModule(
provider.remote,
provider.component || 'AuthPage',
)) as Component
} catch (error: any) {
console.error('加载插件认证页面失败:', error)
pluginAuthError.value = error?.message || t('login.authFailure')
} finally {
pluginAuthLoading.value = false
}
}
// 关闭插件认证弹窗。
function closePluginAuth() {
pluginAuthDialog.value = false
selectedAuthProvider.value = null
RemoteAuthView.value = null
pluginAuthError.value = ''
}
// 兑换插件认证票据并完成系统登录。
async function exchangePluginAuthTicket(ticket: string) {
pluginAuthLoading.value = true
try {
const response: any = await api.post('auth/exchange', { ticket })
closePluginAuth()
await handleLoginSuccess(response)
} catch (error: any) {
console.error('插件认证票据兑换失败:', error)
pluginAuthError.value = error?.response?.data?.detail || error?.message || t('login.authFailure')
} finally {
pluginAuthLoading.value = false
}
}
// 处理插件认证成功事件。
async function handlePluginAuthenticated(payload: PluginAuthPayload) {
if (!payload?.ticket) {
pluginAuthError.value = t('login.authFailure')
return
}
await exchangePluginAuthTicket(payload.ticket)
}
// 处理插件认证失败事件。
function handlePluginAuthError(error: any) {
pluginAuthError.value = error?.message || String(error || '') || t('login.authFailure')
}
// PassKey 认证核心函数 - 处理 WebAuthn 认证流程
interface PassKeyAuthOptions {
username?: string // 可选的用户名,用于 MFA 场景
@@ -518,6 +629,9 @@ onMounted(async () => {
return
}
// 加载系统和插件声明的未登录认证入口
await loadAuthProviders()
// 初始化 Conditional UI 的 PassKey 自动填充
await initConditionalPasskey()
})
@@ -657,12 +771,13 @@ onUnmounted(() => {
</VBtn>
<!-- or divider -->
<div class="or-divider my-4">
<div v-if="showPasskeyLogin || pluginAuthProviders.length > 0" class="or-divider my-4">
<span class="or-divider-text">{{ t('login.orDivider') }}</span>
</div>
<!-- passkey login button -->
<VBtn
v-if="showPasskeyLogin"
block
variant="outlined"
color="success"
@@ -673,6 +788,19 @@ onUnmounted(() => {
>
{{ t('login.loginWithPasskey') }}
</VBtn>
<VBtn
v-for="provider in pluginAuthProviders"
:key="provider.id"
block
variant="outlined"
color="primary"
class="mt-3"
:prepend-icon="provider.icon || 'mdi-login-variant'"
:loading="pluginAuthLoading && selectedAuthProvider?.id === provider.id"
@click="openPluginAuth(provider)"
>
{{ provider.name }}
</VBtn>
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
{{ errorMessage }}
</VAlert>
@@ -682,6 +810,32 @@ onUnmounted(() => {
</VCardText>
</VCard>
</div>
<VDialog v-model="pluginAuthDialog" max-width="520" persistent>
<VCard>
<VCardItem>
<VCardTitle>{{ selectedAuthProvider?.name }}</VCardTitle>
<template #append>
<VBtn icon="mdi-close" variant="text" @click="closePluginAuth" />
</template>
</VCardItem>
<VCardText>
<VSkeletonLoader v-if="pluginAuthLoading && !RemoteAuthView" type="article" />
<VAlert v-else-if="pluginAuthError" type="error" variant="tonal">
{{ pluginAuthError }}
</VAlert>
<component
v-else-if="RemoteAuthView && selectedAuthProvider"
:is="RemoteAuthView"
:api="api"
:provider="selectedAuthProvider"
:plugin-id="selectedAuthProvider.plugin_id"
@authenticated="handlePluginAuthenticated"
@error="handlePluginAuthError"
@close="closePluginAuth"
/>
</VCardText>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -155,6 +155,7 @@ registerRoute(
!url.pathname.includes('/api/v1/message/') && // 用户消息接口
!url.pathname.includes('/api/v1/system/global') && // 系统配置接口
!url.pathname.includes('/api/v1/mfa/') && // 多因素认证接口
!url.pathname.includes('/api/v1/auth/') && // 登录认证入口与票据交换
!url.pathname.includes('/api/v1/dashboard/') && // Dashboard实时监控数据
!url.pathname.includes('/api/v1/plugin/')&& // 插件接口
!url.pathname.includes('/api/v1/subscribe/'), // 订阅接口

View File

@@ -10,9 +10,10 @@ import {
const federationController = new AbortController()
// 定义远程模块接口
interface RemoteModule {
export interface RemoteModule {
id: string
url: string
name?: string
}
/**
@@ -112,6 +113,17 @@ export async function loadRemoteComponent(id: string, componentName: string = 'P
}
}
/**
* 使用后端发现接口返回的 remote 信息加载指定组件。
* @param remoteModule 远程模块信息
* @param componentName 组件名称
*/
export async function loadRemoteComponentFromModule(remoteModule: RemoteModule, componentName: string = 'Page') {
injectRemoteModule(remoteModule)
const module = await __federation_method_getRemote(remoteModule.id, `./${componentName}`)
return __federation_method_unwrapDefault(module)
}
/**
* 从API获取远程模块列表
*/
@@ -131,7 +143,7 @@ async function fetchRemoteModules(): Promise<RemoteModule[]> {
* 动态注入Federation Remote模块
* @param modules 远程模块列表
*/
function injectRemoteModule(module: RemoteModule): void {
export function injectRemoteModule(module: RemoteModule): void {
// 与 API 请求一致:使用 origin + pathname 作为前缀,子路径代理时 pathname 含 /mp 等
const baseUrl = new URL(window.location.href)
const pathBase = baseUrl.pathname.replace(/\/$/, '') || ''