Files
clube67_newwhats.local/newwhats.clube67.com/newwhats.local/plugins/ext-api/backend/apikey-auth.js
T
VPS 4 Deploy Agent 35188b4f9a feat(ext-api): chave de satélite e-mail-capable (REST + WS)
A integration_key do satélite (nwk_) passa a resolver por x-nw-email quando
presente (preserva multi-conta: cada usuário vê o WhatsApp do seu e-mail),
caindo no tenant dono do par quando ausente — igual no middleware REST e no
handshake do WS bridge. Valida o par (revoke) antes de resolver por email.
Permite migrar o scoreodonto da EXT_MASTER_KEY global p/ chave própria
(revogável/auditada), sem mudança de comportamento.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 03:56:25 +02:00

208 lines
8.7 KiB
JavaScript

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildApiKeyAuth = buildApiKeyAuth;
exports.resolvePairKey = resolvePairKey;
exports.isMasterKey = isMasterKey;
exports.resolveMasterEmail = resolveMasterEmail;
exports.resolveApiKey = resolveApiKey;
// ─── 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 * 60000;
const keyCache = new Map();
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 = 60000;
const RATE_MAX = 120;
const rateMap = new Map();
function checkRateLimit(key) {
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);
// ─── Factory ─────────────────────────────────────────────────────────────────
function buildApiKeyAuth(prisma) {
return async function apiKeyAuth(req, res, next) {
const key = req.headers['x-nw-key']?.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']?.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;
}
// ── Chave de SATÉLITE (PluginPair) ─────────────────────────────────────────
// Cada satélite registrado tem sua própria integration_key (nwk_...). Resolve
// o tenant dono do par; honra revoke e atualiza lastSeenAt. É o registry — e
// não uma única chave mestra global — autenticando o satélite.
if (key.startsWith('nwk_')) {
const owner = await resolvePairKey(prisma, key);
if (!owner) {
res.status(401).json({ error: 'Chave de satélite inválida ou revogada' });
return;
}
// Satélite confiável: com x-nw-email resolve por email (preserva multi-conta,
// ex.: cada usuário do scoreodonto vê o WhatsApp do seu e-mail); sem email,
// cai no tenant dono do par.
const email = req.headers['x-nw-email']?.trim().toLowerCase();
if (email) {
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;
}
req.extTenantId = owner;
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();
};
}
/**
* Resolve uma chave de SATÉLITE (PluginPair, nwk_...) para o tenantId dono do par.
* Retorna null se não existir, estiver revogada ou a conta inativa. Atualiza
* lastSeenAt em background e usa o mesmo cache das demais chaves.
*/
// Throttle das escritas de lastSeenAt (não cacheia a DECISÃO de auth — a checagem
// de revoke bate no banco a cada request, garantindo revoke instantâneo).
const pairTouch = new Map();
async function resolvePairKey(prisma, key) {
if (!key || !key.startsWith('nwk_'))
return null;
const pair = await prisma.pluginPair.findUnique({
where: { integrationKey: key },
select: { userId: true, revokedAt: true, user: { select: { isActive: true } } },
});
if (!pair || pair.revokedAt || !pair.user.isActive)
return null;
const now = Date.now();
if (now - (pairTouch.get(key) ?? 0) > 60000) {
pairTouch.set(key, now);
prisma.pluginPair.update({ where: { integrationKey: key }, data: { lastSeenAt: new Date() } }).catch(() => { });
}
return pair.userId;
}
/**
* 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.
*/
function isMasterKey(key) {
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:).
*/
async function resolveMasterEmail(prisma, email) {
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).
*/
async function resolveApiKey(prisma, key) {
if (!key)
return null;
// Chave de satélite (PluginPair) — resolve pelo registry.
if (key.startsWith('nwk_'))
return resolvePairKey(prisma, key);
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;
}
//# sourceMappingURL=apikey-auth.js.map