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

457 lines
16 KiB
TypeScript

import {
decodeOAuthStateCookie,
getOAuthStateCookieNames,
LEGACY_OAUTH_STATE_COOKIE_NAMES,
parseState,
getRedirectUri,
getSafeOAuthReturnPath,
verifyCompactOAuthState
} from '~~/server/utils/oauth'
import { generateBindingToken } from '~~/server/utils/oauth-token'
import { db, eq, users, systemSettings } from '~/drizzle/db'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { createAuthSession } from '~~/server/utils/auth-session'
import { getOAuthStrategy } from '~~/server/utils/oauth-strategies'
import { isUserBlocked, getUserBlockRemainingTime } from '~~/server/services/securityService'
import {
getOAuthBaseConfig,
getProviderRuntimeConfig,
isOAuthProviderEnabled,
isSupportedOAuthProvider
} from '~~/server/services/oauthConfigService'
import { getClientIP } from '~~/server/utils/ip-utils'
import { getBeijingTime } from '~/utils/timeUtils'
import type { H3Event } from 'h3'
import { getRequestOrigin, isSecureRequest } from '~~/server/utils/request-utils'
import { createApiError } from '~~/server/utils/apiError'
import { computeRequirePasswordChange } from '~~/server/utils/system-settings-helper'
import { canBindOAuthIdentity } from '~~/server/utils/auth-route-policy'
import { syncOAuthIdentityAvatar } from '~~/server/utils/oauth-identity'
const getSingleQueryValue = (value: unknown): string | undefined => {
return typeof value === 'string' ? value : undefined
}
export default defineEventHandler(async (event) => {
const provider = getRouterParam(event, 'provider')
const query = getQuery(event)
// OAuth 回调参数参与身份与 CSRF 校验,拒绝重复参数可避免上下游解析结果不一致。
const code = getSingleQueryValue(query.code)
const stateStr = getSingleQueryValue(query.state)
const callbackLoginType = getSingleQueryValue(query.type)?.trim().toLowerCase()
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) {
return sendRedirect(
event,
`/auth/error?code=PROVIDER_DISABLED&message=${encodeURIComponent('该 OAuth 登录方式未启用')}`
)
}
if (!code) {
throw createApiError(400, 'AUTH_MISSING_CODE_OR_STATE', 'OAuth 回调缺少或包含冲突的 code 参数')
}
if (!stateStr) {
throw createApiError(400, 'AUTH_MISSING_CODE_OR_STATE', 'OAuth 回调缺少或包含冲突的 state 参数,无法完成安全验证')
}
// 1. 验证 State
const stateCookieNames = getOAuthStateCookieNames(provider === 'aggregate' ? stateStr : undefined)
let activeStateCookieNames = stateCookieNames
let csrfCookie = getCookie(event, activeStateCookieNames.csrf)
let storedFullState = getCookie(event, activeStateCookieNames.fullState)
let storedCompactState = getCookie(event, activeStateCookieNames.compactState)
// 兼容升级前已经发起、仍在十分钟有效期内的登录流程。
// 仅在新命名 Cookie 全部缺失时才回退,避免部分丢失时连同有效的新命名 Cookie 一起被丢弃。
if (provider === 'aggregate' && !csrfCookie && !storedFullState && !storedCompactState) {
activeStateCookieNames = LEGACY_OAUTH_STATE_COOKIE_NAMES
csrfCookie = getCookie(event, activeStateCookieNames.csrf)
storedFullState = getCookie(event, activeStateCookieNames.fullState)
storedCompactState = getCookie(event, activeStateCookieNames.compactState)
}
// 验证失败时同时清理两套命名下的残留 Cookie,避免无效状态干扰后续流程
const cleanupStateCookies = () => {
const names = new Set([
stateCookieNames.csrf,
LEGACY_OAUTH_STATE_COOKIE_NAMES.csrf,
...(provider === 'aggregate'
? [
stateCookieNames.fullState,
stateCookieNames.compactState,
LEGACY_OAUTH_STATE_COOKIE_NAMES.fullState,
LEGACY_OAUTH_STATE_COOKIE_NAMES.compactState
]
: [])
])
for (const name of names) {
deleteCookie(event, name, { path: '/' })
}
}
if (!csrfCookie) {
cleanupStateCookies()
throw createApiError(400, 'AUTH_CSRF_COOKIE_MISSING', 'CSRF验证失败:Cookie丢失,请从登录页面重新开始')
}
// 获取 Origin
const origin = getRequestOrigin(event)
const { stateSecret, redirectUriTemplate } = await getOAuthBaseConfig()
const providerConfig = await getProviderRuntimeConfig(provider)
let stateToVerify = stateStr
if (provider === 'aggregate') {
// Cookie 比对确认回调属于本流程,HMAC 验签阻止伪造的 state
if (
!storedFullState ||
!storedCompactState ||
storedCompactState !== stateStr ||
!verifyCompactOAuthState(stateStr, stateSecret)
) {
cleanupStateCookies()
throw createApiError(400, 'AUTH_AGGREGATED_STATE_INVALID', '聚合登录状态无效或已过期')
}
stateToVerify = decodeOAuthStateCookie(storedFullState)
if (!stateToVerify) {
cleanupStateCookies()
throw createApiError(400, 'AUTH_AGGREGATED_STATE_INVALID', '聚合登录状态无效或已过期')
}
}
const state = parseState(stateToVerify, origin, csrfCookie, stateSecret)
if (!state) {
cleanupStateCookies()
throw createApiError(400, 'AUTH_STATE_INVALID', 'Invalid or expired state')
}
if (state.provider && state.provider !== provider) {
throw createApiError(400, 'AUTH_OAUTH_PROVIDER_STATE_MISMATCH', 'OAuth provider 与 state 不匹配')
}
let identityProvider: string = provider
if (provider === 'aggregate') {
const loginType = state.loginType?.trim().toLowerCase()
if (!loginType || !providerConfig.loginTypes?.includes(loginType)) {
throw createApiError(400, 'AUTH_AGGREGATED_METHOD_CHANGED', '聚合登录方式未启用或已变更')
}
if (callbackLoginType !== loginType) {
throw createApiError(400, 'AUTH_AGGREGATED_CALLBACK_STATE_MISMATCH', '聚合登录回调类型与 state 不匹配')
}
providerConfig.loginType = loginType
identityProvider = `aggregate:${loginType}`
}
// 清除 CSRF cookie
deleteCookie(event, activeStateCookieNames.csrf, { path: '/' })
if (provider === 'aggregate') {
deleteCookie(event, activeStateCookieNames.fullState, { path: '/' })
deleteCookie(event, activeStateCookieNames.compactState, { path: '/' })
}
const strategy = getOAuthStrategy(provider)
const redirectUri = getRedirectUri(provider, redirectUriTemplate)
// 2. 使用 Code 换取 Token
let accessToken: string
try {
accessToken = await strategy.exchangeToken(code, redirectUri, providerConfig)
} catch (e: any) {
console.error(`[OAuth] ${provider} token exchange failed:`, e.message)
const errorMessage =
provider === 'aggregate' && typeof e?.message === 'string' && e.message.trim()
? `聚合登录授权失败:${e.message.trim()}`
: '授权失败,无法获取访问令牌'
return sendRedirect(
event,
`/auth/error?code=TOKEN_EXCHANGE_FAILED&message=${encodeURIComponent(errorMessage)}`
)
}
// 3. 获取用户信息
let userInfo
try {
userInfo = await strategy.getUserInfo(accessToken, providerConfig)
} catch (e: any) {
console.error(`[OAuth] ${provider} get user info failed:`, e.message)
return sendRedirect(
event,
`/auth/error?code=USER_INFO_FAILED&message=${encodeURIComponent('获取用户信息失败')}`
)
}
const providerUserId = userInfo.id
const providerUsername = userInfo.username
return handleUserLoginOrBind(
event,
identityProvider,
providerUserId,
providerUsername,
state.returnTo,
userInfo.avatar
)
})
async function handleUserLoginOrBind(
event: H3Event,
provider: string,
providerUserId: string,
providerUsername: string,
returnTo?: string,
avatar?: string
) {
const isSecure = isSecureRequest(event)
const safeReturnTo = getSafeOAuthReturnPath(returnTo)
// 4. 检查是否已登录(绑定模式)
const authToken = getCookie(event, 'auth-token')
let currentUser: any = null
if (authToken) {
try {
const payload = JWTEnhanced.verifyToken(authToken)
currentUser = payload
} catch (e) {
// Token 无效或已过期,忽略
}
}
// 5. 检查身份关联
const existingIdentity = await db.query.userIdentities.findFirst({
where: (t, { eq, and }) => and(eq(t.provider, provider), eq(t.providerUserId, providerUserId)),
with: { user: true }
})
// 如果用户已登录,则是绑定操作
if (currentUser) {
let bindingResult:
| 'success'
| 'already-bound'
| 'bound-to-other'
| 'invalid-session'
| 'inactive'
| 'password-change-required'
try {
bindingResult = await db.transaction(async (tx) => {
const [currentUserRecord] = await tx
.select({
id: users.id,
status: users.status,
tokenVersion: users.tokenVersion,
forcePasswordChange: users.forcePasswordChange,
passwordChangedAt: users.passwordChangedAt,
avatarProvider: users.avatarProvider,
avatarProviderUserId: users.avatarProviderUserId
})
.from(users)
.where(eq(users.id, currentUser.userId))
.for('update')
if (
!currentUserRecord ||
// 兼容迁移前签发的无版本号令牌,按版本 0 参与比对,与全局认证中间件语义一致
(currentUser.tokenVersion ?? 0) !== currentUserRecord.tokenVersion
) {
return 'invalid-session'
}
if (currentUserRecord.status !== 'active') {
return 'inactive'
}
const [currentSettings] = await tx
.select({
forcePasswordChangeOnFirstLogin: systemSettings.forcePasswordChangeOnFirstLogin
})
.from(systemSettings)
.limit(1)
.for('share')
const requirePasswordChange = computeRequirePasswordChange(
currentUserRecord,
currentSettings?.forcePasswordChangeOnFirstLogin ?? false
)
if (!canBindOAuthIdentity(requirePasswordChange)) {
return 'password-change-required'
}
const identity = await tx.query.userIdentities.findFirst({
where: (t, { eq, and }) =>
and(eq(t.provider, provider), eq(t.providerUserId, providerUserId))
})
if (identity) {
if (identity.userId !== currentUser.userId) {
return 'bound-to-other'
}
await syncOAuthIdentityAvatar(tx, currentUserRecord, identity, {
provider,
providerUserId,
providerUsername,
avatar
})
return 'already-bound'
}
await syncOAuthIdentityAvatar(tx, currentUserRecord, null, {
provider,
providerUserId,
providerUsername,
avatar
})
return 'success'
})
} catch (error: any) {
if (error?.code !== '23505') {
console.error('[OAuth] 绑定第三方账号失败:', error)
return sendRedirect(
event,
'/account?error=' + encodeURIComponent('绑定第三方账号失败,请稍后重试')
)
}
const concurrentIdentity = await db.query.userIdentities.findFirst({
where: (t, { eq, and }) =>
and(eq(t.provider, provider), eq(t.providerUserId, providerUserId))
})
bindingResult =
concurrentIdentity?.userId === currentUser.userId ? 'already-bound' : 'bound-to-other'
}
if (bindingResult === 'success') {
return sendRedirect(event, '/account?message=' + encodeURIComponent('绑定成功'))
}
if (bindingResult === 'already-bound') {
return sendRedirect(event, '/account?message=' + encodeURIComponent('账号已绑定'))
}
if (bindingResult === 'bound-to-other') {
return sendRedirect(event, '/account?error=' + encodeURIComponent('该账号已被其他用户绑定'))
}
if (bindingResult === 'password-change-required') {
return sendRedirect(
event,
'/change-password?error=' + encodeURIComponent('请先完成密码修改后再绑定第三方账号')
)
}
if (bindingResult === 'invalid-session') {
deleteCookie(event, 'auth-token')
return sendRedirect(event, '/login?error=' + encodeURIComponent('会话已失效,请重新登录'))
}
return sendRedirect(
event,
'/account?error=' + encodeURIComponent('当前账号状态异常,暂时无法绑定第三方账号')
)
}
// 未登录,则是登录或新绑定流程
if (existingIdentity && existingIdentity.user) {
// 检查用户状态
const user = existingIdentity.user
if (user.status === 'withdrawn') {
return sendRedirect(
event,
`/auth/error?code=ACCOUNT_WITHDRAWN&message=${encodeURIComponent('该账号已退学,限制访问')}`
)
}
if (user.status === 'graduate') {
return sendRedirect(
event,
`/auth/error?code=ACCOUNT_GRADUATED&message=${encodeURIComponent('该账号已毕业,限制访问')}`
)
}
if (user.status === 'pending') {
return sendRedirect(
event,
`/auth/error?code=ACCOUNT_PENDING_APPROVAL&message=${encodeURIComponent('账号待管理员审核,请耐心等待')}`
)
}
if (user.status !== 'active') {
return sendRedirect(
event,
`/auth/error?code=ACCOUNT_DISABLED&message=${encodeURIComponent('该账号当前不可用')}`
)
}
if (isUserBlocked(user.id)) {
const remaining = getUserBlockRemainingTime(user.id)
return sendRedirect(
event,
`/auth/error?code=ACCOUNT_BLOCKED&message=${encodeURIComponent(`账户处于风险控制期,请在 ${remaining} 分钟后重试`)}`
)
}
await db.transaction(async (tx) => {
const [userRecord] = await tx
.select({
id: users.id,
avatarProvider: users.avatarProvider,
avatarProviderUserId: users.avatarProviderUserId
})
.from(users)
.where(eq(users.id, user.id))
.for('update')
if (!userRecord) {
throw createApiError(404, 'USER_NOT_FOUND', '用户不存在')
}
await syncOAuthIdentityAvatar(tx, userRecord, existingIdentity, {
provider,
providerUserId,
providerUsername,
avatar
})
await tx
.update(users)
.set({
lastLogin: getBeijingTime(),
lastLoginIp: getClientIP(event)
})
.where(eq(users.id, user.id))
})
const { token } = await createAuthSession(event, existingIdentity.user, provider)
setCookie(event, 'auth-token', token, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/'
})
return sendRedirect(event, safeReturnTo || '/')
} else {
// 绑定
const bindingToken = generateBindingToken({
provider: provider,
providerUserId,
providerUsername,
avatar
})
// 将绑定令牌存入 cookie
setCookie(event, 'binding-token', bindingToken, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 60 * 10, // 10分钟
path: '/'
})
const redirectQuery = safeReturnTo ? `&redirect=${encodeURIComponent(safeReturnTo)}` : ''
return sendRedirect(
event,
`/login?action=bind&provider=${encodeURIComponent(provider)}&username=${encodeURIComponent(providerUsername)}${redirectQuery}`
)
}
}