开发客户端

在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 Security本身传递性地引入。这样我们就可以为应用编写一些安全配置了。如下的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

这样会注册一个名为taco-admin-client的Spring Security OAuth 2客户端。注册细节包括客户端的凭证(client-id和client-secret属性)、授权类型(authorization-grant-type属性)、请求的scope(scope属性),以及重定向URL(redirect-uri)。请注意,设置给redirect-uri属性的值中包含了一个占位符,它引用了客户端的注册ID,也就是taco-admin-client。因此,重定向URL实际被设置为http://127.0.0.1:9090/login/oauth2/code/taco-admin-client,这与我们之前所配置的OAuth 2路径是相同的。

那么,怎么设置授权服务器?客户端应该将用户的浏览器重定向到何处?这就是provider属性的用武之地了,只不过它是间接配置的。在这里,provider属性设置为tacocloud,这是对一套单独配置的引用,这套配置描述了tacocloud provider的授权服务器。这个provider也在相同的application.yml文件中进行了配置,如下所示:

spring:
  security:
    oauth2:
      client:
...
        provider:
          tacocloud:
            issuer-uri: http://authserver:9000

在这里,provider唯一要配置的属性是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将自动暴露user-info端点。

现在所有的组成部分都已准备就绪,应用可以从授权服务器上进行认证并获得访问令牌。我们不需要再做任何事情了,启动应用并向应用的任意URL发送请求,它会重定向到授权服务器进行授权。授权服务器重定向回来时,Spring Security的OAuth 2客户端库会在内部将重定向中收到的授权码替换成一个访问令牌。那么,我们该如何使用这个令牌呢?

假设我们有一个服务bean,它会使用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请求未被保护,所以findAll()方法就能正常完成我们的要求,只要Taco Cloud API监听localhost的8080端口。但是,addIngredient()可能会因为HTTP 401响应而失败,因为我们对“/ingredients”的POST请求进行了保护,使其需要writeIngredients scope。唯一的解决办法是在请求的Authorization头信息中提交一个scope为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”开始,随后是实际的令牌。为了保持构造函数的整洁,客户端拦截器是在一个单独的private辅助方法中创建的。

现在,还有一个问题:访问令牌是从哪里来呢?正是下面的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中获取认证信息,而SecurityContext是由Spring Security的某个过滤器在每个请求中填充的。默认作用域的bean是在应用程序启动时创建的,此时并没有SecurityContext。

在返回RestIngredientService实例之前,bean方法会检查认证是不是以OAuth2AuthenticationToken的方式实现的。如果是,那么它会有一个令牌。然后,我们检查认证令牌是不是为“taco-admin-client”客户端创建的。如果是,就从授权的客户端中提取令牌,并将其以构造函数的形式传递给RestIngredientService。有了令牌,RestIngredientService就可以毫无阻碍地向Taco Cloud API的端点发送请求了。这个过程中,RestIngredient Service代表的就是授予该应用权限的用户。

小结

  • OAuth 2是确保API安全的一种常见方式,比简单的HTTP Basic认证更强大。

  • 授权服务器为客户端发放访问令牌,客户端在向API发送请求时能够代表用户行事,而在客户端令牌流程中,客户端发送请求时则代表客户端本身。

  • 资源服务器位于API之前,在访问API资源时,能够验证请求是否提供了包含所需scope的、合法的、未过期的令牌。

  • Spring Authorization Server是一个实验性的项目,实现了OAuth 2授权服务器。

  • Spring Security提供了对创建资源服务器和客户端的支持,客户端能够从授权服务器获得访问令牌,并在向资源服务器发出请求时传递这些令牌。