Published on

JWT Authentication and Authorization with TypeScript, Express, and MongoDB

Authors
Buy Me A Coffee

Table of Contents

Introduction

Welcome to our comprehensive tutorial on building a REST API with Node.js/Express and MongoDB! Throughout this guide, we'll walk you through the process of creating a robust API that empowers users to perform essential actions such as user creation, login, profile retrieval, single-device logout, and multi-device logout.

Harnessing the power of Node.js/Express alongside MongoDB, we'll craft a seamless experience for handling user interactions efficiently. To ensure scalability and accessibility, we'll leverage MongoDB Atlas, a versatile cloud database service designed specifically for hosting MongoDB databases.

While MongoDB Atlas offers unparalleled convenience, it's worth noting that you can opt for a local MongoDB setup if preferred. However, for the sake of clarity and focus, we'll omit the installation and configuration of a local MongoDB database in this tutorial.

Prepare to embark on a journey towards mastering RESTful API development with Node.js, Express, and MongoDB. Let's dive in!

Setting Up a MongoDB Database on Atlas

To begin with, let's establish a MongoDB database on Atlas. If you haven't created an account on MongoDB Atlas yet, fear not! You can easily sign up for free here.

Once you've successfully logged in to your account, navigate to the dashboard sidebar and select Database. From there, follow these simple steps:

1. Creating a Database:

  • Click on Build a Database.
  • Opt for the M0 free version, maintain AWS as the provider, allowing MongoDB to automatically select the closest region to you.
  • Name your cluster without any spaces. For instance, I've named mine mainCluster.
  • Hit the green Create button to initiate the process.

2. Configuring Access:

  • As your cluster is being created, establish a database username and password. Keep these credentials handy as they'll be necessary for the connection string.
  • Select My Local Environment under Where would you like to connect from?.
  • In the IP Access List section, add 0.0.0.0/0 to grant access from anywhere. Provide a brief description and click Finish and Close.

3. Connecting to your Database:

  • Locate the cluster you created and click on the Connect button situated at the top left corner.
  • A modal will appear; navigate to Drivers under the Connect to Your Application section.
  • You'll be directed to the Select your driver and version screen. Stick with the default Node.js driver and version.
  • Copy the connection string provided and securely store it. This string will be crucial for connecting to our MongoDB database.
  • The connection string should resemble this: mongodb+srv://<username>:<password>@maincluster.fg2lmpc.mongodb.net/?retryWrites=true&w=majority&appName=MainCluster.
  • When utilizing it, remember to replace <username> and <password> with the credentials you established earlier.

Congratulations! You've successfully set up a MongoDB database on Atlas.

With the MongoDB setup complete, let's now dive into the fun parts.

File Structure

Now that our database is well set up, we can go ahead and create our project. Create a folder anywhere on your computer and name it. I’ve chosen user-registration-api for mine but feel free to pick a name that suits your project.

└── src/
    ├── app.ts
    ├── controllers/
    │   └── userController.ts
    ├── db/
    │   └── index.ts
    ├── middleware/
    │   └── auth.ts
    ├── models/
    │   └── User.ts
    └── routers/
        └── userRoutes.ts

src/: This is our root folder where our project resides.

  • app.ts: This file will serve as the entry point for our Express server.
  • controllers/: Contains the logic for handling different user-related operations.
  • db/: Houses the database connection logic.
  • middleware/: Stores middleware functions, including the auth middleware for securing endpoints.
  • models/: Holds the data models for our application. In this case, we'll have a User model.
  • routers/: Handles routing logic for our API endpoints. Specifically, userRoutes.ts will define routes related to user interactions.

I personally use VS Code, but feel free to use any IDE you're comfortable with. Assuming you already have Node.js and npm installed, we're all set to proceed. If not, you can easily install them from here.

