AngularJS 應用身份認證的技巧

身份認證

最廣泛的身份認證方式就是用用戶名(或 email)和密碼作登錄操做。這就意味要實現一個登錄的表單,以便用戶可以用他們我的信息登錄。這個表單看起來是這樣的:javascript

<form name="loginForm" ng-controller="LoginController"
      ng-submit="login(credentials)" novalidate>
  <label for="username">Username:</label>
  <input type="text" id="username"
         ng-model="credentials.username">
  <label for="password">Password:</label>
  <input type="password" id="password"
         ng-model="credentials.password">
  <button type="submit">Login</button>
</form>

既然這個是 Angular-powered 的表單,咱們使用 ngSubmit 指令去觸發上傳表單時的函數。注意一點的是,咱們把我的信息傳入到上傳表單的函數,而不是直接使用 $scope.credentials 這個對象。這樣使得函數更容易進行 unit-test 和下降這個函數與當前 Controller 做用域的耦合。這個 Controller 看起來是這樣的:html

.controller('LoginController', function ($scope, $rootScope, AUTH_EVENTS, AuthService) {
  $scope.credentials = {
    username: '',
    password: ''
  };
  $scope.login = function (credentials) {
    AuthService.login(credentials).then(function (user) {
      $rootScope.$broadcast(AUTH_EVENTS.loginSuccess);
      $scope.setCurrentUser(user);
    }, function () {
      $rootScope.$broadcast(AUTH_EVENTS.loginFailed);
    });
  };javascript:void(0);
})

咱們注意到這裏是缺乏實際的邏輯的。這個 Controller 被作成這樣,目的是使身份認證的邏輯跟表單解耦。把邏輯儘量的從咱們的 Controller 裏面抽離出來,把他們都放到 services 裏面,這是個很好的想法。AngularJS 的 Controller 應該只管理 $scope 裏面的對象(用 watching 或者 手動操做)而不是承擔過多過度重的東西。java

通知 Session 的變化

身份認證會影響整個應用的狀態。基於這個緣由我更推薦使用事件(用 $broadcast)去通知 user session 的改變。把全部可能用到的事件代碼定義在一箇中間地帶是個不錯的選擇。我喜歡用 constants 去作這個事情:git

.constant('AUTH_EVENTS', {
  loginSuccess: 'auth-login-success',
  loginFailed: 'auth-login-failed',
  logoutSuccess: 'auth-logout-success',
  sessionTimeout: 'auth-session-timeout',
  notAuthenticated: 'auth-not-authenticated',
  notAuthorized: 'auth-not-authorized'
})

constants 有個很好的特性就是他們能隨便注入到別的地方,就像 services 那樣。這樣使得 constants 很容易被咱們的 unit-test 調用。constants 也容許你很容易地在隨後對他們重命名而不須要改一大串文件。一樣的戲法運用到了 user roles:github

.constant('USER_ROLES', {
  all: '*',
  admin: 'admin',
  editor: 'editor',
  guest: 'guest'
})

若是你想給予 editors 和 administrators 一樣的權限,你只須要簡單地把 ‘editor’ 改爲 ‘admin’。後端

The AuthService

與身份認證和受權(訪問控制)相關的邏輯最好被放到同一個 service:瀏覽器

.factory('AuthService', function ($http, Session) {
  var authService = {};

  authService.login = function (credentials) {
    return $http
      .post('/login', credentials)
      .then(function (res) {
        Session.create(res.data.id, res.data.user.id,
                       res.data.user.role);
        return res.data.user;
      });
  };

  authService.isAuthenticated = function () {
    return !!Session.userId;
  };

  authService.isAuthorized = function (authorizedRoles) {
    if (!angular.isArray(authorizedRoles)) {
      authorizedRoles = [authorizedRoles];
    }
    return (authService.isAuthenticated() &&
      authorizedRoles.indexOf(Session.userRole) !== -1);
  };
  return authService;
})

爲了進一步遠離身份認證的擔心,我使用另外一個 service(一個單例對象,using the service style)去保存用戶的 session 信息。session 的信息細節是依賴於後端的實現,可是我仍是給出一個較廣泛的例子吧:服務器

.service('Session', function () {
  this.create = function (sessionId, userId, userRole) {
    this.id = sessionId;
    this.userId = userId;
    this.userRole = userRole;
  };
  this.destroy = function () {
    this.id = null;
    this.userId = null;
    this.userRole = null;
  };
  return this;
})

