This guide provides a step-by-step walkthrough for implementing robust SMS-based Two-Factor Authentication (2FA) and phone number verification using One-Time Passwords (OTP) in a NestJS application. We'll leverage Twilio for SMS delivery and Prisma for database interactions with a PostgreSQL database.
Last Updated: October 26, 2023
Project Overview and Goals
We will build a secure NestJS backend application featuring:
- User Sign-up: Basic user registration with email, phone number, and password.
- User Login: Standard email/password authentication returning a JSON Web Token (JWT).
- Phone Number Verification: Users must verify their phone number via an SMS OTP before enabling 2FA.
- 2FA Enable/Disable: Secure endpoints for users to opt-in or opt-out of 2FA, potentially requiring OTP confirmation for disabling.
- 2FA-Protected Login: If 2FA is enabled, users must provide a valid SMS OTP after successful password verification to receive their JWT.
Problem Solved: This implementation adds a critical layer of security beyond simple passwords, mitigating risks associated with compromised credentials by requiring access to the user's registered phone.
Technologies Used:
- NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Chosen for its modular architecture, built-in features (like pipes, guards, modules), and TypeScript support.
- Node.js: The JavaScript runtime environment.
- TypeScript: Superset of JavaScript adding static types, improving code quality and maintainability.
- Twilio: A cloud communications platform used here to send SMS OTPs. Chosen for its reliable API and developer-friendly tools.
- PostgreSQL: A powerful, open-source object-relational database system. Chosen for its reliability and robustness.
- Prisma: A modern database toolkit for Node.js and TypeScript, simplifying database access with type safety. Chosen for its excellent developer experience and migration management.
- JWT (JSON Web Tokens): Used for stateless session management after successful authentication.
- bcrypt: Library for securely hashing passwords.
- Joi: Library for data validation.
System Architecture:
The system involves the following components interacting:
- Client (Browser/App): Interacts with the NestJS API.
- NestJS API: Handles incoming requests, business logic, validation, authentication, and coordinates with other services (Prisma, Twilio). Contains Controllers, Services, Guards, and Pipes.
- Prisma ORM: Manages database connections, queries, and migrations based on the defined schema. Acts as the data access layer.
- Postgres Database: Stores user data, OTPs, and application state.
- Twilio API: An external service used by the NestJS API to send SMS messages containing OTP codes and potentially receive delivery status updates.
Prerequisites:
- Node.js (LTS version recommended, e.g., v18 or v20) and npm/yarn installed.
- PostgreSQL server installed and running.
- A Twilio account with credentials (Account SID, Auth Token, Twilio Phone Number).
- Basic understanding of TypeScript, Node.js, REST APIs, and relational databases.
- A code editor (like VS Code).
- A tool for testing APIs (like Postman or curl).
Final Outcome: A functional NestJS API with secure user authentication, phone verification, and optional SMS-based 2FA.
1. Setting up the Project
Let's scaffold our NestJS project and configure the basic utilities.
1.1 Install NestJS CLI: If you haven't already, install the NestJS CLI globally.
npm install -g @nestjs/cli
1.2 Create New Project:
Generate a new NestJS project. Replace nestjs-twilio-2fa
with your desired project name.
nest new nestjs-twilio-2fa
cd nestjs-twilio-2fa
This creates a standard NestJS project structure.
1.3 Initial Cleanup (Optional): The default project includes sample controller and service files. Let's remove them for a clean start.
- Delete
src/app.controller.ts
,src/app.service.ts
, andsrc/app.controller.spec.ts
. - Open
src/app.module.ts
and removeAppController
from thecontrollers
array andAppService
from theproviders
array.
src/app.module.ts
(after cleanup):
import { Module } from '@nestjs/common';
@Module({
imports: [],
controllers: [], // Remove AppController
providers: [], // Remove AppService
})
export class AppModule {}
1.4 Set Global API Prefix:
It's good practice to prefix your API routes (e.g., /api/v1
).
Edit src/main.ts
:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Set global prefix
app.setGlobalPrefix('api/v1');
await app.listen(process.env.PORT || 3000); // Use environment variable for port
console.log(`Application is running on: ${await app.getUrl()}`);
}
bootstrap();
1.5 Configure Logging Middleware: NestJS has built-in logging, but a custom middleware provides more request context.
Create src/common/middleware/logger.middleware.ts
:
import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
private logger = new Logger('HTTP'); // Context for logger
use(request: Request, response: Response, next: NextFunction): void {
const { ip, method, originalUrl } = request;
const userAgent = request.get('user-agent') || '';
response.on('finish', () => {
const { statusCode } = response;
const contentLength = response.get('content-length');
this.logger.log(
`${method} ${originalUrl} ${statusCode} ${contentLength || 0} - ${userAgent} ${ip}`,
);
});
next();
}
}
Register the middleware globally in src/app.module.ts
:
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware'; // Adjust path if needed
@Module({
imports: [],
controllers: [],
providers: [],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes('*'); // Apply to all routes
}
}
1.6 Custom Exception Filter: Handle errors gracefully and provide consistent error responses.
Create the custom filter (src/common/filters/custom-exception.filter.ts
):
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch() // Catch all types of exceptions
export class CustomExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(CustomExceptionFilter.name); // Instantiate logger
catch(exception: unknown, host: ArgumentsHost) { // Catch 'unknown' for broader compatibility
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status: number;
let message: string;
let errorName: string;
let stack: string | undefined;
if (exception instanceof HttpException) {
status = exception.getStatus();
const errorResponse = exception.getResponse();
message = typeof errorResponse === 'string'
? errorResponse
: (errorResponse as any)?.message || exception.message;
errorName = exception.constructor.name;
// Optionally log stack for specific HTTP errors if needed
// stack = exception.stack;
} else if (exception instanceof Error) {
// Handle generic errors
status = (exception as any)?.statusCode || HttpStatus.INTERNAL_SERVER_ERROR;
message = exception.message || 'Internal server error';
errorName = exception.name || 'InternalServerError';
stack = exception.stack; // Capture stack for generic errors
} else {
// Handle non-Error exceptions
status = HttpStatus.INTERNAL_SERVER_ERROR;
message = 'An unexpected error occurred';
errorName = 'UnknownError';
// Try to stringify the exception for logging
try {
stack = JSON.stringify(exception);
} catch {
stack = 'Could not stringify the unknown exception.';
}
}
// Log the error
this.logger.error(
`HTTP Status: ${status} Error: ${errorName} Message: ${message} Path: ${request.url}`,
stack, // Log stack trace, especially for non-HttpExceptions
CustomExceptionFilter.name, // Context for the logger
);
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
error: errorName,
message: message,
});
}
}
Register the filter globally in src/main.ts
:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { CustomExceptionFilter } from './common/filters/custom-exception.filter'; // Adjust path
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api/v1');
app.useGlobalFilters(new CustomExceptionFilter()); // Register filter
await app.listen(process.env.PORT || 3000);
console.log(`Application is running on: ${await app.getUrl()}`);
}
bootstrap();
Why these choices?
- Global Prefix: Standardizes API access.
- Logger Middleware: Provides essential request visibility for debugging and monitoring.
- Custom Exception Filter: Ensures consistent, informative error responses for clients and detailed server-side logging, crucial for production environments. Catches both NestJS
HttpException
s and standard JavaScriptError
s.
2. Database Setup with Prisma
We'll set up PostgreSQL and use Prisma to interact with it.
2.1 Set up PostgreSQL Database:
Using psql
or a GUI tool, create your database.
-- Connect using psql (replace 'postgres' with your superuser if different)
-- psql -U postgres
CREATE DATABASE nest_twilio_2fa_dev; -- Or your preferred name
2.2 Install Prisma: Install the Prisma CLI as a development dependency and the Prisma Client.
npm install -D prisma
npm install @prisma/client
2.3 Initialize Prisma:
This command creates a prisma
directory with a schema.prisma
file and a .env
file at the project root.
npx prisma init --datasource-provider postgresql
2.4 Configure Database Connection:
Prisma automatically creates a .env
file (or adds to an existing one). Update the DATABASE_URL
with your PostgreSQL connection string.
.env
:
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
# Syntax: DATABASE_URL=""protocol://USER:PASSWORD@HOST:PORT/DATABASE?schema=SCHEMA""
# Ensure special characters in the password or other parts are URL-encoded if needed.
DATABASE_URL=""postgresql://YOUR_DB_USER:YOUR_DB_PASSWORD@YOUR_DB_HOST:YOUR_DB_PORT/YOUR_DB_NAME?schema=public""
# Example:
# DATABASE_URL=""postgresql://postgres:mysecretpassword@localhost:5432/nest_twilio_2fa_dev?schema=public""
- Replace placeholders (
YOUR_DB_USER
,YOUR_DB_PASSWORD
, etc.) with your actual database credentials. - Ensure
schema=public
is present if you're using the default schema. - The entire connection string value should be enclosed in double quotes (
""
).
2.5 Define Data Models:
Open prisma/schema.prisma
and define the User
and Otp
models.
prisma/schema.prisma
:
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = ""prisma-client-js""
}
datasource db {
provider = ""postgresql""
url = env(""DATABASE_URL"")
}
model User {
id String @id @default(uuid())
email String @unique
phone String @unique // Ensure phone numbers are unique
password String // Hashed password
firstName String
lastName String
twoFA Boolean @default(false) // 2FA status
isPhoneVerified Boolean @default(false) // Phone verification status
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
otps Otp[] // Relation to Otp model
}
model Otp {
id String @id @default(uuid())
code String // The OTP code itself
userId String // Foreign key to User
useCase UseCase // Purpose of the OTP
expiresAt DateTime // Expiry timestamp
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation(fields: [userId], references: [id], onDelete: Cascade) // Relation field
@@index([userId, useCase]) // Index for faster lookups
}
// Enum for different OTP use cases
enum UseCase {
PHONE_VERIFICATION // For verifying phone number initially
TWO_FACTOR_AUTH // For logging in when 2FA is enabled
DISABLE_TWO_FACTOR // Optional: For confirming disabling 2FA
}
Explanation:
User
: Stores user details, including flags fortwoFA
andisPhoneVerified
.Otp
: Stores OTP codes, linked to a user (userId
), with a specificuseCase
and anexpiresAt
timestamp.UseCase
Enum: Defines the distinct purposes for which an OTP can be generated, improving clarity and logic.onDelete: Cascade
: If a User is deleted, their associated OTPs are also deleted.@@index
: Improves query performance when finding OTPs for a specific user and use case.
2.6 Run Database Migration: Apply the schema changes to your database. Prisma generates SQL migration files and runs them.
npx prisma migrate dev --name init_user_otp_schema
This command will:
- Create a
prisma/migrations
folder. - Generate a SQL migration file based on your schema changes.
- Apply the migration to your database, creating the
User
andOtp
tables. - Generate/update the Prisma Client (
@prisma/client
) based on your schema.
Important: Whenever you change prisma/schema.prisma
, run npx prisma migrate dev --name <descriptive_migration_name>
to update your database and npx prisma generate
to update the Prisma Client typings.
2.7 Create Prisma Service: Encapsulate Prisma Client logic within a dedicated NestJS service for better organization and testability.
Generate the module and service:
nest generate module prisma
nest generate service prisma --no-spec # No spec file needed for this simple service
Implement the service (src/prisma/prisma.service.ts
):
import { Injectable, OnModuleInit, INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
// Connect to the database when the module is initialized
await this.$connect();
}
async enableShutdownHooks(app: INestApplication) {
// Ensure Prisma disconnects gracefully on application shutdown
// Listen for the 'beforeExit' event emitted by Prisma Client
process.on('beforeExit', async () => {
console.log('Closing NestJS application and disconnecting Prisma Client...');
await app.close(); // This should trigger Prisma's own shutdown logic
console.log('Prisma Client disconnected.');
});
}
}
Configure the module (src/prisma/prisma.module.ts
):
import { Module, Global } from '@nestjs/common'; // Make it Global
import { PrismaService } from './prisma.service';
@Global() // Make PrismaService available globally without importing PrismaModule everywhere
@Module({
providers: [PrismaService],
exports: [PrismaService], // Export the service
})
export class PrismaModule {}
Import PrismaModule
into the root AppModule
(src/app.module.ts
):
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { PrismaModule } from './prisma/prisma.module'; // Import PrismaModule
@Module({
imports: [PrismaModule], // Add PrismaModule here
controllers: [],
providers: [],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes('*');
}
}
Enable shutdown hooks in src/main.ts
:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { CustomExceptionFilter } from './common/filters/custom-exception.filter';
import { PrismaService } from './prisma/prisma.service'; // Import PrismaService
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api/v1');
app.useGlobalFilters(new CustomExceptionFilter());
// Get PrismaService instance and enable shutdown hooks
const prismaService = app.get(PrismaService);
await prismaService.enableShutdownHooks(app); // Call the hook setup
await app.listen(process.env.PORT || 3000);
console.log(`Application is running on: ${await app.getUrl()}`);
}
bootstrap();
Why these choices?
- Prisma: Provides type safety, auto-completion, and simplifies database interactions and migrations significantly compared to writing raw SQL or using less integrated ORMs.
- Prisma Service: Decouples database logic from business logic, adhering to SOLID principles.
- Global Module: Makes the
PrismaService
easily injectable across the application. - Shutdown Hooks: Ensures the database connection is closed properly when the application stops.
- UUIDs: Using UUIDs for primary keys is generally better for distributed systems and avoids exposing sequential information compared to auto-increment integers.
3. Implementing User Sign-up
Create the endpoints and logic for user registration.
3.1 Install Dependencies:
npm install joi bcrypt class-validator class-transformer
npm install -D @types/bcrypt @types/joi
joi
: For request validation schemas used with our custom pipe.bcrypt
: For hashing passwords securely.class-validator
,class-transformer
: Standard NestJS validation libraries. While this guide uses a Joi pipe for validation consistency, these are often used for DTO validation.
3.2 Create Account Module, Controller, Service:
nest generate module account
nest generate controller account --no-spec
nest generate service account --no-spec
3.3 Create Validation Pipe: We need a pipe to validate incoming request bodies using Joi schemas.
Create src/common/pipes/joi-validation.pipe.ts
:
import {
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException,
} from '@nestjs/common';
import { ObjectSchema } from 'joi';
@Injectable()
export class JoiValidationPipe implements PipeTransform {
constructor(private schema: ObjectSchema) {}
transform(value: any, metadata: ArgumentMetadata) {
// We only validate the body of the request
if (metadata.type !== 'body' || !value) { // Also check if value exists
return value;
}
const { error, value: validatedValue } = this.schema.validate(value, {
abortEarly: false, // Return all validation errors
allowUnknown: false, // Disallow properties not defined in the schema
stripUnknown: true, // Remove unknown properties instead of throwing error
});
if (error) {
// Format error messages for better readability
const errorMessages = error.details.map((d) => d.message.replace(/"/g, "'")).join('; '); // Use single quotes and semicolon separator
throw new BadRequestException(`Validation failed: ${errorMessages}`);
}
return validatedValue; // Return validated (and potentially stripped) value
}
}
3.4 Create Sign-up DTO and Schema:
Define the expected request body structure.
Create src/account/dto/create-user.dto.ts
:
// Although we use Joi pipe, DTO helps with type inference in service/controller
export class CreateUserDto {
email: string;
phone: string; // Consider validation for phone format if needed
password: string;
firstName: string;
lastName: string;
}
Create the Joi validation schema src/account/validation/signup.schema.ts
:
import * as Joi from 'joi';
// Basic password complexity: at least 8 chars, 1 letter, 1 number
const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/;
// Basic phone number regex (allows +, digits, spaces, hyphens - adjust as needed)
// Consider E.164 format for stricter validation: /^\+[1-9]\d{1,14}$/
const phoneRegex = /^[+\d\s-]+$/;
export const signupSchema = Joi.object({
email: Joi.string().email().required().messages({
'string.email': 'Email must be a valid email address',
'any.required': 'Email is required',
}),
phone: Joi.string().pattern(phoneRegex).required().messages({
'string.pattern.base': 'Phone number format is invalid',
'any.required': 'Phone number is required',
}),
password: Joi.string().pattern(passwordRegex).required().messages({
'string.pattern.base': 'Password must be at least 8 characters long and include at least one letter and one number',
'any.required': 'Password is required',
}),
firstName: Joi.string().trim().min(1).required().messages({
'string.empty': 'First name cannot be empty',
'any.required': 'First name is required',
}),
lastName: Joi.string().trim().min(1).required().messages({
'string.empty': 'Last name cannot be empty',
'any.required': 'Last name is required',
}),
});
3.5 Create Password Hashing Utility:
Create src/common/utils/password.util.ts
:
import * as bcrypt from 'bcrypt';
const SALT_ROUNDS = 10; // Recommended salt rounds
export const hashPassword = async (password: string): Promise<string> => {
return bcrypt.hash(password, SALT_ROUNDS);
};
export const verifyPassword = async (
password: string,
hash: string,
): Promise<boolean> => {
return bcrypt.compare(password, hash);
};
3.6 Implement Sign-up Service Logic:
Inject PrismaService
and implement the sign-up logic.
Edit src/account/account.service.ts
:
import { Injectable, ConflictException, InternalServerErrorException, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; // Adjust path if needed
import { CreateUserDto } from './dto/create-user.dto';
import { hashPassword } from '../common/utils/password.util'; // Adjust path if needed
import { User } from '@prisma/client';
import { Prisma } from '@prisma/client'; // Import Prisma namespace for error handling
@Injectable()
export class AccountService {
private readonly logger = new Logger(AccountService.name);
constructor(private prisma: PrismaService) {}
async signUp(createUserDto: CreateUserDto): Promise<Omit<User_ 'password'>> {
const { email, phone, password, firstName, lastName } = createUserDto;
// 1. Check if user with email or phone already exists
const existingUser = await this.prisma.user.findFirst({
where: { OR: [{ email }, { phone }] },
select: { email: true, phone: true } // Select only needed fields
});
if (existingUser) {
if (existingUser.email === email) {
this.logger.warn(`Signup attempt failed: Email ${email} already exists.`);
throw new ConflictException('User with this email already exists');
}
if (existingUser.phone === phone) {
this.logger.warn(`Signup attempt failed: Phone ${phone} already exists.`);
throw new ConflictException('User with this phone number already exists');
}
}
// 2. Hash the password
const hashedPassword = await hashPassword(password);
// 3. Create the user
try {
const user = await this.prisma.user.create({
data: {
email,
phone,
password: hashedPassword,
firstName,
lastName,
// Defaults (isPhoneVerified, twoFA) are set in schema
},
});
this.logger.log(`User created successfully: ${user.id} / ${user.email}`);
// 4. Omit password from the returned user object
const { password: _, ...result } = user;
return result;
} catch (error) {
// Catch potential unique constraint violations (race condition safeguard)
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
const target = (error.meta as any)?.target;
this.logger.warn(`Signup failed due to unique constraint violation on: ${target}`);
if (target && target.includes('email')) {
throw new ConflictException('User with this email already exists.');
} else if (target && target.includes('phone')) {
throw new ConflictException('User with this phone number already exists.');
} else {
throw new ConflictException('A user with these details already exists.');
}
}
// Log and re-throw other errors
this.logger.error(`Error during user signup: ${error.message}`, error.stack);
throw new InternalServerErrorException('Could not create user account.');
}
}
// ... other account service methods will go here
}
3.7 Implement Sign-up Controller Endpoint:
Define the route handler in the controller.
Edit src/account/account.controller.ts
:
import { Controller, Post, Body, UsePipes, HttpStatus, HttpCode } from '@nestjs/common';
import { AccountService } from './account.service';
import { CreateUserDto } from './dto/create-user.dto';
import { signupSchema } from './validation/signup.schema'; // Adjust path
import { JoiValidationPipe } from '../common/pipes/joi-validation.pipe'; // Adjust path
import { User } from '@prisma/client';
// Optional: Import Swagger decorators if using Swagger
// import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger';
// @ApiTags('Account') // Example Swagger tag
@Controller('account') // Route prefix: /api/v1/account
export class AccountController {
constructor(private readonly accountService: AccountService) {}
@Post('signup') // POST /api/v1/account/signup
// @ApiOperation({ summary: 'Register a new user account' }) // Example Swagger operation
// @ApiBody({ type: CreateUserDto }) // Example Swagger body definition
// @ApiResponse({ status: HttpStatus.CREATED, description: 'User created successfully.', type: CreateUserDto }) // Example Swagger response
// @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Validation failed.' })
// @ApiResponse({ status: HttpStatus.CONFLICT, description: 'Email or phone number already exists.' })
@UsePipes(new JoiValidationPipe(signupSchema)) // Apply Joi validation
@HttpCode(HttpStatus.CREATED) // Set default success status to 201
async create(
@Body() createUserDto: CreateUserDto,
): Promise<Omit<User_ 'password'>> {
return this.accountService.signUp(createUserDto);
}
// ... other account controller methods
}
Make sure the AccountService
and AccountController
are correctly listed in src/account/account.module.ts
.
src/account/account.module.ts
:
import { Module } from '@nestjs/common';
import { AccountService } from './account.service';
import { AccountController } from './account.controller';
// PrismaModule is global, no need to import here
@Module({
controllers: [AccountController],
providers: [AccountService],
exports: [AccountService] // Export if needed by other modules like AuthModule
})
export class AccountModule {}
Import AccountModule
into the root AppModule
(src/app.module.ts
):
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { PrismaModule } from './prisma/prisma.module';
import { AccountModule } from './account/account.module'; // Import AccountModule
@Module({
imports: [
PrismaModule,
AccountModule // Add AccountModule here
],
controllers: [],
providers: [],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes('*');
}
}
Why these choices?
- Joi Pipe: Provides robust, schema-based validation at the entry point of the controller.
- DTO: Enforces type safety within TypeScript code.
- bcrypt: Industry standard for password hashing.
- Conflict Check: Prevents duplicate user registrations based on unique fields. Includes robust Prisma error handling.
- Password Omission: Never return the hashed password to the client.
4. Implementing User Login (Password Phase)
Set up the initial login mechanism using email and password, returning a JWT if credentials are valid but before handling the 2FA OTP step.
4.1 Install JWT Dependency:
npm install @nestjs/jwt dotenv
# dotenv is likely already installed by NestJS CLI, ensure it is.
4.2 Configure JWT Module:
Define a JWT secret in your .env
file. Use a strong, randomly generated secret in production.
.env
:
DATABASE_URL=""postgresql://postgres:mysecretpassword@localhost:5432/nest_twilio_2fa_dev?schema=public""
# Generate a strong secret (e.g., using openssl rand -hex 32)
JWT_SECRET=""your-very-strong-and-secret-jwt-key-CHANGE-ME""
JWT_EXPIRATION_TIME=""2h"" # Example: Token valid for 2 hours
Create a configuration file for easy access to constants (src/common/config/config.ts
):
import * as dotenv from 'dotenv';
dotenv.config(); // Load .env file variables
export const config = {
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRATION_TIME || '1h', // Default expiration
},
// Add other configs like Twilio later
};
// Validate essential config on startup
if (!config.jwt.secret || config.jwt.secret.length < 10) { // Basic check for presence and minimum length
console.error(""FATAL ERROR: JWT_SECRET environment variable is missing or too short."");
process.exit(1); // Exit if critical config is missing
}