🎗️OAuth2 Remember Me với Refresh Token

Tổng quan

Trong bài viết này, chúng ta sẽ thêm chức năng "Ghi nhớ tôi" vào một ứng dụng được bảo mật bằng OAuth 2, bằng cách tận dụng Refresh Token của OAuth 2.

Bài viết này là phần tiếp theo trong loạt bài về việc sử dụng OAuth 2 để bảo mật một Spring REST API, mà được truy cập thông qua một Client AngularJS. Để cài đặt Authorization Server, Resource Server và Client phía trước, bạn có thể làm theo bài viết giới thiệu.

Lưu ý: Bài viết này sử dụng dự án Spring OAuth phiên bản cũ.

OAuth 2 Access Token and Refresh Token

Trước tiên, hãy làm một tóm tắt nhanh về các mã thông báo OAuth 2 và cách chúng có thể được sử dụng.

Trong lần xác thực đầu tiên sử dụng grant type là password, người dùng cần gửi một tên người dùng và mật khẩu hợp lệ, cùng với client id và client secret. Nếu yêu cầu xác thực thành công, máy chủ sẽ gửi lại phản hồi dưới dạng:

{
    "access_token": "2e17505e-1c34-4ea6-a901-40e49ba786fa",
    "token_type": "bearer",
    "refresh_token": "e5f19364-862d-4212-ad14-9d6275ab1a62",
    "expires_in": 59,
    "scope": "read write",
}

Chúng ta có thể thấy phản hồi từ máy chủ chứa cả access token và refresh token. Access token sẽ được sử dụng cho các cuộc gọi API sau đó yêu cầu xác thực, trong khi refresh token được sử dụng để nhận được một access token mới hợp lệ hoặc vô hiệu hóa access token trước đó.

Để nhận được một access token mới bằng cách sử dụng grant type refresh_token, người dùng không cần nhập thông tin đăng nhập của họ nữa, mà chỉ cần client id, secret và tất nhiên là refresh token.

Mục đích của việc sử dụng hai loại mã thông báo này là tăng cường bảo mật cho người dùng. Thông thường, access token có thời hạn hiệu lực ngắn hơn để đảm bảo rằng nếu kẻ tấn công có được access token, họ chỉ có thời gian hạn chế để sử dụng nó. Trong khi đó, nếu refresh token bị xâm phạm, nó trở nên vô dụng vì cần có cả client id và secret.

Một lợi ích khác của refresh token là cho phép vô hiệu hóa access token và không gửi lại access token mới nếu người dùng có hành vi bất thường như đăng nhập từ địa chỉ IP mới.

Remember-Me Functionality With Refresh Tokens

Người dùng thường thấy hữu ích khi có tùy chọn để duy trì phiên làm việc của họ, vì họ không cần nhập thông tin đăng nhập mỗi khi truy cập vào ứng dụng.

Vì Access Token có thời gian hiệu lực ngắn, chúng ta có thể sử dụng refresh token để tạo ra access token mới và tránh việc yêu cầu người dùng nhập thông tin đăng nhập mỗi khi một access token hết hạn.

Ở các phần tiếp theo, chúng ta sẽ thảo luận về hai cách để triển khai chức năng này:

  • Thứ nhất, bằng cách chặn bất kỳ yêu cầu từ người dùng nào trả về mã trạng thái 401, có nghĩa là access token không hợp lệ. Khi điều này xảy ra, nếu người dùng đã chọn tùy chọn "ghi nhớ tôi", chúng ta sẽ tự động gửi yêu cầu để nhận access token mới bằng cách sử dụng grant type refresh_token, sau đó thực hiện lại yêu cầu ban đầu.

  • Thứ hai, chúng ta có thể làm mới Access Token một cách chủ động - chúng ta sẽ gửi một yêu cầu để làm mới token vài giây trước khi nó hết hạn.

Lựa chọn thứ hai có lợi thế là yêu cầu của người dùng sẽ không bị trì hoãn.

Storing the Refresh Token

Trong bài viết trước về Refresh Tokens, chúng ta đã thêm một CustomPostZuulFilter, nó chặn các yêu cầu tới máy chủ OAuth, trích xuất refresh token được gửi lại trong quá trình xác thực và lưu trữ nó trong một cookie phía máy chủ:

@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);
        //...
    }
}

Tiếp theo, hãy thêm một ô kiểm (checkbox) vào mẫu đăng nhập của chúng ta và liên kết dữ liệu với biến loginData.remember:

<input type="checkbox"  ng-model="loginData.remember" id="remember"/>
<label for="remember">Remeber me</label>

Mẫu đăng nhập của chúng ta sẽ hiển thị một ô kiểm bổ sung:

Đối tượng loginData được gửi cùng với yêu cầu xác thực, vì vậy nó sẽ bao gồm tham số remember. Trước khi gửi yêu cầu xác thực, chúng ta sẽ thiết lập một cookie có tên là remember dựa trên tham số này:

function obtainAccessToken(params){
    if (params.username != null){
        if (params.remember != null){
            $cookies.put("remember","yes");
        }
        else {
            $cookies.remove("remember");
        }
    }
    //...
}

Như kết quả, chúng ta sẽ kiểm tra cookie này để xác định liệu chúng ta có nên cố gắng refresh access token hay không, tùy thuộc vào nguyện vọng của người dùng có muốn được ghi nhớ hay không.

Refreshing Tokens by Intercepting 401 Responses

Để chặn các yêu cầu trả về mã phản hồi 401, chúng ta sẽ điều chỉnh ứng dụng AngularJS của chúng ta để thêm một interceptor với hàm responseError. Dưới đây là ví dụ về cách bạn có thể thực hiện điều này:

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;
}]);

Hàm của chúng ta kiểm tra nếu mã phản hồi là 401 - điều này có nghĩa là Access Token không hợp lệ, và nếu có, tiến hành sử dụng Refresh Token để có được Access Token mới hợp lệ.

Nếu quá trình này thành công, hàm sẽ tiếp tục thử lại yêu cầu ban đầu đã gây ra lỗi 401. Điều này đảm bảo trải nghiệm mượt mà cho người dùng.

Hãy xem xét quá trình làm mới Access Token một cách cụ thể hơn. Trước tiên, chúng ta sẽ khởi tạo các biến cần thiết:

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)
}

Bạn có thể thấy biến req mà chúng ta sẽ sử dụng để gửi một yêu cầu POST đến địa chỉ /oauth/token, với tham số grant_type=refresh_token.

Tiếp theo, chúng ta sẽ sử dụng module $http mà chúng ta đã inject để gửi yêu cầu. Nếu yêu cầu thành công, chúng ta sẽ đặt một tiêu đề xác thực mới với giá trị access token mới, cũng như một giá trị mới cho cookie access_token. Nếu yêu cầu thất bại, điều này có thể xảy ra nếu refresh token cũng sau cùng hết hạn, thì người dùng sẽ được chuyển hướng đến trang đăng nhập:

$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";
    }
);

Trong bài viết trước, chúng ta đã triển khai một CustomPreZuulFilter để thêm Refresh Token vào yêu cầu trước khi nó đến đích dịch vụ backend. Hãy xem cách filter này thêm Refresh Token vào tiêu đề yêu cầu:

@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));
        }
        //...
    }
}

Ngoài việc định nghĩa interceptor, chúng ta cần đăng ký nó với $httpProvider.

app.config(['$httpProvider', function($httpProvider) {  
    $httpProvider.interceptors.push('rememberMeInterceptor');
}]);

Refreshing Tokens Proactively

Một cách khác để triển khai chức năng "remember-me" là bằng cách yêu cầu một access token mới trước khi access token hiện tại hết hạn.

Khi nhận được access token, phản hồi JSON chứa giá trị expires_in xác định số giây mà access token sẽ có hiệu lực.

Hãy lưu giá trị này vào một cookie cho mỗi lần xác thực:

$cookies.put("validity", data.data.expires_in);

Để gửi một yêu cầu làm mới bằng cách sử dụng dịch vụ $timeout trong AngularJS để lên lịch gọi làm mới 10 giây trước khi token hết hạn, bạn có thể sửa đổi mã của bạn như sau:

if ($cookies.get("remember") == "yes"){
    var validity = $cookies.get("validity");
    if (validity >10) validity -= 10;
    $timeout( function(){ $scope.refreshAccessToken(); }, validity * 1000);
}

Last updated