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
|
||||
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
|
||||
|
||||
|
||||
@ -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 { 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;
|
||||
},
|
||||
|
||||
@ -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 };
|
||||
|
||||
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