Restricting access based on auth state or roles
Once you move beyond the quick-starts and examples and start building a real app with Angular2 you soon find you need to handle things that the examples often leave out or pass over.
Securing routes with the new component router is one of these and it can be difficult to figure out. Here’s the approach I’m using which seems to be working well for me and has been reusable across multiple projects.
First of all, this is for declarative security only. That is, where the rules can be statically defined on the route. It should be adaptable to work with whatever authentication system your app uses but is particularly suited to using JSON Web Tokens which can contain the roles that your user has been granted.
If you need per-object permission checks I favour doing these within the
component and / or service that is responsible for accessing and handling
them (some people prefer to use CanActivate
). That kind of dynamic check
is out of scope of this article.
The natural place to declare the permissions for a route is on the route
config which provides a data
property. While we could add a flag to indicate
if a route should be public or not, it seems a little superfluous as routes
are public by default and so the flag would only ever add new information if
set to false. As we’re also going to define roles that the user requires we
can make that do double-duty and use it’s presence to indicate that we need
authentication but it can be empty if we don’t care about any specific roles
which is the same as saying we only care that the user is authenticated.
Here’s an example of three routes, the first one is open to all, the second one only available to authenticated users (but no roles specified) and the last one requiring the user has the role ‘admin’.
@RouteConfig([
{ path:'/open2all', component:OpenComponent, name:'Open' }
{ path:'/needauth', component:AuthComponent, name:'Auth', data:{ roles:[] }}
{ path:'/needrole', component:RoleComponent, name:'Role', data:{ roles:['admin'] }}
])
So that’s the permissions for our routes defined - the easy part! We want to be
able to check these permissions as part of the routing process and the natural
place to do this is the <router-outlet>
so we are going to create out own
<secure-outlet>
version that will override the activate
method to do the
permission checks. But what if permission fails? What do we want to happen?
There are two scenarios:
A route that requires authentication when the user has not been authenticated.
The natural thing to do is to redirect to the sign-in route to allow the user to sign-in and then return back to our protected route.
A route that requires certain roles than an authenticated user does not have.
The user doesn’t have access and should be redirected to a permission denied route.
So we need our secure router to be configureable with two routes - one for sign-in and one for unauthorized access. We can pass these as properties in the view of our routing component which will look like this:
<secure-outlet signin="/Signin" unauthorized="/Denied"></secure-outlet>
All the magic will happen in the <secure-outlet>
component but we don’t want
to couple it directly to our app-specific authentication service as this can make
it harder to re-use. Instead we’ll define an interface that our auth service needs
to supply to allow it to be used by this component.
export abstract class IAuthService {
// is the current user authenticated?
abstract isAuthenticated():boolean;
// does the current user have one of these roles?
abstract hasRole(roles: string[]):boolean;
}
This should be straightforward to implement into any AuthService
our app uses but
one subtle thing to ensure is that when we ask for an instance of the IAuthService
we’re actually give the instance of the AuthService
used by the app (whatever it
is called). We do this by setting the provider to use in our app bootstrap using the
useExisting
option which prevents us getting a separate instance when the AuthService
itself will also likely be configured:
bootstrap(App, [
AUTH_HTTP_PROVIDERS,
// more providers ...
provide(IAuthService, { useExisting: AuthService }),
AuthService,
]);
Finally, we’re ready to create our <secure-outlet>
component which will provide
the permissions checks and handle the route redirects if they fail:
import {Directive, Attribute, ElementRef, DynamicComponentLoader} from 'angular2/core';
import {Router, RouteData, RouterOutlet, ComponentInstruction} from 'angular2/router';
@Directive({selector: 'secure-outlet'})
export class SecureRouterOutlet extends RouterOutlet {
signin:string;
unauthorized:string;
private parentRouter: Router;
private authService: IAuthService;
constructor(_elementRef: ElementRef, _loader: DynamicComponentLoader,
_parentRouter: Router, @Attribute('name') nameAttr: string,
authService:IAuthService,
@Attribute('signin') signinAttr: string,
@Attribute('unauthorized') unauthorizedAttr: string) {
super(_elementRef, _loader, _parentRouter, nameAttr);
this.parentRouter = _parentRouter;
this.authService = authService;
this.signin = signinAttr;
this.unauthorized = unauthorizedAttr;
}
activate(nextInstruction: ComponentInstruction): Promise<any> {
var roles = <string[]>nextInstruction.routeData.data['roles'];
// no roles defined means route has no restrictions so activate
if (roles == null) {
return super.activate(nextInstruction);
}
// if user isn't authenticated then redirect to sign-in route
// pass the URL to this route for redirecting back after auth
// TODO: include querystring parameters too?
if (!this.authService.isAuthenticated()) {
var ins = this.parentRouter.generate([this.signin,{url:location.pathname}]);
return super.activate(ins.component);
}
// if no specific roles are required *or* the user has one of the
// roles required then the route can be activated
if (roles.length == 0 || this.authService.hasRole(roles)) {
return super.activate(nextInstruction);
}
// user has insufficient role permissions so redirect to denied
var ins = this.parentRouter.generate([this.unauthorized]);
return super.activate(ins.component);
}
reuse(nextInstruction: ComponentInstruction): Promise<any> {
return super.reuse(nextInstruction);
}
}
Now we just need to make sure our applications AuthService
(whatever it is
called) provides the necessary pieces:
import { IAuthService } from '../components/secure-outlet/secure-outlet';
@Injectable()
export class AuthService extends IAuthService {
user:User;
isAuthenticated():boolean {
return this.user !== null;
}
hasRole(string[] roles):boolean {
return this.isAuthenticate() && [check intersection of user roles]
}
// other auth functionality, sign-in, token handling etc
}
And there we have it. Permissions declared on our routes and handled for us.
Just be sure to have the sign-in route check for the url parameter it is sent and redirect back to it after authentication has been completed.