/** * OAuth2.0客户端类 * 用于处理IDaaS OAuth2.0认证流程 * 如果需要添加新的Token相关功能: * 1. 优先考虑在 `TokenManager` 中添加 * 2. 如果需要新的网络请求,在 `OAuthClient` 中添加 */ import axios from 'axios'; interface OAuthConfig { serverUrl: string; clientId: string; clientSecret?: string; // 可选,客户端不需要,仅服务器端使用 redirectUri: string; appId: string; } interface TokenResponse { access_token: string; token_type: string; refresh_token: string; expires_in: number; scope: string; jti: string; } interface UserInfoResponse { success: boolean; code: string; message: string | null; requestId: string; data: { sub: string; ou_id: string; nickname: string; phone_number: string; ou_name: string; email: string; username: string; }; } export class OAuthClient { private config: OAuthConfig; private runtimeRedirectUri?: string; // 运行时回调地址(用于钉钉/内网动态切换) constructor(config: OAuthConfig) { this.config = { ...config, serverUrl: config.serverUrl.replace(/\/$/, '') // 移除末尾斜杠 }; // 🔍 仅在服务器端打印配置调试信息 if (typeof window === 'undefined') { console.log('🔧 [服务器端] OAuthClient 初始化配置:', { serverUrl: this.config.serverUrl, clientId: this.config.clientId, redirectUri: this.config.redirectUri, appId: this.config.appId, hasClientSecret: !!this.config.clientSecret, clientSecretLength: this.config.clientSecret?.length || 0, clientSecretPreview: this.config.clientSecret ? `${this.config.clientSecret.substring(0, 10)}...` : 'undefined' }); } } /** * 设置运行时回调地址(用于钉钉/内网动态切换) * @param redirectUri 回调地址 */ setRedirectUri(redirectUri: string): void { this.runtimeRedirectUri = redirectUri; console.log('🔧 [OAuthClient] 运行时回调地址已设置:', redirectUri); } /** * 获取当前使用的回调地址 * 优先使用运行时设置的回调地址,否则使用配置中的默认地址 * @returns 回调地址 */ private getRedirectUri(): string { const uri = this.runtimeRedirectUri || this.config.redirectUri || ''; if (this.runtimeRedirectUri) { console.log('🔧 [OAuthClient] 使用运行时回调地址:', uri); } return uri; } /** * 生成授权URL * @param state 状态值,建议包含随机字符串和_idp后缀 * @returns 授权URL */ getAuthorizeUrl(state: string): string { const params = new URLSearchParams({ response_type: 'code', scope: 'read', client_id: this.config.clientId, redirect_uri: this.config.redirectUri, state: state }); // 只打印公开信息,不打印 client_secret(安全考虑) console.log('🔧 OAuth授权URL配置:', { serverUrl: this.config.serverUrl, clientId: this.config.clientId, redirectUri: this.config.redirectUri, client_secret: this.config.clientSecret }); return `${this.config.serverUrl}/oauth/authorize?${params.toString()}`; } /** * 获取访问令牌 * @param code 授权码 * @returns 访问令牌响应 */ async getAccessToken(code: string): Promise { const url = `${this.config.serverUrl}/oauth/token`; const redirectUri = this.getRedirectUri(); // 使用动态回调地址 const data = new URLSearchParams({ grant_type: 'authorization_code', code: code, client_id: this.config.clientId, client_secret: this.config.clientSecret || '', // 提供默认值避免类型错误 redirect_uri: redirectUri }); console.log('🔧 请求Token URL:', url); console.log('🔧 请求参数:', { grant_type: 'authorization_code', code: code, client_id: this.config.clientId, redirect_uri: this.config.redirectUri, // 仅在服务器端打印 client_secret 状态 hasClientSecret: !!this.config.clientSecret, clientSecretLength: this.config.clientSecret?.length || 0 }); try { const response = await axios.post(url, data, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 60000 // 60秒超时 }); console.log('🔧 Token响应状态:', response.status, response.statusText); const tokenResponse = response.data as TokenResponse; console.log('✅ 获取访问令牌成功:', { token_type: tokenResponse.token_type, expires_in: tokenResponse.expires_in, scope: tokenResponse.scope }); return tokenResponse; } catch (error) { if (axios.isAxiosError(error)) { if (error.code === 'ECONNABORTED') { console.error('❌ 获取访问令牌超时(60秒):', error.message); } else if (error.response) { console.error('❌ 获取访问令牌失败:', { status: error.response.status, statusText: error.response.statusText, errorData: error.response.data }); } else { console.error('❌ 获取访问令牌网络错误:', error.message); } } else { console.error('❌ 获取访问令牌错误:', error); } return null; } } /** * 获取用户信息 * @param accessToken 访问令牌 * @returns 用户信息响应 */ async getUserInfo(accessToken: string): Promise { const url = `${this.config.serverUrl}/api/bff/v1.2/oauth2/userinfo`; try { const response = await axios.get(url, { headers: { 'Authorization': `Bearer ${accessToken}` }, timeout: 60000 // 60秒超时 }); return response.data as UserInfoResponse; } catch (error) { if (axios.isAxiosError(error)) { if (error.code === 'ECONNABORTED') { console.error('❌ 获取用户信息超时(60秒):', error.message); } else if (error.response) { console.error('获取用户信息失败:', error.response.status, error.response.statusText); } else { console.error('❌ 获取用户信息网络错误:', error.message); } } else { console.error('❌ 获取用户信息错误:', error); } return null; } } /** * 刷新访问令牌 * @param refreshToken 刷新令牌 * @returns 新的访问令牌响应 */ async refreshAccessToken(refreshToken: string): Promise { const url = `${this.config.serverUrl}/oauth/token`; const data = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: this.config.clientId, client_secret: this.config.clientSecret || '' // 提供默认值避免类型错误 }); console.log('🔧 [刷新Token] 请求参数状态:', { hasClientSecret: !!this.config.clientSecret, clientSecretLength: this.config.clientSecret?.length || 0, refreshTokenLength: refreshToken?.length || 0 }); try { const response = await axios.post(url, data, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 60000 // 60秒超时 }); return response.data as TokenResponse; } catch (error) { if (axios.isAxiosError(error)) { if (error.code === 'ECONNABORTED') { console.error('❌ 刷新访问令牌超时(60秒):', error.message); } else if (error.response) { console.error('刷新访问令牌失败:', error.response.data); } else { console.error('❌ 刷新访问令牌网络错误:', error.message); } } else { console.error('❌ 刷新访问令牌错误:', error); } return null; } } /** * 单点登出 * @param accessToken 访问令牌 * @param redirectUrl 登出后重定向URL * @returns 登出是否成功 */ async logout(accessToken: string, redirectUrl: string): Promise { const url = `${this.config.serverUrl}/public/sp/slo/${this.config.appId}`; const data = new URLSearchParams({ access_token: accessToken, redirect_url: redirectUrl }); try { const response = await axios.post(url, data, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 60000 // 60秒超时 }); return response.status >= 200 && response.status < 300; } catch (error) { if (axios.isAxiosError(error)) { if (error.code === 'ECONNABORTED') { console.error('❌ 登出超时(60秒):', error.message); } else { console.error('❌ 登出失败:', error); } } else { console.error('❌ 登出失败:', error); } return false; } } /** * 生成随机状态值 * @returns 状态值字符串 */ generateState(): string { // 获取当前端口号,优先级:API_PORT_CONFIG > PORT > 默认值 let currentPort = process.env.API_PORT_CONFIG || process.env.PORT; // 如果环境变量中没有端口号,尝试从浏览器location获取 if (!currentPort && typeof window !== 'undefined') { currentPort = window.location.port; } // 如果仍然没有端口号,使用默认端口 if (!currentPort) { currentPort = '51703'; // 默认端口 } const randomStr = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); const stateValue = `login${currentPort}_${randomStr}_idp`; console.log(`生成状态值: ${stateValue} (端口: ${currentPort})`); return stateValue; } } /** * OAuth2.0工具函数 */ export const oauthUtils = { /** * 从URL中提取查询参数 * @param url URL字符串 * @returns 查询参数对象 */ getQueryParams(url: string): Record { const params: Record = {}; const urlObj = new URL(url); for (const [key, value] of urlObj.searchParams) { params[key] = value; } return params; }, /** * 验证状态值 * @param state 返回的状态值 * @param expectedState 期望的状态值 * @returns 是否匹配 */ validateState(state: string, expectedState: string): boolean { return state === expectedState; }, };