Spring Security 5 替代 OAuth2RestTemplate 的解决方案

76
spring-security-oauth2:2.4.0.RELEASE 中,诸如 OAuth2RestTemplateOAuth2ProtectedResourceDetailsClientCredentialsAccessTokenProvider 等类都已被标记为过时。
从这些类的 javadoc 中可以看出,它们指向了一个 spring security 迁移指南,暗示人们应该迁移到核心的 spring-security 5 项目。然而,我在这个项目中找不到如何实现我的用例的方法。
所有的文档和示例都是关于与第三方 OAuth 提供者集成,如果你希望对你的应用程序的传入请求进行身份验证,并且希望使用第三方 OAuth 提供者来验证身份。
在我的使用案例中,我只想使用RestTemplate向受OAuth保护的外部服务发送请求。目前,我创建了一个带有客户端ID和密钥的OAuth2ProtectedResourceDetails,并将其传递给OAuth2RestTemplate。我还在OAuth2RestTemplate中添加了一个自定义的ClientCredentialsAccessTokenProvider,它只是在令牌请求中添加了一些额外的头信息,这些头信息是我使用的OAuth提供程序所需的。
在spring-security 5的文档中,我找到了一个提到自定义令牌请求的部分,但是这似乎是在认证来自第三方OAuth提供程序的传入请求的上下文中。不清楚如何将其与ClientHttpRequestInterceptor结合使用,以确保每个发送到外部服务的请求首先获取一个令牌,然后将其添加到请求中。
此外,在上面链接的迁移指南中,还提到了一个OAuth2AuthorizedClientService,它说这对于在拦截器中使用很有用。但是,这似乎又依赖于像ClientRegistrationRepository这样的东西,如果您想使用该提供程序来确保传入请求已经过身份验证,那么它似乎是维护第三方提供程序的注册的地方。
有没有办法利用spring-security 5中的新功能来注册OAuth提供程序,以便从我的应用程序中获取令牌并添加到外发请求中?
5个回答

62

Spring Security 5.2.x的OAuth 2.0客户端功能不支持RestTemplate,只支持WebClient。请参见Spring安全参考文档

HTTP客户端支持

  • 用于Servlet环境的WebClient集成(用于请求受保护的资源)

此外,RestTemplate将在未来的版本中被弃用。请参见RestTemplate javadoc

注意:从5.0开始,非阻塞式反应org.springframework.web.reactive.client.WebClient提供了现代的替代方案RestTemplate,对于同步和异步以及流式处理场景都具有高效的支持。RestTemplate将在未来的版本中被弃用,并且不会在今后添加重要的新功能。有关更多详细信息和示例代码,请参见Spring框架参考文档中的WebClient部分。

因此,最佳解决方案是放弃使用RestTemplate,转而使用WebClient


使用WebClient进行客户端凭证流程

通过编程或使用Spring Boot自动配置来配置客户端注册和提供程序:

spring:
  security:
    oauth2:
      client:
        registration:
          custom:
            client-id: clientId
            client-secret: clientSecret
            authorization-grant-type: client_credentials
        provider:
          custom:
            token-uri: http://localhost:8081/oauth/token

...以及 OAuth2AuthorizedClientManager@Bean:

@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
        ClientRegistrationRepository clientRegistrationRepository,
        OAuth2AuthorizedClientRepository authorizedClientRepository) {

    OAuth2AuthorizedClientProvider authorizedClientProvider =
            OAuth2AuthorizedClientProviderBuilder.builder()
                    .clientCredentials()
                    .build();

    DefaultOAuth2AuthorizedClientManager authorizedClientManager =
            new DefaultOAuth2AuthorizedClientManager(
                    clientRegistrationRepository, authorizedClientRepository);
    authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

    return authorizedClientManager;
}

配置 WebClient 实例,使用提供的 OAuth2AuthorizedClientManagerServerOAuth2AuthorizedClientExchangeFilterFunction

