Angular 5/6: protect route (route guard) without redirecting to error route
Asked Answered
S

4

14

I have a bit of a pickle. I am using Route guard (implementing CanActivate interface) to check if user is granted access to particular route:

const routes: Routes = [
    {
        path: '',
        component: DashboardViewComponent
    },
    {
        path: 'login',
        component: LoginViewComponent
    },
    {
        path: 'protected/foo',
        component: FooViewComponent,
        data: {allowAccessTo: ['Administrator']},
        canActivate: [RouteGuard]
    },
    {
        path: '**',
        component: ErrorNotFoundViewComponent
    }
];

Now it works great in protecting the '/protected/foo' route from activating, but I would like to tell the user that route he is trying to access is forbidden (similar to 403 Forbidden you may get from server).

The problem: How do I show the user this special error view without redirecting him to error route which seams to be the preferred option by so many sources I have found? And how do I still use my RouteGuard without actually loading the forbidden route, because if I check access inside my FooViewComponent and display different view it kind of defeats point of having RouteGuard in the first place.

Ideally I would like to have my RouteGuard not only returning false in canActivate() method, but also replace component completely with say ErrorForbiddenViewComponent. But I have no idea how to do it, or is it event possible. Any alternatives?

This is how my route guard looks now:

import {Injectable} from '@angular/core';
import {Router, CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot} from '@angular/router';
import {AuthService} from '../services/auth.service';

@Injectable()
export class RouteGuard implements CanActivate {

    constructor(
        private router: Router,
        private auth: AuthService
    ) {}

    canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
        const { auth, router } = this;
        const { allowAccessTo } = next.data;
        const identity = auth.getIdentity();
        if (
            identity &&
            allowAccessTo.indexOf(identity.role)
        ) {
            // all good, proceed with activating route
            return true;
        }
        if (identity) {
            // TODO show ErrorForbiddenViewComponent instead of redirecting
            console.log('403 Forbidden >>', next);
        }
        else { 
            // not logged in: redirect to login page with the return url
            const [returnUrl, returnQueryParams] = state.url.split('?');
            console.log('401 Unauthorised >>', returnUrl, returnQueryParams, next);
            router.navigate(['/login'], {queryParams: {returnUrl, returnQueryParams}});
        }
        return false;
    }
}

So I am just preventing route from loading, but I am not redirecting. I only redirect non logged visitors to login route.

Reasoning:

  • Routes should reflect certain state of application - visiting a route url should recreate that state
  • To have error routes (except for 404 Not Found) would mean your application can actually recreate error states. This makes no sense as why would you keep error state as state of your application? For debugging purpose one should use logs (console or server), revisiting error page (i.e. page refresh) might interfere with that.
  • Also by redirecting to error route app should provide some insights of error to user. For that matter either some parameter would need to be passed via url or (far worse) keeping the error sate in some error service and retrieve it upon accessing error route.
  • Also, ignoring the RouteGuard and just loading the component and checking access inside it may result in some extra dependencies loaded which would not be used anyway (as user is not allowed), makes the whole lazy loading much harder.

Does anyone have some kind of solution for this? I also wonder how come that after Angular 2+ being around for so long nobody had this kind of situation before? Everybody is just ok with redirecting?

Also keep in mind that although I am currently using the FooViewComponent synchronously, that may change in future!

Siphonostele answered 22/5, 2018 at 23:20 Comment(4)
How about using ComponentFactory as described in blog.mgechev.com/2015/09/30/… and replace the component itself with a AccessForbidden oneNeedlefish
@TarunLalwani If you want to earn those 200 rep, please provide an answer. I have to also warn you that NG2 functionality is long deprecated, so for NG5/6 that code needs serious overhaul. On the other note, I am looking at dynamic component loading - maybe that provides better insightPipes
I am not an angular dev, that's why posted a directionNeedlefish
dzone.com/articles/implementing-guard-in-angular-5-appVoltammeter
S
4

After looking at angular2 example provided by Tarun Lalwani in comments of question and after taking deeper look into Dynamic component loader article on Angular docs I have managed to apply it to my code:

I no longer use my RouteGuard when specifying routes:

{
     path: 'protected/foo',
     component: FooViewComponent,
     data: {allowAccessTo: ['Administrator']}, // admin only
     canActivate: [RouteGuard]
},

Instead I have created special RouteGuardComponent and here is how I use it:

{
    path: 'protected/foo',
    component: RouteGuardComponent,
    data: {component: FooViewComponent, allowAccessTo: ['Administrator']}
},

This is the code of RouteGuardComponent:

@Component({
    selector: 'app-route-guard',
    template: '<ng-template route-guard-bind-component></ng-template>
    // note the use of special directive ^^
})
export class RouteGuardComponent implements OnInit {

    @ViewChild(RouteGuardBindComponentDirective)
    bindComponent: RouteGuardBindComponentDirective;
    // ^^ and here we bind to that directive instance in template

    constructor(
        private auth: AuthService,
        private route: ActivatedRoute,
        private componentFactoryResolver: ComponentFactoryResolver
    ) {
    }

    ngOnInit() {
        const {auth, route, componentFactoryResolver, bindComponent} = this;
        const {component, allowAccessTo} = route.snapshot.data;
        const identity = auth.getIdentity();
        const hasAccess = identity && allowAccessTo.indexOf(identity.role);
        const componentFactory = componentFactoryResolver.resolveComponentFactory(
            hasAccess ?
               component : // render component
               ErrorForbiddenViewComponent // render Forbidden view
        );
        // finally use factory to create proper component
        routeGuardBindComponentDirective
            .viewContainerRef
            .createComponent(componentFactory);
    }

}

Also, this requires special directive to be defined (I am sure this can be done some other way, but I have just applied that Dynamic component example from Angular docs):

@Directive({
    selector: '[route-guard-bind-component]'
})
export class RouteGuardBindComponentDirective {
    constructor(public viewContainerRef: ViewContainerRef) {}
}

It isn't full answer to my own question (but its a start), so if somebody provides something better (i.e. a way to still use canActivate and ability to lazy load) I'll make sure to take that into account.

Siphonostele answered 30/5, 2018 at 15:10 Comment(0)
F
4

I had once worked on the similar problem.

Sharing my stackblitz poc where I have created -

  • Authenticated Component (with guard)
  • Login Component
  • Permission Guard
  • Route (/auth route is provided with PermissionGuardService guard)

The guard is evaluating the user type and handling the redirection / error accordingly.

The use cases are -

  • User is not logged in (shows a toast with log in message)
  • User is not admin (shows a toast with unauthorised message)
  • User is admin (show a toast with success messaage)

I have stored the user in local storage.

EDIT - DEMO enter image description here

Let me know if you need a special handling in it and I will update the code base.

Cheers!

Funnyman answered 1/6, 2018 at 20:30 Comment(1)
Your solution relies on "special communication service" (ToasterService) that keeps the error message between redirection. This is what I am trying to avoid. This message is not permanent state of Application (its transient), and redirecting to same location just to show same error message is also pretty hacky way of solving this issue. If I may suggest you only redirect non-logged users via your PermissionGuard, and use AuthenticatedComponent to actually block access (Forbidden)Pipes
D
2

Your RouteGuard can inject whatever service you're using for modal windows, and the .canActivate() can pop the modal without redirection to inform the user without disturbing the current state of the app.

We use toastr and its angular wrapper for this, since it creates a modeless pop-up that self-dismisses after so-many seconds, no OK/Cancel buttons needed.

Deemster answered 1/6, 2018 at 23:18 Comment(1)
I personally dont like the popup solution, because it also raises problem when user comes from "outside", i.e. by clicking link in email. But I understand where you are going (check answer of @Funnyman ). Just like mine its not universal, and it will either work for you or wont. For me popup does not workPipes
F
2

I've recently come across the same problem. In the end, I couldn't manage to do this using CanActivate guard, so I've implemented the authorisation logic in the component that holds the <router-outlet>.

Here is its template:

<div class="content">
  <router-outlet *ngIf="(accessAllowed$ | async) else accessDenied"></router-outlet>
</div>
<ng-template #accessDenied>
  <div class="message">
    <mat-icon>lock</mat-icon>
    <span>Access denied.</span>
  </div>
</ng-template>

And its source code:

import { ActivatedRoute, ActivationStart, Router } from '@angular/router';
import { filter, switchMap, take } from 'rxjs/operators';
import { merge, Observable, of } from 'rxjs';
import { Component } from '@angular/core';

@Component({
  selector: 'app-panel-content',
  templateUrl: './content.component.html',
  styleUrls: ['./content.component.scss'],
})
export class PanelContentComponent {

  /**
   * A stream of flags whether access to current route is permitted.
   */
  accessAllowed$: Observable<boolean>;

  constructor(
    permissions: UserPermissionsProviderContract, // A service for accessing user permissions; implementation omitted
    route: ActivatedRoute,
    router: Router,
  ) {
    const streams: Observable<boolean>[] = [];

    /*
    The main purpose of this component is to replace `<router-outlet>` with "Access denied" 
    message, if necessary. Such logic will be universal for all possible route components, and 
    doesn't require any additional components - you will always have at least one component with
    `<router-outlet>`.

    This component contains `<router-outlet>`, which by definition means that all possible authorisable 
    routes are beneath it in the hierarchy.
    This implicates that we cannot listen to `route.data` observable of `ActivatedRoute`, because the route 
    itself in this component will always be the parent route of the one we need to process. 

    So the only real (the least hacky, IMO) solution to access data of child routes is to listen to
    router events.
    However, by the time an instance of this component is constructed, all routing events will have been 
    triggered. This is especially important in case user loads the page on this route.

    To solve that, we can merge two streams, the first one of which will be a single access flag 
    for **activated route**, and the second will be a stream of flags, emitted from router 
    events (e.g. caused by user navigating through app).

    This approach requires that the authorised route is bottom-most in the hierarchy, because otherwise the 
    last value emitted from the stream created from router events will be `true`.
    */

    const deepestChild = this.findDeepestTreeNode(route);
    const currentData = deepestChild.routeConfig.data;

    // `data.authActions` is just an array of strings in my case
    if (currentData && 
        currentData.authActions && 
        Array.isArray(currentData.authActions) && 
        currentData.authActions.length > 0) {

      streams.push(
        // `hasPermissions(actions: strings[]): Observable<boolean>`
        permissions.hasPermissions(currentData.authActions).pipe(take(1))
      );

    } else {
      // If the route in question doesn't have any authorisation logic, simply allow access
      streams.push(of(true));
    }

    streams.push(router.events
      .pipe(
        filter(e => e instanceof ActivationStart),
        switchMap((event: ActivationStart) => {
          const data = event.snapshot.data;

          if (data.authActions && 
            Array.isArray(currentData.authActions) && 
            data.authActions.length > 0) {

            return permissions.hasPermissions(data.authActions);
          }

          return of(true);
        }),
      ));

    this.accessAllowed$ = merge(...streams);
  }

  /**
   * Returns the deepest node in a tree with specified root node, or the first 
   * encountered node if there are several on the lowest level.
   * 
   * @param root The root node.
   */
  findDeepestTreeNode<T extends TreeNodeLike>(root: T): T {
    const findDeepest = (node: T, level = 1): [number, T] => {
      if (node.children && node.children.length > 0) {
        const found = node.children.map(child => findDeepest(child as T, level + 1));
        found.sort((a, b) => a[0] - b[0]);

        return found[0];

      } else {
        return [level, node];
      }
    };

    return findDeepest(root)[1];
  }

}

interface TreeNodeLike {
    children?: TreeNodeLike[];
}

I've explained the approach in comments in the source code, but in short: access authorisation data in route.data using router events, and replace <router-outlet> with an error message if access is denied.

Felicio answered 27/9, 2019 at 4:26 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.