⛓️OAuth2 cho một Spring REST API

Trong tutorial này, chúng ta sẽ tiếp tục khám phá quy trình OAuth password flow mà chúng ta đã bắt đầu trong bài viết trước đó và chúng ta sẽ tập trung vào cách xử lý Refresh Token trong AngularJs.

Access Token Expiration

Trước tiên, hãy nhớ rằng client đã nhận một Access Token bằng cách sử dụng phương thức grant type là Authorization Code trong hai bước. Trong bước đầu tiên, chúng ta nhận được Authorization Code. Và trong bước thứ hai, chúng ta thực sự nhận được Access Token.

Access Token của chúng ta được lưu trữ trong một cookie sẽ hết hạn dựa trên thời gian hết hạn của Token:

var expireDate = new Date().getTime() + (1000 * token.expires_in);
Cookie.set("access_token", token.access_token, expireDate);

Điều quan trọng cần hiểu là cookie chỉ được sử dụng để lưu trữ và không ảnh hưởng đến bất kỳ phần nào khác trong quy trình OAuth2. Ví dụ, trình duyệt sẽ không bao giờ tự động gửi cookie đến máy chủ với các yêu cầu, vì vậy chúng ta đã được bảo đảm ở đây.

Nhưng hãy lưu ý cách chúng ta định nghĩa hàm retrieveToken() để lấy Access Token:

retrieveToken(code) {
  let params = new URLSearchParams();
  params.append('grant_type','authorization_code');
  params.append('client_id', this.clientId);
  params.append('client_secret', 'newClientSecret');
  params.append('redirect_uri', this.redirectUri);
  params.append('code',code);

  let headers =
    new HttpHeaders({'Content-type': 'application/x-www-form-urlencoded; charset=utf-8'});

  this._http.post('http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/token',
    params.toString(), { headers: headers })
    .subscribe(
      data => this.saveToken(data),
      err => alert('Invalid Credentials'));
}

Chúng ta đang gửi client secret trong các tham số, đây không phải là một cách an toàn để xử lý. Hãy xem cách chúng ta có thể tránh làm điều này.

The Proxy

Vậy bây giờ chúng ta sẽ có một proxy Zuul chạy trong ứng dụng front-end và nó sẽ đứng giữa client front-end và Authorization Server. Tất cả thông tin nhạy cảm sẽ được xử lý ở tầng này.

Client front-end sẽ được lưu trữ như một ứng dụng Boot để chúng ta có thể kết nối một cách mượt mà với proxy Zuul được nhúng bằng Spring Cloud Zuul starter.

Nếu bạn muốn tìm hiểu về cơ bản của Zuul, hãy đọc qua bài viết chính về Zuul.

Bây giờ hãy cấu hình các route của proxy:

