AngularJS routing doesn't support route authorization out of the box and nor does its popular cousin ui-router even though the latter supports state change cancellation with later continuation (using $urlRouter.sync()
; check documentation). But in non-trivial Angular SPAs this feature is regularly needed. So I went off implementing it.
Work of others
It is possible to find several working examples for this problem on the internet that do route authorization, but I haven't come across an elegant example that wouldn't authorize synchronously. With this I mean requesting server for authorization info (be it general per-user or granular per-route). This means that an asynchronous request has to be sent to server during routing phase. And that's exactly what said examples most commonly don't implement. They also don't reveal how they initially obtain user roles? Whether they're obtained before SPA bootstraping (this would require manual bootstraping) or after.
What's already supported by ngRoute
The first thing we think of when we mention promises (which an anyc server request is) and routing are route resolves (checkresolve
property of the route
parameter object in Angular documentation). This parameter defines any promises that need to be resolved before your routed view is being processed. Documentation states that this is a mapping of dependencies that will be injected into controller some of which may be promises that will first get resolved before controller is instantiated. Ok, so there is some already supported mechanism in ngRoute that can get us closer to route authorization. The problem is that we should be manually adding resolves to all routes that require some form of authorization. That's quite a bit of code duplication on the routing configuration as we'd have to be doing something along these lines:
// some route definition object { templateUrl: "viewTemplate.html", controller: "SomeController", controllerAs: "vm", resolve: { authorize: ["userService", "$location", function(userService, $location) { return userService.getUserInfo() .then(function(userInfo) { if (userInfo.isAnonymous) { $location.path("/"); } return userInfo; }); }] } }...for every route. Not to mention if we wanted to implement role based authorization. And if we wanted to introduce some change to this process we'd have to change all routes that apply. Maintainability nightmare!
There's also one much greater problem with upper technique that you should be aware of. Authorized route resolution is taking place even on unauthorized route requests. And that is particularly problematic, because authorization-secured route controller gets instantiated possibly making some server resource requests in the process. Not to mention that authorization-secured view also gets processed along the way. But at least this major problem can be solved by throwing an error in our resolve which basically stops route from processing all the way to success rather ending up in a route change error state.
// some route definition object { templateUrl: "viewTemplate.html", controller: "SomeController", controllerAs: "vm", resolve: { authorize: ["userService", "$location", function(userService, $location) { return userService.getUserInfo() .then(function(userInfo) { if (userInfo.isAnonymous) { $location.path("/"); throw 302; // THIS ERROR as HTTP 302 Found } return userInfo; }); }] } }
Which gets us to centralized solution
Specifically that route change error makes us think of centralized set it and forget it solution to this problem with minor code additions to routing configuration and no code duplication. There are three routing events in ngRoute that we can add listeners to and interfere with the process to our requirements.
$routeChangeStart
which fires before route starts processing its resolves$routeChangeSuccess
which fires right after all route resolves are successfully resolved and$routeChangeError
which fires after resolving route resolves but at least one failed resolution
- client issues an HTTP request for some authorization-secured resource
- server processes authorization
- if authorization succeeds authorization-secured resource is returned if authorization fails an HTTP 302 Found error is being returned instead with Location header pointing to redirection resource - usually login (although this may vary on the server and platform we're using)
Final solution process
Following solution shows simple authenticated flag authorization but it could easily be changed to support role-based authentication as well. I'll explain how to do it later on. In any way the process works like this:
- client side routing starts for authorized route (authorized routes have an additional custom property authorize: true)
$routeChangeStart
event fires where we inject authorization resolve making sure we only inject it once- authorization resolve makes a server request getting authorization information and checks its result with authorization requirements
- if authorization succeeds, nothing is particularly done so routing will continue to execute
if authorization fails, we throw a specific error type (similar to server responding with 302 and not doing anything further) - on fail $routeChangeError event handler executes where error type is being checked (don't assume it's always authorization problem as other resolves may fail for other reasons) and if it matches our custom
AuthorizationError
a redirect is being done to login route
Routing configuration
Routing still needs some sort of configuration so we define somehow which routes require authorization. But since we're building a centralized solution to this problem, we want to simplify this as much as possible. So we only add a simple route configuration property.
// some route definition object { templateUrl: "viewTemplate.html", controller: "SomeController", controllerAs: "vm", authorize: true }Now this is a lot less code than previously with route resolves. We only mark those routes that require authorization and keep others as they are.
Route change events
Main logic is part of these events. We have to inject a missing resolve in routes that require it and then handle authorization errors by redirecting to login route. Nothing particularly complicated.
1: angular
2: .module("ModuleName", ["ngRoute"])
3: .config(/* routing configuration */)
4: .run(["$rootScope", "$location", function($rootScope, $location) {
5: $rootScope.$on("$routeChangeStart", function(evt, to, from) {
6: // requires authorization?
7: if (to.authorize === true)
8: {
9: to.resolve = to.resolve || {};
10: if (!to.resolve.authorizationResolver)
11: {
12: // inject resolver
13: to.resolve.authorizationResolver = ["authService", function(authService) {
14: return authService.authorize();
15: }];
16: }
17: }
18: });
19:
20: $rootScope.$on("$routeChangeError", function(evt, to, from, error) {
21: if (error instanceof AuthorizationError)
22: {
23: // redirect to login with original path we'll be returning back to
24: $location
25: .path("/login")
26: .search("returnTo", to.originalPath);
27: }
28: });
29: }]);
Other minor things
There are other minor things that need implementation like AuthorizationError
type or AuthorizationService
implementation, but especially the latter is completely up to you.
Complete code of a running example
I've created a plnkr example that you can play with and see how it works. If you want to see how individual parts execute and in what order, make sure you open developer console and observe logs created by the code. Below code is without the additional logging but would run just the same.
1: <!DOCTYPE html>
2: <html ng-app="Test">
3: <head>
4: <meta charset="utf-8" />
5: <title>Angular routing authentication example</title>
6: <style>
7: body {
8: font-family: Sans-serif;
9: }
10: section {
11: margin-top: 2em;
12: border: 1px solid #ccc;
13: padding: 0 2em 1em;
14: }
15: .important {
16: color: #e00;
17: }
18: </style>
19: </head>
20:
21: <body>
22: <h3>Angular routing authorization implementation</h3>
23: <p>
24: Home and login are public views and load immediately because they don't have
25: any promises to resolve (set by <code>route.resolve</code>).
26: Authorized view is only accessible after user is authenticated.
27: Authorization promise resolves in 1 second.
28: </p>
29: <p>
30: Anonymous users are <strong>auto-redirected to login</strong> when trying to
31: access Authorized view. After login they're auto-redirected back to where they
32: were before redirection to login view. If users manually access login view
33: (clicking Login link) they're redirected to Home after login/logout.
34: </p>
35: <p class="important">Open <strong>development console</strong> to observe execution log.</p>
36: <a href="#/home">Home</a> |
37: <a href="#/private">Authorized</a> |
38: <a href="#/login">Login</a>
39:
40: <section>
41: <ng-view></ng-view>
42: </section>
43:
44: <script type="text/ng-template" id="login">
45: <h1>Login view</h1>
46: <p>This is a publicly accessible login view</p>
47: <p><a href="" ng-click="context.login()">click here</a> to auto-authenticate</p>
48: <p><a href="" ng-click="context.logout()">logout</a> to prevent authorized view access.</p>
49: </script>
50:
51: <script type="text/ng-template" id="public">
52: <h1>Public view</h1>
53: <p>This is a publicly accessible view</p>
54: </script>
55:
56: <script type="text/ng-template" id="private">
57: <h1>Authorized view</h1>
58: <p>This is an authorized view</p>
59: </script>
60:
61: <script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.4.6/angular.min.js"></script>
62: <script src="https://code.angularjs.org/1.4.3/angular-route.js"></script>
63: <script type="text/javascript">
64: (function(angular){
65:
66: "use strict";
67:
68: angular
69: .module("Test", ["ngRoute"])
70:
71: .config(function($routeProvider){
72: $routeProvider
73: .when("/", {
74: templateUrl: "public",
75: controller: "GeneralController",
76: controllerAs: "context"
77: })
78: .when("/login", {
79: templateUrl: "login",
80: controller: "LoginController",
81: controllerAs: "context"
82: })
83: .when("/private", {
84: templateUrl: "private",
85: controller: "GeneralController",
86: controllerAs: "context",
87: authorize: true
88: })
89: .otherwise({
90: redirectTo: "/"
91: });
92: })
93:
94: .run(function($rootScope, $location){
95: $rootScope.$on("$routeChangeStart", function(evt, to, from){
96: if (to.authorize === true)
97: {
98: to.resolve = to.resolve || {};
99: if (!to.resolve.authorizationResolver)
100: {
101: to.resolve.authorizationResolver = function(authService) {
102: return authService.authorize();
103: };
104: }
105: }
106: });
107:
108: $rootScope.$on("$routeChangeError", function(evt, to, from, error){
109: if (error instanceof AuthorizationError)
110: {
111: $location.path("/login").search("returnTo", to.originalPath);
112: }
113: });
114: })
115:
116: .controller("LoginController", function($location, authService){
117: this.login = login(true, $location.search().returnTo);
118: this.logout = login(false, "/");
119: // DRY helper
120: function login(doWhat, whereTo){
121: return function() {
122: authService.authenticated = doWhat;
123: $location.path(whereTo && whereTo || "/");
124: };
125: }
126: })
127:
128: .controller("GeneralController", function(){
129: })
130:
131: .service("authService", function($q, $timeout){
132: var self = this;
133: this.authenticated = false;
134: this.authorize = function() {
135: return this
136: .getInfo()
137: .then(function(info){
138: if (info.authenticated === true)
139: return true;
140: // anonymous
141: throw new AuthorizationError();
142: });
143: };
144: this.getInfo = function() {
145: return $timeout(function(){
146: return self;
147: }, 1000);
148: };
149: });
150:
151: // Custom error type
152: function AuthorizationError(description) {
153: this.message = "Forbidden";
154: this.description = description || "User authentication required.";
155: }
156:
157: AuthorizationError.prototype = Object.create(Error.prototype);
158: AuthorizationError.prototype.constructor = AuthorizationError;
159:
160: })(angular);
161: </script>
162:
163: </body>
164:
165: </html>
How about those user roles
The same code with minor modifications can also be used for role-based authorization. Depending on how your role security should work you'd mainly have to make changes below. By how I mean how authorization should be. Whether you would be granting permission to single role or a combination. Suppose we define three distinct roles: anonymous, authenticated and administrator.
- routing configuration should replace
authorize: true
to one of the these:- authorize: "authenticated" when you'd like to authorise against single roles
authorize: ["authenticated", "administrator"]
orauthorize: "authenticated|administrator"
when you'd like to authorize against several roles and depending whether role matching would be done using array's.indexOf()
or string's regular expression.test()
matching
- authorization service's
.authorize(roles)
should now accept a parameter with route roles which you'd use to decide on the outcome of authorization execution $routeChangeStart
should provide roles when injecting authorization resolve so that authorization service would work as per previous alinee
One warning though
I should warn you about one last thing. Some very old AngularJS bug that's been around since 2013 or at least that was the time it was reported. The bug is about browser history. When routing starts resolving an and even if it ends up in the $routeChangeError
state, your browser's location (URL) would still be changed as if routing took place successfully. This is particularly problematic in our case where we deliberately fail route changes due to failed authorization checks.
Don't say I didn't warn you. You can mitigate this problem somehow by rewriting URL using $location.path(fromRouteURL).rewrite()
which would replace failed route's URL to previous one. You can also call $window.histroy.back()
after it, so you don't end up with two identical states in your history if you'd be clicking back button in your browser. Until your next navigation you'd have a forward state defined, but users are a lot more likely to click back than forward.
If you have any suggestions of your own, you're welcome to participate on GitHub's issue or comment on this post and if I find your idea particularly interesting I'll pass it forward to develpers on GitHub.
Hey going to give this a shot with UI-Router, just wanted to thank you in advance incase I forget to show my results.
ReplyDeleteHow'd it go?
DeleteWhy is it necessary to call getInfo with a 1s delay instead of evaluating authenticated right away? Can't quite figure that out. Thanks for this: simple, centralized, and effective. Was able to extend it for authorizing elements, too.
ReplyDeletegetInfo() in this case is just a simulation of an async server call. It's a proof of concept of asynchronous authentication during routing phase. Hence I've written it as a 1s async timeout. Async call should return much quicker though in order for your app to keep performance. :)
DeleteThank you, Robert!!
ReplyDeleteYour post helped me solve the issue i had with authorization.
thank you! it was very useful!
ReplyDeleteHi,
ReplyDeleteUseful but your code is not minifcation safe even when using ng-annotate. To make it safe make the following changes:
to.resolve.authorizationResolver = ['authService', function(authService) {
console.log("Resolving authorization.");
return authService.authorize();
}];
My homepage components were all firing their respective ajax requests when landing on the root url before the router redirected to login. I was doing everything with promises and didn't think of throwing an Error. I was just rejecting the promise thinking that a rejected promise would prevent the controller from getting instantiated at all. It makes sense to me that it would do that... anyway, thanks!
ReplyDelete> "There's also one much greater problem with upper technique
ReplyDelete> that you should be aware of. Authorized route resolution is
> taking place even on unauthorized route requests. And that
> is particularly problematic, because authorization-secured
> route controller gets instantiated possibly making some
> server resource requests in the process."
That was my problem. The home page was firing requests to
get all the stuff I want to display there. Thank you for
teaching the solution to this. Throwing an error did the
trick, though it seems a little like throwing the baby
out with the bathwater. Wow, client side routing sucks.
ui-router states resolve property cannot be set as variable like in your tutorial:
ReplyDeleteto.resolve.authorizationResolver = function(authService) ...
https://github.com/angular-ui/ui-router/issues/1165
transitions should be used, but I can't make it work
great post thanks for sharing
ReplyDeleteI think this is one of the most significant information for me. And i’m glad reading your article. Keep it up.
ReplyDelete