Once that’s done, still, on your terminal, navigate to your project root folder and type yarn init to initialize our node project. This will provide you with a series of questions that you can navigate through by just hitting enter to accept the default options. This process will create a package.json file which will list all the packages our project depends on.

Install Dependencies

Our project will need a number of npm packages and below is the list of those packages and a brief explanation of what each of those packages will help us achieve.

  • Express: A node.js framework that makes it easy to build web applications.
  • mongodb: Official MongoDB driver for Node.js.
  • Mongoose: A MongoDB object modeling tool designed to work in an asynchronous environment. We shall use mongoose to define database schemas and interact with the database.
  • bcrypt: This will help us hash user passwords before storing them into the database.
  • jsonwebtoken: This will help us generate JSON web tokens for authentication. This package will help to set up protected routes that only logged in users can access.
  • validator: We shall use this package to validate and sanitize user input, for example, we need to ensure that a user gives us an email in the right format.
  • env-cmd: This will help us load environment variables from a .env file.
  • nodemon: This will help us run our server in development mode.Nodemon will re-run the express server every time we make changes to our code.
  • Typescript: This will help us write our code in a more structured and readable way.

Now that we've identified the essential packages for our project, let's proceed with their installation. Open your terminal and ensure that you're still positioned in the root directory of your project. Run the following command:

yarn add express mongodb mongoose bcryptjs validator jsonwebtoken

This command installs the necessary packages and adds them to our project's dependencies in the package.json file. Additionally, we'll include nodemon and env-cmd as development dependencies by executing:

yarn add env-cmd nodemon typescript ts-node-dev @types/express --dev

Generate a tsconfig.json file by running the following command:

npx tsc --init

This command will generate a tsconfig.json file in the root directory of our project and will create some compiler options. Let's leave the defaults as is and proceed to the next step.

Before advancing to the next step, let's set up an environment file where we'll define our environment variables. In the root directory, create a .env file.

Next, open the package.json file and add the following script just after the main:index.js line:

"scripts": {
  "start": "env-cmd -f ./.env nodemon src/app.ts"
}

This script configuration ensures that whenever we execute yarn start, nodemon serves our server defined in src/app.ts, utilizing the environment variables specified in the .env file.

Define Environment Variables

Open the .env file and add the following environment variables.

MONGODB_URL=mongodb+srv://<username>:<password>@clustermain.mcy5faw.mongodb.net/?retryWrites=true&w=majority&appName=ClusterMain
JWT_KEY=WinterIsComingGOT2019
PORT=3000

Remember to replace <username> and <password> with the credentials you established earlier.

Setting up an Express Server

In your project directory, navigate to the src/app.ts file. This file serves as the entry point for your Express server. Below is the code snippet that configures and starts your server:

import express from 'express'
import userRouter from './routes/userRouter'
const port = process.env.PORT || 3000
import './db/db'

const app = express()

app.use(express.json())
app.use('/api/users', userRouter)

app.listen(port, () => {
  console.log('Server is running on port 3000')
})

Let's break down what this code accomplishes:

  • Express Setup: The code imports the Express framework to create a web server. It also imports the userRouter module, which defines routes related to user operations.

  • Port Configuration: The port variable is assigned the value of the PORT environment variable, if available. If not, it defaults to port 3000. This allows flexibility in deploying our application by allowing the port to be configured via environment variables.

  • Database Connection: It imports the database configuration from ./db/db. This ensures that our Express server connects to the mongoDB database specified in our configuration.

  • Middleware Setup: The server uses express.json() middleware to parse incoming JSON request bodies. This middleware is essential for handling JSON data in request bodies.

  • Routing: The userRouter is mounted under the /api/users prefix. This means that any routes defined in the userRouter module will be accessible under the /api/users path.

  • Server Startup: The server is started by calling the listen method on the Express application object. It listens for incoming HTTP requests on the specified port. When the server starts successfully, it logs a message to the console indicating the port number.

