How to Implement Role-Based Access Control in NestJS with MongoDB - Part 2
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
andSign-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: http://localhost:3000/auth/sign-up
For Sign In: http://localhost:3000/auth/sign-in
Conclusion
We covered a lot, so let's summarize:
Added hashing to our passwords using
Bcrypt
libraryImplemented
Sign-in
andSign-up
routesAdded 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
Subscribe to my newsletter
Read articles from Amanpreet Singh directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Amanpreet Singh
Amanpreet Singh
Passionate developer and software crafter with a passion for problem-solving. Always learning and growing in the field. Currently looking for a job or freelance gig. Need a software consultant? I am your guy! With experience in websites, applications, and cloud services, I can provide top-notch solutions for your business needs. Let's work together to take your tech game to the next level. Contact me today for part-time consulting opportunities