一旦用戶登陸了,他的信息應該會被展現在某些地方(好比右上角用戶頭像什麼的)。爲了實現這個,用戶對象必需要被 $scope 對象引用,更好的是一個能夠被全局調用的地方。雖然 $rootScope 是顯然易見的第一個選擇,可是我嘗試剋制本身,不過多地使用 $rootScope(實際上我只在全局事件廣播使用 $rootScope)。用我所喜歡的方式去作這個事情,就是在應用的根節點,或者在別的至少高於 Dom 樹的地方,定義一個 controller 。 標籤是個很好的選擇:cookie

<body ng-controller="ApplicationController">
  ...
</body>

ApplicationController 是應用的全局邏輯的容器和一個用於運行 Angular 的 run 方法的選擇。所以它要處於 $scope 樹的根,全部其餘的 scope 會繼承它(除了隔離 scope)。這是個很好的地方去定義 currentUser 對象:session

.controller('ApplicationController', function ($scope,
                                               USER_ROLES,
                                               AuthService) {
  $scope.currentUser = null;
  $scope.userRoles = USER_ROLES;
  $scope.isAuthorized = AuthService.isAuthorized;

  $scope.setCurrentUser = function (user) {
    $scope.currentUser = user;
  };
})

咱們實際上不分配 currentUser 對象,咱們僅僅初始化做用域上的屬性以便 currentUser 能在後面被訪問到。不幸的是,咱們不能簡單地在子做用域分配一個新的值給 currentUser 由於那樣會形成 shadow property。這是用以值傳遞原始類型(strings, numbers, booleans,undefined and null)代替以引用傳遞原始類型的結果。爲了防止 shadow property,咱們要使用 setter 函數。若是想了解更多 Angular 做用域和原形繼承,請閱讀 Understanding Scopes

訪問控制

身份認證,也就是訪問控制,其實在 AngularJS 並不存在。由於咱們是客戶端應用,全部源碼都在用戶手上。沒有辦法阻止用戶篡改代碼以得到認證後的界面。咱們能作的只是顯示控制。若是你須要真正的身份認證,你須要在服務器端作這個事情,可是這個超出了本文範疇。

限制元素的顯示

AngularJS 擁有基於做用域或者表達式來控制顯示或者隱藏元素的指令: ngShow, ngHide, ngIf 和 ngSwitch。前兩個會使用一個 <style> 屬性去隱藏元素,可是後兩個會從 DOM 移除元素。

第一種方式,也就是隱藏元素,最好用於表達式頻繁改變而且沒有包含過多的模板邏輯和做用域引用的元素上。緣由是在隱藏的元素裏,這些元素的模板邏輯仍然會在每一個 digest 循環裏從新計算,使得應用性能降低。第二種方式,移除元素,也會移除全部在這個元素上的 handler 和做用域綁定。改變 DOM 對於瀏覽器來講是很大工做量的(在某些場景,和 ngShow/ngHide 對比),可是在不少時候這種代價是值得的。由於用戶訪問信息不會常常改變,使用 ngIf 或 ngShow 是最好的選擇:

<div ng-if="currentUser">Welcome, {{ currentUser.name }}</div>
<div ng-if="isAuthorized(userRoles.admin)">You're admin.</div>
<div ng-switch on="currentUser.role">
  <div ng-switch-when="userRoles.admin">You're admin.</div>
  <div ng-switch-when="userRoles.editor">You're editor.</div>
  <div ng-switch-default>You're something else.</div>
</div>

限制路由訪問

不少時候你會想讓整個網頁都不能被訪問,而不是僅僅隱藏一個元素。若是能夠再路由(在UI Router 裏,路由也叫狀態)使用一種自定義的數據結構,咱們就能夠明確哪些用戶角色能夠被容許訪問哪些內容。下面這個例子使用 UI Router 的風格,可是這些一樣適用於 ngRoute。

.config(function ($stateProvider, USER_ROLES) {
  $stateProvider.state('dashboard', {
    url: '/dashboard',
    templateUrl: 'dashboard/index.html',
    data: {
      authorizedRoles: [USER_ROLES.admin, USER_ROLES.editor]
    }
  });
})

下一步,咱們須要檢查每次路由變化(就是用戶跳轉到其餘頁面的時候)。這須要監聽 $routeChangStart(ngRoute 裏的)或者 $stateChangeStart(UI Router 裏的)事件:

