添加OAuth2认证相关配置文件和服务实现,包括环境变量配置、PKCE流程支持、token管理等功能。主要变更: - 新增OAuth2配置文件 - 实现OAuth2服务层 - 更新请求拦截器支持token自动刷新 - 修改认证API和store以支持OAuth2流程
32 KiB
OAuth2 Integration Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Integrate Vben Admin 5.0 with Fengling.AuthService using OAuth2 Authorization Code flow with PKCE for centralized authentication.
Architecture: OAuth2 Authorization Code flow with PKCE. Vben acts as OAuth2 client redirecting to auth service for authentication, exchanging authorization code for JWT tokens, and storing tokens locally with automatic refresh on expiry.
Tech Stack: Vue 3, TypeScript, OAuth2 with PKCE, JWT tokens, OpenIddict (auth service)
Task 1: Create OAuth2 Configuration
Files:
- Create:
apps/web-antd/src/config/oauth.ts
Step 1: Create OAuth configuration file
export const oauthConfig = {
clientId: import.meta.env.VITE_OAUTH_CLIENT_ID || 'vben-web',
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 roles',
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',
},
};
Step 2: Commit
cd apps/web-antd
git add src/config/oauth.ts
git commit -m "feat: add OAuth2 configuration"
Task 2: Create OAuth2 Service
Files:
- Create:
apps/web-antd/src/api/core/oauth.ts
Step 1: Create OAuth service with PKCE functions
import { oauthConfig } from '#/config/oauth';
import { baseRequestClient } from '#/api/request';
export interface TokenResponse {
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
scope: string;
}
/**
* Generate random string for code verifier
*/
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;
}
/**
* Generate code verifier for PKCE
*/
export function generateCodeVerifier(): string {
return generateRandomString(128);
}
/**
* Generate code challenge from verifier using SHA-256
*/
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(/=+$/, '');
}
/**
* Generate random state parameter
*/
export function generateState(): string {
return generateRandomString(32);
}
/**
* Store OAuth state in sessionStorage
*/
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);
}
}
/**
* Retrieve and clear OAuth state from sessionStorage
*/
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 };
}
/**
* Redirect user to OAuth2 authorization endpoint
*/
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()}`;
});
}
/**
* Exchange authorization code for access token
*/
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();
}
/**
* Refresh access token using refresh token
*/
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();
}
/**
* Revoke refresh token
*/
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}`);
}
}
/**
* Get logout URL for single logout
*/
export function getLogoutUrl(): string {
const params = new URLSearchParams({
post_logout_redirect_uri: oauthConfig.redirectUri,
});
return `${oauthConfig.authUrl}${oauthConfig.endpoints.logout}?${params.toString()}`;
}
Step 2: Commit
cd apps/web-antd
git add src/api/core/oauth.ts
git commit -m "feat: add OAuth2 service with PKCE support"
Task 3: Update Environment Configuration
Files:
- Modify:
apps/web-antd/.env.development - Modify:
apps/web-antd/.env.production
Step 1: Add OAuth2 environment variables to .env.development
Add these lines to the end of apps/web-antd/.env.development:
# OAuth2 Configuration
VITE_OAUTH_CLIENT_ID=vben-web
VITE_OAUTH_REDIRECT_URI=http://localhost:5173/auth/callback
VITE_AUTH_SERVICE_URL=http://localhost:5000
VITE_OAUTH_SCOPE=api offline_access openid profile email roles
Step 2: Add OAuth2 environment variables to .env.production
Add these lines to the end of apps/web-antd/.env.production:
# OAuth2 Configuration
VITE_OAUTH_CLIENT_ID=vben-web
VITE_OAUTH_REDIRECT_URI=https://your-app.com/auth/callback
VITE_AUTH_SERVICE_URL=https://auth.your-domain.com
VITE_OAUTH_SCOPE=api offline_access openid profile email roles
Step 3: Commit
cd apps/web-antd
git add .env.development .env.production
git commit -m "feat: add OAuth2 environment configuration"
Task 4: Update API Request Interceptor
Files:
- Modify:
apps/web-antd/src/api/request.ts
Step 1: Import OAuth utilities and update token handling
Replace the entire content of apps/web-antd/src/api/request.ts with:
import { oauthConfig } from '#/config/oauth';
import type { RequestClientOptions } from '@vben/request';
import { useAppConfig } from '@vben/hooks';
import { preferences } from '@vben/preferences';
import {
authenticateResponseInterceptor,
defaultResponseInterceptor,
errorMessageResponseInterceptor,
RequestClient,
} from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { message } from 'ant-design-vue';
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) {
const client = new RequestClient({
...options,
baseURL,
});
/**
* Check if access token is expired or will expire soon
*/
function isTokenExpired(expiresAt: number | null): boolean {
if (!expiresAt) return true;
// Refresh 5 minutes before expiry
return Date.now() >= expiresAt - 5 * 60 * 1000;
}
/**
* Re-authenticate logic
*/
async function doReAuthenticate() {
console.warn('Access token or refresh token is invalid or expired.');
const accessStore = useAccessStore();
const authStore = useAuthStore();
accessStore.setAccessToken(null);
if (
preferences.app.loginExpiredMode === 'modal' &&
accessStore.isAccessChecked
) {
accessStore.setLoginExpired(true);
} else {
await authStore.logout();
}
}
/**
* Refresh token logic
*/
async function doRefreshToken() {
const accessStore = useAccessStore();
const refreshToken = accessStore.refreshToken;
if (!refreshToken) {
await doReAuthenticate();
throw new Error('No refresh token available');
}
try {
const tokenResponse = await refreshAccessToken(refreshToken);
accessStore.setAccessToken(tokenResponse.access_token);
accessStore.setRefreshToken(tokenResponse.refresh_token);
accessStore.setExpiresAt(Date.now() + tokenResponse.expires_in * 1000);
return tokenResponse.access_token;
} catch (error) {
console.error('Failed to refresh token:', error);
await doReAuthenticate();
throw error;
}
}
function formatToken(token: null | string) {
return token ? `Bearer ${token}` : null;
}
// Request interceptor: Add authorization header and handle token refresh
client.addRequestInterceptor({
fulfilled: async (config) => {
const accessStore = useAccessStore();
// Check if token needs refresh
if (accessStore.accessToken && accessStore.expiresAt) {
if (isTokenExpired(accessStore.expiresAt)) {
try {
const newToken = await doRefreshToken();
config.headers.Authorization = formatToken(newToken);
} catch {
config.headers.Authorization = formatToken(null);
}
} else {
config.headers.Authorization = formatToken(accessStore.accessToken);
}
}
config.headers['Accept-Language'] = preferences.app.locale;
return config;
},
});
// Response interceptor: Handle OAuth2 token response format
client.addResponseInterceptor(
defaultResponseInterceptor({
codeField: 'code',
dataField: 'data',
successCode: 0,
}),
);
// Token expiry and 401 error handling
client.addResponseInterceptor(
authenticateResponseInterceptor({
client,
doReAuthenticate,
doRefreshToken,
enableRefreshToken: preferences.app.enableRefreshToken,
formatToken,
}),
);
// Generic error handling
client.addResponseInterceptor(
errorMessageResponseInterceptor((msg: string, error) => {
const responseData = error?.response?.data ?? {};
const errorMessage = responseData?.error ?? responseData?.message ?? '';
message.error(errorMessage || msg);
}),
);
return client;
}
export const requestClient = createRequestClient(apiURL, {
responseReturn: 'data',
});
export const baseRequestClient = new RequestClient({ baseURL: apiURL });
Step 2: Commit
cd apps/web-antd
git add src/api/request.ts
git commit -m "feat: update request interceptor for OAuth2 token management"
Task 5: Update Auth API
Files:
- Modify:
apps/web-antd/src/api/core/auth.ts
Step 1: Update auth API for OAuth2
Replace the entire content of apps/web-antd/src/api/core/auth.ts with:
import { baseRequestClient, requestClient } from '#/api/request';
import { oauthConfig } from '#/config/oauth';
export namespace AuthApi {
/** Token response from OAuth2 */
export interface TokenResponse {
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
scope: string;
}
/** User info from JWT claims */
export interface UserInfo {
sub: string;
name: string;
email: string;
role: string[];
tenant_id: string;
}
}
/**
* Parse JWT token to extract claims
*/
export function parseJwt(token: string): AuthApi.UserInfo | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const payload = JSON.parse(atob(parts[1]));
return {
sub: payload.sub,
name: payload.name || payload['unique_name'],
email: payload.email,
role: Array.isArray(payload.role) ? payload.role : [payload.role].filter(Boolean),
tenant_id: payload.tenant_id || payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'],
};
} catch {
return null;
}
}
/**
* Revoke refresh token (logout)
*/
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);
// Don't throw - allow logout to continue even if revocation fails
}
}
/**
* Check if access token is still valid on server
*/
export async function validateTokenApi() {
return requestClient.get<void>('/health');
}
Step 2: Commit
cd apps/web-antd
git add src/api/core/auth.ts
git commit -m "feat: update auth API for OAuth2 token handling"
Task 6: Update Auth Store
Files:
- Modify:
apps/web-antd/src/store/auth.ts
Step 1: Update auth store for OAuth2 flow
Replace the entire content of apps/web-antd/src/store/auth.ts with:
import type { UserInfo } from '@vben/types';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { LOGIN_PATH } from '@vben/constants';
import { preferences } from '@vben/preferences';
import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
import { notification } from 'ant-design-vue';
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;
}
export const useAuthStore = defineStore('auth', () => {
const accessStore = useAccessStore();
const userStore = useUserStore();
const router = useRouter();
const loginLoading = ref(false);
/**
* Handle OAuth2 callback
*/
async function handleOAuthCallback(oauthState: OAuth2State) {
let userInfo: null | UserInfo = null;
try {
loginLoading.value = true;
// Exchange code for tokens
const tokenResponse = await exchangeCodeForToken(oauthState.code, oauthState.state);
// Store tokens
accessStore.setAccessToken(tokenResponse.access_token);
accessStore.setRefreshToken(tokenResponse.refresh_token);
accessStore.setExpiresAt(Date.now() + tokenResponse.expires_in * 1000);
// Parse user info from JWT
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 || []);
}
// Redirect to home or return URL
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) {
notification.success({
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`,
duration: 3,
message: $t('authentication.loginSuccess'),
});
}
} catch (error) {
console.error('OAuth2 callback error:', error);
notification.error({
description: error instanceof Error ? error.message : 'Authentication failed',
duration: 5,
message: $t('authentication.loginFailed'),
});
await router.push(LOGIN_PATH);
} finally {
loginLoading.value = false;
}
return {
userInfo,
};
}
/**
* Initiate OAuth2 login flow
*/
async function authLogin(
params: any = {},
onSuccess?: () => Promise<void> | void,
) {
const returnTo = params.returnTo || router.currentRoute.value.fullPath;
redirectToAuth(returnTo);
return { userInfo: null };
}
/**
* Logout with single logout (SLO)
*/
async function logout(redirect: boolean = true) {
const refreshToken = accessStore.refreshToken;
try {
// Revoke refresh token
if (refreshToken) {
await logoutApi(refreshToken);
}
} catch {
// Don't fail logout if revocation fails
}
// Clear local state
resetAllStores();
accessStore.setLoginExpired(false);
accessStore.setAccessToken(null);
accessStore.setRefreshToken(null);
accessStore.setExpiresAt(null);
// Redirect to auth service logout (SLO)
window.location.href = getLogoutUrl();
}
async function fetchUserInfo() {
// User info is now extracted from JWT in handleOAuthCallback
// This can fetch additional user details if needed
const userInfo = userStore.getUserInfo;
return userInfo;
}
function $reset() {
loginLoading.value = false;
}
return {
$reset,
authLogin,
fetchUserInfo,
handleOAuthCallback,
loginLoading,
logout,
};
});
Step 2: Commit
cd apps/web-antd
git add src/store/auth.ts
git commit -m "feat: update auth store for OAuth2 flow"
Task 7: Update Access Store for Refresh Token
Files:
- Locate and modify access store file (likely in
@vben/storespackage)
Step 1: Check access store location
find /Users/movingsam/Fengling.Refactory.Buiding/src/Fengling.Console.Web.Vben -name "*access*" -type f | grep -i store
Step 2: Update access store to support refresh token
Since the access store is likely in the @vben/stores workspace package, we need to add refresh token support. Look for the access store definition and add:
// Add these new state variables
const refreshToken = ref<string | null>(null);
const expiresAt = ref<number | null>(null);
// Add these new functions
function setRefreshToken(token: string | null) {
refreshToken.value = token;
if (token) {
localStorage.setItem('oauth_refresh_token', token);
} else {
localStorage.removeItem('oauth_refresh_token');
}
}
function setExpiresAt(timestamp: number | null) {
expiresAt.value = timestamp;
if (timestamp) {
localStorage.setItem('oauth_expires_at', timestamp.toString());
} else {
localStorage.removeItem('oauth_expires_at');
}
}
// Initialize from localStorage on store creation
setRefreshToken(localStorage.getItem('oauth_refresh_token'));
setExpiresAt(localStorage.getItem('oauth_expires_at') ? parseInt(localStorage.getItem('oauth_expires_at')!) : null);
// Add to return statement
return {
// ...existing returns
refreshToken,
expiresAt,
setRefreshToken,
setExpiresAt,
};
Step 3: Commit
cd apps/web-antd
git add <access-store-file>
git commit -m "feat: add refresh token support to access store"
Task 8: Create OAuth Callback Page
Files:
- Create:
apps/web-antd/src/views/_core/authentication/callback.vue
Step 1: Create OAuth2 callback component
<script lang="ts" setup>
import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '#/store';
defineOptions({ name: 'OAuthCallback' });
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
onMounted(async () => {
const { code, state, error, error_description } = route.query as {
code?: string;
state?: string;
error?: string;
error_description?: string;
};
// Handle OAuth2 errors
if (error) {
console.error('OAuth2 error:', error, error_description);
await router.push({
path: '/login',
query: {
error,
error_description,
},
});
return;
}
// Handle successful authorization
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;
}
// Process the callback
await authStore.handleOAuthCallback({ code, state });
});
</script>
<template>
<div class="flex h-screen items-center justify-center">
<div class="text-center">
<div class="mb-4">
<div class="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent motion-reduce:animate-[spin_1.5s_linear_infinite]"></div>
</div>
<p class="text-gray-600">正在完成登录...</p>
</div>
</div>
</template>
Step 2: Commit
cd apps/web-antd
git add src/views/_core/authentication/callback.vue
git commit -m "feat: add OAuth2 callback page"
Task 9: Update Login Page
Files:
- Modify:
apps/web-antd/src/views/_core/authentication/login.vue
Step 1: Update login page to redirect to OAuth2
Replace the entire content of apps/web-antd/src/views/_core/authentication/login.vue with:
<script lang="ts" setup>
import { useAuthStore } from '#/store';
defineOptions({ name: 'Login' });
const authStore = useAuthStore();
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div class="w-full max-w-md space-y-8">
<div class="text-center">
<h2 class="mt-6 text-3xl font-bold text-gray-900">
登录系统
</h2>
<p class="mt-2 text-sm text-gray-600">
使用 Fengling 认证中心账号登录
</p>
</div>
<div class="mt-8 space-y-4">
<button
type="button"
class="group relative flex w-full justify-center rounded-md bg-blue-600 px-3 py-3 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="authStore.loginLoading"
@click="authStore.authLogin()"
>
<svg v-if="authStore.loginLoading" class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<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"></path>
</svg>
<svg v-else class="-ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
</svg>
{{ authStore.loginLoading ? '正在跳转...' : '使用 Fengling 认证中心登录' }}
</button>
<div class="mt-4 text-center text-sm text-gray-500">
<p>默认账号: admin / Admin@123</p>
<p>测试账号: testuser / Test@123</p>
</div>
</div>
<!-- OAuth Error Display -->
<div v-if="$route.query.error" class="mt-6 rounded-md bg-red-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">登录失败</h3>
<div class="mt-2 text-sm text-red-700">
<p>{{ $route.query.error_description || '未知错误' }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
Step 2: Commit
cd apps/web-antd
git add src/views/_core/authentication/login.vue
git commit -m "feat: update login page for OAuth2 flow"
Task 10: Add OAuth Callback Route
Files:
- Modify:
apps/web-antd/src/router/routes/core.ts
Step 1: Add OAuth callback route to core routes
Find the route definition file and add this route:
{
path: '/auth/callback',
name: 'OAuthCallback',
component: () => import('#/views/_core/authentication/callback.vue'),
meta: {
title: 'OAuth Callback',
hideInMenu: true,
ignoreAuth: true,
},
},
Step 2: Commit
cd apps/web-antd
git add src/router/routes/core.ts
git commit -m "feat: add OAuth2 callback route"
Task 11: Register OAuth2 Client in Auth Service
Files:
- Modify:
src/Fengling.AuthService/Data/SeedData.cs
Step 1: Add Vben OAuth2 client to seed data
Add this OAuth application to the SeedData.cs file:
var vbenClient = new OAuthApplication
{
ClientId = "vben-web",
ClientSecret = null, // Public client with PKCE
DisplayName = "Vben Admin",
RedirectUris = new[] { "http://localhost:5173/auth/callback" },
PostLogoutRedirectUris = new[] { "http://localhost:5173" },
Permissions = new[]
{
OpenIddictConstants.Permissions.Endpoints.Authorization,
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.Endpoints.Logout,
OpenIddictConstants.Permissions.Endpoints.Revocation,
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.ResponseTypes.Code,
OpenIddictConstants.Permissions.Scopes.Profile,
OpenIddictConstants.Permissions.Scopes.Email,
OpenIddictConstants.Permissions.Scopes.Roles,
OpenIddictConstants.Permissions.Prefixes.Scope + "api"
}
};
await context.OAuthApplications.AddAsync(vbenClient);
await context.SaveChangesAsync();
Step 2: Commit
cd /Users/movingsam/Fengling.Refactory.Buiding/src/Fengling.AuthService
git add Data/SeedData.cs
git commit -m "feat: register Vben Admin as OAuth2 client"
Task 12: Add Database Migration
Files:
- Generate new migration for auth service
Step 1: Generate migration
cd /Users/movingsam/Fengling.Refactory.Buiding/src/Fengling.AuthService
dotnet ef migrations add AddVbenOAuthClient
Step 2: Apply migration
dotnet ef database update
Step 3: Commit
git add Migrations/
git commit -m "feat: add migration for Vben OAuth2 client"
Task 13: Test Implementation
Files:
- No files to modify
Step 1: Start auth service
cd /Users/movingsam/Fengling.Refactory.Buiding/src/Fengling.AuthService
dotnet run
Verify auth service is running on http://localhost:5000
Step 2: Start Vben app in development mode
cd /Users/movingsam/Fengling.Refactory.Buiding/src/Fengling.Console.Web.Vben
pnpm dev
Verify Vben app is running on http://localhost:5173
Step 3: Test login flow
- Navigate to http://localhost:5173
- Click "使用 Fengling 认证中心登录"
- Should redirect to auth service login page
- Login with admin / Admin@123
- Should redirect back to Vben app
- Verify user is logged in and can access dashboard
Step 4: Test token refresh
- Log in successfully
- Wait for token to near expiry or manually clear localStorage
- Make an API request
- Verify automatic token refresh works
Step 5: Test logout
- Log in successfully
- Click logout
- Verify redirect to auth service logout
- Verify tokens are revoked
- Verify user is logged out
Step 6: Test error handling
- Cancel login on auth service page
- Verify error is displayed on Vben login page
- Try with invalid credentials
- Verify error is displayed correctly
Step 7: Document any issues
Note any issues found during testing for fixes.
Task 14: Update API Exports
Files:
- Modify:
apps/web-antd/src/api/core/index.ts
Step 1: Export OAuth2 functions
Add to the exports:
export * from './oauth';
Step 2: Commit
cd apps/web-antd
git add src/api/core/index.ts
git commit -m "feat: export OAuth2 service functions"
Task 15: Final Testing and Verification
Files:
- No files to modify
Step 1: Run type checking
cd apps/web-antd
pnpm typecheck
Fix any type errors.
Step 2: Run linter
cd apps/web-antd
pnpm lint
Fix any linting errors.
Step 3: Build production bundle
cd apps/web-antd
pnpm build
Verify build succeeds.
Step 4: Test production build
pnpm preview
Test the flow with production build.
Step 5: Final commit
cd /Users/movingsam/Fengling.Refactory.Buiding/src/Fengling.Console.Web.Vben
git add .
git commit -m "feat: complete OAuth2 integration with Fengling.AuthService
Implemented OAuth2 Authorization Code flow with PKCE:
- OAuth2 client configuration
- PKCE code verifier/challenge generation
- Token exchange and automatic refresh
- Single logout (SLO) support
- JWT claim parsing for user info
- Complete error handling
Features:
- Secure authentication with PKCE
- Automatic token refresh before expiry
- JWT token management with localStorage
- User info extracted from JWT claims
- Multi-tenant and RBAC support
- Full error handling and validation"
Summary
This implementation plan transforms Vben Admin from a basic authentication system to a full OAuth2 client integrated with Fengling.AuthService. The plan covers:
✅ OAuth2 client configuration ✅ PKCE security implementation ✅ Authorization flow handling ✅ Token management and automatic refresh ✅ Single logout (SLO) support ✅ JWT claim parsing ✅ Error handling throughout ✅ Full frontend and backend changes ✅ Testing and verification steps
The implementation follows OAuth2 best practices with PKCE for public clients, automatic token refresh, and secure token storage.