8.4 开发客户端
在 OAuth 2 授权过程中,客户端应用程序的角色是获取访问令牌和代表用户向资源服务器发出请求。因为我们使用的是 OAuth 2 授权代码流,这意味着当客户端应用程序确定用户尚未通过身份验证时,它应该将用户的浏览器重定向到授权服务器以获取用户的同意。然后,当授权服务器将控制重定向回客户端时,客户端必须将收到的授权码交换访问令牌。
第一件事:客户端在其类路径中需要 Spring Security 的 OAuth 2 客户端支持。以下 starter 依赖项完成这件工作:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
这不仅为应用程序 OAuth 2 客户端提供了我们稍后将利用的功能,它也带来了 Spring Securite 本身。这使我们能够为应用程序编写一些安全配置。下面的 SecurityFilterChain bean 设置 Spring Security,以便所有请求都需要身份验证:
@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();
}
此外,这个 SecurityFilterChain bean 还增加了一些 OAuth 2 客户端功能。具体来说,它在路径“/oauth2/authorization/taco admin client”处设置登录页面。但是这个不是一个普通的需要用户名和密码的登录页面。相反,它接受授权码,将其交换为访问令牌,并使用访问令牌确定使用者。换句话说,这是授权服务器在用户访问授权后,将重定向到的路径。
我们还需要配置有关授权服务器和应用程序的 OAuth 2 的客户详细信息。这是在配置属性中完成的。例如下面的 application.yml 中 配置名为“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
这将注册一个名为“aco-admin-client”的 Spring Security OAuth 2 客户端。这个注册详细信息包括客户的凭据(client-id 和 client-secret)、授权类型(authorization-grant-type)、请求的作用域(scope)和重定向 URI (redirect-uri)。请注意,给redirect-uri 的值有一个占位符,引用客户端的 ID,即“taco-admin-client”。因此,重定向 URI 设置为 ""http://127.0.0.1:9090/login/oauth2/code/taco-admin-client”,这与我们先前已配置的 OAuth 2 登录地址相同。
但是授权服务器本身呢?我们应该在哪里告诉客户应该将用户的浏览器重定向到哪里?provider 属性就做这个的,尽管是间接的。这个 provider 属性设置为“tacocloud”,这是对一组单独配置的引用。它描述了“tacocloud”的授权服务器。该配置在同一 application.yml 文件中配置如下:
spring:
security:
oauth2:
client:
...
provider:
tacocloud:
issuer-uri: http://authserver:9000
提供程序配置所需的唯一属性是 issuer-uri 此属性标识授权服务器的基本 URI。在本例中,它指的是名称为“authserver”的服务器主机。假设您在本地运行这些示例,这只是“localhost”的另一个别名。在大多数基于 Unix 的操作系统上,这可以添加以下行到 /etc/hosts
文件中:
127.0.0.1 authserver
如果修改 /etc/hosts
在您的计算机上不起作用,请参阅操作系统的文档,查找如何创建自定义主机的详细信息。
基于基本 URL,Spring Security 的 OAuth 2 客户端,将假定授权 URL、令牌 URL 和其他授权服务器配置信息都使用默认值。但是,如果您正在使用的授权服务器不想使用这些默认值,您可以明确地配置授权详细信息,如下所示:
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: sub
我们已经看到过这些 URI 中的大多数,例如授权、令牌和 JWK 集 URI。然而,user-info-uri 是新的。这个 URI 用来获取基本用户信息,尤其是用户的用户名。对该 URI 的请求应返回 JSON,包含 user-name-attribute 属性以标识用户。但是,请注意,在使用 Spring Authorization Server 时,不需要创建该 URI 的端点;Spring Authorization Server 将自动地公开用户信息端点。
现在,应用程序进行权限验证,以及从授权服务器获取访问令牌的所有部分都已就绪。不需要做更多的事情,您就可以启动应用程序。向应用程序上的任何 URL 发出请求,将重定向到授权服务器以进行权限申请,当授权服务器重定向回来时,Spring Security’s OAuth 2 的内部将在重定向中接收到的授权码交换访问令牌。现在,我们如何使用该令牌呢?
假设我们有一个服务,它使用 RestTemplate 调用 Taco Cloud API。下面的 RestIngredientService 实现展示了这样一个类。类中提供了两种方法:一个用于获取配料列表,另一种用于保存新的配料:
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);
}
}
对 /ingredients
端点的 HTTP GET 请求是不安全的,因为只要 Taco Cloud API 在本地主机端口 8080 上侦听, findAll()
方法就可以正常工作。但是 addIngredient()
方法可能会因 HTTP 401 响应而失败,因为我们已保护 /ingredients
, 要求“writeIngredients”作用域。唯一的办法是在请求头的 Authorization 字段中提交具有“writeIngredients”作用域的访问令牌。
幸运的是,Spring Security 的 OAuth 2 客户端应该在完成授权码流之后就有了访问令牌。我们需要做的就是确保访问令牌在请求中出现。为此,我们将构造函数修改一下,把拦截器附加到它创建的 RestTemplate 上:
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;
}
构造函数现在接受一个 String 参数,以传入访问令牌。使用这个令牌,它附加一个客户端请求拦截器,该拦截器将 Authorization 请求头添加到每个请求中,通过 RestTemplate,请求头的值为“Bearer”,后边跟着令牌。为了保持构造函数整洁,客户机拦截器是在单独的私有方法中创建的。
只剩下一个问题:访问令牌来自哪里?下面的 bean 方法是这件事发生的地方:
@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);
}
首先,请注意 bean 使用了 @RequestScope
注解声明具有请求作用域功能。这意味着每个请求都将创建一个新的 bean 实例。bean 必须是请求作用域,因为它需要从 SecurityContext 拉取授权。这是由 Spring Security 的一个过滤器在每次请求时填充的。应用程序启动时没有 SecurityContext,创建的是默认作用域的 bean。
在返回 RestIngredientService 实例之前,bean 方法检查身份验证情况,这是作为 OAuth2AuthenticationToken 实现的。如果通过,那就意味着它将拥有令牌。然后验证令牌是否用于名为“taco-admin-client”的客户端。如果是这样,则从授权客户端提取令牌并传递给 RestIngredientService 的构造函数。有了令牌,RestIngredientService 在向 Taco Cloud API 发出请求时不会遇到任何问题,这也代表用户正确授权了应用程序。