.run(function ($rootScope, AUTH_EVENTS, AuthService) {
  $rootScope.$on('$stateChangeStart', function (event, next) {
    var authorizedRoles = next.data.authorizedRoles;
    if (!AuthService.isAuthorized(authorizedRoles)) {
      event.preventDefault();
      if (AuthService.isAuthenticated()) {
        // user is not allowed
        $rootScope.$broadcast(AUTH_EVENTS.notAuthorized);
      } else {
        // user is not logged in
        $rootScope.$broadcast(AUTH_EVENTS.notAuthenticated);
      }
    }
  });
})

Session 時效

身份認證多半是服務器端的事情。不管你用什麼實現方式,你的後端會對用戶信息作真正的驗證和處理諸如 Session 時效和訪問控制的處理。這意味着你的 API 會有時返回一些認證錯誤。標準的錯誤碼就是 HTTP 狀態嗎。廣泛使用這些錯誤碼:

  • 401 Unauthorized — The user is not logged in
  • 403 Forbidden — The user is logged in but isn’t allowed access
  • 419 Authentication Timeout (non standard) — Session has expired
  • 440 Login Timeout (Microsoft only) — Session has expired

後兩種不是標準內容,可是可能普遍應用。最好的官方的判斷 session 過時的錯誤碼是 401。不管怎樣,你的登錄對話框都應該在 API 返回 401, 419, 440 或者 403 的時候立刻顯示出來。總的來講,咱們想廣播和基於這些 HTTP 返回碼的時間,爲此咱們在 $httpProvider 增長一個攔截器:

.config(function ($httpProvider) {
  $httpProvider.interceptors.push([
    '$injector',
    function ($injector) {
      return $injector.get('AuthInterceptor');
    }
  ]);
})
.factory('AuthInterceptor', function ($rootScope, $q,
                                      AUTH_EVENTS) {
  return {
    responseError: function (response) { 
      $rootScope.$broadcast({
        401: AUTH_EVENTS.notAuthenticated,
        403: AUTH_EVENTS.notAuthorized,
        419: AUTH_EVENTS.sessionTimeout,
        440: AUTH_EVENTS.sessionTimeout
      }[response.status], response);
      return $q.reject(response);
    }
  };
})

這只是一個認證攔截器的簡單實現。有個很棒的項目在 Github ,它作了相同的事情,而且使用了 httpBuffer 服務。當返回 HTTP 錯誤碼時,它會阻止用戶進一步的請求,直到用戶再次登陸,而後繼續這個請求。

登陸對話框指令

當一個 session 過時了,咱們須要用戶從新進入他的帳號。爲了防止他丟失他當前的工做,最好的方法就是彈出登陸登陸對話框,而不是跳轉到登陸頁面。這個對話框須要監聽 notAuthenticated 和 sessionTimeout 事件,因此當其中一個事件被觸發了,對話框就要打開:

.directive('loginDialog', function (AUTH_EVENTS) {
  return {
    restrict: 'A',
    template: '<div ng-if="visible"
                    ng-include="\'login-form.html\'">',
    link: function (scope) {
      var showDialog = function () {
        scope.visible = true;
      };

      scope.visible = false;
      scope.$on(AUTH_EVENTS.notAuthenticated, showDialog);
      scope.$on(AUTH_EVENTS.sessionTimeout, showDialog)
    }
  };
})

只要你喜歡,這個對話框能夠隨便擴展。主要的思想是重用已存在的登錄表單模板和 LoginController。你須要在每一個頁面寫上以下的代碼:

<div login-dialog ng-if="!isLoginPage"></div>

注意 isLoginPage 檢查。一個失敗了的登錄會觸發 notAuthenticated 時間,但咱們不想在登錄頁面顯示這個對話框,由於這不少餘和奇怪。這就是爲何咱們不把登錄對話框也放在登錄頁面的緣由。因此在 ApplicationController 裏定義一個 $scope.isLoginPage 是合理的。

保存用戶狀態

在用戶刷新他們的頁面,依舊保存已登錄的用戶信息是單頁應用認證裏面狡猾的一個環節。由於全部狀態都存在客戶端,刷新會清空用戶信息。爲了修復這個問題,我一般實現一個會返回已登錄的當前用戶的數據的 API (好比 /profile),這個 API 會在 AngularJS 應用啓動(好比在 「run」 函數)。而後用戶數據會被保存在 Session 服務或者 $rootScope,就像用戶已經登錄後的狀態。或者,你能夠把用戶數據直接嵌入到 index.html,這樣就不用額外的請求了。第三種方式就是把用戶數據存在 cookie 或者 LocalStorage,但這會使得登出或者清空用戶數據變得困難一點。

最後……

鄙人才疏學淺,一點點經驗,這是一篇翻譯的文章,若有謬誤,歡迎指正。

相關文章
相關標籤/搜索