DEV Community

Cover image for Autenticación en 2 factores via SMS con Nest.js, PostgreSQL y Twilio
losarcosdev
losarcosdev

Posted on

Autenticación en 2 factores via SMS con Nest.js, PostgreSQL y Twilio

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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=
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Dependencias de desarrollo:

npm install -D @types/bcrypt @types/cors @types/passport-jwt
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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();

Enter fullscreen mode Exit fullscreen mode

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.tsy 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[];
}

Enter fullscreen mode Exit fullscreen mode

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();
}

Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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 {}

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}

Enter fullscreen mode Exit fullscreen mode

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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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);
  }
}

Enter fullscreen mode Exit fullscreen mode

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),
    };
  }
}

}

Enter fullscreen mode Exit fullscreen mode

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);
};

Enter fullscreen mode Exit fullscreen mode

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

Communication APIs for SMS, Voice, Video & Authentication | Twilio

Connect with customers on their preferred channels—anywhere in the world. Quickly integrate powerful communication APIs to start building solutions for SMS and WhatsApp messaging, voice, video, and email.

favicon twilio.com

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

Image description

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;
  },
);
Enter fullscreen mode Exit fullscreen mode

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()));
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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);
  }
Enter fullscreen mode Exit fullscreen mode

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 };
}
}
Enter fullscreen mode Exit fullscreen mode

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;
};

Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
  }

Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

La función validatePhoneCodevalida 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 getOTPRecordpara 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 getOTPRecordbusca 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;
}

Enter fullscreen mode Exit fullscreen mode

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);
  }
Enter fullscreen mode Exit fullscreen mode

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 };
}

Enter fullscreen mode Exit fullscreen mode

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);
  }
Enter fullscreen mode Exit fullscreen mode

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 };
}

Enter fullscreen mode Exit fullscreen mode

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');
  }
Enter fullscreen mode Exit fullscreen mode

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);
  }
Enter fullscreen mode Exit fullscreen mode

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),
    };
  }
Enter fullscreen mode Exit fullscreen mode

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)