Lazyweb Blogs

Boost App Security: Passwordless Authentication & SMS OTP with Node.js, TypeScript, & PostgreSQL

14 min read

Cover Image for Boost App Security: Passwordless Authentication & SMS OTP with Node.js, TypeScript, & PostgreSQL

Setting Up Your TypeScript Node.js Project

Starting a new Node.js project with TypeScript can seem daunting, but it doesn't have to be. You have two primary ways to set up your TypeScript Node.js project: manually, which gives you full control over your configuration, or by using a convenient npm package boilerplate.

For those who prefer a quick and efficient start without the fuss of manual configuration, I've created an npm package boilerplate that will set up a TypeScript Node.js project with essential tools and configurations.

Using the NPM Package Boilerplate

Here’s how you can use the boilerplate to set up your project:

  1. Run the Boilerplate Command: Begin by running the boilerplate command in your terminal:

     npx cr-ts-node
    
  2. Enter Project Details: The CLI will prompt you to enter your project name and description. Choose a name that reflects the purpose of your project, and provide a brief description of what the application will do. For example:

     Enter project name: auth
     Enter project description: Demo Authentication
    
  3. Automated Setup: The boilerplate will take care of the rest, initializing your npm project, installing dependencies, and setting up TypeScript configurations. You'll see output indicating the process and success messages:

     Creating project: auth...
     Setting up basic Express server...
     Project auth setup complete. Go to auth and run 'npm start' to start the server.
    

    You'll also notice messages about the creation of .gitignore, .env, and README.md files, among other essentials.

Manual Setup

If you choose to set up your project manually, you'll need to:

  1. Initialize a new npm project with npm init.

  2. Install TypeScript and its Node.js types with npm install typescript @types/node.

  3. Create a tsconfig.json file to configure TypeScript options.

  4. Install the required node modules like express.

  5. Set up a script in your package.json to compile and run your TypeScript app.

Next Steps

Once you've set up your project, whether through the boilerplate or manually, your next step is to structure your application's directory. This includes creating directories for your models, utilities, controllers, and routes.

With the project setup out of the way, we're ready to dive into creating the authentication system using SMS OTP in Node.js and TypeScript! Let's move on to connecting our application to a PostgreSQL database.

Installing Key Packages and Understanding the Folder Structure

With your TypeScript Node.js project initialized, the next step is to install the necessary packages that will power your authentication system. Each package plays a critical role in the functionality of your app. Here’s how to set it up.

Essential Packages:

To create a robust authentication system, you’ll need the following packages:

  • Sequelize: An ORM for Node.js, which supports PostgreSQL, MySQL, SQLite, and MSSQL.

  • Twilio: For sending OTPs via SMS.

  • jsonwebtoken: To generate and verify JWT tokens for secure authentication.

  • otp-generator: To generate one-time passwords for the OTP-based authentication process.

  • pg and pg-hstore: PostgreSQL client for Node.js and a module to serialize and deserialize JSON data to hstore format.

Installation:

To install these packages, run the following command in your terminal:

npm install sequelize twilio jsonwebtoken otp-generator pg pg-hstore

You’ll also want to install the TypeScript definitions for these packages to take advantage of TypeScript's static typing. This can be done using the following command:

npm install @types/sequelize @types/twilio @types/jsonwebtoken @types/pg @types/pg-hstore -D

These commands will install the packages and their type definitions as dev dependencies, making your development process smoother and more error-proof.

Folder Structure:

Now let’s break down the folder structure and understand the role of each component:

  • src: The source directory where your TypeScript files reside.

    • controller: Contains controllers like user that define the logic for handling incoming requests.

    • modals: Here you define your Sequelize models. Will will have otp and user subfolders for defining OTP and user-related data structures.

    • routes: Manages your application's routing, the user folder will handle user-related routes.

    • utils: A collection of utility files. db.ts for the database connection, errorHandling.ts for managing errors, jwt.ts for JSON Web Tokens, and sns.ts for SMS functionality.

  • dist: The compiled JavaScript files from your TypeScript source will be output here.

  • node_modules: The folder where all your installed npm packages live.

  • util: Could contain additional utilities not directly related to the express server functionality.

  • Root Level Files:

    • .env and .env.example: For your environment variables.

    • server.ts: The main entry point for your server.

    • tsconfig.json: TypeScript compiler configuration.

Understanding this structure is crucial as it helps maintain organization and scalability, making your code easier to navigate and manage. With the packages installed and a clear directory structure, you’re well on your way to developing the authentication features for your app.

