How to Implement Role-Based Access Control in NestJS with MongoDB - Part 2

How to Implement Role-Based Access Control in NestJS with MongoDB - Part 2

Learn how to implement password hashing, sign-in, and sign-up routes in NestJS with MongoDB for robust role-based access control

Introduction

This post is the second in a series titled "Implement Role-Based Access Control in NestJS using MongoDB." In this series, we'll create an RBAC (Role-Based Access Control) app from scratch using NestJS and MongoDB. Whether you're a beginner or looking to implement role-based access control in your app, follow along and build the app with me!

Previous Post

In the previous post, we worked on the following things.

  • Setting up a new NestJs app,

  • Created a Users resource via CLI,

  • Configured MongoDB using Docker.

Check out the blog post here

What's Next?

In this post, we will dive into authentication and cover the following:

  • Hashing Passwords

  • Implementing Sign-in and Sign-up routes

Hashing Passwords

Hashing passwords is a crucial security practice for protecting sensitive user data. Storing passwords in plain text makes them vulnerable to multiple attacks, including data breaches.

Hashing transforms a password into a fixed-length string of characters (a hash), designed to be impossible to reverse. When a user attempts to log in, the system hashes the entered password and compares it to the stored hash in the database.

We will be using Bcrypt library to hash the passwords and we can add them as dependencies by using the following commands:

npm i bcrypt
npm i -D @types/bcrypt

To keep working in a modular approach, let's create a new Nest module and name it iam (short form for Identity and Access Management). Use the following command to create a new module.

nest g module iam

Inside this module, let's generate two services, one that will act as an interface (hashing.service.ts and the other represents its implementation (bcrypt.service.ts).

nest g service iam/hashing
nest g service iam/hashing/bcrypt --flat

Let's start by making some adjustments to the hashing service file. To act as an interface let's make this class abstract and add two methods.

import { Injectable } from '@nestjs/common';

@Injectable()
export abstract class HashingService {
  abstract hash(data: string | Buffer): Promise<string>;
  abstract compare(data: string | Buffer, encrypted: string): Promise<boolean>;
}

The hash method takes data as an input argument and returns a hashed string. The method compare takes two arguments data to be encrypted and the encrypted being the data to be compared against.

For the bcrypt.service.ts file following adjustments are to be made

import { Injectable } from '@nestjs/common';
import { HashingService } from './hashing.service';
import { compare, genSalt, hash } from 'bcrypt';

@Injectable()
export class BcryptService implements HashingService {
  async hash(data: string | Buffer): Promise<string> {
    const salt = await genSalt();
    return hash(data, salt);
  }

  compare(data: string | Buffer, encrypted: string): Promise<boolean> {
    return compare(data, encrypted);
  }
}

Finally, we made some changes to the HashingModule file. Since HashingService is an abstract class and it can't be registered as a provider since it can't be instantiated.

  providers: [
    {
      provide: HashingService,
      useClass: BcryptService,
    },
  ],

By making the above adjustments, whenever the hashing service token is resolved, it will point to the Bcrypt service. The hashing service will act as an abstract interface while the Bcrypt service will be the implementation of that service.

With the above flow, we managed to separate our hashing workflow, and in case in the future we want to use a different hashing service we can do it easily by replacing the Bcrypt service with the new service.

Implementing Sign-in and Sign-up routes

In this section, we will be implementing routes that are essential to our authentication workflows.

  • Sign-in: This route will authenticate a user by verifying their credentials (such as email/password) and manage the authenticated state but using a JWT token.

  • Sign-up: This route will let users register into the system.

Let's first generate a new authentication controller in our existing IAM module, together with this, we also need to create a new authentication service.

Let's add the controller and service by executing the following commands in your CLI or terminal.

# Generate Authentication Controller 
nest g controller iam/auth

# Generate Authentication Service
nest g service iam/auth

Let's generate the DTO or data transfer object classes for the two endpoints which will be publically exposed in our application.

nest g class iam/auth/dto/sign-in.dto --no-spec --flat
nest g class iam/auth/dto/sign-up.dto --no-spec --flat

By using --no-spec and --flat attributes, the classes are generated without any test files and are generated in the dto folder directly instead of having separate folders.

Next, we will add a few more dependencies required for validating the input.

npm i class-validator class-transformer

The ValidationPipe class is provided by the @nestjs/common library and is used to automatically validate incoming requests based on the class-validator library.

By configuring a global validation pipe, we can ensure that all incoming requests are validated before they are processed by the application. This helps to ensure that the data being processed by the application is valid and reduces the likelihood of errors or security vulnerabilities. Let's do that in our main.ts file

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}
bootstrap();

In the next step, we will add the properties and validations for our sign-in.dto and sign-up.dto files.

// signin.dto.ts
import { IsEmail, MinLength } from 'class-validator';

export class SignInDto {
  @IsEmail()
  email: string;

  @MinLength(8)
  password: string;
}
// signup.dto.ts
import { IsEmail, MinLength } from 'class-validator';

export class SignUpDto {
  @IsEmail()
  email: string;

  @MinLength(8)
  password: string;
}

The @IsEmail() decorator is used to validate the email and the password field is decorated with @MinLength() to make sure the minimum length of our password field is 8.

With our validations in place, we can now switch to our authentication service. To create new users this service will be using the User model, so let's inject our User model in our service.

// auth.service.ts

@Injectable()
export class AuthService {
  constructor(
    @InjectModel(User.name) private readonly userModel: Model<User>,
  ) {}
}

Note that before we insert a new user document, we have to make sure to hash the passwords we receive. To achieve this, let's inject the hashing service we created earlier.

// auth.service.ts

@Injectable()
export class AuthService {
  constructor(
    @InjectModel(User.name) private readonly userModel: Model<User>,
    private readonly hashingService: HashingService,
  ) {}
}

Now let's create the functionality for SignUp where we will be using the hash method of HashingService to hash the SignUp password.

  async signUp(signUpDto: SignUpDto): Promise<User> {
    try {
      const check = await this.userModel.findOne({
        email: signUpDto.email,
      });
      if (!check) {
        const user = { ...signUpDto };
        user.password = await this.hashingService.hash(user.password);
        return await this.userModel.create(user);
      } else {
        throw new ConflictException('User already exists');
      }
    } catch (error) {
      return error;
    }
  }

Initially, we will check if the email used already exists or not. If email already exists we will throw an error of ConflictException otherwise, we will create the user document record as per the signUpDto.

For SignIn functionality we will first check whether the email exists or not and then compare the user input password with the hashed password stored in DB.

  async signIn(signInDto: SignInDto) {
    try {
      const user = await this.userModel.findOne({
        email: signInDto.email,
      });
      if (!user) {
        throw new UnauthorizedException('User does not exists');
      }
      const isEqual = await this.hashingService.compare(
        signInDto.password,
        user.password,
      );
      if (!isEqual) {
        throw new UnauthorizedException('Password does not match');
      }
    } catch (error) {}
  }
}

The next step will be to define the two different routes in our controller file and inject the AuthService.

By defining these methods in the AuthController class, we can handle incoming requests to sign up or sign in a user. The AuthService object is injected into the AuthController class using dependency injection, which makes it easier to test and maintain the code.

// auth.controller.ts

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('sign-up')
  signUp(@Body() signUpDto: SignUpDto) {
    return this.authService.signUp(signUpDto);
  }

  @Post('sign-in')
  signIn(@Body() signInDto: SignInDto) {
    return this.authService.signIn(signInDto);
  }
}

The signUp() method takes a SignUpDto object as a parameter and returns the result of calling the signUp() method on an AuthService object. The signIn() method takes a SignInDto object as a parameter and returns the result of calling the signIn() method on an AuthService object.

To make sure we can use the User model in our IamModule, we need to import it using MongooseModule.forFeature(). Let's do that as our next step.

@Module({
  imports: [
    MongooseModule.forFeature([
        { name: User.name, schema: UserSchema }
    ]),
  ],
  .
  .
  .
})
export class IamModule {}

Once we have set up our routes as mentioned above we can test this using any REST API client like Insomnia or Postman.

Please make sure you are using the correct routes

For Sign Up: localhost:3000/auth/sign-up

For Sign In: localhost:3000/auth/sign-in

Conclusion

We covered a lot, so let's summarize:

  • Added hashing to our passwords using Bcrypt library

  • Implemented Sign-in and Sign-up routes

  • Added a global validation for incoming requests using ValidationPipe.

That's it for today. Next, we will work on the implementation of JWT, Protecting our routes with a guard and adding public routes.

Next Post

Stay tuned for the next post, where we will deep dive more into the implementation of JWT, Protecting our routes with a guard and adding public routes. The new blog post will be published by 11 July 2024.

References

  1. GitHub Repo

  2. https://blog.amanpreet.dev/how-to-implement-role-based-access-control-in-nestjs-with-mongodb-part-1