diff --git a/apps/web-ele/.env.development b/apps/web-ele/.env.development index 6220ca8..e9b6db6 100644 --- a/apps/web-ele/.env.development +++ b/apps/web-ele/.env.development @@ -6,6 +6,12 @@ VITE_BASE=/ # 接口地址 - Fengling Console Backend VITE_GLOB_API_URL=http://localhost:5231/api +# OAuth 认证中心配置 +VITE_OAUTH_ISSUER=https://auth.fengling.local +VITE_OAUTH_CLIENT_ID=fengling-console +VITE_OAUTH_REDIRECT_URI=http://localhost:5777/callback +VITE_OAUTH_SCOPES=openid profile email api + # 是否开启 Nitro Mock服务,true 为开启,false 为关闭 VITE_NITRO_MOCK=false diff --git a/apps/web-ele/src/api/core/auth.ts b/apps/web-ele/src/api/core/auth.ts index 71d9f99..cdea719 100644 --- a/apps/web-ele/src/api/core/auth.ts +++ b/apps/web-ele/src/api/core/auth.ts @@ -43,6 +43,15 @@ export async function logoutApi() { }); } +/** + * OAuth 登出 + */ +export async function oauthLogoutApi() { + return baseRequestClient.post('/connect/logout', { + withCredentials: true, + }); +} + /** * 获取用户权限码 */ diff --git a/apps/web-ele/src/api/request.ts b/apps/web-ele/src/api/request.ts index 203b35b..cdea60b 100644 --- a/apps/web-ele/src/api/request.ts +++ b/apps/web-ele/src/api/request.ts @@ -16,6 +16,7 @@ import { useAccessStore } from '@vben/stores'; import { ElMessage } from 'element-plus'; import { useAuthStore } from '#/store'; +import { oauthService } from '#/services/auth/oauth'; import { refreshTokenApi } from './core'; @@ -64,8 +65,9 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) { client.addRequestInterceptor({ fulfilled: async (config) => { const accessStore = useAccessStore(); + const token = oauthService.getAccessToken() || accessStore.accessToken; - config.headers.Authorization = formatToken(accessStore.accessToken); + config.headers.Authorization = formatToken(token); config.headers['Accept-Language'] = preferences.app.locale; return config; }, diff --git a/apps/web-ele/src/router/routes/core.ts b/apps/web-ele/src/router/routes/core.ts index 949b0b6..91e5943 100644 --- a/apps/web-ele/src/router/routes/core.ts +++ b/apps/web-ele/src/router/routes/core.ts @@ -92,6 +92,16 @@ const coreRoutes: RouteRecordRaw[] = [ }, ], }, + { + name: 'OAuthCallback', + path: '/callback', + component: () => import('#/views/fengling/oauth-callback.vue'), + meta: { + hideInMenu: true, + hideInTab: true, + title: 'OAuth Callback', + }, + }, ]; export { coreRoutes, fallbackNotFoundRoute }; diff --git a/apps/web-ele/src/services/auth/oauth.ts b/apps/web-ele/src/services/auth/oauth.ts new file mode 100644 index 0000000..a144300 --- /dev/null +++ b/apps/web-ele/src/services/auth/oauth.ts @@ -0,0 +1,158 @@ +interface OAuthConfig { + issuer: string + clientId: string + redirectUri: string + scopes: string +} + +interface TokenResponse { + access_token: string + token_type: string + expires_in: number + refresh_token?: string + scope?: string +} + +interface TokenInfo { + accessToken: string + refreshToken?: string + expiresAt: number +} + +export class OAuthService { + private config: OAuthConfig + private tokenInfo: TokenInfo | null = null + private tokenKey = 'oauth_token' + + constructor() { + this.config = { + issuer: import.meta.env.VITE_OAUTH_ISSUER || 'https://auth.fengling.local', + clientId: import.meta.env.VITE_OAUTH_CLIENT_ID || 'fengling-console', + redirectUri: import.meta.env.VITE_OAUTH_REDIRECT_URI || 'http://localhost:5777/callback', + scopes: import.meta.env.VITE_OAUTH_SCOPES || 'openid profile email api', + } + this.loadToken() + } + + private loadToken() { + const savedToken = localStorage.getItem(this.tokenKey) + if (savedToken) { + this.tokenInfo = JSON.parse(savedToken) + } + } + + private saveToken(tokenInfo: TokenInfo) { + this.tokenInfo = tokenInfo + localStorage.setItem(this.tokenKey, JSON.stringify(tokenInfo)) + } + + getAccessToken(): string | null { + if (!this.tokenInfo) { + return null + } + + if (this.tokenInfo.expiresAt < Date.now()) { + return null + } + + return this.tokenInfo.accessToken + } + + isAuthenticated(): boolean { + return this.getAccessToken() !== null + } + + initiateAuthorization(): void { + const params = new URLSearchParams({ + response_type: 'code', + client_id: this.config.clientId, + redirect_uri: this.config.redirectUri, + scope: this.config.scopes, + state: this.generateState(), + }) + + const authUrl = `${this.config.issuer}/connect/authorize?${params.toString()}` + localStorage.setItem('oauth_state', params.get('state')!) + window.location.href = authUrl + } + + async handleCallback(code: string, state: string): Promise { + const savedState = localStorage.getItem('oauth_state') + if (!savedState || savedState !== state) { + throw new Error('Invalid state parameter') + } + + localStorage.removeItem('oauth_state') + + const tokenResponse = await this.exchangeCodeForToken(code) + this.saveToken({ + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + expiresAt: Date.now() + tokenResponse.expires_in * 1000, + }) + } + + private async exchangeCodeForToken(code: string): Promise { + const response = await fetch(`${this.config.issuer}/connect/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: this.config.clientId, + redirect_uri: this.config.redirectUri, + }), + }) + + if (!response.ok) { + throw new Error('Failed to exchange code for token') + } + + return response.json() + } + + async refreshToken(): Promise { + if (!this.tokenInfo?.refreshToken) { + throw new Error('No refresh token available') + } + + const response = await fetch(`${this.config.issuer}/connect/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: this.tokenInfo.refreshToken, + client_id: this.config.clientId, + }), + }) + + if (!response.ok) { + throw new Error('Failed to refresh token') + } + + const tokenResponse: TokenResponse = await response.json() + this.saveToken({ + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token || this.tokenInfo.refreshToken, + expiresAt: Date.now() + tokenResponse.expires_in * 1000, + }) + } + + logout(): void { + this.tokenInfo = null + localStorage.removeItem(this.tokenKey) + localStorage.removeItem('oauth_state') + const logoutUrl = `${this.config.issuer}/account/logout?post_logout_redirect_uri=${encodeURIComponent(window.location.origin)}` + window.location.href = logoutUrl + } + + private generateState(): string { + return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) + } +} + +export const oauthService = new OAuthService() diff --git a/apps/web-ele/src/views/fengling/oauth-callback.vue b/apps/web-ele/src/views/fengling/oauth-callback.vue new file mode 100644 index 0000000..0d1aedb --- /dev/null +++ b/apps/web-ele/src/views/fengling/oauth-callback.vue @@ -0,0 +1,50 @@ + + +