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:
parent
02415839ab
commit
c0874bfab8
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
155
apps/web-ele/src/api/core/oauth.ts
Normal file
155
apps/web-ele/src/api/core/oauth.ts
Normal 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()}`;
|
||||
}
|
||||
@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
@ -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) {
|
||||
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);
|
||||
}
|
||||
|
||||
if (userInfo?.realName) {
|
||||
ElNotification({
|
||||
message: `${$t('authentication.loginSuccessDesc')}:${fetchUserInfoResult?.realName}`,
|
||||
message: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`,
|
||||
title: $t('authentication.loginSuccess'),
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
await router.push(
|
||||
fetchUserInfoResult.homePath || preferences.app.defaultHomePath,
|
||||
);
|
||||
}
|
||||
} 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();
|
||||
} catch {
|
||||
// 不做任何处理
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
58
apps/web-ele/src/views/_core/authentication/callback.vue
Normal file
58
apps/web-ele/src/views/_core/authentication/callback.vue
Normal 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>
|
||||
@ -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 认证中心
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await authStore.authLogin();
|
||||
} catch (error: any) {
|
||||
message.value = error.message || '登录跳转失败';
|
||||
} finally {
|
||||
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);
|
||||
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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user