angularasynchronousangular-router-guardscanactivate

Angular CanActivate Guard


I am developing a small project using Angular, Nodejs, Express, MySQL. In the project there is 2 type of user, customer user and contractor user.

I am having difficulty in using CanActivate to guard the route for the 'Contractor Profile Page' and 'User Profile Page'.

The contractor should not access customer user profile and customer user should not be access contractor profile

In MySQL database, I stored a value 'isContractor' which is used to identify if a user is a contractor or not. And I am using JWT to persist my login, each time refresh I will request all data from the server once again including the isContractor using the JWT (If the JWT expired I will use a refresh token to get new JWT, therefore the auto logging might took some time to process).

Here is the problem. When I refreshed on my 'Contractor or Customer User profile page' the boolean value I get is not correctly reflecting the user type of the logged in user(the CanActivate took the default value I set). Therefore the guard is also not working correctly as it is intended to be.

Contractor Guard:

import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable, Subscription } from 'rxjs';
import { map } from 'rxjs/operators';
import { AuthService } from '../auth.service';

@Injectable({
  providedIn: 'root'
})
export class ContractorGuard {

  constructor(private authService: AuthService,
    private router: Router) {
    //
  }

  public canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {

    return this.authService.isContractorUser().pipe(
      map(isContractor => {

        console.log('contractor is csutomer? : ' + isContractor);
        if (!isContractor) {
          this.router.navigate(['page-forbidden']);
          return false;
        } else {
          return true;
        }
      })
    );
  }
}

Customer Guard:

import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AuthService } from '../auth.service';

@Injectable({
  providedIn: 'root'
})
export class CustomerGuard {

  constructor(private authService: AuthService, private router: Router) {
    //
  }

  public canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {

    return this.authService.isContractorUser().pipe(
      map(isContractor => {
        console.log('Customer is contractor : ' + isContractor);
        if (isContractor) {

          this.router.navigate(['page-forbidden']);
          return false;
        } else {
          return true;
        }
      })
    );

  }
}

Login Guard(To guard the login page for logged in user):

import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AuthService } from '../auth.service';

@Injectable({
  providedIn: 'root'
})
export class LoginGuard {

  constructor(private authService: AuthService, private router: Router) {
    //
  }

  public canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {

    return this.authService.isAuthenticated().pipe(
      map(isAuthenticated => {
        if (isAuthenticated) {
          this.router.navigate(['page-forbidden']);
          return false;
        } else {
          return true;
        }
      })
    );
  }
}

Function to return the Behavioral subject of 'isContractor' and 'isLoggedIn':

import { Injectable } from '@angular/core';
import { UserService } from './user.service';
import { SnackbarService } from './snackbar.service';
import { Router } from '@angular/router';
import { BehaviorSubject, Observable } from 'rxjs';
import { HttpHeaders } from '@angular/common/http';


@Injectable({
  providedIn: 'root'
})
export class AuthService {

  constructor(private userService: UserService,
    private snackbarService: SnackbarService,
    private router: Router) { }

  private _loggedIn = new BehaviorSubject<boolean>(false);
  private loggedIn = this._loggedIn.asObservable();
  private _isContractor = new BehaviorSubject<boolean>(false);
  private isContractor = this._isContractor.asObservable();

  public isAuthenticated(): Observable<boolean> {
    return this.loggedIn;
  }

  public isContractorUser(): Observable<boolean> {
    return this.isContractor;
  }
}

Function to persist logging with JWT (This function is placed in ngOnInit of the app.component.ts, so it will be called every time after the hard refresh or revisit the page.):

public autoLogin(): void {
    if (localStorage.getItem("accessToken")) {

      //Persist the login with JWT, Server verify if it is expired. If it is, try create new JWT with the refresh JWT (The logic is in error section).
      this.userService.loginWithJwt(localStorage.getItem('accessToken')).subscribe(
        response => {
          if (response.status == 200) {
            this.userData = response.body.data[0];
            localStorage.setItem('IsLoggedIn', '1');
            this._loggedIn.next(true);

            if (this.userData['isContractor']) {
              this._isContractor.next(true);
            } else {
              this._isContractor.next(false);
            }
          }
        })
}

As you can see the value for 'isContractor' and 'isLoggedIn' behavioral subject is updated with the API route call 'loginWithJWT' which is an Observable. Thus there is some asynchronous problem that the value in the Customer Guard or Contractor guard will always use the default value.

Demo video on youtube for the problem: https://youtu.be/HrjkfMd_YlA

I had tried

1.promise (by using session storage to determine if the value is retrieved from the database before returning a new boolean value, but for some reason, the Guard will just take the default value even with this approach.)

2.BehaviouralSubject (Observable)

I am stuck at how to make the CanActivate not taking the boolean value of isContractor earlier than the value retrieved from the database. It seems like the value is always lagging behind. I believe this is some asynchronous problem that I am not sure how to solve...


Solution

  • You can turn _isContractor to a plain Subject instead of a BehaviorSubject. Because the fact that your observable source is a BehaviorSubject (which by definition is created with a default value) the rest of your asynchronous logic just uses that value instead of actually waiting for it to be found out (request to server in your case).

    When you use a plain Subject, there will be no default value involved, and all of your logic that depends on this, will wait until your subject emits; that will happen after you authenticate, when you call _isContractor.next.

    There is also another thing that you could do: use your isLoggedIn observable to actually check isContractor after the user is logged in. Something like this:

    export class CustomerGuard {
    
      constructor(private authService: AuthService, private router: Router) {
        //
      }
    
      public canActivate(
        route: ActivatedRouteSnapshot,
        state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {
    
        return this.authService.isAuthenticated().pipe(
          filter(isAuthenticated => isAuthenticated),
          switchMap(() => this.authService.isContractorUser())
          map(isContractor) => {
            console.log('Customer is contractor : ' + isContractor);
            if (isContractor) {
    
              this.router.navigate(['page-forbidden']);
              return false;
            } else {
              return true;
            }
          })
        );
      }
    }