原文:
https://medium.com/opinionated-angularjs/7bbf0346acec
认证
最常用的表单认证就是用户名(或者邮件)和密码登录。这就表示要实现一个用户可以输入他们证书的登录表单。这样的表单可能像这样:
1 <form name="loginForm" ng-submit="login(credentials)" novalidate> 2 <label for="username">Username:</label> 3 <input id="username" type="text" ng-model="credentials.username"/> 4 5 <label for="password">Password:</label> 6 <input id="password" type="password" ng-model="credentials.password"/> 7 8 <button type="submit">Login</button> 9 </form>
(注意:下面有一个升级版的,这个只是一个简单示例)
因为Angular提供了form,我们在提交的时候可以用ngSubmit指令触发一个作用域函数。注意到我们传了credentials作为一个参数,而不是用$scope.credentials,这让函数更容易进行单元测试和避免和环境作用域的耦合。其对应的控制器可能像这样:
1 // var app = angular.module('myApp', [‘ui.router’]) or 2 var app = angular.module('myApp', ['ngRoute']) 3 app.controller('LoginController', [ 4 '$scope', 5 '$rootScope', 6 'AUTH_EVENTS', 7 'AuthService', 8 function ($scope, $rootScope, AUTH_EVENTS, AuthService) { 9 $scope.credentials = { 10 username: '', 11 password: '' 12 }; 13 $scope.login = function (credentials) { 14 AurhService.login(credentials) 15 .then(function () { 16 $rootScope.$broadcast(AUTH_EVENTS.loginSuccess); 17 }, function () { 18 $rootScope.$broadcast(AUTH_EVENTS.loginFailed); 19 }); 20 }; 21 }])
第一我们注意到缺省的真实逻辑代码。这是有意这样做的,为了将表单从实际的认证逻辑中解耦。通常一个好的方式就是将尽可能多的逻辑抽离出你的控制器,以服务来填充。AngularJS的控制器应该只管理$scope对象(通过监视和操作),而不应该变得厚重。
会话改变交流
认证是一种会影响全部应用的东西。因为这样我选择使用events事件(和$broadcast广播)和用户会话交流。将可用的事件代码集中放在一个地方是一个良好的实践。我喜欢将它们作为应用常量
1 app.constant('AUTH_EVENTS', { 2 loginSuccess: 'auth-login-success', 3 loginFailed: 'auth-login-failed', 4 logoutSuccess: 'auth-logout-success', 5 sessionTimeout: 'auth-session-timeout', 6 notAuthenticated: 'auth-not-authenticated', 7 notAuthorized: 'auth-not-authorized' 8 });
关于AngularJS的应用常量很棒的事是它们可以被注入到服务(services)中,这样在你的单元测试中可以轻易用假数据代替。同时以后你可以很轻松地重命名它们(改变值),而不用更改一大坨文件。用户的角色也用了相同的技巧:
1 app.constant('USER_ROLES', { 2 all: '', 3 admin: 'admin', 4 editor: 'editor', 5 guest: 'guest' 6 });
如果你想要给所有编辑者和管理员一样的权限,只需要简单地将editor的值改为admin。
AuthService认证授权服务
认证和授权的相关逻辑最好放在一个服务里面:
1 app.factory('AuthService', [ 2 '$http', 3 'Session', 4 function($http, Session){ 5 return { 6 login: function login(credentials){ 7 return $http.post('/login', credentials) 8 .then(function(res){ 9 Session.create(res.id, res.userid, res.role); 10 }); 11 }, 12 isAuthenticated: function(){ 13 return !!Session.userid; 14 }, 15 isAuthorized: function(authorizedRoles){ 16 if(!angular.isArray(authorizedRoles)){ 17 authorizedRoles = [authorizedRoles]; 18 } 19 20 return (this.isAuthenticated() 21 && authorizedRoles.indexOf(Session.userRole) !== -1); 22 } 23 }; 24 } 25 ]);
为了更深的分离有关认证,我用了另一个服务(单体对象,service风格)来保留用户的会话信息。这个对象的细节依赖于你后端的接口,但是我在下面给出了一个通用的例子。你可以还想要用一个包装器封装$http(例如一个“API”provider服务),这样你就可以通过你的app的config函数配置URL。
1 app.service('Session', function(){ 2 this.create = function(sessionId, userId, userRole){ 3 this.id = sessionId; 4 this.userId = userId; 5 this.userRole = userRole; 6 }; 7 this.destroy = function(){ 8 this.id = null; 9 this.userId = null; 10 this.userRole = null; 11 }; 12 return this; 13 });
一单一个用户登进来,你会想要在某个地方(可能右上角)显示他的信息。为了做到这个,用户对象必须引用$scope对象,最好是在一个可以被整个应用访问到的地方。毫不犹豫想到的第一个选择就是$rootScope,但是我会避免经常使用$rootScope(事实上我只在全局事件广播的时候才用到它)。代替地,我偏爱在应用的根节点中定义一个控制器,或者至少在某个够高的节点,body标签会是一个好的候选:
<body ng-controller="ApplicationController">
...
</body>
ApplicationController是一个有许多全局应用逻辑的容器,而且是Angular的run函数的代替品。因为它在$scope树的根节点,其他所有的作用域都会继承它(除了独立作用域)。是个定义当前用户对象的好地方:
1 app.controller('ApplicationController', [ 2 '$scope', 3 'USER_ROLES', 4 'AuthService', 5 function($scope, USER_ROLES, AuthService){ 6 $scope.currentUser = null; 7 $scope.userRoles = USER_ROLES; 8 $scope.isAuthorized = AuthService.isAuthorized; 9 } 10 ]);
我们并没有指定当前用户对象,我们只是初始化作用域的属性。这里的作用是创建一个占位符,供我们以后(从任意子作用域)存储对象。这是一个技巧将一个可用的变量定义在高层次的作用域,而不是你实际指定的地方。如果你想要了解更多,首先得明白原型继承的概念。
除了初始化currentUser属性,我还添加了USER_ROLES和isAuthorized函数。这些应该只用在模板表达式中,而不是在其它控制器中,因为那样会使控制器的测试性变得复杂。
Access control访问控制
授权a.k.a访问控制在AngularJS中并不真实存在。因为我们谈论的是一个客户端应用,所有的源码都是有关客户端的。我们并没有阻止用户篡改相关代码来获得访问特定的视图和界面元素。我们只是进行了简单的显示控制。如果你需要真实的授权你不得不在服务端进行,但那不在本文之类。
estricting element visibility限制元素显示
AngularJS有几种指令可以显示隐藏一个元素,这些指令都是基于作用与属性或者表达式:ngShow, ngHide, ngIf和ngSwitch。前两个会使用style属性来隐藏元素,后两个则会将元素从DOM中删除。
前两种最好用在当表达式频繁改变,而且元素不包含很多模板逻辑和作用域引用。原因是任何模板逻辑内部有隐藏元素,将会导致每次digest循环的时候被重新评估,使应用变得缓慢。后两种方案将会完全移除DOM元素,包括事件处理程序和作用域绑定。把工作交给浏览器来干,但大多数时候都较效率。因为用户访问不会经常改变,使用ngIF或者ngSwitch是最好的选择:
1 <div ng-if="currentUser">Welcome, {{ currentUser.name }}</div> 2 3 <div ng-if="isAuthorized(userRoles.admin)">You're admin.</div> 4 5 <div ng-switch on="currentUser.role"> 6 <div ng-switch-when="userRoles.admin">you're admin.</div> 7 <div ng-switch-when="userRoles.editor">You're editor.</div> 8 <div ng-switch-default>you're something else</div> 9 </div>
Switch的例子是假设一个用户只能有一个角色。
Restricting route access限制路由访问
大多数时候你想要禁止访问整个页面而不是隐藏单个元素。在路由中用自定义的数据。我们可以指定哪个角色可以通过访问。这里使用了UI Router(https://github.com/angular-ui/ui-router):
1 app.config(function ($stateProvider, USER_ROLES) { 2 $stateProvider.state('dashboard', { 3 url: '/dashboard', 4 templateUrl: 'dashboard/index.html', 5 data: { 6 authorizedRoles: [USER_ROLES.admin, USER_ROLES.editor] 7 } 8 }); 9 });
下面为使用ngRoute的例子
1 app.config([ 2 '$routeProvider', 3 'USER_ROLES', 4 function($routeProvider, USER_ROLES){ 5 $routeProvider.when('/dashboard', { 6 templateUrl: 'dashboard/index.html', 7 resolve: { 8 authorizedRoles: [USER_ROLES.admin, USER_ROLES.editor] 9 } 10 }); 11 } 12 ]);
下一步当路由改变的时候,我们需要每次检查这个属性。这里涉及到监听$locationChangeStart(for ngRoute)或者$statechangeStart(for ui.router)事件:
1 app.run([ 2 '$rootScope', 3 'AUTH_EVENTS', 4 'AuthService', 5 function($rootScope, AUTH_EVENTS, AuthService){ 6 $rootScope.$on('$locationChangeStart', function(event, next){ 7 var authorizedRoles = next.authorizedRoles; 8 if(!authService.isAuthorized(authorizedRoles)) { 9 event.preventDefault(); 10 if(AuthService.isAuthenticated()) { 11 // user is not allowed 12 $rootScope.$broadcast(AUTH_EVENTS.notAuthorized); 13 } else { 14 // user is not logged in 15 $rootScope.$broadcast(AUTH_EVENTS.notAuthenticated); 16 } 17 } 18 }); 19 } 20 ]);
当一个用户未被授权访问一个页面(因为他没有登录或者没有权限),当下一个页面的过渡将会被阻止,所以用户仍会停留在当前页面。下一步,我们广播一个事件给其他模块。我建议在页面中加上loginDialog指令,当未认证事件被触发时显示,还有当未授权事件触发时应该显示一个错误信息。
Session expiration会话过期
认证几乎是服务端的事了。不管你怎么实现,你的后端都要验证用户证书,处理像会话过期和撤销访问的事。这表示你的API有时将会响应认证错误。标准的方式是通过HTTP状态码来交流。常用的认证错误状态码有:
1.401 Unanthorized(未授权)----用户没有登入
2.403 Forbidden(禁止) ---- 用户登入了但不允许访问。
3.419 Authentication Timeout(认证超时) ---- 会话过期
5.440 Login Timeout(登入超时,只有微软才会) ---- 会话过期
最后两个并不是标准的部分,但可能会被用到。最好的官方办法就是通过401交流会话超时。不管怎样,你的loginDialog应该在API返回401,419或者440自动出现,还有当403时,错误应该被显示出来。首先我们想要广播同样的基于HTTp响应状态码的notAuthenticated/notAuthorized事件。为了达到目的,我们给$httpProvider添加一个拦截器:
1 app.config(['$httpProvider', function($httpProvider){ 2 $httpProvider.intercepters.push([ 3 '$injector', 4 function($injector){ 5 return $injector.get('AuthInterceptor'); 6 } 7 ]); 8 }]) 9 .factory('AuthInterceptor', [ 10 '$rootScope', 11 '$q', 12 'AUTH_EVENTS', 13 function($rootScope, $q, AUTH_EVENTS){ 14 return { 15 responseError: function(response){ 16 var event; 17 switch(response.status){ 18 case 401: 19 event = AUTH_EVENTS.notAuthenticated; 20 break; 21 case 403: 22 event = AUTH_EVENTS.notAuthorized; 23 break; 24 case 419: 25 case 440: 26 event = AUTH_EVENTS.sessionTimeout 27 } 28 29 $rootScope.$broadcast(event, response); 30 return $q.reject(response); 31 } 32 }; 33 } 34 ]);
这是一个简单的Auth拦截器实现。在Github上有一个更好的项目也做同样事情,但是还包括了一个httpBuffer服务,当HTTP错误返回,将会阻止API请求,直到用户再次登入,才会顺序触发它们。
Issues with the login form登录表单的一些问题
许多用户将会以来一个密码管理来保存他们的证书,这样他们以后就可以轻松登录了。如果你使用文章开头的简单示例,你会发现密码管理器很难和AngularJS表单一起工作。有两个问题将会发生:
1.表单提交并没有被检测到,所以证书没有被存储。
2.自动填充域并没有被AngularJS提取。
这些问题可以被绕开,但是这涉及到一个iframe和一个超时函数。如果你更偏爱你的代码整洁,而不是丑陋的hack,你可能想要将登录表单完全的从AngularJS独立出去,转而替代地依赖过时的服务端渲染,否则还是使用这个升级版的表单:
1 <iframe src="sink.html" frameborder="0" name="sink" style="display:none;"></iframe> 2 3 <form name="loginForm" action="sink.html" target="sink" method="post" 4 ng-submit="login(credentials)" 5 novalidate 6 form-autofill-fix> 7 8 <label for="username">Username:</label> 9 <input id="username" type="text" ng-model="credentials.username"/> 10 11 <label for="password">Password:</label> 12 <input id="password" type="password" ng-model="credentials.password"/> 13 14 <button type="submit">Login</button> 15 16 </form>
不同之处在于我们新加了<iframe>和给<form>元素新加了一些属性。通过提供action,target和method,当调用ngSubmit函数时,表单会被post到iframe的sink.html中。这种方式浏览器的正常表单处理逻辑会被剔除,地址栏不会跳转到sink.html(至少在主窗口中)。密码管理器会认识到这是一个正常的表单提交,然后请求存储证书到密钥中。Sink.html页面只是一个空的HTML文档,在你的index.html同级。我会在这样写上注释解释为什么该文件存在(这样其他开发者就不会删除它)。
注意:不要忘了method=”post”,f否则你的证书会被添加到查询字符串参数后面,这样会暴露到浏览器的历史记录中。
现在密码管理器可以存储证书了,我们不得不支持恢复这些证书。这叫做自动填充(又叫自动完成)。自动填充的问题是大多数浏览器不会触发被填充的输入域的事件。因为这样,AngularJS没有办法知道域内的内容是否被改变了,然后就不会更新$scope对象。结果当提交一个按理来说完成的登录表单的时候,只会因为提供了无效的证书而被拒绝访问。这就是formAutofillFix指令存在的意义:
1 app.directive('formAutofillFix', [ 2 '$timeout', 3 function($timeout){ 4 return function(scope, element, attrs){ 5 element.prop('method', 'post'); 6 7 if(!attrs.length) return; 8 9 $timeout(function(){ 10 element.off('submit') 11 .on('submit', function(event){ 12 event.preventDefault(); 13 element.find('input, textarea, select') 14 .trigger('input') 15 .trigger('change') 16 .trigger('keydown'); 17 18 scope.$apply(attrs.ngSubmit); 19 }); 20 }); 21 }; 22 } 23 ]);
这个指令通过一个函数将会重新绑定submit事件,这个函数会触发每个输入域的事件(强制Angular更新作用域),等待Angular完成digest循环,就会调用ngSubmit函数。当表单被提交的时候,任何作用域都会被更新成自动填充值。这种方式的一个缺陷就是表单验证不会工作了,因为自动填充值在表单提交之前对于作用于来说是无效的。如果你需要表单验证正常工作,这里有一个polyfill(https://github.com/tbosch/autofill-event)可以提前触发change事件。
有无数变数和其他的选择,最终你几乎要依赖后端的实现。真正安全的办法还是在于服务端,而且记住总是使用HTTPS。