feat: integrate OAuth 2.0 authentication with Fengling Auth Center

This commit is contained in:
Sam 2026-02-06 00:41:41 +08:00
parent 393296ef3d
commit 0ebe467927
6 changed files with 236 additions and 1 deletions

View File

@ -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

View File

@ -43,6 +43,15 @@ export async function logoutApi() {
}); });
} }
/**
* OAuth
*/
export async function oauthLogoutApi() {
return baseRequestClient.post('/connect/logout', {
withCredentials: true,
});
}
/** /**
* *
*/ */

View File

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

View File

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

View 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()

View 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>