@Bean
WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
    ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
            new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
    oauth2Client.setDefaultClientRegistrationId("custom");
    return WebClient.builder()
            .apply(oauth2Client.oauth2Configuration())
            .build();
}

现在,如果您尝试使用此 WebClient 实例进行请求,它将首先从授权服务器请求令牌,并将其包含在请求中。


1
现在那个也被弃用了,哈哈... 至少UnAuthenticatedServerOAuth2AuthorizedClientRepository已经废弃了... - SledgeHammer
3
“因此,最好的解决方案是放弃RestTemplate,转而使用WebClient。”那么在这不是一个选项的地方怎么办?例如,Spring Cloud Discovery、Configuration和Feign客户端仍然依赖于RestTemplate,并且文档指出如果您计划为这些服务添加OAuth等安全性,则需要提供自定义RestTemplate。 - loesak
@AnarSultanov 我尝试了你给出的确切示例,但我收到了401错误。似乎在尝试执行请求时没有进行身份验证。有什么建议吗? - rafael.braga
@rafael.braga,若没有看到所有的代码和配置,我无法做出任何推荐。你可以尝试使用官方存储库中的示例,并根据自己的需要进行适应:https://github.com/spring-projects/spring-security/tree/master/samples/boot/oauth2webclient - Anar Sultanov
1
这是相关的Spring Security文档。提供了更多关于不同配置WebClient的方式的细节和解释: https://docs.spring.io/spring-security/site/docs/5.2.1.RELEASE/reference/htmlsingle/#oauth2Client-webclient-servlet - Crafton
不错。当涉及到OAuth2 client_credentials流程时,我会使用AuthorizedClientServiceOAuth2AuthorizedClientManager而不是DefaultOAuth2AuthorizedClientManager。前者“能够在HttpServletRequest的上下文之外运行”,这意味着它不绑定于传入请求。 - egelev

36

你好,也许现在已经太晚了,但是RestTemplate仍然受到Spring Security 5的支持,对于非响应式应用程序,仍然使用RestTemplate,您需要做的就是正确配置Spring Security并根据迁移指南中提到的创建拦截器。

使用以下配置来使用client_credentials流程

application.yml

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: ${okta.oauth2.issuer}/v1/keys
      client:
        registration:
          okta:
            client-id: ${okta.oauth2.clientId}
            client-secret: ${okta.oauth2.clientSecret}
            scope: "custom-scope"
            authorization-grant-type: client_credentials
            provider: okta
        provider:
          okta:
            authorization-uri: ${okta.oauth2.issuer}/v1/authorize
            token-uri: ${okta.oauth2.issuer}/v1/token

配置 OauthResTemplate

@Configuration
@RequiredArgsConstructor
public class OAuthRestTemplateConfig {

    public static final String OAUTH_WEBCLIENT = "OAUTH_WEBCLIENT";

    private final RestTemplateBuilder restTemplateBuilder;
    private final OAuth2AuthorizedClientService oAuth2AuthorizedClientService;
    private final ClientRegistrationRepository clientRegistrationRepository;

    @Bean(OAUTH_WEBCLIENT)
    RestTemplate oAuthRestTemplate() {
        var clientRegistration = clientRegistrationRepository.findByRegistrationId(Constants.OKTA_AUTH_SERVER_ID);

        return restTemplateBuilder
                .additionalInterceptors(new OAuthClientCredentialsRestTemplateInterceptorConfig(authorizedClientManager(), clientRegistration))
                .setReadTimeout(Duration.ofSeconds(5))
                .setConnectTimeout(Duration.ofSeconds(1))
                .build();
    }

    @Bean
    OAuth2AuthorizedClientManager authorizedClientManager() {
        var authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder()
                .clientCredentials()
                .build();

        var authorizedClientManager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(clientRegistrationRepository, oAuth2AuthorizedClientService);
        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

        return authorizedClientManager;
    }

}

拦截器

