使用 Spring Security 5 集成 OAuth 2 安全服务,如 Facebook 和 GitHub

工程 | Craig Walls | 2018年3月6日 | ...

Spring Security 5 的主要功能之一是支持编写与通过 OAuth 2 保护的服务集成的应用程序。这包括通过外部服务(如 Facebook 或 GitHub)登录应用程序的功能。

但是,只需少量额外代码,您还可以获取 OAuth 2 访问令牌,该令牌可用于对服务 API 执行授权请求。

在本文中,我们将探讨如何使用 Spring Security 5 开发一个与 Facebook 集成的 Spring Boot 应用程序。您可以在 https://github.com/habuma/facebook-security5 找到本文的完整代码。

启用 OAuth 2 登录

假设您希望应用程序的用户能够使用 Facebook 登录。使用 Spring Security 5,这变得异常简单。您只需将 Spring Security 的 OAuth 2 客户端支持添加到项目的构建中,然后配置应用程序的 Facebook 凭据即可。

首先,将 Spring Security OAuth 2 客户端库以及 Spring Security 启动器依赖项添加到 Spring Boot 项目的构建中

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-oauth2-client</artifactId>
</dependency>

然后,您需要配置应用程序的客户端 ID 和客户端密钥(您可以通过在 https://developers.facebook.com/ 上注册应用程序来获取)。所有 OAuth 2 客户端的属性都以 spring.security.oauth2.client.registration 为前缀。对于 Facebook,您将在此前缀下添加 facebook.client-idfacebook-client-secret 属性。在项目的 application.yml 文件中,它看起来会像这样

spring:
  security:
    oauth2:
      client:
        registration:
          facebook:
            client-id: YOUR CLIENT ID GOES HERE
            client-secret: YOUR CLIENT SECRET GOES HERE

您也可以将这些属性设置为环境变量、属性文件或 Spring Boot 支持的任何属性源。当然,您会用自己应用程序的客户端 ID 和密钥替换上面 YAML 中显示的占位符文本。

有了 OAuth 2 客户端依赖项和这些属性设置后,您的应用程序现在将通过 Facebook 提供身份验证。当您尝试访问一个尚未经过身份验证的页面时,您会看到一个类似于以下页面

FB Link

此页面为您提供了使用任何已配置的 OAuth 2 客户端登录的机会。出于我们的目的,Facebook 是唯一的选项。

点击 Facebook 链接后,您将被重定向到 Facebook。如果您尚未登录 Facebook,系统会提示您登录。登录后,并假设您尚未授权此应用程序,您将看到一个授权提示,它看起来会像这样

FB Authorities

如果您选择继续(通过点击“继续”按钮),您将被重定向回您的应用程序并完成身份验证。(如果您选择“取消”,您也将被重定向回应用程序,但不会成功通过身份验证。)

使用像 Facebook 这样的外部服务进行身份验证是传统应用程序登录的一个不错的替代方案。但这只是故事的一半。一旦用户登录,您还可以使用该身份验证来访问远程服务 API 上的资源。

访问 API 资源

成功通过外部 OAuth 2 服务进行身份验证后,安全上下文中保存的 Authentication 对象实际上是 OAuth2AuthenticationToken,它在 OAuth2AuthorizedClientService 的帮助下,可以为我们提供一个访问令牌,用于向服务 API 发出请求。

Authentication 可以通过多种方式获取,包括通过 SecurityContextHolder。一旦您有了 Authentication,就可以将其转换为 OAuth2AuthenticationToken

Authentication authentication =
    SecurityContextHolder
        .getContext()
        .getAuthentication();

OAuth2AuthenticationToken oauthToken =
    (OAuth2AuthenticationToken) authentication;

Spring 应用程序上下文中将自动配置一个 OAuth2AuthorizedClientService 作为 bean,因此您只需将其注入到您将使用它的任何地方。

OAuth2AuthorizedClient client =
    clientService.loadAuthorizedClient(
            oauthToken.getAuthorizedClientRegistrationId(),
            oauthToken.getName());

String accessToken = client.getAccessToken().getTokenValue();

调用 loadAuthorizedClient() 时会提供客户端的注册 ID,这是客户端凭据在配置中注册的方式——在我们的例子中是“facebook”。第二个参数是用户的用户名。本质上,我们要求客户端服务加载给定用户和给定服务的 OAuth2AuthorizedClient。获得 OAuth2AuthorizedClient 后,通过调用 getAccessToken().getTokenValue() 获取访问令牌值就变得很简单。

我们可以应用此技术来充实服务的客户端 API 绑定。首先,我们将创建一个基础 API 绑定类来处理确保在所有请求中包含访问令牌的基本任务

public abstract class ApiBinding {

  protected RestTemplate restTemplate;

  public ApiBinding(String accessToken) {
    this.restTemplate = new RestTemplate();
    if (accessToken != null) {
      this.restTemplate.getInterceptors()
          .add(getBearerTokenInterceptor(accessToken));
    } else {
      this.restTemplate.getInterceptors().add(getNoTokenInterceptor());
    }
  }

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

  private ClientHttpRequestInterceptor getNoTokenInterceptor() {
    return new ClientHttpRequestInterceptor() {
      @Override
      public ClientHttpResponse intercept(HttpRequest request, byte[] bytes,
                  ClientHttpRequestExecution execution) throws IOException {
        throw new IllegalStateException(
                "Can't access the API without an access token");
      }
    };
  }

}

ApiBinding 类最重要的部分是 getBearerTokenInterceptor() 方法,在该方法中为 RestTemplate 创建了一个请求拦截器,以确保在所有对 API 的请求中都包含给定的访问令牌。但是,如果给定的访问令牌为 null,则特殊的请求拦截器将抛出 IllegalStateException,甚至不会尝试进行 API 请求。对于大多数需要所有请求都经过授权的 API 来说,这是可以接受甚至值得的行为。

现在我们可以基于 ApiBinding 基类编写 Facebook API 绑定

public class Facebook extends ApiBinding {

  private static final String GRAPH_API_BASE_URL =
              "https://graph.facebook.com/v2.12";

  public Facebook(String accessToken) {
    super(accessToken);
  }

  public Profile getProfile() {
    return restTemplate.getForObject(
            GRAPH_API_BASE_URL + "/me", Profile.class);
  }

  public List<Post> getFeed() {
    return restTemplate.getForObject(
            GRAPH_API_BASE_URL + "/me/feed", Feed.class).getData();
  }

}

如您所见,Facebook 类相当简单。所有 OAuth 2 的具体细节都包含在 ApiBinding 中,因此这个类可以专注于发出请求以支持应用程序所需的操作。

现在我们只需要配置一个 Facebook bean。该 bean 将是请求范围的,以允许根据用户 Authentication 中的访问令牌创建实例

@Configuration
public class SocialConfig {

  @Bean
  @RequestScope
  public Facebook facebook(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("facebook")) {
        OAuth2AuthorizedClient client = clientService.loadAuthorizedClient(
                    clientRegistrationId, oauthToken.getName());
        accessToken = client.getAccessToken().getTokenValue();
      }
    }
    return new Facebook(accessToken);
  }

}

