feat: integrate OAuth 2.0 authentication with Fengling Auth Center
This commit is contained in:
parent
393296ef3d
commit
0ebe467927
@ -6,6 +6,12 @@ VITE_BASE=/
|
|||||||
# 接口地址 - Fengling Console Backend
|
# 接口地址 - Fengling Console Backend
|
||||||
VITE_GLOB_API_URL=http://localhost:5231/api
|
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 为关闭
|
# 是否开启 Nitro Mock服务,true 为开启,false 为关闭
|
||||||
VITE_NITRO_MOCK=false
|
VITE_NITRO_MOCK=false
|
||||||
|
|
||||||
|
|||||||
@ -43,6 +43,15 @@ export async function logoutApi() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth 登出
|
||||||
|
*/
|
||||||
|
export async function oauthLogoutApi() {
|
||||||
|
return baseRequestClient.post('/connect/logout', {
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户权限码
|
* 获取用户权限码
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { useAccessStore } from '@vben/stores';
|
|||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
import { useAuthStore } from '#/store';
|
import { useAuthStore } from '#/store';
|
||||||
|
import { oauthService } from '#/services/auth/oauth';
|
||||||
|
|
||||||
import { refreshTokenApi } from './core';
|
import { refreshTokenApi } from './core';
|
||||||
|
|
||||||
@ -64,8 +65,9 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
|||||||
client.addRequestInterceptor({
|
client.addRequestInterceptor({
|
||||||
fulfilled: async (config) => {
|
fulfilled: async (config) => {
|
||||||
const accessStore = useAccessStore();
|
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;
|
config.headers['Accept-Language'] = preferences.app.locale;
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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 };
|
export { coreRoutes, fallbackNotFoundRoute };
|
||||||
|
|||||||
158
apps/web-ele/src/services/auth/oauth.ts
Normal file
158
apps/web-ele/src/services/auth/oauth.ts
Normal file
@ -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<void> {
|
||||||
|
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<TokenResponse> {
|
||||||
|
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<void> {
|
||||||
|
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()
|
||||||
50
apps/web-ele/src/views/fengling/oauth-callback.vue
Normal file
50
apps/web-ele/src/views/fengling/oauth-callback.vue
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { ElMessage, ElCard, ElResult, ElButton } from 'element-plus'
|
||||||
|
import { oauthService } from '#/services/auth/oauth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const code = route.query.code as string
|
||||||
|
const state = route.query.state as string
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
throw new Error('No authorization code found')
|
||||||
|
}
|
||||||
|
|
||||||
|
await oauthService.handleCallback(code, state)
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
router.push('/')
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || '认证失败'
|
||||||
|
ElMessage.error(error.value)
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-screen items-center justify-center bg-gray-100">
|
||||||
|
<el-card v-if="loading" class="w-96">
|
||||||
|
<el-result icon="loading" title="正在处理认证..." sub-title="请稍候" />
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card v-else-if="error" class="w-96">
|
||||||
|
<el-result icon="error" title="认证失败" :sub-title="error">
|
||||||
|
<template #extra>
|
||||||
|
<el-button type="primary" @click="handleLogin">返回登录</el-button>
|
||||||
|
</template>
|
||||||
|
</el-result>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Loading…
Reference in New Issue
Block a user