Quantcast
Channel: RSS Feed
Viewing all articles
Browse latest Browse all 391

Adding JWT Authentication to an Ionic Application with MongoDB and NestJS

$
0
0

Many Ionic applications will require the concept of an authenticated user who is authorised to perform certain actions - perhaps only authenticated users may access your application at all, or perhaps you want to restrict certain types of functionality in your application to certain types of users.

There are many different options that can be used to add this functionality to your application, including services like Firebase and Auth0 which do most of the heavy lifting for you and provide a simple API for authenticating users. However, you can also create your own authentication/authorisation functionality, which may be a more attractive option if you are taking a self-hosted approach to your backend.

In this tutorial, we are going to cover how to create our own authentication system for an Ionic application with MongoDB (to store users other data) and NestJS (to handle HTTP requests to the backend). The basic flow will work like this:

  1. A user will create an account (if they do not have one already)
  2. The account will be stored in the MongoDB database (if it has not been already)
  3. The user will supply their email and password to sign in to the application
  4. If the sign in is successful, the user will be supplied with a JWT that identifies them
  5. Requests to any restricted routes will require that the JWT is sent along with the request in order to access it

I will not be covering what JSON Web Tokens (JWT) are in this tutorial. If you are not already familiar with the concept, I would recommend reading this article. In short, a JWT supplied to a user cannot be modified by the user (without breaking the signature), and so if we supply a JWT to the user that says they are Natalie, we can trust that is true since we know the user couldn’t have just edited it themselves on the client-side. If the someone were to try to modify a JWT (e.g. changing Natalie to Josh) then when the JWT is checked on the server we would be able to tell that it was tampered with (and thus reject the request). This concept means we can check a users authorisation without needing their password after they have signed in initially.

A critical point about a JWT is that they cannot be modified not that they can’t be read. A JWT is encoded and that may give the illusion that you could store sensitive data in the JWT, but you should definitely never do this as a JWT can be easily decoded by anybody. A JWT is good for storing information like a user_id, an email, or a username, but never something sensitive like a password. Another important thing to keep in mind about a JWT is that anybody who has it has the ability to be authorised as the “true” owner of the JWT (e.g. if someone managed to steal a JWT from someone, they may be able to perform actions that they should not be authorised to do). We will be taking a very simple approach in this tutorial, but keep in mind that other security measures are often used in conjunction with a JWT.

IMPORTANT: This tutorial is for learning purposes only. It has not been rigorously tested and it is not a plug-and-play solution. Authentication/authorisation and security in general, are important and complex topics. If you are creating anything where security is important, you should always engage the help of an expert with relevant knowledge.

Before We Get Started

Although this tutorial is completely standalone, it continues on from the concepts that we have covered in the previous NestJS tutorials:

  1. An Introduction to NestJS for Ionic Developers
  2. Using Providers and HTTP Requests in a NestJS Backend
  3. Sending Data with POST Requests to a NestJS Backend
  4. Using MongoDB with Ionic and NestJS

If you have not already read these, or you are not already familiar with the basic NestJS concepts, I would recommend that you read those first as I won’t be explaining those concepts in this tutorial.

In order to work with MongoDB on your machine, you will need to have it installed. If you do not already have MongoDB installed on your machine, you can find information on how to do that here. I also released a video recently that covers installing MongoDB on macOS: Installing MongoDB with Homebrew on macOS.

Once you have installed MongoDB, you will need to make sure to open a separate terminal window and run the following command:

mongod

This will start the MongoDB daemon, meaning that the database will be running in the background on your computer and you will be able to interact with it.

1. Create a New NestJS Application

We will start by creating a fresh new NestJS application, which we can do with the following command:

nest new nest-jwt-auth

We are also going to take care of installing all of the dependencies we require as well.

npm install --save @nestjs/mongoose mongoose

This will install mongoose and the associated NestJS package that we will use to interact with our MongoDB database.

npm install --save bcrypt

We will be using bcrypt to hash the user passwords that we will be storing in the database.

npm install --save passport
npm install --save passport-jwt
npm install --save @nestjs/jwt
npm install --save @nestjs/passport

We will be using the Passport library to implement authentication “strategies” - this helps us define the process that will be used to determine whether a user is authorised to access certain routes or not. We will be implementing a JWT strategy, so we also require the JWT packages.

