feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user