In previous tutorial we added support for MongoDb database to store messages, users, and rooms to our chat application. In this tutorial we are going to add authentication and authorization support. There are several ways of doing this. However, in this tutorial we are going to use JWT
authentication. At the same time there are also several ways of authenticating with JWT
. The first one, is to handle the authentication in our server without a third party service. And the second one, is to let a third party single-sign-on server like auth0 to handle the authentication. In this tutorial we are going to discuss the first one, and in other tutorials we are going to discuss the second one.
Nest Application
In this section we are going to add authentication and authorization support to our chat-api
server.
Before continue with this part, in the console go to
|
Add Required Libraries
In order to add support for authentication we are going to use a library called: express-jwt
. As you can notice, this library is for an expressjs
server. However, you should remember that nest-js
is just a framework made using typescript that runs on a expressjs
server. So that, our next step will be to add express-jwt
and some other libraries running next command:
npm i -s express-jwt jsonwebtoken bcrypt dotenv
npm i -D @types/express-jwt @types/jsonwebtoken @types/bcrypt @types/dotenv
Create Environment files
Environment files are useful to save global configuration variables that will be used across the application. There are several ways to use this, for example you could see how the NestJs documentation suggest to use it. However, I will show you a shorter approach. This approach consist on creating *.env
files under environments
folder and environment.ts
under src
folder. So first created environments/local.env
and add next code:
MONGO_DB_URL=mongodb://chat-admin:password123@localhost/chat
JWT_SECRET_PASSWORD=supper-secret
Then create src/environment.ts
file and add next code:
import { parse } from 'dotenv';
import { readFileSync } from 'fs';
export interface Environment { (1)
MONGO_DB_URL: string;
JWT_SECRET_PASSWORD: string;
}
export const environment: Environment =
parse(readFileSync(`environments/${process.env.NODE_ENV || 'local'}.env`)) as any; (2)
1 | Creates a helper interface that will be useful for autocomplete purposes |
2 | Reads the environment variables from *.env file. If NODE_ENV variable is not specified in the command line then it will use local as a default. |
At this point you can create as many *.env
files in dependence of how you want to set up your development process. In general is good practice to have around five environments: local
, dev
, qa
, sta
and prod
. You could have more or less if you want.
Docker Compose Environment
Also is good to be able to start our server using docker-compose
, so we will need to create local_docker.env
file under environments
folder containing next code:
MONGO_DB_URL=mongodb://chat-admin:password123@mongo/chat
JWT_SECRET_PASSWORD=supper-secret
Then modify docker-compose.yaml
file and add next code:
serve: (1)
image: node (2)
depends_on: (3)
- mongo
ports: (4)
- 3000:3000
volumes: (5)
- .:/app
working_dir: /app (6)
command: bash -c "npm i && npm start" (7)
environment: (8)
NODE_ENV: local_docker
1 | Adds the service serve to the docker-compose file |
2 | Uses the node image |
3 | Starts mongo image any time the serve image is started. |
4 | expose the port 3000 to be accessed by host’s browser |
5 | Sets the current directory to be /app directory inside the docker container |
6 | Sets the working directory to /app , so the commands will run inside this directory. |
7 | Runs the commands to install dependencies and start the server |
8 | Creates the environment variable NODE_ENV and sets the value local_docker , which will tell the server to use local_docker.env file for environment configuration variables. |
Add AuthMiddleware
There are several ways of adding authentication to a NestJs application, you can check the authentication documentation to see one of them. However, I am going to show you a shorter way of adding authentication to a NestJs app. This shorter way consist on using express-jwt
library which contains a function that allows us to create an authentication middleware. This middleware will run before any of the controller methods execution. This at the same time will allow us to reduce the work of adding an AuthGuard
for each of the controllers methods. To do that, run next command:
nest g mi middlewares/auth
then modify auth.middleware.ts
file with next code:
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { User } from '../models/user.model';
import { environment } from '../environment';
import { expressjwt } from 'express-jwt';
import { JwtPayload } from 'jsonwebtoken';
@Injectable()
export class AuthMiddleware implements NestMiddleware {
constructor(@InjectModel(User.name) private readonly userModel: Model<User>) {} (1)
use(req, res, next) {
expressjwt({ (2)
secret: environment.JWT_SECRET_PASSWORD, (3)
algorithms: ['HS256'],
isRevoked: async (req1, token) => { (4)
const payload = token?.payload as JwtPayload;
if (!payload._id) {
throw new UnauthorizedException('The token contains invalid credentials or has expired');
}
const user = await this.userModel.findById(payload._id).exec();
if (!user || !user.loggedIn) throw new UnauthorizedException('The user has been logged out');
return false;
},
}).unless({path: ['/api/auth/login', '/api/auth/sign-up']})(req, res, next); (5)
}
}
1 | Inject User model to the middleware |
2 | Initialize jwt middleware |
3 | Set the secret password used to encode and decode json web tokens |
4 | Set the paths that are not going to be affected by this middleware. |
after that add the middleware to app.module.ts
by mean of next code:
configure(consumer: MiddlewareConsumer) {
consumer.apply(AuthMiddleware)
.forRoutes('/api'); (1)
}
1 | Sets the base route where the AuthMiddleware will be applied |
so app.module.ts
should look like this:
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { MessagesGateway } from './gateways/messages/messages.gateway';
import { MessagesController } from './controllers/messages/messages.controller';
import { RoomsController } from './controllers/rooms/rooms.controller';
import { MongooseModule } from '@nestjs/mongoose';
import { Message, MessageSchema } from './models/message.model';
import { Room, RoomSchema } from './models/room.model';
import { User, UserSchema } from './models/user.model';
import { AuthController } from './controllers/auth/auth.controller';
import { AuthMiddleware } from './middlewares/auth.middleware';
import { environment } from './environment';
@Module({
imports: [
MongooseModule.forRoot(environment.MONGO_DB_URL, {}),
MongooseModule.forFeature([
{ name: Message.name, schema: MessageSchema },
{ name: Room.name, schema: RoomSchema },
{ name: User.name, schema: UserSchema },
]),
],
controllers: [
AppController,
RoomsController,
MessagesController,
AuthController,
],
providers: [AppService, MessagesGateway],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(AuthMiddleware)
.forRoutes('/api'); (1)
}
}
1 | Sets the base route where the AuthMiddleware will be applied |
After Adding this middleware all request parameter passed to controllers will have added an user
attribute. This attribute will contain the decoded json-web-token. This json-web-token will contain the user._id
and the user.nickname
values. Also, this json-web-token will be created every time a user logs in.
Modify User Model
We need to modify user.model.ts
so it can handle password
and loggedIn
attributes. Also, we need to make nickname
unique, so it is not mistaken with other users during authentication. To do that change user.model.ts
with next code:
import {Message} from './message.model';
import {Room} from './room.model';
import {ObjectID} from 'bson';
import {Types} from "mongoose";
import {Prop, Schema, SchemaFactory} from "@nestjs/mongoose";
@Schema()
export class User {
_id?: ObjectID | string;
@Prop({
required: true,
maxlength: 20,
minlength: 5,
unique: true (1)
})
nickname: string;
@Prop({required: true})
password: string; (2)
@Prop()
loggedIn: boolean; (3)
@Prop({type: [{type: Types.ObjectId, ref: 'Message'}]})
messages?: Message[];
@Prop({type: [{type: Types.ObjectId, ref: 'Room'}]})
joinedRooms?: Room[];
}
export const UserSchema = SchemaFactory.createForClass(User)
1 | Makes nickname unique so there is only one user with that value |
2 | Adds password and make it required . |
3 | Adds loggedIn attribute. This will be in charge of checking if the user has logged out or still logged in. |
notice that we no longer need to store clientId in the users collection. This is because we are going to add the user._id into the json-web-token, then we will use a websocket middleware to decode the json-web-token and retrieve the user from the users collection using the user._id stored in the json-web-token.
|
Add CurrentUser annotation
Thanks to express-jwt
library all requests now will have a user
attribute added to them. Saying that to get the user information you will only need to annotate the request
parameter in controllers with @Req()
annotation, for example:
@Controller('controller-url')
export class SomeController {
@Get('method-url')
someMethod(@Req() req) {
console.log('user: ', req.user); (1)
}
}
1 | req.user will contain the decoded value saved in the json-web-token |
However, this approach is not too clean. It will be better if we can do this for all controller methods and get the user information directly. NestJs has the ability to do that by creating CurrentUser
annotation which will be in charge of retrieving this value. To do that we need to create a new file decorators/current-user.decorator.ts
and add next code:
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator((data, ctx: ExecutionContext) =>
ctx.switchToHttp().getRequest().auth);
Add Auth Controller
Next step will be adding auth.controller
. This Controller will be in charge of handling login, logout and sign-up actions. To do that run next command:
nest g co controllers/auth
then modify auth.controller.ts
with next code:
import { Body, Controller, Get, Post, Req, UnauthorizedException } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { User } from '../../models/user.model';
import { Model } from 'mongoose';
import { sign } from 'jsonwebtoken';
import { compare, hash } from 'bcrypt';
import { CurrentUser } from '../../decorators/current-user.decorator';
import { environment } from '../../environment';
@Controller('api/auth')
export class AuthController {
constructor(@InjectModel(User.name) private readonly userModel: Model<User>) {} (1)
@Post('login')
async login(@Body() credentials) { (2)
const user = await this.userModel.findOne({nickname: credentials.nickname}).exec();
if (!user) throw new UnauthorizedException('The nickname/password combination is invalid');
const isMatch = await compare(credentials.password, user.password);
if (!isMatch) throw new UnauthorizedException('The nickname/password combination is invalid');
user.loggedIn = true;
await user.save();
return {token: sign({_id: user._id, nickname: user.nickname},
environment.JWT_SECRET_PASSWORD,
{expiresIn: '1h', algorithm: 'HS256'})};
}
@Post('logout')
async logout(@CurrentUser() user) { (3)
await this.userModel.findByIdAndUpdate(user._id, {loggedIn: false});
return {message: 'Logout Successfully'};
}
@Post('sign-up')
async signUp(@Body() signUpCredentials) { (4)
signUpCredentials.password = await hash(signUpCredentials.password, 10);
await this.userModel.create(signUpCredentials);
return {message: 'User Created Successfully'};
}
}
1 | Injects the userModel |
2 | Handles the POST request to api/auth/login url. This method will use the nickname coming from credentials body parameter and search for a user matching this nickname . If the user does not exist then throws an UnauthorizedException . If the user exists then compares the credentials.password with the one saved in the database. To do this comparison we use the compare method from the bcrypt library. If the passwords do not match then we throw an UnauthorizedException . If the passwords match then we set user.loggedIn = true and save the value to the database. Finally, we send back to the client an object containing the json-web-token information, which at the same times contains user._id and user.nickname . This token is encoded by mean of the sign function of the jsonwebtoken library. |
3 | Handles the POST request to api/auth/logout url. This method will get the current user using the CurrentUser annotation created previously. Then it will set user.loggedIn = false in the database. And finally it will send back an object containing a message Logout Successfully . |
4 | Handles the POST request to api/auth/signup url. This method will hash the password using the hash function from the bcrypt library and then create a new user and save it in the database. |
In next tutorial we will add validations so the controller methods are more secure and handle better the wrong incoming data. |
Add UnauthorizedErrorFilter
At this moment if you send any http request without authorization or with an expired token you will receive next response:
{
"statusCode": 500,
"message": "Internal server error"
}
And you are going to see in the server logs something like this:
UnauthorizedError: jwt expired
This is because NestJs does not know how to handle the error. To solve this problem, we should add an exception filter for UnauthrizedError
. To do that we should run next command:
nest g f filters/unauthorized-error
then modify unauthorized-error.filter.ts
file with next code:
import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';
import { UnauthorizedError } from 'express-jwt';
@Catch(UnauthorizedError)
export class UnauthorizedErrorFilter implements ExceptionFilter {
catch(exception: UnauthorizedError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const status = exception.status;
response
.status(status)
.json({
statusCode: status,
error: exception.code,
message: exception.message,
});
}
}
after that add the global filter to the application by adding next line:
app.useGlobalFilters(new UnauthorizedErrorFilter()); (1)
so main.ts
should look like follow:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { UnauthorizedErrorFilter } from './filters/unauthorized-error.filter';
import { AuthAdapter } from './adapters/auth.adapter';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {cors: true});
app.useGlobalFilters(new UnauthorizedErrorFilter()); (1)
app.useWebSocketAdapter(new AuthAdapter(app));
await app.listen(3000);
}
bootstrap();
1 | Use global filter UnauthrizedErrorFilter |
After doing that you are going to get a response similar to next one whenever there is an error:
{
"statusCode": 401,
"error": "invalid_token",
"message": "jwt expired"
}
Add AuthAdapter
Similarly to auth.middleware.ts
websocket gateways need a middleware to handle authentication. However, we cannot add middlewares directly to websockets in NestJs
. To do that we need to create first a websocket adapter and then create a middleware to handle websocket authentication. So create auth.adapter.ts
file under src/adapters
folder containing next code:
import { IoAdapter } from '@nestjs/platform-socket.io';
import { verify } from 'jsonwebtoken';
import { Socket } from 'socket.io';
import { environment } from '../environment';
import { User } from '../models/user.model';
export interface CustomSocket extends Socket { (1)
user: User;
}
export class AuthAdapter extends IoAdapter {
createIOServer(port: number, options?: any): any {
const server = super.createIOServer(port, {...options, cors: true});
server.use((socket: CustomSocket, next) => { (2)
if (socket.handshake.query && socket.handshake.query.token) {
verify(socket.handshake.query.token as string, environment.JWT_SECRET_PASSWORD, (err, decoded) => { (3)
if (err) {
next(new Error('Authentication error')); (4)
} else {
socket.user = decoded as User; (5)
next();
}
});
} else {
next(new Error('Authentication error')); (6)
}
});
return server;
}
}
1 | Creates the CustomSocket interface which allows us to store the decodedToken object. This interface is not really needed because we could use any instead. However, this will make easier to use the stored decodedToken variable in other classes. |
2 | Adds the middleware function to the websocket server. |
3 | Verifies the json-web-token and returns a callback function with error and decoded token parameters. Also uses super-secret as password for verification. |
4 | If the token is expired or was not created with the private password it sends back an error with message Authentication error . |
5 | If the token is verified then we store the decodedToken into the socket instance to be used later for gateways. |
6 | If the handshake query does not contain the token value then it sends an error with message Authentication error . |
Modify MessagesGateway
After adding the AuthAdapter
. we can modify the messages.gateway.ts
file to make use of the stored decodedToken
. So that, replace it with next code:
import { OnGatewayDisconnect, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Model } from 'mongoose';
import { Message } from '../../models/message.model';
import { User } from '../../models/user.model';
import { Room } from '../../models/room.model';
import { InjectModel } from '@nestjs/mongoose';
import { CustomSocket } from '../../adapters/auth.adapter';
import { Server } from 'socket.io';
@WebSocketGateway()
export class MessagesGateway implements OnGatewayDisconnect {
constructor(@InjectModel(Message.name) private readonly messagesModel: Model<Message>,
@InjectModel(Room.name) private readonly roomsModel: Model<Room>,
@InjectModel(User.name) private readonly usersModel: Model<User>) {
}
@WebSocketServer()
server: Server;
async handleDisconnect(client: CustomSocket) { (1)
this.server.emit('users-changed', {user: client.user.nickname, event: 'left'});
}
@SubscribeMessage('enter-chat-room') (3)
async enterChatRoom(client: CustomSocket, roomId: string) {
client.join(roomId);
client.broadcast.to(roomId)
.emit('users-changed', {user: client.user.nickname, event: 'joined'}); (2)
}
@SubscribeMessage('leave-chat-room') (3)
async leaveChatRoom(client: CustomSocket, roomId: string) {
client.broadcast.to(roomId).emit('users-changed', {user: client.user.nickname, event: 'left'}); (3)
client.leave(roomId);
}
@SubscribeMessage('add-message') (4)
async addMessage(client: CustomSocket, message: Message) {
message.owner = client.user._id;
message.created = new Date();
message = await this.messagesModel.create(message);
message.owner = {_id: client.user._id, nickname: client.user.nickname} as User;
this.server.in(message.room as string).emit('message', message);
}
}
1 | Listen for client disconnection and emits the message users-changed containing the user nickname stored in the decodedToken attribute and the event left . |
2 | Listen for client enter-chat-room and emits the message users-changed containing the user nickname stored in the decodedToken attribute and the event joined . |
3 | Listen for client leave-chat-room and emits the message users-changed containing the user nickname stored in the decodedToken attribute and the event left . |
4 | Listen for client add-message , then sets the message.owner using the client.decodedToken._id value. After that it save it to the messages collection in the database. Finally, it sends back the created message to the clients connected tho the same room. |
Ionic Application
In the previous tutorial inside our Ionic chat app we modified the first screen, so after setting the nickname we go to another page where we select the chat room from a list, and then in the last screen we will only show messages for that specified chat room. In this tutorial we are going to modify the first screen and add a login form with username and password boxes. To do that, we are going to add a login page. After that we are going to create a sign-up page so users can be added themselves to our app.
Before continue with this part, in the console go to
|
Add Login Page
The first step will be to create the login page. To do that run next command:
ionic g page pages/login
Then modify the login.page.ts
file with next code:
import { Component, OnInit } from '@angular/core';
import { AuthService } from '../../services/auth.service';
@Component({
selector: 'app-login',
templateUrl: './login.page.html',
styleUrls: ['./login.page.scss'],
})
export class LoginPage implements OnInit {
credentials = { (1)
nickname: '',
password: ''
};
constructor(public authSvc: AuthService) { } (2)
ngOnInit() {
}
}
1 | Sets the initial values of credential model. |
2 | Injects the AuthService . |
and the login.page.html
file with next code:
<ion-content>
<ion-row class="ion-justify-content-center">
<ion-col sizeXl="3" sizeLg="4" sizeMd="6" sizeSm="9" sizeXs="12">
<ion-card>
<ion-card-content>
<form #f="ngForm" (submit)="authSvc.login(credentials)"> (1)
<ion-item>
<ion-label position="floating">Nickname:</ion-label> (2)
<ion-input name="nickname" [(ngModel)]="credentials.nickname" [required]="true" minlength="5"></ion-input>
</ion-item>
<ion-item>
<ion-label position="floating">Password:</ion-label> (3)
<ion-input type="password" name="password" [(ngModel)]="credentials.password" [required]="true" minlength="6"></ion-input>
</ion-item>
<div class="ion-padding">
<ion-button expand="full" type="submit" [disabled]="f.invalid">Login</ion-button> (4)
<button hidden type="submit" [disabled]="f.invalid"></button> (5)
<ion-button expand="full" routerLink="/sign-up" fill="clear">Sign Up</ion-button> (6)
</div>
</form>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-content>
1 | Attaches the ngForm value to the f variable and add the listener for submit event to use the authSvc.login function. |
2 | Attaches the credentials.nickname model to the nickname input |
3 | Attaches the credentials.password model to the password input |
4 | Creates an ion-button of type submit this way it emits submit event every time is clicked. |
5 | Creates a hidden submit button to listen when the user hits enter on any of the inputs |
6 | Creates a button that sends you to sign-up page whenever is clicked |
Add SignUp Page
To add sign-up
page we need to run next command:
ionic g page pages/sign-up
Then modify the signup.pages.ts
file with next code:
import { Component, OnInit } from '@angular/core';
import { AuthService } from '../../services/auth.service';
@Component({
selector: 'app-sign-up',
templateUrl: './sign-up.page.html',
styleUrls: ['./sign-up.page.scss'],
})
export class SignUpPage implements OnInit {
credentials: {
nickname?: string,
password?: string,
confirmPassword?: string
} = {};
constructor(public authSvc: AuthService) { }
ngOnInit() {
}
}
and modify sign-up.page.html
file with next code:
<ion-content>
<ion-row class="ion-justify-content-center">
<ion-col sizeXl="3" sizeLg="4" sizeMd="6" sizeSm="9" sizeXs="12">
<ion-card>
<ion-card-content>
<form #f="ngForm" (submit)="authSvc.signUp(credentials)">
<ion-item>
<ion-label position="floating">Nickname:</ion-label>
<ion-input name="nickname" [(ngModel)]="credentials.nickname" [required]="true" minlength="5"></ion-input>
</ion-item>
<ion-item>
<ion-label position="floating">Password:</ion-label>
<ion-input type="password" name="password" [(ngModel)]="credentials.password" #password="ngModel"
[required]="true" minlength="6"></ion-input>
</ion-item>
<ion-item>
<ion-label position="floating">Confirm Password:</ion-label>
<ion-input type="password" name="confirmPassword" [(ngModel)]="credentials.confirmPassword"
[equalTo]="password.control" [required]="true" minlength="6"></ion-input>
</ion-item>
<div class="ion-padding">
<ion-button expand="full" type="submit" [disabled]="f.invalid">Sign Up</ion-button>
<button hidden type="submit" [disabled]="f.invalid"></button>
<ion-button expand="full" routerLink="/login" fill="clear">Login</ion-button>
</div>
</form>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-content>
Add AuthService
As you can see in previous code the LoginPage
gets injected the AuthService
. This service is going to be in charge of authentication logic. To create it run next command:
ionic g service services/auth
then modify it with next code:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { NavController, ToastController } from '@ionic/angular';
import { environment } from '../../environments/environment';
import { JwtHelperService } from '@auth0/angular-jwt';
import { User } from '../models/user';
import { LoggedInService } from './logged-in.service';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private _currentUser: User; (1)
get currentUser(): User { (2)
return this._currentUser = this._currentUser || this.jwtHelper.decodeToken();
}
constructor(private http: HttpClient,
private toastCtrl: ToastController,
private navCtrl: NavController,
private jwtHelper: JwtHelperService,
private loggedInSvc: LoggedInService) {} (3)
login(credentials) { (4)
this.http.post<{token: string}>(environment.apiUrl + 'auth/login', credentials).subscribe(({token}) => {
sessionStorage.setItem('user_token', token);
this.loggedInSvc.loggedIn$.next(true);
this.navCtrl.navigateRoot('/select-room');
});
}
logout() { (5)
this.http.post(environment.apiUrl + 'auth/logout', null).subscribe(resp => {
sessionStorage.removeItem('user_token');
this.loggedInSvc.loggedIn$.next(false);
this.navCtrl.navigateRoot('/login');
});
}
signUp(credentials) { (6)
this.http.post<{token: string}>(environment.apiUrl + 'auth/sign-up', credentials).subscribe(() => {
this.navCtrl.navigateRoot('/login');
});
}
}
1 | Variable used to memoize the current user value |
2 | Gets current user value from the decoded json-web-token. |
3 | Injects needed services. |
4 | Creates a POST request that sends the credentials to the server. Once the response is received, it saves the user_token into the sessionStorage , emits the that the user has been logged in and navigates to select-room page. |
5 | Creates a POST request to the auth/logout api. Once the response is received, it removes the user_token from sessionStorage , emits loggedIn = false , and navigates to login page. |
6 | Creates a POST request that sends the credentials to the server. Once the response is received, it navigates to login page. |
Add LoggedInService
It will be needed to add a service that handles and shares the loggedIn status across the app. Then run next command:
ionic g service services/logged-in
then modify it with next code:
import { Injectable } from '@angular/core';
import { ReplaySubject } from 'rxjs';
import { Socket } from 'ngx-socket-io';
import { JwtHelperService } from '@auth0/angular-jwt';
@Injectable({
providedIn: 'root'
})
export class LoggedInService {
loggedIn$ = new ReplaySubject<boolean>(1); (1)
constructor(socket: Socket, jwtHelper: JwtHelperService) {
this.loggedIn$.next(!jwtHelper.isTokenExpired()); (2)
this.loggedIn$.subscribe(loggedIn => { (3)
if (loggedIn) {
socket.ioSocket.io.opts.query = {token: jwtHelper.tokenGetter()};
console.log('socket.ioSocket.io.opts.query: ', socket.ioSocket.io.opts.query)
socket.connect();
} else {
socket.disconnect();
sessionStorage.removeItem('user_token');
}
});
}
}
1 | loggedIn$ subject will keep in memory the logged-in status. Also, it will emit the new value whenever the user changes his status. Furthermore, we use a ReplaySubject so new subscribers always get the last value, this is useful for guards since they create a new subscriber every time an url check occurs. |
2 | Checks if the token is not expired and emits the result to loggedIn$ subject. |
3 | Subscribes to the loggedIn$ subject. If the user is logged-in then it starts the websocket connection sending the token in the query parameter of the handshake query. |
Add @auth0/angular-jwt package
Maybe the easiest way of handling authentication in any angular app is using the @auth0/angular-jwt
package. This package creates a http interceptor, and we will only need to add some small configurations parameters to the module to use it. Saying that the next step will be to run next command:
npm i -s @auth0/angular-jwt
then you need to create a tokenGetter
function in app.module.ts
, so we will need to add next code:
export function tokenGetter() {
return sessionStorage.getItem('user_token');
}
then add the JwtModule
to the AppModule
. To do it add next code:
JwtModule.forRoot({
config: {
tokenGetter,
allowedDomains: [environment.baseUrl],
disallowedRoutes: ['/login', '/sign-up']
}
}),
Add IsLoggedInGuard
This guard will be in charge of checking if the user can enter to certain routes if the user has logged in previously. If not then the user will be redirected to the login page. To do this run next command:
ionic g guard guards/is-logged-in
Then modify the is-logged-in.guard.ts
file with next code:
import { Injectable } from '@angular/core';
import { CanActivate } from '@angular/router';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { NavController } from '@ionic/angular';
import { LoggedInService } from '../services/logged-in.service';
@Injectable({
providedIn: 'root'
})
export class IsLoggedInGuard implements CanActivate {
constructor(private loggedInSvc: LoggedInService, private navCtrl: NavController) {}
canActivate(): Observable<boolean> {
return this.loggedInSvc.loggedIn$.pipe(tap(loggedIn => {
if (!loggedIn) {
this.navCtrl.navigateRoot('/login');
}
}));
}
}
Add IsNotLoggedInGuard
This guard will be in charge of checking if the user can enter to certain routes if the user is not logged. If so then the user will be redirected to the select-room
page. To do this run next command:
ionic g guard guards/is-not-logged-in
Then modify the is-logged-in.guard.ts
file with next code:
import { Injectable } from '@angular/core';
import { CanActivate } from '@angular/router';
import { Observable } from 'rxjs';
import { NavController } from '@ionic/angular';
import { LoggedInService } from '../services/logged-in.service';
import { map } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class IsNotLoggedInGuard implements CanActivate {
constructor(private loggedInSvc: LoggedInService, private navCtrl: NavController) {}
canActivate(): Observable<boolean> {
return this.loggedInSvc.loggedIn$.pipe(map(loggedIn => {
if (loggedIn) {
this.navCtrl.navigateRoot('/select-room', {replaceUrl: true});
}
return !loggedIn;
}));
}
}
Change AppRoutingModule
The next step will be to modify the app-routing.module.ts
file with next code:
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { IsLoggedInGuard } from './guards/is-logged-in.guard';
import { IsNotLoggedInGuard } from './guards/is-not-logged-in.guard';
const routes: Routes = [
{
path: '',
canActivate: [IsLoggedInGuard], (1)
children: [
{path: '', redirectTo: 'select-room', pathMatch: 'full'},
{path: 'chat-room', loadChildren: () => import('./pages/chat-room/chat-room.module').then(m => m.ChatRoomPageModule)},
{path: 'select-room', loadChildren: () => import('./pages/select-room/select-room.module').then(m => m.SelectRoomPageModule)}
]
},
{path: 'login', loadChildren: () => import('./pages/login/login.module').then(m => m.LoginPageModule), canActivate: [IsNotLoggedInGuard]}, (2)
{path: 'sign-up', loadChildren: () => import('./pages/sign-up/sign-up.module').then(m => m.SignUpPageModule), canActivate: [IsNotLoggedInGuard]},
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule {}
1 | Only users that have logged in previously can enter chat-room and select-room routes |
2 | Only users that have not logged in can enter login and sign-up routes. |
Add HttpErrorsInterceptor
The final step will be to add an interceptor that handles all the errors produced by http requests. To do that run next code:
ionic g interceptor interceptors/http-errors
then modify http-errors.interceptors.ts
with next code:
import { Injectable } from '@angular/core';
import { HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { catchError } from 'rxjs/operators';
import { EMPTY, throwError } from 'rxjs';
import { NavController, ToastController } from '@ionic/angular';
import { LoggedInService } from '../services/logged-in.service';
@Injectable()
export class HttpErrorsInterceptor implements HttpInterceptor {
constructor(private navCtrl: NavController,
private toastCtrl: ToastController,
private loggedInSvc: LoggedInService) {}
intercept(req: HttpRequest<any>, next: HttpHandler): any {
return next.handle(req).pipe(catchError(async errorResp => {
const message = errorResp.error && errorResp.error.message || errorResp.message;
(await this.toastCtrl.create({
message,
position: 'middle',
buttons: [{text: 'Ok'}],
duration: 5000
})).present();
if (errorResp.status === 401) { (1)
this.loggedInSvc.loggedIn$.next(false);
this.navCtrl.navigateRoot('/login');
return EMPTY;
}
return throwError(errorResp);
}));
}
}
1 | if the error status is 401 then the user will be redirected to the login page. |
Conclusion
As you can see adding Authentication to our project was relatively simple. Furthermore, express-jwt
give us the huge ability to handle the server-side authentication without writing too much code. As well as @auth0/angular-jwt
give us great authentication module for angular to handle authentication in the client-side.
Now this application has the ability to limit the pages that a users can enter if he has not logged in previously.