diff --git a/apps/web-ele/.env.development b/apps/web-ele/.env.development index e9b6db6..9a8b24d 100644 --- a/apps/web-ele/.env.development +++ b/apps/web-ele/.env.development @@ -7,10 +7,10 @@ VITE_BASE=/ VITE_GLOB_API_URL=http://localhost:5231/api # OAuth 认证中心配置 -VITE_OAUTH_ISSUER=https://auth.fengling.local +VITE_AUTH_SERVICE_URL=http://localhost:5000 VITE_OAUTH_CLIENT_ID=fengling-console -VITE_OAUTH_REDIRECT_URI=http://localhost:5777/callback -VITE_OAUTH_SCOPES=openid profile email api +VITE_OAUTH_REDIRECT_URI=http://localhost:5777/auth/callback +VITE_OAUTH_SCOPE=api offline_access openid profile email roles # 是否开启 Nitro Mock服务,true 为开启,false 为关闭 VITE_NITRO_MOCK=false diff --git a/apps/web-ele/.env.production b/apps/web-ele/.env.production index ca2963c..0da55d6 100644 --- a/apps/web-ele/.env.production +++ b/apps/web-ele/.env.production @@ -17,3 +17,9 @@ VITE_INJECT_APP_LOADING=true # 打包后是否生成dist.zip VITE_ARCHIVER=true + +# OAuth 认证中心配置 +VITE_AUTH_SERVICE_URL=https://auth.yourdomain.com +VITE_OAUTH_CLIENT_ID=fengling-console +VITE_OAUTH_REDIRECT_URI=https://your-app.com/auth/callback +VITE_OAUTH_SCOPE=api offline_access openid profile email roles diff --git a/apps/web-ele/src/api/core/auth.ts b/apps/web-ele/src/api/core/auth.ts index cdea719..e31e0c5 100644 --- a/apps/web-ele/src/api/core/auth.ts +++ b/apps/web-ele/src/api/core/auth.ts @@ -1,60 +1,61 @@ -import { baseRequestClient, requestClient } from '#/api/request'; +import { baseRequestClient } from '#/api/request'; +import { oauthConfig } from '#/config/oauth'; export namespace AuthApi { - /** 登录接口参数 */ - export interface LoginParams { - password?: string; - username?: string; + export interface TokenResponse { + access_token: string; + refresh_token: string; + token_type: string; + expires_in: number; + scope: string; } - /** 登录接口返回值 */ - export interface LoginResult { - accessToken: string; - } - - export interface RefreshTokenResult { - data: string; - status: number; + export interface UserInfo { + sub: string; + name: string; + email: string; + role: string[]; + tenant_id: string; } } -/** - * 登录 - */ -export async function loginApi(data: AuthApi.LoginParams) { - return requestClient.post('/auth/login', data); +export function parseJwt(token: string): AuthApi.UserInfo | null { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + + const payload = JSON.parse(atob(parts[1])); + return { + sub: payload.sub, + name: payload.name || payload['unique_name'], + email: payload.email, + role: Array.isArray(payload.role) ? payload.role : [payload.role].filter(Boolean), + tenant_id: payload.tenant_id || payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'], + }; + } catch { + return null; + } } -/** - * 刷新accessToken - */ -export async function refreshTokenApi() { - return baseRequestClient.post('/auth/refresh', { - withCredentials: true, +export async function logoutApi(refreshToken: string) { + const response = await fetch(`${oauthConfig.authUrl}${oauthConfig.endpoints.revocation}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + token: refreshToken, + token_type_hint: 'refresh_token', + client_id: oauthConfig.clientId, + }), }); + + if (!response.ok) { + const error = await response.text(); + console.error('Failed to revoke token:', error); + } } -/** - * 退出登录 - */ -export async function logoutApi() { - return baseRequestClient.post('/auth/logout', { - withCredentials: true, - }); -} - -/** - * OAuth 登出 - */ -export async function oauthLogoutApi() { - return baseRequestClient.post('/connect/logout', { - withCredentials: true, - }); -} - -/** - * 获取用户权限码 - */ -export async function getAccessCodesApi() { - return requestClient.get('/auth/codes'); +export async function validateTokenApi() { + return baseRequestClient.get('/health'); } diff --git a/apps/web-ele/src/api/core/oauth.ts b/apps/web-ele/src/api/core/oauth.ts new file mode 100644 index 0000000..24d3b4a --- /dev/null +++ b/apps/web-ele/src/api/core/oauth.ts @@ -0,0 +1,155 @@ +import { oauthConfig } from '#/config/oauth'; + +export interface TokenResponse { + access_token: string; + refresh_token: string; + token_type: string; + expires_in: number; + scope: string; +} + +export function generateRandomString(length: number = 128): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + let result = ''; + const array = new Uint8Array(length); + crypto.getRandomValues(array); + for (let i = 0; i < length; i++) { + result += chars[array[i] % chars.length]; + } + return result; +} + +export function generateCodeVerifier(): string { + return generateRandomString(128); +} + +export async function generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hash = await crypto.subtle.digest('SHA-256', data); + return btoa(String.fromCharCode(...new Uint8Array(hash))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +export function generateState(): string { + return generateRandomString(32); +} + +export function storeOAuthState(codeVerifier: string, state: string, returnTo?: string) { + sessionStorage.setItem(oauthConfig.storageKeys.codeVerifier, codeVerifier); + sessionStorage.setItem(oauthConfig.storageKeys.state, state); + if (returnTo) { + sessionStorage.setItem(oauthConfig.storageKeys.returnTo, returnTo); + } +} + +export function getAndClearOAuthState() { + const codeVerifier = sessionStorage.getItem(oauthConfig.storageKeys.codeVerifier); + const state = sessionStorage.getItem(oauthConfig.storageKeys.state); + const returnTo = sessionStorage.getItem(oauthConfig.storageKeys.returnTo); + + sessionStorage.removeItem(oauthConfig.storageKeys.codeVerifier); + sessionStorage.removeItem(oauthConfig.storageKeys.state); + sessionStorage.removeItem(oauthConfig.storageKeys.returnTo); + + return { codeVerifier, state, returnTo }; +} + +export function redirectToAuth(returnTo?: string) { + const codeVerifier = generateCodeVerifier(); + const state = generateState(); + + generateCodeChallenge(codeVerifier).then((codeChallenge) => { + storeOAuthState(codeVerifier, state, returnTo); + + const params = new URLSearchParams({ + client_id: oauthConfig.clientId, + redirect_uri: oauthConfig.redirectUri, + response_type: 'code', + scope: oauthConfig.scope, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state: state, + }); + + window.location.href = `${oauthConfig.authUrl}${oauthConfig.endpoints.authorize}?${params.toString()}`; + }); +} + +export async function exchangeCodeForToken(code: string, state: string): Promise { + const { codeVerifier } = getAndClearOAuthState(); + + if (!codeVerifier) { + throw new Error('Code verifier not found. OAuth2 state may be corrupted.'); + } + + const response = await fetch(`${oauthConfig.authUrl}${oauthConfig.endpoints.token}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + redirect_uri: oauthConfig.redirectUri, + client_id: oauthConfig.clientId, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to exchange code for token: ${error}`); + } + + return await response.json(); +} + +export async function refreshAccessToken(refreshToken: string): Promise { + const response = await fetch(`${oauthConfig.authUrl}${oauthConfig.endpoints.token}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: oauthConfig.clientId, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to refresh token: ${error}`); + } + + return await response.json(); +} + +export async function revokeToken(token: string) { + const response = await fetch(`${oauthConfig.authUrl}${oauthConfig.endpoints.revocation}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + token: token, + token_type_hint: 'refresh_token', + client_id: oauthConfig.clientId, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to revoke token: ${error}`); + } +} + +export function getLogoutUrl(): string { + const params = new URLSearchParams({ + post_logout_redirect_uri: oauthConfig.redirectUri, + }); + return `${oauthConfig.authUrl}${oauthConfig.endpoints.logout}?${params.toString()}`; +} diff --git a/apps/web-ele/src/api/request.ts b/apps/web-ele/src/api/request.ts index cdea60b..7c24a1d 100644 --- a/apps/web-ele/src/api/request.ts +++ b/apps/web-ele/src/api/request.ts @@ -1,6 +1,4 @@ -/** - * 该文件可自行根据业务逻辑进行调整 - */ +import { oauthConfig } from '#/config/oauth'; import type { RequestClientOptions } from '@vben/request'; import { useAppConfig } from '@vben/hooks'; @@ -16,9 +14,8 @@ import { useAccessStore } from '@vben/stores'; import { ElMessage } from 'element-plus'; import { useAuthStore } from '#/store'; -import { oauthService } from '#/services/auth/oauth'; -import { refreshTokenApi } from './core'; +import { refreshAccessToken } from './core/oauth'; const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); @@ -28,11 +25,13 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) { baseURL, }); - /** - * 重新认证逻辑 - */ + function isTokenExpired(expiresAt: number | null): boolean { + if (!expiresAt) return true; + return Date.now() >= expiresAt - 5 * 60 * 1000; + } + async function doReAuthenticate() { - console.warn('Access token or refresh token is invalid or expired. '); + console.warn('Access token or refresh token is invalid or expired.'); const accessStore = useAccessStore(); const authStore = useAuthStore(); accessStore.setAccessToken(null); @@ -46,34 +45,54 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) { } } - /** - * 刷新token逻辑 - */ async function doRefreshToken() { const accessStore = useAccessStore(); - const resp = await refreshTokenApi(); - const newToken = resp.data; - accessStore.setAccessToken(newToken); - return newToken; + const refreshToken = accessStore.refreshToken; + + if (!refreshToken) { + await doReAuthenticate(); + throw new Error('No refresh token available'); + } + + try { + const tokenResponse = await refreshAccessToken(refreshToken); + accessStore.setAccessToken(tokenResponse.access_token); + accessStore.setRefreshToken(tokenResponse.refresh_token); + accessStore.setExpiresAt(Date.now() + tokenResponse.expires_in * 1000); + return tokenResponse.access_token; + } catch (error) { + console.error('Failed to refresh token:', error); + await doReAuthenticate(); + throw error; + } } function formatToken(token: null | string) { return token ? `Bearer ${token}` : null; } - // 请求头处理 client.addRequestInterceptor({ fulfilled: async (config) => { const accessStore = useAccessStore(); - const token = oauthService.getAccessToken() || accessStore.accessToken; - config.headers.Authorization = formatToken(token); + if (accessStore.accessToken && accessStore.expiresAt) { + if (isTokenExpired(accessStore.expiresAt)) { + try { + const newToken = await doRefreshToken(); + config.headers.Authorization = formatToken(newToken); + } catch { + config.headers.Authorization = formatToken(null); + } + } else { + config.headers.Authorization = formatToken(accessStore.accessToken); + } + } + config.headers['Accept-Language'] = preferences.app.locale; return config; }, }); - // 处理返回的响应数据格式 client.addResponseInterceptor( defaultResponseInterceptor({ codeField: 'code', @@ -82,7 +101,6 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) { }), ); - // token过期的处理 client.addResponseInterceptor( authenticateResponseInterceptor({ client, @@ -93,14 +111,10 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) { }), ); - // 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里 client.addResponseInterceptor( errorMessageResponseInterceptor((msg: string, error) => { - // 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg - // 当前mock接口返回的错误字段是 error 或者 message const responseData = error?.response?.data ?? {}; const errorMessage = responseData?.error ?? responseData?.message ?? ''; - // 如果没有错误信息,则会根据状态码进行提示 ElMessage.error(errorMessage || msg); }), ); diff --git a/apps/web-ele/src/router/routes/core.ts b/apps/web-ele/src/router/routes/core.ts index 91e5943..32612fc 100644 --- a/apps/web-ele/src/router/routes/core.ts +++ b/apps/web-ele/src/router/routes/core.ts @@ -94,11 +94,11 @@ const coreRoutes: RouteRecordRaw[] = [ }, { name: 'OAuthCallback', - path: '/callback', - component: () => import('#/views/fengling/oauth-callback.vue'), + path: '/auth/callback', + component: () => import('#/views/_core/authentication/callback.vue'), meta: { hideInMenu: true, - hideInTab: true, + ignoreAuth: true, title: 'OAuth Callback', }, }, diff --git a/apps/web-ele/src/store/auth.ts b/apps/web-ele/src/store/auth.ts index 0055039..7d56fcf 100644 --- a/apps/web-ele/src/store/auth.ts +++ b/apps/web-ele/src/store/auth.ts @@ -1,4 +1,4 @@ -import type { Recordable, UserInfo } from '@vben/types'; +import type { UserInfo } from '@vben/types'; import { ref } from 'vue'; import { useRouter } from 'vue-router'; @@ -10,10 +10,15 @@ import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores'; import { ElNotification } from 'element-plus'; import { defineStore } from 'pinia'; -import { getAccessCodesApi, getUserInfoApi, logoutApi, oauthLogoutApi } from '#/api'; -import { oauthService } from '#/services/auth/oauth'; +import { parseJwt, logoutApi } from '#/api/core/auth'; +import { exchangeCodeForToken, getLogoutUrl, redirectToAuth } from '#/api/core/oauth'; import { $t } from '#/locales'; +export interface OAuth2State { + code: string; + state: string; +} + export const useAuthStore = defineStore('auth', () => { const accessStore = useAccessStore(); const userStore = useUserStore(); @@ -21,91 +26,94 @@ export const useAuthStore = defineStore('auth', () => { const loginLoading = ref(false); - /** - * OAuth 2.0 登录 - 重定向到认证中心 - */ - function authLogin( - params?: Recordable, - onSuccess?: () => Promise | void, - ) { - loginLoading.value = true; - try { - // OAuth 2.0 方式:重定向到认证中心 - oauthService.initiateAuthorization(); - } catch (error) { - ElNotification({ - message: $t('authentication.loginFailed'), - title: $t('authentication.loginFailedTitle'), - type: 'error', - }); - } finally { - loginLoading.value = false; - } - } - - /** - * 处理 OAuth 回调 - */ - async function handleOAuthCallback(code: string, state: string) { + async function handleOAuthCallback(oauthState: OAuth2State) { + let userInfo: null | UserInfo = null; try { loginLoading.value = true; - await oauthService.handleCallback(code, state); + const tokenResponse = await exchangeCodeForToken(oauthState.code, oauthState.state); - const accessToken = oauthService.getAccessToken(); - if (accessToken) { - accessStore.setAccessToken(accessToken); + accessStore.setAccessToken(tokenResponse.access_token); + accessStore.setRefreshToken(tokenResponse.refresh_token); + accessStore.setExpiresAt(Date.now() + tokenResponse.expires_in * 1000); - const [fetchUserInfoResult, accessCodes] = await Promise.all([ - fetchUserInfo(), - getAccessCodesApi(), - ]); + const jwtClaims = parseJwt(tokenResponse.access_token); + if (jwtClaims) { + userInfo = { + userId: jwtClaims.sub, + username: jwtClaims.name, + realName: jwtClaims.name, + email: jwtClaims.email, + roles: jwtClaims.role, + homePath: preferences.app.defaultHomePath, + }; - userStore.setUserInfo(fetchUserInfoResult); - accessStore.setAccessCodes(accessCodes); + userStore.setUserInfo(userInfo); + accessStore.setAccessCodes(jwtClaims.role || []); + } - if (fetchUserInfoResult?.realName) { - ElNotification({ - message: `${$t('authentication.loginSuccessDesc')}:${fetchUserInfoResult?.realName}`, - title: $t('authentication.loginSuccess'), - type: 'success', - }); - } + if (accessStore.loginExpired) { + accessStore.setLoginExpired(false); + } else { + const returnTo = sessionStorage.getItem('oauth_return_to') || preferences.app.defaultHomePath; + sessionStorage.removeItem('oauth_return_to'); + await router.push(returnTo); + } - await router.push( - fetchUserInfoResult.homePath || preferences.app.defaultHomePath, - ); + if (userInfo?.realName) { + ElNotification({ + message: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`, + title: $t('authentication.loginSuccess'), + type: 'success', + }); } } catch (error: any) { + console.error('OAuth2 callback error:', error); ElNotification({ - message: error.message || $t('authentication.loginFailed'), - title: $t('authentication.loginFailedTitle'), + message: error.message || '认证失败', + title: '登录失败', type: 'error', }); - await router.replace(LOGIN_PATH); + await router.push(LOGIN_PATH); } finally { loginLoading.value = false; } + + return { + userInfo, + }; + } + + async function authLogin( + params: any = {}, + onSuccess?: () => Promise | void, + ) { + const returnTo = params.returnTo || router.currentRoute.value.fullPath; + redirectToAuth(returnTo); + return { userInfo: null }; } async function logout(redirect: boolean = true) { + const refreshToken = accessStore.refreshToken; + try { - // OAuth 登出 - await oauthLogoutApi(); + if (refreshToken) { + await logoutApi(refreshToken); + } } catch { - // 不做任何处理 } + resetAllStores(); accessStore.setLoginExpired(false); + accessStore.setAccessToken(null); + accessStore.setRefreshToken(null); + accessStore.setExpiresAt(null); - // OAuth 登出并重定向到认证中心 - oauthService.logout(); + window.location.href = getLogoutUrl(); } async function fetchUserInfo() { - let userInfo: null | UserInfo = null; - userInfo = await getUserInfoApi(); - userStore.setUserInfo(userInfo); + const userInfo = userStore.getUserInfo; return userInfo; } @@ -121,54 +129,4 @@ export const useAuthStore = defineStore('auth', () => { loginLoading, logout, }; -}); - } - } - } finally { - loginLoading.value = false; - } - - return { - userInfo, - }; - } - - async function logout(redirect: boolean = true) { - try { - await logoutApi(); - } catch { - // 不做任何处理 - } - resetAllStores(); - accessStore.setLoginExpired(false); - - // 回登录页带上当前路由地址 - await router.replace({ - path: LOGIN_PATH, - query: redirect - ? { - redirect: encodeURIComponent(router.currentRoute.value.fullPath), - } - : {}, - }); - } - - async function fetchUserInfo() { - let userInfo: null | UserInfo = null; - userInfo = await getUserInfoApi(); - userStore.setUserInfo(userInfo); - return userInfo; - } - - function $reset() { - loginLoading.value = false; - } - - return { - $reset, - authLogin, - fetchUserInfo, - loginLoading, - logout, - }; }); diff --git a/apps/web-ele/src/views/_core/authentication/callback.vue b/apps/web-ele/src/views/_core/authentication/callback.vue new file mode 100644 index 0000000..c356a59 --- /dev/null +++ b/apps/web-ele/src/views/_core/authentication/callback.vue @@ -0,0 +1,58 @@ + + + diff --git a/apps/web-ele/src/views/_core/authentication/login.vue b/apps/web-ele/src/views/_core/authentication/login.vue index fa9e249..a121cdc 100644 --- a/apps/web-ele/src/views/_core/authentication/login.vue +++ b/apps/web-ele/src/views/_core/authentication/login.vue @@ -4,23 +4,24 @@ import { onMounted, ref } from 'vue'; import { ElCard, ElIcon } from 'element-plus'; import { Loading } from '@element-plus/icons-vue'; +import { useAuthStore } from '#/store'; + defineOptions({ name: 'Login' }); +const authStore = useAuthStore(); const loading = ref(true); const message = ref('正在跳转到认证中心...'); -onMounted(() => { - // 页面加载后自动跳转到 OAuth 认证中心 - setTimeout(() => { - window.location.href = import.meta.env.VITE_OAUTH_ISSUER + '/connect/authorize?' + - new URLSearchParams({ - response_type: 'code', - client_id: import.meta.env.VITE_OAUTH_CLIENT_ID || 'fengling-console', - redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URI || 'http://localhost:5777/callback', - scope: import.meta.env.VITE_OAUTH_SCOPES || 'openid profile email api', - state: Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2), - }).toString(); - }, 500); +onMounted(async () => { + try { + await authStore.authLogin(); + } catch (error: any) { + message.value = error.message || '登录跳转失败'; + } finally { + setTimeout(() => { + loading.value = false; + }, 1000); + } }); @@ -29,15 +30,33 @@ onMounted(() => { -
+

{{ message }}

+ +
+
+

自动跳转失败,请点击下方按钮手动跳转

+
+ + 使用认证中心登录 + +
+

默认账号: admin / Admin@123

+

测试账号: testuser / Test@123

+
+