Setting Up a Database Connection with Neon and Sequelize

To get your PostgreSQL server up and running for your Node.js application, you can use Neon for a hassle-free, managed database solution. After signing up and creating a PostgreSQL instance on Neon, grab the connection URL they provide.

With the connection string in hand, it's time to integrate it into your Node.js project using Sequelize. Update your db.ts file with the following code to establish a connection:

import { Sequelize } from 'sequelize';
import dotenv from 'dotenv-safe';

dotenv.config();

// Connect to the PostgreSQL server hosted on Neon
const sq = new Sequelize(process.env.DATABASE_URL);

const db = async () => {
    try {
        // Test the connection to the PostgreSQL database
        await sq.authenticate();
        console.log('Connected to the database successfully.');
    } catch (error) {
        // Log any errors
        console.error('Database connection error:', error);
    }
}

export { db, sq };

This configuration ensures your app connects to the database at startup, allowing you to move on to defining models and building your authentication system.

Integrating the Database Connection

Once your db.ts is set up, you'll need to integrate it with the rest of your application:

  • Call the db function at the start of your application to ensure you're connected to the database before handling any requests.

  • Use the sq instance when defining models and querying the database within your application.

Integrating JWT for Secure Authentication

JSON Web Tokens (JWT) are an essential part of secure authentication in web applications. They provide a way to transmit user information securely as a JSON object. Here’s how to set up JWT in your TypeScript Node.js project using the jsonwebtoken package.

Setting Up JWT (jwt.ts):

  1. Import Necessary Modules: Start by importing the jsonwebtoken library and other necessary modules:

     import jwt from 'jsonwebtoken';
     import { config } from 'dotenv-safe';
     import { Request, Response, NextFunction } from 'express';
     import User from '../modals/user'; // Adjust the path as necessary
    
  2. Environment Configuration: Initialize dotenv-safe to load and use environment variables securely:

     config();
    
  3. Defining Secret Key: Define a secret key for signing JWTs. You should store this in your .env file and never expose it in your codebase:

     const secret = process.env.JWT_SECRET;
    
  4. Generate Token Function: Create a function to generate tokens. This will sign the payload with your secret key and set an expiration:

     export const generateToken = (id: string) => {
       return jwt.sign({ id }, secret, { expiresIn: '30d' });
     };
    
  5. Verify Token Function: Implement a function to verify the given token with your secret key:

     const verifyToken = (token: string) => {
       return jwt.verify(token, secret);
     };
    
  6. Verify Token Middleware: Use middleware to protect routes that require authentication. It should extract the token from request headers, verify it, and handle the logic for expired or invalid tokens:

     export const verifyTokenMiddleware = async (req: Request, res: Response, next: NextFunction) => {
       const token = req?.headers?.authorization?.split(' ')[1];
       if (!token) return res.status(401).json({ status: 'error', message: 'Access Denied' });
    
       try {
         const verified = verifyToken(token);
         if (!verified) return res.status(400).json({ status: 'error', message: 'Invalid Token' });
    
         if (new Date((verified as jwt.JwtPayload).exp! * 1000) < new Date()) return res.status(400).json({ status: 'error', message: 'Token Expired.' });
    
         const user = await User.findOne({ where: { phone: (verified as jwt.JwtPayload).id } });
         if (!user) return res.status(400).json({ status: 'error', message: 'Invalid Token' });
         const userFromDB =  user.get({ plain: true });
         console.log(userFromDB)
         req.user = userFromDB;
         next();
       } catch (error) {
         res.status(400).json({ status: 'error', message: 'Invalid Token.' });
       }
     }
    
  7. Standalone Verify Token Function: You might also need a function to verify tokens outside of the middleware context:

     export const verifyTokenFunction = async (token: string) => {
       // Your standalone verification logic here
      try {
         const verified = verifyToken(token);
    
         if (!verified) return { status: 'error', message: 'Invalid Token.' };
    
         if (new Date((verified as jwt.JwtPayload).exp! * 1000) < new Date()) return { status: 'error', message: 'Token Expired.' };
    
         const user = await User.findOne({ where: { id: (verified as jwt.JwtPayload).id } });
         if (!user) return { status: 'error', message: 'Invalid Token.' };
         return { status: 'success', user };
       } catch (error) {
         return { status: 'error', message: 'Invalid Token.' };
       }
     };
    

With these steps, you have set up the JWT functionality in your project, which includes generating tokens for users upon login or registration and verifying those tokens with each request to protected routes. This will ensure that only authenticated users can access certain parts of your application.

