跨域和同源策略
浏览器在全局层面禁止了页面加载或执行与自身来源不同的域的任何脚本。
同源策略允许页面从同一个站点加载和执行特定的脚本。浏览器通过对比每一个资源的协议、主机名和端口号来判断资源是否与页面同源。站外其他来源的脚本同页面的交互则被严格限制。 跨域资源共享(Cross Origin Resource Sharing
,CORS)是一个解决跨域问题的好方法,从而可以使用XHR从不同的源加载数据和资源。
除CORS以外还有几个方法可以用来从外部的数据源将数据加载到应用中:
- JSONP
- CORS
- 服务器代理
JSONP
JSONP是一种可以绕过浏览器的安全限制,从不同的域请求数据的方法。使用JSONP需要服务器端提供必要的支持。
JSONP的原理是通过<script>
标签发起一个GET请求来取代XHR请求。JSONP生成一个<script>
标签并插到DOM中,然后浏览器会接管并向src
属性所指向的地址发送请求。当服务器返回请求时,响应结果会被包装成一个JS函数,并由该请求所对应的回调函数调用。
AngularJS在$http
服务中提供了一个JSONP辅助函数。通过$http
服务的jsonp
方法可以发送请求。
$http.jsonp("https://api.github.com?callback=JSON_CALLBACK").success(function(data) {
// 数据
});
当请求被发送时,AngularJS会在DOM中生成一个如下所示的<script>
标签。
<script src="https://api.github.com?callback=angular.callbacks._0"
type="text/javascript"></script>
JSON_CALLBACK
被替换成了一个特地为此请求生成的自定义函数。
当支持JSONP的服务器返回数据时,数据会被包装在由AngularJS生成的具名函数angular.callbacks._0
中。
在这个例子中,服务器会返回包含在回调函数中的JSON数据,响应看起来如下所示:
// 简写
angular.callbacks._0({
'meta': {
'X-RateLimit-Limit': '60',
'status': 200
},
'data': {
'current_user_url': 'https://api.github.com/user'
}
})
当AngularJS调用指定的回调函数时会对$http
的promis
e对象进行resolve
。
当我们自己开发支持JSONP的后端服务时,要确保响应的数据被包含在请求所指定的回调函数中。
使用JSONP需要意识到潜在的安全风险。首先,服务器会完全开放,允许后端服务调用应用中的任何JS。
不受我们控制的外部站点(或者蓄意攻击者)可以随时更改脚本,使我们的整个站点变得脆弱。 服务器或中间人有可能会将额外的JS逻辑返回给页面,从而将用户的隐私数据暴露出来。
使用CORS
CORS规范简单地扩展了标准的XHR对象,以允许JS发送跨域的XHR请求。它会通过预检查来确认是否有权限向目标服务器发送请求。
预检查可以让服务器接受或拒绝来自全部服务器、特定服务器或一组服务器的请求。这意味着客户端和服务端应用需要协同工作,才能向客户端或服务器发送数据。
设置
为了使用CORS,首先需要告诉AngularJS我们正在使用CORS。使用config()
方法在应用模块上设置两个参数以达到此目的。
首先,告诉AngularJS使用XDomain
,并从所有的请求中把X-Request-With
头移除掉。X-Request-With
头默认就是移除掉的,但是再次确认它已经被移除没有坏处。
angular.module('myApp', [])
.config(function($httpProvider) {
$httpProvider.defaults.useXDomain = true;
delete $httpProvider.defaults.headers
.common['X-Requested-With'];
});
现在可以发送CORS请求了。
服务器端CORS支持
确保服务器支持CORS是很重要的。支持CORS的服务器必须在响应中加入几个访问控制相关的头。
- Access-Control-Allow-Origin
这个头的值可以是与请求头的值相呼应的值,也可以是*
,从而允许接收从任何来源发来的请求。 - Access-Control-Allow-Credentials(可选)
默认情况下,CORS请求不会发送cookie
。如果服务器返回了这个头,那么就可以通过将withCredentials
设置为true
来将cookie
同请求一同发送出去。
如果将$http
发送的请求中的withCredentials
设置为true
,但服务器没有返回Access-Control-Allow-Credentials
,请求就会失败,反之亦然。 后端服务器必须能处理OPTIONS方法的HTTP请求。
CORS请求分为简单和非简单两种类型。
简单请求
如果请求使用HEAD、GET、POST
中的一种HTTP方法就是简单请求。
如果请求除了下面列表中的一个或多个HTTP头以外,没有使用其他头:
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
- Content-Type
- application/x-www-form-urlencoded
- multipart/form-data;
- text/plain
我们把这类请求归类为简单请求,因为浏览器可以不需要使用CORS就发送这类请求。简单请求不要求浏览器和服务器之间有任何的特殊通信。
$http.get("https://api.github.com").success(function(data) {
// 数据
});
非简单请求
不符合简单请求标准的请求被称为非简单请求。如果想要支持PUT或DELETE方法,又或者想给请求设置特殊的内容类型,就需要发送非简单请求。
尽管这些请求在客户端开发者看来没什么不同,但浏览器会以不同的方式处理它们。浏览器实际上会发送两个请求:预请求和请求。浏览器首先会向服务器发送预请求来获得发送请求的许可,只有许可通过了,浏览器才会发送真正的请求。
浏览器处理CORS的过程是透明的。同简单请求一样,浏览器会给预请求和请求都加上Origin
头。
预请求
浏览器发送的预请求是OPTIONS类型的,预请求中包含以下头信息:
- Access-Control-Request-Method
这个头是请求所使用的HTTP方法,会始终包含在请求中。 - Access-Control-Request-Headers(可选)
这个头的值是一个以逗号分隔的非简单头列表,列表中的每个头都会包含在这个请求中。服务器必须接受这个请求,然后检查HTTP方法和头的合法性。如果通过了检查,服务器会在响应中添加下面这个头: - Access-Control-Allow-Origin
这个头的值必须和请求的来源相同,或者是*
符号,以允许接受来自任何来源的请求。 - Access-Control-Allow-Methods
这是一个可以接受的HTTP方法列表,对在客户端缓存响应结果很有帮助,并且未来发送的请求可以不必总是发送预请求。 - Access-Control-Allow-Headers
如果设置了Access-Control-Request-Headers
头,服务器必须在响应中添加同一个头。
我们希望服务器在可以接受这个请求时返回200状态码。如果服务器返回了200状态码,真正的请求才会发出。
CORS并不是一个安全机制,只是现代浏览器实现的一个标准。AngularJS中的非简单请求与普通请求看起来没有什么区别。
$http.delete("https://api.github.com/api/users/1").success(function(data) {
// 数据
});
服务器端代理
实现向所有服务器发送请求的最简单方式是使用服务器端代理。这个服务器和页面处在同一个域中(或者不在同一个域中但支持CORS),做为所有远程资源的代理。
可以简单地通过使用本地服务器来代替客户端向外部资源发送请求,并将响应结果返回给客户端。通过这种方式,老式浏览器不必使用需要发送额外请求的CORS(只有现代浏览器支持CORS)也能发送跨域请求,并且可以在浏览器中采用标准的安全策略。
为了实现服务器端代理,需要架设一个本地服务器来处理我们所有的请求,并负责向第三方发送实际的请求。
使用 JSON
JSON是JavaScript Object Notation
的简写,是一种看起来像JS对象的数据交换格式。事实上,当JS加载它时,它确实会被当做一个对象来解析。AngularJS也会将所有以JSON格式返回的JS对象解析为一个与之对应的Angular对象。例如,如果服务器返回以下JSON:
[
{"msg": "This is the first msg", state: 1},
{"msg": "This is the second msg", state: 2},
{"msg": "This is the third msg", state: 1},
{"msg": "This is the fourth msg", state: 3}
]
当AngularJS通过$http
服务收到这个数据后,可以像普通JS对象那样来引用其中的数据。
$http.get('/v1/messages.json').success(function(data,status) {
$scope.first_msg = data[0].msg;
$scope.first_state = data[0].state;
});
使用AngularJS进行身份验证
服务器端需求
首先必须保证服务器端API的安全性。下面是常被用来保护客户端应用的两种方法。
1.服务器端视图渲染
如果站点所有的HTML页面都是由后端服务器处理的,可以使用传统的授权方式,由服务器
端进行鉴权,只发送客户端需要的HTML。
2.纯客户端身份验证
我们希望客户端和服务端的开发工作可以解耦并各自独立进行,且可以将组件独立地发布到生产环境中,互相没有影响。因此,需要通过使用服务器端API来保护客户端身份验证的安全, 但并不依赖这些API来进行身份验证。
通过令牌授权来实现客户端身份验证,服务器需要做的是给客户端应用提供授权令牌。令牌本身是一个由服务器端生成的随机字符串,由数字和字母组成,它与特定的用户会话相关联。uuid
库是用来生成令牌的好选择。
当用户登录到我们的站点后,服务器会生成一个随机的令牌,并将用户会话同令牌之间建立关联,用户无需将ID或其他身份验证信息发送给服务器。
客户端发送的每个请求都应该包含此令牌,这样服务器才能根据令牌来对请求的发送者进行身份验证。
服务器端则无论请求是否合法,都会将对应事件的状态码返回给客户端,这样客户端才能做出响应。
例如,我们希望服务端对所有身份验证未通过的请求都返回401状态码。下面是一些常用的状态码:
当客户端收到这些状态码时会做出相应的响应。
数据流程如下:
(1)一个未经过身份验证的用户浏览了我们的站点;
(2)用户试图访问一个受保护的资源,被重定向到登录页面,或者用户手动访问了登录页面;
(3)用户输入了他的登录ID(用户名或电子邮箱)以及密码,接着AngularJS应用通过POST请求将用户的信息发送给服务端;
(4)服务端对ID和密码进行校验,检查它们是否匹配;
(5)如果ID和密码匹配,服务端生成一个唯一的令牌,并将其同一个状态码为200的响应一起返回。如果ID和密码不匹配,服务器返回一个状态码为401的响应。
对一个已经通过身份验证的用户(通过了上面5个步骤的用户),流程如下:
(1) 用户请求一个受保护的资源路径(比如他自己的账号页面);
(2) 如果用户尚未登录,应用会将他重定向到登录页面。如果用户登录了,应用会使用该会话对应的令牌来发送请求;
(3) 服务器对令牌进行校验,并根据请求返回合适的数据。
客户端身份验证
身份验证机制需要处理的一些行为
- 重定向未经过身份验证的页面请求
- 捕获所有响应状态码非200的XHR请求,并进行相应的处理
- 在整个页面会话中持续监视用户的身份验证情况
为了对未通过验证的用户访问受保护资源的行为进行重定向,需要能够对公共资源和受保护资源进行区分。
有下面几种方法可以将路由定义为公共或非公共。
1.保护API访问的资源
如果想要对一个会发送受保护的API请求(例如,一个服务器可能返回401状态码的API请求)的路由进行保护,但又希望可以正常加载页面,可以简单地通过$http
拦截器来实现。
想要创建一个$http
拦截器并能够处理未通过身份验证的API请求,首先要创建一个拦截器来处理所有的响应。
现在,我们在应用的.config()
代码块内设置$http
响应拦截器,并将$httpProvider
注入其中。这个拦截器会处理所有请求的响应以及响应错误。
angular.module('myApp', [])
.config(function($httpProvider) {
// 在这里构造拦截器
var interceptor = function($q, $rootScope, Auth) {
return {
'response': function(resp) {
if (resp.config.url == '/api/login') {
// 假设API服务器返回的数据格式如下:
// { token: "AUTH_TOKEN" }
Auth.setToken(resp.data.token);
}
return resp;
},
'responseError': function(rejection) {
// 错误处理
switch(rejection.status) {
case 401:
if (rejection.config.url!=='api/login')
// 如果当前不是在登录页面
$rootScope.$broadcast('auth:loginRequired');
break;
case 403:
$rootScope.$broadcast('auth:forbidden');
break;
case 404:
$rootScope.$broadcast('page:notFound');
break;
case 500:
$rootScope.$broadcast('server:error');
break;
}
return $q.reject(rejection);
}
};
};
});
这个授权拦截器会处理特定请求中一些可预见的服务器响应状态码。当拦截器捕获到401状态码,会通过$broadcasts
从$rootScope
开始向所有的子作用域广播此事件。另外,拦截器会为任何返回200状态码的请求将令牌保存到/api/login
登录路由中。
为了实现这个拦截器,需要让$httpProvider
将这个拦截器添加到拦截器链中。
angular.module('myApp', [])
.config(function($httpProvider) {
// 在这里构造拦截器
var interceptor = function($q, $rootScope, Auth) {
// ...
};
// 将拦截器和$http的request/response链整合在一起
$httpProvider
.interceptors.push(interceptor);
});
2.使用路由定义受保护资源
如果我们希望始终对某些路径进行保护,或者请求的API不会对路由进行保护,那就需要监视路由的变化,以确保访问受保护路由的用户是处于登录状态的。为了监视路由变化,需要为$routeChangeStart
事件设置一个事件监听器。这个事件会在路由属性开始resolve
时触发,但此时路由还没有真的发生变化。
通过同拦截器协同工作,这种方式会更加有效。如果不通过拦截器检查状态码, 用户依然有可能发送未经授权的请求。
通过监听器对事件进行监听,并检查路由,看它是否定义为可被当前用户访问。首先要定义应用的访问规则。可以通过在应用中设置常量,然后在每个路由中通过对比这些常量来判断用户是否具有访问权限。
angular.module('myApp', ['ngRoute'])
.constant('ACCESS_LEVELS', {
pub: 1,
user: 2
});
通过把ACCESS_LEVELS
设置为常量,可以将它注入到.confgi()
和.run()
代码块中,并在整个应用范围内使用。下面,使用这些常量来为每个路由都定义访问级别:
angular.module('myApp', ['ngRoute'])
.config(function($routeProvider, ACCESS_LEVELS) {
$routeProvider
.when('/', {
controller: 'MainController',
templateUrl: 'views/main.html',
access_level: ACCESS_LEVELS.pub
})
.when('/account', {
controller: 'AccountController',
templateUrl: 'views/account.html',
access_level: ACCESS_LEVELS.user
})
.otherwise({
redirectTo: '/'
});
});
上面每一个路由都定义了自身的access_level
,可以根据这一点判断当前用户的授权状态, 以及用户的级别是否有权限访问当前路由。
此时,用户可能处于以下两种状态:
- 未经过身份验证的匿名用户;
- 通过身份验证的已知用户。
为了验证用户的身份,需要创建一个服务来对已经存在的用户进行监视。同时需要让服务能够访问浏览器的cookie
,这样当用户重新登录时,只要会话有效就无需再次进行身份验证。 这个小服务包含了一些操作用户对象的辅助函数。
angular.module('myApp.services', [])
.factory('Auth', function($cookieStore,ACCESS_LEVELS) {
var _user = $cookieStore.get('user');
var setUser = function(user) {
if (!user.role || user.role < 0) {
user.role = ACCESS_LEVELS.pub;
}
_user = user;
$cookieStore.put('user', _user);
};
return {
isAuthorized: function(lvl) {
return _user.role >= lvl;
},
setUser: setUser,
isLoggedIn: function() {
return _user ? true : false;
},
getUser: function() {
return _user;
},
getId: function() {
return _user ? _user._id : null;
},
getToken: function() {
return _user ? _user.token : '';
},
logout: function() {
$cookieStore.remove('user');
_user = null; }
}
};
});
现在,当用户已经通过身份验证并登录后,可以在$routeChangeStart
事件中对其有效性进行检查。
angular.module('myApp', [])
.run(function($rootScope, $location, Auth) {
// 给$routeChangeStart设置监听
$rootScope.$on('$routeChangeStart', function(evt, next, curr) {
if (!Auth.isAuthorized(next.$$route.access_level)) {
if (Auth.isLoggedIn()) {
// 用户登录了,但没有访问当前视图的权限
$location.path('/');
} else {
$location.path('/login');
}
}
});
});
3.发送经过身份验证的请求
当我们通过了身份验证,并取回了用户的授权令牌后,就可以在向服务器发送请求时使用令牌。从服务器的角度看,当收到一个带有令牌的请求时,验证令牌的有效性是服务器的责任之一。
如果提供的令牌是合法的,且与一个合法用户是关联的状态,那服务器就会认为用户的身份是合法且安全的。
通过令牌进行身份验证的安全性取决于通信所采用的通道,因此尽可能地使用SSL连接可以提高安全性。
如果用户已经通过了身份验证,可以在发送请求时单独给每个请求都加入验证信息,或者把令牌附加到所有的请求中。
手动使用身份令牌 手动创建一个可以发送令牌的请求,只要将token
当作参数或请求头添加到请求中即可。例如,如果我们想对服务器发出一个请求,此时我们正在这个服务器上通过Backend
服务请求用户分析数据。
angular.module('myApp', [])
.service('Backend', function($http, $q, $rootScope, Auth) {
this.getDashboardData = function() {
$http({
method: 'GET',
url: 'http://myserver.com/api/dashboard'
}).success(function(data) {
return data.data;
}).catch(function(reason) {
$q.reject(reason);
});
};
});
简单地将token
当作参数(或请求头)发送就可以进行令牌验证。
angular.module('myApp', [])
.service('Backend', function($http, $q, $rootScope, Auth) {
this.getDashboardData = function() {
$http({
method: 'GET',
url: 'http://myserver.com/api/dashboard',
params: {
token: Auth.getToken()
}).success(function(data) {
return data.data;
}).catch(function(reason) {
$q.reject(reason);
});
};
});
当向后端发送请求时,请求会被添加token
参数。
自动添加身份令牌更进一步,如果想要为每个请求都添加上当前用户的令牌,可以创建一个请求拦截器,并将令牌当作参数添加进请求中。
angular.module('myApp', [])
.config(function($httpProvider) {
// 在这里构造拦截器
var interceptor = function($q, $rootScope, Auth) {
return {
'request': function(req) {
return req;
},
'requestError': function(reqErr) {
return reqErr;
}
};
};
});
在请求拦截器内部可以加入向请求中添加token
参数的业务逻辑,通过用户是否持有令牌来检查身份验证情况,同时需要确保不会将手动添加的同名参数覆盖。
function($q, $rootScope, Auth) {
return {
'request': function(req) {
req.params = req.params || {};
if (Session.isAuthenticated() && !req.params.token) {
req.params.token = Auth.getToken();
}
return req;
},
// ...
}
}