feat: implement OAuth2 integration in web-ele

- Add OAuth2 configuration and PKCE service
- Update request interceptor for OAuth2 token management
- Update auth API for OAuth2 token handling
- Update auth store for OAuth2 flow
- Create OAuth callback page
- Update login page for OAuth2
- Add OAuth callback route
- Update environment configuration for OAuth2
This commit is contained in:
Sam 2026-02-06 01:47:05 +08:00
parent 02415839ab
commit c0874bfab8
9 changed files with 413 additions and 202 deletions

View File

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

View File

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

View File

@ -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<AuthApi.LoginResult>('/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<AuthApi.RefreshTokenResult>('/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<string[]>('/auth/codes');
export async function validateTokenApi() {
return baseRequestClient.get<void>('/health');
}

View File

@ -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<string> {
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<TokenResponse> {
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<TokenResponse> {
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()}`;
}

View File

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

View File

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

View File

@ -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<any>,
onSuccess?: () => Promise<void> | 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> | 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,
};
});

View File

@ -0,0 +1,58 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '#/store';
defineOptions({ name: 'OAuthCallback' });
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
onMounted(async () => {
const { code, state, error, error_description } = route.query as {
code?: string;
state?: string;
error?: string;
error_description?: string;
};
if (error) {
console.error('OAuth2 error:', error, error_description);
await router.push({
path: '/login',
query: {
error,
error_description,
},
});
return;
}
if (!code || !state) {
console.error('Missing code or state in callback');
await router.push({
path: '/login',
query: {
error: 'invalid_request',
error_description: 'Missing authorization code or state',
},
});
return;
}
await authStore.handleOAuthCallback({ code, state });
});
</script>
<template>
<div class="flex h-screen items-center justify-center">
<div class="text-center">
<div class="mb-4">
<div class="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent motion-reduce:animate-[spin_1.5s_linear_infinite]"></div>
</div>
<p class="text-gray-600">正在完成登录...</p>
</div>
</div>
</template>

View File

@ -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);
}
});
</script>
@ -29,15 +30,33 @@ onMounted(() => {
<el-card class="w-full max-w-md">
<template #header>
<h1 class="text-2xl font-bold text-center">Fengling Console</h1>
<p class="text-center text-sm text-gray-500">管理后台登录</p>
<p class="text-center text-sm text-gray-500">使用 OAuth2 认证中心登录</p>
</template>
<div class="flex flex-col items-center justify-center py-12">
<div v-if="loading" class="flex flex-col items-center justify-center py-12">
<el-icon :size="60" class="text-blue-500">
<Loading />
</el-icon>
<p class="mt-4 text-center text-gray-600">{{ message }}</p>
</div>
<div v-else class="py-8">
<div class="text-center mb-6">
<p class="text-gray-600">自动跳转失败请点击下方按钮手动跳转</p>
</div>
<el-button
type="primary"
class="w-full"
size="large"
@click="authStore.authLogin()"
>
使用认证中心登录
</el-button>
<div class="mt-6 text-center text-sm text-gray-500">
<p>默认账号: admin / Admin@123</p>
<p>测试账号: testuser / Test@123</p>
</div>
</div>
</el-card>
</div>
</template>