Before we move on, we are also going to enable CORS which will allow us to make cross-domain requests to our NestJS server.

Modify src/main.ts to reflect the following:

import{ NestFactory }from'@nestjs/core';import{ AppModule }from'./app.module';asyncfunctionbootstrap(){const app =await NestFactory.create(AppModule);
  app.enableCors();await app.listen(3000);}bootstrap();

2. Set up the MongoDB Connection

In order to interact with our MongoDB database, we will need to set up the connection in the root module of our NestJS application.

Modify src/app.module.ts to reflect the following:

import{ Module }from'@nestjs/common';import{ MongooseModule }from'@nestjs/mongoose';import{ AppController }from'./app.controller';import{ AppService }from'./app.service';

@Module({
  imports:[
    MongooseModule.forRoot('mongodb://localhost/authexample')],
  controllers:[AppController],
  providers:[AppService],})exportclassAppModule{}

In this particular example, we will be using a database called authexample but you can name this whatever you like (there is no need to “create” the database beforehand).

3. Create the Users Module

Now that we have all of the plumbing out of the way, we can move on to building our authentication functionality. We are going to start off by creating a users module that will contain all of the functionality related to creating and finding users.

Run the following command to create the users module

nest g module Users

By using the g command to generate the module for us, the NestJS CLI will automatically import the module into our root app.module.ts module for us. If you create your modules manually, you will also need to manually add the module to your root module.

We can also use the NestJS CLI to automatically create our users controller for us (which will handle the various routes available on the server) and our users service (which will handle any logic for us).

Run the following commands:

nest g controller users
nest g service users

The controller and the service will also automatically be added to the users module since we have used the generate command. We will get to implementing the controller and the service soon, but there are still some additional files we need to create.

Create a file at src/users/dto/create-user.dto.ts and add the following:

exportclassCreateUserDto{
    readonly email: string;
    readonly password: string;}

If you recall, we use DTOs (Data Transfer Objects) to define the structure of the data that we want to be able to POST to our server (from our Ionic application or from whatever frontend we are using). This particular DTO defines the data that we will be sending when we want to create a new user. We also need to define a DTO for when a user wants to log in.

Create a file at src/users/dto/login-user.dto.ts and add the following:

exportclassLoginUserDto{
    readonly email: string;
    readonly password: string;}

These two DTOs are exactly the same, so technically, we don’t really need both in this case. However, it would be common that when creating a user you might also want to accept some additional information (perhaps an age, address, account type, and so on).

Create a file at src/users/user.interface.ts and add the following:

exportinterfaceUser{
    email: string
}

This file just defines a simple type that we will be able to use with our User objects in the application.

Create a file at src/users/user.schema.ts and add the following:

import*as mongoose from'mongoose';import*as bcrypt from'bcrypt';exportconst UserSchema =newmongoose.Schema({
    email:{
        type: String,
        unique:true,
        required:true},
    password:{
        type: String,
        required:true}});// NOTE: Arrow functions are not used here as we do not want to use lexical scope for 'this'
UserSchema.pre('save',function(next){let user =this;// Make sure not to rehash the password if it is already hashedif(!user.isModified('password'))returnnext();// Generate a salt and use it to hash the user's password
    bcrypt.genSalt(10,(err, salt)=>{if(err)returnnext(err);

        bcrypt.hash(user.password, salt,(err, hash)=>{if(err)returnnext(err);
            user.password = hash;next();});});}); 

UserSchema.methods.checkPassword=function(attempt, callback){let user =this;

    bcrypt.compare(attempt, user.password,(err, isMatch)=>{if(err)returncallback(err);callback(null, isMatch);});};

Now we are getting into something a bit more substantial, and this is actually a core part of how our authentication system will work. As we have talked about in previous tutorials, a Mongoose schema allows to more easily work with data in our MongoDB database. In this case, we are creating a schema to represent a User.

The interesting part here is the save function we are adding as well as the custom method. With Mongoose, we can specify a function we want to run whenever a document is going to be saved in the database. What will happen here is that when we want to create a new User we will supply the email and the password that the user gives us, but we don’t want to just immediately save that to the database. If we did that, the password would just be stored in plain text - we want to hash the user’s password first.

