import { JWTEnhanced } from '../utils/jwt-enhanced' import type { H3Event } from 'h3' import { db } from '~/drizzle/db' import { users } from '~/drizzle/schema' import { eq } from 'drizzle-orm' import { isUserBlocked, getUserBlockRemainingTime } from '../services/securityService' import { isSupportedOAuthProvider } from '../services/oauthConfigService' import { isSecureRequest } from '../utils/request-utils' import { resolveRequirePasswordChange } from '../utils/system-settings-helper' import { isPublicApiPath, shouldBlockDuringPasswordChange, shouldBypassPublicApiAuthentication } from '../utils/auth-route-policy' import { getPasswordSetupState } from '../utils/initial-password-policy' import { createApiError } from '../utils/apiError' import { SERVER_ERROR_CODES } from '../config/constants' import { ensureAuthSession, isAuthSessionStorageError, sessionExpiryIsActive, touchAuthSession } from '../utils/auth-session' function clearAuthCookie(event: H3Event) { setCookie(event, 'auth-token', '', { httpOnly: true, secure: isSecureRequest(event), sameSite: 'lax', maxAge: 0, path: '/' }) } export default defineEventHandler(async (event) => { // 清除用户上下文 if (event.context.user) { delete event.context.user } const url = getRequestURL(event) const pathname = url.pathname const method = event.method.toUpperCase() // 跳过非API路由 if (!pathname.startsWith('/api/')) { return } // 动态判断 OAuth 路径 // 允许 /api/auth/[provider] 和 /api/auth/[provider]/callback // 但排除已知的受保护/特定 Auth 端点 const isOAuthProviderRoute = (() => { if (!pathname.startsWith('/api/auth/')) return false const segments = pathname.split('/') const provider = segments[3] const isProviderIndexPath = segments.length === 4 && isSupportedOAuthProvider(provider || '') const isProviderCallbackPath = segments.length === 5 && segments[4] === 'callback' && isSupportedOAuthProvider(provider || '') return method === 'GET' && (isProviderIndexPath || isProviderCallbackPath) })() // 从请求头或cookie获取token let token: string | null = null const authHeader = getRequestHeader(event, 'authorization') if (authHeader && authHeader.startsWith('Bearer ')) { token = authHeader.substring(7) } if (!token) { token = getCookie(event, 'auth-token') || null } // 公共接口只有匿名访问时才绕过认证;携带登录态必须继续检查强制改密状态。 // OAuth 路由只有匿名启动/回调时公开;携带登录态时仍必须经过强制改密门控。 const isPublicApi = isPublicApiPath(pathname, method) if ( shouldBypassPublicApiAuthentication(pathname, method, Boolean(token)) || (isOAuthProviderRoute && !token) ) { return } // 受保护路由缺少token时返回401错误 if (!token) { return sendError( event, createError({ statusCode: 401, message: '未授权访问:缺少有效的认证信息' }) ) } // 公共接口与 OAuth 启动/回调路由上,失效登录态统一降级为匿名访问,避免残留 Cookie 阻断公共功能。 const degradeToAnonymous = (clearCookie: boolean) => { if (!isPublicApi && !isOAuthProviderRoute) return false if (clearCookie) { clearAuthCookie(event) } delete event.context.user event.context.authRejected = true return true } try { // 验证token并自动续期 const { valid, payload, newToken } = JWTEnhanced.verifyAndRefresh(token) if (!valid || !payload) { // 打标令牌失效错误,与数据库等基础设施异常区分处理 const invalidTokenError = new Error('Token无效') as Error & { invalidToken?: boolean } invalidTokenError.invalidToken = true throw invalidTokenError } // 如果生成了新token,更新cookie if (newToken) { const isSecure = isSecureRequest(event) setCookie(event, 'auth-token', newToken, { httpOnly: true, secure: isSecure, sameSite: 'lax', maxAge: 60 * 60 * 24 * 7, // 7天 path: '/' }) } const decoded = payload const userResult = await db .select({ id: users.id, username: users.username, name: users.name, grade: users.grade, class: users.class, role: users.role, status: users.status, password: users.password, passwordChangedAt: users.passwordChangedAt, forcePasswordChange: users.forcePasswordChange, tokenVersion: users.tokenVersion, email: users.email, emailVerified: users.emailVerified }) .from(users) .where(eq(users.id, decoded.userId)) .limit(1) const user = userResult[0] || null // 用户不存在或状态异常时token无效 if (!user || user.status !== 'active') { if (degradeToAnonymous(true)) return clearAuthCookie(event) const errorMessage = !user ? '用户不存在,请重新登录' : user.status === 'withdrawn' ? '该账号已退学,限制访问' : user.status === 'graduate' ? '该账号已毕业,限制访问' : user.status === 'pending' ? '账号待管理员审核,请耐心等待' : '该账号已被禁用' return sendError( event, createError({ statusCode: 401, message: errorMessage, data: { code: user?.status === 'pending' ? SERVER_ERROR_CODES.AUTH_USER_PENDING_APPROVAL : user?.status === 'withdrawn' ? SERVER_ERROR_CODES.AUTH_ACCOUNT_WITHDRAWN : user?.status === 'graduate' ? SERVER_ERROR_CODES.AUTH_ACCOUNT_GRADUATED : SERVER_ERROR_CODES.AUTH_ACCOUNT_DISABLED_OR_RESTRICTED } }) ) } // 用户级版本号可以可靠撤销同一秒内签发的旧令牌。 if ((decoded.tokenVersion ?? 0) !== user.tokenVersion) { if (degradeToAnonymous(true)) return clearAuthCookie(event) return sendError( event, createApiError(401, SERVER_ERROR_CODES.AUTH_SESSION_EXPIRED, '登录状态已失效,请重新登录', { invalidToken: true, passwordChanged: true }) ) } // 会话表支持单设备撤销;会话存储不可用时不接受无法撤销的登录态。 let authSession try { authSession = await ensureAuthSession(event, decoded) if (authSession) { await touchAuthSession(authSession.id, Boolean(newToken)) } } catch (sessionError) { console.error('[Auth] 会话存储处理失败:', sessionError) if (isAuthSessionStorageError(sessionError)) { // 登出必须允许清除 Cookie,即使会话表当前不可用。 if (pathname === '/api/auth/logout' && method === 'POST') { clearAuthCookie(event) delete event.context.user return } if (isPublicApi || isOAuthProviderRoute) { clearAuthCookie(event) delete event.context.user event.context.authRejected = true return } return sendError( event, createApiError(503, SERVER_ERROR_CODES.AUTH_DATABASE_UNAVAILABLE, '登录会话存储暂时不可用,请稍后重试') ) } else { if (isPublicApi || isOAuthProviderRoute) { delete event.context.user event.context.authRejected = true return } throw sessionError } } if (!authSession || authSession.userId !== user.id || authSession.revokedAt || !sessionExpiryIsActive(authSession.expiresAt)) { const invalidSessionError = new Error('登录会话已失效') as Error & { invalidToken?: boolean } invalidSessionError.invalidToken = true throw invalidSessionError } event.context.authSessionId = authSession?.id // 仅兼容迁移前签发的无版本号令牌;新体系令牌由 tokenVersion 撤销,避免 NTP 校准时钟与 iat 本机时钟偏差误杀。 if (decoded.tokenVersion === undefined && user.passwordChangedAt && decoded.iat) { const passwordChangedTime = Math.floor(new Date(user.passwordChangedAt).getTime() / 1000) if (decoded.iat < passwordChangedTime) { if (degradeToAnonymous(true)) return clearAuthCookie(event) return sendError( event, createError({ statusCode: 401, message: '密码已修改,请重新登录', data: { invalidToken: true, passwordChanged: true } }) ) } } let requirePasswordChange = false try { requirePasswordChange = await resolveRequirePasswordChange(user) } catch (error) { // 配置依赖故障时放行核心请求,避免缓存或数据库抖动造成全站重定向雪崩。 console.error('[Auth] 读取强制改密策略失败,按放行策略降级:', error) } if (isUserBlocked(user.id)) { // 风控期为临时限制,不清除 Cookie,公共接口降级匿名保持可访问。 if (degradeToAnonymous(false)) return delete event.context.user const remaining = getUserBlockRemainingTime(user.id) return sendError( event, createError({ statusCode: 401, message: `账户处于风险控制期,请在 ${remaining} 分钟后重试` }) ) } const passwordSetupState = getPasswordSetupState(user, requirePasswordChange) event.context.user = { id: user.id, username: user.username, name: user.name, grade: user.grade, class: user.class, role: user.role, status: user.status, passwordChangedAt: user.passwordChangedAt, forcePasswordChange: user.forcePasswordChange, tokenVersion: user.tokenVersion, sessionId: authSession?.id, email: user.email, emailVerified: user.emailVerified, requirePasswordChange, ...passwordSetupState } // 认证完成前只允许维持登录态和完成改密所需的接口。 if (shouldBlockDuringPasswordChange(pathname, method, requirePasswordChange)) { return sendError( event, createApiError(403, SERVER_ERROR_CODES.AUTH_PASSWORD_CHANGE_REQUIRED, '请先完成密码修改后再访问其他功能', { requirePasswordChange: true }) ) } // 检查管理员专用路由 if ( pathname.startsWith('/api/admin') && !['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(user.role) ) { return sendError( event, createError({ statusCode: 403, message: '需要管理员权限' }) ) } } catch (error: any) { // 仅令牌校验失败才视为登录态失效;数据库瞬断等异常不得误清合法会话 Cookie。 if (error?.invalidToken !== true) { console.error('[Auth] 认证中间件处理异常:', error) // 公共接口保留 Cookie 降级匿名,待基础设施恢复后自愈;受保护接口返回 503。 if (degradeToAnonymous(false)) { return } return sendError( event, createError({ statusCode: 503, message: '服务暂时不可用,请稍后重试' }) ) } // 携带失效登录态访问公共接口或 OAuth 登录入口时,清理 Cookie 并按匿名请求处理,保证可自愈。 if (degradeToAnonymous(true)) { return } // 处理JWT验证错误 clearAuthCookie(event) return sendError( event, createError({ statusCode: 401, message: `认证失败: ${error.message}`, data: { invalidToken: true } }) ) } })