Files
BYW-VoiceHub/server/api/auth/[provider]/index.get.ts
T

151 lines
5.6 KiB
TypeScript

import {
encodeOAuthStateCookie,
generateCompactOAuthState,
generateState,
getOAuthStateCookieNames,
getRedirectUri,
getSafeOAuthReturnPath
} from '~~/server/utils/oauth'
import { getOAuthStrategy } from '~~/server/utils/oauth-strategies'
import {
getOAuthBaseConfig,
getProviderRuntimeConfig,
isOAuthProviderEnabled,
isSupportedOAuthProvider
} from '~~/server/services/oauthConfigService'
import { getRequestOrigin, getSafeRequestProtocol } from '~~/server/utils/request-utils'
import { createApiError } from '~~/server/utils/apiError'
export default defineEventHandler(async (event) => {
const provider = getRouterParam(event, 'provider')
if (!provider) {
throw createApiError(400, 'AUTH_MISSING_PROVIDER', 'Missing provider')
}
if (!isSupportedOAuthProvider(provider)) {
throw createApiError(
400,
'AUTH_UNSUPPORTED_OAUTH_PROVIDER',
'当前仅支持 GitHub / Casdoor / Google / 聚合登陆 / 第三方 OAuth2'
)
}
const enabled = await isOAuthProviderEnabled(provider)
if (!enabled) {
throw createApiError(403, 'AUTH_OAUTH_PROVIDER_DISABLED', 'OAuth provider is disabled')
}
const query = getQuery(event)
const { stateSecret, redirectUriTemplate } = await getOAuthBaseConfig()
const providerConfig = await getProviderRuntimeConfig(provider)
let aggregateLoginType: string | undefined
if (provider === 'aggregate') {
aggregateLoginType =
typeof query.type === 'string' ? query.type.trim().toLowerCase() : undefined
if (!aggregateLoginType || !providerConfig.loginTypes?.includes(aggregateLoginType)) {
throw createApiError(400, 'AUTH_AGGREGATED_LOGIN_UNSUPPORTED', '未启用或不支持的聚合登录方式')
}
providerConfig.loginType = aggregateLoginType
}
const strategy = getOAuthStrategy(provider)
// 获取 Origin
const origin = getRequestOrigin(event)
const protocol = getSafeRequestProtocol(event)
const redirectUri = getRedirectUri(provider, redirectUriTemplate)
// 代理平台可能重写服务端可见的 Host,且 Auth-Broker 场景下回调域名本就与源站不同,
// 因此这里只校验回调地址格式,回调请求仍会通过 state、CSRF 和 host-only Cookie 完成来源验证。
try {
const redirectUrl = new URL(redirectUri)
if (!['http:', 'https:'].includes(redirectUrl.protocol)) {
throw new Error('unsupported protocol')
}
} catch {
throw createApiError(
400,
'AUTH_OAUTH_REDIRECT_INVALID',
'OAuth 重定向 URI 配置无效,请在管理员后台检查配置'
)
}
const returnTo = getSafeOAuthReturnPath(query.redirect)
const { state, csrf } = generateState(origin, provider, stateSecret, returnTo, aggregateLoginType)
// 在开发环境 (HTTP) 中,必须将 secure 设置为 false,否则浏览器会拒绝设置 cookie
const isHttps = protocol === 'https'
let authorizeState = state
if (provider === 'aggregate') {
authorizeState = generateCompactOAuthState(origin, stateSecret)
// 限制并发授权流程数量,防止被放弃的流程在 Cookie 中无限累积导致请求头超限
const existingCookies = parseCookies(event)
const pendingSuffixes = new Set<string>()
for (const name of Object.keys(existingCookies)) {
const suffix = name.match(/^oauth_(?:csrf|full_state|compact_state)_(.+)$/)?.[1]
if (suffix) pendingSuffixes.add(suffix)
}
if (pendingSuffixes.size >= 3) {
for (const suffix of pendingSuffixes) {
deleteCookie(event, `oauth_csrf_${suffix}`, { path: '/' })
deleteCookie(event, `oauth_full_state_${suffix}`, { path: '/' })
deleteCookie(event, `oauth_compact_state_${suffix}`, { path: '/' })
}
}
}
const stateCookieNames = getOAuthStateCookieNames(
provider === 'aggregate' ? authorizeState : undefined
)
const stateCookieOptions = {
httpOnly: true,
secure: isHttps,
sameSite: 'lax' as const,
maxAge: 60 * 10,
path: '/'
}
// 不指定 domain,让每个授权流程的 Cookie 绑定到当前 host 和独立 state。
setCookie(event, stateCookieNames.csrf, csrf, stateCookieOptions)
if (provider === 'aggregate') {
setCookie(event, stateCookieNames.fullState, encodeOAuthStateCookie(state), stateCookieOptions)
setCookie(event, stateCookieNames.compactState, authorizeState, stateCookieOptions)
}
let url: string
try {
url = await strategy.getAuthorizeUrl(redirectUri, authorizeState, providerConfig)
} catch (error: any) {
if (provider !== 'aggregate') throw error
deleteCookie(event, stateCookieNames.csrf, { path: '/' })
deleteCookie(event, stateCookieNames.fullState, { path: '/' })
deleteCookie(event, stateCookieNames.compactState, { path: '/' })
const statusCode = error?.statusCode || 500
console.error('聚合登录方式暂不可用', {
loginType: aggregateLoginType,
statusCode,
message: error?.message
})
// 区分服务端配置缺失与上游未开通,避免误导管理员排查方向
const isSecurityChallenge =
typeof error?.message === 'string' && error.message.includes('安全防护拦截')
const errorMessage =
statusCode === 500
? '聚合登录配置不完整,请联系管理员检查配置。'
: isSecurityChallenge
? error.message
: '当前登录方式暂不可用,可能尚未在聚合登录服务中开通。请尝试其他登录方式或联系管理员。'
return sendRedirect(
event,
`/auth/error?code=AGGREGATE_LOGIN_UNAVAILABLE&message=${encodeURIComponent(errorMessage)}`
)
}
return sendRedirect(event, url)
})