Hashing is a one-way process (unlike encryption, which can be reversed) that we use to avoid storing a user’s password in plain-text in a database. Instead of checking a user’s password against the value stored in the database directly, we check the hashed version of the password they supply against the hashed version stored in the database. This way, we (as the database owner or anyone with access) won’t be able to actually see what the user’s password is, and perhaps more importantly if the database were compromised by an attacker they would not be able to retrieve the user’s passwords either. You should never store a user’s password without hashing it.

What happens with our save function above is that rather than saving the original value, it will use bcrypt to convert the password field into a hashed version of the value supplied. Before doing that, we use isModified to check if the password value is being changed (if we were just updating some other information on the user, we don’t want to re-hash the password field otherwise they would no longer be able to log in).

We define the checkPassword method on the User schema so that we have an easy way to compare a password from a login attempt to the hashed value that is stored. Before we can continue, we will need to set up our new User schema in our users module (which will allow us to use it in our users service).

Modify src/users/users.module.ts to reflect the following:

import{ Module }from'@nestjs/common';import{ MongooseModule }from'@nestjs/mongoose';import{ PassportModule }from'@nestjs/passport';import{ UsersController }from'./users.controller';import{ UsersService }from'./users.service';import{ UserSchema }from'./user.schema';

@Module({
  imports:[
    MongooseModule.forFeature([{name:'User', schema: UserSchema}]),
    PassportModule.register({ defaultStrategy:'jwt', session:false})],
  exports:[UsersService],
  controllers:[UsersController],
  providers:[UsersService]})exportclassUsersModule{}

The important part here is the addition of forFeature which will make our User schema available to use throughout this module. We have also added JWT as the default strategy for the PassportModule here - this is something we are going to get into more in the Auth module, but we will need to add this PassportModule import into any module that contains routes we want to protect with our JWT authorisation. Now we can move on to implementing our service.

Modify src/users/user.service.ts to reflect the following:

import{ Model }from'mongoose';import{ Injectable }from'@nestjs/common';import{ InjectModel }from'@nestjs/mongoose';import{ User }from'./user.interface';import{ CreateUserDto }from'./dto/create-user.dto';

@Injectable()exportclassUsersService{constructor(@InjectModel('User')private userModel: Model<User>){}asynccreate(createUserDto: CreateUserDto){let createdUser =newthis.userModel(createUserDto);returnawait createdUser.save();}asyncfindOneByEmail(email): Model<User>{returnawaitthis.userModel.findOne({email: email});}}

As you can see in the constructor we are injecting our User model, and we will be able to use all of the methods that it makes available. We create two functions in this service. The create function will accept the data for creating a new user, and it will use that data to create a new user in MongoDB. The findOneByEmail function will allow us to find a MongoDB record that matches the supplied email address (which will be useful when we are attempting authentication).

Finally, we just need to implement the controller.

Modify src/users/users.controller.ts to reflect the following:

import{ Controller, Get, Post, Body }from'@nestjs/common';import{ CreateUserDto }from'./dto/create-user.dto';import{ UsersService }from'./users.service';

@Controller('users')exportclassUsersController{constructor(private usersService: UsersService){}

    @Post()asynccreate(@Body() createUserDto: CreateUserDto){returnawaitthis.usersService.create(createUserDto);}}

For now, we just need a single route. This will allow us to make a POST request to /users containing the data required to create a new user. Later, we are going to add an additional “protected” route here that will only be able to be accessed by authorised users.

4. Create the Auth Module

Now we can move on to the Auth module which is going to handle authenticating users, creating JWTs, and checking the validity of JWTs when accessing a protected route. As I mentioned before, we are going to use Passport to create a JWT “strategy” which basically means the code/logic we want to run when attempting to authorise a user for a particular route.

First, we will need to create the module itself:

nest g module Auth

We will also create a controller and service for this module as well:

nest g controller auth
nest g service auth

As we had to do in the Users module, we will also need to create some additional files for our Auth module. We will start by creating an interface for our JWTs.

Create a file at src/auth/interfaces/jwt-payload.interface.ts and add the following:

exportinterfaceJwtPayload{
    email: string;}

