457 lines
16 KiB
TypeScript
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}`
|
|
)
|
|
}
|
|
}
|