Skip to main content

Como criar um gateway de API seguro em Node.js

Escrito por

Florian Rappl

28 de dezembro de 2022

0 minutos de leitura

Os microsserviços oferecem vantagens significativas em relação aos monólitos. É mais fácil escalar o desenvolvimento e ter controle preciso sobre a escalabilidade da infraestrutura. Além disso, a possibilidade de fazer várias pequenas atualizações e implantações incrementais reduz significativamente o tempo de lançamento no mercado.

Apesar desses benefícios, a arquitetura de microsserviços apresenta um problema: a impossibilidade de acessar os serviços externamente. Felizmente, um gateway de API pode resolver essa questão.

Os gateways de API podem oferecer um caminho claro para que seus aplicativos de front-end (por exemplo, sites e aplicativos nativos) acessem todas as funcionalidades do back-end. Eles podem atuar como agregadores dos microsserviços e como middleware para lidar com questões comuns, como autenticação e autorização. Além disso, os gateways de API facilitam a coleta e a unificação de serviços, combinando formatos de saída como XML e JSON em um único formato.

Os gateways de API são ferramentas essenciais para segurança e confiabilidade, oferecendo gerenciamento de sessão, sanitização de entradas, proteção contra ataques de negação de serviço distribuída (DDoS), limitação de taxa e registro de transações.

Neste artigo, vamos criar do zero um gateway de API seguro usando apenas Node.js e alguns pacotes de código aberto. Você só precisa ter conhecimentos básicos de terminal, Node.js versão 14 ou posterior e JavaScript. Você encontra o código final do projeto aqui.

Vamos começar!

Criando nosso gateway de API

Embora pudéssemos escrever um servidor web em Node.js do zero, aqui vamos usar um framework existente para cuidar do trabalho pesado. Express é, provavelmente, a opção mais popular para Node.js, pois é leve, razoavelmente rápido e fácil de estender. Além disso, você pode integrar praticamente qualquer pacote necessário ao Express.

Para começar, vamos criar um novo projeto Node.js e instalar o Express:

npm init -y
npm install express --save

Com esses dois comandos, criamos um novo projeto Node.js usando as configurações padrão. Também instalamos o pacote express. Neste ponto, devemos encontrar um arquivo package.json e um diretório node_modules no diretório do projeto.

Agora, precisamos criar um arquivo chamado index.js e colocá-lo ao lado de package.json. O arquivo deve ter o seguinte conteúdo inicial:

const express = require("express");

const app = express();
const port = 3000;

app.get("/", (req, res) => {
  const { name = "user" } = req.query;
  res.send(`Hello ${name}!`);
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

Essa configuração deve ser suficiente para executar um servidor web básico com um endpoint. Você pode iniciá-lo executando o seguinte comando no terminal:

node index.js

Agora, acesse http://localhost:3000 no navegador. Você verá “Hello user!” na tela. Ao acrescentar uma consulta à URL, como ?name=King, você verá “Hello King!”

O próximo passo é configurar a autenticação. Para isso, usamos o pacote express-session. Esse pacote usa um cookie para nos ajudar a proteger endpoints. Usuários sem um cookie de sessão válido receberão uma resposta apropriada com o código de status HTTP 401 (Unauthorized) ou 403 (Forbidden).

Também vamos proteger um pouco mais nossa senha armazenando-a em uma variável de ambiente. Usaremos o pacote dotenv para carregar a variável SESSION_SECRET de um arquivo .env para process.env.

Primeiro, pare o processo anterior digitando Ctrl + C no console. Em seguida, instale express-session e dotenv com o seguinte comando:

npm install express-session dotenv --save

Consulte a documentação do express-session no GitHub para saber como configurar e estabelecer uma sessão segura. Por exemplo, defina a flag secure do cookie como true para que ele seja transmitido somente por conexões HTTPS. Também é importante considerar outros controles de segurança para o gerenciamento de sessões, como regenerar o identificador da sessão em operações confidenciais, como login e alteração de senha.

Em seguida, crie um arquivo .env no diretório raiz do projeto e adicione o seguinte código:

SESSION_SECRET=`<your_secret>`

Por convenção, os nomes das variáveis de ambiente são escritos em letras maiúsculas. Use também um segredo exclusivo e aleatório: uma senha forte funciona.

As alterações em index.js devem ficar assim (vamos usar este trecho mais adiante, ao montar a solução completa):

require("dotenv").config();

const session = require("express-session");

const secret = process.env.SESSION_SECRET;
const store = new session.MemoryStore();
const protect = (req, res, next) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    res.sendStatus(401);
  } else {
    next();
  }
};

