1. 概述
在本文中,我們將為 OAuth 2 安全的應用程序添加“記住我”功能,通過利用 OAuth 2 刷新令牌。
本文是我們在使用 OAuth 2 安全 Spring REST API 的系列文章的延續,該 API 通過 AngularJS 客户端訪問。要設置授權服務器、資源服務器和前端客户端,您可以參考介紹性文章。
注意:本文使用 Spring OAuth 遺留項目。
2. OAuth 2 訪問令牌和刷新令牌
首先,我們快速回顧一下 OAuth 2 令牌以及它們的使用方法。
在首次使用 密碼 grant 類型進行身份驗證時,用户需要發送有效的用户名和密碼,以及客户端 ID 和密鑰。如果身份驗證請求成功,服務器會返回一個類似於以下格式的響應:
{
"access_token": "2e17505e-1c34-4ea6-a901-40e49ba786fa",
"token_type": "bearer",
"refresh_token": "e5f19364-862d-4212-ad14-9d6275ab1a62",
"expires_in": 59,
"scope": "read write",
}
我們可以看到服務器響應包含訪問令牌以及刷新令牌。訪問令牌將用於後續需要身份驗證的 API 調用,而 刷新令牌的目的在於獲取一個新的有效訪問令牌,或直接撤銷之前的令牌。
為了使用 刷新令牌 grant 類型獲取新的訪問令牌,用户不再需要輸入憑據,只需客户端 ID、密鑰和當然是刷新令牌。
使用兩種類型的令牌的目標是提高用户安全性。通常,訪問令牌具有較短的有效期限,以便如果攻擊者獲取訪問令牌,他們只有有限的時間來使用它。另一方面,如果刷新令牌被泄露,這毫無用處,因為客户端 ID 和密鑰也需要。
刷新令牌的另一個好處是允許撤銷訪問令牌,即使用户表現出異常行為,例如從新的 IP 地址登錄,也不需要重新發送令牌。
3. 使用刷新令牌的記住我功能
用户通常希望保留會話,因為他們不需要每次訪問應用程序時都輸入憑據。
由於訪問令牌的有效時間較短,我們可以使用刷新令牌生成新的訪問令牌,從而避免每次訪問令牌過期時要求用户輸入憑據。
在下一部分,我們將討論兩種實現此功能的途徑:
- 首先,通過攔截任何返回 401 狀態碼的用户請求,這意味着訪問令牌無效。當這種情況發生時,如果用户已選中“記住我”選項,我們將自動使用 refresh_token grant 類型發出請求以獲取新的訪問令牌,然後重新執行初始請求。
- 其次,我們可以主動刷新訪問令牌——在訪問令牌即將過期前幾秒發送請求以刷新令牌。
第二種選項具有優勢,即用户的請求不會被延遲。
4. 存儲刷新令牌
在關於刷新令牌的文章中,我們添加了一個 CustomPostZuulFilter,該過濾器攔截向 OAuth 服務器發送的請求,提取在身份驗證過程中返回的刷新令牌,並將其存儲在服務器端 cookie 中:
@Component
public class CustomPostZuulFilter extends ZuulFilter {
@Override
public Object run() {
//...
Cookie cookie = new Cookie("refreshToken", refreshToken);
cookie.setHttpOnly(true);
cookie.setPath(ctx.getRequest().getContextPath() + "/oauth/token");
cookie.setMaxAge(2592000); // 30 days
ctx.getResponse().addCookie(cookie);
//...
}
}
接下來,我們在登錄表單上添加一個複選框,該複選框與 loginData.remember 變量綁定:
<input type="checkbox" ng-model="loginData.remember" id="remember"/>
<label for="remember">Remeber me</label>
我們的登錄表單現在將顯示一個額外的複選框:
loginData 對象將包含在身份驗證請求中,因此它將包含 remember 參數。在身份驗證請求發送之前,我們將根據參數設置一個名為 remember 的 cookie:
function obtainAccessToken(params){
if (params.username != null){
if (params.remember != null){
$cookies.put("remember","yes");
}
else {
$cookies.remove("remember");
}
}
//...
}
因此,我們將檢查此 cookie 以確定我們是否應該刷新訪問令牌,具體取決於用户是否希望被記住:
5. 通過攔截 401 響應刷新令牌
為了攔截返回 401 響應的請求,我們修改我們的 AngularJS 應用程序,添加一個攔截器,其中包含一個 responseError 函數:
app.factory('rememberMeInterceptor', ['$q', '$injector', '$httpParamSerializer',
function($q, $injector, $httpParamSerializer) {
var interceptor = {
responseError: function(response) {
if (response.status == 401){
// refresh access token
// make the backend call again and chain the request
return deferred.promise.then(function() {
return $http(response.config);
});
}
return $q.reject(response);
}
};
return interceptor;
}]);
我們的函數檢查狀態是否為 401 – 這意味着 Access Token 無效,如果確實,則嘗試使用 Refresh Token 以獲取新的有效 Access Token。
如果此操作成功,則該函數將繼續重試導致 401 錯誤的原請求。這確保了用户體驗的無縫性。
讓我們更詳細地瞭解刷新 Access Token 的過程。首先,我們需要初始化必要的變量:
var $http = $injector.get('$http');
var $cookies = $injector.get('$cookies');
var deferred = $q.defer();
var refreshData = {grant_type:"refresh_token"};
var req = {
method: 'POST',
url: "oauth/token",
headers: {"Content-type": "application/x-www-form-urlencoded; charset=utf-8"},
data: $httpParamSerializer(refreshData)
}
你可以看到 req 變量,我們將使用它向 /oauth/token 端點發送一個帶有參數 grant_type=refresh_token 的 POST 請求。
接下來,我們將使用我們已注入的 $http 模塊來發送請求。如果請求成功,我們將設置一個新的 Authentication 標題,其中包含新的 Access Token 值,以及一個新的 access_token cookie 值。如果請求失敗,這可能會發生,如果 Refresh Token 最終也會過期,則用户將被重定向到登錄頁面:
$http(req).then(
function(data){
$http.defaults.headers.common.Authorization= 'Bearer ' + data.data.access_token;
var expireDate = new Date (new Date().getTime() + (1000 * data.data.expires_in));
$cookies.put("access_token", data.data.access_token, {'expires': expireDate});
window.location.href="index";
},function(){
console.log("error");
$cookies.remove("access_token");
window.location.href = "login";
}
);
Refresh Token 通過我們在上一篇文章中實現的 CustomPreZuulFilter 添加到請求中:
@Component
public class CustomPreZuulFilter extends ZuulFilter {
@Override
public Object run() {
//...
String refreshToken = extractRefreshToken(req);
if (refreshToken != null) {
Map<String, String[]> param = new HashMap<String, String[]>();
param.put("refresh_token", new String[] { refreshToken });
param.put("grant_type", new String[] { "refresh_token" });
ctx.setRequest(new CustomHttpServletRequest(req, param));
}
//...
}
}
除了定義攔截器,還需要將其註冊到 $httpProvider 中:
app.config(['$httpProvider', function($httpProvider) {
$httpProvider.interceptors.push('rememberMeInterceptor');
}]);
6. 主動刷新令牌
實施“記住我”功能的一種方式是,在當前令牌過期之前請求新的訪問令牌。
收到訪問令牌時,JSON 響應包含一個 expires_in 值,該值指定令牌的有效時間(以秒為單位)。
將此值存儲在每個身份驗證的 Cookie 中:
$cookies.put("validity", data.data.expires_in);
然後,使用 AngularJS $timeout 服務,在令牌過期前 10 秒內安排刷新請求:
if ($cookies.get("remember") == "yes"){
var validity = $cookies.get("validity");
if (validity >10) validity -= 10;
$timeout( function(){ $scope.refreshAccessToken(); }, validity * 1000);
}
7. 結論
在本教程中,我們探討了兩種使用 OAuth2 應用程序和 AngularJS 前端實現“記住我”功能的實現方式。