Authentication Flow in an Express Server with Mongoose and MongoDB | Manvi Sharma

Post

editor-img
Manvi Sharma
Jul 22, 2023

Authentication Flow in an Express Server with Mongoose and MongoDB

Hi everyone,

Today we will be discussing the authentication flow setup, that I used in my recent Express and Mongoose application.

Introduction

In today's digital landscape, where security breaches and unauthorized access are persistent threats, implementing a robust authentication flow is crucial for any web application. Whether it's protecting user data, safeguarding sensitive information, or ensuring authorized access to specific resources, a secure authentication system forms the bedrock of a trustworthy and reliable application.

Today we will discuss the intricacies of building a secure authentication flow in an Express server using the power of Mongoose and MongoDB. Express provides a flexible and scalable framework for building web applications, while Mongoose acts as a powerful Object Data Modeling (ODM) library that simplifies working with MongoDB, a widely-used NoSQL database.

Throughout this article, we will explore the different steps involved in setting up a secure authentication flow, including user registration, login, session management, and access control. By following this article, we will gain the knowledge and tools necessary to create a robust authentication system that protects user accounts, verifies credentials, and restricts access to authorized users only.

Overview of the authentication flow:

The authentication flow we will be discussing consists of Registration, Verification, Login, Session Management, Access Control, and Logout.

Before I start, I would like to demonstrate the user schema that I am using for this app

interface TokenDetails { token: string; sessionsDetails: UAParser.IResult;}export interface UserDocument extends mongoose.Document { email: string; password: string; name: string; isOTPVerified: boolean; otp: string; otpGeneratedTime?: string; sessionsDetails: TokenDetails[];}

So we will be storing the user email, password, name, isOTPVerified, otp, otpGeneratedTime and sessionsDetails

Registration:

The registration step allows users to create new accounts and provide credentials for future authentication. Users will typically provide their email, password, and name.

The primary step is to apply validations. I have used JOI for validating the user payload sent over the network call about which you can read more here.

Since using Mongoose, I have hashed the password in a lifecycle method using bcrypt, which you can read more about here.

Once data is validated we will send an OTP to the user's email for verification and save the user in our database.

Note, the user is saved but not verified yet.

Verification:

We will accept the user's email and OTP as the payload in our API call for verification. We will first check the verification time using otpGeneratedTime. In my case, I have set the time for OTP expiry as 30 min. If OTP is still valid then I will compare the OTP from payload to the one in database.

If valid we can just update the user details to the following.

await User.updateOne( { _id: user._id }, { isOTPVerified: true, otp: null, otpGeneratedTime: null } );

At this point, we will also generate the token and session details for the user, since we will track the number of sessions per user. In my app, I allow the maximum number of sessions per user to be 3. You can update as per your requirement

userSchema.methods.updateUserSessionDetails = async function (headers: { [x: string]: string | string[] | undefined;}) { const user = this; if (user.sessionsDetails.length > MAX_SESSIONS_ALLOWED - 1) { throw `Max ${MAX_SESSIONS_ALLOWED} sessions allowed`; } else { const token = await user.generateToken(); const sessionsDetails = [ ...user.sessionsDetails, { token, sessionDetails: parser(headers["user-agent"] as any) }, ]; await User.updateOne( { _id: user._id }, { sessionsDetails, } ); }

We can now always count the number of sessions the user has by checking the sessionsdetails in the database..

This will also help after logout, in case the token has not expired on logout, we will still remove it from session details and if not present in session details, the token will not be considered valid.

Login:

Once registered, users need to authenticate themselves to access protected resources. The login step involves verifying the user's credentials against the stored information. We will be accepting the user's email and password as payload.

We will verify these details, mainly that the email is verified and the password sent is correct. One more step will again be adding a new session on every app.

As per the structure of this app, a new session is added on every login and when the user verifies their email. No new session is added when the user registers in the app.

Access Control and Authorization:

So once these routes are set up, we can start making the protected routes. Those routes will only be accessible to the users who send a valid token in their network calls.

The authorization tokens are attached to the API headers. We will make a middleware at our end which will validate all protected routes, which we have already discussed here.

import jwt, { JwtPayload } from "jsonwebtoken";import { NextFunction, Request, Response } from "express";import { User } from "../models/user";export const auth = async (req: Request, res: Response, next: NextFunction) => { try { const token = req.headers.authorization?.replace("Bearer ", ""); if (token) { const decoded: JwtPayload = jwt.verify( token, process.env.JWT_SECRET || "" ) as JwtPayload; const user = await User.findOne({ _id: decoded._id }); if (!user) { throw new Error(); } else { let isValidSession = false; user.sessionsDetails.forEach((session) => { if (session.token === token) { isValidSession = true; } }); if (isValidSession) { req.body._userAssociatedWithToken = user; req.body._token = token; next(); } else { res.status(401).send({ error: "Invalid Token" }); } } } else { res.status(401).send({ error: "Authorization key missing" }); } } catch (error) { res.status(401).send({ error: "Please authenticate." }); }};

Our middleware not only checks for the validity of the token but also verifies if the token is present in sessionsDetails

Logout

We will also implement logout route, where we will accept the token in the headers. On successful logouts, we will just remove the corresponding token from the session details.

We will also add a chron to our server, which will run once every 24 hours and remove the expired token sessions from the database. This will be helpful with automatic logouts in case of token expiry.

Note: From the client side, always make sure to attach the correct token to the headers in case of logout.

We can also provide our users with /sessions route, which will give them information about all their sessions, from any device that they are using. So users can Logout from anywhere, i.e., clear any of their sessions from any device. This further secures our application.

Note: The sessions make take up to 24 hours to update.

With the completion of these steps, we have created a robust and secure authentication flow using the Express server with Mongoose.