Implementing SMS Sending Functionality with Twilio

To enable your application to send SMS messages, such as OTPs for authentication, you will integrate Twilio's service. Here is how you can set up the sns.ts module to handle sending SMS.

Setting Up the Twilio Client (sns.ts):

  1. Import Modules: Begin by importing the necessary modules:

     import twilio from 'twilio';
     import dotenv from 'dotenv-safe';
    
  2. Configure Environment Variables: Load your environment variables which should include your Twilio credentials:

     dotenv.config();
    
  3. Initialize Twilio Client: Use the Twilio credentials from your environment variables to create a Twilio client:

     const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
    
  4. CreatesendSMS Function: Define an asynchronous sendSMS function that takes a phone number and a message as parameters:

     const sendSMS = async (phoneNumber: string, message: string) => {
       try {
         // Attempt to send the SMS message
         await client.messages.create({
           body: message,
           from: process.env.TWILIO_PHONE_NUMBER,
           to: phoneNumber
         });
       } catch (err) {
         // Log any errors to the console
         console.error(err);
       }
     };
    
  5. Export thesendSMS Function: Make the sendSMS function available for import in other modules of your application:

     export default sendSMS;
    

By following these steps, you've now equipped your Node.js application with the ability to send SMS messages using Twilio. This will primarily be used for sending OTPs as part of the two-factor authentication process, greatly enhancing the security of user accounts.

Setting Up the Models for User and OTP

Having completed the setup of utility functions, we'll now shift our focus to defining the models for our application. These models will interact with the PostgreSQL database and represent the User and OTP entities, which are core to the authentication process.

Creating the User Model

The User model is crucial for storing user-related information, such as their name, email, and uniquely identifying phone number.

  1. Define the User Schema: Here's how you can define your User model within the user.ts file in your modals directory:

     import { sq } from "../../utils/db";
     import { DataTypes } from "sequelize";
    
     const User = sq.define('user', {
         name: {
             type: DataTypes.STRING,
             allowNull: true
         },
         email: {
             type: DataTypes.STRING,
             allowNull: true,
         },
         phone: {
             type: DataTypes.STRING,
             allowNull: false,
             unique: true,
             validate:{
               is: /^\+91[0-9]{10}$/
             }
         }
     });
    
     export default User;
    
  2. Validation and Constraints: The phone field is marked as unique and includes a regex pattern to validate that the input matches the specified format.

Creating the OTP Model

The OTP model will hold the one-time password associated with a user's phone number, along with the expiration time of the OTP.

  1. Define the OTP Schema: Similarly, define the OTP model in an otp.ts file within the modals directory:

     import { sq } from "../../utils/db";
     import { DataTypes } from "sequelize";
    
     const Otp = sq.define('otp', {
         phone: {
             type: DataTypes.STRING,
             allowNull: false,
             unique: true,
             validate:{
               is: /^\+91[0-9]{10}$/
             }
         },
         otp: {
             type: DataTypes.STRING,
             allowNull: false,
         },
         timeToLive: {
             type: DataTypes.DATE,
             allowNull: false,
             defaultValue: DataTypes.NOW
         }
     });
    
     export default Otp;
    
  2. Time-to-Live: The timeToLive field uses DataTypes.DATE and is set to the current timestamp by default, which will be used to validate the OTP's validity period.

By defining these models, you lay the foundational structures for handling users and their authentication OTPs within your application, enabling secure sign-up and login processes.

Setting Up Authentication Controllers

With our utility functions and models in place, it's time to create the controllers responsible for handling user authentication, specifically OTP generation and verification.

Generate OTP Controller

This controller will handle the OTP generation process:

  1. Setup Controller Imports: Load all necessary modules and utilities:

     import User from "../../modals/user";
     import Otp from "../../modals/otp";
     import otpGenerator from "otp-generator";
     import { Request, Response } from "express";
     import { asyncMiddleware } from "../../utils/errorHandling";
     import sendSMS from "../../utils/sns";
    
  2. Create OTP Generation Logic: Inside the generateOtp function, include logic to generate an OTP, store it with a TTL (time-to-live), and send it to the user's phone:

     export const generateOtp = asyncMiddleware(async (req: Request, res: Response) => {
         const { phone } = req.body;
         const otp = otpGenerator.generate(6, { digits: true, alphabets: false, upperCase: false, specialChars: false });
         const ttl = new Date();
         ttl.setMinutes(ttl.getMinutes() + 15); // Set OTP expiration time
    
         await Otp.sync();
         await Otp.destroy({ where: { phone } }); // Remove any existing OTPs for this phone
         await Otp.create({ phone, otp, timeToLive: ttl }); // Create a new OTP record
         await sendSMS(phone, `Your OTP is ${otp}`); // Send OTP via SMS
    
         res.status(200).json({ status: 'success', message: 'OTP generated successfully.' });
     });
    

