dbb4da8220
Satélite confiável acessa a conta de qualquer email via EXT_MASTER_KEY: - apikey-auth: helpers isMasterKey/resolveMasterEmail resolvem o tenant pelo User.email; middleware REST refatorado para usá-los. - ws-bridge: handshake do /api/ext/v1/stream trata a chave mestra + x-nw-email (antes só tabela apiKey → o túnel WS tomava 401). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
177 lines
6.4 KiB
TypeScript
177 lines
6.4 KiB
TypeScript
/**
|
|
* Middleware de autenticação para a External API.
|
|
*
|
|
* Aceita a integration_key via:
|
|
* - Header: x-nw-key: nw_...
|
|
*
|
|
* Resolve a chave para tenantId e injeta em req.extTenantId.
|
|
* Rate-limiting básico: max 120 req/min por chave (janela deslizante simples em memória).
|
|
*/
|
|
import type { Request, Response, NextFunction } from 'express'
|
|
import type { PrismaClient } from '@prisma/client'
|
|
|
|
// ─── Cache de API key (evita Prisma query em cada requisição) ────────────────
|
|
// Chaves são imutáveis durante a vida da sessão — TTL de 5 min é suficiente.
|
|
const KEY_CACHE_TTL = 5 * 60_000
|
|
interface KeyCacheEntry { tenantId: string; expiresAt: Date | null; cachedAt: number }
|
|
const keyCache = new Map<string, KeyCacheEntry>()
|
|
|
|
setInterval(() => {
|
|
const now = Date.now()
|
|
for (const [k, v] of keyCache.entries()) {
|
|
if (now - v.cachedAt > KEY_CACHE_TTL) keyCache.delete(k)
|
|
}
|
|
}, KEY_CACHE_TTL)
|
|
|
|
// ─── Rate limiter simples em memória (por chave) ─────────────────────────────
|
|
const RATE_WINDOW_MS = 60_000
|
|
const RATE_MAX = 120
|
|
|
|
interface RateEntry { count: number; resetAt: number }
|
|
const rateMap = new Map<string, RateEntry>()
|
|
|
|
function checkRateLimit(key: string): boolean {
|
|
const now = Date.now()
|
|
let entry = rateMap.get(key)
|
|
if (!entry || now > entry.resetAt) {
|
|
entry = { count: 1, resetAt: now + RATE_WINDOW_MS }
|
|
rateMap.set(key, entry)
|
|
return true
|
|
}
|
|
entry.count++
|
|
return entry.count <= RATE_MAX
|
|
}
|
|
|
|
// Limpeza periódica para não vazar memória
|
|
setInterval(() => {
|
|
const now = Date.now()
|
|
for (const [k, v] of rateMap.entries()) {
|
|
if (now > v.resetAt) rateMap.delete(k)
|
|
}
|
|
}, RATE_WINDOW_MS * 2)
|
|
|
|
// ─── Extend Express Request ───────────────────────────────────────────────────
|
|
declare global {
|
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
namespace Express {
|
|
interface Request {
|
|
extTenantId?: string
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Factory ─────────────────────────────────────────────────────────────────
|
|
export function buildApiKeyAuth(prisma: PrismaClient) {
|
|
return async function apiKeyAuth(req: Request, res: Response, next: NextFunction) {
|
|
const key = (req.headers['x-nw-key'] as string | undefined)?.trim()
|
|
|
|
if (!key) {
|
|
res.status(401).json({ error: 'Header x-nw-key ausente' })
|
|
return
|
|
}
|
|
|
|
if (!checkRateLimit(key)) {
|
|
res.status(429).json({ error: 'Rate limit excedido (120 req/min por chave)' })
|
|
return
|
|
}
|
|
|
|
// ── Chave MESTRA + x-nw-email: integração multi-conta por email ────────────
|
|
// Permite que um satélite confiável acesse a conta de QUALQUER email (resolve
|
|
// o tenant pelo User.email). O satélite deve enviar SEMPRE o email do usuário
|
|
// autenticado dele — nunca um email arbitrário do cliente.
|
|
if (isMasterKey(key)) {
|
|
const email = (req.headers['x-nw-email'] as string | undefined)?.trim().toLowerCase()
|
|
if (!email) { res.status(400).json({ error: 'x-nw-email ausente (chave mestra)' }); return }
|
|
const tenantId = await resolveMasterEmail(prisma, email)
|
|
if (!tenantId) { res.status(404).json({ error: `Conta não encontrada no motor para ${email}` }); return }
|
|
req.extTenantId = tenantId
|
|
next(); return
|
|
}
|
|
|
|
// Verifica cache antes de ir ao banco
|
|
const cached = keyCache.get(key)
|
|
if (cached && Date.now() - cached.cachedAt < KEY_CACHE_TTL) {
|
|
if (cached.expiresAt && cached.expiresAt < new Date()) {
|
|
res.status(401).json({ error: 'Chave expirada' })
|
|
return
|
|
}
|
|
req.extTenantId = cached.tenantId
|
|
next()
|
|
return
|
|
}
|
|
|
|
const apiKey = await prisma.apiKey.findUnique({
|
|
where: { key },
|
|
select: { tenantId: true, isActive: true, expiresAt: true },
|
|
})
|
|
|
|
if (!apiKey || !apiKey.isActive) {
|
|
res.status(401).json({ error: 'Chave inválida ou inativa' })
|
|
return
|
|
}
|
|
|
|
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
|
|
res.status(401).json({ error: 'Chave expirada' })
|
|
return
|
|
}
|
|
|
|
keyCache.set(key, { tenantId: apiKey.tenantId, expiresAt: apiKey.expiresAt, cachedAt: Date.now() })
|
|
req.extTenantId = apiKey.tenantId
|
|
next()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Indica se a chave recebida é a chave MESTRA de integração (EXT_MASTER_KEY).
|
|
* A resolução do tenant, nesse caso, é feita por email (x-nw-email) — ver
|
|
* resolveMasterEmail — nunca pela tabela apiKey.
|
|
*/
|
|
export function isMasterKey(key: string): boolean {
|
|
const MASTER = process.env.EXT_MASTER_KEY
|
|
return Boolean(MASTER && key === MASTER)
|
|
}
|
|
|
|
/**
|
|
* Resolve o tenant a partir do email (fluxo da chave mestra). Compartilhado
|
|
* entre o middleware REST e o handshake do WS. Retorna null se o email não
|
|
* tiver conta ativa no motor. Usa o mesmo cache (prefixo master:).
|
|
*/
|
|
export async function resolveMasterEmail(
|
|
prisma: PrismaClient,
|
|
email: string | undefined | null,
|
|
): Promise<string | null> {
|
|
const e = email?.trim().toLowerCase()
|
|
if (!e) return null
|
|
const cacheK = `master:${e}`
|
|
const c = keyCache.get(cacheK)
|
|
if (c && Date.now() - c.cachedAt < KEY_CACHE_TTL) return c.tenantId
|
|
const user = await prisma.user.findUnique({ where: { email: e }, select: { id: true, isActive: true } })
|
|
if (!user || !user.isActive) return null
|
|
keyCache.set(cacheK, { tenantId: user.id, expiresAt: null, cachedAt: Date.now() })
|
|
return user.id
|
|
}
|
|
|
|
/**
|
|
* Versão standalone que valida a chave sem passar pelo Express.
|
|
* Usada no handshake do WS (upgrade request).
|
|
*/
|
|
export async function resolveApiKey(
|
|
prisma: PrismaClient,
|
|
key: string,
|
|
): Promise<string | null> {
|
|
if (!key) return null
|
|
const cached = keyCache.get(key)
|
|
if (cached && Date.now() - cached.cachedAt < KEY_CACHE_TTL) {
|
|
if (cached.expiresAt && cached.expiresAt < new Date()) return null
|
|
return cached.tenantId
|
|
}
|
|
const apiKey = await prisma.apiKey.findUnique({
|
|
where: { key },
|
|
select: { tenantId: true, isActive: true, expiresAt: true },
|
|
})
|
|
if (!apiKey || !apiKey.isActive) return null
|
|
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) return null
|
|
keyCache.set(key, { tenantId: apiKey.tenantId, expiresAt: apiKey.expiresAt, cachedAt: Date.now() })
|
|
return apiKey.tenantId
|
|
}
|