feat: 添加OAuth2认证配置和实现
添加OAuth2认证相关配置文件和服务实现,包括环境变量配置、PKCE流程支持、token管理等功能。主要变更: - 新增OAuth2配置文件 - 实现OAuth2服务层 - 更新请求拦截器支持token自动刷新 - 修改认证API和store以支持OAuth2流程
This commit is contained in:
parent
e32b8b3080
commit
fa93d71725
172
OIDC_INTEGRATION.md
Normal file
172
OIDC_INTEGRATION.md
Normal 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. 查看浏览器控制台的详细错误信息
|
||||
@ -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
|
||||
|
||||
@ -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:"
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
export * from './auth';
|
||||
export * from './menu';
|
||||
export * from './oauth';
|
||||
export * from './user';
|
||||
|
||||
@ -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()}`;
|
||||
}
|
||||
@ -5,6 +5,6 @@ import { requestClient } from '#/api/request';
|
||||
/**
|
||||
* 获取用户信息
|
||||
*/
|
||||
export async function getUserInfoApi() {
|
||||
export async function getUserInfoOldApi() {
|
||||
return requestClient.get<UserInfo>('/user/info');
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
@ -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',
|
||||
},
|
||||
};
|
||||
25
apps/web-ele/src/config/oidc.ts
Normal file
25
apps/web-ele/src/config/oidc.ts
Normal 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 };
|
||||
@ -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 {
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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()
|
||||
201
apps/web-ele/src/services/oidc-auth.service.ts
Normal file
201
apps/web-ele/src/services/oidc-auth.service.ts
Normal 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;
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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;
|
||||
|
||||
1232
docs/plans/2026-02-06-oauth2-implementation-plan.md
Normal file
1232
docs/plans/2026-02-06-oauth2-implementation-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user