fengling-console-web/docs/plans/2026-02-06-oauth2-implementation-plan.md
Sam fa93d71725 feat: 添加OAuth2认证配置和实现
添加OAuth2认证相关配置文件和服务实现,包括环境变量配置、PKCE流程支持、token管理等功能。主要变更:
- 新增OAuth2配置文件
- 实现OAuth2服务层
- 更新请求拦截器支持token自动刷新
- 修改认证API和store以支持OAuth2流程
2026-02-07 17:47:11 +08:00

1233 lines
32 KiB
Markdown

# 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**
```typescript
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**
```bash
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**
```typescript
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**
```bash
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`:
```bash
# 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`:
```bash
# 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**
```bash
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:
```typescript
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**
```bash
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:
```typescript
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**
```bash
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:
```typescript
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**
```bash
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/stores` package)
**Step 1: Check access store location**
```bash
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:
```typescript
// 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**
```bash
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**
```vue
<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**
```bash
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:
```vue
<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**
```bash
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:
```typescript
{
path: '/auth/callback',
name: 'OAuthCallback',
component: () => import('#/views/_core/authentication/callback.vue'),
meta: {
title: 'OAuth Callback',
hideInMenu: true,
ignoreAuth: true,
},
},
```
**Step 2: Commit**
```bash
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:
```csharp
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**
```bash
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**
```bash
cd /Users/movingsam/Fengling.Refactory.Buiding/src/Fengling.AuthService
dotnet ef migrations add AddVbenOAuthClient
```
**Step 2: Apply migration**
```bash
dotnet ef database update
```
**Step 3: Commit**
```bash
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**
```bash
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**
```bash
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**
1. Navigate to http://localhost:5173
2. Click "使用 Fengling 认证中心登录"
3. Should redirect to auth service login page
4. Login with admin / Admin@123
5. Should redirect back to Vben app
6. Verify user is logged in and can access dashboard
**Step 4: Test token refresh**
1. Log in successfully
2. Wait for token to near expiry or manually clear localStorage
3. Make an API request
4. Verify automatic token refresh works
**Step 5: Test logout**
1. Log in successfully
2. Click logout
3. Verify redirect to auth service logout
4. Verify tokens are revoked
5. Verify user is logged out
**Step 6: Test error handling**
1. Cancel login on auth service page
2. Verify error is displayed on Vben login page
3. Try with invalid credentials
4. 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:
```typescript
export * from './oauth';
```
**Step 2: Commit**
```bash
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**
```bash
cd apps/web-antd
pnpm typecheck
```
Fix any type errors.
**Step 2: Run linter**
```bash
cd apps/web-antd
pnpm lint
```
Fix any linting errors.
**Step 3: Build production bundle**
```bash
cd apps/web-antd
pnpm build
```
Verify build succeeds.
**Step 4: Test production build**
```bash
pnpm preview
```
Test the flow with production build.
**Step 5: Final commit**
```bash
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.