app.use(
  session({
    secret,
    resave: false,
    saveUninitialized: true,
    store,
  })
);

Com essa configuração, temos o gerenciamento de sessões baseado em memória, que você pode usar para proteger endpoints. Vamos aplicá-lo a um novo endpoint e criar endpoints específicos para login e logout. Adicione o seguinte código ao final de index.js:

app.get("/login", (req, res) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    req.session.authenticated = true;
    res.send("Successfully authenticated");
  } else {
    res.send("Already authenticated");
  }
});

app.get("/logout", protect, (req, res) => {
  req.session.destroy(() => {
    res.send("Successfully logged out");
  });
});

app.get("/protected", protect, (req, res) => {
  const { name = "user" } = req.query;
  res.send(`Hello ${name}!`);
});

Agora, execute o código novamente para testar por conta própria, usando o comando node index.js.

Como isso funciona? Veja um fluxo de uso simples:

  • Acessar / funciona.

  • Acessar /protected retorna o código de status HTTP 401 (Unauthorized).

  • Acessar /login faz o login automaticamente e exibe “Successfully authenticated.”

  • Acessar /login novamente exibe “Already authenticated.”

  • Agora, ao acessar /protected, a página funciona.

  • Acessar /logout redefine a sessão e exibe “Successfully logged out.”

  • Em seguida, /protected volta a ficar inacessível e exibe o código de status 401.

  • Acessar /logout novamente também retorna o código de status 401.

Usando o middleware protect antes do handler da solicitação, podemos proteger um endpoint garantindo que o usuário tenha feito login. Além disso, temos os endpoints /login e /logout para reforçar esses recursos.

Vamos encerrar o processo com Ctrl + C novamente e, antes de falar sobre o núcleo do nosso gateway de API, explorar o registro de logs e a limitação de taxa adequada.

Limitação de taxa

A limitação de taxa garante que sua API só possa ser acessada um determinado número de vezes em um intervalo específico. Isso a protege contra o esgotamento da largura de banda causado pelo tráfego orgânico e por ataques DoS. Você pode configurar limites de taxa para o tráfego proveniente de fontes específicas. Há muitas maneiras de calcular e aplicar a janela de tempo em que as solicitações serão processadas.

Primeiro, precisamos instalar um pacote para limitar a taxa:

npm install express-rate-limit --save

Em seguida, configuramos o pacote. Insira este código no arquivo index.js, antes de quaisquer rotas que você queira limitar:

const rateLimit = require("express-rate-limit");

app.use(
  rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 5, // 5 calls
  })
);

Agora podemos testar. Reinicie o servidor com o comando node index.js e acesse nosso endpoint inicial várias vezes. Ao atingir o limite de cinco solicitações em um período de 15 minutos, você verá a mensagem “Too many requests, please try again later.” Por padrão, express-rate-limit também retorna o código de status HTTP correto: 429 (Too Many Requests).

Antes de passar para a próxima etapa, encerre o processo para redefinir o limite.

Registro de logs

Para configurar o registro de logs, podemos usar o winston, que também conta com um middleware específico para Express: express-winston. Além disso, podemos querer registrar os tempos de resposta dos endpoints para analisá-los com mais atenção depois. Um pacote útil para isso é o response-time. Vamos instalar esses pacotes.

npm install winston express-winston response-time --save

A integração desses pacotes é simples. Adicione o código a seguir ao index.js, antes de qualquer código que você queira registrar nos logs. O ideal é inseri-lo antes do código de limitação de taxa:

const winston = require("winston");
const expressWinston = require("express-winston");
const responseTime = require("response-time");

app.use(responseTime());

app.use(
  expressWinston.logger({
    transports: [new winston.transports.Console()],
    format: winston.format.json(),
    statusLevels: true,
    meta: false,
    msg: "HTTP {{req.method}} {{req.url}} {{res.statusCode}} {{res.responseTime}}ms",
    expressFormat: true,
    ignoreRoute() {
      return false;
    },
  })
);

Com essa configuração, temos algumas informações adicionais na saída. Ao iniciar o servidor e acessar /, o console deve ficar assim:

Server running at http://localhost:3000
{"level":"info","message":"GET / 200 8ms","meta":{}}
{"level":"warn","message":"GET /favicon.ico 404 3ms","meta":{}}

Quando quiser continuar, encerre o processo usando Ctrl + C.

Compartilhamento de recursos entre origens (CORS)

Como usamos nosso gateway de API como camada entre os serviços de front-end e back-end, vamos tratar aqui do compartilhamento de recursos entre origens (CORS). CORS é um mecanismo de segurança do navegador que garante que o back-end aceite determinadas solicitações de recursos entre origens (por exemplo, solicitações de www.company.com para api.company.com).

Para isso, fazemos uma solicitação especial antes da solicitação principal. Ela usa o verbo HTTP OPTION HTTP e espera cabeçalhos especiais na resposta para permitir ou bloquear as solicitações seguintes.

Para habilitar CORS no nosso gateway, podemos instalar o pacote cors. Neste ponto, também podemos adicionar mais cabeçalhos de segurança usando um pacote útil chamado helmet. Podemos instalar os dois pacotes com o código abaixo:

npm install cors helmet --save

Podemos integrá-los assim:

const cors = require("cors");
const helmet = require("helmet");

app.use(cors());
app.use(helmet());

Essa configuração permite que todos os domínios acessem a API. Também poderíamos definir configurações mais detalhadas, mas, por enquanto, a configuração acima é suficiente.

Proxy

A principal função de um gateway de API é encaminhar solicitações a outros microsserviços específicos para direcionar solicitações de lógica de negócios e outras solicitações HTTP. Por isso, precisamos de um pacote que faça esse encaminhamento: um proxy. Vamos usar http-proxy-middleware, que você pode instalar com o código abaixo:

npm install http-proxy-middleware --save

Agora, vamos adicioná-lo junto com um novo endpoint:

const { createProxyMiddleware } = require("http-proxy-middleware");

app.use(
  "/search",
  createProxyMiddleware({
    target: "http://api.duckduckgo.com/",
    changeOrigin: true,
    pathRewrite: {
      [`^/search`]: "",
    },
  })
);

Você pode testar iniciando o servidor e acessando /search?q=x&format=json, que retorna os resultados obtidos ao encaminhar a solicitação para http://api.duckduckgo.com/. Se executar o código, encerre o processo quando terminar para prosseguir com as alterações finais.

Configuração

Agora que temos todas as peças, vamos configurá-las para que funcionem em conjunto. Para isso, crie um novo arquivo chamado config.js no mesmo diretório dos outros arquivos:

require("dotenv").config();

exports.serverPort = 3000;
exports.sessionSecret = process.env.SESSION_SECRET;
exports.rate = {
  windowMs: 5 * 60 * 1000,
  max: 100,
};
exports.proxies = {
  "/search": {
    protected: true,
    target: "http://api.duckduckgo.com/",
    changeOrigin: true,
    pathRewrite: {
      [`^/search`]: "",
    },
  },
};

Aqui, criamos a configuração essencial do nosso gateway de API. Definimos a porta, a chave de criptografia do cookie de sessão e os diferentes endpoints para os quais encaminhar solicitações. As opções de cada proxy correspondem às opções da função createProxyMiddleware, com a inclusão da chave protected.

Também vamos atualizar as declarações de secret e port, além da chamada a rateLimit em index.js, para que apontem para os valores definidos no arquivo de configuração:

const secret = config.sessionSecret;
...
const port = config.serverPort;
...
app.use(rateLimit(config.rate));

Também precisamos remover o código cuja funcionalidade foi transferida para config.js, assim como o código inicial de teste do endpoint. Remova o seguinte código de index.js:

require("dotenv").config();
...
app.use(
 "/search",
 createProxyMiddleware({
   target: "http://api.duckduckgo.com/",
   changeOrigin: true,
   pathRewrite: {
 [`^/search`]: "",
   },
 })
);
...
app.get("/", (req, res) => {
 const { name = "user" } = req.query;
 res.send(`Hello ${name}!`);
});
...
app.get("/protected", protect, (req, res) => {
 const { name = "user" } = req.query;
 res.send(`Hello ${name}!`);
});

Aplicativo final

Vamos analisar o conteúdo do nosso arquivo final index.js, que tem algumas pequenas adições:

