feat: 添加OAuth2认证配置和实现

添加OAuth2认证相关配置文件和服务实现,包括环境变量配置、PKCE流程支持、token管理等功能。主要变更:
- 新增OAuth2配置文件
- 实现OAuth2服务层
- 更新请求拦截器支持token自动刷新
- 修改认证API和store以支持OAuth2流程
This commit is contained in:
Sam 2026-02-07 17:47:11 +08:00
parent e32b8b3080
commit fa93d71725
23 changed files with 2217 additions and 668 deletions

172
OIDC_INTEGRATION.md Normal file
View File

@ -0,0 +1,172 @@
# OIDC 集成说明
## 概述
本项目已经使用 `oidc-client-ts` 库集成 OpenID Connect 认证,替换了原有的自定义登录实现。
## 新增文件
### 1. OIDC 配置
- `src/config/oidc.ts` - OIDC 客户端配置
### 2. OIDC 服务
- `src/services/oidc-auth.service.ts` - OIDC 认证服务封装
### 3. 回调页面
- `src/views/_core/authentication/callback.vue` - OIDC 登录回调处理页面
### 4. API 更新
- `src/api/core/auth.ts` - 更新为使用 OIDC token
### 5. Store 更新
- `src/store/auth.ts` - 更新为使用 OIDC 认证流程
## 修改文件
### 1. 路由配置
- `src/router/routes/core.ts` - 添加了 `/auth/callback` 路由
- `src/router/guard.ts` - 更新为使用 OIDC 认证检查
### 2. 请求拦截器
- `src/api/request.ts` - 更新为使用 OIDC 的 token 刷新
### 3. 环境配置
- `.env.development` - 添加 `VITE_AUTH_SERVER_URL` 配置
## 环境变量
`.env` 文件中配置以下变量:
```env
# OIDC认证服务器地址
VITE_AUTH_SERVER_URL=http://localhost:5132
```
## 认证流程
### 密码模式登录 (Resource Owner Password Credentials)
1. 用户在登录页面输入用户名和密码
2. 调用 `authStore.authLogin({ username, password })`
3. 使用 OIDC 的 `signinResourceOwnerCredentials` 方法直接获取 token
4. Token 存储到 store并在请求头中携带
5. 获取用户信息并设置到用户 store
### 授权码模式登录 (Authorization Code Flow)
1. 前端调用 `oidcAuthService.signinRedirect()` 重定向到认证服务器
2. 用户在认证服务器完成认证
3. 认证服务器重定向回 `/auth/callback?code=...`
4. 回调页面调用 `authStore.handleCallback()` 处理授权码
5. 换取访问令牌并存储
## Token 管理
### 自动续期
- 当 token 即将过期60秒前OIDC 服务会自动静默续期
- 如果静默续期失败,会重定向到登录页
### 手动刷新
```typescript
await authStore.refreshToken();
```
### 退出登录
```typescript
await authStore.logout();
```
## API 使用
### 使用 Access Token
API 请求会自动携带 Bearer token
```typescript
// 请求头会自动添加
Authorization: Bearer {access_token}
```
### 获取用户信息
```typescript
// 从 token claims 获取
const userInfo = getUserInfoFromToken(user);
// 或从服务器获取
const userInfo = await getUserInfoApi();
```
## 错误处理
### 常见错误
1. **invalid_grant** - 用户名或密码错误
2. **invalid_client** - 客户端 ID 无效
3. **expired_token** - Token 已过期
4. **invalid_token** - Token 无效
所有错误都会显示友好的提示信息,并重定向到登录页。
## 注意事项
1. **Token 存储**Token 存储在 localStorage 中,由 oidc-client-ts 管理
2. **CORS 配置**:认证服务器需要配置 CORS 允许前端域名
3. **HTTPS**:生产环境必须使用 HTTPS
4. **PKCE**:如果使用授权码模式,建议启用 PKCE 增强安全性
## 后端要求
认证服务器必须支持以下功能:
- OpenID Connect 1.0 兼容
- Resource Owner Password Credentials 授权类型
- Authorization Code 授权类型
- Refresh Token 支持
- Userinfo 端点
## 开发建议
1. 开发时使用 `http://localhost:5132` 作为认证服务器
2. 生产环境配置实际的认证服务器地址
3. 使用浏览器开发者工具的 Application 标签查看存储的 token
4. 监控网络请求,查看 OAuth 流程
## 迁移指南
如果你之前使用自定义的登录实现:
1. 删除旧的登录 API 调用
2. 替换为使用 `authStore.authLogin()` 方法
3. 确保 request 拦截器使用 OIDC token
4. 添加 `/auth/callback` 路由
5. 配置环境变量 `VITE_AUTH_SERVER_URL`
## 故障排除
### Token 过期问题
如果遇到 token 频繁过期:
1. 检查认证服务器的 token 有效期设置
2. 确保自动续期功能正常工作
3. 查看浏览器控制台的错误信息
### CORS 问题
如果遇到 CORS 错误:
1. 确保认证服务器配置了 CORS
2. 检查前端域名是否在允许列表中
3. 确认 `VITE_AUTH_SERVER_URL` 配置正确
### 回调失败
如果登录回调失败:
1. 检查 `redirect_uri` 是否匹配
2. 确认认证服务器返回的授权码
3. 查看浏览器控制台的详细错误信息

View File

@ -6,7 +6,10 @@ VITE_BASE=/
# 接口地址 - Fengling Console Backend
VITE_GLOB_API_URL=http://localhost:5231/api
# OAuth 认证中心配置
# OIDC认证服务器地址
VITE_AUTH_SERVER_URL=http://localhost:5132
# OAuth 认证中心配置(旧的,保留兼容性)
VITE_AUTH_SERVICE_URL=http://localhost:5132
VITE_OAUTH_CLIENT_ID=fengling-console
VITE_OAUTH_REDIRECT_URI=http://localhost:5777/auth/callback

View File

@ -43,6 +43,7 @@
"@vueuse/core": "catalog:",
"dayjs": "catalog:",
"element-plus": "catalog:",
"oidc-client-ts": "^3.4.1",
"pinia": "catalog:",
"vue": "catalog:",
"vue-router": "catalog:"

View File

