En mi búsqueda de una solución de autenticación rápida, intuitiva y optimizada para mis aplicaciones Node.js, encontré escenarios que exigían una implementación rápida sin comprometer la funcionalidad.
Desde el registro y el inicio de sesión de usuarios hasta la gestión de contraseñas olvidadas, la actualización de datos de usuarios e incluso la eliminación de cuentas, busqué una solución integral que navegara sin problemas a través de estas interacciones esenciales de los usuarios.
Por lo tanto, mi artículo pretende presentar precisamente eso: un enfoque coherente que integre metodologías claras para implementar la autenticación y el almacenamiento en caché, garantizando un flujo de usuarios sólido y eficiente.
Aquí, omitiremos los procedimientos de instalación fundamentales y la creación de modelos, centrándonos directamente en las complejidades de la autenticación y el flujo de usuarios. Incluiremos todos los enlaces necesarios para obtener archivos de configuración a lo largo del artículo, garantizando un acceso perfecto a los recursos necesarios para la configuración.
Para esta implementación, aprovecharemos la versión 20.11.1 de Node.js junto con Knex, Express y Redis. Además, utilizaremos PostgreSQL como nuestra base de datos, que se organizará en contenedores y se organizará mediante Docker para una gestión perfecta.
El nombre de nuestra aplicación será user-flow-boilerplate
. Creemos esa carpeta y ejecutemos npm init -y
para generar package.json
básico.json
{ "name": "user-flow-boilerplate", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC" }
Paquete inicial.json
El siguiente paso es agregar las dependencias necesarias:
dependencias : npm i -S bcrypt body-parser cors dotenv express jsonwebtoken knex pg redis validator
dependencias de desarrollo :
npm i -D @babel/core @babel/eslint-parser @babel/plugin-transform-class-properties @babel/plugin-transform-runtime @babel/preset-env @babel/preset-typescript @faker-js/faker @types/bcrypt @types/body-parser @types/cors @types/express @types/jest @types/jsonwebtoken @types/node @types/node-cron @types/validator @typescript-eslint/eslint-plugin @typescript-eslint/parser babel-jest cross-env eslint eslint-config-prettier eslint-plugin-prettier jest nodemon npm-run-all prettier ts-jest ts-loader ts-node tsconfig-paths tslint typescript webpack webpack-cli webpack-node-externals
y agregue scripts que construirán y ejecutarán nuestra aplicación:
"scripts": { "start": "NODE_ENV=production node dist/bundle.js", "build": "NODE_ENV=production webpack --config webpack.config.js", "dev": "cross-env NODE_ENV=development && npm-run-all -p dev:*", "dev:build": "webpack --config webpack.config.js --watch", "dev:start": "nodemon --watch dist --exec node dist/bundle.js", "test": "NODE_ENV=test jest --config ./jest.config.js", "lint": "eslint ./src -c .eslintrc.json" },
Para garantizar el inicio fluido de nuestra aplicación, es esencial crear una carpeta src
y colocar nuestro archivo de punto de entrada inicial, index.ts
, dentro de ella.
require('dotenv').config(); import process from 'process'; import express from 'express'; import bodyParser from 'body-parser'; import cors from 'cors'; const app = express(); const PORT = process.env.PORT || 9999; app.use(bodyParser.json()); app.use(cors()); app.get('/api/v1/health', (req, res) => res.status(200).json({ message: 'OK' })); (async () => { try { app.listen(PORT, async () => { console.log(`Server is running on port ${PORT}`); }); } catch (error) { console.error('Failed to start server:', error); process.exit(1); } })();
Archivo de punto de entrada
Para el desarrollo, necesitamos tener configuraciones para typscript
, lint
, jest
, bable
, prettier
, nodemon
. Todos esos archivos los describí en el siguiente artículo: Creación de un servidor Node.js con Postgres y Knex en Express .
Después de configurar todos los ajustes y crear el punto de entrada, ejecutar npm run dev
debería iniciar el servidor y debería esperar ver un resultado similar al siguiente:
./src/index.ts 1.7 KiB [built] [code generated] external "dotenv" 42 bytes [built] [code generated] external "process" 42 bytes [built] [code generated] external "express" 42 bytes [built] [code generated] external "body-parser" 42 bytes [built] [code generated] external "cors" 42 bytes [built] [code generated] webpack 5.90.3 compiled successfully in 751 ms [nodemon] restarting due to changes... [nodemon] starting `node dist/bundle.js` Server is running on port 9999
A continuación, navegue hastaGET
, presione cmd + E
(en Mac, pero las claves dependen de su sistema operativo) y asígnele el nombre health
.
Agregue enter para la URL: {{BASE_URI}}/health
. Para BASE_URI
, agregue una nueva variable que usará en toda la colección: http://localhost:9999/api/v1
Luego, simplemente haga clic en el botón 'Enviar' y debería observar el cuerpo de la respuesta:
{ "message": "OK" }
Antes de seguir adelante, es fundamental tener nuestra base de datos en funcionamiento. Lo lograremos lanzándolo con docker-compose
. Para acceder y administrar la base de datos, puede utilizar varias plataformas de desarrollo como
Personalmente prefiero usar
Necesitamos un archivo .env
con las claves, contraseñas y nombres de prueba necesarios:
PORT=9999 WEB_HOST="localhost" # DB DB_HOST="localhost" DB_PORT=5432 DB_NAME="user_flow_boilerplate" DB_USER="username_123" DB_PASSWORD="SomeParole999" # User DEFAULT_PASSWORD="SomeParole999" JWT_SECRET="6f1d7e9b9ba56476ae2f4bdebf667d88eeee6e6c98c68f392ed39f7cf6e51c5a" # Test User TEST_EMAIL="[email protected]" TEST_USERNAME="test_username" TEST_PASSWORD="SomeParole999" # Redis REDIS_HOST="localhost" REDIS_PORT=6379 REDIS_DB=0 REDIS_PASSWORD="SomeParole999"
.env para conexión a base de datos, Redis y valores de prueba para semillas
No temas, generé aleatoriamente el JWT_SECRET
para ilustrarlo de una manera más auténtica. Entonces, creemos un archivo docker-compose.yml
en la raíz del proyecto:
version: '3.6' volumes: data: services: database: build: context: . dockerfile: postgres.dockerfile image: postgres:latest container_name: postgres environment: TZ: Europe/Madrid POSTGRES_DB: ${DB_NAME} POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} networks: - default volumes: - data:/var/lib/postgresql/data ports: - "5432:5432" restart: unless-stopped redis: image: redis:latest container_name: redis command: redis-server --requirepass ${REDIS_PASSWORD} networks: - default ports: - "6379:6379" restart: unless-stopped
archivo docker-compose con servicios
Vamos a poner en marcha dos servicios en Docker para una conectividad rápida. He simplificado este proceso para facilitar el acceso rápido a la base de datos o Redis, permitiéndonos recuperar datos de manera eficiente. Entonces, ejecutemos esos servicios docker-compose up
y debemos poder ver el resultado después del siguiente resultado docker ps
:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES e4bef95de1dd postgres:latest "docker-entrypoint.s…" About a minute ago Up About a minute 0.0.0.0:5432->5432/tcp postgres 365e3a68351a redis:latest "docker-entrypoint.s…" About a minute ago Up About a minute 0.0.0.0:6379->6379/tcp redis
Ahora, necesitamos crear el archivo src/@types/index.ts
donde almacenamos nuestros tipos para la aplicación:
export enum Role { Admin = 'admin', Blogger = 'blogger', } export type UserSession = { id: number; }; export type DatabaseDate = { created_at: Date; updated_at: Date; }; export type DefaultUserData = { role: Role; }; export interface User extends DatabaseDate { id: number; email: string; username: string; password: string; role: Role; }
tipos de servicio
En este momento, necesita tener knexfile.ts
en la raíz del proyecto y en la carpeta de la base de datos para la conexión, las migraciones y las semillas.
Dejé una explicación bastante detallada en el artículo Creación de un servidor Node.js con Postgres y Knex en Express sobre cómo migrar y enviar usuarios a la base de datos donde usamos esas variables de entorno.
Me gustaría verificar específicamente las migraciones para asegurarme de que estemos en la misma página. Ya lanzamos nuestros servicios y tenemos que poder verificar la conexión a la base de datos.
docker exec -it postgres psql -U username_123 user_flow_boilerplate
Si la conexión es buena, estará en la consola psql
. Ok, si la conexión no tiene problemas, entonces deberíamos poder migrar allí nuestras tablas. Ejecute knex migrate:latest
. Luego deberías observar las columnas recién agregadas en tu tabla users
dentro de la base de datos.
Sembrémoslo con datos falsos knex seed:run
y revisemos la tabla nuevamente.
Entonces, ahora estamos equipados para manipular la base de datos, permitiéndonos agregar, eliminar o actualizar usuarios según sea necesario.
Finalmente, podemos olvidarnos de la configuración y la preparación y centrarnos específicamente en el flujo de usuarios. Para eso, necesitamos crear un enrutador. Necesitamos manejar mediante ese enrutador las siguientes operaciones: login
, logout
, signup
, delete_user
, update_user
.
Para eso, en src/routes/index.ts
, agregue el siguiente código:
import { Router } from 'express'; import { authRouter } from 'src/routes/authRouter'; import { healthController } from 'src/controllers/healthController'; import { sessionController } from 'src/controllers/sessionController'; import { authMiddleware } from 'src/middlewares/authMiddleware'; import { userRouter } from 'src/routes/userRouter'; export const router = Router({ mergeParams: true }); router.get('/health', healthController); router.use('/auth', authRouter); router.get('/session', authMiddleware, sessionController); router.use('/user', authMiddleware, userRouter); router.use((_, res) => { return res.status(404).json({ message: 'Not Found' }); });
Archivo de rutas
Como puede ver, al principio agregamos la ruta /health
que ya verificamos. Entonces, actualicemos el punto de entrada para aplicar esas rutas allí. Primero, elimine get
anterior.
-> REMOVE -> app.get('/api/v1/health', (req, res) => res.status(200).json({ message: 'OK' }));
y agregue al principio del archivo:
import { router } from 'src/routes'; // ... app.use(cors()); app.use('/api/v1', router);
y cree el primer controlador para la verificación health
src/controllers/healthController.ts
con el código:
import { Request, Response } from 'express'; export const healthController = (_: Request, res: Response) => res.status(200).send('ok');
Controlador de salud
Ahora, volvamos al enrutador y verifiquemos qué tenemos para agregar más a las rutas. Necesitamos agregar dos archivos más: authRouter.ts
y userRouter.ts
import { Router } from 'express'; import { signUpController } from 'src/controllers/auth/signUpController'; import { loginController } from 'src/controllers/auth/loginController'; export const authRouter = Router(); authRouter.post('/signup', signUpController); authRouter.post('/login', loginController);
Enrutador de autenticación
import { Router } from 'express'; import { updateUserController } from 'src/controllers/user/updateUserController'; import { deleteUserController } from 'src/controllers/user/deleteUserController'; import { logoutController } from 'src/controllers/user/logoutController'; import { updatePasswordController } from 'src/controllers/user/updatePasswordController'; export const userRouter = Router(); userRouter.patch('/', updateUserController); userRouter.delete('/', deleteUserController); userRouter.post('/logout', logoutController); userRouter.post('/update-password', updatePasswordController);
Enrutador de usuario
He dividido esta lógica por motivos de legibilidad y responsabilidad para mantener una funcionalidad aislada. Todas esas rutas necesitan controladores donde manejaremos la lógica.
Las rutas de autenticación y de salud no necesitan middleware de autenticación, por lo que esas rutas no están protegidas, pero si no hay coincidencia, obtendremos un estado 404.
router.get('/health', healthController); router.use('/auth', authRouter);
Ahora, como hemos configurado todas las rutas, tenemos que configurar el modelo de usuario.
Utilizaré un modelo base para el modelo de usuario, a partir del cual reutilizaré los métodos CRUD. Si bien ya cubrí la creación de modelos en otrosrc/models/Model.ts
import { database } from 'root/database'; export abstract class Model { protected static tableName?: string; protected static get table() { if (!this.tableName) { throw new Error('The table name must be defined for the model.'); } return database(this.tableName); } public static async insert<Payload>(data: Payload): Promise<{ id: number; }> { const [result] = await this.table.insert(data).returning('id'); return result; } public static async updateOneById<Payload>( id: number, data: Payload ): Promise<{ id: number; }> { const [result] = await this.table.where({ id }).update(data).returning('id'); return result; } public static async delete(id: number): Promise<number> { return this.table.where({ id }).del(); } public static async findOneById<Result>(id: number): Promise<Result> { return this.table.where('id', id).first(); } public static async findOneBy<Payload, Result>(data: Payload): Promise<Result> { return this.table.where(data as string).first(); } }
modelo básico
Con el modelo base, tenemos que poder crear UserModel.ts
en la misma carpeta:
import { Model } from 'src/models/Model'; import { Role, User, DefaultUserData } from 'src/@types'; export class UserModel extends Model { static tableName = 'users'; public static async create<Payload>(data: Payload) { return super.insert<Payload & DefaultUserData>({ ...data, role: data.role || Role.Blogger, }); } public static findByEmail(email: string): Promise<User | null> { return this.findOneBy< { email: string; }, User >({ email }); } public static findByUsername(username: string): Promise<User | null> { return this.findOneBy< { username: string; }, User >({ username }); } }
Modelo de usuario
En el modelo de usuario, configuro role
de forma predeterminada si no se proporciona desde la carga útil. Y ahora que tenemos nuestros modelos listos, podemos proceder a utilizarlos dentro de nuestros controladores y middlewares.
El middleware de autenticación en una aplicación Node.js es responsable de autenticar las solicitudes entrantes, garantizando que provengan de usuarios válidos y autorizados.
Por lo general, intercepta solicitudes entrantes, extrae tokens de autenticación o credenciales y verifica su validez con un mecanismo de autenticación predefinido, como JWT (JSON Web Tokens) en este caso.
Si el proceso de autenticación tiene éxito, el middleware permite que la solicitud pase al siguiente controlador en el ciclo de solicitud-respuesta. Sin embargo, si la autenticación falla, responde con un código de estado HTTP apropiado (por ejemplo, 401 no autorizado) y, opcionalmente, proporciona un mensaje de error.
Cree la carpeta src/middlewares
y agregue allí un archivo authMiddleware.ts
con el siguiente código:
import { jwt } from 'src/utils/jwt'; import { Redis } from 'src/redis'; import type { Request, Response, NextFunction } from 'express'; import type { UserSession } from 'src/@types'; export async function authMiddleware(req: Request, res: Response, next: NextFunction) { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; const JWT_SECRET = process.env.JWT_SECRET; if (!token) return res.sendStatus(401); if (!JWT_SECRET) { console.error('JWT_SECRET Not Found'); return res.sendStatus(500); } if (!token) return res.status(401).json({ error: 'Token not provided' }); try { const userSession = await jwt.verify<UserSession>(token); if (!userSession) { return res.sendStatus(401); } const storedToken = await Redis.getSession(userSession.id); if (!storedToken || storedToken !== token) { return res.sendStatus(401); } req.user = userSession; next(); } catch (error) { console.error('JWT_ERROR', error); return res.sendStatus(401); } }
Archivo de middleware de autenticación
El middleware de autenticación extrae el token JWT del encabezado de la solicitud, verifica su validez utilizando la biblioteca JWT y comprueba si el token coincide con el almacenado en Redis.
Si el token es válido y coincide con el token almacenado, el middleware establece la sesión del usuario autenticado en el objeto de solicitud ( req.user
) y llama a la función next()
para pasar el control al siguiente middleware o controlador de ruta. De lo contrario, responde con un código de estado 401 que indica un error de autenticación.
Repasemos la utilidad de jwt. Cree en el archivo src/utils/jwt.ts
con el siguiente código:
require('dotenv').config(); import jsonwebtoken from 'jsonwebtoken'; const JWT_SECRET = process.env.JWT_SECRET as string; export const jwt = { verify: <Result>(token: string): Promise<Result> => { if (!JWT_SECRET) { throw new Error('JWT_SECRET not found in environment variables!'); } return new Promise((resolve, reject) => { jsonwebtoken.verify(token, JWT_SECRET, (error, decoded) => { if (error) { reject(error); } else { resolve(decoded as Result); } }); }); }, sign: (payload: string | object | Buffer): Promise<string> => { if (!JWT_SECRET) { throw new Error('JWT_SECRET not found in environment variables!'); } return new Promise((resolve, reject) => { try { resolve(jsonwebtoken.sign(payload, JWT_SECRET)); } catch (error) { reject(error); } }); }, };
Archivo de utilidad JWT
Esta utilidad desempeña un papel fundamental en el manejo de tokens web JSON dentro de la aplicación Node.js. El objeto jwt
exporta funciones para firmar y verificar JWT, aprovechando la biblioteca jsonwebtoken
. Estas funciones facilitan la creación y validación de JWT, esenciales para implementar mecanismos de autenticación en la aplicación.
La utilidad encapsula la funcionalidad para manejar JWT, garantizando mecanismos de autenticación seguros dentro de la aplicación Node.js mientras se adhiere a las mejores prácticas para la gestión de variables de entorno.
Se utiliza como base de datos, caché y intermediario de mensajes. Se utiliza habitualmente en una variedad de casos de uso, incluido el almacenamiento en caché, la gestión de sesiones, el análisis en tiempo real, las colas de mensajería, las tablas de clasificación y más.
Verificar el token de Redis sirve como una capa adicional de seguridad y validación para el token JWT. Profundicemos en la configuración. Para eso, cree en el archivo src/redis/index.ts
con el siguiente código:
require('dotenv').config({ path: '../../.env', }); import process from 'process'; import * as redis from 'redis'; const client = redis.createClient({ url: `redis://:${process.env.REDIS_PASSWORD}@${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`, }); client.on('error', error => console.error('Redis Client Error', error)); const connect = async () => { try { await client.connect(); console.log('Connected to Redis'); } catch (err) { console.error(`Could not connect to Redis: ${err}`); process.exit(1); } }; class Redis { public static setSession(userId: number, token: string) { if (!userId) throw new Error('userId is required'); if (!token) throw new Error('token is required'); try { return client.set(`session:${userId}`, token); } catch (error) { console.error(error); } } public static getSession(userId: number) { if (!userId) throw new Error('userId is required'); return client.get(`session:${userId}`); } public static deleteSession(userId: string) { if (!userId) throw new Error('userId is required'); try { return client.del(`session:${userId}`); } catch (error) { console.error(error); } } } export { client, connect, Redis };
Tienda de sesiones de Redis
Mediante Redis, almacenaremos y administraremos tokens de sesión de usuario. En el middleware de autenticación, después de verificar la autenticidad del token JWT, el middleware verifica si el token existe y coincide con el almacenado en Redis para la sesión de usuario correspondiente. Esto ayuda a garantizar que solo los usuarios válidos y autorizados puedan acceder a rutas protegidas.
Redis se utiliza como almacén de valores clave para mantener tokens de sesión de usuario. Cuando un usuario inicia sesión o se autentica, su token de sesión se almacena en Redis. Esto permite una recuperación rápida y eficiente de los tokens de sesión durante las comprobaciones de autenticación posteriores.
Redis se utiliza en el middleware de autenticación para una gestión eficiente de la sesión, mientras que el archivo relacionado con Redis maneja la configuración y la conexión al servidor de Redis y proporciona funciones para interactuar con Redis en otras partes de la aplicación.
Esta configuración garantiza mecanismos de autenticación seguros y confiables, con tokens de sesión de usuario almacenados y administrados en Redis.
La última parte es que tenemos que conectarnos a Redis en nuestro punto de entrada:
// all imports import * as Redis from 'src/redis'; const app = express(); const PORT = process.env.PORT || 9999; // middlewares (async () => { try { await Redis.connect(); app.listen(PORT, async () => { console.log(`Server is running on port ${PORT}`); }); } catch (error) { console.error('Failed to start server:', error); process.exit(1); } })();
Conéctate a Redis
Después de completar la preparación de la autenticación, ahora podemos centrarnos en los controladores.
Los controladores en las rutas ayudan a organizar la lógica de la aplicación al separar las preocupaciones y promover la mantenibilidad del código. Ya hemos creado el controlador para el control de estado. A continuación, procederemos a crear controladores para manejar las operaciones con el usuario.
El primer controlador que vamos a tomar es sessionController.ts
el cual tiene que estar en src/controllers
con el siguiente código:
import { Request, Response } from 'express'; import { UserModel } from 'src/models/UserModel'; import type { User } from 'src/@types'; export const sessionController = async (req: Request, res: Response) => { if (!req.user) return res.sendStatus(401); try { const user = await UserModel.findOneById<User>(req.user.id); if (user) { return res.status(200).json(user); } else { return res.sendStatus(401); } } catch (error) { return res.sendStatus(500); } };
Controlador de sesión
Este controlador tiene el propósito de manejar un punto final relacionado con la sesión, probablemente responsable de recuperar información sobre el usuario actualmente autenticado. Necesitamos este controlador por las siguientes razones:
Información de sesión del usuario: este controlador permite que la aplicación recupere información sobre la sesión del usuario, como su perfil de usuario u otros datos relevantes. Esta información puede ser útil para personalizar la experiencia del usuario o proporcionar contenido personalizado basado en el perfil del usuario.
Autenticación y autorización: al verificar si existe req.user
, el controlador garantiza que solo los usuarios autenticados puedan acceder al punto final. Esto ayuda a hacer cumplir las reglas de autenticación y autorización, garantizando que solo los usuarios autorizados puedan acceder a los datos confidenciales del usuario.
Recuperación del perfil de usuario: el controlador consulta la base de datos (utilizando UserModel
) para recuperar la información del usuario en función de su ID de sesión. Esto permite que la aplicación obtenga datos específicos del usuario de forma dinámica, proporcionando una experiencia personalizada para cada usuario. Esta parte definitivamente se puede mejorar con el caché de Redis:
import { Request, Response } from 'express'; import { UserModel } from 'src/models/UserModel'; import { Redis } from 'src/redis'; import type { User } from 'src/@types'; export const sessionController = async (req: Request, res: Response) => { if (!req.user) return res.sendStatus(401); try { const cachedProfile = await Redis.getSession(req.user.id); if (cachedProfile) { return res.status(200).json(JSON.parse(cachedProfile)); } else { const user = await UserModel.findOneById<User>(req.user.id); if (user) { await Redis.setSession(req.user.id, JSON.stringify(user), CACHE_EXPIRATION); return res.status(200).json(user); } else { return res.sendStatus(401); } } } catch (error) { console.error('Error retrieving user profile:', error); return res.sendStatus(500); } };
Archivo de controlador de sesión con sesión configurada de Redis
Definimos una constante CACHE_EXPIRATION
para especificar el tiempo de caducidad de la caché en segundos. En este ejemplo, está configurado en 3600 segundos (1 hora). Los datos almacenados en caché se actualizan periódicamente, lo que evita que se entreguen datos obsoletos a los usuarios y se mantiene la integridad de los datos dentro del caché.
Antes de proceder a crear signUpController
, que gestiona el proceso de registro para nuevos usuarios en nuestra aplicación, revisemos el esquema:
En nuestro caso, cuando intentamos registrarnos con un correo electrónico existente en la base de datos, priorizamos la privacidad del usuario al no revelar explícitamente si el usuario existe. En su lugar, informamos al cliente con un mensaje genérico que indica Invalid email or password
.
Este enfoque anima al cliente a enviar credenciales válidas sin revelar información innecesaria sobre los usuarios existentes.
Ahora creemos src/controllers/auth/signUpController.ts
y agreguemos el siguiente código:
import bcrypt from 'bcrypt'; import { jwt } from 'src/utils/jwt'; import { Request, Response } from 'express'; import { validate } from 'src/helpers/validation/validate'; import { userSchema } from 'src/helpers/validation/schemas/userSchema'; import { UserModel } from 'src/models/UserModel'; import { Redis } from 'src/redis'; import type { User } from 'src/@types'; import { getRandomString } from 'src/utils/getRandomString'; type Payload = Omit<User, 'id' | 'created_at' | 'updated_at' | 'role'>; export async function signUpController(req: Request, res: Response) { const { email, password }: Payload = req.body; const validation = validate<Payload>(req.body, userSchema); if (!validation.isValid) { return res.status(400).send(`Invalid ${validation.invalidKey}`); } try { const user = await UserModel.findOneBy({ email }); if (user) { return res.status(400).json({ message: 'Invalid email or password' }); } const hashedPassword = (await bcrypt.hash(password, 10)) as string; const username = `${email.split('@')[0]}${getRandomString(5)}`; const createdUser = await UserModel.create<Payload>({ email, password: hashedPassword, username, }); const token = await jwt.sign({ id: createdUser.id, }); await Redis.setSession(createdUser.id, token); res.status(200).json({ token, }); } catch (error) { return res.sendStatus(500); } }
Registrarse Controlador
El controlador recibe una solicitud que contiene el correo electrónico y la contraseña del usuario, generalmente desde un formulario de registro. Valida los datos entrantes con un userSchema
predefinido para garantizar que cumplan con el formato requerido.
Si la validación se realiza correctamente, indicando que no hay ningún usuario existente ni campos válidos, el controlador procede a codificar la contraseña usando bcrypt.hash
, genera un username
y crea el usuario usando UserModel.create
.
Finalmente, genera un token
usando jwt
, establece los datos session
en Redis
y devuelve el token
al usuario.
Ahora, centrémonos en la creación de un controlador de inicio de sesión. Cree el archivo src/controllers/auth/loginController.ts
:
require('dotenv').config({ path: '../../.env', }); import bcrypt from 'bcrypt'; import { Request, Response } from 'express'; import { jwt } from 'src/utils/jwt'; import { UserModel } from 'src/models/UserModel'; import { Redis } from 'src/redis'; export async function loginController(req: Request, res: Response) { const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ message: 'Invalid email or password' }); } try { const user = await UserModel.findByEmail(email); if (user) { const isValidPassword = await bcrypt.compare(password, user.password); if (!isValidPassword) { return res.status(400).json({ message: 'Invalid email or password' }); } const token: string = await jwt.sign({ id: user.id, }); await Redis.setSession(user.id, token); res.status(200).json({ token }); } else { return res.status(400).json({ message: 'Invalid email or password' }); } } catch (error) { console.error(error); return res.sendStatus(500); } }
Controlador de inicio de sesión
Básicamente, comenzamos validando los campos proporcionados y luego verificando la existencia de un usuario. Si no se encuentra ningún usuario, respondemos con un código de estado 400 junto con el mensaje Invalid email or password
, similar al comportamiento en signupController
.
Si existe un usuario, procedemos a comparar la contraseña proporcionada con la contraseña hash almacenada en la base de datos usando bcrypt.compare
.
Si las contraseñas no coinciden, respondemos con el conocido mensaje "Correo electrónico o contraseña no válidos". Finalmente, tras una autenticación exitosa, generamos un token, configuramos la sesión en Redis y enviamos el token de regreso al cliente.
Repasemos nuestros controladores protegidos, que dependen de la presencia de un user_id obtenido del middleware. Confiamos constantemente en este user_id para las operaciones dentro de estos controladores. En los casos en que la solicitud carezca de encabezado authorization
, debemos responder con un código de estado 401
.
const authHeader = req.headers['authorization'];
Cree el archivo src/controllers/user/logoutController.ts
con el siguiente código:
import type { Request, Response } from 'express'; import { Redis } from 'src/redis'; export async function logoutController(req: Request, res: Response) { try { await Redis.deleteSession(req.user.id); return res.sendStatus(200); } catch (error) { return res.sendStatus(500); } }
Cerrar sesión Controlador
Este logoutController
es responsable de cerrar la sesión de un usuario del sistema. Al recibir una solicitud, interactúa con el cliente Redis para eliminar la sesión asociada con user.id
Si la operación es exitosa, responde con un código de estado 200
para indicar un cierre de sesión exitoso.
Sin embargo, si ocurre un error durante el proceso, responde con un código de estado 500
para señalar un error interno del servidor.
A continuación, abordemos la eliminación de datos de usuario.
Cree src/controllers/user/deleteUserController.ts
y agregue este código:
import { Request, Response } from 'express'; import { UserModel } from 'src/models/UserModel'; import { Redis } from 'src/redis'; export const deleteUserController = async (req: Request, res: Response) => { const user_id = req.user.id; try { await Redis.deleteSession(user_id); await UserModel.delete(user_id); return res.sendStatus(200); } catch (error) { return res.sendStatus(500); } };
Eliminar controlador de usuario
Cuando se recibe una solicitud, extrae el ID de usuario del objeto de la solicitud, que normalmente se obtiene del middleware de autenticación.
Posteriormente se procede a eliminar la sesión asociada a este user_id
de Redis utilizando el cliente Redis. Luego, invoca el método delete
de UserModel
para eliminar los datos del usuario de la base de datos.
Tras la eliminación exitosa tanto de la sesión como de los datos del usuario, responde con un código de estado 200
para indicar la eliminación exitosa. En caso de un error durante el proceso de eliminación, responde con un código de estado 500
para indicar un error interno del servidor.
Para actualizar los datos del usuario en el sistema, cree src/controllers/user/updateUserController.ts
y agregue el siguiente código al archivo:
import { Request, Response } from 'express'; import { UserModel } from 'src/models/UserModel'; import { filterObject } from 'src/utils/filterObject'; type Payload = { first_name?: string; last_name?: string; username?: string; }; export const updateUserController = async (req: Request, res: Response) => { const { first_name, last_name, username } = req.body; const payload: Payload = filterObject({ first_name, last_name, username, }); try { const existingUserName = await UserModel.findByUsername(username); if (existingUserName) { return res.status(400).json({ error: 'Invalid username', }); } const updatedUser = await UserModel.updateOneById<typeof payload>(req.user.id, payload); res.status(200).json(updatedUser); } catch (error) { res.sendStatus(500); } };
Actualizar controlador de usuario
Al recibir una solicitud, extrae los campos first_name
, last_name
y username
del cuerpo de la solicitud. A continuación, filtra estos campos utilizando la función de utilidad filterObject
para garantizar que solo se incluyan campos válidos en la carga útil.
Posteriormente, verifica si el username
proporcionado ya existe en la base de datos. Si es así, el controlador responde con un código de estado 400
y un mensaje de error que indica un username
no válido. Si el username
es único, el controlador procede a actualizar los datos del usuario en la base de datos utilizando el método updateOneById
de UserModel
.
Tras una actualización exitosa, responde con un código de estado 200
y los datos del usuario actualizados. En caso de cualquier error durante el proceso de actualización, el controlador responde con un código de estado 500
para indicar un error interno del servidor.
El último será actualizar la contraseña, más o menos la misma idea que actualizar los datos del usuario, pero con hash de la nueva contraseña. Cree el último controlador de nuestra lista src/controllers/user/updatePasswordController.ts
y agregue el código:
import { Request, Response } from 'express'; import { UserModel } from 'src/models/UserModel'; import bcrypt from 'bcrypt'; export const updatePasswordController = async (req: Request, res: Response) => { try { const { password } = req.body; if (!password) return res.sendStatus(400); const hashedPassword = (await bcrypt.hash(password, 10)) as string; const user = await UserModel.updateOneById(req.user.id, { password: hashedPassword }); return res.status(200).json({ id: user.id }); } catch (error) { return res.sendStatus(500); } };
Actualizar controlador de contraseña
Al recibir una solicitud, extrae la nueva contraseña del cuerpo de la solicitud. Luego verifica si se proporciona una contraseña en el cuerpo de la solicitud. De lo contrario, responde con un código de estado 400
, lo que indica una solicitud incorrecta. A continuación, codifica la nueva contraseña utilizando la biblioteca bcrypt
con un factor de sal de 10.
Luego, la contraseña hash se almacena de forma segura en la base de datos utilizando el método updateOneById
de UserModel
, asociándola con user.id
Tras una actualización exitosa de la contraseña, el controlador responde con un código de estado 200
y un objeto JSON que contiene la identificación del usuario.
En caso de cualquier error durante el proceso de actualización de contraseña, el controlador responde con un código de estado 500
para indicar un error interno del servidor como en otros controladores.
Asegúrese de revisar y configurar el asistente de validación y las utilidades desde el
Comprobemos el punto final de registro:
Como es evidente, hemos obtenido un token, que se utilizará en el encabezado para recuperar la sesión.
Enviamos el token de autorización en el encabezado al servidor y, en respuesta, el servidor nos proporcionó los datos del usuario recuperados de la base de datos.
Siéntase libre de explorar y experimentar con funciones de seguridad y almacenamiento en caché de Redis. Con el modelo fundamental implementado, puede profundizar en funcionalidades adicionales, como la recuperación de cuentas para usuarios que olvidan sus contraseñas. Sin embargo, este tema quedará reservado para un artículo futuro.
Administrar el flujo de enrutamiento y autenticación de usuarios de manera escalable puede ser un desafío. Si bien hemos implementado middleware para proteger las rutas, existen estrategias adicionales disponibles para mejorar el rendimiento y la confiabilidad del servicio.
La experiencia del usuario mejora aún más al proporcionar mensajes de error más claros, ya que el manejo de errores sigue siendo un aspecto importante que requiere una cobertura más completa. Sin embargo, hemos implementado con éxito el flujo de autenticación principal, lo que permite a los usuarios registrarse, acceder a sus cuentas, recuperar datos de sesión, actualizar la información del usuario y eliminar cuentas.
Espero que este viaje le haya resultado revelador y haya adquirido conocimientos valiosos sobre la autenticación de usuarios.
También publicado aquí