To run your server, navigate to your terminal and execute the command yarn start or npm start, depending on your package manager. If everything is set up correctly, you should see the message Server is running on port 3000 in your terminal. This indicates that your Express server is up and running, ready to handle incoming requests.

Connecting to MongoDB Database with Mongoose

Go to db/db.ts file and type the code below.

import mongoose from 'mongoose'

if (!process.env.MONGODB_URL) {
  throw new Error('MONGODB_URL is not defined in the environment variables.')
}
console.log('Connecting to MongoDB...')

mongoose.connect(process.env.MONGODB_URL)

This code is responsible for establishing a connection to your MongoDB database using Mongoose, a popular MongoDB object modeling tool for Node.js applications.

  • Environment Variable Check: The code checks if the MONGODB_URL environment variable is defined. If not, it throws an error indicating that the variable is missing from the environment variables.

  • Connection Logging: It logs a message to the console indicating that the application is attempting to connect to the MongoDB database.

  • Database Connection: The mongoose.connect() function is used to establish a connection to the MongoDB database using the URL specified in the MONGODB_URL environment variable.

Defining User Model with Mongoose Schema

Now it’s time to define our user model. Head over to /models/User.ts file and type the following code.

import { Schema, model, Document, Model, HydratedDocument } from 'mongoose'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'

export interface IUser extends Document {
  name: string
  email: string
  password: string
  tokens: { token: string }[]
}

export interface IUserMethods {
  generateAuthToken(): Promise<string>
  toJSON(): IUser
}

interface UserModel extends Model<IUser, {}, IUserMethods> {
  findByCredentials(email: string, password: string): Promise<HydratedDocument<IUser, IUserMethods>>
}

const userSchema = new Schema<IUser, UserModel, IUserMethods>({
  name: { type: String, required: true },
  email: { type: String, required: true },
  password: { type: String, required: true },
  tokens: [{ token: { type: String, required: true } }],
})

userSchema.pre('save', async function (next) {
  if (this.isModified('password')) {
    this.password = await bcrypt.hash(this.password, 8)
  }
  next()
})

userSchema.methods.generateAuthToken = async function () {
  const user = this
  const token = jwt.sign({ _id: user._id.toString() }, process.env.JWT_KEY as string)
  user.tokens = user.tokens.concat({ token })
  await user.save()
  return token
}

userSchema.methods.toJSON = function () {
  const user = this as IUser
  const userObject = user.toObject()
  delete userObject.password
  delete userObject.tokens
  return userObject
}

userSchema.statics.findByCredentials = async (email, password) => {
  const user = await User.findOne({ email })
  if (!user) {
    return null
  }
  const isMatch = await bcrypt.compare(password, user.password)
  if (!isMatch) {
    return null
  }
  return user
}

const User = model<IUser, UserModel>('User', userSchema)

export default User

This code defines a Mongoose model for managing user data in a MongoDB database. Here's a breakdown of what it does:

  • User Interface: The IUser interface defines the structure of a user document in the database. It includes properties like name, email, password, and tokens.

  • User Methods Interface: The IUserMethods interface defines methods that can be applied to user documents, such as generating authentication tokens and converting user objects to JSON format.

  • User Model Interface: The UserModel interface extends the Mongoose Model interface and specifies additional methods available on the User model, like finding users by their credentials.

  • Schema Definition: The userSchema variable defines the schema for the User model. It specifies the structure of user documents, including required fields like name, email, password, and tokens.

  • Pre-Save Hook: The pre middleware function is used to hash the user's password before saving it to the database. This ensures that passwords are securely stored.

  • Instance Methods: Instance methods like generateAuthToken and toJSON are added to the userSchema. These methods handle token generation for authentication and customize the user object returned by toJSON() to exclude sensitive information like passwords and tokens.

  • Static Method: The findByCredentials static method is added to the User model. It allows finding a user by their email and password combination, facilitating user authentication.

  • Model Creation: Finally, the User model is created using the model() function, which binds the schema definition to the User model and exports it for use in other parts of the application.

