添加OAuth2认证相关配置文件和服务实现,包括环境变量配置、PKCE流程支持、token管理等功能。主要变更: - 新增OAuth2配置文件 - 实现OAuth2服务层 - 更新请求拦截器支持token自动刷新 - 修改认证API和store以支持OAuth2流程
1233 lines
32 KiB
Markdown
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.
|