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
|
VITE_GLOB_API_URL=http://localhost:5231/api
|
||||||
|
|
||||||
# OAuth 认证中心配置
|
# OAuth 认证中心配置
|
||||||
VITE_OAUTH_ISSUER=https://auth.fengling.local
|
VITE_AUTH_SERVICE_URL=http://localhost:5000
|
||||||
VITE_OAUTH_CLIENT_ID=fengling-console
|
VITE_OAUTH_CLIENT_ID=fengling-console
|
||||||
VITE_OAUTH_REDIRECT_URI=http://localhost:5777/callback
|
VITE_OAUTH_REDIRECT_URI=http://localhost:5777/auth/callback
|
||||||
VITE_OAUTH_SCOPES=openid profile email api
|
VITE_OAUTH_SCOPE=api offline_access openid profile email roles
|
||||||
|
|
||||||
# 是否开启 Nitro Mock服务,true 为开启,false 为关闭
|
# 是否开启 Nitro Mock服务,true 为开启,false 为关闭
|
||||||
VITE_NITRO_MOCK=false
|
VITE_NITRO_MOCK=false
|
||||||
|
|||||||
@ -17,3 +17,9 @@ VITE_INJECT_APP_LOADING=true
|
|||||||
|
|
||||||
# 打包后是否生成dist.zip
|
# 打包后是否生成dist.zip
|
||||||
VITE_ARCHIVER=true
|
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 namespace AuthApi {
|
||||||
/** 登录接口参数 */
|
export interface TokenResponse {
|
||||||
export interface LoginParams {
|
access_token: string;
|
||||||
password?: string;
|
refresh_token: string;
|
||||||
username?: string;
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
scope: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登录接口返回值 */
|
export interface UserInfo {
|
||||||
export interface LoginResult {
|
sub: string;
|
||||||
accessToken: string;
|
name: string;
|
||||||
}
|
email: string;
|
||||||
|
role: string[];
|
||||||
export interface RefreshTokenResult {
|
tenant_id: string;
|
||||||
data: string;
|
|
||||||
status: number;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function parseJwt(token: string): AuthApi.UserInfo | null {
|
||||||
* 登录
|
try {
|
||||||
*/
|
const parts = token.split('.');
|
||||||
export async function loginApi(data: AuthApi.LoginParams) {
|
if (parts.length !== 3) return null;
|
||||||
return requestClient.post<AuthApi.LoginResult>('/auth/login', data);
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function logoutApi(refreshToken: string) {
|
||||||
* 刷新accessToken
|
const response = await fetch(`${oauthConfig.authUrl}${oauthConfig.endpoints.revocation}`, {
|
||||||
*/
|
method: 'POST',
|
||||||
export async function refreshTokenApi() {
|
headers: {
|
||||||
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
withCredentials: true,
|
},
|
||||||
|
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 validateTokenApi() {
|
||||||
* 退出登录
|
return baseRequestClient.get<void>('/health');
|
||||||
*/
|
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|||||||
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 type { RequestClientOptions } from '@vben/request';
|
||||||
|
|
||||||
import { useAppConfig } from '@vben/hooks';
|
import { useAppConfig } from '@vben/hooks';
|
||||||
@ -16,9 +14,8 @@ 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 { refreshAccessToken } from './core/oauth';
|
||||||
|
|
||||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||||
|
|
||||||
@ -28,11 +25,13 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
|||||||
baseURL,
|
baseURL,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
function isTokenExpired(expiresAt: number | null): boolean {
|
||||||
* 重新认证逻辑
|
if (!expiresAt) return true;
|
||||||
*/
|
return Date.now() >= expiresAt - 5 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
async function doReAuthenticate() {
|
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 accessStore = useAccessStore();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
accessStore.setAccessToken(null);
|
accessStore.setAccessToken(null);
|
||||||
@ -46,34 +45,54 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 刷新token逻辑
|
|
||||||
*/
|
|
||||||
async function doRefreshToken() {
|
async function doRefreshToken() {
|
||||||
const accessStore = useAccessStore();
|
const accessStore = useAccessStore();
|
||||||
const resp = await refreshTokenApi();
|
const refreshToken = accessStore.refreshToken;
|
||||||
const newToken = resp.data;
|
|
||||||
accessStore.setAccessToken(newToken);
|
if (!refreshToken) {
|
||||||
return newToken;
|
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) {
|
function formatToken(token: null | string) {
|
||||||
return token ? `Bearer ${token}` : null;
|
return token ? `Bearer ${token}` : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 请求头处理
|
|
||||||
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(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;
|
config.headers['Accept-Language'] = preferences.app.locale;
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 处理返回的响应数据格式
|
|
||||||
client.addResponseInterceptor(
|
client.addResponseInterceptor(
|
||||||
defaultResponseInterceptor({
|
defaultResponseInterceptor({
|
||||||
codeField: 'code',
|
codeField: 'code',
|
||||||
@ -82,7 +101,6 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// token过期的处理
|
|
||||||
client.addResponseInterceptor(
|
client.addResponseInterceptor(
|
||||||
authenticateResponseInterceptor({
|
authenticateResponseInterceptor({
|
||||||
client,
|
client,
|
||||||
@ -93,14 +111,10 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
|
|
||||||
client.addResponseInterceptor(
|
client.addResponseInterceptor(
|
||||||
errorMessageResponseInterceptor((msg: string, error) => {
|
errorMessageResponseInterceptor((msg: string, error) => {
|
||||||
// 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
|
|
||||||
// 当前mock接口返回的错误字段是 error 或者 message
|
|
||||||
const responseData = error?.response?.data ?? {};
|
const responseData = error?.response?.data ?? {};
|
||||||
const errorMessage = responseData?.error ?? responseData?.message ?? '';
|
const errorMessage = responseData?.error ?? responseData?.message ?? '';
|
||||||
// 如果没有错误信息,则会根据状态码进行提示
|
|
||||||
ElMessage.error(errorMessage || msg);
|
ElMessage.error(errorMessage || msg);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -94,11 +94,11 @@ const coreRoutes: RouteRecordRaw[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'OAuthCallback',
|
name: 'OAuthCallback',
|
||||||
path: '/callback',
|
path: '/auth/callback',
|
||||||
component: () => import('#/views/fengling/oauth-callback.vue'),
|
component: () => import('#/views/_core/authentication/callback.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
hideInMenu: true,
|
hideInMenu: true,
|
||||||
hideInTab: true,
|
ignoreAuth: true,
|
||||||
title: 'OAuth Callback',
|
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 { ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
@ -10,10 +10,15 @@ import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
|
|||||||
import { ElNotification } from 'element-plus';
|
import { ElNotification } from 'element-plus';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
import { getAccessCodesApi, getUserInfoApi, logoutApi, oauthLogoutApi } from '#/api';
|
import { parseJwt, logoutApi } from '#/api/core/auth';
|
||||||
import { oauthService } from '#/services/auth/oauth';
|
import { exchangeCodeForToken, getLogoutUrl, redirectToAuth } from '#/api/core/oauth';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
export interface OAuth2State {
|
||||||
|
code: string;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const accessStore = useAccessStore();
|
const accessStore = useAccessStore();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
@ -21,91 +26,94 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
const loginLoading = ref(false);
|
const loginLoading = ref(false);
|
||||||
|
|
||||||
/**
|
async function handleOAuthCallback(oauthState: OAuth2State) {
|
||||||
* OAuth 2.0 登录 - 重定向到认证中心
|
let userInfo: null | UserInfo = null;
|
||||||
*/
|
|
||||||
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) {
|
|
||||||
try {
|
try {
|
||||||
loginLoading.value = true;
|
loginLoading.value = true;
|
||||||
|
|
||||||
await oauthService.handleCallback(code, state);
|
const tokenResponse = await exchangeCodeForToken(oauthState.code, oauthState.state);
|
||||||
|
|
||||||
const accessToken = oauthService.getAccessToken();
|
accessStore.setAccessToken(tokenResponse.access_token);
|
||||||
if (accessToken) {
|
accessStore.setRefreshToken(tokenResponse.refresh_token);
|
||||||
accessStore.setAccessToken(accessToken);
|
accessStore.setExpiresAt(Date.now() + tokenResponse.expires_in * 1000);
|
||||||
|
|
||||||
const [fetchUserInfoResult, accessCodes] = await Promise.all([
|
const jwtClaims = parseJwt(tokenResponse.access_token);
|
||||||
fetchUserInfo(),
|
if (jwtClaims) {
|
||||||
getAccessCodesApi(),
|
userInfo = {
|
||||||
]);
|
userId: jwtClaims.sub,
|
||||||
|
username: jwtClaims.name,
|
||||||
|
realName: jwtClaims.name,
|
||||||
|
email: jwtClaims.email,
|
||||||
|
roles: jwtClaims.role,
|
||||||
|
homePath: preferences.app.defaultHomePath,
|
||||||
|
};
|
||||||
|
|
||||||
userStore.setUserInfo(fetchUserInfoResult);
|
userStore.setUserInfo(userInfo);
|
||||||
accessStore.setAccessCodes(accessCodes);
|
accessStore.setAccessCodes(jwtClaims.role || []);
|
||||||
|
}
|
||||||
|
|
||||||
if (fetchUserInfoResult?.realName) {
|
if (accessStore.loginExpired) {
|
||||||
ElNotification({
|
accessStore.setLoginExpired(false);
|
||||||
message: `${$t('authentication.loginSuccessDesc')}:${fetchUserInfoResult?.realName}`,
|
} else {
|
||||||
title: $t('authentication.loginSuccess'),
|
const returnTo = sessionStorage.getItem('oauth_return_to') || preferences.app.defaultHomePath;
|
||||||
type: 'success',
|
sessionStorage.removeItem('oauth_return_to');
|
||||||
});
|
await router.push(returnTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
await router.push(
|
if (userInfo?.realName) {
|
||||||
fetchUserInfoResult.homePath || preferences.app.defaultHomePath,
|
ElNotification({
|
||||||
);
|
message: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`,
|
||||||
|
title: $t('authentication.loginSuccess'),
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
console.error('OAuth2 callback error:', error);
|
||||||
ElNotification({
|
ElNotification({
|
||||||
message: error.message || $t('authentication.loginFailed'),
|
message: error.message || '认证失败',
|
||||||
title: $t('authentication.loginFailedTitle'),
|
title: '登录失败',
|
||||||
type: 'error',
|
type: 'error',
|
||||||
});
|
});
|
||||||
await router.replace(LOGIN_PATH);
|
await router.push(LOGIN_PATH);
|
||||||
} finally {
|
} finally {
|
||||||
loginLoading.value = false;
|
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) {
|
async function logout(redirect: boolean = true) {
|
||||||
|
const refreshToken = accessStore.refreshToken;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// OAuth 登出
|
if (refreshToken) {
|
||||||
await oauthLogoutApi();
|
await logoutApi(refreshToken);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 不做任何处理
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
accessStore.setLoginExpired(false);
|
accessStore.setLoginExpired(false);
|
||||||
|
accessStore.setAccessToken(null);
|
||||||
|
accessStore.setRefreshToken(null);
|
||||||
|
accessStore.setExpiresAt(null);
|
||||||
|
|
||||||
// OAuth 登出并重定向到认证中心
|
window.location.href = getLogoutUrl();
|
||||||
oauthService.logout();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUserInfo() {
|
async function fetchUserInfo() {
|
||||||
let userInfo: null | UserInfo = null;
|
const userInfo = userStore.getUserInfo;
|
||||||
userInfo = await getUserInfoApi();
|
|
||||||
userStore.setUserInfo(userInfo);
|
|
||||||
return userInfo;
|
return userInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,54 +129,4 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
loginLoading,
|
loginLoading,
|
||||||
logout,
|
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 { ElCard, ElIcon } from 'element-plus';
|
||||||
import { Loading } from '@element-plus/icons-vue';
|
import { Loading } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
|
import { useAuthStore } from '#/store';
|
||||||
|
|
||||||
defineOptions({ name: 'Login' });
|
defineOptions({ name: 'Login' });
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const message = ref('正在跳转到认证中心...');
|
const message = ref('正在跳转到认证中心...');
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
// 页面加载后自动跳转到 OAuth 认证中心
|
try {
|
||||||
setTimeout(() => {
|
await authStore.authLogin();
|
||||||
window.location.href = import.meta.env.VITE_OAUTH_ISSUER + '/connect/authorize?' +
|
} catch (error: any) {
|
||||||
new URLSearchParams({
|
message.value = error.message || '登录跳转失败';
|
||||||
response_type: 'code',
|
} finally {
|
||||||
client_id: import.meta.env.VITE_OAUTH_CLIENT_ID || 'fengling-console',
|
setTimeout(() => {
|
||||||
redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URI || 'http://localhost:5777/callback',
|
loading.value = false;
|
||||||
scope: import.meta.env.VITE_OAUTH_SCOPES || 'openid profile email api',
|
}, 1000);
|
||||||
state: Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2),
|
}
|
||||||
}).toString();
|
|
||||||
}, 500);
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -29,15 +30,33 @@ onMounted(() => {
|
|||||||
<el-card class="w-full max-w-md">
|
<el-card class="w-full max-w-md">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h1 class="text-2xl font-bold text-center">Fengling Console</h1>
|
<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>
|
</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">
|
<el-icon :size="60" class="text-blue-500">
|
||||||
<Loading />
|
<Loading />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<p class="mt-4 text-center text-gray-600">{{ message }}</p>
|
<p class="mt-4 text-center text-gray-600">{{ message }}</p>
|
||||||
</div>
|
</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>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user