8.4 Phát triển ứng dụng client
Trong quá trình ủy quyền OAuth 2, vai trò của ứng dụng client là lấy access token và gửi yêu cầu đến resource server thay mặt cho người dùng. Vì chúng ta đang sử dụng luồng ủy quyền (authorization code flow) của OAuth 2, điều đó có nghĩa là khi ứng dụng client phát hiện rằng người dùng chưa xác thực, nó sẽ chuyển hướng trình duyệt của người dùng đến authorization server để nhận sự đồng ý từ họ. Sau đó, khi authorization server chuyển hướng lại cho client, client phải trao đổi mã ủy quyền (authorization code) nhận được để lấy access token.
Trước tiên, client sẽ cần hỗ trợ OAuth 2 từ Spring Security trong classpath. Dependency khởi đầu sau đây sẽ giúp làm điều đó:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>Không chỉ cung cấp khả năng client OAuth 2 mà chúng ta sẽ khai thác ngay sau đây, dependency này còn kéo theo Spring Security. Điều này cho phép chúng ta viết cấu hình bảo mật cho ứng dụng. Bean SecurityFilterChain sau sẽ cấu hình Spring Security sao cho tất cả các yêu cầu đều yêu cầu xác thực:
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(
authorizeRequests -> authorizeRequests.anyRequest().authenticated()
)
.oauth2Login(
oauth2Login ->
oauth2Login.loginPage("/oauth2/authorization/taco-admin-client"))
.oauth2Client(withDefaults());
return http.build();
}Hơn thế nữa, bean SecurityFilterChain này cũng kích hoạt các phần liên quan đến client OAuth 2. Cụ thể, nó thiết lập một trang đăng nhập tại đường dẫn /oauth2/authorization/taco-admin-client. Nhưng đây không phải là một trang đăng nhập thông thường yêu cầu tên người dùng và mật khẩu. Thay vào đó, nó chấp nhận authorization code, trao đổi nó để lấy access token, và sử dụng access token để xác định danh tính người dùng. Nói cách khác, đây là đường dẫn mà authorization server sẽ chuyển hướng đến sau khi người dùng cấp quyền.
Chúng ta cũng cần cấu hình chi tiết về authorization server và client OAuth 2 của ứng dụng. Điều này được thực hiện trong các thuộc tính cấu hình, chẳng hạn như file application.yml sau, cấu hình một client có tên là taco-admin-client:
spring:
security:
oauth2:
client:
registration:
taco-admin-client:
provider: tacocloud
client-id: taco-admin-client
client-secret: secret
authorization-grant-type: authorization_code
redirect-uri: "http://127.0.0.1:9090/login/oauth2/code/{registrationId}"
scope: writeIngredients,deleteIngredients,openidĐoạn này đăng ký một client với Spring Security OAuth 2 client có tên là taco-admin-client. Chi tiết đăng ký bao gồm thông tin xác thực của client (client-id và client-secret), loại grant (authorization-grant-type), các scope được yêu cầu (scope) và URI chuyển hướng (redirect-uri). Lưu ý rằng giá trị được cung cấp cho redirect-uri sử dụng một placeholder tham chiếu đến ID đăng ký của client là taco-admin-client. Do đó, redirect URI sẽ là http://127.0.0.1:9090/login/oauth2/code/taco-admin-client, trùng với đường dẫn mà chúng ta đã cấu hình là OAuth 2 login ở trên.
Vậy còn authorization server thì sao? Làm thế nào để client biết cần chuyển hướng trình duyệt người dùng đến đâu? Đó là vai trò của thuộc tính provider, mặc dù gián tiếp. Thuộc tính provider được đặt là tacocloud, tham chiếu đến một nhóm cấu hình riêng biệt mô tả authorization server của nhà cung cấp tacocloud. Cấu hình provider được định nghĩa trong cùng file application.yml như sau:
spring:
security:
oauth2:
client:
...
provider:
tacocloud:
issuer-uri: http://authserver:9000Thuộc tính duy nhất bắt buộc trong cấu hình provider là issuer-uri. Thuộc tính này xác định URI gốc của authorization server. Trong ví dụ này, nó trỏ đến một máy chủ tên là authserver. Giả sử bạn đang chạy ví dụ này trên máy cục bộ, đây chỉ là một bí danh khác cho localhost. Trên hầu hết các hệ điều hành dựa trên Unix, bạn có thể thêm dòng sau vào file /etc/hosts để cấu hình alias:
127.0.0.1 authserverHãy tham khảo tài liệu của hệ điều hành bạn đang dùng để biết cách tạo alias nếu /etc/hosts không hoạt động trên máy của bạn.
Dựa trên URL gốc đó, Spring Security OAuth 2 client sẽ giả định các giá trị mặc định hợp lý cho authorization URL, token URL và các thông số khác của authorization server. Tuy nhiên, nếu authorization server bạn đang dùng không phù hợp với các giá trị mặc định đó, bạn có thể cấu hình tường minh các chi tiết ủy quyền như sau:
spring:
security:
oauth2:
client:
provider:
tacocloud:
issuer-uri: http://authserver:9000
authorization-uri: http://authserver:9000/oauth2/authorize
token-uri: http://authserver:9000/oauth2/token
jwk-set-uri: http://authserver:9000/oauth2/jwks
user-info-uri: http://authserver:9000/userinfo
user-name-attribute: subChúng ta đã thấy hầu hết các URI này như authorization URI, token URI, và JWK Set URI. Tuy nhiên, user-info-uri là mới. URI này được client sử dụng để lấy thông tin người dùng cơ bản, đặc biệt là tên người dùng. Một yêu cầu đến URI đó nên trả về phản hồi JSON chứa thuộc tính được chỉ định trong user-name-attribute để xác định người dùng. Tuy nhiên, nếu bạn đang sử dụng Spring Authorization Server, bạn không cần phải tự tạo endpoint cho URI đó; Spring Authorization Server sẽ tự động cung cấp endpoint userinfo.
Giờ đây tất cả các phần đều đã sẵn sàng để ứng dụng xác thực và lấy access token từ authorization server. Không cần làm gì thêm, bạn có thể khởi chạy ứng dụng, gửi yêu cầu đến bất kỳ URL nào trên ứng dụng đó, và được chuyển hướng đến authorization server để ủy quyền. Khi authorization server chuyển hướng lại, các cơ chế nội bộ của thư viện OAuth 2 client trong Spring Security sẽ trao đổi mã được nhận trong redirect để lấy access token. Vậy chúng ta có thể sử dụng token đó như thế nào?
Giả sử chúng ta có một service bean tương tác với Taco Cloud API sử dụng RestTemplate. Lớp triển khai RestIngredientService sau đây cho thấy một lớp như vậy với hai phương thức: một để lấy danh sách nguyên liệu và một để lưu nguyên liệu mới:
package tacos;
import java.util.Arrays;
import org.springframework.web.client.RestTemplate;
public class RestIngredientService implements IngredientService {
private RestTemplate restTemplate;
public RestIngredientService() {
this.restTemplate = new RestTemplate();
}
@Override
public Iterable<Ingredient> findAll() {
return Arrays.asList(restTemplate.getForObject(
"http://localhost:8080/api/ingredients",
Ingredient[].class));
}
@Override
public Ingredient addIngredient(Ingredient ingredient) {
return restTemplate.postForObject(
"http://localhost:8080/api/ingredients",
ingredient,
Ingredient.class);
}
}Yêu cầu HTTP GET đến endpoint /ingredients không được bảo vệ, vì vậy phương thức findAll() sẽ hoạt động bình thường, miễn là API Taco Cloud đang lắng nghe tại localhost, cổng 8080. Nhưng phương thức addIngredient() có khả năng sẽ thất bại với phản hồi HTTP 401 bởi vì chúng ta đã bảo vệ các yêu cầu POST đến /ingredients để yêu cầu phạm vi "writeIngredients". Cách duy nhất để vượt qua điều đó là gửi một access token với phạm vi "writeIngredients" trong header Authorization của yêu cầu.
May mắn thay, client OAuth 2 của Spring Security sẽ có sẵn access token sau khi hoàn thành luồng mã ủy quyền. Tất cả những gì chúng ta cần làm là đảm bảo rằng access token được đưa vào trong yêu cầu. Để làm điều đó, hãy thay đổi constructor để đính kèm một interceptor vào RestTemplate như sau:
public RestIngredientService(String accessToken) {
this.restTemplate = new RestTemplate();
if (accessToken != null) {
this.restTemplate
.getInterceptors()
.add(getBearerTokenInterceptor(accessToken));
}
}
private ClientHttpRequestInterceptor
getBearerTokenInterceptor(String accessToken) {
ClientHttpRequestInterceptor interceptor =
new ClientHttpRequestInterceptor() {
@Override
public ClientHttpResponse intercept(
HttpRequest request, byte[] bytes,
ClientHttpRequestExecution execution) throws IOException {
request.getHeaders().add("Authorization", "Bearer " + accessToken);
return execution.execute(request, bytes);
}
};
return interceptor;
}Constructor bây giờ nhận một tham số String, đó là access token. Sử dụng token này, nó đính kèm một interceptor client request để thêm header Authorization vào mọi yêu cầu được thực hiện bởi RestTemplate sao cho giá trị của header là "Bearer" theo sau là giá trị của token. Để giữ cho constructor gọn gàng, interceptor client được tạo trong một phương thức trợ giúp private riêng biệt.
Chỉ còn một câu hỏi: access token đến từ đâu? Phương thức bean sau đây là nơi điều kỳ diệu xảy ra:
@Bean
@RequestScope
public IngredientService ingredientService(
OAuth2AuthorizedClientService clientService) {
Authentication authentication =
SecurityContextHolder.getContext().getAuthentication();
String accessToken = null;
if (authentication.getClass()
.isAssignableFrom(OAuth2AuthenticationToken.class)) {
OAuth2AuthenticationToken oauthToken =
(OAuth2AuthenticationToken) authentication;
String clientRegistrationId =
oauthToken.getAuthorizedClientRegistrationId();
if (clientRegistrationId.equals("taco-admin-client")) {
OAuth2AuthorizedClient client =
clientService.loadAuthorizedClient(
clientRegistrationId, oauthToken.getName());
accessToken = client.getAccessToken().getTokenValue();
}
}
return new RestIngredientService(accessToken);
}Đầu tiên, hãy chú ý rằng bean được khai báo là có phạm vi request bằng cách sử dụng annotation @RequestScope. Điều này có nghĩa là một instance mới của bean sẽ được tạo ra cho mỗi yêu cầu. Bean phải có phạm vi request vì nó cần lấy thông tin xác thực từ SecurityContext, vốn được Spring Security tự động thiết lập trên mỗi yêu cầu thông qua một filter; sẽ không có SecurityContext khi ứng dụng khởi động (thời điểm mà các bean có phạm vi mặc định được tạo ra).
Trước khi trả về một instance của RestIngredientService, phương thức bean sẽ kiểm tra xem xác thực có thực sự được triển khai là OAuth2AuthenticationToken không. Nếu có, điều đó có nghĩa là token sẽ có sẵn. Nó sau đó xác minh rằng token xác thực là của client tên là taco-admin-client. Nếu đúng, nó sẽ trích xuất token từ client đã được ủy quyền và truyền nó vào constructor của RestIngredientService. Với token đó trong tay, RestIngredientService sẽ không gặp bất kỳ khó khăn nào khi thực hiện các yêu cầu đến các endpoint của API Taco Cloud thay mặt cho người dùng đã ủy quyền cho ứng dụng.