This code provides a robust foundation for managing user data and authentication in a Node.js application using Mongoose and MongoDB.

Defining User Controllers

The next step would be to create controllers to handle user registration and login. Copy the code below to /controllers/userController.ts:

import User from '../models/user'
import { IUser } from '../models/user'

export const registerUser = async (user: Partial<IUser>) => {
  const { name, email, password } = user
  if (!name || !email || !password) {
    return {
      error: 'Please provide all the required fields',
    }
  }
  const existingUser = await User.findOne({ email })
  if (existingUser) {
    return {
      error: 'User with that email already exists.',
    }
  }
  const newUser = new User({ name, email, password })
  await newUser.save()
  const token = await newUser.generateAuthToken()
  return {
    user: newUser,
    token,
  }
}

export const loginUser = async (user: Partial<IUser>) => {
  const { email, password } = user
  if (!email || !password) {
    return {
      error: 'Please provide all the required fields',
    }
  }
  const existingUser = await User.findByCredentials(email, password)
  if (!existingUser) {
    return null
  }
  const token = await existingUser.generateAuthToken()
  return {
    user: existingUser,
    token,
  }
}

The provided code defines controller functions responsible for handling user registration and login operations in the application. Let's break down each controller function:

registerUser

  • Functionality: This function handles the registration process for new users.
  • Parameters: It takes a partial user object containing the user's name, email, and password as input.
  • Input Validation: It checks if all the required fields (name, email, password) are provided. If any of the fields are missing, it returns an error message.
  • Existing User Check: It queries the database to check if a user with the provided email already exists. If a user with the email exists, it returns an error message indicating that the user already exists.
  • User Creation: If the email is unique, it creates a new user instance with the provided name, email, and password, and saves it to the database.
  • Token Generation: After saving the new user, it generates an authentication token for the user using the generateAuthToken method defined in the User model.
  • Return Value: It returns an object containing the newly created user and the authentication token.

loginUser

  • Functionality: This function handles user login operations.
  • Parameters: It also takes a partial user object containing the user's email and password as input.
  • Input Validation: Similar to the registration function, it checks if both the email and password are provided. If any of the fields are missing, it returns an error message.
  • User Authentication: It attempts to find a user with the provided email and password combination using the findByCredentials static method defined in the User model.
  • Token Generation: If the user is found and the password matches, it generates an authentication token for the user.
  • Return Value: It returns an object containing the authenticated user and the authentication token.

These controller functions serve as the intermediary between the HTTP routes and the database operations, encapsulating the logic for user registration and login within separate functions for clarity and maintainability.

Creating authentication middleware

Middleware serves as a crucial component in software architecture, acting as a bridge between the database and the application, particularly within a network environment. In the context of this project, our objective is to intercept incoming requests before they reach the server and produce a response. The middleware plays a pivotal role in enforcing authorization checks, ensuring that only authorized individuals can access specific resources within the application.

Head over to /middleware/auth.ts and write the following code.

import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import User, { IUser } from '../models/user'

export interface CustomRequest extends Request {
  user?: IUser
  token?: string
}

interface DecodedToken {
  _id: string
}

const auth = async (req: CustomRequest, res: Response, next: NextFunction) => {
  try {
    const token = req.header('Authorization')?.replace('Bearer ', '')
    if (!token) {
      throw new Error('Authentication failed. Token missing.')
    }

    const decoded = jwt.verify(token, process.env.JWT_KEY as string) as DecodedToken
    const user = await User.findOne({
      _id: decoded._id,
      'tokens.token': token,
    })

    if (!user) {
      throw new Error('Authentication failed. User not found.')
    }

    req.user = user
    req.token = token
    next()
  } catch (error) {
    res.status(401).send({ error: 'Authentication failed.' })
  }
}

export default auth