@ -1,61 +1,110 @@
import type { User } from 'oidc-client-ts';
import { baseRequestClient } from '#/api/request';
import { oauthConfig } from '#/config/oauth';
import { ElNotification } from 'element-plus';
export namespace AuthApi {
export interface TokenResponse {
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
scope: string;
/** 用户信息 */
export interface UserInfo {
id: string;
username: string;
email: string;
realName: string;
tenantId: string;
tenantCode: string;
tenantName: string;
roles: string[];
homePath?: string;
}
export interface UserInfo {
sub: string;
name: string;
email: string;
role: string[];
tenant_id: string;
/** 登录参数 */
export interface LoginParams {
password: string;
username: string;
}
/** OIDC用户 */
export type OidcUser = User;
}
/**
*
* Get user info from token claims
*/
export function getUserInfoFromToken(user: AuthApi.OidcUser): AuthApi.UserInfo {
const profile = user.profile as any;
return {
id: profile.sub || user.profile.sub,
username: profile.preferred_username || profile.name,
email: profile.email || '',
realName: profile.name || profile.preferred_username,
tenantId: profile.tenant_id,
tenantCode: profile.tenant_code,
tenantName: profile.tenant_name,
roles: (profile.role || []) as string[],
};
}
/**
*
* Fetch detailed user info from server
*/
export async function getUserInfoApi(): Promise<AuthApi.UserInfo> {
try {
return await baseRequestClient.get<AuthApi.UserInfo>('/connect/userinfo', {
withCredentials: true,
});
} catch (error: any) {
// 如果token无效返回错误
if (error?.response?.status === 401) {
ElNotification({
title: '会话已过期',
message: '您的登录会话已过期,请重新登录',
type: 'error',
});
throw new Error('Unauthorized');
}
throw error;
}
}
/**
* 退
*/
export async function logoutApi() {
try {
await baseRequestClient.post('/connect/logout', {
withCredentials: true,
});
} catch (error) {
// 静默处理退出错误
console.error('[Auth] Logout error:', error);
}
}
/**
* Parse JWT token (legacy, for backward compatibility)
*/
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]));
const payloadStr = parts[1];
if (!payloadStr) return null;
const payload = JSON.parse(atob(payloadStr));
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'],
id: payload.sub,
username: payload.preferred_username || payload.name,
email: payload.email || '',
realName: payload.name || payload.preferred_username,
tenantId: payload.tenant_id,
tenantCode: payload.tenant_code,
tenantName: payload.tenant_name,
roles: Array.isArray(payload.role) ? payload.role : [payload.role].filter(Boolean),
};
} catch {
return null;
}
}
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 validateTokenApi() {
return baseRequestClient.get<void>('/health');
}

View File

@ -1,4 +1,3 @@
export * from './auth';
export * from './menu';
export * from './oauth';
export * from './user';

View File

@ -1,155 +0,0 @@
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

