Skip to main content

Cómo crear un gateway de API seguro en Node.js

Escrito por

Florian Rappl

28 de diciembre de 2022

0 minutos de lectura

Los microservicios ofrecen ventajas importantes frente a los monolitos. Puedes escalar el desarrollo con mayor facilidad y tener un control preciso sobre el escalamiento de la infraestructura. Además, la posibilidad de hacer muchas actualizaciones pequeñas y lanzamientos incrementales reduce considerablemente el tiempo de comercialización.

A pesar de estos beneficios, la arquitectura de microservicios presenta un problema: la imposibilidad de acceder a sus servicios desde el exterior. Por suerte, un gateway de API puede resolverlo.

Los gateways de API pueden ofrecer una ruta clara desde tus aplicaciones de front-end (por ejemplo, sitios web y aplicaciones nativas) a todas las funciones de back-end. Pueden actuar como agregadores de microservicios y como middleware para gestionar aspectos comunes, como la autenticación y la autorización. Además, facilitan la recopilación y unificación de servicios, al combinar formatos de salida como XML y JSON en un único formato.

Los gateways de API son una herramienta fundamental para la seguridad y la confiabilidad: ofrecen administración de sesiones, saneamiento de entradas, protección contra ataques de denegación de servicio distribuido (DDoS), limitación de la tasa de solicitudes y registro de transacciones.

En este artículo, crearemos desde cero un gateway de API seguro usando solo Node.js y un par de paquetes de código abierto. Solo necesitas conocimientos básicos de la terminal, Node.js versión 14 o posterior y JavaScript. Puedes encontrar el código final del proyecto aquí.

¡Comencemos!

Cómo crear nuestro gateway de API

Aunque podríamos escribir un servidor web en Node.js desde cero, aquí usaremos un framework existente para hacer el trabajo pesado. Podría decirse que Express es la opción más popular para Node.js, ya que es ligero, bastante rápido y fácil de extender. Además, puedes integrar en Express casi cualquier paquete que necesites.

Para empezar, creemos un nuevo proyecto de Node.js e instalemos Express:

npm init -y
npm install express --save

Con estos dos comandos, creamos un nuevo proyecto de Node.js con la configuración predeterminada. También instalamos el paquete express. En este punto, deberíamos encontrar un archivo package.json y un directorio node_modules en el directorio del proyecto.

Ahora tenemos que crear un archivo llamado index.js y colocarlo junto a package.json. El archivo debe tener el siguiente contenido 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}`);
});

Esta configuración debería bastar para ejecutar un servidor web básico con un endpoint. Puedes iniciarlo ejecutando el siguiente comando en la terminal:

node index.js

Ahora, ve a http://localhost:3000 desde tu navegador. Deberías ver “¡Hola, usuario!” en la pantalla. Si agregas una consulta a la URL, como ?name=King, deberías ver “¡Hola, King!”

El siguiente paso es configurar la autenticación. Para ello, usamos el paquete express-session. Este paquete usa una cookie para ayudarnos a proteger los endpoints. Los usuarios que no tengan una cookie de sesión válida recibirán una respuesta pertinente con el código de estado HTTP 401 (Unauthorized) o 403 (Forbidden).

También protegeremos un poco más nuestra contraseña guardándola en una variable de entorno. Usaremos el paquete dotenv para cargar la variable SESSION_SECRET desde un archivo .env en process.env.

Primero, asegúrate de detener el proceso anterior ingresando Ctrl + C en la consola. Luego, instala express-session y dotenv con el siguiente comando:

npm install express-session dotenv --save

Consulta la documentación de express-session en GitHub para obtener más instrucciones sobre cómo configurar y establecer una sesión segura, por ejemplo, establecer en secure el valor true para que la cookie se transmita solo a través de conexiones HTTPS. También debes implementar otros controles de seguridad relacionados con la administración de sesiones, como regenerar el identificador de sesión en operaciones sensibles, como iniciar sesión o cambiar la contraseña.

A continuación, crea un archivo .env en el directorio raíz del proyecto y agrega el siguiente código:

SESSION_SECRET=`<your_secret>`

Por convención, las variables de entorno se nombran en mayúsculas. Asegúrate de usar un secreto único y aleatorio; una contraseña segura será suficiente.

Nuestras adiciones a index.js deberían verse así (usaremos este fragmento más adelante al integrar la solución):

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,
  })
);

Con esta configuración, obtenemos una administración de sesiones basada en memoria que puedes usar para proteger endpoints. Vamos a usarla con un nuevo endpoint y agregar endpoints específicos para iniciar y cerrar sesión. Agrega el siguiente código al 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}!`);
});

Ahora, vuelve a ejecutar el código con el comando node index.js para probarlo.

¿Cómo funciona? Este es un flujo de uso sencillo:

  • Al ir a /, todo funcionará.

  • Al ir a /protected, se devolverá el código de estado HTTP 401 (Unauthorized).

  • Al ir a /login, iniciarás sesión automáticamente y aparecerá el mensaje “Autenticación exitosa”.

  • Si vuelves a ir a /login, aparecerá el mensaje “Ya se inició sesión”.

  • Ahora, al acceder a /protected, la página funcionará.

  • Al ir a /logout, se restablecerá la sesión y aparecerá el mensaje “Cierre de sesión exitoso”.

  • Después, /protected volverá a ser inaccesible y mostrará el código de estado 401.

  • Si vuelves a ir a /logout, también se devolverá el código de estado 401.

Al usar el middleware protect antes del controlador de solicitudes, podemos proteger un endpoint y asegurarnos de que el usuario haya iniciado sesión. Además, tenemos los endpoints /login y /logout para reforzar estas capacidades.

Volvamos a salir del proceso con Ctrl + C y, antes de hablar del núcleo de nuestro gateway de API, exploremos el registro y la limitación adecuada de la tasa de solicitudes.

Limitación de la tasa de solicitudes

La limitación de la tasa de solicitudes garantiza que solo se pueda acceder a tu API una cantidad determinada de veces durante un intervalo específico. Esto la protege del agotamiento del ancho de banda debido al tráfico normal y a los ataques DoS. Puedes configurar límites para el tráfico que se origina en fuentes específicas, y existen muchas maneras de calcular y aplicar la ventana de tiempo durante la cual se procesarán las solicitudes.

Primero, tenemos que instalar un paquete para limitar la tasa de solicitudes:

npm install express-rate-limit --save

Luego, configuramos el paquete. Asegúrate de insertar este código en el archivo index.js antes de las rutas a las que quieras aplicar el límite:

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

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

Ahora podemos probarlo reiniciando el servidor con el comando node index.js y accediendo varias veces a nuestro endpoint inicial. Cuando alcances el límite de cinco solicitudes dentro de un período de 15 minutos, deberías ver el mensaje “Demasiadas solicitudes. Inténtalo de nuevo más tarde”. De forma predeterminada, express-rate-limit también devuelve el código de estado HTTP correcto: 429 (Too Many Requests).

Antes de pasar al siguiente paso, asegúrate de salir del proceso para restablecer el límite.

Registro

Para configurar el registro, podemos usar winston, que también incluye un middleware específico para Express: express-winston. Además, quizá queramos registrar los tiempos de respuesta de los endpoints para analizarlos más adelante. Un paquete útil para esto es response-time. Instalemos estos paquetes.

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

La integración de estos paquetes es sencilla. Agrega el siguiente código a index.js antes del código que quieras registrar; lo mejor es insertarlo antes del código de limitación de solicitudes:

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;
    },
  })
);

Con esta configuración, obtenemos información adicional en la salida. Ahora, al iniciar el servidor e ir a /, la consola debería verse así:

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

Cuando quieras continuar, sal del proceso con Ctrl + C.

Intercambio de recursos de origen cruzado (CORS)

Como usamos nuestro gateway de API como capa entre los servicios de front-end y back-end, aquí gestionaremos el intercambio de recursos de origen cruzado (CORS). CORS es un mecanismo de seguridad basado en el navegador que garantiza que el back-end acepte ciertas solicitudes de recursos de origen cruzado (por ejemplo, solicitudes de www.company.com a api.company.com).

Para lograrlo, hacemos una solicitud especial antes de la solicitud principal. Esta solicitud usa el verbo HTTP OPTION HTTP y espera encabezados especiales en la respuesta para permitir o rechazar las solicitudes posteriores.

Para habilitar CORS en nuestro gateway, podemos instalar el paquete cors. En este punto, también podemos agregar más encabezados de seguridad usando un paquete útil llamado helmet. Podemos instalar ambos paquetes con el siguiente código:

npm install cors helmet --save

Podemos integrarlos así:

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

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

Esta configuración permite que todos los dominios accedan a la API. También podríamos definir una configuración más precisa, pero por ahora basta con la anterior.

Proxy

La función principal de un gateway de API es reenviar solicitudes a otros microservicios dedicados para enrutar solicitudes de lógica de negocio y otras solicitudes HTTP. Por eso, necesitamos un paquete que gestione este reenvío: un proxy. Usaremos http-proxy-middleware, que puedes instalar con el siguiente código:

npm install http-proxy-middleware --save

Ahora lo agregaremos junto con un nuevo endpoint:

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

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

Puedes probarlo iniciando el servidor y accediendo a /search?q=x&format=json. Esto devuelve los resultados obtenidos al enviar la solicitud a través de un proxy a http://api.duckduckgo.com/. Si ejecutas el código, asegúrate de salir del proceso cuando termines para poder continuar con los cambios finales.

Configuración

Ahora que tenemos todas las piezas, configurémoslas para que funcionen juntas. Para hacerlo, creemos un archivo nuevo llamado config.js en el mismo directorio que los demás archivos:

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`]: "",
    },
  },
};

Aquí creamos la configuración esencial de nuestro gateway de API. Definimos su puerto, la clave de cifrado de la sesión de cookies y los distintos endpoints a los que se enviará el tráfico mediante un proxy. Las opciones de cada proxy coinciden con las de la función createProxyMiddleware, con el agregado de la clave protected.

Actualicemos también las declaraciones de secret y port, así como la llamada a rateLimit en index.js, para que apunten a los valores definidos en nuestro archivo de configuración:

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

También tenemos que eliminar el código cuya funcionalidad trasladamos a config.js, así como el código inicial de prueba del endpoint. Elimina el siguiente 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}!`);
});

Aplicación final

Revisemos el contenido de nuestro archivo final index.js, que incluye un par de pequeñas adiciones:

// 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();

Agregaremos lógica para comprobar los valores de la propiedad protected de los proxies que enumeramos en config.js. Si están establecidos en false, la función alwaysAllow que definimos aquí pasa el control al siguiente controlador:

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

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

Algunas tecnologías de servidor heredadas también incluyen en el encabezado HTTP datos descriptivos del servidor que no cumplen ninguna función. Para mantener segura nuestra API, los eliminaremos y así revelaremos menos información a posibles atacantes:

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");
  }
});

A continuación, iteramos sobre los proxies enumerados en config.js, comprobamos el valor de su parámetro protected, llamamos a la función protect o alwaysAllow que definimos antes y agregamos un proxy por 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 ya es bastante flexible, pero usa HTTP estándar en lugar de HTTPS. En muchos casos, esto podría ser suficiente. Por ejemplo, en Kubernetes, el código podría estar detrás de un ingress de NGINX, que gestiona TLS y se encarga del certificado.

Sin embargo, a veces podríamos querer exponer directamente a Internet el código en ejecución. En ese caso, necesitaríamos un certificado válido.

Un proveedor de certificados como Let’s Encrypt ofrece varias formas de habilitar HTTPS. Puedes usar un cliente como Greenlock Express para administrar automáticamente tus certificados o implementar un enfoque más detallado con un cliente como 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);

Ten en cuenta que esto requerirá sudo, pero de todos modos se da por sentado, ya que la única razón para usar HTTPS aquí es que el servidor está expuesto directamente. Por lo tanto, necesitamos el puerto 443 en lugar de uno público, como el 8443.

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

Conclusión

Ya terminamos de crear una puerta de enlace de API segura desde cero con Node.js. Implementamos funciones como gestión de sesiones, limitación de solicitudes, proxies, registro y CORS. También aprendimos a configurar Node.js para usar TLS y exigir el acceso mediante HTTPS.

Usar una puerta de enlace de API ofrece ventajas importantes para las infraestructuras de back-end más grandes. Al ocultar los servicios de back-end detrás de una puerta de enlace de API, podemos aplicar fácilmente protocolos de seguridad comunes, simplificar considerablemente nuestros servicios y protegerlos de vulnerabilidades cotidianas.

Publicado en: