| const { v4: uuidv4 } = require('uuid') |
| const config = require('../../config/config') |
| const apiKeyService = require('../services/apiKeyService') |
| const userService = require('../services/userService') |
| const logger = require('../utils/logger') |
| const redis = require('../models/redis') |
| |
| const ClientValidator = require('../validators/clientValidator') |
|
|
| const FALLBACK_CONCURRENCY_CONFIG = { |
| leaseSeconds: 300, |
| renewIntervalSeconds: 30, |
| cleanupGraceSeconds: 30 |
| } |
|
|
| const resolveConcurrencyConfig = () => { |
| if (typeof redis._getConcurrencyConfig === 'function') { |
| return redis._getConcurrencyConfig() |
| } |
|
|
| const raw = { |
| ...FALLBACK_CONCURRENCY_CONFIG, |
| ...(config.concurrency || {}) |
| } |
|
|
| const toNumber = (value, fallback) => { |
| const parsed = Number(value) |
| if (!Number.isFinite(parsed)) { |
| return fallback |
| } |
| return parsed |
| } |
|
|
| const leaseSeconds = Math.max( |
| toNumber(raw.leaseSeconds, FALLBACK_CONCURRENCY_CONFIG.leaseSeconds), |
| 30 |
| ) |
|
|
| let renewIntervalSeconds |
| if (raw.renewIntervalSeconds === 0 || raw.renewIntervalSeconds === '0') { |
| renewIntervalSeconds = 0 |
| } else { |
| renewIntervalSeconds = Math.max( |
| toNumber(raw.renewIntervalSeconds, FALLBACK_CONCURRENCY_CONFIG.renewIntervalSeconds), |
| 0 |
| ) |
| } |
|
|
| const cleanupGraceSeconds = Math.max( |
| toNumber(raw.cleanupGraceSeconds, FALLBACK_CONCURRENCY_CONFIG.cleanupGraceSeconds), |
| 0 |
| ) |
|
|
| return { |
| leaseSeconds, |
| renewIntervalSeconds, |
| cleanupGraceSeconds |
| } |
| } |
|
|
| const TOKEN_COUNT_PATHS = new Set([ |
| '/v1/messages/count_tokens', |
| '/api/v1/messages/count_tokens', |
| '/claude/v1/messages/count_tokens' |
| ]) |
|
|
| function extractApiKey(req) { |
| const candidates = [ |
| req.headers['x-api-key'], |
| req.headers['x-goog-api-key'], |
| req.headers['authorization'], |
| req.headers['api-key'], |
| req.query?.key |
| ] |
|
|
| for (const candidate of candidates) { |
| let value = candidate |
|
|
| if (Array.isArray(value)) { |
| value = value.find((item) => typeof item === 'string' && item.trim()) |
| } |
|
|
| if (typeof value !== 'string') { |
| continue |
| } |
|
|
| let trimmed = value.trim() |
| if (!trimmed) { |
| continue |
| } |
|
|
| if (/^Bearer\s+/i.test(trimmed)) { |
| trimmed = trimmed.replace(/^Bearer\s+/i, '').trim() |
| if (!trimmed) { |
| continue |
| } |
| } |
|
|
| return trimmed |
| } |
|
|
| return '' |
| } |
|
|
| function normalizeRequestPath(value) { |
| if (!value) { |
| return '/' |
| } |
| const lower = value.split('?')[0].toLowerCase() |
| const collapsed = lower.replace(/\/{2,}/g, '/') |
| if (collapsed.length > 1 && collapsed.endsWith('/')) { |
| return collapsed.slice(0, -1) |
| } |
| return collapsed || '/' |
| } |
|
|
| function isTokenCountRequest(req) { |
| const combined = normalizeRequestPath(`${req.baseUrl || ''}${req.path || ''}`) |
| if (TOKEN_COUNT_PATHS.has(combined)) { |
| return true |
| } |
| const original = normalizeRequestPath(req.originalUrl || '') |
| if (TOKEN_COUNT_PATHS.has(original)) { |
| return true |
| } |
| return false |
| } |
|
|
| |
| const authenticateApiKey = async (req, res, next) => { |
| const startTime = Date.now() |
|
|
| try { |
| |
| const apiKey = extractApiKey(req) |
|
|
| if (apiKey) { |
| req.headers['x-api-key'] = apiKey |
| } |
|
|
| if (!apiKey) { |
| logger.security(`🔒 Missing API key attempt from ${req.ip || 'unknown'}`) |
| return res.status(401).json({ |
| error: 'Missing API key', |
| message: |
| 'Please provide an API key in the x-api-key, x-goog-api-key, or Authorization header' |
| }) |
| } |
|
|
| |
| if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { |
| logger.security(`🔒 Invalid API key format from ${req.ip || 'unknown'}`) |
| return res.status(401).json({ |
| error: 'Invalid API key format', |
| message: 'API key format is invalid' |
| }) |
| } |
|
|
| |
| const validation = await apiKeyService.validateApiKey(apiKey) |
|
|
| if (!validation.valid) { |
| const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' |
| logger.security(`🔒 Invalid API key attempt: ${validation.error} from ${clientIP}`) |
| return res.status(401).json({ |
| error: 'Invalid API key', |
| message: validation.error |
| }) |
| } |
|
|
| const skipKeyRestrictions = isTokenCountRequest(req) |
|
|
| |
| if ( |
| !skipKeyRestrictions && |
| validation.keyData.enableClientRestriction && |
| validation.keyData.allowedClients?.length > 0 |
| ) { |
| |
| const validationResult = ClientValidator.validateRequest( |
| validation.keyData.allowedClients, |
| req |
| ) |
|
|
| if (!validationResult.allowed) { |
| const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' |
| logger.security( |
| `🚫 Client restriction failed for key: ${validation.keyData.id} (${validation.keyData.name}) from ${clientIP}` |
| ) |
| return res.status(403).json({ |
| error: 'Client not allowed', |
| message: 'Your client is not authorized to use this API key', |
| allowedClients: validation.keyData.allowedClients, |
| userAgent: validationResult.userAgent |
| }) |
| } |
|
|
| |
| logger.api( |
| `✅ Client validated: ${validationResult.clientName} (${validationResult.matchedClient}) for key: ${validation.keyData.id} (${validation.keyData.name})` |
| ) |
| } |
|
|
| |
| const concurrencyLimit = validation.keyData.concurrencyLimit || 0 |
| if (!skipKeyRestrictions && concurrencyLimit > 0) { |
| const { leaseSeconds: configLeaseSeconds, renewIntervalSeconds: configRenewIntervalSeconds } = |
| resolveConcurrencyConfig() |
| const leaseSeconds = Math.max(Number(configLeaseSeconds) || 300, 30) |
| let renewIntervalSeconds = configRenewIntervalSeconds |
| if (renewIntervalSeconds > 0) { |
| const maxSafeRenew = Math.max(leaseSeconds - 5, 15) |
| renewIntervalSeconds = Math.min(Math.max(renewIntervalSeconds, 15), maxSafeRenew) |
| } else { |
| renewIntervalSeconds = 0 |
| } |
| const requestId = uuidv4() |
|
|
| const currentConcurrency = await redis.incrConcurrency( |
| validation.keyData.id, |
| requestId, |
| leaseSeconds |
| ) |
| logger.api( |
| `📈 Incremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency}, limit: ${concurrencyLimit}` |
| ) |
|
|
| if (currentConcurrency > concurrencyLimit) { |
| |
| await redis.decrConcurrency(validation.keyData.id, requestId) |
| logger.security( |
| `🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${ |
| validation.keyData.name |
| }), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}` |
| ) |
| return res.status(429).json({ |
| error: 'Concurrency limit exceeded', |
| message: `Too many concurrent requests. Limit: ${concurrencyLimit} concurrent requests`, |
| currentConcurrency: currentConcurrency - 1, |
| concurrencyLimit |
| }) |
| } |
|
|
| const renewIntervalMs = |
| renewIntervalSeconds > 0 ? Math.max(renewIntervalSeconds * 1000, 15000) : 0 |
|
|
| |
| let concurrencyDecremented = false |
| let leaseRenewInterval = null |
|
|
| if (renewIntervalMs > 0) { |
| leaseRenewInterval = setInterval(() => { |
| redis |
| .refreshConcurrencyLease(validation.keyData.id, requestId, leaseSeconds) |
| .catch((error) => { |
| logger.error( |
| `Failed to refresh concurrency lease for key ${validation.keyData.id}:`, |
| error |
| ) |
| }) |
| }, renewIntervalMs) |
|
|
| if (typeof leaseRenewInterval.unref === 'function') { |
| leaseRenewInterval.unref() |
| } |
| } |
|
|
| const decrementConcurrency = async () => { |
| if (!concurrencyDecremented) { |
| concurrencyDecremented = true |
| if (leaseRenewInterval) { |
| clearInterval(leaseRenewInterval) |
| leaseRenewInterval = null |
| } |
| try { |
| const newCount = await redis.decrConcurrency(validation.keyData.id, requestId) |
| logger.api( |
| `📉 Decremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), new count: ${newCount}` |
| ) |
| } catch (error) { |
| logger.error(`Failed to decrement concurrency for key ${validation.keyData.id}:`, error) |
| } |
| } |
| } |
|
|
| |
| |
| res.once('close', () => { |
| logger.api( |
| `🔌 Response closed for key: ${validation.keyData.id} (${validation.keyData.name})` |
| ) |
| decrementConcurrency() |
| }) |
|
|
| |
| req.once('close', () => { |
| logger.api( |
| `🔌 Request closed for key: ${validation.keyData.id} (${validation.keyData.name})` |
| ) |
| decrementConcurrency() |
| }) |
|
|
| req.once('aborted', () => { |
| logger.warn( |
| `⚠️ Request aborted for key: ${validation.keyData.id} (${validation.keyData.name})` |
| ) |
| decrementConcurrency() |
| }) |
|
|
| req.once('error', (error) => { |
| logger.error( |
| `❌ Request error for key ${validation.keyData.id} (${validation.keyData.name}):`, |
| error |
| ) |
| decrementConcurrency() |
| }) |
|
|
| res.once('error', (error) => { |
| logger.error( |
| `❌ Response error for key ${validation.keyData.id} (${validation.keyData.name}):`, |
| error |
| ) |
| decrementConcurrency() |
| }) |
|
|
| |
| res.once('finish', () => { |
| logger.api( |
| `✅ Response finished for key: ${validation.keyData.id} (${validation.keyData.name})` |
| ) |
| decrementConcurrency() |
| }) |
|
|
| |
| req.concurrencyInfo = { |
| apiKeyId: validation.keyData.id, |
| apiKeyName: validation.keyData.name, |
| requestId, |
| decrementConcurrency |
| } |
| } |
|
|
| |
| const rateLimitWindow = validation.keyData.rateLimitWindow || 0 |
| const rateLimitRequests = validation.keyData.rateLimitRequests || 0 |
| const rateLimitCost = validation.keyData.rateLimitCost || 0 |
|
|
| |
| const hasRateLimits = |
| rateLimitWindow > 0 && |
| (rateLimitRequests > 0 || validation.keyData.tokenLimit > 0 || rateLimitCost > 0) |
|
|
| if (hasRateLimits) { |
| const windowStartKey = `rate_limit:window_start:${validation.keyData.id}` |
| const requestCountKey = `rate_limit:requests:${validation.keyData.id}` |
| const tokenCountKey = `rate_limit:tokens:${validation.keyData.id}` |
| const costCountKey = `rate_limit:cost:${validation.keyData.id}` |
|
|
| const now = Date.now() |
| const windowDuration = rateLimitWindow * 60 * 1000 |
|
|
| |
| let windowStart = await redis.getClient().get(windowStartKey) |
|
|
| if (!windowStart) { |
| |
| await redis.getClient().set(windowStartKey, now, 'PX', windowDuration) |
| await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration) |
| await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration) |
| await redis.getClient().set(costCountKey, 0, 'PX', windowDuration) |
| windowStart = now |
| } else { |
| windowStart = parseInt(windowStart) |
|
|
| |
| if (now - windowStart >= windowDuration) { |
| |
| await redis.getClient().set(windowStartKey, now, 'PX', windowDuration) |
| await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration) |
| await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration) |
| await redis.getClient().set(costCountKey, 0, 'PX', windowDuration) |
| windowStart = now |
| } |
| } |
|
|
| |
| const currentRequests = parseInt((await redis.getClient().get(requestCountKey)) || '0') |
| const currentTokens = parseInt((await redis.getClient().get(tokenCountKey)) || '0') |
| const currentCost = parseFloat((await redis.getClient().get(costCountKey)) || '0') |
|
|
| |
| if (rateLimitRequests > 0 && currentRequests >= rateLimitRequests) { |
| const resetTime = new Date(windowStart + windowDuration) |
| const remainingMinutes = Math.ceil((resetTime - now) / 60000) |
|
|
| logger.security( |
| `🚦 Rate limit exceeded (requests) for key: ${validation.keyData.id} (${validation.keyData.name}), requests: ${currentRequests}/${rateLimitRequests}` |
| ) |
|
|
| return res.status(429).json({ |
| error: 'Rate limit exceeded', |
| message: `已达到请求次数限制 (${rateLimitRequests} 次),将在 ${remainingMinutes} 分钟后重置`, |
| currentRequests, |
| requestLimit: rateLimitRequests, |
| resetAt: resetTime.toISOString(), |
| remainingMinutes |
| }) |
| } |
|
|
| |
| const tokenLimit = parseInt(validation.keyData.tokenLimit) |
| if (tokenLimit > 0) { |
| |
| if (currentTokens >= tokenLimit) { |
| const resetTime = new Date(windowStart + windowDuration) |
| const remainingMinutes = Math.ceil((resetTime - now) / 60000) |
|
|
| logger.security( |
| `🚦 Rate limit exceeded (tokens) for key: ${validation.keyData.id} (${validation.keyData.name}), tokens: ${currentTokens}/${tokenLimit}` |
| ) |
|
|
| return res.status(429).json({ |
| error: 'Rate limit exceeded', |
| message: `已达到 Token 使用限制 (${tokenLimit} tokens),将在 ${remainingMinutes} 分钟后重置`, |
| currentTokens, |
| tokenLimit, |
| resetAt: resetTime.toISOString(), |
| remainingMinutes |
| }) |
| } |
| } else if (rateLimitCost > 0) { |
| |
| if (currentCost >= rateLimitCost) { |
| const resetTime = new Date(windowStart + windowDuration) |
| const remainingMinutes = Math.ceil((resetTime - now) / 60000) |
|
|
| logger.security( |
| `💰 Rate limit exceeded (cost) for key: ${validation.keyData.id} (${ |
| validation.keyData.name |
| }), cost: $${currentCost.toFixed(2)}/$${rateLimitCost}` |
| ) |
|
|
| return res.status(429).json({ |
| error: 'Rate limit exceeded', |
| message: `已达到费用限制 ($${rateLimitCost}),将在 ${remainingMinutes} 分钟后重置`, |
| currentCost, |
| costLimit: rateLimitCost, |
| resetAt: resetTime.toISOString(), |
| remainingMinutes |
| }) |
| } |
| } |
|
|
| |
| await redis.getClient().incr(requestCountKey) |
|
|
| |
| req.rateLimitInfo = { |
| windowStart, |
| windowDuration, |
| requestCountKey, |
| tokenCountKey, |
| costCountKey, |
| currentRequests: currentRequests + 1, |
| currentTokens, |
| currentCost, |
| rateLimitRequests, |
| tokenLimit, |
| rateLimitCost |
| } |
| } |
|
|
| |
| const dailyCostLimit = validation.keyData.dailyCostLimit || 0 |
| if (dailyCostLimit > 0) { |
| const dailyCost = validation.keyData.dailyCost || 0 |
|
|
| if (dailyCost >= dailyCostLimit) { |
| logger.security( |
| `💰 Daily cost limit exceeded for key: ${validation.keyData.id} (${ |
| validation.keyData.name |
| }), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}` |
| ) |
|
|
| return res.status(429).json({ |
| error: 'Daily cost limit exceeded', |
| message: `已达到每日费用限制 ($${dailyCostLimit})`, |
| currentCost: dailyCost, |
| costLimit: dailyCostLimit, |
| resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString() |
| }) |
| } |
|
|
| |
| logger.api( |
| `💰 Cost usage for key: ${validation.keyData.id} (${ |
| validation.keyData.name |
| }), current: $${dailyCost.toFixed(2)}/$${dailyCostLimit}` |
| ) |
| } |
|
|
| |
| const totalCostLimit = validation.keyData.totalCostLimit || 0 |
| if (totalCostLimit > 0) { |
| const totalCost = validation.keyData.totalCost || 0 |
|
|
| if (totalCost >= totalCostLimit) { |
| logger.security( |
| `💰 Total cost limit exceeded for key: ${validation.keyData.id} (${ |
| validation.keyData.name |
| }), cost: $${totalCost.toFixed(2)}/$${totalCostLimit}` |
| ) |
|
|
| return res.status(429).json({ |
| error: 'Total cost limit exceeded', |
| message: `已达到总费用限制 ($${totalCostLimit})`, |
| currentCost: totalCost, |
| costLimit: totalCostLimit |
| }) |
| } |
|
|
| logger.api( |
| `💰 Total cost usage for key: ${validation.keyData.id} (${ |
| validation.keyData.name |
| }), current: $${totalCost.toFixed(2)}/$${totalCostLimit}` |
| ) |
| } |
|
|
| |
| const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0 |
| if (weeklyOpusCostLimit > 0) { |
| |
| const requestBody = req.body || {} |
| const model = requestBody.model || '' |
|
|
| |
| if (model && model.toLowerCase().includes('claude-opus')) { |
| const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0 |
|
|
| if (weeklyOpusCost >= weeklyOpusCostLimit) { |
| logger.security( |
| `💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${ |
| validation.keyData.name |
| }), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}` |
| ) |
|
|
| |
| const now = new Date() |
| const dayOfWeek = now.getDay() |
| const daysUntilMonday = dayOfWeek === 0 ? 1 : (8 - dayOfWeek) % 7 || 7 |
| const resetDate = new Date(now) |
| resetDate.setDate(now.getDate() + daysUntilMonday) |
| resetDate.setHours(0, 0, 0, 0) |
|
|
| return res.status(429).json({ |
| error: 'Weekly Opus cost limit exceeded', |
| message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`, |
| currentCost: weeklyOpusCost, |
| costLimit: weeklyOpusCostLimit, |
| resetAt: resetDate.toISOString() |
| }) |
| } |
|
|
| |
| logger.api( |
| `💰 Opus weekly cost usage for key: ${validation.keyData.id} (${ |
| validation.keyData.name |
| }), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}` |
| ) |
| } |
| } |
|
|
| |
| req.apiKey = { |
| id: validation.keyData.id, |
| name: validation.keyData.name, |
| tokenLimit: validation.keyData.tokenLimit, |
| claudeAccountId: validation.keyData.claudeAccountId, |
| claudeConsoleAccountId: validation.keyData.claudeConsoleAccountId, |
| geminiAccountId: validation.keyData.geminiAccountId, |
| openaiAccountId: validation.keyData.openaiAccountId, |
| bedrockAccountId: validation.keyData.bedrockAccountId, |
| droidAccountId: validation.keyData.droidAccountId, |
| permissions: validation.keyData.permissions, |
| concurrencyLimit: validation.keyData.concurrencyLimit, |
| rateLimitWindow: validation.keyData.rateLimitWindow, |
| rateLimitRequests: validation.keyData.rateLimitRequests, |
| rateLimitCost: validation.keyData.rateLimitCost, |
| enableModelRestriction: validation.keyData.enableModelRestriction, |
| restrictedModels: validation.keyData.restrictedModels, |
| enableClientRestriction: validation.keyData.enableClientRestriction, |
| allowedClients: validation.keyData.allowedClients, |
| dailyCostLimit: validation.keyData.dailyCostLimit, |
| dailyCost: validation.keyData.dailyCost, |
| totalCostLimit: validation.keyData.totalCostLimit, |
| totalCost: validation.keyData.totalCost, |
| usage: validation.keyData.usage |
| } |
| req.usage = validation.keyData.usage |
|
|
| const authDuration = Date.now() - startTime |
| const userAgent = req.headers['user-agent'] || 'No User-Agent' |
| logger.api( |
| `🔓 Authenticated request from key: ${validation.keyData.name} (${validation.keyData.id}) in ${authDuration}ms` |
| ) |
| logger.api(` User-Agent: "${userAgent}"`) |
|
|
| return next() |
| } catch (error) { |
| const authDuration = Date.now() - startTime |
| logger.error(`❌ Authentication middleware error (${authDuration}ms):`, { |
| error: error.message, |
| stack: error.stack, |
| ip: req.ip, |
| userAgent: req.get('User-Agent'), |
| url: req.originalUrl |
| }) |
|
|
| return res.status(500).json({ |
| error: 'Authentication error', |
| message: 'Internal server error during authentication' |
| }) |
| } |
| } |
|
|
| |
| const authenticateAdmin = async (req, res, next) => { |
| const startTime = Date.now() |
|
|
| try { |
| |
| const token = |
| req.headers['authorization']?.replace(/^Bearer\s+/i, '') || |
| req.cookies?.adminToken || |
| req.headers['x-admin-token'] |
|
|
| if (!token) { |
| logger.security(`🔒 Missing admin token attempt from ${req.ip || 'unknown'}`) |
| return res.status(401).json({ |
| error: 'Missing admin token', |
| message: 'Please provide an admin token' |
| }) |
| } |
|
|
| |
| if (typeof token !== 'string' || token.length < 32 || token.length > 512) { |
| logger.security(`🔒 Invalid admin token format from ${req.ip || 'unknown'}`) |
| return res.status(401).json({ |
| error: 'Invalid admin token format', |
| message: 'Admin token format is invalid' |
| }) |
| } |
|
|
| |
| const adminSession = await Promise.race([ |
| redis.getSession(token), |
| new Promise((_, reject) => |
| setTimeout(() => reject(new Error('Session lookup timeout')), 5000) |
| ) |
| ]) |
|
|
| if (!adminSession || Object.keys(adminSession).length === 0) { |
| logger.security(`🔒 Invalid admin token attempt from ${req.ip || 'unknown'}`) |
| return res.status(401).json({ |
| error: 'Invalid admin token', |
| message: 'Invalid or expired admin session' |
| }) |
| } |
|
|
| |
| const now = new Date() |
| const lastActivity = new Date(adminSession.lastActivity || adminSession.loginTime) |
| const inactiveDuration = now - lastActivity |
| const maxInactivity = 24 * 60 * 60 * 1000 |
|
|
| if (inactiveDuration > maxInactivity) { |
| logger.security( |
| `🔒 Expired admin session for ${adminSession.username} from ${req.ip || 'unknown'}` |
| ) |
| await redis.deleteSession(token) |
| return res.status(401).json({ |
| error: 'Session expired', |
| message: 'Admin session has expired due to inactivity' |
| }) |
| } |
|
|
| |
| redis |
| .setSession( |
| token, |
| { |
| ...adminSession, |
| lastActivity: now.toISOString() |
| }, |
| 86400 |
| ) |
| .catch((error) => { |
| logger.error('Failed to update admin session activity:', error) |
| }) |
|
|
| |
| req.admin = { |
| id: adminSession.adminId || 'admin', |
| username: adminSession.username, |
| sessionId: token, |
| loginTime: adminSession.loginTime |
| } |
|
|
| const authDuration = Date.now() - startTime |
| logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`) |
|
|
| return next() |
| } catch (error) { |
| const authDuration = Date.now() - startTime |
| logger.error(`❌ Admin authentication error (${authDuration}ms):`, { |
| error: error.message, |
| ip: req.ip, |
| userAgent: req.get('User-Agent'), |
| url: req.originalUrl |
| }) |
|
|
| return res.status(500).json({ |
| error: 'Authentication error', |
| message: 'Internal server error during admin authentication' |
| }) |
| } |
| } |
|
|
| |
| const authenticateUser = async (req, res, next) => { |
| const startTime = Date.now() |
|
|
| try { |
| |
| const sessionToken = |
| req.headers['authorization']?.replace(/^Bearer\s+/i, '') || |
| req.cookies?.userToken || |
| req.headers['x-user-token'] |
|
|
| if (!sessionToken) { |
| logger.security(`🔒 Missing user session token attempt from ${req.ip || 'unknown'}`) |
| return res.status(401).json({ |
| error: 'Missing user session token', |
| message: 'Please login to access this resource' |
| }) |
| } |
|
|
| |
| if (typeof sessionToken !== 'string' || sessionToken.length < 32 || sessionToken.length > 128) { |
| logger.security(`🔒 Invalid user session token format from ${req.ip || 'unknown'}`) |
| return res.status(401).json({ |
| error: 'Invalid session token format', |
| message: 'Session token format is invalid' |
| }) |
| } |
|
|
| |
| const sessionValidation = await userService.validateUserSession(sessionToken) |
|
|
| if (!sessionValidation) { |
| logger.security(`🔒 Invalid user session token attempt from ${req.ip || 'unknown'}`) |
| return res.status(401).json({ |
| error: 'Invalid session token', |
| message: 'Invalid or expired user session' |
| }) |
| } |
|
|
| const { session, user } = sessionValidation |
|
|
| |
| if (!user.isActive) { |
| logger.security( |
| `🔒 Disabled user login attempt: ${user.username} from ${req.ip || 'unknown'}` |
| ) |
| return res.status(403).json({ |
| error: 'Account disabled', |
| message: 'Your account has been disabled. Please contact administrator.' |
| }) |
| } |
|
|
| |
| req.user = { |
| id: user.id, |
| username: user.username, |
| email: user.email, |
| displayName: user.displayName, |
| firstName: user.firstName, |
| lastName: user.lastName, |
| role: user.role, |
| sessionToken, |
| sessionCreatedAt: session.createdAt |
| } |
|
|
| const authDuration = Date.now() - startTime |
| logger.info(`👤 User authenticated: ${user.username} (${user.id}) in ${authDuration}ms`) |
|
|
| return next() |
| } catch (error) { |
| const authDuration = Date.now() - startTime |
| logger.error(`❌ User authentication error (${authDuration}ms):`, { |
| error: error.message, |
| ip: req.ip, |
| userAgent: req.get('User-Agent'), |
| url: req.originalUrl |
| }) |
|
|
| return res.status(500).json({ |
| error: 'Authentication error', |
| message: 'Internal server error during user authentication' |
| }) |
| } |
| } |
|
|
| |
| const authenticateUserOrAdmin = async (req, res, next) => { |
| const startTime = Date.now() |
|
|
| try { |
| |
| const adminToken = |
| req.headers['authorization']?.replace(/^Bearer\s+/i, '') || |
| req.cookies?.adminToken || |
| req.headers['x-admin-token'] |
|
|
| |
| const userToken = |
| req.headers['x-user-token'] || |
| req.cookies?.userToken || |
| (!adminToken ? req.headers['authorization']?.replace(/^Bearer\s+/i, '') : null) |
|
|
| |
| if (adminToken) { |
| try { |
| const adminSession = await redis.getSession(adminToken) |
| if (adminSession && Object.keys(adminSession).length > 0) { |
| req.admin = { |
| id: adminSession.adminId || 'admin', |
| username: adminSession.username, |
| sessionId: adminToken, |
| loginTime: adminSession.loginTime |
| } |
| req.userType = 'admin' |
|
|
| const authDuration = Date.now() - startTime |
| logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`) |
| return next() |
| } |
| } catch (error) { |
| logger.debug('Admin authentication failed, trying user authentication:', error.message) |
| } |
| } |
|
|
| |
| if (userToken) { |
| try { |
| const sessionValidation = await userService.validateUserSession(userToken) |
| if (sessionValidation) { |
| const { session, user } = sessionValidation |
|
|
| if (user.isActive) { |
| req.user = { |
| id: user.id, |
| username: user.username, |
| email: user.email, |
| displayName: user.displayName, |
| firstName: user.firstName, |
| lastName: user.lastName, |
| role: user.role, |
| sessionToken: userToken, |
| sessionCreatedAt: session.createdAt |
| } |
| req.userType = 'user' |
|
|
| const authDuration = Date.now() - startTime |
| logger.info(`👤 User authenticated: ${user.username} (${user.id}) in ${authDuration}ms`) |
| return next() |
| } |
| } |
| } catch (error) { |
| logger.debug('User authentication failed:', error.message) |
| } |
| } |
|
|
| |
| logger.security(`🔒 Authentication failed from ${req.ip || 'unknown'}`) |
| return res.status(401).json({ |
| error: 'Authentication required', |
| message: 'Please login as user or admin to access this resource' |
| }) |
| } catch (error) { |
| const authDuration = Date.now() - startTime |
| logger.error(`❌ User/Admin authentication error (${authDuration}ms):`, { |
| error: error.message, |
| ip: req.ip, |
| userAgent: req.get('User-Agent'), |
| url: req.originalUrl |
| }) |
|
|
| return res.status(500).json({ |
| error: 'Authentication error', |
| message: 'Internal server error during authentication' |
| }) |
| } |
| } |
|
|
| |
| const requireRole = (allowedRoles) => (req, res, next) => { |
| |
| if (req.admin) { |
| return next() |
| } |
|
|
| |
| if (req.user) { |
| const userRole = req.user.role |
| const allowed = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles] |
|
|
| if (allowed.includes(userRole)) { |
| return next() |
| } else { |
| logger.security( |
| `🚫 Access denied for user ${req.user.username} (role: ${userRole}) to ${req.originalUrl}` |
| ) |
| return res.status(403).json({ |
| error: 'Insufficient permissions', |
| message: `This resource requires one of the following roles: ${allowed.join(', ')}` |
| }) |
| } |
| } |
|
|
| return res.status(401).json({ |
| error: 'Authentication required', |
| message: 'Please login to access this resource' |
| }) |
| } |
|
|
| |
| const requireAdmin = (req, res, next) => { |
| if (req.admin) { |
| return next() |
| } |
|
|
| |
| if (req.user && req.user.role === 'admin') { |
| return next() |
| } |
|
|
| logger.security( |
| `🚫 Admin access denied for ${req.user?.username || 'unknown'} from ${req.ip || 'unknown'}` |
| ) |
| return res.status(403).json({ |
| error: 'Admin access required', |
| message: 'This resource requires administrator privileges' |
| }) |
| } |
|
|
| |
| |
|
|
| |
| const corsMiddleware = (req, res, next) => { |
| const { origin } = req.headers |
|
|
| |
| const allowedOrigins = [ |
| 'http://localhost:3000', |
| 'https://localhost:3000', |
| 'http://127.0.0.1:3000', |
| 'https://127.0.0.1:3000' |
| ] |
|
|
| |
| const isChromeExtension = origin && origin.startsWith('chrome-extension://') |
|
|
| |
| if (allowedOrigins.includes(origin) || !origin || isChromeExtension) { |
| res.header('Access-Control-Allow-Origin', origin || '*') |
| } |
|
|
| res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') |
| res.header( |
| 'Access-Control-Allow-Headers', |
| [ |
| 'Origin', |
| 'X-Requested-With', |
| 'Content-Type', |
| 'Accept', |
| 'Authorization', |
| 'x-api-key', |
| 'x-goog-api-key', |
| 'api-key', |
| 'x-admin-token', |
| 'anthropic-version', |
| 'anthropic-dangerous-direct-browser-access' |
| ].join(', ') |
| ) |
|
|
| res.header('Access-Control-Expose-Headers', ['X-Request-ID', 'Content-Type'].join(', ')) |
|
|
| res.header('Access-Control-Max-Age', '86400') |
| res.header('Access-Control-Allow-Credentials', 'true') |
|
|
| if (req.method === 'OPTIONS') { |
| res.status(204).end() |
| } else { |
| next() |
| } |
| } |
|
|
| |
| const requestLogger = (req, res, next) => { |
| const start = Date.now() |
| const requestId = Math.random().toString(36).substring(2, 15) |
|
|
| |
| req.requestId = requestId |
| res.setHeader('X-Request-ID', requestId) |
|
|
| |
| const clientIP = req.ip || req.connection?.remoteAddress || req.socket?.remoteAddress || 'unknown' |
| const userAgent = req.get('User-Agent') || 'unknown' |
| const referer = req.get('Referer') || 'none' |
|
|
| |
| if (req.originalUrl !== '/health') { |
| |
| logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`) |
| } |
|
|
| res.on('finish', () => { |
| const duration = Date.now() - start |
| const contentLength = res.get('Content-Length') || '0' |
|
|
| |
| const logMetadata = { |
| requestId, |
| method: req.method, |
| url: req.originalUrl, |
| status: res.statusCode, |
| duration, |
| contentLength, |
| ip: clientIP, |
| userAgent, |
| referer |
| } |
|
|
| |
| if (res.statusCode >= 500) { |
| logger.error( |
| `◀️ [${requestId}] ${req.method} ${req.originalUrl} | ${res.statusCode} | ${duration}ms | ${contentLength}B`, |
| logMetadata |
| ) |
| } else if (res.statusCode >= 400) { |
| logger.warn( |
| `◀️ [${requestId}] ${req.method} ${req.originalUrl} | ${res.statusCode} | ${duration}ms | ${contentLength}B`, |
| logMetadata |
| ) |
| } else if (req.originalUrl !== '/health') { |
| logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata) |
| } |
|
|
| |
| if (req.apiKey) { |
| logger.api( |
| `📱 [${requestId}] Request from ${req.apiKey.name} (${req.apiKey.id}) | ${duration}ms` |
| ) |
| } |
|
|
| |
| if (duration > 5000) { |
| logger.warn( |
| `🐌 [${requestId}] Slow request detected: ${duration}ms for ${req.method} ${req.originalUrl}` |
| ) |
| } |
| }) |
|
|
| res.on('error', (error) => { |
| const duration = Date.now() - start |
| logger.error(`💥 [${requestId}] Response error after ${duration}ms:`, error) |
| }) |
|
|
| next() |
| } |
|
|
| |
| const securityMiddleware = (req, res, next) => { |
| |
| res.setHeader('X-Content-Type-Options', 'nosniff') |
| res.setHeader('X-Frame-Options', 'DENY') |
| res.setHeader('X-XSS-Protection', '1; mode=block') |
| res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin') |
|
|
| |
| res.setHeader('X-DNS-Prefetch-Control', 'off') |
| res.setHeader('X-Download-Options', 'noopen') |
| res.setHeader('X-Permitted-Cross-Domain-Policies', 'none') |
|
|
| |
| const host = req.get('host') || '' |
| const isLocalhost = |
| host.includes('localhost') || host.includes('127.0.0.1') || host.includes('0.0.0.0') |
| const isHttps = req.secure || req.headers['x-forwarded-proto'] === 'https' |
|
|
| if (isLocalhost || isHttps) { |
| res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') |
| res.setHeader('Cross-Origin-Resource-Policy', 'same-origin') |
| res.setHeader('Origin-Agent-Cluster', '?1') |
| } |
|
|
| |
| if (req.path.startsWith('/web') || req.path === '/') { |
| res.setHeader( |
| 'Content-Security-Policy', |
| [ |
| "default-src 'self'", |
| "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://unpkg.com https://cdn.tailwindcss.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://cdn.bootcdn.net", |
| "style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://cdnjs.cloudflare.com https://cdn.bootcdn.net", |
| "font-src 'self' https://cdnjs.cloudflare.com https://cdn.bootcdn.net", |
| "img-src 'self' data:", |
| "connect-src 'self'", |
| "frame-ancestors 'none'", |
| "base-uri 'self'", |
| "form-action 'self'" |
| ].join('; ') |
| ) |
| } |
|
|
| |
| if (req.secure || req.headers['x-forwarded-proto'] === 'https') { |
| res.setHeader('Strict-Transport-Security', 'max-age=15552000; includeSubDomains') |
| } |
|
|
| |
| res.removeHeader('X-Powered-By') |
| res.removeHeader('Server') |
|
|
| |
| res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') |
| res.setHeader('Pragma', 'no-cache') |
| res.setHeader('Expires', '0') |
|
|
| next() |
| } |
|
|
| |
| const errorHandler = (error, req, res, _next) => { |
| const requestId = req.requestId || 'unknown' |
| const isDevelopment = process.env.NODE_ENV === 'development' |
|
|
| |
| logger.error(`💥 [${requestId}] Unhandled error:`, { |
| error: error.message, |
| stack: error.stack, |
| url: req.originalUrl, |
| method: req.method, |
| ip: req.ip || 'unknown', |
| userAgent: req.get('User-Agent') || 'unknown', |
| apiKey: req.apiKey ? req.apiKey.id : 'none', |
| admin: req.admin ? req.admin.username : 'none' |
| }) |
|
|
| |
| let statusCode = 500 |
| let errorMessage = 'Internal Server Error' |
| let userMessage = 'Something went wrong' |
|
|
| if (error.status && error.status >= 400 && error.status < 600) { |
| statusCode = error.status |
| } |
|
|
| |
| switch (error.name) { |
| case 'ValidationError': |
| statusCode = 400 |
| errorMessage = 'Validation Error' |
| userMessage = 'Invalid input data' |
| break |
| case 'CastError': |
| statusCode = 400 |
| errorMessage = 'Cast Error' |
| userMessage = 'Invalid data format' |
| break |
| case 'MongoError': |
| case 'RedisError': |
| statusCode = 503 |
| errorMessage = 'Database Error' |
| userMessage = 'Database temporarily unavailable' |
| break |
| case 'TimeoutError': |
| statusCode = 408 |
| errorMessage = 'Request Timeout' |
| userMessage = 'Request took too long to process' |
| break |
| default: |
| if (error.message && !isDevelopment) { |
| |
| if (error.message.includes('ECONNREFUSED')) { |
| userMessage = 'Service temporarily unavailable' |
| } else if (error.message.includes('timeout')) { |
| userMessage = 'Request timeout' |
| } |
| } |
| } |
|
|
| |
| res.setHeader('X-Request-ID', requestId) |
|
|
| |
| const errorResponse = { |
| error: errorMessage, |
| message: isDevelopment ? error.message : userMessage, |
| requestId, |
| timestamp: new Date().toISOString() |
| } |
|
|
| |
| if (isDevelopment) { |
| errorResponse.stack = error.stack |
| errorResponse.url = req.originalUrl |
| errorResponse.method = req.method |
| } |
|
|
| res.status(statusCode).json(errorResponse) |
| } |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const globalRateLimit = async (req, res, next) => |
| |
| next() |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| const requestSizeLimit = (req, res, next) => { |
| const maxSize = 60 * 1024 * 1024 |
| const contentLength = parseInt(req.headers['content-length'] || '0') |
|
|
| if (contentLength > maxSize) { |
| logger.security(`🚨 Request too large: ${contentLength} bytes from ${req.ip}`) |
| return res.status(413).json({ |
| error: 'Payload Too Large', |
| message: 'Request body size exceeds limit', |
| limit: '10MB' |
| }) |
| } |
|
|
| return next() |
| } |
|
|
| module.exports = { |
| authenticateApiKey, |
| authenticateAdmin, |
| authenticateUser, |
| authenticateUserOrAdmin, |
| requireRole, |
| requireAdmin, |
| corsMiddleware, |
| requestLogger, |
| securityMiddleware, |
| errorHandler, |
| globalRateLimit, |
| requestSizeLimit |
| } |
|
|