From 5c2c1359404cb8b65efb552d014acb5048809e62 Mon Sep 17 00:00:00 2001 From: Boen_Shi Date: Thu, 21 May 2026 16:06:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(OAuth2):=20=E5=AE=8C=E6=88=90Oauth2?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=8F=8A=E6=B5=8B=E8=AF=95=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E7=9A=84=E5=9F=BA=E6=9C=AC=E7=BC=96=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/oauth.ts | 23 ++ src/axios.ts | 4 +- src/composables/token.ts | 7 +- src/layouts/MainLayout.vue | 1 + src/pages/LoginPage.vue | 55 ++++- src/pages/OauthClientsPage.vue | 429 +++++++++++++++++++++++++++++++++ src/pages/OauthConsentPage.vue | 170 +++++++++++++ src/router/index.ts | 9 + 8 files changed, 693 insertions(+), 5 deletions(-) create mode 100644 src/api/oauth.ts create mode 100644 src/pages/OauthClientsPage.vue create mode 100644 src/pages/OauthConsentPage.vue diff --git a/src/api/oauth.ts b/src/api/oauth.ts new file mode 100644 index 0000000..3d9e5d1 --- /dev/null +++ b/src/api/oauth.ts @@ -0,0 +1,23 @@ +import request from '@/axios' + +export const oauthApi = { + clients(params: Record = {}) { + return request.get('/oauth/clients', { params }) + }, + createClient(data: Record) { + return request.post('/oauth/clients', data) + }, + updateClient(id: number, data: Record) { + return request.put(`/oauth/clients/${id}`, data) + }, + removeClient(id: number) { + return request.delete(`/oauth/clients/${id}`) + }, + resetClientSecret(id: number) { + return request.post(`/oauth/clients/${id}/reset-secret`) + }, + + authorizeDecision(data: Record) { + return request.post('/oauth/authorize/decision', data) + }, +} diff --git a/src/axios.ts b/src/axios.ts index 752f3f3..241bfdd 100644 --- a/src/axios.ts +++ b/src/axios.ts @@ -19,7 +19,9 @@ const service: AxiosInstance = axios.create({ service.interceptors.request.use((config) => { const token = getToken() - if (token) { + const requestUrl = String(config.url || '') + const isLoginRequest = requestUrl.endsWith('/auth/login') || requestUrl === '/auth/login' + if (token && !isLoginRequest) { config.headers.Authorization = `Bearer ${token}` } diff --git a/src/composables/token.ts b/src/composables/token.ts index b854b4d..59b3a49 100644 --- a/src/composables/token.ts +++ b/src/composables/token.ts @@ -2,15 +2,16 @@ import { useCookies } from "@vueuse/integrations/useCookies" const TOKEN_KEY = "token" const cookie = useCookies() +const TOKEN_OPTIONS = { path: "/" } export function getToken(): string | undefined { return cookie.get(TOKEN_KEY) } export function setToken(token: string): void { - cookie.set(TOKEN_KEY, token) + cookie.set(TOKEN_KEY, token, TOKEN_OPTIONS) } export function removeToken(): void { - cookie.remove(TOKEN_KEY) -} \ No newline at end of file + cookie.remove(TOKEN_KEY, TOKEN_OPTIONS) +} diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 29771a5..225036f 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -38,6 +38,7 @@ 服务器资源 堡垒机账号 访问日志 + OAuth 客户端 diff --git a/src/pages/LoginPage.vue b/src/pages/LoginPage.vue index 82f13c7..7e78b32 100644 --- a/src/pages/LoginPage.vue +++ b/src/pages/LoginPage.vue @@ -76,13 +76,14 @@ import type { FormInstance, FormRules } from 'element-plus' import { ElMessage } from 'element-plus' import { onMounted, reactive, ref } from 'vue' -import { useRouter } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' import { authApi } from '@/api/auth' import { getToken } from '@/composables/token' import { removeToken, setToken } from '@/composables/token' import { useAuthStore } from '@/stores/auth' const router = useRouter() +const route = useRoute() const authStore = useAuthStore() const formRef = ref() const submitting = ref(false) @@ -157,6 +158,11 @@ async function handleLogin(): Promise { return } ElMessage.success('登录成功') + const returnTo = resolveReturnTo() + if (returnTo) { + redirectToOAuthFlow(returnTo) + return + } await router.replace('/') } catch (error: any) { const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null @@ -247,12 +253,59 @@ onMounted(async () => { forcePasswordDialogVisible.value = true return } else { + const returnTo = resolveReturnTo() + if (returnTo) { + redirectToOAuthFlow(returnTo) + return + } await router.replace('/') } } catch (_error) { // ignore: invalid token will be handled by route guard } }) + +function redirectToOAuthFlow(returnTo: string): void { + const token = getToken() + if (!token) { + router.replace('/').catch(() => null) + return + } + + let targetUrl = returnTo + try { + const url = new URL(returnTo, window.location.origin) + url.searchParams.set('access_token', token) + targetUrl = url.toString() + } catch (_error) { + // keep original value + } + + window.location.href = targetUrl +} + +function resolveReturnTo(): string { + const fromRoute = typeof route.query.return_to === 'string' ? route.query.return_to : '' + if (fromRoute) { + return fromRoute + } + + const fromSearch = new URLSearchParams(window.location.search).get('return_to') || '' + if (fromSearch) { + return fromSearch + } + + const hash = window.location.hash || '' + const queryIndex = hash.indexOf('?') + if (queryIndex >= 0) { + const fromHash = new URLSearchParams(hash.slice(queryIndex + 1)).get('return_to') || '' + if (fromHash) { + return fromHash + } + } + + return '' +} diff --git a/src/pages/OauthConsentPage.vue b/src/pages/OauthConsentPage.vue new file mode 100644 index 0000000..3d103da --- /dev/null +++ b/src/pages/OauthConsentPage.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/src/router/index.ts b/src/router/index.ts index a920b1d..a16d2ff 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -10,6 +10,10 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/pages/LoginPage.vue'), meta: { guest: true }, }, + { + path: '/oauth-consent', + component: () => import('@/pages/OauthConsentPage.vue'), + }, { path: '/', component: () => import('@/layouts/MainLayout.vue'), @@ -22,6 +26,7 @@ const routes: RouteRecordRaw[] = [ { path: 'servers', component: () => import('@/pages/ServersPage.vue') }, { path: 'accounts', component: () => import('@/pages/AccountsPage.vue') }, { path: 'logs', component: () => import('@/pages/LogsPage.vue') }, + { path: 'oauth-clients', component: () => import('@/pages/OauthClientsPage.vue') }, ], }, { path: '/:pathMatch(.*)*', component: () => import('@/pages/NotFoundPage.vue') }, @@ -62,6 +67,10 @@ router.beforeEach(async (to) => { } if (token && to.meta.guest) { + const returnTo = typeof to.query.return_to === 'string' ? to.query.return_to : '' + if (to.path === '/login' && returnTo) { + return true + } const forcePasswordChange = Boolean((authStore.user as any)?.force_password_change) if (forcePasswordChange) { if (to.path === '/login') {