From 65134dd9ec63ca59aa3406eff7dd2e0f32b55aa6 Mon Sep 17 00:00:00 2001 From: colin Date: Thu, 5 Jun 2025 20:28:36 +0800 Subject: [PATCH] feat(vben5): Add authority server center login - add `oidc-client-ts` package - add `onlyOidc` environment variable - add oidc login and token refresh functions to `useAuthStore` - If `onlyOidc` is enabled to guide users to jump to the certification authority server - third-party login only provides certification center server --- apps/vben5/apps/app-antd/.env.development | 7 +++ apps/vben5/apps/app-antd/package.json | 1 + .../app-antd/src/adapter/request/index.ts | 21 ++----- .../apps/app-antd/src/auth/authService.ts | 54 +++++++++++++++++ .../src/locales/langs/en-US/page.json | 5 +- .../src/locales/langs/zh-CN/page.json | 5 +- .../apps/app-antd/src/router/routes/core.ts | 11 ++++ apps/vben5/apps/app-antd/src/store/auth.ts | 60 +++++++++++++++---- .../src/views/_core/authentication/login.vue | 31 +++++++++- .../authentication/third-party-login.vue | 26 ++++---- .../views/_core/fallback/login-callback.vue | 15 +++++ .../effects/hooks/src/use-app-config.ts | 2 + apps/vben5/packages/types/global.d.ts | 2 + apps/vben5/pnpm-workspace.yaml | 1 + 14 files changed, 196 insertions(+), 45 deletions(-) create mode 100644 apps/vben5/apps/app-antd/src/auth/authService.ts create mode 100644 apps/vben5/apps/app-antd/src/views/_core/fallback/login-callback.vue diff --git a/apps/vben5/apps/app-antd/.env.development b/apps/vben5/apps/app-antd/.env.development index d7204a13d..2e7b0f3d5 100644 --- a/apps/vben5/apps/app-antd/.env.development +++ b/apps/vben5/apps/app-antd/.env.development @@ -15,6 +15,13 @@ VITE_DEVTOOLS=false # 是否注入全局loading VITE_INJECT_APP_LOADING=true +# 是否仅允许OIDC登录 +VITE_GLOB_ONLY_OIDC=false +# 认证服务器 +VITE_GLOB_AUTHORITY="http://127.0.0.1:30001" +# 授权范围 VITE_GLOB_AUDIENCE="openid email address phone profile offline_access lingyun-abp-application" +# 客户端Id VITE_GLOB_CLIENT_ID=vue-admin-client +# 客户端密钥【生产环境请勿设置此值,建议启用仅允许OIDC登录,将使用授权码类型登录】 VITE_GLOB_CLIENT_SECRET=1q2w3e* diff --git a/apps/vben5/apps/app-antd/package.json b/apps/vben5/apps/app-antd/package.json index 392c09fdc..0f267f1d4 100644 --- a/apps/vben5/apps/app-antd/package.json +++ b/apps/vben5/apps/app-antd/package.json @@ -64,6 +64,7 @@ "@vueuse/core": "catalog:", "ant-design-vue": "catalog:", "dayjs": "catalog:", + "oidc-client-ts": "catalog:", "pinia": "catalog:", "vue": "catalog:", "vue-router": "catalog:" diff --git a/apps/vben5/apps/app-antd/src/adapter/request/index.ts b/apps/vben5/apps/app-antd/src/adapter/request/index.ts index ddafbf93c..e0c13d6a5 100644 --- a/apps/vben5/apps/app-antd/src/adapter/request/index.ts +++ b/apps/vben5/apps/app-antd/src/adapter/request/index.ts @@ -5,7 +5,7 @@ import { } from '@vben/request'; import { useAccessStore } from '@vben/stores'; -import { useOAuthError, useTokenApi } from '@abp/account'; +import { useOAuthError } from '@abp/account'; import { useAbpStore } from '@abp/core'; import { requestClient, useWrapperResult } from '@abp/request'; import { message } from 'ant-design-vue'; @@ -13,7 +13,6 @@ import { message } from 'ant-design-vue'; import { useAuthStore } from '#/store'; export function initRequestClient() { - const { refreshTokenApi } = useTokenApi(); /** * 重新认证逻辑 */ @@ -36,19 +35,11 @@ export function initRequestClient() { * 刷新token逻辑 */ async function doRefreshToken() { - const accessStore = useAccessStore(); - if (accessStore.refreshToken) { - try { - const { accessToken, tokenType, refreshToken } = await refreshTokenApi({ - refreshToken: accessStore.refreshToken, - }); - const newToken = `${tokenType} ${accessToken}`; - accessStore.setAccessToken(newToken); - accessStore.setRefreshToken(refreshToken); - return newToken; - } catch { - console.warn('The refresh token has expired or is unavailable.'); - } + const authStore = useAuthStore(); + try { + return await authStore.refreshSession(); + } catch { + console.warn('The refresh token has expired or is unavailable.'); } return ''; } diff --git a/apps/vben5/apps/app-antd/src/auth/authService.ts b/apps/vben5/apps/app-antd/src/auth/authService.ts new file mode 100644 index 000000000..110620bcd --- /dev/null +++ b/apps/vben5/apps/app-antd/src/auth/authService.ts @@ -0,0 +1,54 @@ +import { useAppConfig } from '@vben/hooks'; + +import { UserManager, WebStorageStateStore } from 'oidc-client-ts'; + +const { authority, audience, clientId, clientSecret } = useAppConfig( + import.meta.env, + import.meta.env.PROD, +); + +const userManager = new UserManager({ + authority, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: `${window.location.origin}/signin-callback`, + response_type: 'code', + scope: audience, + post_logout_redirect_uri: `${window.location.origin}/`, + silent_redirect_uri: `${window.location.origin}/silent-renew.html`, + automaticSilentRenew: true, + loadUserInfo: true, + userStore: new WebStorageStateStore({ store: window.localStorage }), +}); + +export default { + async login() { + return userManager.signinRedirect(); + }, + + async logout() { + return userManager.signoutRedirect(); + }, + + async refreshToken() { + return userManager.signinSilent(); + }, + + async getAccessToken() { + const user = await userManager.getUser(); + return user?.access_token; + }, + + async isAuthenticated() { + const user = await userManager.getUser(); + return !!user && !user.expired; + }, + + async handleCallback() { + return userManager.signinRedirectCallback(); + }, + + async getUser() { + return userManager.getUser(); + }, +}; diff --git a/apps/vben5/apps/app-antd/src/locales/langs/en-US/page.json b/apps/vben5/apps/app-antd/src/locales/langs/en-US/page.json index 618a258c0..03b44db1f 100644 --- a/apps/vben5/apps/app-antd/src/locales/langs/en-US/page.json +++ b/apps/vben5/apps/app-antd/src/locales/langs/en-US/page.json @@ -4,7 +4,10 @@ "register": "Register", "codeLogin": "Code Login", "qrcodeLogin": "Qr Code Login", - "forgetPassword": "Forget Password" + "forgetPassword": "Forget Password", + "oidcLogin": "Open Connect", + "oidcLoginMessage": "Please click \"OK\" to jump to the Certification center for login.", + "processingLogin": "Processing Login..." }, "dashboard": { "title": "Dashboard", diff --git a/apps/vben5/apps/app-antd/src/locales/langs/zh-CN/page.json b/apps/vben5/apps/app-antd/src/locales/langs/zh-CN/page.json index 4cb67081c..2b3f24782 100644 --- a/apps/vben5/apps/app-antd/src/locales/langs/zh-CN/page.json +++ b/apps/vben5/apps/app-antd/src/locales/langs/zh-CN/page.json @@ -4,7 +4,10 @@ "register": "注册", "codeLogin": "验证码登录", "qrcodeLogin": "二维码登录", - "forgetPassword": "忘记密码" + "forgetPassword": "忘记密码", + "oidcLogin": "认证中心登录", + "oidcLoginMessage": "请点击确定跳转认证中心登录", + "processingLogin": "登录成功,正在跳转中..." }, "dashboard": { "title": "概览", diff --git a/apps/vben5/apps/app-antd/src/router/routes/core.ts b/apps/vben5/apps/app-antd/src/router/routes/core.ts index 7218da228..5952b5926 100644 --- a/apps/vben5/apps/app-antd/src/router/routes/core.ts +++ b/apps/vben5/apps/app-antd/src/router/routes/core.ts @@ -21,6 +21,17 @@ const fallbackNotFoundRoute: RouteRecordRaw = { /** 基本路由,这些路由是必须存在的 */ const coreRoutes: RouteRecordRaw[] = [ + { + component: () => import('#/views/_core/fallback/login-callback.vue'), + meta: { + hideInBreadcrumb: true, + hideInMenu: true, + hideInTab: true, + title: 'Processing login', + }, + name: 'OidcFallback', + path: '/signin-callback', + }, /** * 根路由 * 使用基础布局,作为所有页面的父级容器,子级就不必配置BasicLayout。 diff --git a/apps/vben5/apps/app-antd/src/store/auth.ts b/apps/vben5/apps/app-antd/src/store/auth.ts index 1f0c9c822..6773557c0 100644 --- a/apps/vben5/apps/app-antd/src/store/auth.ts +++ b/apps/vben5/apps/app-antd/src/store/auth.ts @@ -2,7 +2,7 @@ import type { TokenResult } from '@abp/account'; import type { Recordable, UserInfo } from '@vben/types'; -import { ref, watch } from 'vue'; +import { ref } from 'vue'; import { useRouter } from 'vue-router'; import { DEFAULT_HOME_PATH, LOGIN_PATH } from '@vben/constants'; @@ -20,11 +20,12 @@ import { notification } from 'ant-design-vue'; import { defineStore } from 'pinia'; import { useAbpConfigApi } from '#/api/core/useAbpConfigApi'; +import authService from '#/auth/authService'; import { $t } from '#/locales'; export const useAuthStore = defineStore('auth', () => { const { publish } = useEventBus(); - const { loginApi } = useTokenApi(); + const { loginApi, refreshTokenApi } = useTokenApi(); const { loginApi: qrcodeLoginApi } = useQrCodeLoginApi(); const { loginApi: phoneLoginApi } = usePhoneLoginApi(); const { getUserInfoApi } = useUserInfoApi(); @@ -37,16 +38,45 @@ export const useAuthStore = defineStore('auth', () => { const loginLoading = ref(false); - watch( - () => accessStore.accessToken, - (accessToken) => { - if (accessToken && !loginLoading.value) { - loginLoading.value = true; - fetchUserInfo(); - loginLoading.value = false; + async function refreshSession() { + if (await authService.getAccessToken()) { + const user = await authService.refreshToken(); + const newToken = `${user?.token_type} ${user?.access_token}`; + accessStore.setAccessToken(newToken); + if (user?.refresh_token) { + accessStore.setRefreshToken(user.refresh_token); } - }, - ); + return newToken; + } else { + const { accessToken, tokenType, refreshToken } = await refreshTokenApi({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + refreshToken: accessStore.refreshToken!, + }); + const newToken = `${tokenType} ${accessToken}`; + accessStore.setAccessToken(newToken); + accessStore.setRefreshToken(refreshToken); + return newToken; + } + } + + async function oidcLogin() { + await authService.login(); + } + + async function oidcCallback() { + try { + const user = await authService.handleCallback(); + return await _loginSuccess({ + accessToken: user.access_token, + tokenType: user.token_type, + refreshToken: user.refresh_token ?? '', + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expiresIn: user.expires_in!, + }); + } finally { + loginLoading.value = false; + } + } async function qrcodeLogin( key: string, @@ -96,7 +126,10 @@ export const useAuthStore = defineStore('auth', () => { async function logout(redirect: boolean = true) { try { - // await logoutApi(); + if (await authService.getAccessToken()) { + accessStore.setAccessToken(null); + await authService.logout(); + } } catch { // 不做任何处理 } @@ -200,8 +233,11 @@ export const useAuthStore = defineStore('auth', () => { authLogin, phoneLogin, qrcodeLogin, + oidcLogin, + oidcCallback, fetchUserInfo, loginLoading, logout, + refreshSession, }; }); diff --git a/apps/vben5/apps/app-antd/src/views/_core/authentication/login.vue b/apps/vben5/apps/app-antd/src/views/_core/authentication/login.vue index 5550ce382..157db329a 100644 --- a/apps/vben5/apps/app-antd/src/views/_core/authentication/login.vue +++ b/apps/vben5/apps/app-antd/src/views/_core/authentication/login.vue @@ -7,9 +7,11 @@ import type { Recordable } from '@vben/types'; import { computed, nextTick, onMounted, useTemplateRef } from 'vue'; import { AuthenticationLogin, useVbenModal, z } from '@vben/common-ui'; +import { useAppConfig } from '@vben/hooks'; import { $t } from '@vben/locales'; import { useAbpStore, useSettings } from '@abp/core'; +import { Modal } from 'ant-design-vue'; import { useAbpConfigApi } from '#/api/core/useAbpConfigApi'; import { useAuthStore } from '#/store'; @@ -24,6 +26,8 @@ interface LoginInstance { defineOptions({ name: 'Login' }); +const { onlyOidc } = useAppConfig(import.meta.env, import.meta.env.PROD); + const abpStore = useAbpStore(); const authStore = useAuthStore(); @@ -34,6 +38,9 @@ const { getConfigApi } = useAbpConfigApi(); const login = useTemplateRef('login'); const formSchema = computed((): VbenFormSchema[] => { + if (onlyOidc) { + return []; + } let schemas: VbenFormSchema[] = [ { component: 'Input', @@ -75,6 +82,24 @@ const [ShouldChangePasswordModal, changePasswordModalApi] = useVbenModal({ connectedComponent: ShouldChangePassword, }); async function onInit() { + if (onlyOidc === true) { + setTimeout(() => { + Modal.confirm({ + centered: true, + title: $t('page.auth.oidcLogin'), + content: $t('page.auth.oidcLoginMessage'), + maskClosable: false, + closable: false, + cancelButtonProps: { + disabled: true, + }, + async onOk() { + await authStore.oidcLogin(); + }, + }); + }, 300); + return; + } const abpConfig = await getConfigApi(); abpStore.setApplication(abpConfig); nextTick(() => { @@ -83,6 +108,10 @@ async function onInit() { }); } async function onLogin(params: Recordable) { + if (onlyOidc === true) { + await authStore.oidcLogin(); + return; + } try { await authStore.authLogin(params); } catch (error) { @@ -115,7 +144,7 @@ onMounted(onInit);