import { createError } from 'h3' import { createHmac, randomUUID } from 'node:crypto' import { db } from '~/drizzle/db' import { systemSettings } from '~/drizzle/schema' import { asc } from 'drizzle-orm' import { isSafeAggregateOAuthUrl, getAggregateOAuthLoginTypesOrDefault } from './oauth-providers' export interface OAuthUserInfo { id: string username: string email?: string name?: string avatar?: string } export interface OAuthStrategy { /** * 获取授权跳转 URL */ getAuthorizeUrl(redirectUri: string, state: string): string /** * 使用 code 换取 access_token */ exchangeToken(code: string, redirectUri: string): Promise /** * 获取用户信息 */ getUserInfo(accessToken: string): Promise } // GitHub 策略实现 const githubStrategy: OAuthStrategy = { getAuthorizeUrl(redirectUri: string, state: string) { const clientId = process.env.GITHUB_CLIENT_ID if (!clientId) throw createError({ statusCode: 500, message: 'GitHub Client ID not configured' }) return `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=user:email&state=${encodeURIComponent(state)}` }, async exchangeToken(code: string, redirectUri: string) { const clientId = process.env.GITHUB_CLIENT_ID const clientSecret = process.env.GITHUB_CLIENT_SECRET if (!clientId || !clientSecret) { throw createError({ statusCode: 500, message: 'GitHub config missing' }) } try { const tokenResponse = await $fetch('https://github.com/login/oauth/access_token', { method: 'POST', body: { client_id: clientId, client_secret: clientSecret, code, redirect_uri: redirectUri }, headers: { Accept: 'application/json' } }) if (tokenResponse.error) { console.error('GitHub Token Error:', tokenResponse.error) throw new Error('授权失败,请重试') } if (!tokenResponse.access_token) { console.error('GitHub Token Response missing access_token') throw new Error('未能获取访问令牌') } return tokenResponse.access_token } catch (e: any) { console.error('GitHub token exchange failed', e.message || e) const errorMessage = e.data?.error_description || e.message || '令牌请求失败' throw new Error(errorMessage) } }, async getUserInfo(accessToken: string) { try { const userInfo = await $fetch('https://api.github.com/user', { headers: { Authorization: `Bearer ${accessToken}` } }) return { id: String(userInfo.id), username: userInfo.login, name: userInfo.name, email: userInfo.email, avatar: userInfo.avatar_url } } catch (e: any) { console.error('GitHub user info failed', e) throw new Error('获取用户信息失败') } } } // Casdoor 策略实现 const casdoorStrategy: OAuthStrategy = { getAuthorizeUrl(redirectUri: string, state: string) { const clientId = process.env.CASDOOR_CLIENT_ID const endpoint = process.env.CASDOOR_ENDPOINT if (!clientId || !endpoint) throw createError({ statusCode: 500, message: 'Casdoor config missing' }) return `${endpoint}/login/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&scope=read&state=${encodeURIComponent(state)}` }, async exchangeToken(code: string, redirectUri: string) { const clientId = process.env.CASDOOR_CLIENT_ID const clientSecret = process.env.CASDOOR_CLIENT_SECRET const endpoint = process.env.CASDOOR_ENDPOINT if (!clientId || !clientSecret || !endpoint) { throw createError({ statusCode: 500, message: 'Casdoor config missing' }) } try { const tokenResponse = await $fetch(`${endpoint}/login/oauth/access_token`, { method: 'POST', body: { client_id: clientId, client_secret: clientSecret, code, grant_type: 'authorization_code', redirect_uri: redirectUri }, headers: { Accept: 'application/json' } }) if (tokenResponse.error) { console.error('Casdoor Token Error:', tokenResponse.error, tokenResponse.error_description) throw new Error(tokenResponse.error_description || '授权失败,请重试') } if (!tokenResponse.access_token) { console.error('Casdoor Token Response missing access_token') throw new Error('未能获取访问令牌,请检查 Casdoor 配置') } return tokenResponse.access_token } catch (e: any) { console.error('Casdoor token exchange failed', e.message || e) const errorMessage = e.data?.error_description || e.message || '令牌请求失败' throw new Error(errorMessage) } }, async getUserInfo(accessToken: string) { const endpoint = process.env.CASDOOR_ENDPOINT if (!endpoint) throw createError({ statusCode: 500, message: 'Casdoor config missing' }) let userInfo: any = {} try { // Casdoor 用户信息端点通常是 /api/get-account 或 /api/userinfo // 标准 OIDC 使用 /userinfo try { userInfo = await $fetch(`${endpoint}/api/userinfo`, { headers: { Authorization: `Bearer ${accessToken}` } }) } catch (e) { // 尝试备用端点 userInfo = await $fetch(`${endpoint}/api/get-account`, { headers: { Authorization: `Bearer ${accessToken}` } }) } return { id: String(userInfo.id || userInfo.sub), username: userInfo.name || userInfo.preferred_username || userInfo.username, name: userInfo.displayName || userInfo.name, email: userInfo.email, avatar: userInfo.avatar } } catch (e: any) { console.error('Casdoor user info failed', e) throw new Error('获取用户信息失败') } } } // Google 策略实现 const googleStrategy: OAuthStrategy = { getAuthorizeUrl(redirectUri: string, state: string) { const clientId = process.env.GOOGLE_CLIENT_ID if (!clientId) throw createError({ statusCode: 500, message: 'Google Client ID not configured' }) return `https://accounts.google.com/o/oauth2/v2/auth?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=openid%20email%20profile&state=${encodeURIComponent(state)}` }, async exchangeToken(code: string, redirectUri: string) { const clientId = process.env.GOOGLE_CLIENT_ID const clientSecret = process.env.GOOGLE_CLIENT_SECRET if (!clientId || !clientSecret) { throw createError({ statusCode: 500, message: 'Google config missing' }) } try { const tokenResponse = await $fetch('https://oauth2.googleapis.com/token', { method: 'POST', body: { client_id: clientId, client_secret: clientSecret, code, grant_type: 'authorization_code', redirect_uri: redirectUri }, headers: { Accept: 'application/json' } }) if (tokenResponse.error) { console.error('Google Token Error:', tokenResponse.error) throw new Error('授权失败,请重试') } if (!tokenResponse.access_token) { console.error('Google Token Response missing access_token') throw new Error('未能获取访问令牌') } return tokenResponse.access_token } catch (e: any) { console.error('Google token exchange failed', e.message || e) const errorMessage = e.data?.error_description || e.message || '令牌请求失败' throw new Error(errorMessage) } }, async getUserInfo(accessToken: string) { try { const userInfo = await $fetch('https://www.googleapis.com/oauth2/v3/userinfo', { headers: { Authorization: `Bearer ${accessToken}` } }) return { id: userInfo.sub, username: userInfo.email, // Google doesn't have a username, use email name: userInfo.name, email: userInfo.email, avatar: userInfo.picture } } catch (e: any) { console.error('Google user info failed', e) throw new Error('获取用户信息失败') } } } // 策略注册表 const strategies: Record = { github: githubStrategy, casdoor: casdoorStrategy, google: googleStrategy } async function loadAggregateSettings() { const row = await db .select({ aggregateOAuthEnabled: systemSettings.aggregateOAuthEnabled, aggregateOAuthAppId: systemSettings.aggregateOAuthAppId, aggregateOAuthAppKey: systemSettings.aggregateOAuthAppKey, aggregateOAuthLoginType: systemSettings.aggregateOAuthLoginType, aggregateOAuthEndpoint: systemSettings.aggregateOAuthEndpoint }) .from(systemSettings) .orderBy(asc(systemSettings.id)) .limit(1) const base = row[0] ?? null const envAppId = process.env.AGGREGATE_OAUTH_APP_ID const envAppKey = process.env.AGGREGATE_OAUTH_APP_KEY const envLoginType = process.env.AGGREGATE_OAUTH_LOGIN_TYPE const envEndpoint = process.env.AGGREGATE_OAUTH_ENDPOINT const hasEnvConfig = !!(envAppId && envAppKey && envEndpoint) const dbEnabled = base?.aggregateOAuthEnabled const dbHasConfig = !!( base?.aggregateOAuthAppId && base?.aggregateOAuthAppKey && base?.aggregateOAuthEndpoint ) const enabled = Boolean(dbEnabled ?? hasEnvConfig) const appId = (base?.aggregateOAuthAppId || envAppId || '') as string const appKey = (base?.aggregateOAuthAppKey || envAppKey || '') as string const loginType = ((base?.aggregateOAuthLoginType as string) || envLoginType || '') as string const endpoint = ((base?.aggregateOAuthEndpoint as string) || envEndpoint || '') as string return { aggregateOAuthEnabled: enabled && !!(appId && appKey && endpoint), aggregateOAuthAppId: appId, aggregateOAuthAppKey: appKey, aggregateOAuthLoginType: loginType, aggregateOAuthEndpoint: endpoint, _dbHasConfig: dbHasConfig, _hasEnvConfig: hasEnvConfig } as any } function normalizeEndpoint(endpoint: string): string { return endpoint.replace(/\/+$/, '') } function aggregateSign(params: Record, appKey: string): string { const sorted = Object.keys(params) .filter((k) => params[k] !== undefined && params[k] !== null && params[k] !== '') .sort() .map((k) => `${k}=${params[k]}`) .join('&') return createHmac('sha256', appKey).update(sorted).digest('hex') } const auxxUserInfoCache = new Map() function isAuxxEndpoint(endpoint: string): boolean { return /(^|\.)auxx\.cn(\/|$)/i.test(endpoint) } const auxxStrategy: OAuthStrategy = { async getAuthorizeUrl(redirectUri: string, state: string) { const settings = await loadAggregateSettings() if (!settings?.aggregateOAuthEnabled || !settings.aggregateOAuthAppId || !settings.aggregateOAuthAppKey || !settings.aggregateOAuthEndpoint) { throw createError({ statusCode: 500, message: '聚合登录未配置' }) } if (!isSafeAggregateOAuthUrl(settings.aggregateOAuthEndpoint)) { throw createError({ statusCode: 500, message: '聚合登录端点不安全或格式错误' }) } const loginTypes = getAggregateOAuthLoginTypesOrDefault(settings.aggregateOAuthLoginType) const loginType = loginTypes[0] || 'qq' const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint) const loginQuery = new URLSearchParams({ act: 'login', appid: settings.aggregateOAuthAppId, appkey: settings.aggregateOAuthAppKey, type: loginType, redirect_uri: redirectUri }).toString() try { const resp: any = await $fetch(`${endpoint}/connect.php?${loginQuery}`, { headers: { Accept: 'application/json' } }) if (resp.code !== 0 || !resp.url) { throw new Error(resp.msg || '获取登录跳转地址失败') } const sep = resp.url.includes('?') ? '&' : '?' return `${resp.url}${sep}state=${encodeURIComponent(state)}` } catch (e: any) { console.error('Auxx OAuth authorize URL failed', e) throw new Error(e.message || '获取登录跳转地址失败') } }, async exchangeToken(code: string, redirectUri: string) { const settings = await loadAggregateSettings() if (!settings?.aggregateOAuthEnabled || !settings.aggregateOAuthAppId || !settings.aggregateOAuthAppKey || !settings.aggregateOAuthEndpoint) { throw createError({ statusCode: 500, message: '聚合登录未配置' }) } const loginTypes = getAggregateOAuthLoginTypesOrDefault(settings.aggregateOAuthLoginType) const loginType = loginTypes[0] || 'qq' const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint) const cbQuery = new URLSearchParams({ act: 'callback', appid: settings.aggregateOAuthAppId, appkey: settings.aggregateOAuthAppKey, type: loginType, code }).toString() try { const resp: any = await $fetch(`${endpoint}/connect.php?${cbQuery}`, { headers: { Accept: 'application/json' } }) if (resp.code !== 0 || !resp.access_token || !resp.social_uid) { throw new Error(resp.msg || '回调换取用户信息失败') } const cacheKey = `${settings.aggregateOAuthAppId}:${resp.access_token}` auxxUserInfoCache.set(cacheKey, resp) setTimeout(() => auxxUserInfoCache.delete(cacheKey), 10 * 60 * 1000) return String(resp.access_token) } catch (e: any) { console.error('Auxx OAuth token exchange failed', e) throw new Error(e.message || '回调换取用户信息失败') } }, async getUserInfo(accessToken: string) { const settings = await loadAggregateSettings() const appId = settings?.aggregateOAuthAppId || '' const cacheKey = `${appId}:${accessToken}` let resp = auxxUserInfoCache.get(cacheKey) if (!resp) { resp = Array.from(auxxUserInfoCache.values()).find((r: any) => r.access_token === accessToken) } if (!resp || resp.code !== 0 || !resp.social_uid) { throw new Error('获取用户信息失败') } return { id: String(resp.social_uid), username: resp.nickname || `wx_${String(resp.social_uid).slice(0, 8)}`, name: resp.nickname || undefined, email: undefined, avatar: resp.faceimg || undefined } } } const standardAggregateStrategy: OAuthStrategy = { async getAuthorizeUrl(redirectUri: string, state: string) { const settings = await loadAggregateSettings() if (!settings?.aggregateOAuthEnabled || !settings.aggregateOAuthAppId || !settings.aggregateOAuthAppKey || !settings.aggregateOAuthEndpoint) { throw createError({ statusCode: 500, message: '聚合登录未配置' }) } if (!isSafeAggregateOAuthUrl(settings.aggregateOAuthEndpoint)) { throw createError({ statusCode: 500, message: '聚合登录端点不安全或格式错误' }) } const loginTypes = getAggregateOAuthLoginTypesOrDefault(settings.aggregateOAuthLoginType) const loginType = loginTypes[0] || 'qq' const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint) const timestamp = String(Math.floor(Date.now() / 1000)) const nonce = randomUUID().replace(/-/g, '') const baseParams: Record = { app_id: settings.aggregateOAuthAppId, response_type: 'code', redirect_uri: redirectUri, state, login_type: loginType, timestamp, nonce } const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey) const query = new URLSearchParams({ ...baseParams, sign }).toString() return `${endpoint}/oauth/authorize?${query}` }, async exchangeToken(code: string, redirectUri: string) { const settings = await loadAggregateSettings() if (!settings?.aggregateOAuthEnabled || !settings.aggregateOAuthAppId || !settings.aggregateOAuthAppKey || !settings.aggregateOAuthEndpoint) { throw createError({ statusCode: 500, message: '聚合登录未配置' }) } const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint) const timestamp = String(Math.floor(Date.now() / 1000)) const nonce = randomUUID().replace(/-/g, '') const baseParams: Record = { app_id: settings.aggregateOAuthAppId, grant_type: 'authorization_code', code, redirect_uri: redirectUri, timestamp, nonce } const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey) try { const tokenResponse = await $fetch(`${endpoint}/oauth/token`, { method: 'POST', body: { ...baseParams, sign }, headers: { Accept: 'application/json' } }) if (tokenResponse.error) { throw new Error(tokenResponse.error_description || tokenResponse.error || '令牌请求失败') } const accessToken = tokenResponse.access_token || tokenResponse.accessToken if (!accessToken) { throw new Error('未能获取访问令牌') } return accessToken } catch (e: any) { console.error('Aggregate OAuth token exchange failed', e) throw new Error(e.message || '令牌请求失败') } }, async getUserInfo(accessToken: string) { const settings = await loadAggregateSettings() if (!settings?.aggregateOAuthAppId || !settings?.aggregateOAuthAppKey || !settings?.aggregateOAuthEndpoint) { throw createError({ statusCode: 500, message: '聚合登录未配置' }) } const endpoint = normalizeEndpoint(settings.aggregateOAuthEndpoint) const timestamp = String(Math.floor(Date.now() / 1000)) const nonce = randomUUID().replace(/-/g, '') const baseParams: Record = { app_id: settings.aggregateOAuthAppId, access_token: accessToken, timestamp, nonce } const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey) try { const userInfo = await $fetch(`${endpoint}/oauth/userinfo`, { method: 'POST', body: { ...baseParams, sign }, headers: { Accept: 'application/json' } }) return { id: String(userInfo.id || userInfo.openid || userInfo.sub || userInfo.userId || ''), username: userInfo.username || userInfo.nickname || userInfo.name || String(userInfo.id || 'user'), name: userInfo.name || userInfo.nickname || undefined, email: userInfo.email || undefined, avatar: userInfo.avatar || userInfo.avatar_url || userInfo.headimgurl || undefined } } catch (e: any) { console.error('Aggregate OAuth user info failed', e) throw new Error('获取用户信息失败') } } } const aggregateStrategy: OAuthStrategy = { async getAuthorizeUrl(redirectUri: string, state: string) { const settings = await loadAggregateSettings() const endpoint = settings?.aggregateOAuthEndpoint || '' return isAuxxEndpoint(endpoint) ? auxxStrategy.getAuthorizeUrl(redirectUri, state) : standardAggregateStrategy.getAuthorizeUrl(redirectUri, state) }, async exchangeToken(code: string, redirectUri: string) { const settings = await loadAggregateSettings() const endpoint = settings?.aggregateOAuthEndpoint || '' return isAuxxEndpoint(endpoint) ? auxxStrategy.exchangeToken(code, redirectUri) : standardAggregateStrategy.exchangeToken(code, redirectUri) }, async getUserInfo(accessToken: string) { const settings = await loadAggregateSettings() const endpoint = settings?.aggregateOAuthEndpoint || '' return isAuxxEndpoint(endpoint) ? auxxStrategy.getUserInfo(accessToken) : standardAggregateStrategy.getUserInfo(accessToken) } } strategies.aggregate = aggregateStrategy export const getOAuthStrategy = (provider: string): OAuthStrategy => { const strategy = strategies[provider] if (!strategy) { throw createError({ statusCode: 400, message: `Unknown provider: ${provider}` }) } return strategy }