The provided middleware code is responsible for authenticating incoming requests by verifying the presence and validity of JSON Web Tokens (JWTs). Let's break down the functionality of this middleware:

  • Custom Request Interface: The CustomRequest interface extends the default Express Request interface to include additional properties user and token, which represent the authenticated user and the JWT token, respectively.

  • DecodedToken Interface: The DecodedToken interface specifies the structure of the decoded JWT token, containing the _id field representing the user's unique identifier.

  • Middleware Function: The auth function is an asynchronous middleware function that takes three parameters: req (request), res (response), and next (next middleware function).

  • Token Extraction: It extracts the JWT token from the request header using the "Authorization" header. If the token is missing, it throws an error indicating authentication failure due to a missing token.

  • Token Verification: It verifies the authenticity and validity of the JWT token using the jwt.verify() method. If the token is invalid or expired, it throws an error.

  • User Retrieval: It queries the database to find the user associated with the decoded token's _id. If no user is found or if the token is not associated with the user, it throws an authentication error.

  • Request Modification: If the authentication is successful, it attaches the authenticated user and token to the request object (req.user and req.token), making them available to subsequent middleware functions.

  • Error Handling: It catches any errors that occur during the authentication process and sends a 401 (Unauthorized) status response along with an error message indicating authentication failure.

  • Export: The middleware function is exported as the default export, making it accessible to other parts of the application.

This middleware ensures that only authenticated users with valid JWT tokens can access protected routes, enhancing the security of the application.

Defining User Routes

Now most of the pieces are in place. All that's left is to define the routes for this application. Head over to /routes/userRouter.ts and define the following routes:

import express from 'express'
import { IUser } from '../models/user'
import { loginUser, registerUser } from '../controllers/userController'
import auth, { CustomRequest } from '../middleware/auth'

const router = express.Router()

router.post('/register', async (req, res) => {
  const userData: Partial<IUser> = {
    name: req.body.name,
    email: req.body.email,
    password: req.body.password,
  }
  const registeredUser = await registerUser(userData)
  if (registeredUser.error) {
    return res.status(400).json({
      error: registeredUser.error,
    })
  }
  return res.status(201).json(registeredUser)
})

router.post('/login', async (req, res) => {
  const userData: Partial<IUser> = {
    email: req.body.email,
    password: req.body.password,
  }
  const loggedInUser = await loginUser(userData)
  if (loggedInUser?.error) {
    return res.status(400).json({
      error: loggedInUser.error,
    })
  }
  return res.status(200).json(loggedInUser)
})

// Fetch logged in user
router.get('/me', auth, async (req: CustomRequest, res) => {
  return res.status(200).json({
    user: req.user,
  })
})

// Logout user
router.post('/logout', auth, async (req: CustomRequest, res) => {
  if (req.user) {
    req.user.tokens = req.user.tokens.filter((token) => {
      return token.token !== req.token
    })
    await req.user.save()
  }

  return res.status(200).json({
    message: 'User logged out successfully.',
  })
})

// Logout user from all devices
router.post('/logoutall', auth, async (req: CustomRequest, res) => {
  if (req.user) {
    req.user.tokens = []
    await req.user.save()
  }
  return res.status(200).json({
    message: 'User logged out from all devices successfully.',
  })
})

export default router

The provided code defines routes for user registration, login, fetching user details, and user logout operations. Let's explore each route in detail:

/register (POST)

  • Functionality: This route handles user registration by accepting POST requests with user data (name, email, password) in the request body.
  • Controller Function: It calls the registerUser controller function, passing the user data extracted from the request body.
  • Response Handling: If the registration is successful, it returns a 201 (Created) status along with the registered user data. If registration fails due to missing fields or duplicate email, it returns a 400 (Bad Request) status with an error message.

