I'm trying to check permissions on routes in an AngularJS application. Routes are handled by angular-ui-router.
My routes and permissions-per-route are defined as this:
angular.module('my_app')
.config(function ($stateProvider, $urlRouterProvider) {
$stateProvider
.state('root', {
abstract: true,
templateUrl: 'content.html',
resolve: {
user_info: function (UserService) {
return UserService.user_info();
}
}
})
.state('root.some_page', {
url: '/',
templateUrl: 'some_page.html',
controller: 'home',
data: {
roles: ['some_right', 'some_other_right]
}
});
}
I then check for permissions as this:
[...].run(function ($rootScope, $state, UserService) {
$rootScope.$on('$stateChangeStart',
function (event, toState) {
if (!!toState.data) {
var required_roles = toState.data.roles;
if (!UserService.has_permission_in(required_roles)) {
event.preventDefault();
$state.go('root.access_forbidden');
}
}
});
});
All good for now. My issue is resolving user permissions. There are provided by a backend and fetched using $http.get(...). Since the $http service returns a promise, it don't always have the result when the $stateChangeStart is event triggered.
It's "good" when the application is "warm", but if I land on a protected page, it fails (the data are not ready). I have no way to "wait" for the promise.
What would be a "good" way of doing access control on routes?
In your protected state's definition:
.state('root.some_page', {
url: '/',
templateUrl: 'some_page.html',
controller: 'home',
resolve: {
access: function($http, $state){ //Note: you can inject your own services/factories too
var requiredPermissions = ['some_right', 'some_other_right'];
return $http.get(...).then(function(response){
var permissions = response.data.permissions;
var granted = permissions.reduce((prev,curr) => requiredPermissions.indexOf(curr) > -1 || prev, false);
if(!granted)
$state.go('publicState');
return permissions;
});
}
}
})
Resolve waits for promises to settle before moving to a new state.
Better:
function checkPermissions(requiredPermissions){
return function ($http, $state){ //Note: you can inject your own services/factories too
return $http.get(...).then(function(response){
var permissions = response.data.permissions;
var granted = permissions.reduce((prev,curr) => requiredPermissions.indexOf(curr) > -1 || prev, false);
if(!granted)
$state.go('publicState');
return permissions;
});
}
}
//In state definition
.state('root.some_page', {
url: '/',
templateUrl: 'some_page.html',
controller: 'home',
resolve: {
access: checkPermissions(['first','second'])
}
})
Related
I have resolve method inside angular config. It was written to protect the view from unauthorized access. Now the problem is, if I create a different route file, I have to copy the same resolve on each file. Is there any other way so that I can write it once and use it everywhere?
(function(){
'use strict';
var app = angular.module('app');
app.config(/* #ngInject */ function($stateProvider, $urlRouterProvider) {
var authenticated = ['$q', 'MeHelper', '$state', function ($q, MeHelper, $state) {
var deferred = $q.defer();
MeHelper.ready()
.then(function (me) {
if (me.isAuthenticated()) {
deferred.resolve();
} else {
deferred.reject();
$state.go('login');
}
});
return deferred.promise;
}];
$stateProvider
.state('index', {
url: "",
views: {
"FullContentView": { templateUrl: "start.html" }
}
})
.state('dashboard', {
url: "/dashboard",
views: {
"FullContentView": { templateUrl: "dashboard/dashboard.html" }
},
resolve: {
authenticated: authenticated
}
})
$urlRouterProvider.otherwise('/404');
});
})();
Edit: MeHelper is a Service.
To refactor your code, you should register a service and take the authentication code to the service.
Authenticate service:
app.factory('authenticateService', ['$q', 'MeHelper',
function($q,MeHelper){
var obj = {};
obj.check_authentication = function(params)
{
var deferred = $q.defer();
MeHelper.ready()
.then(function (me) {
if (me.isAuthenticated()) {
deferred.resolve();
} else {
deferred.reject();
$state.go('login');
}
});
return deferred.promise;
}
return obj;
}
]);
Then, use this service in any route file in resolve, taking this service name in dependency injection or the function parameter,
Route configuration file:
(function(){
'use strict';
var app = angular.module('app');
app.config(/* #ngInject */ function($stateProvider, $urlRouterProvider) {
$stateProvider
.state('index', {
url: "",
views: {
"FullContentView": { templateUrl: "start.html" }
}
})
.state('dashboard', {
url: "/dashboard",
views: {
"FullContentView": { templateUrl: "dashboard/dashboard.html" }
},
resolve: {
authenticated:
function(authenticateService) {
return authenticateService.check_authentication();
}
}
})
$urlRouterProvider.otherwise('/404');
});
})();
watch the below lines, this is what we changes in the route configuration to resolve.
the service is injected in below lines:
resolve: {
authenticated:
function(authenticateService) {
return authenticateService.check_authentication();
}
}
Do your check on route change.
app.run(function ($rootScope, $state) {
$rootScope.$on('$locationChangeSuccess', function () {
if (unauthorized && $state.current.name !== 'login') {
$state.go('login');
}
});
});
For some reason after adding:
.state('root', {
abstract: true,
url: '',
views: {
'layout': {
templateUrl: '../views/layout.html'
},
'header#root': {
templateUrl: '../views/shared/header.html'
},
'footer#root': {
templateUrl: '../views/shared/footer.html'
}
}
})
it stopped redirecting if the user is not logged in.
here is the full code:
angular
.module('authApp', ['ui.router', 'ui.bootstrap', 'ngAnimate', 'satellizer', 'smart-table'])
.config(function ($stateProvider, $urlRouterProvider, $authProvider, $httpProvider, $provide) {
function redirectWhenLoggedOut($q, $injector) {
return {
responseError: function (rejection) {
// Need to use $injector.get to bring in $state or else we get
// a circular dependency error
var $state = $injector.get('$state');
// Instead of checking for a status code of 400 which might be used
// for other reasons in Laravel, we check for the specific rejection
// reasons to tell us if we need to redirect to the login state
var rejectionReasons = ['token_not_provided', 'token_expired', 'token_absent', 'token_invalid'];
// Loop through each rejection reason and redirect to the login
// state if one is encountered
angular.forEach(rejectionReasons, function (value, key) {
if (rejection.data.error === value) {
// If we get a rejection corresponding to one of the reasons
// in our array, we know we need to authenticate the user so
// we can remove the current user from local storage
localStorage.removeItem('user');
// Send the user to the auth state so they can login
$state.go('root.login');
}
});
return $q.reject(rejection);
}
}
}
// Setup for the $httpInterceptor
$provide.factory('redirectWhenLoggedOut', redirectWhenLoggedOut);
// Push the new factory onto the $http interceptor array
$httpProvider.interceptors.push('redirectWhenLoggedOut');
$authProvider.loginUrl = '/api/authenticate';
$urlRouterProvider.otherwise('/login');
$stateProvider
.state('root', {
abstract: true,
url: '',
views: {
'layout': {
templateUrl: '../views/layout.html'
},
'header#root': {
templateUrl: '../views/shared/header.html'
},
'footer#root': {
templateUrl: '../views/shared/footer.html'
}
}
})
.state('root.login', {
url: '/login',
templateUrl: '../views/authView.html',
controller: 'AuthController as auth'
})
.state('root.account', {
url: '/account',
templateUrl: '../views/account.html',
controller: 'UserController as user'
})
.state('root.dashboard', {
url: '/dashboard',
templateUrl: '../views/dashboard.html',
controller: 'UserController as user'
})
.state('root.report', {
url: '/report',
templateUrl: '../views/account.html',
controller: 'UserController as user'
});
})
.run(function ($rootScope, $state, $location) {
// $stateChangeStart is fired whenever the state changes. We can use some parameters
// such as toState to hook into details about the state as it is changing
$rootScope.$on('$stateChangeStart', function (event, toState) {
// Grab the user from local storage and parse it to an object
var user = JSON.parse(localStorage.getItem('user'));
// If there is any user data in local storage then the user is quite
// likely authenticated. If their token is expired, or if they are
// otherwise not actually authenticated, they will be redirected to
// the auth state because of the rejected request anyway
if (user) {
// The user's authenticated state gets flipped to
// true so we can now show parts of the UI that rely
// on the user being logged in
$rootScope.authenticated = true;
// Putting the user's data on $rootScope allows
// us to access it anywhere across the app. Here
// we are grabbing what is in local storage
$rootScope.currentUser = user;
// If the user is logged in and we hit the auth route we don't need
// to stay there and can send the user to the main state
if (toState.name === "root.login") {
// Preventing the default behavior allows us to use $state.go
// to change states
event.preventDefault();
// go to the "main" state which in our case is dashboard
$state.go('root.dashboard');
}
}
});
});
i've added :
else{
// go to the "login" state
$state.go("root.login");
event.preventDefault();
}
in
if (user) {}
Is there an easier way to redirect to the login state without using a resolve: {} on each state? My app is growing and adding resolves like loginRequired or skipIfLoggedIn to individual states is cluttering my config blocks. If it helps, I'm using Satellizer for authentication.
Example module route config:
(function () {
'use strict';
angular
.module('uberCoolModule')
.config(configRoutes);
configRoutes.$inject = ['$stateProvider'];
function configRoutes ($stateProvider) {
$stateProvider
.state('login', {
url : '/login',
templateUrl : 'entrance/login/login.html',
data : { pageTitle: 'Login' },
resolve: {
skipIfLoggedIn: skipIfLoggedIn
}
})
.state('logout', {
url : '/logout',
controller : 'Logout'
resolve: {
loginRequired: loginRequired
}
})
.state('members', {
url : '/members',
controller : 'Home'
resolve: {
loginRequired: loginRequired
}
});
function skipIfLoggedIn($q, $auth) {
var deferred = $q.defer();
if ($auth.isAuthenticated()) {
deferred.reject();
} else {
deferred.resolve();
}
return deferred.promise;
}
function loginRequired($q, $location, $auth) {
var deferred = $q.defer();
if ($auth.isAuthenticated()) {
deferred.resolve();
} else {
$location.path('/login');
}
return deferred.promise;
}
}
})();
The easiest way is have a parent state that contains the authorization resolve and make all states that require login be children of that parent state.
A child state can not be accessed if resolve of any of it's ancestor states gets rejected
app.state('members', {
url: '/members',
controller: 'Home'
resolve: {
loginRequired: loginRequired
}
}).state('members.profile', {
url: '/profile',
});
In this example going to /members/profile will fail if loginRequired is rejected
I have some routes defined like this :
$stateProvider
.state('app', {
url: '/',
abstract: true,
views: {
'menuContent': {
templateUrl: 'templates/home.html'
}
}
})
.state('app.restricted', {
url: '/restricted',
views: {
'content': {
templateUrl: 'templates/restricted/restricted-dashboard.html',
controller: 'RestrictedController as vmRestricted'
}
},
resolve: {
isGranted: 'isGranted'
}
})
.state('app.restricted.pending', {
url: '/pending',
views: {
'tabsView': {
templateUrl: 'templates/restricted/restricted-manage-pending.html',
controller: 'RestrictedPendingController as vm'
}
},
resolve: {
isGranted: 'isGranted'
}
})
.state('app.restricted.devices', {
url: '/devices',
views: {
'tabsView': {
templateUrl: 'templates/trusted/restricted-manage-devices.html',
controller: 'RestrictedDevicesController as vm'
}
},
resolve: {
isGranted: 'isGranted'
}
})
.state('app.grant', {
url: '/grant-access',
views: {
'content': {
templateUrl: 'templates/grant-access.html',
controller: 'GrantAccessController as vm'
}
}
})
;
In these routes I have a restricted area and a grant access page to grant access to the restricted area.
When the isGranted resolve provider is rejected I redirect to the app.grant route.
This is the code doing this :
$rootScope.$on(AngularEvents.STATE_CHANGE_ERROR, _onStateChangeError);
function _onStateChangeError(event, toState, toParams, fromState, fromParams, error){
switch (error) {
case 'accessRejected':
$state.go('app.grant');
break;
}
}
Here is the code of my isGranted provider :
(function() {
'use strict';
angular.module('app')
.provider('isGranted', isGrantedProvider);
isGrantedProvider.$inject = [];
function isGrantedProvider() {
this.$get = isGranted;
isGranted.$inject = ['$q', '$log', 'grantService'];
function isGranted($q, $log, grantService){
$log.log('isGrantedProvider');
if (grantService.isGranted()) {
return $q.when(true);
} else {
return $q.reject('accessRejected');
}
}
}
})();
(grantService.isGranted() just returns a boolean value)
The first time I go to the app.restricted route with $state.go('app.restricted') the provider is executed.
The route is rejected because the access is not granted and we are redirected to the app.grant route.
In this page, the user can log in and have access to the restricted area. Once the user is logged in we redirect him to the app.restricted.pending route but the resolve is not called and the route is rejected and we are redirected to the app.grant route again, whereas the access was granted.
Why is the resolve not called?
Is there a way to force it?
EDIT
I have new information after some testing.
I saw that the resolve is not called the second time only when it is a service:
This resolve is always executed when we enter the state:
state('app.restricted', {
url: '/restricted',
views: {
'content': {
templateUrl: 'templates/restricted/restricted-dashboard.html',
controller: 'RestrictedController as vmRestricted'
}
},
resolve: {
isGranted: ['$log', function($log) {
$log.log('RESOLVE');
}]
}
})
But this resolve is only executed once even when I enter again to the state:
state('app.restricted', {
url: '/restricted',
views: {
'content': {
templateUrl: 'templates/restricted/restricted-dashboard.html',
controller: 'RestrictedController as vmRestricted'
}
},
resolve: {
isGranted: 'isGranted'
}
})
angular.module('app')
.provider('isGranted', isGrantedP);
isGrantedP.$inject = [];
function isGrantedP() {
this.$get = isGranted;
isGranted.$inject = ['$q', '$log'];
function isGranted($q, $log){
$log.log('RESOLVE');
}
}
Why isn't this service called each time? Is it because a service is a singleton? How should I proceed?
After a lot of investigations and testing I found the solution!
First, let's see why it is not working
As mentioned in the docs (http://angular-ui.github.io/ui-router/site/#/api/ui.router.state.$stateProvider), if the resolve is a string, then it corresponds to a service
factory - {string|function}: If string then it is alias for service.
Otherwise if function, it is injected and return value it treated as
dependency. If result is a promise, it is resolved before its value is
injected into controller.
And as mentioned in the angularjs docs (https://docs.angularjs.org/guide/providers), all services are singletons, meaning that it will be instantiated only once
Note: All services in Angular are singletons. That means that the
injector uses each recipe at most once to create the object. The
injector then caches the reference for all future needs.
Why is it important?
Because resolves do not call a function inside our service. They just use the return value of the instantiated service. BUT because our service will be instantiated only once, the return value will always be the same! (because our service initialization is only called once)
What can we do?
From my tests I could see that a resolve defined like this:
resolve: {
myResolve: ['$log', function($log) {
$log.log('My Resolve!');
}]
}
is always executed, so we can write them this way to make it work correctly.
But how can I do if I want to use my service?
The best working solution I found to be able to use my service and have a syntax that looks similar to this one: myResolve: 'myResolveService' is to declare my resolve like this:
resolve: {
myResolve: ['myResolveService', function(MyResolveService) {
myResolveService.log();
}]
}
And my service like this:
angular.module('app')
.factory('myResolve', myResolve);
myResolve.$inject = ['$log'];
function myResolve($log) {
function service(){
this.log = log;
function log() {
$log.log('My resolve!');
}
}
return new service();
}
This code can also be adapted for resolves that return a promise:
Resolve:
resolve: {
myResolve: ['myResolveService', function(MyResolveService) {
return myResolveService.check();
}]
}
Service:
angular.module('app')
.factory('myResolve', myResolve);
myResolve.$inject = ['$q', 'myService'];
function myResolve($q, myService) {
function service(){
this.check = check;
function check() {
var defer = $q.defer();
if (myService.check()) {
defer.resolve(true);
} else {
defer.reject('rejected');
}
return defer.promise;
}
}
return new service();
}
I'm trying to redirect the user to the login page if the user is not logged in. The only use case that I can't get to work is if the user points the browser to a URL that requires authentication.
If the user goes directly to http://.../index.html#/dashboard (requires authentication), the browser will attempt to load that page.
If the user first goes to http://.../index.html#/login and then replaces 'login' with 'dashboard', the app will correctly not allow that and redirect to the login page.
When the user goes directly to 'dashboard', I can SEE that the code hits the redirection logic, but it seems to just ignore it.
WAT?
Here's my ui-router config and code:
angular.module('app', [
'ngRoute',
'ngCookies',
'ui.router',
'datatables',
'datatables.bootstrap',
'angularMoment'
])
.config(config)
.run(run);
config.$inject = ['$stateProvider', '$urlRouterProvider', '$httpProvider'];
function config($stateProvider, $urlRouterProvider, $httpProvider) {
$stateProvider
.state('login', {
url: '/login?ac',
controller: 'LoginController as vm',
templateUrl: 'app/login/login.view.html'
})
.state('content', {
url: '/',
abstract: true,
views: {
// the main template will be placed here (relatively named)
'#': {
templateUrl: 'app/content-template/container-with-nav.partial.html'
},
'navigation#content': {
templateUrl: 'app/common/views/master-navigation.partial.html'
}
}
})
.state('content.dashboard', {
url: 'dashboard',
views: {
'': {
templateUrl: 'app/dashboard/dashboard.partial.html'
},
'glance#content.dashboard': {
templateUrl: 'app/dashboard/dashboard-overview.partial.html',
controller: 'DashGlanceController as vm'
},
'cases#content.dashboard': {
templateUrl: 'app/dashboard/dashboard-cases.partial.html',
controller: 'DashCasesController as vm'
}
}
})
.state('content.casedetails', {
url: 'case/:caseId',
views: {
'': {
templateUrl: 'app/pages/cases/case.main.partial.html',
controller: 'CaseController as vm'
},
'casedetails#content.casedetails': {
templateUrl: 'app/pages/cases/case.details.partial.html'
},
'citation#content.casedetails': {
templateUrl: 'app/pages/cases/case.citation.partial.html'
},
'payout#content.casedetails': {
templateUrl: 'app/pages/cases/case.payout.partial.html'
},
'conversation#content.casedetails': {
templateUrl: 'app/pages/cases/case.conversation.partial.html'
},
'actionpending#content.casedetails': {
templateUrl: 'app/pages/cases/case.action-pending.partial.html'
}
}
})
;
$urlRouterProvider.otherwise('/login');
$httpProvider.defaults.withCredentials = true;
}
run.$inject = ['$state', '$rootScope', '$location', '$cookies'];
function run($state, $rootScope, $location, $cookies) {
// keep user logged in after page refresh
$rootScope.globals = ($cookies.get('globals')) ? JSON.parse($cookies.get('globals')) : null;
console.log('globals (should be set if cookie was present): ', $rootScope.globals);
if ($rootScope.globals) {
console.log('Found a cookie for the logged in user: ', $rootScope.globals.currentUser);
}
$rootScope.$on('$locationChangeStart', function (event, next, current) {
var restrictedPage = $.inArray($location.path(), ['/login', '/register']) === -1;
var isUserLoggedIn = ($rootScope.globals) && ($rootScope.globals.currentUser) ? true : false;
console.log('restricted page: ', restrictedPage, ', logged in: ', isUserLoggedIn);
// redirect to login page if not logged in and trying to access a restricted page
if (restrictedPage && !isUserLoggedIn) {
// This actually gets entered!
console.log('Page is restricted and user is not logged in - redirecting to login from listener.');
$state.go('login', {ac: ''});
//$location.path('/login');
}
});
}
It will be because the config is redirecting the user to the new state before your run function has ran when going directly to the restricted state.
The reason is works when they go to login first is because you have already set your event listener and it doesn't get unbound when moving to the new state.
Basically, if you change the listener to the end of state change rather than the start event, it probably will work as the state change will not have ended.
$rootScope.$on('$locationChangeSuccess', function (event, next, current) {