Implementación del Módulo de Autenticación con JWT
En esta sección llevaremos los conceptos teóricos a la práctica, construyendo un módulo completo de autenticación (AuthModule) en NestJS. Implementaremos la validación de credenciales con hashing criptográfico, la generación de tokens firmados mediante JwtService, la estrategia JwtStrategy para validar cabeceras Bearer y el controlador de acceso (AuthController).
1. Arquitectura de Componentes
La siguiente ilustración resume las relaciones de inyección de dependencias y el flujo de datos entre los componentes del módulo de autenticación:
2. Implementación Paso a Paso
Sigue esta guía secuencial para estructurar y enlazar todos los componentes necesarios.
Configurar Variables de Entorno
Asegúrate de definir la clave secreta y el tiempo de expiración del token en tu archivo .env. Nunca compartas esta clave en repositorios públicos.
JWT_SECRET=super_secreto_para_firmar_tokens_jwt_icesi_2026
JWT_EXPIRES_IN=1h
En entornos productivos, JWT_SECRET debe ser una cadena aleatoria de alta entropía (mínimo 256 bits) inyectada mediante secretos de CI/CD o administradores de claves (como AWS Secrets Manager o Doppler).
Definir DTOs y Tipos de Datos
Crea los archivos para tipar la solicitud de inicio de sesión y la estructura interna del payload que viajará en el JWT.
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
export class UserLoginDto {
@IsEmail({}, { message: 'El correo electrónico suministrado no es válido' })
@IsNotEmpty({ message: 'El correo electrónico es requerido' })
email: string;
@IsString()
@IsNotEmpty({ message: 'La contraseña es requerida' })
@MinLength(6, { message: 'La contraseña debe tener al menos 6 caracteres' })
password: string;
}
Define la interfaz de TypeScript que describe el contenido del token decodificado:
export interface JwtPayload {
sub: number;
email: string;
permissions: string[];
iat?: number;
exp?: number;
}
Implementar el Servicio de Autenticación (AuthService)
AuthService coordina la verificación de credenciales contra la base de datos (usando bcrypt.compare) y delega la firma del token a JwtService.
import {
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { UsersService } from '../users/users.service';
import { UserLoginDto } from './dto/user-login.dto';
import { JwtPayload } from './interfaces/jwt-payload.interface';
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
) {}
/**
* Valida si el usuario existe y si la contraseña coincide con el hash almacenado.
*/
async validateUser(email: string, pass: string) {
// findByEmail debe cargar las relaciones de roles y permisos
const user = await this.usersService.findByEmail(email);
if (!user) {
throw new NotFoundException('Usuario no encontrado');
}
const isMatch = await bcrypt.compare(pass, user.passwordHash);
if (!isMatch) {
throw new UnauthorizedException('Credenciales inválidas');
}
// Omitimos la propiedad passwordHash antes de continuar
const { passwordHash, ...safeUser } = user;
return safeUser;
}
/**
* Genera el token JWT a partir de la identidad y los permisos del usuario.
*/
async login(userLoginDto: UserLoginDto) {
const user = await this.validateUser(
userLoginDto.email,
userLoginDto.password,
);
// Mapeamos los permisos asociados al rol del usuario
const permissions =
user.role?.rolePermissions?.map((rp) => rp.permission.name) ?? [];
const payload: JwtPayload = {
sub: user.id,
email: user.email,
permissions,
};
return {
access_token: this.jwtService.sign(payload),
token_type: 'Bearer',
user: {
id: user.id,
email: user.email,
role: user.role?.name,
},
};
}
}
Asegúrate de que el método findByEmail en tu UsersService incluya las relaciones de TypeORM correspondientes (role, role.rolePermissions, role.rolePermissions.permission) para que los permisos puedan ser extraídos con éxito.
Configurar la Estrategia JWT (JwtStrategy)
JwtStrategy extiende de PassportStrategy(Strategy). Passport se encarga de interceptar la petición, extraer el token del encabezado Authorization: Bearer <token> y comprobar criptográficamente la firma con la clave secreta. Si la firma es legítima, invoca el método validate(payload).
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UsersService } from '../../users/users.service';
import { JwtPayload } from '../interfaces/jwt-payload.interface';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
configService: ConfigService,
private readonly usersService: UsersService,
) {
const secret = configService.get<string>('JWT_SECRET');
if (!secret) {
throw new Error('La variable de entorno JWT_SECRET no está configurada');
}
super({
// Extrae el token del header 'Authorization: Bearer <token>'
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
// Rechaza automáticamente tokens que hayan superado su fecha de expiración
ignoreExpiration: false,
// Clave secreta simétrica para verificar la firma
secretOrKey: secret,
});
}
/**
* Método invocado automáticamente por Passport tras verificar la firma del token.
* El objeto retornado aquí es inyectado por NestJS en 'req.user'.
*/
async validate(payload: JwtPayload) {
const user = await this.usersService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException('El token no corresponde a un usuario activo');
}
return user;
}
}
Crear el Controlador de Autenticación (AuthController)
El controlador expone el endpoint público de inicio de sesión (POST /auth/login) y recibe las credenciales validadas por el DTO:
import {
Controller,
Post,
Body,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { UserLoginDto } from './dto/user-login.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() loginDto: UserLoginDto) {
return this.authService.login(loginDto);
}
}
Integrar y Configurar el Módulo (AuthModule)
Configura el módulo importando JwtModule de manera asíncrona mediante registerAsync. Esto asegura que ConfigService esté listo antes de inicializar la clave secreta.
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
UsersModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') || 'defaultSecret',
signOptions: {
expiresIn: configService.get<string>('JWT_EXPIRES_IN') || '1h',
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, PassportModule, JwtModule],
})
export class AuthModule {}
3. Verificación del Endpoint de Login
Para comprobar que el flujo de autenticación funciona adecuadamente, envía una petición POST al endpoint /auth/login:
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "ana@icesi.edu.co",
"password": "MiPasswordSeguro123"
}'
Respuesta esperada (HTTP 200 OK):
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImVtYWlsIjoiYW5hQGljZXNpLmVkdS5jbyIsInBlcm1pc3Npb25zIjpbInVzZXJzOnJlYWQiLCJ1c2VyczpjcmVhdGUiXSwiaWF0IjoxNzczNTE3OTA1LCJleHAiOjE3NzM1MjE1MDV9.X9jYd8L2...",
"token_type": "Bearer",
"user": {
"id": 1,
"email": "ana@icesi.edu.co",
"role": "admin"
}
}
Si el usuario envía una contraseña errónea o un correo inexistente:
{
"message": "Credenciales inválidas",
"error": "Unauthorized",
"statusCode": 401
}