Files
BYW-VoiceHub/server/utils/oauth-strategies.ts
T

542 lines
20 KiB
TypeScript

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<string>
/**
* 获取用户信息
*/
getUserInfo(accessToken: string): Promise<OAuthUserInfo>
}
// 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<any>('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<any>('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<any>(`${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<any>('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<any>('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<string, OAuthStrategy> = {
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<string, string>, 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<string, any>()
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<string, string> = {
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<string, string> = {
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<any>(`${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<string, string> = {
app_id: settings.aggregateOAuthAppId,
access_token: accessToken,
timestamp,
nonce
}
const sign = aggregateSign(baseParams, settings.aggregateOAuthAppKey)
try {
const userInfo = await $fetch<any>(`${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
}