// import all the required packages
const cors = require("cors");
const express = require("express");
const session = require("express-session");
const rateLimit = require("express-rate-limit");
const expressWinston = require("express-winston");
const helmet = require("helmet");
const { createProxyMiddleware } = require("http-proxy-middleware");
const responseTime = require("response-time");
const winston = require("winston");
const config = require("./config");

// configure the application
const app = express();
const port = config.serverPort;
const secret = config.sessionSecret;
const store = new session.MemoryStore();

Vamos adicionar uma lógica para verificar os valores da propriedade protected dos proxies listados em config.js. Se estiverem definidos como false, a função alwaysAllow que definimos aqui transfere o controle para o próximo handler:

const alwaysAllow = (_1, _2, next) => {
  next();
};
const protect = (req, res, next) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    res.sendStatus(401);
  } else {
    next();
  }
};

Algumas tecnologias de servidor legadas também incluem no cabeçalho HTTP dados descritivos não funcionais sobre o servidor. Para manter nossa API segura e revelar menos informações a possíveis agentes mal-intencionados, vamos removê-los:

app.disable("x-powered-by");

app.use(helmet());

app.use(responseTime());

app.use(
  expressWinston.logger({
    transports: [new winston.transports.Console()],
    format: winston.format.json(),
    statusLevels: true,
    meta: false,
    level: "debug",
    msg: "HTTP {{req.method}} {{req.url}} {{res.statusCode}} {{res.responseTime}}ms",
    expressFormat: true,
    ignoreRoute() {
      return false;
    },
  })
);

app.use(cors());

app.use(rateLimit(config.rate));

app.use(
  session({
    secret,
    resave: false,
    saveUninitialized: true,
    store,
  })
);

app.get("/login", (req, res) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    req.session.authenticated = true;
    res.send("Successfully authenticated");
  } else {
    res.send("Already authenticated");
  }
});

Em seguida, percorremos os proxies listados em config.js, verificamos o valor do parâmetro protected, chamamos a função protect ou alwaysAllow que definimos antes e adicionamos um novo proxy para cada entrada configurada:

Object.keys(config.proxies).forEach((path) => {
  const { protected, ...options } = config.proxies[path];
  const check = protected ? protect : alwaysAllow;
  app.use(path, check, createProxyMiddleware(options));
});

app.get("/logout", protect, (req, res) => {
  req.session.destroy(() => {
    res.send("Successfully logged out");
  });
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

Este código já é bastante flexível, mas usa HTTP padrão em vez de HTTPS. Em muitos casos, isso pode ser suficiente. Por exemplo, no Kubernetes, o código pode ficar atrás de um ingress do NGINX, que gerencia o TLS e é responsável pelo certificado.

No entanto, às vezes podemos querer expor o código em execução diretamente à internet. Nesse caso, precisaríamos de um certificado válido.

Uma autoridade certificadora como a Let’s Encrypt oferece várias maneiras de habilitar HTTPS. Você pode usar um cliente como o Greenlock Express para gerenciar seus certificados automaticamente ou implementar uma abordagem mais detalhada com um cliente como o Publishlab acme-client.

const { readFileSync } = require('fs');
const { createServer } = require('https');

// assumes that the key and certificate are stored in a "cert" directory
const credentials = {
  key: readFileSync('cert/server.key', 'utf8'),
  cert: readFileSync('cert/server.crt', 'utf8'),
};

// here we use the express "app" to attach to the created server
const httpsServer = createServer(credentials, app);

Observe que isso exigirá sudo, mas esse requisito já é esperado, pois o único motivo para habilitar HTTPS aqui é que o servidor está exposto diretamente. Por isso, precisamos da porta 443, e não de uma porta pública como 8443.

// use standard HTTPS port
httpsServer.listen(443);

Conclusão

Concluímos a criação de um gateway de API seguro do zero, usando Node.js. Implementamos recursos como gerenciamento de sessões, limitação de requisições, proxies, registros de log e CORS. Também aprendemos a configurar o Node.js para usar TLS e exigir acesso por HTTPS.

O uso de um gateway de API traz vantagens importantes para infraestruturas de back-end maiores. Ao ocultar os serviços de back-end atrás de um gateway de API, podemos aplicar facilmente protocolos de segurança comuns, simplificando bastante nossos serviços e protegendo-os contra vulnerabilidades do dia a dia.

Publicado em: