Creación de una API REST segura en Node.js
Publicado: 2022-03-11Las interfaces de programación de aplicaciones (API) están en todas partes. Permiten que el software se comunique con otras piezas de software, internas o externas, de manera consistente, lo cual es un ingrediente clave en la escalabilidad, sin mencionar la reutilización.
Es bastante común hoy en día que los servicios en línea tengan API públicas. Estos permiten que otros desarrolladores integren fácilmente funciones como inicios de sesión en redes sociales, pagos con tarjeta de crédito y seguimiento del comportamiento. El estándar de facto que usan para esto se llama Transferencia de estado representacional (REST).
Si bien se puede usar una multitud de plataformas y lenguajes de programación para la tarea, por ejemplo, ASP.NET Core, Laravel (PHP) o Bottle (Python), en este tutorial, crearemos un back-end API REST básico pero seguro usando la siguiente pila:
- Node.js, con el que el lector ya debería estar familiarizado
- Express, que simplifica enormemente la creación de tareas comunes del servidor web en Node.js y es una tarifa estándar en la creación de un back-end de API REST
- Mongoose, que conectará nuestro back-end a una base de datos MongoDB
Los desarrolladores que sigan este tutorial también deberían sentirse cómodos con la terminal (o el símbolo del sistema).
Nota: No cubriremos una base de código de front-end aquí, pero el hecho de que nuestro back-end esté escrito en JavaScript hace que sea conveniente compartir código (modelos de objetos, por ejemplo) en toda la pila.
Anatomía de una API REST
Las API REST se utilizan para acceder y manipular datos mediante un conjunto común de operaciones sin estado. Estas operaciones son parte integral del protocolo HTTP y representan la funcionalidad esencial de creación, lectura, actualización y eliminación (CRUD), aunque no de una manera clara uno a uno:
-
POST
(crear un recurso o proporcionar datos en general) -
GET
(recuperar un índice de recursos o un recurso individual) -
PUT
(crear o reemplazar un recurso) -
PATCH
(actualizar/modificar un recurso) -
DELETE
(quitar un recurso)
Usando estas operaciones HTTP y un nombre de recurso como dirección, podemos construir una API REST creando un punto final para cada operación. Y al implementar el patrón, tendremos una base estable y fácilmente comprensible que nos permitirá desarrollar el código rápidamente y mantenerlo después. Como se mencionó anteriormente, se usará la misma base para integrar funciones de terceros, la mayoría de las cuales también usan API REST, lo que hace que dicha integración sea más rápida.
Por ahora, ¡comencemos a crear nuestra API REST segura usando Node.js!
En este tutorial, vamos a crear una API REST bastante común (y muy práctica) para un recurso llamado users
.
Nuestro recurso tendrá la siguiente estructura básica:
-
id
(un UUID generado automáticamente) -
firstName
-
lastName
-
email
-
password
- nivel de
permissionLevel
(¿qué puede hacer este usuario?)
Y crearemos las siguientes operaciones para ese recurso:
-
POST
en el punto final/users
(crear un nuevo usuario) -
GET
en el punto final/users
(enumere todos los usuarios) -
GET
en el punto final/users/:userId
(obtener un usuario específico) -
PATCH
en el punto final/users/:userId
(actualizar los datos para un usuario específico) -
DELETE
en el punto final/users/:userId
(eliminar un usuario específico)
También usaremos tokens web JSON (JWT) para tokens de acceso. Para ello, crearemos otro recurso llamado auth
que esperará el correo electrónico y la contraseña de un usuario y, a cambio, generará el token utilizado para la autenticación en determinadas operaciones. (El gran artículo de Dejan Milosevic sobre JWT para aplicaciones REST seguras en Java entra en más detalles sobre esto; los principios son los mismos).
Configuración del tutorial de la API REST
En primer lugar, asegúrese de tener instalada la última versión de Node.js. Para este artículo, usaré la versión 14.9.0; también puede funcionar en versiones anteriores.
A continuación, asegúrese de tener MongoDB instalado. No explicaremos los detalles de Mongoose y MongoDB que se usan aquí, pero para que los conceptos básicos funcionen, simplemente inicie el servidor en modo interactivo (es decir, desde la línea de comandos como mongo
) en lugar de como un servicio. Esto se debe a que, en un punto de este tutorial, necesitaremos interactuar con MongoDB directamente en lugar de a través de nuestro código Node.js.
Nota: Con MongoDB, no es necesario crear una base de datos específica como podría haber en algunos escenarios de RDBMS. La primera llamada de inserción de nuestro código Node.js activará su creación automáticamente.
Este tutorial no contiene todo el código necesario para un proyecto de trabajo. En cambio, se pretende que clone el repositorio complementario y simplemente siga los aspectos destacados a medida que lee, pero también puede copiar archivos y fragmentos específicos del repositorio según sea necesario, si lo prefiere.
Navegue a la carpeta rest-api-tutorial/
resultante en su terminal. Verá que nuestro proyecto contiene tres carpetas de módulos:
-
common
(manejo de todos los servicios compartidos y la información compartida entre los módulos de usuario) -
users
(todo lo relacionado con los usuarios) -
auth
(manejando la generación de JWT y el flujo de inicio de sesión)
Ahora, ejecute npm install
(o yarn
si lo tiene).
Felicitaciones, ahora tiene todas las dependencias y la configuración necesarias para ejecutar nuestro back-end API REST simple.
Creación del módulo de usuario
Usaremos Mongoose, una biblioteca de modelado de datos de objetos (ODM) para MongoDB, para crear el modelo de usuario dentro del esquema de usuario.
Primero, necesitamos crear el esquema Mongoose en /users/models/users.model.js
:
const userSchema = new Schema({ firstName: String, lastName: String, email: String, password: String, permissionLevel: Number });
Una vez que definimos el esquema, podemos adjuntar fácilmente el esquema al modelo de usuario.
const userModel = mongoose.model('Users', userSchema);
Después de eso, podemos usar este modelo para implementar todas las operaciones CRUD que queramos dentro de nuestros puntos finales Express.
Comencemos con la operación "crear usuario" definiendo la ruta en users/routes.config.js
:
app.post('/users', [ UsersController.insert ]);
Esto se introduce en nuestra aplicación Express en el archivo index.js
principal. El objeto UsersController
se importa desde nuestro controlador, donde codificamos la contraseña de manera adecuada, definida en /users/controllers/users.controller.js
:
exports.insert = (req, res) => { let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512',salt) .update(req.body.password) .digest("base64"); req.body.password = salt + "$" + hash; req.body.permissionLevel = 1; UserModel.createUser(req.body) .then((result) => { res.status(201).send({id: result._id}); }); };
En este punto, podemos probar nuestro modelo Mongoose ejecutando el servidor ( npm start
) y enviando una solicitud POST
a /users
con algunos datos JSON:
{ "firstName" : "Marcos", "lastName" : "Silva", "email" : "[email protected]", "password" : "s3cr3tp4sswo4rd" }
Hay varias herramientas que puedes usar para esto. Insomnia (tratado a continuación) y Postman son herramientas GUI populares, y curl
es una opción CLI común. Incluso puede usar JavaScript, por ejemplo, desde la consola de herramientas de desarrollo integrada de su navegador:
fetch('http://localhost:3600/users', { method: 'POST', headers: { "Content-type": "application/json" }, body: JSON.stringify({ "firstName": "Marcos", "lastName": "Silva", "email": "[email protected]", "password": "s3cr3tp4sswo4rd" }) }) .then(function(response) { return response.json(); }) .then(function(data) { console.log('Request succeeded with JSON response', data); }) .catch(function(error) { console.log('Request failed', error); });
En este punto, el resultado de una publicación válida será solo la identificación del usuario creado: { "id": "5b02c5c84817bf28049e58a3" }
. También debemos agregar el método createUser
al modelo en users/models/users.model.js
:
exports.createUser = (userData) => { const user = new User(userData); return user.save(); };
Todo listo, ahora necesitamos ver si el usuario existe. Para eso, vamos a implementar la función "obtener usuario por id" para el siguiente punto final: users/:userId
.
Primero, creamos una ruta en /users/routes/config.js
:
app.get('/users/:userId', [ UsersController.getById ]);
Luego, creamos el controlador en /users/controllers/users.controller.js
:
exports.getById = (req, res) => { UserModel.findById(req.params.userId).then((result) => { res.status(200).send(result); }); };
Y finalmente, agregue el método findById
al modelo en /users/models/users.model.js
:
exports.findById = (id) => { return User.findById(id).then((result) => { result = result.toJSON(); delete result._id; delete result.__v; return result; }); };
La respuesta será así:
{ "firstName": "Marcos", "lastName": "Silva", "email": "[email protected]", "password": "Y+XZEaR7J8xAQCc37nf1rw==$p8b5ykUx6xpC6k8MryDaRmXDxncLumU9mEVabyLdpotO66Qjh0igVOVerdqAh+CUQ4n/E0z48mp8SDTpX2ivuQ==", "permissionLevel": 1, "id": "5b02c5c84817bf28049e58a3" }
Tenga en cuenta que podemos ver la contraseña cifrada. Para este tutorial, mostramos la contraseña, pero la mejor práctica obvia es nunca revelar la contraseña, incluso si se ha cifrado. Otra cosa que podemos ver es el nivel de permissionLevel
, que usaremos para manejar los permisos de los usuarios más adelante.
Repitiendo el patrón presentado anteriormente, ahora podemos agregar la funcionalidad para actualizar el usuario. Usaremos la operación PATCH
ya que nos permitirá enviar solo los campos que queremos cambiar. Por lo tanto, la ruta será PATCH
a /users/:userid
y enviaremos los campos que queramos cambiar. También tendremos que implementar alguna validación adicional, ya que los cambios deben estar restringidos al usuario en cuestión o a un administrador, y solo un administrador debe poder cambiar el nivel de permissionLevel
. Omitiremos eso por ahora y volveremos a él una vez que implementemos el módulo de autenticación. Por ahora, nuestro controlador se verá así:
exports.patchById = (req, res) => { if (req.body.password){ let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512', salt).update(req.body.password).digest("base64"); req.body.password = salt + "$" + hash; } UserModel.patchUser(req.params.userId, req.body).then((result) => { res.status(204).send({}); }); };
De forma predeterminada, enviaremos un código HTTP 204 sin cuerpo de respuesta para indicar que la solicitud se realizó correctamente.
Y necesitaremos agregar el método patchUser
al modelo:
exports.patchUser = (id, userData) => { return User.findOneAndUpdate({ _id: id }, userData); };
La lista de usuarios se implementará como GET
en /users/
por el siguiente controlador:
exports.list = (req, res) => { let limit = req.query.limit && req.query.limit <= 100 ? parseInt(req.query.limit) : 10; let page = 0; if (req.query) { if (req.query.page) { req.query.page = parseInt(req.query.page); page = Number.isInteger(req.query.page) ? req.query.page : 0; } } UserModel.list(limit, page).then((result) => { res.status(200).send(result); }) };
El método del modelo correspondiente será:
exports.list = (perPage, page) => { return new Promise((resolve, reject) => { User.find() .limit(perPage) .skip(perPage * page) .exec(function (err, users) { if (err) { reject(err); } else { resolve(users); } }) }); };
La respuesta de la lista resultante tendrá la siguiente estructura:
[ { "firstName": "Marco", "lastName": "Silva", "email": "[email protected]", "password": "z4tS/DtiH+0Gb4J6QN1K3w==$al6sGxKBKqxRQkDmhnhQpEB6+DQgDRH2qr47BZcqLm4/fphZ7+a9U+HhxsNaSnGB2l05Oem/BLIOkbtOuw1tXA==", "permissionLevel": 1, "id": "5b02c5c84817bf28049e58a3" }, { "firstName": "Paulo", "lastName": "Silva", "email": "[email protected]", "password": "wTsqO1kHuVisfDIcgl5YmQ==$cw7RntNrNBNw3MO2qLbx959xDvvrDu4xjpYfYgYMxRVDcxUUEgulTlNSBJjiDtJ1C85YimkMlYruU59rx2zbCw==", "permissionLevel": 1, "id": "5b02d038b653603d1ca69729" } ]
Y la última parte que se implementará es DELETE
en /users/:userId
.
Nuestro controlador para la eliminación será:
exports.removeById = (req, res) => { UserModel.removeById(req.params.userId) .then((result)=>{ res.status(204).send({}); }); };
Igual que antes, el controlador devolverá el código HTTP 204 y ningún cuerpo de contenido como confirmación.

El método del modelo correspondiente debería verse así:
exports.removeById = (userId) => { return new Promise((resolve, reject) => { User.deleteMany({_id: userId}, (err) => { if (err) { reject(err); } else { resolve(err); } }); }); };
Ahora tenemos todas las operaciones necesarias para manipular el recurso de usuario y hemos terminado con el controlador de usuario. La idea principal de este código es brindarle los conceptos básicos del uso del patrón REST. Tendremos que volver a este código para implementar algunas validaciones y permisos, pero primero, tendremos que empezar a construir nuestra seguridad. Vamos a crear el módulo de autenticación.
Creación del módulo de autenticación
Antes de que podamos asegurar el módulo de users
implementando el middleware de permiso y validación, necesitaremos poder generar un token válido para el usuario actual. Generaremos un JWT en respuesta al usuario que proporcione un correo electrónico y una contraseña válidos. JWT es un notable token web JSON que puede usar para que el usuario realice de forma segura varias solicitudes sin validar repetidamente. Por lo general, tiene un tiempo de caducidad y se recrea un nuevo token cada pocos minutos para mantener la comunicación segura. Sin embargo, para este tutorial, renunciaremos a actualizar el token y lo mantendremos simple con un solo token por inicio de sesión.
Primero, crearemos un punto final para las solicitudes POST
al recurso /auth
. El cuerpo de la solicitud contendrá el correo electrónico y la contraseña del usuario:
{ "email" : "[email protected]", "password" : "s3cr3tp4sswo4rd2" }
Antes de involucrar al controlador, debemos validar al usuario en /authorization/middlewares/verify.user.middleware.js
:
exports.isPasswordAndUserMatch = (req, res, next) => { UserModel.findByEmail(req.body.email) .then((user)=>{ if(!user[0]){ res.status(404).send({}); }else{ let passwordFields = user[0].password.split('$'); let salt = passwordFields[0]; let hash = crypto.createHmac('sha512', salt).update(req.body.password).digest("base64"); if (hash === passwordFields[1]) { req.body = { userId: user[0]._id, email: user[0].email, permissionLevel: user[0].permissionLevel, provider: 'email', name: user[0].firstName + ' ' + user[0].lastName, }; return next(); } else { return res.status(400).send({errors: ['Invalid email or password']}); } } }); };
Habiendo hecho eso, podemos pasar al controlador y generar el JWT:
exports.login = (req, res) => { try { let refreshId = req.body.userId + jwtSecret; let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512', salt).update(refreshId).digest("base64"); req.body.refreshKey = salt; let token = jwt.sign(req.body, jwtSecret); let b = Buffer.from(hash); let refresh_token = b.toString('base64'); res.status(201).send({accessToken: token, refreshToken: refresh_token}); } catch (err) { res.status(500).send({errors: err}); } };
Aunque no actualizaremos el token en este tutorial, el controlador se configuró para habilitar dicha generación para que sea más fácil implementarlo en el desarrollo posterior.
Todo lo que necesitamos ahora es crear la ruta e invocar el middleware apropiado en /authorization/routes.config.js
:
app.post('/auth', [ VerifyUserMiddleware.hasAuthValidFields, VerifyUserMiddleware.isPasswordAndUserMatch, AuthorizationController.login ]);
La respuesta contendrá el JWT generado en el campo accessToken:
{ "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1YjAyYzVjODQ4MTdiZjI4MDQ5ZTU4YTMiLCJlbWFpbCI6Im1hcmNvcy5oZW5yaXF1ZUB0b3B0YWwuY29tIiwicGVybWlzc2lvbkxldmVsIjoxLCJwcm92aWRlciI6ImVtYWlsIiwibmFtZSI6Ik1hcmNvIFNpbHZhIiwicmVmcmVzaF9rZXkiOiJiclhZUHFsbUlBcE1PakZIRG1FeENRPT0iLCJpYXQiOjE1MjY5MjMzMDl9.mmNg-i44VQlUEWP3YIAYXVO-74803v1mu-y9QPUQ5VY", "refreshToken": "U3BDQXBWS3kyaHNDaGJNanlJTlFkSXhLMmFHMzA2NzRsUy9Sd2J0YVNDTmUva0pIQ0NwbTJqOU5YZHgxeE12NXVlOUhnMzBWMGNyWmdOTUhSaTdyOGc9PQ==" }
Habiendo creado el token, podemos usarlo dentro del encabezado de Authorization
usando el formulario Bearer ACCESS_TOKEN
.
Creación de middleware de permisos y validaciones
Lo primero que debemos definir es quién puede usar el recurso de los users
. Estos son los escenarios que tendremos que manejar:
- Público para la creación de usuarios (proceso de registro). No usaremos JWT para este escenario.
- Privado para el usuario que inició sesión y para que los administradores actualicen a ese usuario.
- Privado para administrador solo para eliminar cuentas de usuario.
Habiendo identificado estos escenarios, primero necesitaremos un middleware que siempre valide al usuario si está usando un JWT válido. El middleware en /common/middlewares/auth.validation.middleware.js
puede ser tan simple como:
exports.validJWTNeeded = (req, res, next) => { if (req.headers['authorization']) { try { let authorization = req.headers['authorization'].split(' '); if (authorization[0] !== 'Bearer') { return res.status(401).send(); } else { req.jwt = jwt.verify(authorization[1], secret); return next(); } } catch (err) { return res.status(403).send(); } } else { return res.status(401).send(); } };
Usaremos códigos de error HTTP para manejar errores de solicitud:
- HTTP 401 para una solicitud no válida
- HTTP 403 para una solicitud válida con un token no válido o un token válido con permisos no válidos
Podemos usar el operador AND bit a bit (máscara de bits) para controlar los permisos. Si configuramos cada permiso requerido como una potencia de 2, podemos tratar cada bit del entero de 32 bits como un solo permiso. Luego, un administrador puede tener todos los permisos configurando su valor de permiso en 2147483647. Ese usuario podría tener acceso a cualquier ruta. Como otro ejemplo, un usuario cuyo valor de permiso se estableció en 7 tendría permisos para los roles marcados con bits para los valores 1, 2 y 4 (dos elevados a 0, 1 y 2).
El middleware para eso se vería así:
exports.minimumPermissionLevelRequired = (required_permission_level) => { return (req, res, next) => { let user_permission_level = parseInt(req.jwt.permission_level); let user_id = req.jwt.user_id; if (user_permission_level & required_permission_level) { return next(); } else { return res.status(403).send(); } }; };
El middleware es genérico. Si el nivel de permiso del usuario y el nivel de permiso requerido coinciden en al menos un bit, el resultado será mayor que cero y podemos dejar que la acción continúe; de lo contrario, se devolverá el código HTTP 403.
Ahora, necesitamos agregar el middleware de autenticación a las rutas del módulo del usuario en /users/routes.config.js
:
app.post('/users', [ UsersController.insert ]); app.get('/users', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(PAID), UsersController.list ]); app.get('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(FREE), PermissionMiddleware.onlySameUserOrAdminCanDoThisAction, UsersController.getById ]); app.patch('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(FREE), PermissionMiddleware.onlySameUserOrAdminCanDoThisAction, UsersController.patchById ]); app.delete('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(ADMIN), UsersController.removeById ]);
Esto concluye el desarrollo básico de nuestra API REST. Todo lo que queda por hacer es probarlo todo.
Correr y probar con insomnio
Insomnia es un cliente REST decente con una buena versión gratuita. La mejor práctica es, por supuesto, incluir pruebas de código e implementar informes de errores adecuados en el proyecto, pero los clientes REST de terceros son excelentes para probar e implementar soluciones de terceros cuando el servicio de informes de errores y depuración no está disponible. Lo usaremos aquí para desempeñar el papel de una aplicación y obtener una idea de lo que está sucediendo con nuestra API.
Para crear un usuario, solo necesitamos POST
los campos requeridos en el punto final apropiado y almacenar la ID generada para su uso posterior.
La API responderá con el ID de usuario:
Ahora podemos generar el JWT usando el punto final /auth/
:
Deberíamos obtener un token como nuestra respuesta:
Tome accessToken
, prefijelo con Bearer
(recuerde el espacio) y agréguelo a los encabezados de solicitud en Authorization
:
Si no hacemos esto ahora que hemos implementado el middleware de permisos, todas las solicitudes que no sean de registro devolverán el código HTTP 401. Sin embargo, con el token válido en su lugar, obtendremos la siguiente respuesta de /users/:userId
:
Además, como se mencionó anteriormente, estamos mostrando todos los campos, con fines educativos y en aras de la simplicidad. La contraseña (hash o de otro tipo) nunca debe estar visible en la respuesta.
Intentemos obtener una lista de usuarios:
¡Sorpresa! Recibimos una respuesta 403.
Nuestro usuario no tiene los permisos para acceder a este punto final. Tendremos que cambiar el nivel de permissionLevel
de nuestro usuario de 1 a 7 (o incluso 5 sería suficiente, ya que nuestros niveles de permisos gratuitos y pagos se representan como 1 y 4, respectivamente). Podemos hacerlo manualmente en MongoDB, en su indicador interactivo , así (con la ID cambiada a su resultado local):
db.users.update({"_id" : ObjectId("5b02c5c84817bf28049e58a3")},{$set:{"permissionLevel":5}})
Luego, necesitamos generar un nuevo JWT.
Una vez hecho esto, obtenemos la respuesta adecuada:
A continuación, probemos la funcionalidad de actualización enviando una solicitud PATCH
con algunos campos a nuestro punto final /users/:userId
:
Esperamos una respuesta 204 como confirmación de una operación exitosa, pero podemos solicitar al usuario una vez más que verifique.
Finalmente, necesitamos eliminar el usuario. Tendremos que crear un nuevo usuario como se describe anteriormente (no olvide anotar el ID de usuario) y asegurarnos de que tenemos el JWT adecuado para un usuario administrador. El nuevo usuario necesitará que sus permisos se establezcan en 2053 (es decir, 2048— ADMIN
—más los 5 anteriores) para poder realizar también la operación de eliminación. Con eso hecho y un nuevo JWT generado, tendremos que actualizar nuestro encabezado de solicitud de Authorization
:
Al enviar una solicitud DELETE
a /users/:userId
, deberíamos obtener una respuesta 204 como confirmación. Podemos, nuevamente, verificar solicitando /users/
para enumerar todos los usuarios existentes.
Próximos pasos para su API REST
Con las herramientas y los métodos cubiertos en este tutorial, ahora debería poder crear API REST simples y seguras en Node.js. Se omitieron muchas mejores prácticas que no son esenciales para el proceso, así que no olvide:
- Implemente validaciones adecuadas (p. ej., asegúrese de que el correo electrónico del usuario sea único)
- Implementar pruebas unitarias y generación de informes de errores.
- Impedir que los usuarios cambien su propio nivel de permiso
- Impedir que los administradores se eliminen a sí mismos
- Evitar la divulgación de información confidencial (p. ej., contraseñas hash)
- Mueva el secreto JWT de
common/config/env.config.js
a un mecanismo de distribución de secretos fuera del repositorio y no basado en el entorno.
Un ejercicio final para el lector puede ser convertir el código base de su uso de promesas de JavaScript a la técnica async/await.
Para aquellos de ustedes que puedan estar interesados, ahora también hay disponible una versión TypeScript del proyecto.