feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const incomingRequestId = getHeader(event, 'x-request-id')
|
||||
const requestId = incomingRequestId && incomingRequestId.trim().length > 0
|
||||
? incomingRequestId.trim()
|
||||
: randomUUID()
|
||||
|
||||
event.context.requestId = requestId
|
||||
setHeader(event, 'x-request-id', requestId)
|
||||
})
|
||||
@@ -0,0 +1,381 @@
|
||||
import { apiKeyPermissions, apiKeys, db } from '~/drizzle/db'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { ApiLogService } from '~~/server/services/apiLogService'
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
API_ERROR_MESSAGES,
|
||||
API_KEY_CONSTANTS,
|
||||
HTTP_STATUS
|
||||
} from '~~/server/config/constants'
|
||||
import { getBeijingTime } from '~/utils/timeUtils'
|
||||
import { getIPBlockRemainingTime, isIPBlocked } from '~~/server/services/securityService'
|
||||
import { getClientIP } from '~~/server/utils/ip-utils'
|
||||
import { verifyApiKey } from '~~/server/utils/apiKeyUtils'
|
||||
|
||||
const truncateResponseBody = (responseBody: any, maxLength = 10000) => {
|
||||
try {
|
||||
const json = JSON.stringify(responseBody)
|
||||
return json.length <= maxLength ? json : `${json.slice(0, maxLength)}...`
|
||||
} catch {
|
||||
return String(responseBody).slice(0, maxLength)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录API访问日志
|
||||
*/
|
||||
async function logApiAccess(
|
||||
apiKeyId: string,
|
||||
method: string,
|
||||
endpoint: string,
|
||||
statusCode: number,
|
||||
responseTimeMs: number,
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
requestBody?: string,
|
||||
responseBody?: any,
|
||||
errorMessage?: string
|
||||
) {
|
||||
try {
|
||||
// 写入数据库日志
|
||||
await ApiLogService.logAccess({
|
||||
apiKeyId,
|
||||
endpoint,
|
||||
method,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
statusCode,
|
||||
responseTimeMs,
|
||||
requestBody,
|
||||
responseBody: responseBody ? truncateResponseBody(responseBody) : undefined,
|
||||
errorMessage
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[API Auth Middleware] 记录API访问日志失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key认证中间件
|
||||
* 处理开放API的认证和权限验证
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const url = getRequestURL(event)
|
||||
const pathname = url.pathname
|
||||
|
||||
// 只处理开放API路由 (/api/open/*)
|
||||
if (!pathname.startsWith('/api/open/')) {
|
||||
return
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
const method = getMethod(event)
|
||||
const userAgent = getHeader(event, 'user-agent') || ''
|
||||
console.log(`[API Auth Middleware] 开始处理开放API请求: ${method} ${pathname}`)
|
||||
|
||||
// 获取客户端真实IP地址
|
||||
const ipAddress = getClientIP(event)
|
||||
|
||||
// 检查IP是否被限制
|
||||
if (isIPBlocked(ipAddress)) {
|
||||
const remainingTime = getIPBlockRemainingTime(ipAddress)
|
||||
await ApiLogService.logAccess({
|
||||
apiKeyId: null,
|
||||
endpoint: pathname,
|
||||
method,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
statusCode: 423,
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
errorMessage: `IP地址已被限制访问,剩余时间: ${remainingTime}分钟`
|
||||
})
|
||||
|
||||
return sendError(
|
||||
event,
|
||||
createError({
|
||||
statusCode: 423,
|
||||
message: `您的IP地址已被限制访问,请在 ${remainingTime} 分钟后重试`
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// 获取API Key
|
||||
const apiKey = getHeader(event, 'x-api-key')
|
||||
|
||||
console.log('[API Auth Middleware] 已收到开放API Key,开始校验')
|
||||
|
||||
if (!apiKey) {
|
||||
console.log(`[API Auth Middleware] API Key缺失`)
|
||||
await ApiLogService.logAccess({
|
||||
apiKeyId: null,
|
||||
endpoint: pathname,
|
||||
method,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
statusCode: 401,
|
||||
responseTimeMs: Date.now() - startTime
|
||||
})
|
||||
|
||||
return sendError(
|
||||
event,
|
||||
createError({
|
||||
statusCode: HTTP_STATUS.UNAUTHORIZED,
|
||||
message: API_ERROR_MESSAGES[API_ERROR_CODES.MISSING_API_KEY]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// 验证API Key格式 (应该是 vhub_xxxxxxxxxxxxxxxx 格式)
|
||||
console.log('[API Auth Middleware] 验证API Key格式')
|
||||
|
||||
if (
|
||||
!apiKey.startsWith(API_KEY_CONSTANTS.PREFIX) ||
|
||||
apiKey.length !== API_KEY_CONSTANTS.TOTAL_LENGTH
|
||||
) {
|
||||
console.log(`[API Auth Middleware] API Key格式无效`)
|
||||
throw new Error(API_ERROR_MESSAGES[API_ERROR_CODES.INVALID_API_KEY_FORMAT])
|
||||
}
|
||||
|
||||
// 提取前缀用于候选匹配,不输出任何明文片段
|
||||
const keyPrefix = apiKey.substring(0, API_KEY_CONSTANTS.PREFIX_LENGTH)
|
||||
console.log('[API Auth Middleware] 开始按前缀匹配API Key候选')
|
||||
|
||||
// 查询API Key信息
|
||||
const apiKeyResult = await db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
keyHash: apiKeys.keyHash,
|
||||
isActive: apiKeys.isActive,
|
||||
expiresAt: apiKeys.expiresAt,
|
||||
usageCount: apiKeys.usageCount,
|
||||
createdByUserId: apiKeys.createdByUserId
|
||||
})
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.keyPrefix, keyPrefix))
|
||||
|
||||
console.log(
|
||||
`[API Auth Middleware] 数据库查询结果: ${apiKeyResult.length > 0 ? '找到记录' : '未找到记录'}`
|
||||
)
|
||||
|
||||
const apiKeyRecord = await findValidApiKeyRecord(apiKey, apiKeyResult)
|
||||
|
||||
if (!apiKeyRecord) {
|
||||
console.log(`[API Auth Middleware] API Key未找到或未激活`)
|
||||
await ApiLogService.logAccess({
|
||||
apiKeyId: null,
|
||||
endpoint: pathname,
|
||||
method,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
statusCode: HTTP_STATUS.UNAUTHORIZED,
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
errorMessage: API_ERROR_MESSAGES[API_ERROR_CODES.INVALID_API_KEY]
|
||||
})
|
||||
|
||||
throw new Error(API_ERROR_MESSAGES[API_ERROR_CODES.INVALID_API_KEY])
|
||||
}
|
||||
|
||||
console.log('[API Auth Middleware] API Key验证通过')
|
||||
|
||||
// 检查API Key是否激活
|
||||
if (!apiKeyRecord.isActive) {
|
||||
await ApiLogService.logAccess({
|
||||
apiKeyId: apiKeyRecord.id,
|
||||
endpoint: pathname,
|
||||
method,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
statusCode: HTTP_STATUS.FORBIDDEN,
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
errorMessage: API_ERROR_MESSAGES[API_ERROR_CODES.API_KEY_DISABLED]
|
||||
})
|
||||
|
||||
throw new Error(API_ERROR_MESSAGES[API_ERROR_CODES.API_KEY_DISABLED])
|
||||
}
|
||||
|
||||
// 检查API Key是否过期
|
||||
if (apiKeyRecord.expiresAt && getBeijingTime() > apiKeyRecord.expiresAt) {
|
||||
console.log('[API Auth Middleware] API Key已过期')
|
||||
await ApiLogService.logAccess({
|
||||
apiKeyId: apiKeyRecord.id,
|
||||
endpoint: pathname,
|
||||
method,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
statusCode: HTTP_STATUS.FORBIDDEN,
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
errorMessage: API_ERROR_MESSAGES[API_ERROR_CODES.API_KEY_EXPIRED]
|
||||
})
|
||||
|
||||
throw new Error(API_ERROR_MESSAGES[API_ERROR_CODES.API_KEY_EXPIRED])
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
const requiredPermission = getRequiredPermission(pathname, method)
|
||||
console.log(`[API Auth Middleware] 所需权限: ${requiredPermission}`)
|
||||
|
||||
if (requiredPermission) {
|
||||
const permissionResult = await db
|
||||
.select()
|
||||
.from(apiKeyPermissions)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeyPermissions.apiKeyId, apiKeyRecord.id),
|
||||
eq(apiKeyPermissions.permission, requiredPermission)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
console.log(
|
||||
`[API Auth Middleware] 权限检查结果: ${permissionResult.length > 0 ? '通过' : '失败'}`
|
||||
)
|
||||
|
||||
if (permissionResult.length === 0) {
|
||||
console.log(`[API Auth Middleware] 缺少必需权限: ${requiredPermission}`)
|
||||
await ApiLogService.logAccess({
|
||||
apiKeyId: apiKeyRecord.id,
|
||||
endpoint: pathname,
|
||||
method,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
statusCode: HTTP_STATUS.FORBIDDEN,
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
errorMessage: `${API_ERROR_MESSAGES[API_ERROR_CODES.INSUFFICIENT_PERMISSIONS]}: ${requiredPermission}`
|
||||
})
|
||||
|
||||
throw new Error(
|
||||
`${API_ERROR_MESSAGES[API_ERROR_CODES.INSUFFICIENT_PERMISSIONS]}. Required: ${requiredPermission}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用原子操作更新API Key使用统计
|
||||
console.log('[API Auth Middleware] 开始原子更新API Key使用统计')
|
||||
await db
|
||||
.update(apiKeys)
|
||||
.set({
|
||||
lastUsedAt: new Date(),
|
||||
usageCount: sql`${apiKeys.usageCount} + 1`,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(apiKeys.id, apiKeyRecord.id))
|
||||
console.log('[API Auth Middleware] API Key使用统计原子更新完成')
|
||||
|
||||
// 记录成功的API访问
|
||||
console.log('[API Auth Middleware] 开始记录API访问日志')
|
||||
try {
|
||||
await logApiAccess(
|
||||
apiKeyRecord.id,
|
||||
method,
|
||||
pathname,
|
||||
HTTP_STATUS.OK,
|
||||
Date.now() - startTime,
|
||||
ipAddress,
|
||||
getHeader(event, 'user-agent') || 'Unknown'
|
||||
)
|
||||
console.log('[API Auth Middleware] API访问日志记录完成')
|
||||
} catch (error) {
|
||||
console.error(`[API Auth Middleware] 记录API访问日志失败:`, error)
|
||||
}
|
||||
|
||||
console.log('[API Auth Middleware] 验证成功,继续处理请求')
|
||||
|
||||
// 将API Key信息添加到事件上下文中,供后续处理使用
|
||||
event.context.apiKey = apiKeyRecord
|
||||
|
||||
// 记录成功的API访问(在响应后记录)
|
||||
event.context.logApiAccess = async (
|
||||
statusCode: number,
|
||||
responseBody?: any,
|
||||
errorMessage?: string
|
||||
) => {
|
||||
const requestBody = method !== 'GET' ? await readBody(event).catch(() => null) : null
|
||||
await logApiAccess(
|
||||
apiKeyRecord.id,
|
||||
method,
|
||||
pathname,
|
||||
statusCode,
|
||||
Date.now() - startTime,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
requestBody ? JSON.stringify(requestBody) : undefined,
|
||||
responseBody,
|
||||
errorMessage
|
||||
)
|
||||
}
|
||||
|
||||
// 验证成功,让请求继续到API端点
|
||||
return
|
||||
} catch (error: any) {
|
||||
const statusCode =
|
||||
error.message.includes('expired') ||
|
||||
error.message.includes('disabled') ||
|
||||
error.message.includes('permissions') ||
|
||||
error.message.includes('IP address')
|
||||
? HTTP_STATUS.FORBIDDEN
|
||||
: HTTP_STATUS.UNAUTHORIZED
|
||||
|
||||
return sendError(
|
||||
event,
|
||||
createError({
|
||||
statusCode,
|
||||
message: error.message
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 根据路径和方法获取所需权限
|
||||
*/
|
||||
function getRequiredPermission(pathname: string, method: string): string | null {
|
||||
const normalizedPathname = pathname.replace(/\/+$/, '') || '/'
|
||||
|
||||
if (
|
||||
normalizedPathname === '/api/open/card-codes' ||
|
||||
normalizedPathname.startsWith('/api/open/card-codes/')
|
||||
) {
|
||||
if (
|
||||
normalizedPathname === '/api/open/card-codes/delete' ||
|
||||
normalizedPathname.startsWith('/api/open/card-codes/delete/')
|
||||
)
|
||||
return 'card-codes:delete'
|
||||
if (method === 'GET') return 'card-codes:read'
|
||||
if (method === 'DELETE') return 'card-codes:delete'
|
||||
return 'card-codes:write'
|
||||
}
|
||||
|
||||
if (normalizedPathname.startsWith('/api/open/schedules')) {
|
||||
return 'schedules:read'
|
||||
}
|
||||
|
||||
if (normalizedPathname.startsWith('/api/open/songs/mark-played')) {
|
||||
return 'songs:write'
|
||||
}
|
||||
|
||||
if (normalizedPathname === '/api/open/songs/request' && method === 'POST') {
|
||||
return 'songs:request'
|
||||
}
|
||||
|
||||
if (normalizedPathname.startsWith('/api/open/songs')) {
|
||||
return 'songs:read'
|
||||
}
|
||||
|
||||
if (normalizedPathname.startsWith('/api/open/backup')) {
|
||||
return 'backup:execute'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function findValidApiKeyRecord(apiKey: string, records: Array<{ keyHash: string }>) {
|
||||
for (const record of records) {
|
||||
if (await verifyApiKey(apiKey, record.keyHash)) {
|
||||
return record
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { isTrustedOrigin, normalizeOrigin } from '~~/server/utils/request-utils'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const requestUrl = getRequestURL(event)
|
||||
const pathname = requestUrl.pathname
|
||||
const method = getMethod(event)
|
||||
|
||||
// 只处理特定的内部API路由,防止站外调用
|
||||
const isProtectedApi = pathname.startsWith('/api/api-enhanced/netease') ||
|
||||
pathname.startsWith('/api/native-api')
|
||||
|
||||
if (!isProtectedApi) {
|
||||
return
|
||||
}
|
||||
|
||||
// 内部受信客户端绕过 CORS 校验
|
||||
const requestedFrom = getHeader(event, 'x-requested-from')
|
||||
if (requestedFrom === 'ClassIslandPlugin') {
|
||||
return
|
||||
}
|
||||
|
||||
// 显式配置 NUXT_PUBLIC_HOST 时使用配置来源校验。
|
||||
// 未配置时仍使用 Host 头;云平台等公开 Host 继续校验,仅当反向代理把 Host 改写为回环地址时跳过。
|
||||
const config = useRuntimeConfig(event)
|
||||
let configuredHost = typeof config.public?.host === 'string' ? config.public.host.trim() : ''
|
||||
if (!configuredHost) {
|
||||
const hostHeader = getHeader(event, 'x-forwarded-host') || getHeader(event, 'host')
|
||||
if (!hostHeader) {
|
||||
throw createError({ statusCode: 400, message: 'Bad Request: 缺少Host请求头' })
|
||||
}
|
||||
|
||||
configuredHost = (hostHeader.split(',')[0] || '').trim()
|
||||
|
||||
try {
|
||||
const hostOrigin = normalizeOrigin(configuredHost, requestUrl.protocol)
|
||||
if (isLoopbackHostname(hostOrigin.hostname)) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
throw createError({ statusCode: 400, message: 'Bad Request: Host请求头无效' })
|
||||
}
|
||||
|
||||
// 如果 x-forwarded-proto 存在,可以带上协议,使校验更精准
|
||||
const forwardedProto = getHeader(event, 'x-forwarded-proto')
|
||||
if (forwardedProto && !configuredHost.includes('://')) {
|
||||
const proto = (forwardedProto.split(',')[0] || '').trim()
|
||||
configuredHost = `${proto}://${configuredHost}`
|
||||
}
|
||||
}
|
||||
|
||||
// CORS 限制:禁止站外网站调用
|
||||
const origin = getHeader(event, 'origin')
|
||||
const referer = getHeader(event, 'referer')
|
||||
const secFetchSite = getHeader(event, 'sec-fetch-site')
|
||||
const secFetchMode = getHeader(event, 'sec-fetch-mode')
|
||||
const sourceUrl = origin || referer
|
||||
|
||||
// Fetch Metadata 由浏览器生成且脚本无法修改,同源请求不应被部署平台改写的 Host 误伤。
|
||||
if (secFetchSite === 'same-origin') return
|
||||
|
||||
if (sourceUrl) {
|
||||
try {
|
||||
const sourceOrigin = normalizeOrigin(sourceUrl, requestUrl.protocol)
|
||||
const trustedOrigin = normalizeOrigin(configuredHost, requestUrl.protocol)
|
||||
const isSameLoopbackOrigin =
|
||||
isLoopbackHostname(sourceOrigin.hostname) &&
|
||||
isLoopbackHostname(trustedOrigin.hostname) &&
|
||||
sourceOrigin.protocol === trustedOrigin.protocol &&
|
||||
sourceOrigin.port === trustedOrigin.port
|
||||
|
||||
const matchesConfiguredOrigin = isTrustedOrigin(sourceOrigin, trustedOrigin)
|
||||
const requestHost = getHeader(event, 'x-forwarded-host') || getHeader(event, 'host')
|
||||
const requestProto = getHeader(event, 'x-forwarded-proto')?.split(',')[0]?.trim() || requestUrl.protocol.replace(':', '')
|
||||
const hostOrigin = requestHost
|
||||
? normalizeOrigin(`${requestProto}://${requestHost.split(',')[0].trim()}`, requestUrl.protocol)
|
||||
: null
|
||||
const matchesRequestHost = hostOrigin && isTrustedOrigin(sourceOrigin, hostOrigin)
|
||||
|
||||
if (!matchesConfiguredOrigin && !matchesRequestHost && !isSameLoopbackOrigin) {
|
||||
console.warn(`[CORS Middleware] 拦截跨域请求: 来源 ${sourceOrigin.origin}, 期望 ${trustedOrigin.origin}, 路径 ${pathname}`)
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: 'Forbidden: 内部API不允许跨域请求'
|
||||
})
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'statusCode' in e &&
|
||||
typeof e.statusCode === 'number'
|
||||
) {
|
||||
throw e
|
||||
}
|
||||
console.warn(`[CORS Middleware] 无效的来源 URL: ${sourceUrl}`, e)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Bad Request: Origin或Referer头无效'
|
||||
})
|
||||
}
|
||||
} else if (isTrustedFetchMetadata(secFetchSite, secFetchMode, method)) {
|
||||
// 浏览器隐私策略可能移除来源头,此时 Fetch Metadata 是更稳定的同源信号。
|
||||
return
|
||||
} else {
|
||||
// 没有来源信息且缺少可信 Fetch Metadata 时,仍按站外请求处理。
|
||||
console.warn(`[CORS Middleware] 拦截无Origin/Referer头的请求: 路径 ${pathname}, sec-fetch-site: ${secFetchSite || 'missing'}`)
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: 'Forbidden: 访问此API必须提供Origin或Referer头'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
|
||||
}
|
||||
|
||||
function isTrustedFetchMetadata(
|
||||
secFetchSite: string | undefined,
|
||||
secFetchMode: string | undefined,
|
||||
method: string
|
||||
): boolean {
|
||||
const normalizedMethod = method.toUpperCase()
|
||||
const safeMethod = normalizedMethod === 'GET' || normalizedMethod === 'HEAD'
|
||||
|
||||
if (secFetchSite === 'same-origin') return true
|
||||
|
||||
if (secFetchSite === 'none') {
|
||||
const isNavigation = !secFetchMode || secFetchMode === 'navigate'
|
||||
return safeMethod && isNavigation
|
||||
}
|
||||
|
||||
// 兼容不发送 Fetch Metadata 的老浏览器、WebView 或反向代理;跨站请求有明确标记时仍会被拒绝。
|
||||
return !secFetchSite && safeMethod
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
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 }
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user