public class OAuthClientCredentialsRestTemplateInterceptor implements ClientHttpRequestInterceptor {

    private final OAuth2AuthorizedClientManager manager;
    private final Authentication principal;
    private final ClientRegistration clientRegistration;

    public OAuthClientCredentialsRestTemplateInterceptor(OAuth2AuthorizedClientManager manager, ClientRegistration clientRegistration) {
        this.manager = manager;
        this.clientRegistration = clientRegistration;
        this.principal = createPrincipal();
    }

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
        OAuth2AuthorizeRequest oAuth2AuthorizeRequest = OAuth2AuthorizeRequest
                .withClientRegistrationId(clientRegistration.getRegistrationId())
                .principal(principal)
                .build();
        OAuth2AuthorizedClient client = manager.authorize(oAuth2AuthorizeRequest);
        if (isNull(client)) {
            throw new IllegalStateException("client credentials flow on " + clientRegistration.getRegistrationId() + " failed, client is null");
        }

        request.getHeaders().add(HttpHeaders.AUTHORIZATION, BEARER_PREFIX + client.getAccessToken().getTokenValue());
        return execution.execute(request, body);
    }

    private Authentication createPrincipal() {
        return new Authentication() {
            @Override
            public Collection<? extends GrantedAuthority> getAuthorities() {
                return Collections.emptySet();
            }

            @Override
            public Object getCredentials() {
                return null;
            }

            @Override
            public Object getDetails() {
                return null;
            }

            @Override
            public Object getPrincipal() {
                return this;
            }

            @Override
            public boolean isAuthenticated() {
                return false;
            }

            @Override
            public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
            }

            @Override
            public String getName() {
                return clientRegistration.getClientId();
            }
        };
    }
}

这将在第一次调用时生成access_token,并在令牌过期时重新生成。OAuth2AuthorizedClientManager将为您管理所有这些。


我喜欢这种方法,因为它与Spring Security的DefaultClientCredentialsTokenResponseClient一致,后者在内部使用RestTemplate。这意味着我的项目不需要依赖于spring-webflux。 - Nathan
1
最佳解决方案。简洁而且像魔法一样好用。即使Spring文档也没有提供如此清晰的操作指南。 - Walnussbär
1
仅仅是一个小提示,根据源代码来看,我认为 AuthorizedClientServiceOAuth2AuthorizedClientManager 并不是严格的线程安全。虽然这不会导致应用程序崩溃,但如果同时处理多个请求并且它们都使用即将过期/已过期的令牌,则会发出多个获取新令牌的调用。 - Alexis
@Alexis 我们如何避免这种情况?我的意思是不使用已过期的内容? - John Roshan
@JohnRoshan 它会在需要时自动更新令牌,只是在某些边缘情况下可能会并行进行多次更新,其中一些是冗余的。为了避免这种情况,您需要自己进行同步。 - Alexis
@Alexis,我在服务A中实现了常规的Spring Boot OAuthRestTemplates,以便从服务A调用服务B、C、D。由于服务A承载了大量负载,因此它不时地从服务B、C、D面临401错误。 - John Roshan

9

我发现@matt Williams的回答非常有帮助。但是,如果有人想通过编程方式传递clientId和secret进行WebClient配置,则需要添加以下内容。

 @Configuration
    public class WebClientConfig {

    public static final String TEST_REGISTRATION_ID = "test-client";

    @Bean
    public ReactiveClientRegistrationRepository clientRegistrationRepository() {
        var clientRegistration = ClientRegistration.withRegistrationId(TEST_REGISTRATION_ID)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .clientId("<client_id>")
                .clientSecret("<client_secret>")
                .tokenUri("<token_uri>")
                .build();
        return new InMemoryReactiveClientRegistrationRepository(clientRegistration);
    }

    @Bean
    public WebClient testWebClient(ReactiveClientRegistrationRepository clientRegistrationRepo) {

        var oauth = new ServerOAuth2AuthorizedClientExchangeFilterFunction(clientRegistrationRepo,  new UnAuthenticatedServerOAuth2AuthorizedClientRepository());
        oauth.setDefaultClientRegistrationId(TEST_REGISTRATION_ID);

        return WebClient.builder()
                .baseUrl("https://.test.com")
                .filter(oauth)
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
    }
}