This is the type for the “payload” of our JWT (i.e. the data contained within the JWT). We are just using an email which we can then use to identify the user by matching it against an email for a user stored in our MongoDB database. You could add other information to your JWT as well, but remember, do not store sensitive information like passwords in a JWT.

Modify src/auth/auth.service.ts to reflect the following:

import{ Injectable, UnauthorizedException }from'@nestjs/common';import{ JwtService }from'@nestjs/jwt';import{ LoginUserDto }from'../users/dto/login-user.dto';import{ UsersService }from'../users/users.service';import{ JwtPayload }from'./interfaces/jwt-payload.interface';

@Injectable()exportclassAuthService{constructor(private usersService: UsersService,private jwtService: JwtService){}asyncvalidateUserByPassword(loginAttempt: LoginUserDto){// This will be used for the initial loginlet userToAttempt =awaitthis.usersService.findOneByEmail(loginAttempt.email);returnnewPromise((resolve)=>{// Check the supplied password against the hash stored for this email address
            userToAttempt.checkPassword(loginAttempt.password,(err, isMatch)=>{if(err)thrownewUnauthorizedException();if(isMatch){// If there is a successful match, generate a JWT for the userresolve(this.createJwtPayload(userToAttempt));}else{thrownewUnauthorizedException();}});});}asyncvalidateUserByJwt(payload: JwtPayload){// This will be used when the user has already logged in and has a JWTlet user =awaitthis.usersService.findOneByEmail(payload.email);if(user){returnthis.createJwtPayload(user);}else{thrownewUnauthorizedException();}}createJwtPayload(user){let data: JwtPayload ={
            email: user.email
        };let jwt =this.jwtService.sign(data);return{
            expiresIn:3600,
            token: jwt            
        }}}

Our auth service provides three different methods. The first two handle the two different authentication processes available. We have a validateUserByPassword method that will be used when the user is initially logging in with their email and password. This method will find the user in the MongoDB database that matches the supplied email and then it will invoke the custom checkPassword method we added to the User schema. If the hash of the supplied password matches the hash stored in the database for that user, the authentication will succeed. In that case, the method will return a JWT by calling the createJwtPayload method.

The createJwtPayload method will add the user’s email address to the payload, and then it will sign the JWT using the sign method of the JwtService that was injected into the constructor. This is what ensures that the JWT cannot be tampered with as it is signed by a secret key known only to the server (which we will need to set up in a moment).

The validateUserByJwt method will be used when a user has already logged in and has been given a JWT. This will simply check that the email contained in the JWT represents a real user, and if it does it will return a new JWT (and the success of this method will be used to determine whether or not a user can access a particular route).

Modify src/auth/auth.controller.ts to reflect the following:

import{ Controller, Post, Body }from'@nestjs/common';import{ AuthService }from'./auth.service';import{ LoginUserDto }from'../users/dto/login-user.dto'

@Controller('auth')exportclassAuthController{constructor(private authService: AuthService){}

    @Post()asynclogin(@Body() loginUserDto: LoginUserDto){returnawaitthis.authService.validateUserByPassword(loginUserDto);}}

Our controller is quite simple, we just have a single POST route set up which will allow our frontend application to POST a users email and password to the /auth endpoint. This will then invoke the validateUserByPassword method, and if it is successful it will return the JWT for the user.

Create a file at src/auth/strategies/jwt.strategy.ts and add the following:

import{ Injectable, UnauthorizedException }from'@nestjs/common';import{ ExtractJwt, Strategy }from'passport-jwt';import{ AuthService }from'../auth.service';import{ PassportStrategy }from'@nestjs/passport';import{ JwtPayload }from'../interfaces/jwt-payload.interface';

@Injectable()exportclassJwtStrategyextendsPassportStrategy(Strategy){constructor(private authService: AuthService){super({
            jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
            secretOrKey:'thisismykickasssecretthatiwilltotallychangelater'});}asyncvalidate(payload: JwtPayload){const user =awaitthis.authService.validateUserByJwt(payload);if(!user){thrownewUnauthorizedException();}return user;}}

This is the “strategy” that we will use to authorise a user when they are attempting to access a protected route - this is where the Passport package comes in. In the constructor, we supply passport with the settings we want to use. We will be extracting the JWT from the Bearer header that we will send along with any requests to the server, and the secret key that we will use to sign the JWTs is thisismykickasssecretthatiwilltotallychangelater. You need to change this to a secret value that isn’t known to anybody else - if somebody knows the secret key you are using to sign your JWTs then they can easily create their own JWTs containing whatever information they like, and your server will see those JWTs as valid.

IMPORTANT: Don’t forget to change the secretOrKey value.

The validate method handles checking that the JWT supplied is valid by invoking the validateUserByJwt method that we created earlier.

Modify src/auth/auth.module.ts to reflect the following:

import{ Module }from'@nestjs/common';import{ JwtModule }from'@nestjs/jwt';import{ AuthService }from'./auth.service';import{ AuthController }from'./auth.controller';import{ JwtStrategy }from'./strategies/jwt.strategy';import{ UsersModule }from'../users/users.module';import{ PassportModule }from'@nestjs/passport';

@Module({
  imports:[
    PassportModule.register({ defaultStrategy:'jwt', session:false}),
    JwtModule.register({
      secretOrPrivateKey:'thisismykickasssecretthatiwilltotallychangelater',
      signOptions:{
        expiresIn:3600}}),
    UsersModule
  ],
  controllers:[AuthController],
  providers:[AuthService, JwtStrategy]})exportclassAuthModule{}

Now we just need to make a few changes to our Auth module. Once again, we set up the PassportModule with the default strategy that will be used as we need to import this into any module where we want to add protected routes. We set up the NestJS JwtModule with the values we want to use with our JWT (make sure to use the same secret key value as before, but make sure it is different to the one I am using).

5. Create a Restricted Route

With everything above in place, we now have an API that supports:

  • Creating new users
  • Authenticating users with an email and password
  • Supplying users with a JWT
  • Authenticating users with a JWT
  • Restricting particular routes by enforcing that users require a valid JWT in order to access it

Although this is in place, we don’t actually have any protected routes. Let’s create a test route in our users controller.

Modify src/users/users.controller.ts to reflect the following:

import{ Controller, Get, Post, Body, UseGuards }from'@nestjs/common';import{ CreateUserDto }from'./dto/create-user.dto';import{ UsersService }from'./users.service';import{ AuthGuard }from'@nestjs/passport';

@Controller('users')exportclassUsersController{constructor(private usersService: UsersService){}

    @Post()asynccreate(@Body() createUserDto: CreateUserDto){returnawaitthis.usersService.create(createUserDto);}// This route will require successfully passing our default auth strategy (JWT) in order// to access the route
    @Get('test')
    @UseGuards(AuthGuard())testAuthRoute(){return{
            message:'You did it!'}}}

To protect a particular route, all we need to do is add @UseGuards(@AuthGuard()) to it and it will use our default JWT strategy to protect that route. With this in place, if we were to make a GET request to /users/test it would only work if we sent the JWT along with the request in the headers.

6. Test in an Ionic Application

With everything in place, let’s test it!

Everything we have done above really doesn’t have much to do with Ionic at all, you could use this backend with many types of frontends. In the end, all we will be doing is making GET and POST HTTP requests to the NestJS backend. Although you do not have to use Ionic, I am going to show you some rough code that you can use to test the functionality.

NOTE: The following code is purely for testing the API and is in no way designed well. Your Ionic application should not look like this. Keep in mind that you will need to have the HttpClientModule set up in order for the following code to work.

Modify src/home/home.page.ts to reflect the following:

import{ Component }from'@angular/core';import{ HttpClient, HttpHeaders }from'@angular/common/http';

@Component({
  selector:'app-home',
  templateUrl:'home.page.html',
  styleUrls:['home.page.scss'],})exportclassHomePage{public createEmail: string;public createPassword: string;public signInEmail: string;public signInPassword: string;public jwt: string;constructor(private http: HttpClient){}createAccount(){let credentials ={
      email:this.createEmail,
      password:this.createPassword
    }this.http.post('http://localhost:3000/users', credentials).subscribe((res)=>{
      console.log(res);});}signIn(){let credentials ={
      email:this.signInEmail,
      password:this.signInPassword
    }this.http.post('http://localhost:3000/auth', credentials).subscribe((res: any)=>{
      console.log(res);// NOTE: This is just for testing, typically you would store the JWT in local storage and retrieve from therethis.jwt = res.token;});}testRoute(){let headers =newHttpHeaders().set('Authorization','Bearer '+this.jwt)this.http.get('http://localhost:3000/users/test',{headers: headers}).subscribe((res)=>{
      console.log(res);});}logout(){this.jwt =null;}}

Modify src/home/home.page.html to reflect the following:

<ion-header><ion-toolbar><ion-title>
      Ionic Blank
    </ion-title></ion-toolbar></ion-header><ion-contentpadding><h2>Create Account</h2><ion-input[(ngModel)]="createEmail"type="text"placeholder="email"></ion-input><ion-input[(ngModel)]="createPassword"type="password"placeholder="password"></ion-input><ion-button(click)="createAccount()"color="primary">Test Create Account</ion-button><h2>Sign In</h2><ion-input[(ngModel)]="signInEmail"type="text"placeholder="email"></ion-input><ion-input[(ngModel)]="signInPassword"type="password"placeholder="password"></ion-input><ion-button(click)="signIn()"color="primary">Test Sign In</ion-button><ion-button(click)="testRoute()"color="light">Test Protected Route</ion-button><ion-button(click)="logout()"color="light">Test Logout</ion-button></ion-content>

The important part in the code above is that we are setting the Authorization header and sending our JWT as a bearer token. In order to run this example, you will need to make sure that you:

  1. Have the MongoDB daemon running with mongod
  2. Have your NestJS backend being served by running npm run start
  3. Have your Ionic application served with ionic serve

If you are using the code above you should be able to go through the process of:

  1. Creating a user
  2. Signing in with that user
  3. Accessing the protected route
  4. Logging out (and you will no longer be able to access the protected route)

After creating a user or signing in, you should receive a JWT in the server response that will look something like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZ21haWwuY29tIiwiaWF0IjoxNTQ0NTcyNzM3LCJleHAiOjE1NDQ1NzYzMzd9.6p0XH9KkGsde9S38nDOkPudYk02dZK6xxtd3qqWFg3M

If you were to paste this JWT in the debugger at jwt.io you would be able to see that the payload of this JWT is:

{
  "email": "test@gmail.com",
  "iat": 1544572737,
  "exp": 1544576337
}

However, you might notice that it says “Invalid Signature”. This is good, we should only get a valid signature when the secret key the JWT was signed with is supplied. If you were to first add the key thisismykickasssecretthatiwilltotallychangelater to the “Verify Signature” section under “your-256-bit-secret”, and then add the JWT to the “Encoded” section you will see that the signature is valid. Since this key is only known to our server, only our server can validate or create JWTs.

In the test code, we are just saving the JWT on this.jwt but you would typically save this in some kind of local storage.

If you wanted to test the security of the application you could even try modifying the values of the JWT manually, or supplying your own manually created JWTs to see if you can fool the authorisation process. After you have created a user, if you were to open a MongoDB shell in your terminal by running mongo you should be able to run the following commands:

use authexample
users.find()

To retrieve a list of all of the users you have created. The users will look something like this:

{ "_id" : ObjectId("5c0dc18d349bc9479600c171"), "email" : "test@gmail.com", "password" : "$2b$10$2WLRRE/IWW.1yXcEyt0sZeFS/257w6SAaigbMNMfcqX1JNZ1KKXGO", "__v" : 0 }

You can see the password hashing in action here. I’ll let you in on a little secret - the test password I used for this was password. But, there is no way you could know that just by looking at the value stored in the database:

$2b$10$2WLRRE/IWW.1yXcEyt0sZeFS/257w6SAaigbMNMfcqX1JNZ1KKXGO

Since this value is hashed (not encrypted) it means that this value should not be able to be reversed. We can’t read what the password is, and neither could an attacker.

Summary

This tutorial wasn’t exactly a simple one but without too much work we now have a fully functional authentication/authorisation system that can remember users after their initial login. I’d like to stress again that this is primarily for educational purposes and has not been seriously tested - please make sure to do your own research and testing if you intend to use any of this code in a production environment.


Viewing all articles
Browse latest Browse all 391

Trending Articles