zuul:
  routes:
    auth/code:
      path: /auth/code/**
      sensitiveHeaders:
      url: http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/auth
    auth/token:
      path: /auth/token/**
      sensitiveHeaders:
      url: http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/token
    auth/refresh:
      path: /auth/refresh/**
      sensitiveHeaders:
      url: http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/token
    auth/redirect:
      path: /auth/redirect/**
      sensitiveHeaders:
      url: http://localhost:8089/
    auth/resources:
      path: /auth/resources/**
      sensitiveHeaders:
      url: http://localhost:8083/auth/resources/

Chúng ta đã thiết lập các route để xử lý các yêu cầu sau:

  • auth/code: Lấy Authorization Code và lưu trữ nó trong một cookie.

  • auth/redirect: Xử lý việc chuyển hướng đến trang đăng nhập của Authorization Server.

  • auth/resources: Ánh xạ đến đường dẫn tương ứng của trang đăng nhập của Authorization Server cho các tài nguyên của trang đó (css và js).

  • auth/token: Lấy Access Token, loại bỏ refresh_token từ dữ liệu và lưu trữ nó trong một cookie.

  • auth/refresh: Lấy Refresh Token, loại bỏ nó từ dữ liệu và lưu trữ nó trong một cookie.

Điều thú vị ở đây là chúng ta chỉ định tuyển proxy cho lưu lượng truy cập đến Authorization Server và không phải bất kỳ thứ gì khác. Chúng ta thực sự chỉ cần proxy đến khi client đang nhận các token mới.

Tiếp theo, hãy xem từng phần này một cách chi tiết.

Get the Code Using Zuul Pre Filter

Việc sử dụng proxy lần đầu tiên là đơn giản - chúng ta thiết lập một yêu cầu để nhận Authorization Code:

@Component
public class CustomPreZuulFilter extends ZuulFilter {
    @Override
    public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        HttpServletRequest req = ctx.getRequest();
        String requestURI = req.getRequestURI();
        if (requestURI.contains("auth/code")) {
            Map<String, List> params = ctx.getRequestQueryParams();
            if (params == null) {
	        params = Maps.newHashMap();
	    }
            params.put("response_type", Lists.newArrayList(new String[] { "code" }));
            params.put("scope", Lists.newArrayList(new String[] { "read" }));
            params.put("client_id", Lists.newArrayList(new String[] { CLIENT_ID }));
            params.put("redirect_uri", Lists.newArrayList(new String[] { REDIRECT_URL }));
            ctx.setRequestQueryParams(params);
        }
        return null;
    }

    @Override
    public boolean shouldFilter() {
        boolean shouldfilter = false;
        RequestContext ctx = RequestContext.getCurrentContext();
        String URI = ctx.getRequest().getRequestURI();

        if (URI.contains("auth/code") || URI.contains("auth/token") || 
          URI.contains("auth/refresh")) {		
            shouldfilter = true;
	}
        return shouldfilter;
    }

    @Override
    public int filterOrder() {
        return 6;
    }

    @Override
    public String filterType() {
        return "pre";
    }
}

Chúng ta đang sử dụng loại bộ lọc (filter type) là pre để xử lý yêu cầu trước khi tiếp tục chuyển tiếp.

Trong phương thức run() của bộ lọc, chúng ta thêm các tham số truy vấn (query parameters) cho response_type, scope, client_idredirect_uri - mọi thứ mà Authorization Server của chúng ta cần để chúng ta được chuyển đến trang đăng nhập và nhận lại mã (Code).

Hãy lưu ý phương thức shouldFilter(). Chúng ta chỉ lọc các yêu cầu có 3 URI được đề cập, các yêu cầu khác không được chuyển tiếp đến phương thức run().

Put the Code in a Cookie Using Zuul Post Filter

Chúng ta đang lên kế hoạch lưu mã (Code) dưới dạng một cookie để chúng ta có thể gửi nó qua cho Authorization Server để nhận Access Token. Mã (Code) có mặt như một tham số truy vấn trong URL yêu cầu mà Authorization Server chuyển hướng chúng ta sau khi đăng nhập.

Chúng ta sẽ thiết lập một bộ lọc post-filter của Zuul để trích xuất mã (Code) này và đặt nó vào cookie. Đây không chỉ là một cookie thông thường, mà là một cookie được bảo mật, chỉ cho phép giao tiếp qua HTTP và có một đường dẫn rất hạn chế (/auth/token):

@Component
public class CustomPostZuulFilter extends ZuulFilter {
    private ObjectMapper mapper = new ObjectMapper();

    @Override
    public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        try {
            Map<String, List> params = ctx.getRequestQueryParams();

            if (requestURI.contains("auth/redirect")) {
                Cookie cookie = new Cookie("code", params.get("code").get(0));
                cookie.setHttpOnly(true);
                cookie.setPath(ctx.getRequest().getContextPath() + "/auth/token");
                ctx.getResponse().addCookie(cookie);
            }
        } catch (Exception e) {
            logger.error("Error occured in zuul post filter", e);
        }
        return null;
    }

    @Override
    public boolean shouldFilter() {
        boolean shouldfilter = false;
        RequestContext ctx = RequestContext.getCurrentContext();
        String URI = ctx.getRequest().getRequestURI();

        if (URI.contains("auth/redirect") || URI.contains("auth/token") || URI.contains("auth/refresh")) {
            shouldfilter = true;
        }
        return shouldfilter;
    }

    @Override
    public int filterOrder() {
        return 10;
    }

    @Override
    public String filterType() {
        return "post";
    }
}

Để thêm một lớp bảo vệ bổ sung chống lại các cuộc tấn công CSRF, chúng ta sẽ thêm một tiêu đề cookie Same-Site cho tất cả các cookie của chúng ta.

Để làm điều đó, chúng ta sẽ tạo một lớp cấu hình (configuration class):

@Configuration
public class SameSiteConfig implements WebMvcConfigurer {
    @Bean
    public TomcatContextCustomizer sameSiteCookiesConfig() {
        return context -> {
            final Rfc6265CookieProcessor cookieProcessor = new Rfc6265CookieProcessor();
            cookieProcessor.setSameSiteCookies(SameSiteCookies.STRICT.getValue());
            context.setCookieProcessor(cookieProcessor);
        };
    }
}

Ở đây, chúng ta đang thiết lập thuộc tính của cookie là "Strict", để ngăn chặn một cách nghiêm ngặt bất kỳ việc chuyển giao cookie giữa các trang web khác nhau (cross-site).

Get and Use the Code from the Cookie

Bây giờ chúng ta đã có Code trong cookie, khi ứng dụng Angular phía giao diện người dùng cố gắng kích hoạt yêu cầu Token, nó sẽ gửi yêu cầu tới /auth/token và trình duyệt, tất nhiên, sẽ gửi cookie đó cùng yêu cầu.

Vì vậy, chúng ta sẽ có một điều kiện khác trong bộ lọc pre-filter của proxy để trích xuất Code từ cookie và gửi nó cùng với các tham số form khác để nhận Token:

public Object run() {
    RequestContext ctx = RequestContext.getCurrentContext();
    ...
    else if (requestURI.contains("auth/token"))) {
        try {
            String code = extractCookie(req, "code");
            String formParams = String.format(
              "grant_type=%s&client_id=%s&client_secret=%s&redirect_uri=%s&code=%s",
              "authorization_code", CLIENT_ID, CLIENT_SECRET, REDIRECT_URL, code);

            byte[] bytes = formParams.getBytes("UTF-8");
            ctx.setRequest(new CustomHttpServletRequest(req, bytes));
        } catch (IOException e) {
            e.printStackTrace();
        }
    } 
    ...
}

private String extractCookie(HttpServletRequest req, String name) {
    Cookie[] cookies = req.getCookies();
    if (cookies != null) {
        for (int i = 0; i < cookies.length; i++) {
            if (cookies[i].getName().equalsIgnoreCase(name)) {
                return cookies[i].getValue();
            }
        }
    }
    return null;
}

Dưới đây là một ví dụ về lớp CustomHttpServletRequest – được sử dụng để gửi phần thân yêu cầu với các tham số form cần thiết được chuyển đổi thành mảng bytes:

public class CustomHttpServletRequest extends HttpServletRequestWrapper {

    private byte[] bytes;

    public CustomHttpServletRequest(HttpServletRequest request, byte[] bytes) {
        super(request);
        this.bytes = bytes;
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        return new ServletInputStreamWrapper(bytes);
    }

    @Override
    public int getContentLength() {
        return bytes.length;
    }

    @Override
    public long getContentLengthLong() {
        return bytes.length;
    }
	
    @Override
    public String getMethod() {
        return "POST";
    }
}

Điều này sẽ giúp chúng ta nhận được Access Token từ Máy chủ Xác thực trong phản hồi. Tiếp theo, chúng ta sẽ xem cách chúng ta biến đổi phản hồi.

Đến phần thú vị.

Ở đây, kế hoạch của chúng ta là cho phép khách hàng nhận Refresh Token dưới dạng cookie.

Chúng ta sẽ thêm mã lọc (post-filter) vào Zuul để trích xuất Refresh Token từ phần thân JSON của phản hồi và thiết lập nó vào cookie. Đây lại là một cookie an toàn, chỉ sử dụng trên giao thức HTTP, và có đường dẫn hạn chế rất nhỏ (/auth/refresh):

public Object run() {
...
    else if (requestURI.contains("auth/token") || requestURI.contains("auth/refresh")) {
        InputStream is = ctx.getResponseDataStream();
        String responseBody = IOUtils.toString(is, "UTF-8");
        if (responseBody.contains("refresh_token")) {
            Map<String, Object> responseMap = mapper.readValue(responseBody, 
              new TypeReference<Map<String, Object>>() {});
            String refreshToken = responseMap.get("refresh_token").toString();
            responseMap.remove("refresh_token");
            responseBody = mapper.writeValueAsString(responseMap);

            Cookie cookie = new Cookie("refreshToken", refreshToken);
            cookie.setHttpOnly(true);
            cookie.setPath(ctx.getRequest().getContextPath() + "/auth/refresh");
            cookie.setMaxAge(2592000); // 30 days
            ctx.getResponse().addCookie(cookie);
        }
        ctx.setResponseBody(responseBody);
    }
    ...
}

Như chúng ta có thể thấy, ở đây chúng ta đã thêm một điều kiện vào bộ lọc post-filter của Zuul để đọc phản hồi và trích xuất Refresh Token cho các đường dẫn auth/token và auth/refresh. Chúng ta thực hiện cùng một việc đó cho cả hai vì Máy chủ Xác thực thực tế gửi cùng một tải trọng dữ liệu khi lấy Access Token và Refresh Token.

Tiếp theo, chúng ta đã loại bỏ refresh_token khỏi phản hồi JSON để đảm bảo nó không bao giờ được truy cập từ phía giao diện người dùng ngoài cookie.

Một điểm chú ý khác ở đây là chúng ta đã đặt thời gian sống tối đa của cookie là 30 ngày - điều này khớp với thời gian hết hạn của Token.

Get and Use the Refresh Token from the Cookie

Bây giờ sau khi chúng ta có Refresh Token trong cookie, khi ứng dụng Angular phía front-end cố gắng kích hoạt việc làm mới token bằng cách gửi yêu cầu đến /auth/refresh, trình duyệt sẽ tự động bao gồm cookie trong yêu cầu.

Vì vậy, chúng ta sẽ thêm một điều kiện khác trong bộ lọc pre-filter của proxy để trích xuất Refresh Token từ cookie và gửi nó tiếp theo dưới dạng tham số HTTP - để yêu cầu hợp lệ:

public Object run() {
    RequestContext ctx = RequestContext.getCurrentContext();
    ...
    else if (requestURI.contains("auth/refresh"))) {
        try {
            String token = extractCookie(req, "token");                       
            String formParams = String.format(
              "grant_type=%s&client_id=%s&client_secret=%s&refresh_token=%s", 
              "refresh_token", CLIENT_ID, CLIENT_SECRET, token);
 
            byte[] bytes = formParams.getBytes("UTF-8");
            ctx.setRequest(new CustomHttpServletRequest(req, bytes));
        } catch (IOException e) {
            e.printStackTrace();
        }
    } 
    ...
}

Điều này tương tự như việc chúng ta làm khi lấy Access Token ban đầu. Nhưng hãy chú ý rằng nội dung của phần thân yêu cầu (form body) đã thay đổi. Bây giờ chúng ta đang gửi một grant_type là refresh_token thay vì authorization_code, cùng với Refresh Token chúng ta đã lưu trữ trước đó trong cookie.

Sau khi nhận được phản hồi, nó lại trải qua cùng một quá trình biến đổi trong bộ lọc pre-filter như chúng ta đã thấy trước đó ở phần trên (Put the Refresh Token in a Cookie).

Refreshing the Access Token from Angular

Cuối cùng, hãy sửa đổi ứng dụng front-end đơn giản của chúng ta và thực sự sử dụng chức năng làm mới token:

Dưới đây là hàm refreshAccessToken():

refreshAccessToken() {
  let headers = new HttpHeaders({
    'Content-type': 'application/x-www-form-urlencoded; charset=utf-8'});
  this._http.post('auth/refresh', {}, {headers: headers })
    .subscribe(
      data => this.saveToken(data),
      err => alert('Invalid Credentials')
    );
}

Chú ý rằng chúng ta chỉ đơn giản sử dụng lại chức năng saveToken() hiện có - và chỉ cần truyền các đầu vào khác cho nó.

Hãy cũng nhìn thấy rằng chúng ta không thêm bất kỳ tham số biểu mẫu nào với refresh_token bằng tay - vì điều đó sẽ được xử lý bởi bộ lọc Zuul.

Run the Front End

Vì ứng dụng Angular của chúng ta hiện đang được lưu trữ dưới dạng một ứng dụng Boot, việc chạy nó sẽ có một số khác biệt so với trước.

Bước đầu tiên giống như trước đó. Chúng ta cần xây dựng ứng dụng:

mvn clean install

Lệnh trên sẽ kích hoạt frontend-maven-plugin đã được xác định trong tệp pom.xml của chúng ta để xây dựng mã Angular và sao chép các tệp UI sang thư mục target/classes/static. Quá trình này sẽ ghi đè lên bất kỳ tệp nào khác mà chúng ta có trong thư mục src/main/resources. Do đó, chúng ta cần chắc chắn và bao gồm bất kỳ tài nguyên cần thiết nào từ thư mục này, chẳng hạn như application.yml, trong quá trình sao chép.

Ở bước thứ hai, chúng ta cần chạy lớp SpringBootApplication của chúng ta, UiApplication. Ứng dụng khách của chúng ta sẽ được chạy trên cổng 8089 như được chỉ định trong tệp application.yml.

Last updated