4

I am trying to implement a basic security check to block users from accessing certain states depending on their permission set:

'use strict';

var autoExecApp = angular.module("myApp", ['ui.router']);

autoExecApp.config(['$stateProvider', '$urlRouterProvider', function($stateProvider, $urlRouterProvider){
    $stateProvider
        .state('index', {
            url: '/index',
            templateUrl: 'partials/index.html'
        })
        .state('permission', {
            url: '/permission',
            templateUrl: 'partials/permissions.html'
        });
}]);

autoExecApp.run(['$rootScope', '$state', '$userRoles', function($rootScope, $state, $userRoles) {

    $rootScope.$on('$stateChangeStart', function (event, toState, toParams) {

        event.preventDefault(); // prevent page from being loaded anyway
        $userRoles.hasPermissions(toState.name).then(function(data) {
            var result = data === "true";
            if (!result) {
                $state.go('permission');
            }
            else {
                $state.go(toState.name, toParams ,{notify:false});
            }
        });
    });
}]);

Now the problem surrounds the fact that my state change happens inside of a promise. I would happily make it synchronous - as it really should be in this case but I am confused as to why it is not working asynchronously.

If I move the event.preventDefault() inside the promise, it works. BUT does the state change in 2 steps. First, it will always go to the original page and then it will soon after transition to the 'blocked' page (if it was blocked). I gather this is because it returns successfully once from the $on and updates the state, and then updates it again when it returns from the promise.

If the promise is how you see it however, the state will not change. Everything appears to be working, but the page will not update the view. I am not sure why this is the case. For some reason, if the event is not prevented, the later state change will work. However, if it is prevented and the $state.go is called from the returned promise, it appears to be too late to update the $state.

EDIT: I had success using the broadcast solution and this was what it looked like:

event.preventDefault();
$state.go(toState.name, toParams, {notify: false}).then(function() {
     // line 907 state.js
     $rootScope.$broadcast('$stateChangeSuccess', toState, toParams, fromState, fromParams);
});

Apparently due to it being in a promise, the preventDefault(); suppresses this '$stateChangeSuccess' from naturally firing. Performing it manually restores the expected behaviour.

Alex
  • 2,405
  • 4
  • 23
  • 36

3 Answers3

5

Its a known issue. The workaround that has generally worked is to broadcast a custom event from within '$stateChangeStart' handler and then do your state transition from within that custom event's handler.

$rootScope.$on('$stateChangeStart', function (event, toState, toParams) {

    event.preventDefault(); // prevent page from being loaded anyway
    $userRoles.hasPermissions(toState.name).then(function(data) {
        var result = data === "true";
        if (!result) {

            $rootScope.$broadcast('goToPermission');
        }
        else {
            $rootScope.$broadcast('goToState', toState.name);
        }
    });
});

borrowed from: https://github.com/angular-ui/ui-router/issues/178#issuecomment-49156829

Vlad Gurovich
  • 8,463
  • 2
  • 27
  • 28
  • This did contain the answer in the attached link, but what worked was chaining a broadcast of the event 'stateChangeSuccess' to the state.go method. I did not have success by simply placing the transition in a '$on' method – Alex Mar 30 '15 at 06:34
1

I think that Vladimir Gurovich's "broadcast" solution is the way to go, especially as he indicates it to be an established workaround.

But I wonder if a solution in which permissions are cached then looked up synchronously, might also be viable.

autoExecApp.run(['$rootScope', '$state', '$userRoles', function($rootScope, $state, $userRoles) {
    // Create a permissions cache, seeded with 
    // permission to go to the 'permission' state.
    var permissionCache = {
        'permission': true
    };

    // Create a researched list of all possible state names.
    var stateNames = ['stateA', 'stateB', 'stateC'];

    // Now load the cache asynchronously with 
    // `true` for each permission granted.
    stateNames.forEach(function(state) {
        $userRoles.hasPermissions(state).then(function(data) {
            if(data === "true") {
                permissionCache[state] = true;
            }
        });
    });

    // And finally, establish a $stateChangeStart handler 
    // that looks up permissions **synchronously**, 
    // in the permissionCache.
    $rootScope.$on('$stateChangeStart', function (event, toState, toParams) {
        if (!permissionCache[toState.name]) {
            event.preventDefault(); // prevent page from being loaded
            $state.go('permission');
        }
    });
}]);

Of course, this solution would have its own issues :

  • relies on good research of all possible state names.
  • any state change attempted before the cache is loaded, will be rejected.
  • any change of userRoles in the life of the page would require the cache to be cleared and reloaded.

Without research/testing, I can't say whether these issues are significant or not.

Roamer-1888
  • 19,138
  • 5
  • 33
  • 44
0

Why don't you use a resolve function to check if the user can access your route?
Something like this:

autoExecApp.config(['$stateProvider', '$urlRouterProvider', function (
    $stateProvider, $urlRouterProvider) {
    $stateProvider
        .state('index', {
            url: '/index',
            templateUrl: 'partials/index.html'
        })
        .state('permission', {
            url: '/permission',
            templateUrl: 'partials/permissions.html'
        })
    .state('restricted-route', {
      url: '/restricted',
      templateUrl: 'partials/restricted.html',
      resolve: {
        authenticated: function ($q) {
          var deferred = $q.defer();
          $userRoles.hasPermissions(this)
            .then(function (data) {
              var result = data === "true";
              if (result) {
                deferred.resolve();
              } else {
                deferred.reject('unauthorized');
              }
            });
          return deferred.promise;
        }
      }
    });
}]);

$rootScope.$on('$stateChangeError',
  function (event, toState, toParams, fromState, fromParams, rejection) {

    if (rejection.error === 'unauthorized') {
      $state.go('permission');
    }
  }
}
pasine
  • 11,311
  • 10
  • 49
  • 81