/login (POST)

  • Functionality: This route handles user login by accepting POST requests with user credentials (email, password) in the request body.
  • Controller Function: It calls the loginUser controller function, passing the user credentials extracted from the request body.
  • Response Handling: If login is successful, it returns a 200 (OK) status along with the logged-in user data and authentication token. If login fails due to invalid credentials, it returns a 400 (Bad Request) status with an error message.

/me (GET)

  • Functionality: This route fetches details of the logged-in user.
  • Middleware: It utilizes the auth middleware to ensure that only authenticated users can access this route.
  • Response Handling: If the user is authenticated, it returns a 200 (OK) status along with the user details. If authentication fails, it returns a 401 (Unauthorized) status.

/logout (POST)

  • Functionality: This route logs out the user from the current device.
  • Middleware: It utilizes the auth middleware to ensure that only authenticated users can access this route.
  • Response Handling: If the user is logged out successfully, it returns a 200 (OK) status with a success message.

/logoutall (POST)

  • Functionality: This route logs out the user from all devices.
  • Middleware: It utilizes the auth middleware to ensure that only authenticated users can access this route.
  • Response Handling: If the user is logged out from all devices successfully, it returns a 200 (OK) status with a success message.

These routes handle various user operations, ensuring proper authentication and authorization throughout the application.

Testing Routes with Postman

Now that all the pieces have been put together, we can test our API using Postman. Postman is a powerful tool for testing APIs and verifying their functionality.

Follow these steps to test your API:

  1. Download Postman: If you haven't already, download and install Postman from postman.com.

  2. Launch Postman: Open Postman and create a new request by clicking on the + icon in the top left corner.

  3. Send Requests: Choose the appropriate HTTP method (e.g., POST, GET, PUT, DELETE) and enter the URL of the endpoint you want to test. Here are some examples from our application:

    • To register a new user: POST http://localhost:3000/api/users/register
      • Request Body (JSON): { "name": "John Doe", "email": "john@example.com", "password": "password123" } User registeration endpoint
    • To log in: POST http://localhost:3000/api/users/login
      • Request Body (JSON): { "email": "john@example.com", "password": "password123" } User login endpoint
    • To fetch user details: GET http://localhost:3000/api/users/me (Requires authentication)
      • To authenticate, include the JWT token in the Authorization header of the request. Set the header as follows:
        • Key: Authorization
        • Value: Bearer YOUR_JWT_TOKEN Fetch User details
    • To log out: POST http://localhost:3000/api/users/logout (Requires authentication)
      • Include the JWT token in the Authorization header as described above. Test it out yourself. Try to use the same token after logout and see what happens.
    • To log out from all devices: POST http://localhost:3000/api/users/logoutall (Requires authentication)
      • Include the JWT token in the Authorization header as described above.
  4. Send Request: Click the "Send" button to send the request to your API.

  5. Inspect Response: Once you've sent the request, inspect the response returned by your API. Check the status code, response body, and any headers to ensure that the API is functioning as expected.

  6. Test Different Endpoints: Repeat the process for each endpoint you want to test, including registration, login, fetching user details, and logout operations.

  7. Handle Errors: If you encounter any errors or unexpected behavior during testing, review your code, middleware, and database configurations to identify and resolve issues.

  8. Iterate and Improve: Use Postman to iteratively test and refine your API. Experiment with different input data, edge cases, and scenarios to ensure robustness and reliability.

By thoroughly testing your API with Postman, you can identify and address any issues or bugs before deploying it to production, ensuring a seamless experience.

Project Git repository

You can explore the entire project repository on GitHub for reference and deeper understanding. Here's the link to the repository: GitHub Repository.

Conclusion

Congratulations on completing this tutorial! We've covered a lot of ground, and I hope you've gained valuable insights into building a user registration and authentication API with Node.js, Express.js, and MongoDB.

If you have any questions or need further clarification on any part of the tutorial, don't hesitate to reach out. I'm here to help!

Keep exploring, learning, and building amazing projects!

Till next time, happy coding!