Desde su v1.0, Deno se ha convertido en una palabra de moda para los desarrolladores de Javascript/Typecript/Node.js. Entremos en esta tecnología creando una Rest API segura usando JWT.
⚠️ Es preferible tener algunos conceptos básicos en Node y su ecosistema (Express, Nodemon, Sequelize, etc.) para seguir este tutorial.
Deno es un entorno de ejecución simple, moderno y seguro para JavaScript y TypeScript que usa V8 y está construido en Rust. Ya hay muchos artículos que detallan el tema, por lo que no me detendré en ello. Puedo recomendar este.
Desde el lanzamiento oficial de su V1, Deno se ha convertido en la "palabra de moda" de sus últimas semanas (solo por diversión, aquí está la curva de popularidad de la búsqueda "deno" en Google)
Pero, ¿qué es posible hacer con este "Entorno de ejecución seguro para Typecript y Javascript"?
Para comprender mejor y dar mi opinión sobre este proyecto en crecimiento, decidí crear una API REST segura con JWT y compartir mis sentimientos.
Estoy acostumbrado a trabajar con Node.js y Express.
El objetivo de este tutorial será recrear una API REST segura, lo que implica:
- Configurando y levantado de un servidor
- Creación de modelos con ORM y una base de datos
- CRUD para Usuarios
- Implementación de ruta segura con JWT
Para crear nuestra Rest API seguro con JWT, usaré:
- Deno (recomiendo la documentación oficial para instalarlo: aquí)
- VSCode y el complemento de soporte Deno, disponible aquí
Además usare los siguientes paquetes, (volveré sobre esto a lo largo del tutorial):
Primero, configuremos la arquitectura de archivos para mantener ciertas pautas para tener un proyecto limpio y "listo para producción".
|-- deno-rest-jwt
|-- controllers/
| |-- database/
| |-- models/
|-- helpers/
|-- middlewares/
|-- routers/
|-- app.ts
Si hubiéramos estado en una aplicación Node + Express, habría usado Nodemon para facilitar el desarrollo al reiniciar mi servidor automáticamente después de los cambios en el código.
Nodemon es una herramienta que ayuda a desarrollar aplicaciones basadas en node.js al reiniciar automáticamente la aplicación de Node.js cuando se detectan cambios en el directorio.
Para mantener la misma "comodidad de desarrollo", decidí usar Denon, su homónimo para Deno.
deno install --allow-read --allow-run --allow-write -f --unstable https://deno.land/x/denon/denon.ts
Personalicemos un poco la configuración de Denon. Esto será útil más adelante (especialmente para administrar variables de entorno).
// into denon.json
{
"$schema": "https://deno.land/x/denon/schema.json",
"env": {},
"scripts": {
"start": {
"cmd": "deno run app.ts"
}
}
}
¡Ahora estamos listos para comenzar a codificar en buenas condiciones! Para iniciar Denon, simplemente escriba denon start
en su terminal/consola:
➜ denon start
[denon] v2.0.2
[denon] watching path(s): *.*
[denon] watching extensions: ts,js,json
[denon] starting `deno run app.ts`
Compile file:///deno-rest-jwt/app.ts
[denon] clean exit - waiting for changes before restart
Puede ver que nuestro servidor se está ejecutando... ¡pero se bloquea! Es normal, no tiene código para ejecutar en app.ts
.
Decidí usar el framework Oak ya que no es muy escuchado en nuestro entorno.
Oak es un framework de middleware para el servidor http de Deno, que incluye un middleware enrutador. Este framework de middleware está inspirado en Koa y el enrutador de middleware inspirado en @koa/router.
Inicialicemos nuestro servidor con Oak:
// app.ts
import { Application, Router, Status } from "https://deno.land/x/oak/mod.ts";
// Inicialicemos la app
const app = new Application();
// Inicialicemos el enrutador
const router = new Router();
// Creamos la primera ruta por default
router.get("/", (ctx) => {
ctx.response.status = Status.OK;
ctx.response.body = { message: "Todo esta nitído!" };
});
app.use(router.routes());
app.use(router.allowedMethods());
console.log("🚀 Deno esta Online!");
await app.listen("127.0.0.1:3001");
Ahora si ejecutamos nuestro servidor con denon start
error: Uncaught PermissionDenied: network access to "127.0.0.1:3001", run again with the --allow-net flag
Esta es una de las grandes diferencias entre Deno y Node.js: Deno es seguro de forma predeterminada y no obtiene acceso a la red de manera implícita. Tienes que autorizarlo:
// en denon.json
"scripts": {
"start": {
// add --allow-net
"cmd": "deno run --allow-net app.ts"
}
}
Ahora puede acceder desde su navegador (aunque le aconsejo que use Postman/Postwoman/Insomnia) a localhost:3001:
{
"message": "Todo esta nitído!"
}
Usaré DenoDB como ORM (en particular porque es compatible con Sqlite3). Más aún, se parece mucho a Sequelize.
Agreguemos el primer controlador llamado Database
y el archivo Sqlite3.
|-- deno-rest-jwt
|-- controllers/
| |-- Database.ts
| |-- database/
| | |-- db.sqlite
| |-- models/
|-- app.ts
// Database.ts
import { Database } from "https://deno.land/x/denodb/mod.ts";
export class DatabaseController {
client: Database;
/**
* Inicializar cliente de base de datos
*/
constructor() {
this.client = new Database("sqlite3", {
filepath: Deno.realPathSync("./controllers/database/db.sqlite"),
});
}
/**
* Inicializar modelos
*/
async initModels() {
this.client.link([]);
await this.client.sync({});
}
}
Nuestro ORM se inicializa. Puede notar que uso realPathSync
que requiere más permiso. Agreguemos --allow-read
y --allow-write
en nuestro denon.json
:
"scripts": {
"start": {
"cmd": "deno run --allow-write --allow-read --allow-net app.ts"
}
}
Todo lo que queda por hacer es crear el modelo User
a través de nuestro ORM:
|-- deno-rest-jwt
|-- controllers/
| |-- models/
| |-- User.ts
|-- app.ts
// User.ts
import { Model, DATA_TYPES } from "https://deno.land/x/denodb/mod.ts";
import nanoid from "https://deno.land/x/nanoid/mod.ts";
// Creamos un contrato con los atributos de User
export interface IUser {
id?: string;
firstName: string;
lastName: string;
password: string;
}
// Luego creamos la clase User que hereda de Model
export class User extends Model {
static table = "users";
static timestamps = true;
static fields = {
id: {
primaryKey: true,
type: DATA_TYPES.STRING,
},
firstName: {
type: DATA_TYPES.STRING,
},
lastName: {
type: DATA_TYPES.STRING,
},
password: {
type: DATA_TYPES.TEXT,
},
};
// En el atributo id se generará un nanoide por defecto
static defaults = {
id: nanoid(),
};
}
Nada nuevo aquí, así que no me detendré en eso. (ps: uso nanoid
para administrar mi UUID, te dejo leer este artículo muy interesante al respecto)
Aprovecho esta oportunidad para agregar una función que será útil más adelante: el hash de contraseña. Yo uso Bcrypt para hacer eso:
// dentro de la clase User
import * as bcrypt from "https://deno.land/x/bcrypt/mod.ts";
// ...
static async hashPassword(password: string) {
const salt = await bcrypt.genSalt(8);
return bcrypt.hash(password, salt);
}
Finalmente, vinculemos nuestro modelo a nuestro ORM:
// Database.ts
import { User } from "./models/User.ts";
export class DatabaseController {
//...
initModels() {
// Add User here
this.client.link([User]);
return this.client.sync({});
}
}
Bueno! Ahora que nuestro servidor y nuestra base de datos están en su lugar, es hora de inicializar las rutas para la creación de usuarios...
Nada más básico que un buen CRUD:
|-- deno-rest-jwt
|-- controllers/
| |-- Database.ts
| |-- UserController.ts
import * as bcrypt from "https://deno.land/x/bcrypt/mod.ts";
import { IUser, User } from "./models/index.ts";
export class UserController {
async create(values: IUser) {
// Llamanos al método estático para hashear el password
const password = await User.hashPassword(values.password);
const user: IUser = {
firstName: values.firstName,
lastName: values.lastName,
password,
};
await User.create(user as any);
return values;
}
async delete(id: string) {
await User.deleteById(id);
}
getAll() {
return User.all();
}
getOne(id: string) {
return User.where("id", id).first();
}
async update(id: string, values: IUser) {
await User.where("id", id).update(values as any);
return this.getOne(id);
}
async login(lastName: string, password: string) {
const user = await User.where("lastName", lastName).first();
if (!user || !(await bcrypt.compare(password, user.password))) {
return false;
}
// TODO aca generamos el JWT
}
}
Simplemente uso los métodos proporcionados por el ORM. Bueno, ahora solo tengo que gestionar la generación JWT.
Ahora es el momento de crear nuestras diferentes rutas y llamar a nuestro controlador recién codificado.
|-- deno-rest-jwt
|-- routers
|-- UserRoute.ts
El código es el siguiente:
import { Router, Status } from "https://deno.land/x/oak/mod.ts";
import { UserController } from "../controllers/UserController.ts";
import { BadRequest } from "../helpers/BadRequest.ts";
import { NotFound } from "../helpers/NotFound.ts";
// Instanciar nuestro controlador
const controller = new UserController();
export function UserRoutes(router: Router) {
return router
.get("/users", async (ctx) => {
const users = await controller.getAll();
if (users) {
ctx.response.status = Status.OK;
ctx.response.body = users;
} else {
ctx.response.status = Status.NotFound;
ctx.response.body = [];
}
return;
})
.post("/login", async (ctx) => {
if (!ctx.request.hasBody) {
return BadRequest(ctx);
}
const { value } = await ctx.request.body();
// TODO aca generamos el JWT
ctx.response.status = Status.OK;
ctx.response.body = { jwt };
})
.get("/users/:id", async (ctx) => {
if (!ctx.params.id) {
return BadRequest(ctx);
}
const user = await controller.getOne(ctx.params.id);
if (user) {
ctx.response.status = Status.OK;
ctx.response.body = user;
return;
}
return NotFound(ctx);
})
.post("/users", async (ctx) => {
if (!ctx.request.hasBody) {
return BadRequest(ctx);
}
const { value } = await ctx.request.body();
const user = await controller.create(value);
if (user) {
ctx.response.status = Status.OK;
ctx.response.body = user;
return;
}
return NotFound(ctx);
})
.patch("/users/:id", async (ctx) => {
if (!ctx.request.hasBody || !ctx.params.id) {
return BadRequest(ctx);
}
const { value } = await ctx.request.body();
const user = await controller.update(ctx.params.id, value);
if (user) {
ctx.response.status = Status.OK;
ctx.response.body = user;
return;
}
return NotFound(ctx);
})
.delete("/user/:id", async (ctx) => {
if (!ctx.params.id) {
return BadRequest(ctx);
}
await controller.delete(ctx.params.id);
ctx.response.status = Status.OK;
ctx.response.body = { message: "Ok" };
});
}
Todo lo que tenemos que hacer es invocar nuestra lógica desde nuestro controlador. Utilizo los métodos HTTP para definir claramente mis rutas. También he creado heloers para administrar mis respuestas de errores, los cuales seran mostrados al final de todo para no perder el hilo del proyecto. Todo lo que tenemos que hacer es llamar a nuestro enrutador en nuestra aplicación:
// app.ts
import { DatabaseController } from "./controllers/Database.ts";
import { UserRoutes } from "./routers/UserRoute.ts";
const userRoutes = UserRoutes(router);
app.use(userRoutes.routes());
app.use(userRoutes.allowedMethods());
await new DatabaseController().initModels();
¡Es hora de poner algo de seguridad en este proyecto! Usaré JWT para hacer eso.
En primer lugar, vamos a configurar un middleware de seguridad que:
- Comprueba si el encabezado "Autorización" existe en la consulta
- Lo recupera
- Comprueba su validez
- Devuelve un error/Acepta la solicitud y llama a la ruta privada
Usaré la biblioteca Djwt.
|-- deno-rest-jwt
|-- middlewares/
| |-- jwt.ts
Nuestra función tendrá que tomar en parámetros el contexto de la consulta, extraer el token de los encabezados, verificar su validez y actuar en consecuencia.
import { Context, Status } from "https://deno.land/x/oak/mod.ts";
import { validateJwt } from "https://deno.land/x/djwt/validate.ts";
/**
* Creamos una configuración predeterminada
*/
export const JwtConfig = {
header: "Authorization",
schema: "Bearer",
// usamos variable SECRET que debe estar en el contexto Env
secretKey: Deno.env.get("SECRET") || "",
expirationTime: 60000,
type: "JWT",
alg: "HS256",
};
export async function jwtAuth(
ctx: Context<Record<string, any>>,
next: () => Promise<void>
) {
// Obtenga el token de la solicitud
const token = ctx.request.headers
.get(JwtConfig.header)
?.replace(`${JwtConfig.schema} `, "");
// rechazar la solicitud si no se proporcionó el token
if (!token) {
ctx.response.status = Status.Unauthorized;
ctx.response.body = { message: "Unauthorized" };
return;
}
// verificar la validez del token
if (
!(await validateJwt(token, JwtConfig.secretKey, { isThrowing: false }))
) {
ctx.response.status = Status.Unauthorized;
ctx.response.body = { message: "Wrong Token" };
return;
}
// JWT es correcto, así que continúe y llame a la ruta privada
next();
}
Tenga en cuenta que necesitamos una clave secreta para cifrar nuestro token. Yo uso las variables de entorno Deno para eso. Por lo tanto, debemos realizar algunos cambios en nuestra configuración de Denon, explicitamente en nuestro denon.json
: agregar nuestra variable y permita que Deno recupere las variables de entorno.
{
"$schema": "<https://deno.land/x/denon/schema.json>",
// Agregar variable SECRET en env
"env": {
"SECRET": "HENRY_ES_LA_MERA_VERGA_EN_DENO"
},
"scripts": {
"start": {
// agregue el permiso --allow-env
"cmd": "deno run --allow-env --allow-write --allow-read --allow-net app.ts"
}
}
}
(ps: si desea proteger sus variables de entorno, le recomiendo este tutorial)
Entonces creemos nuestra ruta privada.
|-- deno-rest-jwt
|-- routers
|-- UserRoute.ts
|-- PrivateRoute.ts
Simplemente llame a nuestro método antes de llamar a nuestra ruta:
import { Router, Status } from "https://deno.land/x/oak/mod.ts";
import { jwtAuth } from "../middlewares/jwt.ts";
export function PrivateRoutes(router: Router) {
// llama a nuestro middleware antes de nuestra ruta privada
return router.get("/private", jwtAuth, async (ctx) => {
ctx.response.status = Status.OK;
ctx.response.body = { message: "Conectado satisfactoriamente!" };
});
}
No olvides agregarlo a nuestra aplicación:
// app.ts
import { PrivateRoutes } from "./routers/index.ts";
// ...
const privateRoutes = PrivateRoutes(router);
app.use(privateRoutes.routes());
app.use(privateRoutes.allowedMethods());
// ...
Si tratamos de llamar a nuestra API por el endpoint /privado
, obtenemos esta respuesta:
{
"message": "Unauthorized"
}
Ahora es el momento de configurar la generación de tokens cuando los usuarios inician sesión. Recuerde que dejamos un // TODO aca generamos el JWT
en nuestro controlador. Antes de completarlo, primero agregaremos un método estático a nuestro modelo User
para generar un token.
// User.ts
import {
makeJwt,
setExpiration,
Jose,
Payload,
} from "https://deno.land/x/djwt/create.ts";
import { JwtConfig } from "../../middlewares/jwt.ts";
// ...
export class User extends Model {
// ...
static generateJwt(id: string) {
// Cree el payload con la fecha de expiración (el token tiene una fecha de expiración) y el ID del usuario actual (puede agregar lo que desee)
const payload: Payload = {
id,
exp: setExpiration(new Date().getTime() + JwtConfig.expirationTime),
};
const header: Jose = {
alg: JwtConfig.alg as Jose["alg"],
typ: JwtConfig.type,
};
// devolver el token generado
return makeJwt({ header, payload, key: JwtConfig.secretKey });
}
// ...
}
Ahora llamemos a este método en nuestro controlador:
// UserController.ts
export class UserController {
// ...
async login(lastName: string, password: string) {
const user = await User.where("lastName", lastName).first();
if (!user || !(await bcrypt.compare(password, user.password))) {
return false;
}
// Llame a nuestro nuevo método estático.
return User.generateJwt(user.id);
}
}
Finalmente, agreguemos dicha lógica al enrutador:
// UserRoute.ts
// ...
.post("/login", async (ctx) => {
if (!ctx.request.hasBody) {
return BadRequest(ctx);
}
const { value } = await ctx.request.body();
// generar jwt
const jwt = await controller.login(value.lastName, value.password);
if (!jwt) {
return BadRequest(ctx);
}
ctx.response.status = Status.OK;
// y devolverlo
ctx.response.body = { jwt };
})
// ...
Ahora si tratamos de conectar tenemos:
// localhost:3001/login
{
"jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IlEyY0ZZcUxKWk5Hc0toN0FWV0hzUiIsImV4cCI6MTU5MDg0NDU2MDM5MH0.drQ3ay5_DYuXEOnH2Z0RKbhq9nZElWCMvmypjI4BjIk"
}
(No olvides crear una cuenta antes;))
Agreguemos este token a nuestros encabezados de Authorization
y llamemos nuevamente a nuestra ruta privada:
// localhost:3001/private con dicho token en los encabezados
{
"message": "Conectado satisfactoriamente!"
}
Excelente ! Esta creada nuestra Rest API de manera segura con JWT 👏
El código de los helpers que se usan en las respuestas del controlador son los siguientes:
// NotFound.ts
import { Context, Status } from "https://deno.land/x/oak/mod.ts";
export function NotFound(ctx: Context<any>) {
ctx.response.status = Status.NotFound;
ctx.response.body = { message: "Not found" };
return;
}
// BadRequest.ts
import { Context, Status } from "https://deno.land/x/oak/mod.ts";
export function BadRequest(ctx: Context<any>) {
ctx.response.status = Status.BadRequest;
ctx.response.body = { message: "Wrong params" };
return;
}
Puede encontrar este proyecto en mi Github: aquí (agrego la Colección Postman para hacer la solicitud).
Decidí compartir con ustedes mis sentimientos sobre Deno, podría darte una primera impresión:
- Importar módulos por URL es un poco intuitivo al principio: siempre desea hacer un
npm i
oyarn add
. Además, tenemos que ejecutar deno para almacenar en caché nuestras importaciones y solo entonces tenemos acceso a los recurso enlazados de manera "automática".
El módulo remoto XXX no se ha almacenado en caché
-
Siempre uso TypeScript en mis proyectos Javascript, por lo que no me perdí en absoluto al principio. Por el contrario, estoy bastante familiarizado con eso.
-
Punto interesante: permisos. Creo que es bueno que Deno requiera permiso para acceder a la red, por ejemplo. Como desarrollador, nos obliga a ser conscientes del acceso y los derechos de nuestro programa. (más seguro)
-
Al principio, estamos un poco confundidos acerca de dónde buscar paquetes (https://deno.land/x → 460 paquetes y NPM → + 1 millón)
-
Nunca puede estar seguro de si un paquete también funciona en Deno o no. Siempre desea acercarse a lo que sabe y usar en Node para transferirlo a Deno. No sé si es bueno o malo, pero para mí, sigue siendo JavaScript...
Alguna duda, solo investiga...