Другие статьи по AngularJS:

В данной статье поговорим о том, как можно быстро и безболезненно контролировать доступ наших пользователей на клиенте, не нарушая внутренний дзен и организацию кода Angular-приложения. Кроме этого мы вскользь коснемся стандартных средств роутинга, предоставляемых ядром AngularJS, и напишем простейшее приложение с авторизацией и разграничением ресурсов.

Итак, Angular предлагает нам в качестве средства маршрутизации $routeProvider из модуля ngRoute. Подробней о нем можно почитать в доках тут. Мы же остановимся на том, что в нем есть и чего в нем нет.

Напишем простейшее приложение по мотивам «библиотеки». Для нетерпеливых: демо.

Схема роутинга следующая:

  • /books — все книги;
  • /books/a — книги на букву А и т. д.;
  • /login — страница авторизации;
  • /settings — страница настроек.

Ролевая модель:

  • admin — суперпользователь, имеющий доступ к настройкам;
  • user — рядовой юзер.

Код приложения должен быть понятен начинающим ангулярщикам, так что комментариев по реализации простейших функций фильтрации, использованию директив в данной статье не будет.

app.js:

var app = angular.module('bookWorldApp', ['ngRoute', 'authorizationModule'])
  .config(function($routeProvider) {
      $routeProvider
          .when('/books/:letter?', {
              templateUrl: 'books.html',
              controller: 'booksCtrl'
          })
          .when('/login', {
              templateUrl: 'login.html',
              controller: 'loginCtrl'
          })
          .when('/settings', {
              templateUrl: 'settings.html',
              controller: 'settingsCtrl'
          })
          .otherwise({
              redirectTo: '/login'
          });
  });

app.controller('appCtrl', ['$scope', '$location', '$userProvider',
    function($scope, $location, $userProvider) {
        //удобно, чтобы не инжектить $location в дочерних контроллерах 
        //однако, плохо для тестирования
        $scope.goTo = function(path) {
            $location.path(path);
        }
        //расширяем самый верхний $scope методами провайдера пользователя
        //после этого удобно использовать эти методы сразу в представлениях (см. books.html)
        angular.extend($scope, $userProvider, true);
    }]);

app.controller('booksCtrl', ['$scope', '$location', '$routeParams', function($scope, $location, $routeParams) {
    $scope.booksFilter = $routeParams.letter;
    $scope.books = [{ Name: 'Zen and the Art of Motorcycle Maintenance' },
        { Name: 'Ulysses' }, { Name: 'Gatsby' }, { Name: 'Ginger' }, { Name: 'Zimmermann Telegram' }];
    $scope.bookClick = function(book) {
        $scope.goTo('/books/' + book.Name[0].toLowerCase());
    };
    $scope.filterBooks = function() {
        return function(book) {
            if (!$scope.booksFilter) {
                return true;
            }
            return book.Name[0].toLowerCase() === $scope.booksFilter;
        };
    };
}]);

app.controller('settingsCtrl', ['$scope', function ($scope) { }])

authorizationModule.js:

var authorizationModule = angular.module('authorizationModule', []);

authorizationModule.controller('loginCtrl', ['$scope', 'authorizationFactory', '$location', 
function($scope, authorizationFactory, $location){
  $scope.loginClick = function() {
    if (authorizationFactory.login($scope.login, $scope.pass)) {
      $location.path('/books');
    } else {
      alert('Pass is 123456!');
    }
  }
}]);

//заглушка фабрики, обращающейся к серверу для проверки авторизации
authorizationModule.factory('authorizationFactory',['$userProvider',
  function($userProvider){
    var login = function(login, pass){
      if (pass !== '123456') {
        return false;
      }
      if (login === 'admin') {
        $userProvider.setUser({Login: login, Roles: [$userProvider.rolesEnum.Admin]});
      } else {
        $userProvider.setUser({Login: login, Roles: [$userProvider.rolesEnum.User]});
      }
      return true;
    }

    return {
      login: login,
    }
}]);

//провайдер информации о пользователе (роли, логин и тд)
authorizationModule.factory('$userProvider', function(){
  var rolesEnum = {
    Admin: 0,
    User: 1
  };
  var setUser = function(u){
    user = u;
  }
  var getUser = function(){
    return user;
  }

  return {
    getUser: getUser,
    setUser: setUser,
    rolesEnum: rolesEnum
  }
});

Index.html:

<!DOCTYPE html>
<html>
  <head>
    <script src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
    <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.3.0-rc.0/angular.min.js"></script>
    <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.3.0-rc.0/angular-route.js"></script>
    <script src="app.js"></script>
    <script src="authorizationModule.js"></script>
    <script src="securityModule.js"></script>
    <link rel="stylesheet" href="style.css" />
    <script type="text/javascript">
      angular.element(document.getElementsByTagName('head')).append(angular.element('<base href="' + window.location.pathname + '" />'));
    </script>
  </head>

  <body ng-app="bookWorldApp" ng-controller="appCtrl" ng-view=""></body>
</html>

И представления.

Login.html:

<div>
  <div>Welcome to book world!</div>
  <div>Who r u man?</div>
  <div>
    <input placeholder="Login (ex. admin/user)" ng-model="login" />
  </div>
  <div>
    <input placeholder="Pass (ex. 123456)" type="password" ng-model="pass" />
  </div>
  <button ng-click="loginClick()">And press me slowly</button>
</div>

Books.html:

<div>
  Hello, {{getUser().Login}}!
  <div>
    Books list<span ng-show="booksFilter"> on letter "{{booksFilter.toUpperCase()}}"</span>: 
    <a href="" ng-click="goTo('/books')" ng-hide="!booksFilter">All books</a>
  </div>
  <ul>
    <li ng-repeat="book in books | filter: filterBooks()">
      <button ng-click="bookClick(book)">{{book.Name}}</button>
    </li>
  </ul>
  <button ng-click="goTo('/settings')">Settings</button>
  <button ng-click="goTo('/login')">Exit</button>
</div>

Settings.html:

<div>
  <div>Settings.</div>
  <div>Only superuser can see this.</div>
  <button ng-click="goTo('/books')">To books</button>
</div>

Примечание. Настоятельно рекомендую вынести всю логику авторизации пользователя и его идентификации в отдельный модуль. Это не только обезопасит, как правило, слабоизменяемую логику в этом модуле, но и позволит без особых усилий протестировать этот модуль (например, с помощью Jasmine). Кроме этого, стоит расширить $scope в самом верхнем контроллере методами из $userProvider для того, чтобы не инжектить в каждый модуль этот провайдер. (см. вывод логина пользователя в books.html)

Собственно, основная задача $routeProvider с otherwise — перенаправить пользователя любой ценой. Ему глубоко плевать, кто этот пользователь, зачем он здесь и как он сюда попал. Он доверяет всем. С помощью templateUrl указывается путь к файлу представления,  controller указывает имя контроллера, который управляет этим представлением. Помимо этого этот провайдер позволяет передавать параметры в контроллер с помощью $routeParams через конструкции вида /:[parameterName][?] (при указании ? этот параметр становится опциональным).

Из плюсов:

  • прозрачность работы;
  • возможность указывать параметры в url’е.

Из минусов:

  • нет контроля за доступом пользователей к ресурсам при ролевой модели приложения;
  • нет именованных путей: при изменении урла придется менять редиректы в виде goTo(‘url’);
  • нет контроля за вводом параметров, указываемых в url: лицомпоклавиатуре, ойянезналчтотолькочисла, — примет все.

Последний минус не столь болезненный и при малых/средних масштабах приложения легко контролируется на стороне контроллера (см. booksCtrl из примера). Но первый становится большой головной болью, если приложение многопользовательское и требует разграничения прав доступа к отдельным ресурсам. Чтобы не прибегать к изучению сторонних навесов над ангуляром для контроля за доступом к ресурсам, напишем свой простейший сервис с блэкджэком и для минимального контроля за доступом.

Организуем модуль для хранения этого сервиса:

angular.module('securityModule', ['authorizationModule'])
.factory('$pagesSecurityService', ['$userProvider', '$location',
    function ($userProvider, $location) {

        var checkAuthorize = function(path) {
            if ($userProvider.getUser() == null) {
                $location.path('/login');
            }
            switch (path) {
              //запрещенный ресурс
              case '/settings':
                  return checkPageSecurity({
                      //роли текущего пользователя
                      UserRoles: $userProvider.getUser().Roles,
                      //роли, которым доступен ресурс
                      AvailableRoles: [
                          $userProvider.rolesEnum.Admin
                      ]
                  });
            default:
                return true;
            }
        };

        var checkPageSecurity = function (config) {
            var authorize = false;
            for (var i in config.UserRoles) {
                if ($.inArray(config.UserRoles[i], config.AvailableRoles) == -1) {
                    authorize = false;
                } else {
                    authorize = true;
                    break;
                }
            }
            return authorize;
        };

        return {
            checkAuthorize: checkAuthorize,
        };
    }]);

Как это работает? Фабрика $pagesSecurityService возвращает единственный метод checkAuthorize, определяющий, имеет или не имеет пользователь доступ к данному ресурсу по прямому url. Для того чтобы фабрика имела доступ к пользователю инжектим в нее $userProvider из модуля авторизации.

В методе checkAuthorize, в switch указываем запрещенные ресурсы. Если провайдер пользователя хранит в себе объект user (а если не хранит, фабрика запрещает доступ таким товарищам) и url ресурса не присутствует в списке запрещенных ресурсов, то доступ к такому ресурсу по умолчанию разрешен всем (default).

Далее, инжектим securityModule в приложение и изменяем код нашего самого верхнего контроллера appCtrl следующим образом:

app.controller('appCtrl', ['$scope', '$location', '$pagesSecurityService', '$userProvider',
  function($scope, $location, $pagesSecurityService, $userProvider){
    $scope.goTo = function(path){
      $location.path(path);
    }
    angular.extend($scope, $userProvider, true);
    
    //контроль доступа
    $scope.$on('$locationChangeStart', function (event, nextUrl, prevUrl) {
        if ($location.path() != '/login' || nextUrl.indexOf('login') == -1) {
            if (!$pagesSecurityService.checkAuthorize($location.path())) {
                alert('Access denied!');
                $location.path(prevUrl.split('#')[1]);
            }
        }
    });
}]);

Для контроля доступа перед переходом по каждому url (что исключает мерцания и прочее невежество) вызывается метод $pagesSecurityService.checkAuthorize с url назначения. В случае если пользователю запрещен доступ, мы каким-либо образом информируем его и при необходимости перенаправляем.

Минусы такого подхода:

  • значительный рост кейсов в списке контролируемых ресурсов при достаточно сложной организации доступа к данным, что делает код очень громоздким.

Плюсы:

  • простота и прозрачность информации о том, к каким ресурсам ограничивается доступ;
  • простота тестирования.

Другие статьи по AngularJS: