- 新增 /api/admin/songs/batch-reject.post 批量驳回接口(事务清排期/投票/扣接纳数/黑名单去重/异步通知) - requireSongAdmin 三角色校验 + createApiError 统一错误包装 - scheduleDaysBefore/After 四字段排期可见范围限制(开放 API 与公开排期接口三处缓存路径 + DB 条件过滤) - submissionNoteAutoModeration 基于黑名单 KEYWORD/LANGUAGE/GENRE 自动判定留言 publicStatus,命中自动关闭 submissionNotePublic - 更新 songs/request.post 与 songs/[id]/update.put 接入自动审查逻辑
401 lines
14 KiB
TypeScript
401 lines
14 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)
|
|
return row[0] ?? null
|
|
}
|
|
|
|
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 aggregateStrategy: 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('获取用户信息失败')
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|