上面的代码片段有没有可以测试的示例代码? - Sagar Pilkhwal
@SagarPilkhwal 你可以创建一个简单的基于spring-security的样例spring boot应用程序(你可以在网上轻松找到)。在那里设置基于client_credentials的访问并公开一个测试API。然后,你可以使用上述代码创建WebClient并尝试调用该API。 - Jogger

5

@Anar Sultanov的答案帮助我解决了问题,但是因为我需要在OAuth令牌请求中添加一些额外的标头,所以我想提供一个完整的答案来解决我的问题。

配置提供程序详细信息

将以下内容添加到application.properties中:

spring.security.oauth2.client.registration.uaa.client-id=${CLIENT_ID:}
spring.security.oauth2.client.registration.uaa.client-secret=${CLIENT_SECRET:}
spring.security.oauth2.client.registration.uaa.scope=${SCOPE:}
spring.security.oauth2.client.registration.uaa.authorization-grant-type=client_credentials
spring.security.oauth2.client.provider.uaa.token-uri=${UAA_URL:}

实现自定义ReactiveOAuth2AccessTokenResponseClient

由于这是服务器之间的通信,我们需要使用ServerOAuth2AuthorizedClientExchangeFilterFunction。它只接受ReactiveOAuth2AuthorizedClientManager,而不是非响应式的OAuth2AuthorizedClientManager。因此,当我们使用ReactiveOAuth2AuthorizedClientManager.setAuthorizedClientProvider()(为其提供用于进行OAuth2请求的提供程序)时,我们必须给它一个ReactiveOAuth2AuthorizedClientProvider,而不是非响应式的OAuth2AuthorizedClientProvider。根据spring-security参考文档,如果您使用非响应式的DefaultClientCredentialsTokenResponseClient,则可以使用.setRequestEntityConverter()方法来更改OAuth2令牌请求,但是响应式等效的WebClientReactiveClientCredentialsTokenResponseClient不提供此功能,因此我们必须实现自己的(可以利用现有的WebClientReactiveClientCredentialsTokenResponseClient逻辑)。

我的实现称为UaaWebClientReactiveClientCredentialsTokenResponseClient(省略了实现,因为它只是从默认的WebClientReactiveClientCredentialsTokenResponseClient轻微地更改了headers()body()方法,以添加一些额外的头/正文字段,它不会更改底层的身份验证流程)。

配置WebClient

ServerOAuth2AuthorizedClientExchangeFilterFunction.setClientCredentialsTokenResponseClient()方法已被弃用,因此按照该方法的弃用建议:

已弃用。请改用ServerOAuth2AuthorizedClientExchangeFilterFunction(ReactiveOAuth2AuthorizedClientManager)。创建一个配置了WebClientReactiveClientCredentialsTokenResponseClient(或自定义的)的ClientCredentialsReactiveOAuth2AuthorizedClientProvider实例,并将其提供给DefaultReactiveOAuth2AuthorizedClientManager

这将导致配置类似于:

@Bean("oAuth2WebClient")
public WebClient oauthFilteredWebClient(final ReactiveClientRegistrationRepository 
    clientRegistrationRepository)
{
    final ClientCredentialsReactiveOAuth2AuthorizedClientProvider
        clientCredentialsReactiveOAuth2AuthorizedClientProvider =
            new ClientCredentialsReactiveOAuth2AuthorizedClientProvider();
    clientCredentialsReactiveOAuth2AuthorizedClientProvider.setAccessTokenResponseClient(
        new UaaWebClientReactiveClientCredentialsTokenResponseClient());

    final DefaultReactiveOAuth2AuthorizedClientManager defaultReactiveOAuth2AuthorizedClientManager =
        new DefaultReactiveOAuth2AuthorizedClientManager(clientRegistrationRepository,
            new UnAuthenticatedServerOAuth2AuthorizedClientRepository());
    defaultReactiveOAuth2AuthorizedClientManager.setAuthorizedClientProvider(
        clientCredentialsReactiveOAuth2AuthorizedClientProvider);

    final ServerOAuth2AuthorizedClientExchangeFilterFunction oAuthFilter =
        new ServerOAuth2AuthorizedClientExchangeFilterFunction(defaultReactiveOAuth2AuthorizedClientManager);
    oAuthFilter.setDefaultClientRegistrationId("uaa");

    return WebClient.builder()
        .filter(oAuthFilter)
        .build();
}

像正常使用WebClient一样使用

oAuth2WebClient bean现在已经准备好被用来访问受我们配置的OAuth2提供者保护的资源,你可以像使用WebClient发起任何其他请求一样使用它。


如何在程序中通过编程方式传递客户端ID、客户端密钥和OAuth终端点? - monti
1
我没有尝试过这个,但看起来你可以创建 ClientRegistration 的实例,并将其所需的详细信息传递到 InMemoryReactiveClientRegistrationRepository 的构造函数中(ReactiveClientRegistrationRepository 的默认实现)。然后,您可以使用新创建的 InMemoryReactiveClientRegistrationRepository bean 替换我的自动装配的 clientRegistrationRepository,并将其传递到 oauthFilteredWebClient 方法中。 - Matt Williams
嗯,但我不能在运行时注册不同的ClientRegistration,是吗?据我所知,我需要在启动时创建一个ClientRegistration的bean。 - monti
啊,好的,我以为你只是想在application.properties文件中不声明它们。 实现自己的ReactiveOAuth2AccessTokenResponseClient允许您进行任何请求以获取OAuth2令牌,但我不知道如何为每个请求提供动态的“上下文”。如果您实现了自己的整个过滤器,情况也是如此。所有这些都将使您可以访问出站请求,因此,除非您可以从中推断出所需内容,否则我不确定您的选择是什么。 你的用例是什么?为什么您在启动时不知道可能的注册信息? - Matt Williams

3
这是一个简单的替代方案,用于OAuth2RestTemplate。以下代码片段已经使用Spring Boot 3.0.0-M4进行了测试,并且不需要application.yml配置。
SecurityConfig.java
    @Bean
    public ReactiveClientRegistrationRepository getRegistration() {
        ClientRegistration registration = ClientRegistration
                .withRegistrationId("custom")
                .tokenUri("<token_URI>")
                .clientId("<client_id>")
                .clientSecret("<secret>")
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .build();
        return new InMemoryReactiveClientRegistrationRepository(registration);
    }

    @Bean
    public WebClient webClient(ReactiveClientRegistrationRepository clientRegistrations) {
        InMemoryReactiveOAuth2AuthorizedClientService clientService = new InMemoryReactiveOAuth2AuthorizedClientService(clientRegistrations);
        AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager authorizedClientManager = new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(clientRegistrations, clientService);
        ServerOAuth2AuthorizedClientExchangeFilterFunction oauth = new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
        oauth.setDefaultClientRegistrationId("custom");
        return WebClient.builder()
                .filter(oauth)
                .filter(errorHandler()) // This is an optional
                .build();

    }

    public static ExchangeFilterFunction errorHandler() {
        return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> {

            if (clientResponse.statusCode().is5xxServerError() || clientResponse.statusCode().is4xxClientError()) {
                return clientResponse.bodyToMono(String.class)
                        .flatMap(errorBody -> Mono.error(new IllegalAccessException(errorBody)));
            } else {
                return Mono.just(clientResponse);
            }
        });
    }

pom.xml

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.0.0-M4</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-jose</artifactId>
        </dependency>
    <dependencies>

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接