Verify OTP Controller

This controller will validate the OTP received from the user:

  1. Setup Token Generation Utility: Import the token generation utility for creating JWTs post-verification:

     import { generateToken } from "../../utils/jwt";
    
  2. Create OTP Verification Logic: Implement the verifyOtp function that checks the OTP and, if valid, generates a token:

     export const verifyOtp = asyncMiddleware(async (req: Request, res: Response) => {
         const { phone, otp } = req.body;
    
         const otpRecord = await Otp.findOne({ where: { phone, otp } });
         if (!otpRecord) throw new Error('Invalid OTP.');
    
         // Check if OTP has expired
         if (new Date() > new Date(otpRecord.timeToLive)) throw new Error('OTP expired.');
    
         await Otp.destroy({ where: { phone, otp } }); // OTP is single-use, so remove it
    
         let user = await User.findOne({ where: { phone } });
         if (!user) {
             user = await User.create({ phone }); // If user doesn't exist, create a new record
         }
    
         const token = generateToken(user.id); // Generate JWT token for the user
         res.status(200).json({ status: 'success', token });
     });
    

With these controllers, you now have a functional backend for sending OTPs to users and verifying them, a common yet effective layer of security for modern web applications. The next step is to wire these controllers into your routes, allowing them to respond to user requests.

Integrating OTP Authentication into Your Routes

Having established our models and controllers for handling OTP generation and verification, we now proceed to make these functionalities accessible via HTTP endpoints. This involves setting up routes that the frontend of your application can interact with to initiate and complete the authentication process.

Setting Up Express Routes

Express routes will act as the bridge between your application's frontend and the OTP authentication logic residing in your controllers. Here’s how to set up these routes:

  1. Import Express and Controllers: Start by importing the necessary modules. You'll need Express for creating the router and your authentication controllers for handling the OTP generation and verification:

     import { Router } from "express";
     import { generateOtp, verifyOtp } from "../../controller/user";
    
  2. Create the Router: Utilize Express's Router to define paths that are linked to their respective controller functions:

     const router = Router();
    
  3. Define Authentication Routes: Specify the endpoints for OTP generation and verification. Here, we create two POST routes — one for generating the OTP and another for verifying it:

     router.post('/generate-otp', generateOtp);
     router.post('/verify-otp', verifyOtp);
    
  4. Export the Router: Make the router available for use in other parts of your application, especially where you set up your Express server:

     export default router;
    

Incorporating Routes into Your Server Setup

After defining these routes, integrate them into your main server file . This will involve importing the router and using it as middleware in your Express application, enabling your server to handle requests to the specified endpoints.

By completing these steps, you effectively open up a channel through which your application's users can initiate the OTP authentication process by requesting an OTP and then verifying it. This not only enhances the security of your application but also provides a seamless authentication experience for the users.

Conclusion

In this guide, we’ve navigated through the process of adding an extra layer of security to your Node.js application by implementing SMS-based OTP authentication. From setting up your project with TypeScript and essential npm packages, connecting to a PostgreSQL database using Sequelize, to defining user and OTP models, and finally integrating Twilio for SMS functionality, we've covered the critical steps to secure your application.

We further developed controllers to handle OTP generation and verification, ensuring that these processes are both secure and efficient. By integrating these controllers with Express routes, we made these functionalities accessible via HTTP endpoints, allowing for seamless interaction between the frontend and backend of your application.

Key Takeaways:

  • Security: SMS OTP adds a significant security layer, protecting against unauthorized access.

  • Scalability: The modular approach in setting up models, controllers, and routes allows for easy expansion and integration of additional features.

  • User Experience: Despite the additional security layer, the process remains user-friendly, offering a balance between security and usability.

This setup not only secures your application but also provides a foundation on which you can build more complex authentication systems, such as implementing 2FA or integrating with other identity verification services.

Remember, the security landscape is ever-evolving, and staying updated with best practices and potential vulnerabilities in your authentication flow is crucial. Regularly review your security measures, update your dependencies, and test your application for vulnerabilities to ensure that your user's data remains protected.

Congratulations on taking a significant step towards securing your Node.js application!