Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Resolve $http request before running app and switching to a route or state

I have written an app where I need to retrieve the currently logged in user's info when the application runs, before routing is handled. I use ui-router to support multiple/nested views and provide richer, stateful routing.

When a user logs in, they may store a cookie representing their auth token. I include that token with a call to a service to retrieve the user's info, which includes what groups they belong to. The resulting identity is then set in a service, where it can be retrieved and used in the rest of the application. More importantly, the router will use that identity to make sure they are logged in and belong to the appropriate group before transitioning them to the requested state.

I have code something like this:

app
    .config(['$stateProvider', function($stateProvider) {

        // two states; one is the protected main content, the other is the sign-in screen

        $stateProvider
            .state('main', {
                url: '/',
                data: {
                    roles: ['Customer', 'Staff', 'Admin']
                },
                views: {} // omitted
            })
            .state('account.signin', {
                url: '/signin',
                views: {} // omitted
            });
    }])
    .run(['$rootScope', '$state', '$http', 'authority', 'principal', function($rootScope, $state, $http, authority, principal) {

        $rootScope.$on('$stateChangeStart', function (event, toState) { // listen for when trying to transition states...
            var isAuthenticated = principal.isAuthenticated(); // check if the user is logged in

            if (!toState.data.roles || toState.data.roles.length == 0) return; // short circuit if the state has no role restrictions

            if (!principal.isInAnyRole(toState.data.roles)) { // checks to see what roles the principal is a member of
                event.preventDefault(); // role check failed, so...
                if (isAuthenticated) $state.go('account.accessdenied'); // tell them they are accessing restricted feature
                else $state.go('account.signin'); // or they simply aren't logged in yet
            }
        });

        $http.get('/svc/account/identity') // now, looks up the current principal
            .success(function(data) {
                authority.authorize(data); // and then stores the principal in the service (which can be injected by requiring "principal" dependency, seen above)
            }); // this does its job, but I need it to finish before responding to any routes/states
    }]);

It all works as expected if I log in, navigate around, log out, etc. The issue is that if I refresh or drop on a URL while I am logged in, I get sent to the signin screen because the identity service call has not finished before the state changes. After that call completes, though, I could feasibly continue working as expected if there is a link or something to- for example- the main state, so I'm almost there.

I am aware that you can make states wait to resolve parameters before transitioning, but I'm not sure how to proceed.

like image 928
moribvndvs Avatar asked Nov 01 '13 05:11

moribvndvs


2 Answers

OK, after much hair pulling, here is what I figured out.

  1. As you might expect, resolve is the appropriate place to initiate any async calls and ensure they complete before the state is transitioned to.
  2. You will want to make an abstract parent state for all states that ensures your resolve takes place, that way if someone refreshes the browser, your async resolution still happens and your authentication works properly. You can use the parent property on a state to make another state that would otherwise not be inherited by naming/dot notation. This helps in preventing your state names from becoming unmanageable.
  3. While you can inject whatever services you need into your resolve, you can't access the toState or toStateParams of the state it is trying to transition to. However, the $stateChangeStart event will happen before your resolve is resolved. So, you can copy toState and toStateParams from the event args to your $rootScope, and inject $rootScope into your resolve function. Now you can access the state and params it is trying to transition to.
  4. Once you have resolved your resource(s), you can use promises to do your authorization check, and if it fails, use $state.go() to send them to the login page, or do whatever you need to do. There is a caveat to that, of course.
  5. Once resolve is done in the parent state, ui-router won't resolve it again. That means your security check won't occur! Argh! The solution to this is to have a two-part check. Once in resolve as we've already discussed. The second time is in the $stateChangeStart event. The key here is to check and see if the resource(s) are resolved. If they are, do the same security check you did in resolve but in the event. if the resource(s) are not resolved, then the check in resolve will pick it up. To pull this off, you need to manage your resources within a service so you can appropriately manage state.

Some other misc. notes:

  • Don't bother trying to cram all of the authz logic into $stateChangeStart. While you can prevent the event and do your async resolution (which effectively stops the change until you are ready), and then try and resume the state change in your promise success handler, there are some issues preventing that from working properly.
  • You can't change states in the current state's onEnter method.

This plunk is a working example.

like image 80
moribvndvs Avatar answered Sep 29 '22 21:09

moribvndvs


We hit a similar issue. We felt we needed to make the logic which makes the HTTP call accessible to the logic that handles the response. They're separate in the code, so a service is a good way to do this.

We resolved that separation by wrapping the $http.get call in a service which caches the response and calls the success callback immediately if the cache is already populated. E.g.

app.service('authorizationService', ['authority', function (authority) {
    var requestData = undefined;

    return {
        get: function (successCallback) {
            if (typeof requestData !== 'undefined') {
                successCallback(requestData);
            }
            else {
                $http.get('/svc/account/identity').success(function (data) {
                    requestData = data;
                    successCallback(data);
                });
            }
        }
    };
}]);

This acts as a guard around the request being successful. You could then call authorizationService.get() within your $stateChangeStart handler safely.

This approach is vulnerable to a race condition if a request is in already progress when authorizationService.get() is called. It might be possible to introduce some XHR bookkeeping to prevent that.

Alternatively you could publish a custom event once the HTTP request has completed and register a subscriber for that event within the $stateChangeStart handler. You would need to unregister that handler later though, perhaps in a $stateChangeEnd handler.

None of this can complete until the authorisation is done, so you should inform the user that they need to wait by showing a loading view.

Also, there's some interesting discussion of authentication with ui-router in How to Filter Routes? on the AngularJS Google Group.

like image 24
afternoon Avatar answered Sep 29 '22 20:09

afternoon