跨域和同源策略
瀏覽器在全局層面禁止了頁面加載或執(zhí)行與自身來源不同的域的任何腳本。
同源策略允許頁面從同一個站點(diǎn)加載和執(zhí)行特定的腳本。瀏覽器通過對比每一個資源的協(xié)議、主機(jī)名和端口號來判斷資源是否與頁面同源。站外其他來源的腳本同頁面的交互則被嚴(yán)格限制。 跨域資源共享(Cross Origin Resource Sharing,CORS)是一個解決跨域問題的好方法,從而可以使用XHR從不同的源加載數(shù)據(jù)和資源。
除CORS以外還有幾個方法可以用來從外部的數(shù)據(jù)源將數(shù)據(jù)加載到應(yīng)用中:
- JSONP
- CORS
- 服務(wù)器代理
JSONP
JSONP是一種可以繞過瀏覽器的安全限制,從不同的域請求數(shù)據(jù)的方法。使用JSONP需要服務(wù)器端提供必要的支持。
JSONP的原理是通過<script>標(biāo)簽發(fā)起一個GET請求來取代XHR請求。JSONP生成一個<script>標(biāo)簽并插到DOM中,然后瀏覽器會接管并向src屬性所指向的地址發(fā)送請求。當(dāng)服務(wù)器返回請求時,響應(yīng)結(jié)果會被包裝成一個JS函數(shù),并由該請求所對應(yīng)的回調(diào)函數(shù)調(diào)用。
AngularJS在$http服務(wù)中提供了一個JSONP輔助函數(shù)。通過$http服務(wù)的jsonp方法可以發(fā)送請求。
$http.jsonp("https://api.github.com?callback=JSON_CALLBACK").success(function(data) {
// 數(shù)據(jù)
});
當(dāng)請求被發(fā)送時,AngularJS會在DOM中生成一個如下所示的<script>標(biāo)簽。
<script src="https://api.github.com?callback=angular.callbacks._0"
type="text/javascript"></script>
JSON_CALLBACK被替換成了一個特地為此請求生成的自定義函數(shù)。
當(dāng)支持JSONP的服務(wù)器返回?cái)?shù)據(jù)時,數(shù)據(jù)會被包裝在由AngularJS生成的具名函數(shù)angular.callbacks._0中。
在這個例子中,服務(wù)器會返回包含在回調(diào)函數(shù)中的JSON數(shù)據(jù),響應(yīng)看起來如下所示:
// 簡寫
angular.callbacks._0({
'meta': {
'X-RateLimit-Limit': '60',
'status': 200
},
'data': {
'current_user_url': 'https://api.github.com/user'
}
})
當(dāng)AngularJS調(diào)用指定的回調(diào)函數(shù)時會對$http的promise對象進(jìn)行resolve。
當(dāng)我們自己開發(fā)支持JSONP的后端服務(wù)時,要確保響應(yīng)的數(shù)據(jù)被包含在請求所指定的回調(diào)函數(shù)中。
使用JSONP需要意識到潛在的安全風(fēng)險(xiǎn)。首先,服務(wù)器會完全開放,允許后端服務(wù)調(diào)用應(yīng)用中的任何JS。
不受我們控制的外部站點(diǎn)(或者蓄意攻擊者)可以隨時更改腳本,使我們的整個站點(diǎn)變得脆弱。 服務(wù)器或中間人有可能會將額外的JS邏輯返回給頁面,從而將用戶的隱私數(shù)據(jù)暴露出來。
使用CORS
CORS規(guī)范簡單地?cái)U(kuò)展了標(biāo)準(zhǔn)的XHR對象,以允許JS發(fā)送跨域的XHR請求。它會通過預(yù)檢查來確認(rèn)是否有權(quán)限向目標(biāo)服務(wù)器發(fā)送請求。
預(yù)檢查可以讓服務(wù)器接受或拒絕來自全部服務(wù)器、特定服務(wù)器或一組服務(wù)器的請求。這意味著客戶端和服務(wù)端應(yīng)用需要協(xié)同工作,才能向客戶端或服務(wù)器發(fā)送數(shù)據(jù)。
設(shè)置
為了使用CORS,首先需要告訴AngularJS我們正在使用CORS。使用config()方法在應(yīng)用模塊上設(shè)置兩個參數(shù)以達(dá)到此目的。
首先,告訴AngularJS使用XDomain,并從所有的請求中把X-Request-With頭移除掉。X-Request-With頭默認(rèn)就是移除掉的,但是再次確認(rèn)它已經(jīng)被移除沒有壞處。
angular.module('myApp', [])
.config(function($httpProvider) {
$httpProvider.defaults.useXDomain = true;
delete $httpProvider.defaults.headers
.common['X-Requested-With'];
});
現(xiàn)在可以發(fā)送CORS請求了。
服務(wù)器端CORS支持
確保服務(wù)器支持CORS是很重要的。支持CORS的服務(wù)器必須在響應(yīng)中加入幾個訪問控制相關(guān)的頭。
- Access-Control-Allow-Origin
這個頭的值可以是與請求頭的值相呼應(yīng)的值,也可以是*,從而允許接收從任何來源發(fā)來的請求。 - Access-Control-Allow-Credentials(可選)
默認(rèn)情況下,CORS請求不會發(fā)送cookie。如果服務(wù)器返回了這個頭,那么就可以通過將withCredentials設(shè)置為true來將cookie同請求一同發(fā)送出去。
如果將$http發(fā)送的請求中的withCredentials設(shè)置為true,但服務(wù)器沒有返回Access-Control-Allow-Credentials,請求就會失敗,反之亦然。 后端服務(wù)器必須能處理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
我們把這類請求歸類為簡單請求,因?yàn)闉g覽器可以不需要使用CORS就發(fā)送這類請求。簡單請求不要求瀏覽器和服務(wù)器之間有任何的特殊通信。
$http.get("https://api.github.com").success(function(data) {
// 數(shù)據(jù)
});
非簡單請求
不符合簡單請求標(biāo)準(zhǔn)的請求被稱為非簡單請求。如果想要支持PUT或DELETE方法,又或者想給請求設(shè)置特殊的內(nèi)容類型,就需要發(fā)送非簡單請求。
盡管這些請求在客戶端開發(fā)者看來沒什么不同,但瀏覽器會以不同的方式處理它們。瀏覽器實(shí)際上會發(fā)送兩個請求:預(yù)請求和請求。瀏覽器首先會向服務(wù)器發(fā)送預(yù)請求來獲得發(fā)送請求的許可,只有許可通過了,瀏覽器才會發(fā)送真正的請求。
瀏覽器處理CORS的過程是透明的。同簡單請求一樣,瀏覽器會給預(yù)請求和請求都加上Origin頭。
預(yù)請求
瀏覽器發(fā)送的預(yù)請求是OPTIONS類型的,預(yù)請求中包含以下頭信息:
- Access-Control-Request-Method
這個頭是請求所使用的HTTP方法,會始終包含在請求中。 - Access-Control-Request-Headers(可選)
這個頭的值是一個以逗號分隔的非簡單頭列表,列表中的每個頭都會包含在這個請求中。服務(wù)器必須接受這個請求,然后檢查HTTP方法和頭的合法性。如果通過了檢查,服務(wù)器會在響應(yīng)中添加下面這個頭: - Access-Control-Allow-Origin
這個頭的值必須和請求的來源相同,或者是*符號,以允許接受來自任何來源的請求。 - Access-Control-Allow-Methods
這是一個可以接受的HTTP方法列表,對在客戶端緩存響應(yīng)結(jié)果很有幫助,并且未來發(fā)送的請求可以不必總是發(fā)送預(yù)請求。 - Access-Control-Allow-Headers
如果設(shè)置了Access-Control-Request-Headers頭,服務(wù)器必須在響應(yīng)中添加同一個頭。
我們希望服務(wù)器在可以接受這個請求時返回200狀態(tài)碼。如果服務(wù)器返回了200狀態(tài)碼,真正的請求才會發(fā)出。
CORS并不是一個安全機(jī)制,只是現(xiàn)代瀏覽器實(shí)現(xiàn)的一個標(biāo)準(zhǔn)。AngularJS中的非簡單請求與普通請求看起來沒有什么區(qū)別。
$http.delete("https://api.github.com/api/users/1").success(function(data) {
// 數(shù)據(jù)
});
服務(wù)器端代理
實(shí)現(xiàn)向所有服務(wù)器發(fā)送請求的最簡單方式是使用服務(wù)器端代理。這個服務(wù)器和頁面處在同一個域中(或者不在同一個域中但支持CORS),做為所有遠(yuǎn)程資源的代理。
可以簡單地通過使用本地服務(wù)器來代替客戶端向外部資源發(fā)送請求,并將響應(yīng)結(jié)果返回給客戶端。通過這種方式,老式瀏覽器不必使用需要發(fā)送額外請求的CORS(只有現(xiàn)代瀏覽器支持CORS)也能發(fā)送跨域請求,并且可以在瀏覽器中采用標(biāo)準(zhǔn)的安全策略。
為了實(shí)現(xiàn)服務(wù)器端代理,需要架設(shè)一個本地服務(wù)器來處理我們所有的請求,并負(fù)責(zé)向第三方發(fā)送實(shí)際的請求。
使用 JSON
JSON是JavaScript Object Notation的簡寫,是一種看起來像JS對象的數(shù)據(jù)交換格式。事實(shí)上,當(dāng)JS加載它時,它確實(shí)會被當(dāng)做一個對象來解析。AngularJS也會將所有以JSON格式返回的JS對象解析為一個與之對應(yīng)的Angular對象。例如,如果服務(wù)器返回以下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}
]
當(dāng)AngularJS通過$http服務(wù)收到這個數(shù)據(jù)后,可以像普通JS對象那樣來引用其中的數(shù)據(jù)。
$http.get('/v1/messages.json').success(function(data,status) {
$scope.first_msg = data[0].msg;
$scope.first_state = data[0].state;
});
使用AngularJS進(jìn)行身份驗(yàn)證
服務(wù)器端需求
首先必須保證服務(wù)器端API的安全性。下面是常被用來保護(hù)客戶端應(yīng)用的兩種方法。
1.服務(wù)器端視圖渲染
如果站點(diǎn)所有的HTML頁面都是由后端服務(wù)器處理的,可以使用傳統(tǒng)的授權(quán)方式,由服務(wù)器
端進(jìn)行鑒權(quán),只發(fā)送客戶端需要的HTML。
2.純客戶端身份驗(yàn)證
我們希望客戶端和服務(wù)端的開發(fā)工作可以解耦并各自獨(dú)立進(jìn)行,且可以將組件獨(dú)立地發(fā)布到生產(chǎn)環(huán)境中,互相沒有影響。因此,需要通過使用服務(wù)器端API來保護(hù)客戶端身份驗(yàn)證的安全, 但并不依賴這些API來進(jìn)行身份驗(yàn)證。
通過令牌授權(quán)來實(shí)現(xiàn)客戶端身份驗(yàn)證,服務(wù)器需要做的是給客戶端應(yīng)用提供授權(quán)令牌。令牌本身是一個由服務(wù)器端生成的隨機(jī)字符串,由數(shù)字和字母組成,它與特定的用戶會話相關(guān)聯(lián)。uuid庫是用來生成令牌的好選擇。
當(dāng)用戶登錄到我們的站點(diǎn)后,服務(wù)器會生成一個隨機(jī)的令牌,并將用戶會話同令牌之間建立關(guān)聯(lián),用戶無需將ID或其他身份驗(yàn)證信息發(fā)送給服務(wù)器。
客戶端發(fā)送的每個請求都應(yīng)該包含此令牌,這樣服務(wù)器才能根據(jù)令牌來對請求的發(fā)送者進(jìn)行身份驗(yàn)證。
服務(wù)器端則無論請求是否合法,都會將對應(yīng)事件的狀態(tài)碼返回給客戶端,這樣客戶端才能做出響應(yīng)。
例如,我們希望服務(wù)端對所有身份驗(yàn)證未通過的請求都返回401狀態(tài)碼。下面是一些常用的狀態(tài)碼:

當(dāng)客戶端收到這些狀態(tài)碼時會做出相應(yīng)的響應(yīng)。
數(shù)據(jù)流程如下:
(1)一個未經(jīng)過身份驗(yàn)證的用戶瀏覽了我們的站點(diǎn);
(2)用戶試圖訪問一個受保護(hù)的資源,被重定向到登錄頁面,或者用戶手動訪問了登錄頁面;
(3)用戶輸入了他的登錄ID(用戶名或電子郵箱)以及密碼,接著AngularJS應(yīng)用通過POST請求將用戶的信息發(fā)送給服務(wù)端;
(4)服務(wù)端對ID和密碼進(jìn)行校驗(yàn),檢查它們是否匹配;
(5)如果ID和密碼匹配,服務(wù)端生成一個唯一的令牌,并將其同一個狀態(tài)碼為200的響應(yīng)一起返回。如果ID和密碼不匹配,服務(wù)器返回一個狀態(tài)碼為401的響應(yīng)。
對一個已經(jīng)通過身份驗(yàn)證的用戶(通過了上面5個步驟的用戶),流程如下:
(1) 用戶請求一個受保護(hù)的資源路徑(比如他自己的賬號頁面);
(2) 如果用戶尚未登錄,應(yīng)用會將他重定向到登錄頁面。如果用戶登錄了,應(yīng)用會使用該會話對應(yīng)的令牌來發(fā)送請求;
(3) 服務(wù)器對令牌進(jìn)行校驗(yàn),并根據(jù)請求返回合適的數(shù)據(jù)。
客戶端身份驗(yàn)證
身份驗(yàn)證機(jī)制需要處理的一些行為
- 重定向未經(jīng)過身份驗(yàn)證的頁面請求
- 捕獲所有響應(yīng)狀態(tài)碼非200的XHR請求,并進(jìn)行相應(yīng)的處理
- 在整個頁面會話中持續(xù)監(jiān)視用戶的身份驗(yàn)證情況
為了對未通過驗(yàn)證的用戶訪問受保護(hù)資源的行為進(jìn)行重定向,需要能夠?qū)操Y源和受保護(hù)資源進(jìn)行區(qū)分。
有下面幾種方法可以將路由定義為公共或非公共。
1.保護(hù)API訪問的資源
如果想要對一個會發(fā)送受保護(hù)的API請求(例如,一個服務(wù)器可能返回401狀態(tài)碼的API請求)的路由進(jìn)行保護(hù),但又希望可以正常加載頁面,可以簡單地通過$http攔截器來實(shí)現(xiàn)。
想要創(chuàng)建一個$http攔截器并能夠處理未通過身份驗(yàn)證的API請求,首先要創(chuàng)建一個攔截器來處理所有的響應(yīng)。
現(xiàn)在,我們在應(yīng)用的.config()代碼塊內(nèi)設(shè)置$http響應(yīng)攔截器,并將$httpProvider注入其中。這個攔截器會處理所有請求的響應(yīng)以及響應(yīng)錯誤。
angular.module('myApp', [])
.config(function($httpProvider) {
// 在這里構(gòu)造攔截器
var interceptor = function($q, $rootScope, Auth) {
return {
'response': function(resp) {
if (resp.config.url == '/api/login') {
// 假設(shè)API服務(wù)器返回的數(shù)據(jù)格式如下:
// { token: "AUTH_TOKEN" }
Auth.setToken(resp.data.token);
}
return resp;
},
'responseError': function(rejection) {
// 錯誤處理
switch(rejection.status) {
case 401:
if (rejection.config.url!=='api/login')
// 如果當(dāng)前不是在登錄頁面
$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);
}
};
};
});
這個授權(quán)攔截器會處理特定請求中一些可預(yù)見的服務(wù)器響應(yīng)狀態(tài)碼。當(dāng)攔截器捕獲到401狀態(tài)碼,會通過$broadcasts從$rootScope開始向所有的子作用域廣播此事件。另外,攔截器會為任何返回200狀態(tài)碼的請求將令牌保存到/api/login登錄路由中。
為了實(shí)現(xiàn)這個攔截器,需要讓$httpProvider將這個攔截器添加到攔截器鏈中。
angular.module('myApp', [])
.config(function($httpProvider) {
// 在這里構(gòu)造攔截器
var interceptor = function($q, $rootScope, Auth) {
// ...
};
// 將攔截器和$http的request/response鏈整合在一起
$httpProvider
.interceptors.push(interceptor);
});
2.使用路由定義受保護(hù)資源
如果我們希望始終對某些路徑進(jìn)行保護(hù),或者請求的API不會對路由進(jìn)行保護(hù),那就需要監(jiān)視路由的變化,以確保訪問受保護(hù)路由的用戶是處于登錄狀態(tài)的。為了監(jiān)視路由變化,需要為$routeChangeStart事件設(shè)置一個事件監(jiān)聽器。這個事件會在路由屬性開始resolve時觸發(fā),但此時路由還沒有真的發(fā)生變化。
通過同攔截器協(xié)同工作,這種方式會更加有效。如果不通過攔截器檢查狀態(tài)碼, 用戶依然有可能發(fā)送未經(jīng)授權(quán)的請求。
通過監(jiān)聽器對事件進(jìn)行監(jiān)聽,并檢查路由,看它是否定義為可被當(dāng)前用戶訪問。首先要定義應(yīng)用的訪問規(guī)則??梢酝ㄟ^在應(yīng)用中設(shè)置常量,然后在每個路由中通過對比這些常量來判斷用戶是否具有訪問權(quán)限。
angular.module('myApp', ['ngRoute'])
.constant('ACCESS_LEVELS', {
pub: 1,
user: 2
});
通過把ACCESS_LEVELS設(shè)置為常量,可以將它注入到.confgi()和.run()代碼塊中,并在整個應(yīng)用范圍內(nèi)使用。下面,使用這些常量來為每個路由都定義訪問級別:
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,可以根據(jù)這一點(diǎn)判斷當(dāng)前用戶的授權(quán)狀態(tài), 以及用戶的級別是否有權(quán)限訪問當(dāng)前路由。
此時,用戶可能處于以下兩種狀態(tài):
- 未經(jīng)過身份驗(yàn)證的匿名用戶;
- 通過身份驗(yàn)證的已知用戶。
為了驗(yàn)證用戶的身份,需要創(chuàng)建一個服務(wù)來對已經(jīng)存在的用戶進(jìn)行監(jiān)視。同時需要讓服務(wù)能夠訪問瀏覽器的cookie,這樣當(dāng)用戶重新登錄時,只要會話有效就無需再次進(jìn)行身份驗(yàn)證。 這個小服務(wù)包含了一些操作用戶對象的輔助函數(shù)。
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; }
}
};
});
現(xiàn)在,當(dāng)用戶已經(jīng)通過身份驗(yàn)證并登錄后,可以在$routeChangeStart事件中對其有效性進(jìn)行檢查。
angular.module('myApp', [])
.run(function($rootScope, $location, Auth) {
// 給$routeChangeStart設(shè)置監(jiān)聽
$rootScope.$on('$routeChangeStart', function(evt, next, curr) {
if (!Auth.isAuthorized(next.$$route.access_level)) {
if (Auth.isLoggedIn()) {
// 用戶登錄了,但沒有訪問當(dāng)前視圖的權(quán)限
$location.path('/');
} else {
$location.path('/login');
}
}
});
});
3.發(fā)送經(jīng)過身份驗(yàn)證的請求
當(dāng)我們通過了身份驗(yàn)證,并取回了用戶的授權(quán)令牌后,就可以在向服務(wù)器發(fā)送請求時使用令牌。從服務(wù)器的角度看,當(dāng)收到一個帶有令牌的請求時,驗(yàn)證令牌的有效性是服務(wù)器的責(zé)任之一。
如果提供的令牌是合法的,且與一個合法用戶是關(guān)聯(lián)的狀態(tài),那服務(wù)器就會認(rèn)為用戶的身份是合法且安全的。
通過令牌進(jìn)行身份驗(yàn)證的安全性取決于通信所采用的通道,因此盡可能地使用SSL連接可以提高安全性。
如果用戶已經(jīng)通過了身份驗(yàn)證,可以在發(fā)送請求時單獨(dú)給每個請求都加入驗(yàn)證信息,或者把令牌附加到所有的請求中。
手動使用身份令牌 手動創(chuàng)建一個可以發(fā)送令牌的請求,只要將token當(dāng)作參數(shù)或請求頭添加到請求中即可。例如,如果我們想對服務(wù)器發(fā)出一個請求,此時我們正在這個服務(wù)器上通過Backend服務(wù)請求用戶分析數(shù)據(jù)。
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當(dāng)作參數(shù)(或請求頭)發(fā)送就可以進(jìn)行令牌驗(yàn)證。
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);
});
};
});
當(dāng)向后端發(fā)送請求時,請求會被添加token參數(shù)。
自動添加身份令牌更進(jìn)一步,如果想要為每個請求都添加上當(dāng)前用戶的令牌,可以創(chuàng)建一個請求攔截器,并將令牌當(dāng)作參數(shù)添加進(jìn)請求中。
angular.module('myApp', [])
.config(function($httpProvider) {
// 在這里構(gòu)造攔截器
var interceptor = function($q, $rootScope, Auth) {
return {
'request': function(req) {
return req;
},
'requestError': function(reqErr) {
return reqErr;
}
};
};
});
在請求攔截器內(nèi)部可以加入向請求中添加token參數(shù)的業(yè)務(wù)邏輯,通過用戶是否持有令牌來檢查身份驗(yàn)證情況,同時需要確保不會將手動添加的同名參數(shù)覆蓋。
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;
},
// ...
}
}