Introducción
La autenticación de dos factores es un método de seguridad que añade una capa adicional de protección a las cuentas en línea. En lugar de depender únicamente de una contraseña, se requiere un segundo factor de autenticación para verificar la identidad del usuario.
Al agregar este segundo factor, se reduce considerablemente la probabilidad de que una cuenta sea comprometida, ya que un atacante necesitaría tanto la contraseña como el segundo factor para acceder.
En este artículo, vamos a explorar cómo implementar una autenticación de dos pasos basada en SMS en un proyecto de backend utilizando NestJS. Para lograr esto, utilizaremos Twilio como proveedor de servicios de mensajes de texto, el cual nos permitirá enviar un código de un solo uso al número de teléfono del usuario el cual tendrá un tiempo de expiración establecido.
Si venís del marco de trabajo Express pero deseas comenzar con NestJS, esta guía te brindará una idea de cómo se realizan las tareas en NestJS.
Requisitos previos
Para poder seguir esta guía, deberías tener instalado NodeJs y Docker en tu computadora ya que usaremos Docker para levantar la base de datos y simplificar un poco las cosas.
El código está escrito en TypeScript pero tu conocimiento de JavaScript debería ser suficiente para que entiendas lo que está pasando.
Configuración del proyecto Nest
Ejecutaremos el siguiente comando en nuestra terminal
npm install -g @nestjs/cli
Acá estamos instalando globalmente el CLI de NestJs,esto te permite generar archivos de código,como por ejemplo un CRUD básico, administrar dependencias y realizar diversas tareas relacionadas con el desarrollo de aplicaciones NestJS.
Una vez instalado podemos usar el CLI para crear nuestro nuevo proyecto
nest new nombre-del-proyecto
Esto te va a crear un proyecto con la siguiente estructura
nombre-del-proyecto
|-- node_modules
|-- src
| |-- app.controller.spec.ts
| |-- app.controller.ts
| |-- app.module.ts
| |-- app.service.ts
| |-- main.ts
|-- test
|-- .eslintrc.js
|-- .gitignore
|-- .prettierrc
|-- nest-cli.json
|-- package-lock.json
|-- package.json
|-- README.md
|-- tsconfig.build.json
|-- tsconfig.json
Antes de continuar,hagamos que nuestro proyecto quede más limpio y eliminemos archivos que en esta ocasión no vamos a utilizar.
Elimina estos archivos:
app.controller.spec.ts
app.controller.ts
app.service.ts
Luego en el root de tu proyecto crea un archivo .env
, dentro agrega las siguientes variables de entorno,estas nos serviran mas adelante.
PORT=3000
# DB
DB_HOST=localhost
DB_NAME=2FADB
DB_PASSWORD=postgresPassword123!
DB_PORT=5432
DB_USERNAME=postgres
# JWT
JWT_SECRET=algunaClaveSecreta
# Twilio
TWILIO_AUTH_TOKEN=
TWILIO_ACCOUNT_SID=
TWILIO_PHONE_NUMBER=
Instalación de dependencias y configuración inicial
Antes de continuar instalemos todas las dependencias necesarias.
Ejecuta el siguiente comando en la terminal:
npm install @nestjs/passport @nestjs/typeorm @nestjs/config @nestjs/jwt bcrypt class-transformer class-validator cors passport-jwt pg reflect-metadata twilio uuid typeorm moment
Dependencias de desarrollo:
npm install -D @types/bcrypt @types/cors @types/passport-jwt
Ahora necesitamos poder acceder a las variables de entorno, para eso ve a src/app.module.ts
e importa el ConfigModule
de @nestjs/config
y colocalo dentro del array de imports:
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot(),
],
controllers: [],
providers: [],
})
export class AppModule {}
Ahora en el archivo main.ts
debemos configurar los cors , los pipes a nivel global y también setear el prefijo de todas las rutas a "api", aunque esto es totalmente opcional pero nos ahorra bastantes problemas a futuro,mas que nada los pipes ya que sino deberiamos agregarlo manualmente a cada una de las rutas que hagamos.
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger, ValidationPipe } from '@nestjs/common';
import * as cors from 'cors';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
app.use(cors({ origin: '*' }));
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
}),
);
await app.listen(process.env.PORT || 3002);
logger.log(`App running on port: ${process.env.PORT || 3002}`);
}
bootstrap();
Crear el recurso de autenticación
Ahora ejecutaremos el siguiente comando en la terminal:
nest g res auth
Este comando nos va a genera un nuevo recurso llamado auth.
Elejimos REST API y le decimos que si que queremos que nos genere un CRUD.
Dentro de la carpeta auth recien creada eliminaremos los siguientes archivos ya que no los vamos a utilizar:
auth.controller.spec.ts
auth.service.spec.ts
Crear entidades
Vamos a crear 2 entidades en este proyecto, User
y Otp
(one-time-password). Dentro de src/auth/entities
borra el archivo auth.entity que se hizo por defecto y crea 2 archivos nuevos, uno para User user.entity.ts
y otro para Otp opt.entity.ts
user.entity.ts
/* eslint-disable prettier/prettier */
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { Otp } from './otp.entity';
@Entity({ name: 'User' })
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
fullName: string;
@Column({ unique: true })
phone: string;
@Column({ unique: true })
email: string;
@Column()
password: string;
@Column({ default: false })
twoFA: boolean;
@Column({ default: false })
isPhoneVerified: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
// Relations
@OneToMany(() => Otp, (otp) => otp.user)
otp: Otp[];
}
Antes de la siguiente entidad,creemos una función para obtener la fecha de expiración,hazla en src/common/utils/dateTimeUtility.ts
/* eslint-disable prettier/prettier */
import * as moment from 'moment';
export const getExpiry = () => {
const createdAt = new Date();
const expiresAt = moment(createdAt).add(5, 'minutes').toDate();
return expiresAt;
};
export function isTokenExpired(expiry: Date): boolean {
const expirationDate = new Date(expiry);
const currentDate = new Date();
return expirationDate.getTime() <= currentDate.getTime();
}
otp.entity.ts
/* eslint-disable prettier/prettier */
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from './user.entity';
import { getExpiry } from 'src/common/utils/dateTimeUtility';
@Entity({ name: 'One-Time-Password' })
export class Otp {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
userId: string;
@Column()
code: string;
@Column()
useCase: 'LOGIN' | 'D2FA' | 'PHV';
@CreateDateColumn({ type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamp' })
updatedAt: Date;
@Column({ type: 'timestamp' })
expiresAt: Date;
@BeforeInsert()
setExpireDate() {
this.expiresAt = getExpiry();
}
@BeforeInsert()
setCurrentDate() {
this.createdAt = new Date();
this.updatedAt = new Date();
}
// Relations
@ManyToOne(() => User, (user) => user.otp)
user: User;
}
Declaramos una relación uno a muchos entre la tabla "User" y la tabla "OTP". Un usuario puede tener varios OTPs. Cuando se crea un usuario en nuestra aplicación, la autenticación de dos factores (2FA) está desactivada de manera predeterminada y su número de teléfono se establece como no verificado.
Los OTPs generados en nuestra aplicación serán válidos durante 5 minutos y tendrán 3 casos de uso:
LOGIN: Esto se refiere a los OTPs que se envían a los usuarios que tienen cuentas con 2FA habilitada.
D2FA: Cuando un usuario decide desactivar la autenticación de dos factores en su cuenta, le enviaremos un OTP a su número de teléfono. Si lo verifican, desactivaremos el 2FA en su cuenta. Este caso de uso aborda esa situación.
PHV: Esto se refiere a los OTPs que se envían al número de teléfono del usuario cuando desean verificar su número de teléfono en la aplicación.
Una vez que tenemos las entidades listas , las tenemos que cargar en la base de datos, para hacerlo actualiza el archivo src/auth.module.ts
:
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { Otp } from './entities/otp.entity';
import { ConfigModule } from '@nestjs/config';
@Module({
controllers: [AuthController],
providers: [AuthService],
imports: [TypeOrmModule.forFeature([User, Otp]), ConfigModule],
})
export class AuthModule {}
Configurar la base de datos con Docker
En el root de tu proyecto (fuera de la carpeta src) crea un archivo llamado docker-compose.yaml
y pega el siguiente código.
version: '3'
services:
db:
image: postgres:14.3
restart: always
ports:
- '5432:5432'
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
container_name: 2fadb
volumes:
- ./postgres:/var/lib/postgresql/data
Para los que no están familiarizados con Docker, Docker es una plataforma de código abierto que permite empaquetar y distribuir aplicaciones en contenedores. Un contenedor es una unidad ligera y portátil que contiene todo lo necesario para ejecutar una aplicación, incluidas las dependencias, el código y las configuraciones del sistema.
Este código es un archivo de configuración para Docker Compose.
Define un servicio llamado "db" que utiliza una imagen de PostgreSQL versión 14.3.
version: '3': Especifica la versión de la sintaxis de Docker Compose que se está utilizando.
services: Indica que se definirá un servicio en este archivo.
db: Es el nombre del servicio. Puede ser cualquier nombre que desees.
image: postgres:14.3: Especifica la imagen de Docker que se utilizará para el servicio "db". En este caso, se está utilizando la imagen oficial de PostgreSQL en la versión 14.3.
restart: always: Indica que el contenedor se reiniciará siempre que se detenga. Esto garantiza que el servicio de la base de datos esté siempre en ejecución.
ports: - '5432:5432': Mapea el puerto 5432 del contenedor al puerto 5432 del host. Esto permite acceder a la base de datos de PostgreSQL desde el host a través del puerto 5432.
environment: Define las variables de entorno para el contenedor. En este caso, se establece la contraseña de la base de datos (POSTGRES_PASSWORD) y el nombre de la base de datos (POSTGRES_DB). Estas variables se configuran utilizando los valores de las variables de entorno del sistema local.
container_name: 2fadb: Establece el nombre del contenedor como "2fadb".
volumes: - ./postgres:/var/lib/postgresql/data: Mapea un volumen local (./postgres) al directorio /var/lib/postgresql/data dentro del contenedor. Esto permite persistir los datos de la base de datos incluso si el contenedor se elimina o reinicia.
Conectarse a la base de datos
Ahora para conectarnos a la base de datos con Nestjs lo haremos a traves de TypeOrm actualiza el codigo en src/app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from './auth/auth.module';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot(),
TypeOrmModule.forRoot({
autoLoadEntities: true,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
password: process.env.DB_PASSWORD,
port: +process.env.DB_PORT,
synchronize: true,
type: 'postgres',
username: process.env.DB_USERNAME,
}),
AuthModule,
],
controllers: [],
providers: [],
})
export class AppModule {}
Para levantar la base de datos ejecuta el siguiente comando en la terminal: docker-compose up -d
Si todo esta bien deberia aparecer un mensaje como este:
$ docker-compose up -d
[+] Running 2/2
✔ Network 2fa-article_default Created 0.3s
✔ Container 2fadb Started 2.8s
Tené en cuenta que si es la primera vez que usas docker,primero va a instalar la imagen de postgresql la cual tarda unos minutos en completarse.
Implementando nuestra estrategia de autenticación con JWT
NestJS tiene un módulo incorporado que facilita el manejo de la creación de JWTs. Registra el módulo de JWT de NestJS en el módulo de autenticación src/auth/auth.module.ts
:
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { User } from './entities/user.entity';
import { Otp } from './entities/otp.entity';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
imports: [
TypeOrmModule.forFeature([User, Otp]),
ConfigModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET'),
signOptions: { expiresIn: '2 days' },
}),
}),
],
exports: [],
})
export class AuthModule {}
Dentro de la carpeta auth crea una carpeta llamada strategies y dentro un archivo jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { JwtPayload } from '../interfaces/jwt-payload.interface';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UnauthorizedException } from '@nestjs/common/exceptions';
import { ConfigService } from '@nestjs/config';
import { Injectable } from '@nestjs/common';
import { User } from '../entities';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
configService: ConfigService,
) {
super({
secretOrKey: configService.get('JWT_SECRET'),
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
});
}
async validate(payload: JwtPayload): Promise<User> {
const { id } = payload;
const user = await this.userRepository.findOneBy({ id });
if (!user) {
throw new UnauthorizedException(`Invalid token`);
}
return user;
}
}
Tambien creemos un archivo en src/auth/interfaces/jwt-payload.interface.ts
para definir como luce el payload del JWT
/* eslint-disable prettier/prettier */
export interface JwtPayload {
id: string;
}
Implementar registro de usuarios
Antes de crear el controlador y el servicio creemos los Dtos para la creación y logueo de usuarios
Elimina los archivos creados por defecto y crea unos nuevos llamados create-user.dto.ts
y login-user.dto.ts
en src/auth/dto
y actualiza las respectivas clases
login-user.dto.ts
/* eslint-disable prettier/prettier */
import { IsString, IsNotEmpty, IsEmail, MinLength } from 'class-validator';
export class LoginUserDto {
@IsEmail()
@IsNotEmpty()
email: string;
@IsString()
@MinLength(10, { message: 'Password must have at least 10 characters' })
@IsNotEmpty()
password: string;
}
create-user.dto.ts
/* eslint-disable prettier/prettier */
import { IsString, IsNotEmpty, IsEmail, MinLength } from 'class-validator';
export class CreateUserDto {
@IsString()
@IsNotEmpty()
fullName: string;
@IsEmail()
@IsNotEmpty()
email: string;
@IsString()
@MinLength(10, { message: 'Password must have at least 10 characters' })
@IsNotEmpty()
password: string;
@IsNotEmpty()
@IsString()
phone: string;
}
Configurar la ruta de REGISTRO de usuarios
Ve al AuthController
en src/auth/auth.controller
y agrega la ruta para el registro de usuarios
import { Controller, Post, Body } from '@nestjs/common';
import { AuthService } from './auth.service';
import { CreateUserDto } from './dto/create-user.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
register(@Body() createUserDto: CreateUserDto) {
return this.authService.register(createUserDto);
}
}
El servidor escuchará una peticion POST en localhost:3000/api/auth/register
Configurar el servicio para la creación de usuarios
Actualiza tu código en src/auth/auth.service.ts
import { Injectable, BadRequestException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { hashPassword } from 'src/common/utils/passwordHasher';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly jwtService: JwtService,
) {}
async register({ email, fullName, password, phone }: CreateUserDto) {
// Busca si ya existe un usuario con el mismo correo electrónico
const foundUser = await this.userRepository.findOne({ where: { email } });
// Si se encuentra un usuario con el mismo correo electrónico, se lanza una excepción
if (foundUser) {
throw new BadRequestException('User already exist');
}
// Genera el hash de la contraseña proporcionada
const hashedPassword = await hashPassword(password);
// Crea un nuevo objeto de usuario con los datos proporcionados
const newUser = this.userRepository.create({
fullName,
email,
phone,
password: hashedPassword,
});
// Guarda el nuevo usuario en la base de datos
await this.userRepository.save(newUser);
// Elimina la contraseña del objeto de usuario antes de devolverlo
delete newUser.password;
// Devuelve el nuevo usuario junto con un token de acceso generado a partir de su ID
return {
...newUser,
access_token: this.signJWT(newUser.id),
};
}
private signJWT(id: string) {
return this.jwtService.sign({ id });
}
}
Esta función verifica si un usuario con el mismo correo electrónico ya existe en la base de datos. Si no existe, crea un nuevo objeto de usuario con los datos proporcionados, guarda el objeto de usuario en la base de datos después de hashear la contraseña, elimina la contraseña del objeto de usuario y devuelve el usuario junto con un token de acceso generado.
Crea la función para hashear contraseñas en src/common/utils/passwordHasher.ts
/* eslint-disable prettier/prettier */
import * as bcrypt from 'bcrypt';
export const hashPassword = async (password: string): Promise<string> => {
const hash = await bcrypt.hash(password, 10);
return hash;
};
Configurar la ruta de LOGUEO de usuarios
Abre el controlador del módulo auth en src/auth/auth.controller
y agrega la ruta para el *logueo * de usuarios
import { LoginUserDto } from './dto/login-user.dto';
@Controller('auth')
export class UserController {
constructor(private readonly authService: AuthService) {}
...
@Post('login')
login(@Body() loginUserDto: LoginUserDto) {
return this.authService.login(loginUserDto);
}
}
El servidor escuchará una peticion POST en localhost:3000/api/auth/login
Configurar el servicio para el logueo de usuarios
Actualiza tu código en src/auth/auth.service.ts
import {
Injectable,
BadRequestException,
UnauthorizedException,
} from '@nestjs/common';
import { LoginUserDto } from './dto/login-user.dto';
import { verifyPassword } from 'src/common/utils/verifyPassword';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(Otp)
private readonly jwtService: JwtService,
) {}
...
async login({ email, password }: LoginUserDto) {
// Busca un usuario en la base de datos usando el correo electrónico proporcionado
const user = await this.userRepository.findOne({ where: { email } });
// Si no se encuentra un usuario con el correo electrónico proporcionado, se lanza una excepción
if (!user) {
console.log('EMAIL');
throw new UnauthorizedException('Incorrect email or password');
}
// Verifica si la contraseña proporcionada coincide con la contraseña almacenada del usuario
const passwordsMatch: boolean = verifyPassword({
hashedPassword: user.password,
password,
});
// Si las contraseñas no coinciden, se lanza una excepción
if (!passwordsMatch) {
console.log('PASSWORD');
throw new UnauthorizedException('Incorrect email or password');
}
// Si el usuario no tiene habilitada la autenticación de dos factores
if (!user.twoFA) {
// Devuelve el usuario con un indicador de autenticación de dos factores deshabilitado y un token de acceso generado a partir de su ID
return {
...user,
twoFA: false,
access_token: this.signJWT(user.id),
};
}
}
}
Esta función busca un usuario en la base de datos utilizando el correo electrónico proporcionado.
Si se encuentra un usuario, se verifica si la contraseña proporcionada coincide con la contraseña almacenada. Si las contraseñas coinciden, se comprueba si el usuario tiene habilitada la autenticación de dos factores.
Si no la tiene habilitada, se devuelve el usuario con un indicador de autenticación de dos factores deshabilitado y un token de acceso generado.
Si el usuario tiene habilitada la autenticación de dos factores, no se devuelve nada,esto lo actualizaremos más adelante.
Crea una nueva función en nuestra carpeta utils para comparar la contraseña que el usuario ingresó, con la que tenemos nosotros en nuestra base de datos.
/* eslint-disable prettier/prettier */
import * as bcrypt from 'bcrypt';
interface PasswordVerifier {
password: string;
hashedPassword: string;
}
export const verifyPassword = ({
password,
hashedPassword,
}: PasswordVerifier) => {
return bcrypt.compareSync(password, hashedPassword);
};
Verificación de teléfono
Antes de que los usuarios puedan habilitar la autenticación en dos factores en su cuenta, es necesario verificar su número de teléfono en la aplicación. En esta ocasión, utilizaremos Twilio para enviar un OTP (one-time-password) de verificación a su número de teléfono que ingresaron al momento de registrarse.
Crear cuenta en Twilio
Para crear una cuenta en Twilio ve hacia la página web de Twilio
Una vez que te hayas creado la cuenta copia los datos de Account SID Auth Token y Phone Number y actualiza las variables de entorno
Para obtener tu numero de telefono de twilio anda a Phone Numbers > Manage > Buy a Number.Te van a dar 15 dolares para comprar cualquier número.
Tené en cuenta que al tener la cuenta gratuita solo vas a poder enviarle mensajes a números verificados, podes ver la lista en Phone Numbers > Manage > Verified Callers ID , que es básicamente el
número con el que te creaste la cuenta.
Obtener el usuario en la petición
Antes de poder enviar un SMS necesitamos validar que el usuario esté autenticado y también tenemos que saber cual es el usuario que está logueado en ese momento ya que es a ese al que se lo queremos enviar.
Para eso vamos a crear 2 custom decorators:
Auth(): Se encarga de protejer la ruta de usuarios no autenticados.
@GetUser(): Obtiene el usuario autenticado.
En src/common/decorators
crea get-user.decorator.ts
y auth.decorator.ts
get-user.decorator.ts
/* eslint-disable prettier/prettier */
import {
createParamDecorator,
ExecutionContext,
InternalServerErrorException,
} from '@nestjs/common';
import { User } from 'src/auth/entities/user.entity';
export const GetUser = createParamDecorator(
(data: string, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest();
const user = req.user as User;
if (!user) {
throw new InternalServerErrorException('User not found in request');
}
console.log(data);
return user;
},
);
auth.decorator.ts
/* eslint-disable prettier/prettier */
import { AuthGuard } from '@nestjs/passport';
import { applyDecorators, UseGuards } from '@nestjs/common';
export function Auth() {
return applyDecorators(UseGuards(AuthGuard()));
}
Enviar SMS
Crea un nuevo archivo en src/common/utils/twilio.ts
import { Twilio } from 'twilio';
export const sendSMS = async (phoneNumber: string, message: string) => {
const client = new Twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN,
);
try {
const smsResponse = await client.messages.create({
from: process.env.TWILIO_PHONE_NUMBER,
to: `${phoneNumber}`,
body: message,
});
console.log(smsResponse.sid, smsResponse.to);
} catch (error) {
error.statusCode = 400;
throw error;
}
};
Configurar la ruta de ENVIO DE SMS para usuarios autenticados
Abre el controlador del módulo auth en src/auth/auth.controller.ts
y agrega la ruta para el envio de SMS para usuarios autenticados
import { Auth } from 'src/common/decorators/auth.decorator';
import { GetUser } from 'src/common/decorators/get-user.decorator';
import { User } from './entities/user.entity';
@Auth()
@Post('phone/send-code')
sendCodeToVerifyPhone(@GetUser() user: User) {
return this.authService.sendCodeToVerifyPhone(user);
}
El servidor ahora escuchará por peticiones POST en localhost:3000/api/auth/phone/send-code
Actualizar el servicio para el envio de SMS a usuarios autenticados
Ahora vamos a crear una función en src/auth/auth.service.ts
para el envio de SMS a cuentas con el número telefónico no verificado.
import {
Injectable,
BadRequestException,
UnauthorizedException,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { Otp } from './entities/otp.entity';
import { sendSMS } from 'src/common/utils/twilio';
import { generateOTP } from 'src/common/utils/coedGenerator';
export class AuthService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(Otp)
private readonly otpRepository: Repository<Otp>,
private readonly jwtService: JwtService,
) {}
...
async sendCodeToVerifyPhone(user: User) {
// Comprueba si el usuario existe
if (!user) {
throw new NotFoundException('User not found');
}
// Comprueba si el número de teléfono del usuario ya está verificado
if (user.isPhoneVerified) {
return { success: true, successMessage: 'Phone number already verified' };
}
// Envía un código de verificación al usuario utilizando la función sendOTP
// 'PHV' es un código de tipo específico para la verificación del número de teléfono
// 'Code sent, check your inbox' es un mensaje que indica que se ha enviado el código y que debe revisar su bandeja de entrada
return await this.sendOTP(user, 'PHV', 'Code sent, check your inbox');
}
private async sendOTP(
user: User,
useCase: 'LOGIN' | 'D2FA' | 'PHV',
messageSuccess: string,
): Promise<{
success: boolean;
messageSuccess: string;
}> {
// Genera un código OTP (One-Time Password) de 6 dígitos
const otp = generateOTP(6);
// Crea un objeto OTP con la información del usuario, el código OTP generado y el caso de uso especificado
const otpPayload = {
user,
userId: user.id,
code: otp,
useCase,
} as Otp;
// Crea un nuevo registro de OTP utilizando el repositorio correspondiente
const newOtp = this.otpRepository.create({
...otpPayload,
});
// Guarda el nuevo registro de OTP en la base de datos
await this.otpRepository.save(newOtp);
let message = '';
// Define los mensajes asociados a cada caso de uso
const messages = {
D2FA: `Use this code ${otp} to disable multifactor authentication on your account`,
PHV: `Use this code ${otp} to verify the phone number registered on your account`,
LOGIN: `Use this code ${otp} to log in to your account`,
};
// Asigna el mensaje correspondiente al caso de uso actual
if (useCase in messages) {
message = messages[useCase];
} else {
// Si el caso de uso no es válido, se lanza una excepción
throw new ForbiddenException('Invalid use case');
}
// Envía un mensaje SMS al número de teléfono del usuario con el código OTP y el mensaje apropiado
await sendSMS(user.phone, message);
// Retorna un objeto con un indicador de éxito y el mensaje de éxito proporcionado
return { success: true, messageSuccess };
}
}
La función sendCodeToVerifyPhone
primero verifica si un usuario existe. Si el usuario no existe, se lanza una excepción.
Si el número de teléfono del usuario ya está verificado, se devuelve un objeto con un indicador de éxito y un mensaje que indica que el número de teléfono ya está verificado.
Si el número de teléfono no está verificado, se llama a la función helper sendOTP con el usuario, el caso de uso'PHV' (phone verification) y un mensaje indicando que se envió un código y que debe revisar su bandeja de entrada.
Por otro lado, sendOTP es una función helper que genera un código OTP de 6 dígitos y crea un nuevo registro OTP en la base de datos con la información del usuario y el código generado.
Luego, selecciona el mensaje adecuado según el caso de uso proporcionado (LOGIN, D2FA o PHV) y lo envía como un mensaje SMS al número de teléfono del usuario. Finalmente, la función devuelve un objeto con un indicador de éxito y el mensaje de éxito proporcionado.
Esta función sendOTP puede ser utilizada en diferentes partes del código para enviar códigos OTP a los usuarios en diferentes escenarios, como autenticación, verificación de número de teléfono o desactivación de la autenticación multifactor.
Generar OTP
Para generar un OTP aleatorio vamos a crear una función en src/common/utils/codeGenerator.ts
/* eslint-disable prettier/prettier */
export const generateOTP = (number: number): string => {
const digits = '0123456789';
let otp = '';
for (let i = 0; i < number; i++) {
otp += digits[Math.floor(Math.random() * digits.length)];
}
return otp;
};
Verificar OTP
Anteriormente,le enviamos el SMS al usuario y creamos un nuevo registro en la base de datos que contenía el código enviado.
Ahora tenemos que verificar si el código que el usuario nos envía es el mismo que tenemos en la base de datos,para finalmente verificar el número de télefono.
Antes de crear la ruta vamos a crear el DTO para la verificación del código en src/auth/dto/verification-code.dto.ts
import { IsString, Length } from 'class-validator';
export class VerificationCodeDto {
@IsString()
@Length(6, 6, { message: 'OTP Code not valid , try again' })
code: string;
}
Configurar la ruta de VERIFICACIÓN DE OTP
Abre el controlador del módulo auth en src/auth/auth.controller
y agrega la ruta para la VERIFICACIÓN DE OTP
import { VerificationCodeDto } from './dto/verification-code.dto';
@Auth()
@Post('phone/validate-code')
validatePhoneCode(
@Body() verificationCodeDto: VerificationCodeDto,
@GetUser() user: User,
) {
return this.authService.validatePhoneCode(user, verificationCodeDto);
}
Actualizar el servicio para la verificación del OTP
import { isTokenExpired } from '../common/utils/dateTimeUtility';
import { VerificationCodeDto } from './dto/verification-code.dto';
async validatePhoneCode(user: User, { code }: VerificationCodeDto) {
// Verifica si el código tiene una longitud válida de 6 dígitos
if (code.length !== 6) throw new BadRequestException('Invalid Code');
// Verifica si el usuario existe
if (!user) throw new UnauthorizedException();
// Obtiene el registro OTP correspondiente al usuario, el código y el caso de uso 'PHV'
await this.getOTPRecord(user, code, 'PHV');
// Actualiza el estado del usuario para indicar que el número de teléfono ha sido verificado
const updatedUser = await this.userRepository.preload({
id: user.id,
isPhoneVerified: true,
});
// Guarda los cambios en el usuario en la base de datos
await this.userRepository.save(updatedUser);
// Retorna un objeto con un indicador de éxito y un mensaje indicando que el número de teléfono ha sido validado correctamente
return {
success: true,
messageSuccess: 'Phone number validated correctly',
};
}
private async getOTPRecord(
user: User,
code: string,
useCase: 'LOGIN' | 'D2FA' | 'PHV',
): Promise<Otp> {
// Busca el registro OTP en la base de datos que coincida con el código, el usuario y el caso de uso especificados
const otpRecord = await this.otpRepository.findOne({
where: { code, userId: user.id, useCase },
});
// Si no se encuentra ningún registro OTP, se lanza una excepción indicando que el código es inválido
if (!otpRecord) throw new NotFoundException('Invalid code');
// Verifica si el OTP ha expirado
const isExpired = isTokenExpired(otpRecord.expiresAt);
// Si el OTP ha expirado, se elimina el registro OTP de la base de datos y se lanza una excepción indicando que el código ha expirado
if (isExpired) {
await this.otpRepository.delete(otpRecord.id);
throw new NotFoundException('Expired code');
}
// Retorna el registro OTP encontrado
return otpRecord;
}
La función validatePhoneCode
valida un código de verificación de teléfono para un usuario específico.
Verifica la longitud del código, la existencia del usuario y luego utiliza la función getOTPRecord
para obtener el registro OTP correspondiente al código y usuario.
Si el registro OTP se encuentra y no ha expirado, actualiza el estado del usuario como verificado y guarda los cambios. Al final, devuelve un objeto indicando el éxito de la validación.
La función getOTPRecord
busca un registro OTP en la base de datos que coincida con un código, usuario y caso de uso específicos.
Si el registro no se encuentra, lanza una excepción. Si el registro se encuentra, verifica si ha expirado utilizando la función isTokenExpired
. Si ha expirado, se elimina el registro y se lanza una excepción.
En última instancia, se devuelve el registro OTP encontrado. Esta función es utilizada internamente para verificar la validez y la expiración del código OTP antes de validar el número de teléfono del usuario.
Establecer autenticación en 2 Factores
En esta sección, crearemos un punto de conexión para habilitar o deshabilitar la autenticación de dos factores en la cuenta del usuario.
Crea el DTO para habilitar o deshabilitar la autenticación en 2 factores
en src/auth/dto/set2FA.dto.ts
import { IsBoolean, IsNotEmpty } from 'class-validator';
export class Set2FADto {
@IsNotEmpty()
@IsBoolean()
set2FA: boolean;
}
Crea la ruta en AuthController
import { Set2FADto } from './dto/set2FA.dto';
@Auth()
@Post('set/twofa')
set2FA(@Body() set2FADto: Set2FADto, @GetUser() user: User) {
return this.authService.set2FA(user, set2FADto);
}
Crea un nuevo método en AuthService
import { Set2FADto } from './dto/set2FA.dto';
async set2FA(user: User, { set2FA }: Set2FADto) {
// Comprueba si el usuario está autenticado
if (!user) throw new UnauthorizedException();
// Comprueba si la configuración de la autenticación de dos factores ya coincide con el valor proporcionado
if (user.twoFA === set2FA) {
return { success: true };
}
// Comprueba si la autenticación de dos factores está habilitada y el valor proporcionado es false
// Si es así, se envía un código OTP para deshabilitar la autenticación de dos factores
if (user.twoFA && set2FA === false) {
return await this.sendOTP(
user,
'D2FA',
'El código para deshabilitar la autenticación de dos factores fue enviado',
);
}
// Actualiza la configuración de autenticación de dos factores del usuario
const updatedUser = await this.userRepository.preload({
id: user.id,
twoFA: set2FA,
});
// Guarda los cambios en la base de datos
await this.userRepository.save(updatedUser);
return { success: true };
}
Este método realiza lo siguiente:
Si el parámetro set2FA del cuerpo de la solicitud es verdadero y el usuario no tiene habilitada la autenticación de dos factores (2FA), se habilita la autenticación de dos factores en su cuenta.
Si el parámetro set2FA del cuerpo de la solicitud es falso y el usuario ya tiene habilitada la autenticación de dos factores, se envía un OTP (código de verificación de un solo uso) con el caso de uso D2FA a su número de teléfono. El OTP se validará en un punto de conexión separado antes de deshabilitar 2FA en la cuenta del usuario.
Desactivar la autenticación en 2 factores
Para deshabilitar la autenticación de dos factores en su cuenta, el usuario recibirá un OTP.
Este OTP se validará antes de deshabilitar definitivamente 2FA en su cuenta. Crearemos el manejador de ruta y el método de servicio correspondiente para esto.
AuthController
@Auth()
@Post('disable-twofa/verify')
disable2FA(
@Body() verificationCodeDto: VerificationCodeDto,
@GetUser() user: User,
) {
return this.authService.disable2FA(user, verificationCodeDto);
}
AuthService
async disable2FA(user: User, { code }: VerificationCodeDto) {
// Verificar si el usuario existe
if (!user) throw new UnauthorizedException();
// Verificar si el código tiene una longitud válida
if (code.length !== 6) throw new BadRequestException('Invalid Code');
// Obtener el registro de OTP (One-Time Password) correspondiente al usuario y código proporcionados
const otpRecord = await this.getOTPRecord(user, code, 'D2FA');
// Desactivar la autenticación de dos factores en el objeto de usuario actualizado
const updatedUser = await this.userRepository.preload({
id: user.id,
twoFA: false,
});
// Eliminar el registro de OTP correspondiente
await this.otpRepository.delete(otpRecord.id);
// Guardar los cambios realizados en el objeto de usuario actualizado en la base de datos
await this.userRepository.save(updatedUser);
// Devolver una respuesta indicando el éxito de la desactivación de 2FA
return { success: true };
}
Para verificar el OTP enviado para desactivar la autenticación de dos factores (2FA), se enviará una solicitud POST a api/auth/disable-twofa/verify
.
En el método disable2FA
, hacemos uso de nuestro helper para verificar si el OTP existe en la base de datos y, si existe, comprobamos si ha caducado.
Si el OTP aún es válido, actualizamos el registro del usuario en la base de datos estableciendo el campo twoFA en falso. Por último, eliminamos el registro del OTP de la base de datos.
Login para cuentas con 2FA habilitado
En el método login del servicio AuthService
si el usuario no tiene habilitada su cuenta para autenticarse usando 2 factores , se retorna el usuario junto con un token de acceso.
Ahora vamos a ver el caso de uso , donde el usuario si tiene habilitada su cuenta para autenticarse usando 2 factores.
Empecemos por editar el método login que creamos al inicio de esta guía.
async login({ email, password }: LoginUserDto) {
const user = await this.userRepository.findOne({ where: { email } });
if (!user) {
console.log('EMAIL');
throw new UnauthorizedException('Incorrect email or password');
}
const passwordsMatch: boolean = verifyPassword({
hashedPassword: user.password,
password,
});
if (!passwordsMatch) {
console.log('PASSWORD');
throw new UnauthorizedException('Incorrect email or password');
}
if (!user.twoFA) {
const payload = {
id: user.id,
email: user.email,
fullName: user.fullName,
sub: user.id,
};
delete user.password;
return {
...user,
twoFA: false,
access_token: this.signJWT(payload.id),
};
}
return await this.sendOTP(user, 'LOGIN', 'Code for login sent,check inbox');
}
Actualizamos el método para que ahora cuando el usuario tenga activa la autenticación en 2 factores,se le envie un OTP de 6 digitos a su número de teléfono con el caso de uso "LOGIN" para continuar el logueo.
Verificar el OTP para el Login
Ahora necesitamos verificar el código que le enviamos al usuario,para eso crea una nueva ruta en AuthController
@Auth()
@Post('login/verify/token')
validateLoginOTP(
@Body() verificationCodeDto: VerificationCodeDto,
@GetUser() user: User,
) {
return this.authService.validateLoginOTP(user, verificationCodeDto);
}
El servidor escuchará por peticiones POST en /api/auth/login/verify/token
Crea el nuevo método en AuthService
async validateLoginOTP(user: User, { code }: VerificationCodeDto) {
if (!user) throw new UnauthorizedException();
if (code.length !== 6) throw new BadRequestException('Invalid Code');
const otpRecord = await this.getOTPRecord(user, code, 'LOGIN');
if (user.id !== otpRecord.userId)
throw new NotFoundException('Invalid code');
return {
...user,
twoFA: true,
access_token: this.signJWT(user.id),
};
}
En este método chequeamos si el OTP es válido haciendo uso una vez más de nuestra función helper,y si lo es retornamos el usuario con un token de acceso y la bandera de twoFA en true, indicando que el usuario se logueo utilizando 2 factores.
Fin
En resumen, la implementación de la autenticación de dos factores (2FA) mejora la seguridad de una aplicación al requerir que los usuarios proporcionen un factor adicional de verificación.
Esto reduce el riesgo de acceso no autorizado y protege los datos sensibles.
En nuestro proyecto con NestJS cubrimos diversos aspectos de la implementación de 2FA, como registro, inicio de sesión, autenticación, validación de solicitudes y protección de rutas.
Al utilizar las características incorporadas de NestJS, como pipes, guards y DTOS podemos garantizar que nuestra implementación es robusta y segura.
Muchas gracias por leer, decime que opinas en los comentarios!
Top comments (0)