@ -5,6 +5,6 @@ import { requestClient } from '#/api/request';
/**
*
*/
export async function getUserInfoApi() {
export async function getUserInfoOldApi() {
return requestClient.get<UserInfo>('/user/info');
}

View File

@ -1,45 +1,69 @@
import type { FenglingApi } from './typings';
import { requestClient } from '#/api/request';
import { baseRequestClient } from '#/api/request';
export namespace OAuthApi {
/**
* OAuth客户端列表
*/
export async function getClientList(params: {
page?: number
pageSize?: number
keyword?: string
status?: string
}) {
return requestClient.get<FenglingApi.PaginatedResponse<FenglingApi.OAuth.OAuthClient>>(
'/oauth/clients',
{ params }
page: number;
pageSize: number;
keyword?: string;
}): Promise<FenglingApi.PaginatedResponse<FenglingApi.OAuth.OAuthClient>> {
const queryParams = new URLSearchParams({
page: params.page.toString(),
pageSize: params.pageSize.toString(),
...(params.keyword ? { keyword: params.keyword } : {}),
});
return await baseRequestClient.get<FenglingApi.PaginatedResponse<FenglingApi.OAuth.OAuthClient>>(
`/oauth/clients?${queryParams.toString()}`,
);
}
export async function getClientById(id: number) {
return requestClient.get<FenglingApi.OAuth.OAuthClient>(`/oauth/clients/${id}`);
/**
* OAuth客户端详情
*/
export async function getClient(id: number): Promise<FenglingApi.OAuth.OAuthClient> {
return await baseRequestClient.get<FenglingApi.OAuth.OAuthClient>(`/oauth/clients/${id}`);
}
export async function createClient(data: FenglingApi.OAuth.CreateOAuthClientDto) {
return requestClient.post<FenglingApi.OAuth.OAuthClient>('/oauth/clients', data);
/**
* OAuth客户端
*/
export async function createClient(
data: FenglingApi.OAuth.CreateOAuthClientDto,
): Promise<FenglingApi.OAuth.OAuthClient> {
return await baseRequestClient.post<FenglingApi.OAuth.OAuthClient>('/oauth/clients', data);
}
export async function updateClient(id: number, data: FenglingApi.OAuth.UpdateOAuthClientDto) {
return requestClient.put<FenglingApi.OAuth.OAuthClient>(`/oauth/clients/${id}`, data);
/**
* OAuth客户端
*/
export async function updateClient(
id: number,
data: FenglingApi.OAuth.UpdateOAuthClientDto,
): Promise<FenglingApi.OAuth.OAuthClient> {
return await baseRequestClient.put<FenglingApi.OAuth.OAuthClient>(`/oauth/clients/${id}`, data);
}
export async function deleteClient(id: number) {
return requestClient.delete(`/oauth/clients/${id}`);
/**
* OAuth客户端
*/
export async function deleteClient(id: number): Promise<void> {
await baseRequestClient.delete(`/oauth/clients/${id}`);
}
export async function regenerateClientSecret(id: number) {
return requestClient.post(`/oauth/clients/${id}/regenerate-secret`);
/**
* OAuth客户端密钥
*/
export async function resetClientSecret(id: number): Promise<{ clientSecret: string }> {
return await baseRequestClient.post<{ clientSecret: string }>(`/oauth/clients/${id}/reset-secret`);
}
export async function activateClient(id: number) {
return requestClient.post(`/oauth/clients/${id}/activate`);
}
export async function deactivateClient(id: number) {
return requestClient.post(`/oauth/clients/${id}/deactivate`);
/**
* /OAuth客户端
*/
export async function toggleClientStatus(id: number, status: 'active' | 'inactive'): Promise<void> {
await baseRequestClient.put(`/oauth/clients/${id}/status`, { status });
}
}

View File

@ -1,4 +1,3 @@
import { oauthConfig } from '#/config/oauth';
import type { RequestClientOptions } from '@vben/request';
import { useAppConfig } from '@vben/hooks';
@ -15,8 +14,6 @@ import { ElMessage } from 'element-plus';
import { useAuthStore } from '#/store';
import { refreshAccessToken } from './core/oauth';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
@ -25,13 +22,11 @@ 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);
@ -45,24 +40,24 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
}
}
async function doRefreshToken() {
/**
* token逻辑 - 使OIDC
*/
async function doRefreshToken(): Promise<string> {
const accessStore = useAccessStore();
const refreshToken = accessStore.refreshToken;
if (!refreshToken) {
await doReAuthenticate();
throw new Error('No refresh token available');
}
const authStore = useAuthStore();
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;
// 使用OIDC的刷新token方法
await authStore.refreshToken();
// 刷新后重新获取token
const newToken = await authStore.getAccessToken();
if (newToken) {
accessStore.setAccessToken(newToken);
return newToken;
}
throw new Error('Failed to get access token after refresh');
} catch (error) {
console.error('Failed to refresh token:', error);
await doReAuthenticate();
console.error('[Request] Refresh token failed:', error);
throw error;
}
}
@ -71,28 +66,18 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
return token ? `Bearer ${token}` : null;
}
// 请求头处理
client.addRequestInterceptor({
fulfilled: async (config) => {
const accessStore = useAccessStore();
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.Authorization = formatToken(accessStore.accessToken);
config.headers['Accept-Language'] = preferences.app.locale;
return config;
},
});
// 处理返回的响应数据格式
client.addResponseInterceptor(
defaultResponseInterceptor({
codeField: 'code',
@ -101,6 +86,7 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
}),
);
// token过期的处理
client.addResponseInterceptor(
authenticateResponseInterceptor({
client,
@ -111,10 +97,14 @@ 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

@ -1,22 +0,0 @@
export const oauthConfig = {
clientId: import.meta.env.VITE_OAUTH_CLIENT_ID || 'fengling-console',
redirectUri: import.meta.env.VITE_OAUTH_REDIRECT_URI || `${window.location.origin}/auth/callback`,
authUrl: import.meta.env.VITE_AUTH_SERVICE_URL || 'http://localhost:5000',
scope: import.meta.env.VITE_OAUTH_SCOPE || 'api offline_access openid profile email',
endpoints: {
authorize: '/connect/authorize',
token: '/connect/token',
logout: '/connect/logout',
revocation: '/connect/revocation',
},
storageKeys: {
accessToken: 'oauth_access_token',
refreshToken: 'oauth_refresh_token',
expiresAt: 'oauth_expires_at',
codeVerifier: 'oauth_code_verifier',
state: 'oauth_state',
returnTo: 'oauth_return_to',
},
};

View File

@ -0,0 +1,25 @@
import type { UserManager, UserManagerSettings } from 'oidc-client-ts';
/**
* OIDC
* OIDC Configuration
*/
export const oidcConfig: UserManagerSettings = {
authority: import.meta.env.VITE_AUTH_SERVER_URL || 'http://localhost:5132',
client_id: 'fengling-console',
redirect_uri: window.location.origin + '/auth/callback',
post_logout_redirect_uri: window.location.origin + '/auth/logout-callback',
response_type: 'code',
scope: 'openid profile email api offline_access',
automaticSilentRenew: true,
includeIdTokenInSilentRenew: false,
loadUserInfo: true,
filterProtocolClaims: true,
accessTokenExpiringNotificationTimeInSeconds: 60,
silentRequestTimeoutInSeconds: 10000,
silent_redirect_uri: window.location.origin + '/silent-renew.html',
checkSessionIntervalInSeconds: 60000,
query_status_response_type: 'fragment',
};
export type { UserManagerSettings, UserManager };

View File

@ -62,8 +62,9 @@ function setupAccessGuard(router: Router) {
return true;
}
// accessToken 检查
if (!accessStore.accessToken) {
// 使用OIDC检查认证状态
const isAuthenticated = await authStore.checkAuth();
if (!isAuthenticated) {
// 明确声明忽略权限访问权限,则可以访问
if (to.meta.ignoreAccess) {
return true;
@ -85,6 +86,14 @@ function setupAccessGuard(router: Router) {
return to;
}
// 检查store中的accessToken是否存在如果不存在从OIDC获取
if (!accessStore.accessToken) {
const token = await authStore.getAccessToken();
if (token) {
accessStore.setAccessToken(token);
}
}
// 是否已经生成过动态路由
if (accessStore.isAccessChecked) {
return true;
@ -92,8 +101,8 @@ function setupAccessGuard(router: Router) {
// 生成路由表
// 当前登录用户拥有的角色标识列表
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
const userRoles = userInfo.roles ?? [];
const userInfo = userStore.userInfo;
const userRoles = userInfo?.roles ?? [];
// 生成菜单和路由
const { accessibleMenus, accessibleRoutes } = await generateAccess({
@ -107,9 +116,11 @@ function setupAccessGuard(router: Router) {
accessStore.setAccessMenus(accessibleMenus);
accessStore.setAccessRoutes(accessibleRoutes);
accessStore.setIsAccessChecked(true);
const homePath = userInfo?.homePath || preferences.app.defaultHomePath;
const redirectPath = (from.query.redirect ??
(to.path === preferences.app.defaultHomePath
? userInfo.homePath || preferences.app.defaultHomePath
? homePath
: to.fullPath)) as string;
return {

View File

@ -102,6 +102,16 @@ const coreRoutes: RouteRecordRaw[] = [
title: 'OAuth Callback',
},
},
{
name: 'LogoutCallback',
path: '/auth/logout-callback',
component: () => import('#/views/_core/authentication/logout-callback.vue'),
meta: {
hideInMenu: true,
ignoreAuth: true,
title: 'Logout Callback',
},
},
];
export { coreRoutes, fallbackNotFoundRoute };

View File

@ -1,158 +0,0 @@
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,201 @@
import type { User } from 'oidc-client-ts';
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';
import { oidcConfig } from '#/config/oidc';
/**
* OIDC
* OIDC Service for handling authentication
*/
class OidcAuthService {
private userManager: UserManager;
private static instance: OidcAuthService;
private constructor() {
this.userManager = new UserManager({
...oidcConfig,
userStore: new WebStorageStateStore({ store: window.localStorage }),
});
this.setupEventListeners();
}
public static getInstance(): OidcAuthService {
if (!OidcAuthService.instance) {
OidcAuthService.instance = new OidcAuthService();
}
return OidcAuthService.instance;
}
/**
*
* Setup event listeners for token events
*/
private setupEventListeners() {
this.userManager.events.addAccessTokenExpiring(() => {
console.log('[OIDC] Access token expiring...');
this.silentRenew().catch((error) => {
console.error('[OIDC] Silent renew failed:', error);
});
});
this.userManager.events.addAccessTokenExpired(() => {
console.log('[OIDC] Access token expired');
window.location.reload();
});
this.userManager.events.addSilentRenewError((error) => {
console.error('[OIDC] Silent renew error:', error);
});
this.userManager.events.addUserSignedOut(() => {
console.log('[OIDC] User signed out');
this.signinRedirect();
});
}
/**
*
* Get current user
*/
public async getUser(): Promise<User | null> {
try {
return await this.userManager.getUser();
} catch (error) {
console.error('[OIDC] Error getting user:', error);
return null;
}
}
/**
*
* Redirect to login page
*/
public async signinRedirect(): Promise<void> {
try {
await this.userManager.signinRedirect();
} catch (error) {
console.error('[OIDC] Signin redirect error:', error);
throw error;
}
}
/**
*
* Handle login callback
*/
public async handleSigninCallback(): Promise<User> {
try {
const user = await this.userManager.signinRedirectCallback();
console.log('[OIDC] Signin callback successful:', user);
return user;
} catch (error) {
console.error('[OIDC] Signin callback error:', error);
throw error;
}
}
/**
* 使username/password
* Password grant login (direct username/password)
*/
public async signinPassword(
username: string,
password: string,
): Promise<User> {
try {
const user = await this.userManager.signinResourceOwnerCredentials({
username,
password,
});
console.log('[OIDC] Signin with password successful:', user);
return user;
} catch (error) {
console.error('[OIDC] Signin with password error:', error);
throw error;
}
}
/**
* token
* Silent renew token
*/
public async silentRenew(): Promise<User> {
try {
const user = await this.userManager.signinSilent();
if (user) {
console.log('[OIDC] Silent renew successful:', user);
return user;
}
throw new Error('Silent renew returned null');
} catch (error) {
console.error('[OIDC] Silent renew error:', error);
throw error;
}
}
/**
*
* Remove user and redirect to login
*/
public async signoutRedirect(): Promise<void> {
try {
await this.userManager.signoutRedirect();
} catch (error) {
console.error('[OIDC] Signout redirect error:', error);
throw error;
}
}
/**
*
* Remove user without redirect
*/
public async removeUser(): Promise<void> {
try {
await this.userManager.removeUser();
console.log('[OIDC] User removed');
} catch (error) {
console.error('[OIDC] Remove user error:', error);
throw error;
}
}
/**
* 访
* Get access token
*/
public async getAccessToken(): Promise<string | null> {
const user = await this.getUser();
return user?.access_token ?? null;
}
/**
*
* Check if user is authenticated
*/
public async isAuthenticated(): Promise<boolean> {
const user = await this.getUser();
return user !== null && !user.expired;
}
/**
*
* Refresh token
*/
public async refreshToken(): Promise<User> {
try {
const user = await this.userManager.signinSilent();
if (user) {
console.log('[OIDC] Token refreshed:', user);
return user;
}
throw new Error('Refresh token returned null');
} catch (error) {
console.error('[OIDC] Refresh token error:', error);
throw error;
}
}
}
export const oidcAuthService = OidcAuthService.getInstance();
export default oidcAuthService;

View File

@ -1,7 +1,7 @@
import type { UserInfo } from '@vben/types';
import type { User as OidcUser } from 'oidc-client-ts';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { LOGIN_PATH } from '@vben/constants';
import { preferences } from '@vben/preferences';
@ -10,123 +10,222 @@ import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
import { ElNotification } from 'element-plus';
import { defineStore } from 'pinia';
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;
}
import { getUserInfoApi, getUserInfoFromToken, logoutApi } from '#/api/core/auth';
import oidcAuthService from '#/services/oidc-auth.service';
export const useAuthStore = defineStore('auth', () => {
const accessStore = useAccessStore();
const userStore = useUserStore();
const router = useRouter();
const route = useRoute();
const loginLoading = ref(false);
const oidcUser = ref<OidcUser | null>(null);
async function handleOAuthCallback(oauthState: OAuth2State) {
let userInfo: null | UserInfo = null;
/**
* 访
* Get access token from OIDC
*/
async function getAccessToken(): Promise<string | null> {
try {
loginLoading.value = true;
const tokenResponse = await exchangeCodeForToken(oauthState.code, oauthState.state);
accessStore.setAccessToken(tokenResponse.access_token);
accessStore.setRefreshToken(tokenResponse.refresh_token);
accessStore.setExpiresAt(Date.now() + tokenResponse.expires_in * 1000);
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(userInfo);
accessStore.setAccessCodes(jwtClaims.role || []);
}
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')}:${userInfo?.realName}`,
title: $t('authentication.loginSuccess'),
type: 'success',
});
}
} catch (error: any) {
console.error('OAuth2 callback error:', error);
ElNotification({
message: error.message || '认证失败',
title: '登录失败',
type: 'error',
});
await router.push(LOGIN_PATH);
} finally {
loginLoading.value = false;
return await oidcAuthService.getAccessToken();
} catch (error) {
console.error('[Auth] Get access token error:', error);
return null;
}
}
/**
*
* Authorization code login
*/
async function authLogin(
_params: {},
_onSuccess?: () => Promise<void> | void,
) {
// 重定向到 OIDC 授权端点
await oidcAuthService.signinRedirect();
return {
userInfo,
userInfo: null,
};
}
async function authLogin(
params: any = {},
onSuccess?: () => Promise<void> | void,
) {
const returnTo = params.returnTo || router.currentRoute.value.fullPath;
redirectToAuth(returnTo);
return { userInfo: null };
/**
* OIDC回调
* Handle OIDC callback
*/
async function handleCallback() {
try {
const user = await oidcAuthService.handleSigninCallback();
oidcUser.value = user;
accessStore.setAccessToken(user.access_token);
let userInfo = getUserInfoFromToken(user);
const basicUserInfo = {
userId: userInfo.id,
username: userInfo.username,
realName: userInfo.realName,
avatar: '',
token: user.access_token,
};
userStore.setUserInfo(basicUserInfo);
try {
const detailedUserInfo = await getUserInfoApi();
const detailedBasicUserInfo = {
userId: detailedUserInfo.id,
username: detailedUserInfo.username,
realName: detailedUserInfo.realName,
avatar: '',
token: user.access_token,
};
userStore.setUserInfo(detailedBasicUserInfo);
userInfo = detailedUserInfo as any;
} catch (error) {
console.warn('[Auth] Failed to get detailed user info:', error);
}
// 根据角色设置权限码
const accessCodes = generateAccessCodes(userInfo?.roles || []);
accessStore.setAccessCodes(accessCodes);
// 重定向到原始请求的页面或首页
const redirect = route.query.redirect as string;
await router.push(redirect || preferences.app.defaultHomePath);
} catch (error) {
console.error('[Auth] Callback error:', error);
// 回调失败,重定向到登录页
await router.push(LOGIN_PATH);
throw error;
}
}
/**
*
* Check authentication status
*/
async function checkAuth(): Promise<boolean> {
try {
const authenticated = await oidcAuthService.isAuthenticated();
if (authenticated) {
const user = await oidcAuthService.getUser();
if (user) {
accessStore.setAccessToken(user.access_token);
const tokenUserInfo = getUserInfoFromToken(user);
const basicUserInfo: any = {
userId: tokenUserInfo.id,
username: tokenUserInfo.username,
realName: tokenUserInfo.realName,
avatar: '',
token: user.access_token,
desc: tokenUserInfo.realName,
homePath: tokenUserInfo.homePath || preferences.app.defaultHomePath,
};
userStore.setUserInfo(basicUserInfo);
const accessCodes = generateAccessCodes(tokenUserInfo?.roles || []);
accessStore.setAccessCodes(accessCodes);
}
return true;
}
return false;
} catch (error) {
console.error('[Auth] Check auth error:', error);
return false;
}
}
/**
* 退
*/
async function logout(redirect: boolean = true) {
const refreshToken = accessStore.refreshToken;
try {
await logoutApi();
} catch {
// 静默处理退出错误
}
try {
if (refreshToken) {
await logoutApi(refreshToken);
}
} catch {
await oidcAuthService.signoutRedirect();
} catch (error) {
console.error('[Auth] OIDC logout error:', error);
}
resetAllStores();
accessStore.setLoginExpired(false);
accessStore.setAccessToken(null);
accessStore.setRefreshToken(null);
accessStore.setExpiresAt(null);
window.location.href = getLogoutUrl();
if (redirect) {
await router.replace(LOGIN_PATH);
}
}
async function fetchUserInfo() {
const userInfo = userStore.getUserInfo;
return userInfo;
/**
*
*/
async function refreshToken(): Promise<void> {
try {
const user = await oidcAuthService.refreshToken();
oidcUser.value = user;
accessStore.setAccessToken(user.access_token);
const tokenUserInfo = getUserInfoFromToken(user);
const basicUserInfo: any = {
userId: tokenUserInfo.id,
username: tokenUserInfo.username,
realName: tokenUserInfo.realName,
avatar: '',
token: user.access_token,
desc: tokenUserInfo.realName,
homePath: tokenUserInfo.homePath || preferences.app.defaultHomePath,
};
userStore.setUserInfo(basicUserInfo);
} catch (error) {
console.error('[Auth] Refresh token error:', error);
// 刷新失败,需要重新登录
await logout(false);
throw error;
}
}
/**
* 访
* Generate access codes (simplified version)
*/
function generateAccessCodes(roles: string[]): string[] {
// 这里可以根据角色生成权限码
// 实际项目中应该从后端API获取
const codes: string[] = [];
if (roles.includes('Admin') || roles.includes('admin')) {
codes.push('user.manage', 'role.manage', 'tenant.manage', 'oauth.manage', 'log.view', 'system.config');
}
if (roles.length > 0) {
codes.push('user.view');
}
return codes;
}
function $reset() {
loginLoading.value = false;
oidcUser.value = null;
}
return {
$reset,
authLogin,
fetchUserInfo,
handleOAuthCallback,
checkAuth,
getAccessToken,
handleCallback,
loginLoading,
logout,
oidcUser,
refreshToken,
};
});

View File

@ -1,58 +1,74 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElNotification } from 'element-plus';
import { useAuthStore } from '#/store';
defineOptions({ name: 'OAuthCallback' });
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const loading = ref(true);
const error = ref<string | null>(null);
onMounted(async () => {
const { code, state, error, error_description } = route.query as {
code?: string;
state?: string;
error?: string;
error_description?: string;
};
try {
// store handleCallback OIDC
await authStore.handleCallback();
} catch (err: any) {
console.error('OAuth2 callback error:', err);
error.value = err.message || '认证失败';
if (error) {
console.error('OAuth2 error:', error, error_description);
await router.push({
path: '/login',
query: {
error,
error_description,
},
ElNotification({
title: '登录失败',
message: error.value || '认证失败',
type: 'error',
});
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;
//
setTimeout(() => {
router.push('/login');
}, 2000);
} finally {
loading.value = false;
}
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 v-if="loading">
<div class="mb-4">
<svg
class="inline-block h-8 w-8 animate-spin text-blue-500"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
<p class="text-gray-600">正在完成登录...</p>
</div>
<div v-else-if="error">
<p class="text-red-600">{{ error }}</p>
<p class="mt-2 text-sm text-gray-500">正在跳转到登录页...</p>
</div>
<p class="text-gray-600">正在完成登录...</p>
</div>
</div>
</template>

View File

@ -10,51 +10,72 @@ defineOptions({ name: 'Login' });
const authStore = useAuthStore();
const loading = ref(true);
const message = ref('正在跳转到认证中心...');
const errorMessage = ref('');
onMounted(async () => {
//
try {
await authStore.authLogin();
await authStore.authLogin({});
} catch (error: any) {
message.value = error.message || '登录跳转失败';
} finally {
setTimeout(() => {
loading.value = false;
}, 1000);
errorMessage.value = error.message || '登录跳转失败,请刷新页面重试';
loading.value = false;
}
});
async function handleRetry() {
loading.value = true;
errorMessage.value = '';
try {
await authStore.authLogin({});
} catch (error: any) {
errorMessage.value = error.message || '登录跳转失败,请刷新页面重试';
loading.value = false;
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-gray-100 p-4">
<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">使用 OAuth2 认证中心登录</p>
</template>
<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 class="flex min-h-screen items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
<el-card class="w-full max-w-md shadow-lg border-0">
<div class="py-12 text-center">
<!-- Logo -->
<div class="mb-8 flex justify-center">
<div class="text-4xl font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
Fengling
</div>
</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>
<!-- Loading State -->
<div v-if="loading" class="flex flex-col items-center justify-center">
<el-icon :size="48" class="text-blue-600 mb-4">
<Loading />
</el-icon>
<p class="text-gray-600 text-base">正在跳转到认证中心...</p>
</div>
<!-- Error State -->
<div v-else class="space-y-4">
<div class="flex items-center justify-center text-red-500">
<svg class="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v3.75m-9.303 3.376 3.376 3.376 9.303-9.303 0-12.456-3.376-3.376-9.303 3.376-12.456 9.303 0 12.456 3.376 3.376 9.303" />
</svg>
</div>
<p class="text-gray-700 text-base mb-4">{{ errorMessage }}</p>
<el-button
type="primary"
class="w-full"
size="large"
@click="handleRetry"
>
重新跳转
</el-button>
</div>
<!-- Footer -->
<div class="mt-8 pt-6 border-t border-gray-200">
<p class="text-gray-500 text-sm">
使用 OAuth2.0 授权码流程安全登录
</p>
</div>
</div>
</el-card>

View File

@ -0,0 +1,51 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { LOGIN_PATH } from '@vben/constants';
import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
import { ElNotification } from 'element-plus';
import { $t } from '#/locales';
import oidcAuthService from '#/services/oidc-auth.service';
defineOptions({ name: 'LogoutCallback' });
const router = useRouter();
const accessStore = useAccessStore();
onMounted(async () => {
try {
// OIDC
await oidcAuthService.removeUser();
} catch (error) {
console.error('[LogoutCallback] Remove user error:', error);
}
// store
resetAllStores();
accessStore.setLoginExpired(false);
accessStore.setAccessToken(null);
ElNotification({
title: $t('ui.layout.logout'),
message: '已安全退出',
type: 'success',
});
//
await router.replace(LOGIN_PATH);
});
</script>
<template>
<div class="flex h-screen items-center justify-center bg-gray-100 p-4">
<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

@ -1,49 +0,0 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElCard, ElResult, ElButton } from 'element-plus'
import { useAuthStore } from '#/store'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
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 authStore.handleOAuthCallback(code, state)
} 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>

View File

@ -22,6 +22,7 @@ const dialogVisible = ref(false);
const dialogTitle = ref('Create OAuth Client');
const formData = ref<Partial<FenglingApi.OAuth.CreateOAuthClientDto>>({});
const searchKeyword = ref('');
const editingId = ref<number | null>(null);
const pagination = ref({
page: 1,
@ -48,6 +49,7 @@ const loadClients = async () => {
const handleCreate = () => {
dialogTitle.value = 'Create OAuth Client';
editingId.value = null;
formData.value = {
redirectUris: [],
postLogoutRedirectUris: [],
@ -62,6 +64,7 @@ const handleCreate = () => {
const handleEdit = (row: FenglingApi.OAuth.OAuthClient) => {
dialogTitle.value = 'Edit OAuth Client';
editingId.value = row.id;
formData.value = {
displayName: row.displayName,
redirectUris: row.redirectUris,
@ -92,7 +95,11 @@ const handleSave = async () => {
await OAuthApi.createClient(formData.value as FenglingApi.OAuth.CreateOAuthClientDto);
ElMessage.success('OAuth client created successfully');
} else {
await OAuthApi.updateClient(tableData.value[0].id, formData.value as FenglingApi.OAuth.UpdateOAuthClientDto);
if (editingId.value === null) {
ElMessage.error('No client selected for editing');
return;
}
await OAuthApi.updateClient(editingId.value, formData.value as FenglingApi.OAuth.UpdateOAuthClientDto);
ElMessage.success('OAuth client updated successfully');
}
dialogVisible.value = false;

File diff suppressed because it is too large Load Diff

View File

@ -702,6 +702,9 @@ importers:
dayjs:
specifier: 'catalog:'
version: 1.11.19
oidc-client-ts:
specifier: ^3.4.1
version: 3.4.1
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))
@ -765,6 +768,9 @@ importers:
element-plus:
specifier: 'catalog:'
version: 2.13.1(vue@3.5.27(typescript@5.9.3))
oidc-client-ts:
specifier: ^3.4.1
version: 3.4.1
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))
@ -940,7 +946,7 @@ importers:
devDependencies:
'@nolebase/vitepress-plugin-git-changelog':
specifier: 'catalog:'
version: 2.18.2(vitepress@1.6.4(@algolia/client-search@5.47.0)(@types/node@25.0.10)(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(less@4.5.1)(nprogress@0.2.0)(postcss@8.5.6)(qrcode@1.5.4)(sass@1.97.3)(search-insights@2.17.3)(sortablejs@1.15.6)(terser@5.46.0)(typescript@5.9.3))(vue@3.5.27(typescript@5.9.3))
version: 2.18.2(vitepress@1.6.4(@algolia/client-search@5.47.0)(@types/node@25.0.10)(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(jwt-decode@4.0.0)(less@4.5.1)(nprogress@0.2.0)(postcss@8.5.6)(qrcode@1.5.4)(sass@1.97.3)(search-insights@2.17.3)(sortablejs@1.15.6)(terser@5.46.0)(typescript@5.9.3))(vue@3.5.27(typescript@5.9.3))
'@vben/vite-config':
specifier: workspace:*
version: link:../internal/vite-config
@ -949,7 +955,7 @@ importers:
version: 1.1.0(vite-plugin-pwa@1.2.0(vite@5.4.21(@types/node@25.0.10)(less@4.5.1)(sass@1.97.3)(terser@5.46.0))(workbox-build@7.4.0)(workbox-window@7.4.0))
vitepress:
specifier: 'catalog:'
version: 1.6.4(@algolia/client-search@5.47.0)(@types/node@25.0.10)(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(less@4.5.1)(nprogress@0.2.0)(postcss@8.5.6)(qrcode@1.5.4)(sass@1.97.3)(search-insights@2.17.3)(sortablejs@1.15.6)(terser@5.46.0)(typescript@5.9.3)
version: 1.6.4(@algolia/client-search@5.47.0)(@types/node@25.0.10)(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(jwt-decode@4.0.0)(less@4.5.1)(nprogress@0.2.0)(postcss@8.5.6)(qrcode@1.5.4)(sass@1.97.3)(search-insights@2.17.3)(sortablejs@1.15.6)(terser@5.46.0)(typescript@5.9.3)
vue:
specifier: ^3.5.27
version: 3.5.27(typescript@5.9.3)
@ -958,7 +964,7 @@ importers:
dependencies:
'@commitlint/cli':
specifier: 'catalog:'
version: 19.8.1(@types/node@24.10.9)(typescript@5.9.3)
version: 19.8.1(@types/node@25.0.10)(typescript@5.9.3)
'@commitlint/config-conventional':
specifier: 'catalog:'
version: 19.8.1
@ -1614,7 +1620,7 @@ importers:
version: 14.1.0(vue@3.5.27(typescript@5.9.3))
'@vueuse/integrations':
specifier: 'catalog:'
version: 14.1.0(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(focus-trap@7.8.0)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(vue@3.5.27(typescript@5.9.3))
version: 14.1.0(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(focus-trap@7.8.0)(jwt-decode@4.0.0)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(vue@3.5.27(typescript@5.9.3))
json-bigint:
specifier: 'catalog:'
version: 1.0.0
@ -7594,6 +7600,10 @@ packages:
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
jwt-decode@4.0.0:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@ -8229,6 +8239,10 @@ packages:
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
oidc-client-ts@3.4.1:
resolution: {integrity: sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==}
engines: {node: '>=18'}
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
@ -11926,11 +11940,11 @@ snapshots:
'@cloudflare/kv-asset-handler@0.4.2': {}
'@commitlint/cli@19.8.1(@types/node@24.10.9)(typescript@5.9.3)':
'@commitlint/cli@19.8.1(@types/node@25.0.10)(typescript@5.9.3)':
dependencies:
'@commitlint/format': 19.8.1
'@commitlint/lint': 19.8.1
'@commitlint/load': 19.8.1(@types/node@24.10.9)(typescript@5.9.3)
'@commitlint/load': 19.8.1(@types/node@25.0.10)(typescript@5.9.3)
'@commitlint/read': 19.8.1
'@commitlint/types': 19.8.1
tinyexec: 1.0.2
@ -11977,7 +11991,7 @@ snapshots:
'@commitlint/rules': 19.8.1
'@commitlint/types': 19.8.1
'@commitlint/load@19.8.1(@types/node@24.10.9)(typescript@5.9.3)':
'@commitlint/load@19.8.1(@types/node@25.0.10)(typescript@5.9.3)':
dependencies:
'@commitlint/config-validator': 19.8.1
'@commitlint/execute-rule': 19.8.1
@ -11985,7 +11999,7 @@ snapshots:
'@commitlint/types': 19.8.1
chalk: 5.6.2
cosmiconfig: 9.0.0(typescript@5.9.3)
cosmiconfig-typescript-loader: 6.2.0(@types/node@24.10.9)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3)
cosmiconfig-typescript-loader: 6.2.0(@types/node@25.0.10)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3)
lodash.isplainobject: 4.0.6
lodash.merge: 4.6.2
lodash.uniq: 4.5.0
@ -13086,17 +13100,17 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@nolebase/ui@2.18.2(vitepress@1.6.4(@algolia/client-search@5.47.0)(@types/node@25.0.10)(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(less@4.5.1)(nprogress@0.2.0)(postcss@8.5.6)(qrcode@1.5.4)(sass@1.97.3)(search-insights@2.17.3)(sortablejs@1.15.6)(terser@5.46.0)(typescript@5.9.3))(vue@3.5.27(typescript@5.9.3))':
'@nolebase/ui@2.18.2(vitepress@1.6.4(@algolia/client-search@5.47.0)(@types/node@25.0.10)(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(jwt-decode@4.0.0)(less@4.5.1)(nprogress@0.2.0)(postcss@8.5.6)(qrcode@1.5.4)(sass@1.97.3)(search-insights@2.17.3)(sortablejs@1.15.6)(terser@5.46.0)(typescript@5.9.3))(vue@3.5.27(typescript@5.9.3))':
dependencies:
'@iconify-json/octicon': 1.2.20
less: 4.5.1
vitepress: 1.6.4(@algolia/client-search@5.47.0)(@types/node@25.0.10)(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(less@4.5.1)(nprogress@0.2.0)(postcss@8.5.6)(qrcode@1.5.4)(sass@1.97.3)(search-insights@2.17.3)(sortablejs@1.15.6)(terser@5.46.0)(typescript@5.9.3)
vitepress: 1.6.4(@algolia/client-search@5.47.0)(@types/node@25.0.10)(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(jwt-decode@4.0.0)(less@4.5.1)(nprogress@0.2.0)(postcss@8.5.6)(qrcode@1.5.4)(sass@1.97.3)(search-insights@2.17.3)(sortablejs@1.15.6)(terser@5.46.0)(typescript@5.9.3)
vue: 3.5.27(typescript@5.9.3)
'@nolebase/vitepress-plugin-git-changelog@2.18.2(vitepress@1.6.4(@algolia/client-search@5.47.0)(@types/node@25.0.10)(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(less@4.5.1)(nprogress@0.2.0)(postcss@8.5.6)(qrcode@1.5.4)(sass@1.97.3)(search-insights@2.17.3)(sortablejs@1.15.6)(terser@5.46.0)(typescript@5.9.3))(vue@3.5.27(typescript@5.9.3))':
'@nolebase/vitepress-plugin-git-changelog@2.18.2(vitepress@1.6.4(@algolia/client-search@5.47.0)(@types/node@25.0.10)(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(jwt-decode@4.0.0)(less@4.5.1)(nprogress@0.2.0)(postcss@8.5.6)(qrcode@1.5.4)(sass@1.97.3)(search-insights@2.17.3)(sortablejs@1.15.6)(terser@5.46.0)(typescript@5.9.3))(vue@3.5.27(typescript@5.9.3))':
dependencies:
'@iconify-json/octicon': 1.2.20
'@nolebase/ui': 2.18.2(vitepress@1.6.4(@algolia/client-search@5.47.0)(@types/node@25.0.10)(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(less@4.5.1)(nprogress@0.2.0)(postcss@8.5.6)(qrcode@1.5.4)(sass@1.97.3)(search-insights@2.17.3)(sortablejs@1.15.6)(terser@5.46.0)(typescript@5.9.3))(vue@3.5.27(typescript@5.9.3))
'@nolebase/ui': 2.18.2(vitepress@1.6.4(@algolia/client-search@5.47.0)(@types/node@25.0.10)(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(jwt-decode@4.0.0)(less@4.5.1)(nprogress@0.2.0)(postcss@8.5.6)(qrcode@1.5.4)(sass@1.97.3)(search-insights@2.17.3)(sortablejs@1.15.6)(terser@5.46.0)(typescript@5.9.3))(vue@3.5.27(typescript@5.9.3))
colorette: 2.0.20
date-fns: 4.1.0
defu: 6.1.4
@ -13106,7 +13120,7 @@ snapshots:
gray-matter: 4.0.3
less: 4.5.1
uncrypto: 0.1.3
vitepress: 1.6.4(@algolia/client-search@5.47.0)(@types/node@25.0.10)(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(less@4.5.1)(nprogress@0.2.0)(postcss@8.5.6)(qrcode@1.5.4)(sass@1.97.3)(search-insights@2.17.3)(sortablejs@1.15.6)(terser@5.46.0)(typescript@5.9.3)
vitepress: 1.6.4(@algolia/client-search@5.47.0)(@types/node@25.0.10)(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(jwt-decode@4.0.0)(less@4.5.1)(nprogress@0.2.0)(postcss@8.5.6)(qrcode@1.5.4)(sass@1.97.3)(search-insights@2.17.3)(sortablejs@1.15.6)(terser@5.46.0)(typescript@5.9.3)
transitivePeerDependencies:
- vue
@ -14318,7 +14332,7 @@ snapshots:
'@vueuse/shared': 14.1.0(vue@3.5.27(typescript@5.9.3))
vue: 3.5.27(typescript@5.9.3)
'@vueuse/integrations@12.8.2(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(focus-trap@7.8.0)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(typescript@5.9.3)':
'@vueuse/integrations@12.8.2(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(focus-trap@7.8.0)(jwt-decode@4.0.0)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(typescript@5.9.3)':
dependencies:
'@vueuse/core': 12.8.2(typescript@5.9.3)
'@vueuse/shared': 12.8.2(typescript@5.9.3)
@ -14328,13 +14342,14 @@ snapshots:
axios: 1.13.4
change-case: 5.4.4
focus-trap: 7.8.0
jwt-decode: 4.0.0
nprogress: 0.2.0
qrcode: 1.5.4
sortablejs: 1.15.6
transitivePeerDependencies:
- typescript
'@vueuse/integrations@14.1.0(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(focus-trap@7.8.0)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(vue@3.5.27(typescript@5.9.3))':
'@vueuse/integrations@14.1.0(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(focus-trap@7.8.0)(jwt-decode@4.0.0)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(vue@3.5.27(typescript@5.9.3))':
dependencies:
'@vueuse/core': 14.1.0(vue@3.5.27(typescript@5.9.3))
'@vueuse/shared': 14.1.0(vue@3.5.27(typescript@5.9.3))
@ -14344,6 +14359,7 @@ snapshots:
axios: 1.13.4
change-case: 5.4.4
focus-trap: 7.8.0
jwt-decode: 4.0.0
nprogress: 0.2.0
qrcode: 1.5.4
sortablejs: 1.15.6
@ -15125,9 +15141,9 @@ snapshots:
core-util-is@1.0.3: {}
cosmiconfig-typescript-loader@6.2.0(@types/node@24.10.9)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3):
cosmiconfig-typescript-loader@6.2.0(@types/node@25.0.10)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3):
dependencies:
'@types/node': 24.10.9
'@types/node': 25.0.10
cosmiconfig: 9.0.0(typescript@5.9.3)
jiti: 2.6.1
typescript: 5.9.3
@ -17129,6 +17145,8 @@ snapshots:
jwa: 2.0.1
safe-buffer: 5.2.1
jwt-decode@4.0.0: {}
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@ -17823,6 +17841,10 @@ snapshots:
ohash@2.0.11: {}
oidc-client-ts@3.4.1:
dependencies:
jwt-decode: 4.0.0
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
@ -20263,7 +20285,7 @@ snapshots:
optionalDependencies:
vite: 5.4.21(@types/node@25.0.10)(less@4.5.1)(sass@1.97.3)(terser@5.46.0)
vitepress@1.6.4(@algolia/client-search@5.47.0)(@types/node@25.0.10)(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(less@4.5.1)(nprogress@0.2.0)(postcss@8.5.6)(qrcode@1.5.4)(sass@1.97.3)(search-insights@2.17.3)(sortablejs@1.15.6)(terser@5.46.0)(typescript@5.9.3):
vitepress@1.6.4(@algolia/client-search@5.47.0)(@types/node@25.0.10)(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(jwt-decode@4.0.0)(less@4.5.1)(nprogress@0.2.0)(postcss@8.5.6)(qrcode@1.5.4)(sass@1.97.3)(search-insights@2.17.3)(sortablejs@1.15.6)(terser@5.46.0)(typescript@5.9.3):
dependencies:
'@docsearch/css': 3.8.2
'@docsearch/js': 3.8.2(@algolia/client-search@5.47.0)(search-insights@2.17.3)
@ -20276,7 +20298,7 @@ snapshots:
'@vue/devtools-api': 7.7.9
'@vue/shared': 3.5.27
'@vueuse/core': 12.8.2(typescript@5.9.3)
'@vueuse/integrations': 12.8.2(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(focus-trap@7.8.0)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(typescript@5.9.3)
'@vueuse/integrations': 12.8.2(async-validator@4.2.5)(axios@1.13.4)(change-case@5.4.4)(focus-trap@7.8.0)(jwt-decode@4.0.0)(nprogress@0.2.0)(qrcode@1.5.4)(sortablejs@1.15.6)(typescript@5.9.3)
focus-trap: 7.8.0
mark.js: 8.11.1
minisearch: 7.2.0