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

617 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createError } from 'h3'
import type { ProviderRuntimeConfig } from '~~/server/services/oauthConfigService'
import { isSafeAggregateOAuthUrl } from '~~/server/utils/oauth-providers'
const getObjectByPath = (source: any, path?: string): any => {
if (!source || !path) return undefined
return path
.split('.')
.reduce((acc, key) => (acc !== null && acc !== undefined ? acc[key] : undefined), source)
}
const firstStringValue = (values: any[]): string | undefined => {
for (const value of values) {
if (value === null || value === undefined) continue
if (typeof value === 'string' && value.trim()) return value.trim()
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
}
return undefined
}
const getAggregateProviderMessage = (value: any): string | undefined => {
const message = firstStringValue([value, value?.msg, value?.message, value?.error_description])
if (!message) return undefined
// CDN/WAF 挑战页不是聚合协议的业务错误,避免把整段 HTML 当成供应商消息记录。
if (/<html|just a moment|cloudflare|cf-chl-|enable javascript/i.test(message)) {
return undefined
}
return message
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.replace(
/["']?\b(app(?:id|key)|client_(?:id|secret))\b["']?\s*[:=:]\s*["']?[^\s,;"'&]+["']?/gi,
'$1=[已隐藏]'
)
.trim()
.slice(0, 200)
}
const isAggregateSecurityChallenge = (value: any): boolean => {
const message = firstStringValue([value, value?.msg, value?.message, value?.error_description])
return !!message && /<html|just a moment|cloudflare|cf-chl-|enable javascript/i.test(message)
}
const AGGREGATE_REQUEST_HEADERS = {
Accept: 'application/json',
// 部分聚合站点的 CDN 会将无 User-Agent 的服务端请求送入挑战页。
'User-Agent':
'Mozilla/5.0 (compatible; VoiceHub OAuth/1.0; +https://github.com/laoshuikaixue/VoiceHub)',
'Cache-Control': 'no-cache'
}
const fetchAggregateEndpoint = async (url: string): Promise<any> => {
return $fetch<any>(url, { headers: AGGREGATE_REQUEST_HEADERS })
}
const getAggregateAuthorizeErrorMessage = (providerMessage?: string): string =>
providerMessage ? `聚合登陆授权地址获取失败:${providerMessage}` : '聚合登陆授权地址获取失败'
const isAggregateSuccessCode = (value: unknown): boolean =>
value === 0 || (typeof value === 'string' && value.trim() === '0')
const normalizeAggregateEndpoint = (endpoint?: string): string => {
const value = endpoint?.trim()
if (!value) {
throw createError({ statusCode: 500, message: '聚合登录接口地址未配置,请在管理员后台完成配置' })
}
try {
const url = new URL(value)
if (!isSafeAggregateOAuthUrl(url.toString())) {
throw new Error('unsupported protocol')
}
url.search = ''
url.hash = ''
url.pathname = url.pathname.replace(/\/$/, '')
return url.toString().replace(/\/$/, '')
} catch (e: any) {
if (e?.statusCode) throw e
throw createError({ statusCode: 500, message: '聚合登陆接口地址配置错误' })
}
}
export interface OAuthUserInfo {
id: string
username: string
email?: string
name?: string
avatar?: string
}
export interface OAuthStrategy {
/**
* 获取授权跳转 URL
*/
getAuthorizeUrl(
redirectUri: string,
state: string,
config?: ProviderRuntimeConfig
): string | Promise<string>
/**
* 使用 code 换取 access_token
*/
exchangeToken(code: string, redirectUri: string, config?: ProviderRuntimeConfig): Promise<string>
/**
* 获取用户信息
*/
getUserInfo(accessToken: string, config?: ProviderRuntimeConfig): Promise<OAuthUserInfo>
}
// GitHub 策略实现
const githubStrategy: OAuthStrategy = {
getAuthorizeUrl(redirectUri: string, state: string, config?: ProviderRuntimeConfig) {
const clientId = config?.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, config?: ProviderRuntimeConfig) {
const clientId = config?.clientId || process.env.GITHUB_CLIENT_ID
const clientSecret = config?.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, config?: ProviderRuntimeConfig) {
const clientId = config?.clientId || process.env.CASDOOR_CLIENT_ID
const endpoint = config?.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, config?: ProviderRuntimeConfig) {
const clientId = config?.clientId || process.env.CASDOOR_CLIENT_ID
const clientSecret = config?.clientSecret || process.env.CASDOOR_CLIENT_SECRET
const endpoint = config?.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, config?: ProviderRuntimeConfig) {
const endpoint = config?.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, config?: ProviderRuntimeConfig) {
const clientId = config?.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, config?: ProviderRuntimeConfig) {
const clientId = config?.clientId || process.env.GOOGLE_CLIENT_ID
const clientSecret = config?.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 aggregateOAuthStrategy: OAuthStrategy = {
async getAuthorizeUrl(redirectUri: string, state: string, config?: ProviderRuntimeConfig) {
const appid = config?.clientId
const appkey = config?.clientSecret
const loginType = config?.loginType?.trim() || 'qq'
const endpoint = normalizeAggregateEndpoint(config?.endpoint)
if (!appid || !appkey) {
throw createError({ statusCode: 500, message: '聚合登陆配置缺失' })
}
const params = new URLSearchParams({
act: 'login',
appid,
appkey,
type: loginType,
redirect_uri: redirectUri,
state
})
let loginResponse: any
try {
loginResponse = await fetchAggregateEndpoint(`${endpoint}?${params.toString()}`)
} catch (e: any) {
const responseBody = e?.data || e?.response?._data
const providerMessage = getAggregateProviderMessage(responseBody)
// AppKey 位于协议规定的查询串中,避免记录可能包含完整请求 URL 的 FetchError。
console.error('聚合登陆授权地址请求失败', {
statusCode: e?.response?.status || e?.statusCode || e?.status || 'unknown',
providerMessage,
securityChallenge: isAggregateSecurityChallenge(responseBody)
})
if (isAggregateSecurityChallenge(responseBody)) {
throw createError({
statusCode: 502,
message:
'聚合登录供应商的安全防护拦截了服务器请求,请将服务器出口 IP 加白或更换供应商接口地址'
})
}
throw createError({
statusCode: 502,
message: getAggregateAuthorizeErrorMessage(providerMessage)
})
}
if (!isAggregateSuccessCode(loginResponse?.code) || !loginResponse?.url) {
if (isAggregateSecurityChallenge(loginResponse)) {
throw createError({
statusCode: 502,
message:
'聚合登录供应商的安全防护拦截了服务器请求,请将服务器出口 IP 加白或更换供应商接口地址'
})
}
const providerMessage = getAggregateProviderMessage(loginResponse)
// 上游返回的失败原因需落日志,便于管理员排查未开通/配置错误等问题
console.error('聚合登陆授权地址获取失败', { providerMessage })
throw createError({
statusCode: 502,
message: getAggregateAuthorizeErrorMessage(providerMessage)
})
}
try {
const authorizeUrl = new URL(loginResponse.url)
if (!isSafeAggregateOAuthUrl(authorizeUrl.toString())) {
throw new Error('unsupported protocol')
}
return authorizeUrl.toString()
} catch {
throw createError({ statusCode: 502, message: '聚合登陆返回了无效的授权地址' })
}
},
async exchangeToken(code: string, redirectUri: string, config?: ProviderRuntimeConfig) {
const appid = config?.clientId
const appkey = config?.clientSecret
const loginType = config?.loginType?.trim() || 'qq'
const endpoint = normalizeAggregateEndpoint(config?.endpoint)
if (!appid || !appkey) {
throw createError({ statusCode: 500, message: '聚合登陆配置缺失' })
}
const params = new URLSearchParams({
act: 'callback',
appid,
appkey,
type: loginType,
code
})
let tokenResponse: any
try {
tokenResponse = await fetchAggregateEndpoint(`${endpoint}?${params.toString()}`)
} catch (e: any) {
// AppKey 位于协议规定的查询串中,避免记录或继续抛出完整请求 URL。
const responseBody = e?.data || e?.response?._data
const providerMessage = getAggregateProviderMessage(responseBody)
console.error('聚合登陆回调请求失败', {
statusCode: e?.response?.status || e?.statusCode || e?.status || 'unknown',
providerMessage,
securityChallenge: isAggregateSecurityChallenge(responseBody)
})
if (isAggregateSecurityChallenge(responseBody)) {
throw new Error(
'聚合登录供应商的安全防护拦截了服务器请求,请将服务器出口 IP 加白或更换供应商接口地址',
{ cause: e }
)
}
throw new Error(providerMessage ? `令牌请求失败:${providerMessage}` : '令牌请求失败', {
cause: e
})
}
if (!isAggregateSuccessCode(tokenResponse?.code)) {
if (isAggregateSecurityChallenge(tokenResponse)) {
throw new Error(
'聚合登录供应商的安全防护拦截了服务器请求,请将服务器出口 IP 加白或更换供应商接口地址'
)
}
throw new Error(getAggregateProviderMessage(tokenResponse) || '授权失败,请重试')
}
if (!tokenResponse?.social_uid) {
throw new Error('聚合登陆响应缺少用户唯一标识')
}
return Buffer.from(JSON.stringify(tokenResponse), 'utf8').toString('base64url')
},
async getUserInfo(accessToken: string) {
try {
const userInfo = JSON.parse(Buffer.from(accessToken, 'base64url').toString('utf8'))
const id = firstStringValue([userInfo.social_uid])
if (!id) {
throw new Error('用户信息中缺少 social_uid')
}
return {
id,
username: firstStringValue([userInfo.nickname, userInfo.social_uid]) || id,
name: firstStringValue([userInfo.nickname]),
avatar: firstStringValue([userInfo.faceimg])
}
} catch (e: any) {
console.error('聚合登陆 user info failed', e)
throw new Error(e?.message || '获取用户信息失败')
}
}
}
const customOAuth2Strategy: OAuthStrategy = {
getAuthorizeUrl(redirectUri: string, state: string, config?: ProviderRuntimeConfig) {
const authorizeUrl = config?.authorizeUrl
const clientId = config?.clientId
const scope = config?.scope?.trim() || 'openid profile email'
if (!authorizeUrl || !clientId) {
throw createError({ statusCode: 500, message: 'Custom OAuth2 config missing' })
}
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope,
state
})
return `${authorizeUrl}${authorizeUrl.includes('?') ? '&' : '?'}${params.toString()}`
},
async exchangeToken(code: string, redirectUri: string, config?: ProviderRuntimeConfig) {
const tokenUrl = config?.tokenUrl
const clientId = config?.clientId
const clientSecret = config?.clientSecret
if (!tokenUrl || !clientId || !clientSecret) {
throw createError({ statusCode: 500, message: 'Custom OAuth2 config missing' })
}
try {
const body = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: redirectUri
})
const tokenResponse = await $fetch<any>(tokenUrl, {
method: 'POST',
body,
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
}
})
if (tokenResponse?.error) {
throw new Error(
tokenResponse.error_description || tokenResponse.error || '授权失败,请重试'
)
}
if (!tokenResponse?.access_token) {
throw new Error('未能获取访问令牌')
}
return tokenResponse.access_token
} catch (e: any) {
console.error('Custom OAuth2 token exchange failed', e.message || e)
const errorMessage = e?.data?.error_description || e?.message || '令牌请求失败'
throw new Error(errorMessage)
}
},
async getUserInfo(accessToken: string, config?: ProviderRuntimeConfig) {
const userInfoUrl = config?.userInfoUrl
if (!userInfoUrl) {
throw createError({ statusCode: 500, message: 'Custom OAuth2 config missing' })
}
try {
const userInfo = await $fetch<any>(userInfoUrl, {
headers: { Authorization: `Bearer ${accessToken}` }
})
const id = firstStringValue([
getObjectByPath(userInfo, config?.userIdField),
getObjectByPath(userInfo, 'id'),
getObjectByPath(userInfo, 'sub')
])
if (!id) {
throw new Error('用户信息中缺少唯一标识字段,请配置用户 ID 字段')
}
const username = firstStringValue([
getObjectByPath(userInfo, config?.usernameField),
getObjectByPath(userInfo, 'username'),
getObjectByPath(userInfo, 'preferred_username'),
getObjectByPath(userInfo, 'email'),
getObjectByPath(userInfo, 'name'),
id
])
return {
id,
username: username || id,
name: firstStringValue([
getObjectByPath(userInfo, config?.nameField),
getObjectByPath(userInfo, 'name')
]),
email: firstStringValue([
getObjectByPath(userInfo, config?.emailField),
getObjectByPath(userInfo, 'email')
]),
avatar: firstStringValue([
getObjectByPath(userInfo, config?.avatarField),
getObjectByPath(userInfo, 'avatar'),
getObjectByPath(userInfo, 'picture')
])
}
} catch (e: any) {
console.error('Custom OAuth2 user info failed', e)
throw new Error(e?.message || '获取用户信息失败')
}
}
}
// 策略注册表
const strategies: Record<string, OAuthStrategy> = {
github: githubStrategy,
casdoor: casdoorStrategy,
google: googleStrategy,
oauth2: customOAuth2Strategy,
aggregate: aggregateOAuthStrategy
}
export const getOAuthStrategy = (provider: string): OAuthStrategy => {
const strategy = strategies[provider]
if (!strategy) {
throw createError({ statusCode: 400, message: `Unknown provider: ${provider}` })
}
return strategy
}