此外,由于 Facebook API 绑定中的 getFeed() 方法从用户订阅源中获取数据,因此在验证用户时,我们需要将 spring.security.oauth2.client.registration.facebook.scope 设置为指定“user_posts”范围

spring:
  security:
    oauth2:
      client:
        registration:
          facebook:
            client-id: YOUR CLIENT ID GOES HERE
            client-secret: YOUR CLIENT SECRET GOES HERE
            scope: user_posts

更灵活的 API 绑定

您可能想知道这与 Spring Social 有何关系,Spring Social 也支持使用外部服务登录以及为 Facebook 提供 API 绑定。

Spring Social 通过 ProviderSignInControllerSocialAuthenticationFilter 提供登录支持。这两个实现都利用 ConnectionFactory 为外部服务提供 ServiceProvider。Spring Social 的每个 API 绑定都必须提供 ConnectionFactoryServiceProvider 的 API 特定实现。这限制了 Spring Social 只能支持使用那些已提供 ConnectionFactoryServiceProvider 实现的服务进行登录。

相比之下,Spring Security 5 能够通过在配置中简单地提供服务详细信息来支持使用几乎任何 OAuth 2 或 OpenID Connect 服务进行登录。Spring Security 5 开箱即用地为 Facebook、Google、GitHub 和 Okta 提供了基线配置(您只需指定客户端 ID 和密钥)。但是,如果您必须与另一个服务集成,则只需在应用程序配置中指定服务的详细信息(例如授权 URL)。

至于 API 绑定,Spring Social 的 API 绑定非常庞大,涵盖了它们所针对的 API 提供的大部分功能。但实际上,大多数应用程序只需要 Spring Social 支持的操作的一小部分。如果您只需要获取用户的订阅源,为什么必须使用一个提供数百个其他操作的庞大 API 绑定呢?同样,如果您只关心帖子响应的一两个属性,为什么还要处理一个全面涵盖 Facebook Graph API 所提供内容的 Post 对象呢?在许多这样的情况下,编写一个针对您应用程序需求量身定制的 API 绑定可能更容易。

此外,Spring Social 的 API 绑定都使用了底层 RestTemplate。如果您更喜欢使用非阻塞响应式 API 绑定,那您就没辙了。将 API 绑定改造为基于 WebClient 并非易事,并且实际上会使这些 API 绑定的维护工作量增加一倍。

但是,如果您开发了自己的 API 绑定,那么将 RestTemplate 替换为响应式 WebClient 就足够简单了,如这里的 ReactiveApiBinding 所示

public abstract class ReactiveApiBinding {
  protected WebClient webClient;

  public ReactiveApiBinding(String accessToken) {
    Builder builder = WebClient.builder();
    if (accessToken != null) {
      builder.defaultHeader("Authorization", "Bearer " + accessToken);
    } else {
      builder.exchangeFunction(
          request -> {
            throw new IllegalStateException(
                    "Can't access the API without an access token");
          });
    }
    this.webClient = builder.build();
  }
}

您甚至可以在同一个 API 绑定中混合使用 WebClientRestTemplate,在需要时应用非阻塞的 WebClient,在同步请求足够时应用 RestTemplate

总结

Spring Security 5 对 OAuth 2 的客户端支持提供了通过外部服务登录的能力,以及使用从身份验证获得的令牌来消费该服务 API 的能力。这只是协调 Spring OAuth 故事的第一步,该故事目前分散在 Spring Social 和 Spring Security OAuth 等多个项目中。

Spring Security 的未来版本将继续改进 OAuth 2 客户端支持,并采取措施协调 Spring 在 OAuth 安全服务器端的故事。事实上,Spring Security 5.1.0 目前正在进行的工作旨在使 API 操作更加简单,有效消除本文中显示的 ApiBinding 类和 Facebook bean 配置中的大部分管道代码。敬请关注!

获取 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,助您加速进步。

了解更多

获得支持

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件,只需一份简单的订阅。

了解更多

即将举行的活动

查看 Spring 社区所有即将举行的活动。

查看所有