【问题标题】:Why Spring Boot WebClient OAuth2 (client_credentials) asks for a new token for each request?为什么 Spring Boot WebClient OAuth2 (client_credentials) 要求为每个请求提供一个新令牌?
【发布时间】:2020-03-08 17:34:20
【问题描述】:

我正在尝试创建一个 Spring Boot REST 应用程序,该应用程序必须对另一个受 OAuth2 保护的应用程序进行远程 REST 调用。

第一个应用程序使用 Reactive WebClient 调用第二个 OAuth2 REST 应用程序。我已经用 grant_type "client_credentials" 配置了 WebClient。

application.yml

spring:
  security:
    oauth2:
      client:
        provider:
          client-registration-id:
            token-uri: http://localhost:8080/oauth/token
        registration:
          client-registration-id:
            authorization-grant-type: client_credentials
            client-id: public
            client-secret: test
            client-authentication-method: post
            scope: myscope
@Configuration
public class WebClientConfig {

    @Bean
    WebClient webClient(ReactiveClientRegistrationRepository clientRegistrations) {
        ServerOAuth2AuthorizedClientExchangeFilterFunction oauth = new ServerOAuth2AuthorizedClientExchangeFilterFunction(
                clientRegistrations, new UnAuthenticatedServerOAuth2AuthorizedClientRepository());
        oauth.setDefaultClientRegistrationId("client-registration-id");
        return WebClient.builder().filter(oauth).build();
    }

}
@Component
public class WebClientChronJob {

    Logger logger = LoggerFactory.getLogger(WebClientChronJob.class);

    @Autowired
    private WebClient webClient;

    @Scheduled(fixedRate = 5000)
    public void logResourceServiceResponse() {

        webClient.get()
                .uri("http://localhost:8080/test")
                .retrieve()
                .bodyToMono(String.class)
                .map(string -> "RESPONSE: " + string)
                .subscribe(logger::info);
    }

}

根据此链接Baeldung Spring Webclient Oauth2 上的文章,WebClientChronJob 第二次运行时,应用程序应该先请求资源,而无需先请求令牌,因为最后一个令牌尚未过期。不幸的是,启用调试日志时,我注意到相反的情况:每次作业请求资源时,它都在请求新令牌。如果配置或代码中缺少某些内容,请告诉我。

Netty started on port(s): 8082
Started MyApp in 2.242 seconds (JVM running for 2.717)
HTTP POST http://localhost:8080/oauth/token
Writing form fields [grant_type, scope, client_id, client_secret] (content masked)
Response 200 OK
Decoded [{access_token=nrLr7bHpV0aqr5cQNhv0NjJYvVv3bv, token_type=Bearer, expires_in=86400, scope=rw:profile  (truncated)...]
Cancel signal (to close connection)
HTTP GET http://localhost:8080/test
Response 200 OK
Decoded "{"status":{"description":"ok","success":true},"result":[]}"
ESPONSE: {"status":{"description":"ok","success":true},"result":[]}
HTTP POST http://localhost:8080/oauth/token
Writing form fields [grant_type, scope, client_id, client_secret] (content masked)
Response 200 OK
Decoded [{access_token=CsOxziw6W6J7IoqA8EiF4clhiwVJ8m, token_type=Bearer, expires_in=86400, scope=rw:profile  (truncated)...]
Cancel signal (to close connection)
HTTP GET http://localhost:8080/test
Response 200 OK
Decoded "{"status":{"description":"ok","success":true},"result":[]}"
ESPONSE: {"status":{"description":"ok","success":true},"result":[]}

在我在 pom.xml 中唯一的依赖项下

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

【问题讨论】:

    标签: java spring spring-boot oauth-2.0


    【解决方案1】:

    我找到了解决问题的方法。 Spring Security 版本 5.1.x 的当前 WebClient 实现不会在令牌过期后请求新令牌,并且可能 Spring 的开发人员决定每次都询问令牌。 Spring 的开发人员还决定仅在新版本 5.2.0.M2 或 (M1) 中修复此错误,而不将修复向后移植到 5.1.x

    pom.xml

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
        <parent>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-parent</artifactId>
            <version>2.2.1.RELEASE</version>
        </parent>
        <groupId>net.tuxy</groupId>
        <artifactId>oauth2-client</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <name>MyApp</name>
        <description>Spring Boot WebClient OAuth2 client_credentials example</description>
    
        <repositories>
            <repository>
                <id>spring-snapshots</id>
                <name>Spring Snapshots</name>
                <url>https://repo.spring.io/snapshot</url>
                <snapshots>
                    <enabled>true</enabled>
                </snapshots>
            </repository>
            <repository>
                <id>spring-milestones</id>
                <name>Spring Milestones</name>
                <url>https://repo.spring.io/milestone</url>
            </repository>
        </repositories>
        <pluginRepositories>
            <pluginRepository>
                <id>spring-snapshots</id>
                <name>Spring Snapshots</name>
                <url>https://repo.spring.io/snapshot</url>
                <snapshots>
                    <enabled>true</enabled>
                </snapshots>
            </pluginRepository>
            <pluginRepository>
                <id>spring-milestones</id>
                <name>Spring Milestones</name>
                <url>https://repo.spring.io/milestone</url>
            </pluginRepository>
        </pluginRepositories>
    
        <properties>
            <java.version>11</java.version>
        </properties>
    
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-webflux</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.security</groupId>
                <artifactId>spring-security-oauth2-client</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-security</artifactId>
            </dependency>
    
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
                <scope>test</scope>
            </dependency>
    
            <dependency>
                <groupId>org.springframework.security</groupId>
                <artifactId>spring-security-core</artifactId>
                <version>5.2.0.M2</version>
            </dependency>
            <dependency>
                <groupId>org.springframework.security</groupId>
                <artifactId>spring-security-web</artifactId>
                <version>5.2.0.M2</version>
            </dependency>
            <dependency>
                <groupId>org.springframework.security</groupId>
                <artifactId>spring-security-config</artifactId>
                <version>5.2.0.M2</version>
            </dependency>
            <dependency>
                <groupId>org.springframework.security</groupId>
                <artifactId>spring-security-oauth2-core</artifactId>
                <version>5.2.0.M2</version>
            </dependency>
            <dependency>
                <groupId>org.springframework.security</groupId>
                <artifactId>spring-security-oauth2-client</artifactId>
                <version>5.2.0.M2</version>
            </dependency>
    
        </dependencies>
    
        <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                </plugin>
            </plugins>
        </build>
    
    </project>
    
    

    【讨论】:

      【解决方案2】:

      Spring 继续前进,自从 @angus asked 找到了现在已弃用的 UnAuthenticatedServerOAuth2AuthorizedClientRepository 的替代方法,我想分享我的实现。这里分别使用Spring Boot 2.4.4Spring Security 5.4.5

      recommended alternativeUnAuthenticatedServerOAuth2AuthorizedClientRepositoryAuthorizedClientServiceReactiveOAuth2AuthorizedClientManager。此外,您的 bean 中的 recommended way to provide a WebClient 是通过注入 WebClient.Builder 来实现的。所以,像这样配置你的WebClient.Builder

      @Configuration
      public class OAuth2ClientConfiguration {
      
          @Bean
          public WebClientCustomizer oauth2WebClientCustomizer(
                  ReactiveOAuth2AuthorizedClientManager reactiveOAuth2AuthorizedClientManager) {
              ServerOAuth2AuthorizedClientExchangeFilterFunction oAuth2AuthorizedClientExchangeFilterFunction =
                      new ServerOAuth2AuthorizedClientExchangeFilterFunction(reactiveOAuth2AuthorizedClientManager);
      
              oAuth2AuthorizedClientExchangeFilterFunction.setDefaultClientRegistrationId("api-client");
      
              return webClientBuilder ->
                      webClientBuilder
                              .filter(oAuth2AuthorizedClientExchangeFilterFunction);
          }
      
          @Bean
          public ReactiveOAuth2AuthorizedClientManager reactiveOAuth2AuthorizedClientManager(
                  ReactiveClientRegistrationRepository registrationRepository,
                  ReactiveOAuth2AuthorizedClientService authorizedClientService) {
              AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager authorizedClientManager =
                      new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(
                              registrationRepository, authorizedClientService);
      
              authorizedClientManager.setAuthorizedClientProvider(
                      new ClientCredentialsReactiveOAuth2AuthorizedClientProvider());
      
              return authorizedClientManager;
          }
      }
      

      这就是它的全部,真的。只要授权令牌对资源请求有效,就会只获取一次授权令牌。

      题外话: 如果您想在集成测试中完全阻止对令牌 URI 的授权请求,您可能对this 感兴趣。


      测试

      以下是对应的src/test/resources/application.yml 和使用MockServer 模拟资源服务器和授权服务器的集成测试,以证明对多个资源请求仅调用一次令牌URI。

      spring:
        security:
          oauth2:
            client:
              registration:
                api-client:
                  authorization-grant-type: client_credentials
                  client-id: test-client
                  client-secret: 6b30087f-65e2-4d89-a69e-08cb3c9f34d2
                  provider: some-keycloak
              provider:
                some-keycloak:
                  token-uri: http://localhost:1234/token/uri
      api:
        base-url: http://localhost:1234/api/v1
      
      @SpringBootTest
      @ExtendWith(MockServerExtension.class)
      @MockServerSettings(ports = 1234)
      class TheRestClientImplIT {
      
          @Autowired
          TheRestClient theRestClient;
      
          @BeforeEach
          void setUpTest(MockServerClient mockServer) {
              mockServer
                      .when(HttpRequest
                              .request("/token/uri"))
                      .respond(HttpResponse
                              .response("{\n" +
                                      "    \"access_token\": \"c29tZS10b2tlbg==\",\n" +
                                      "    \"expires_in\": 300,\n" +
                                      "    \"token_type\": \"bearer\",\n" +
                                      "    \"not-before-policy\": 0,\n" +
                                      "    \"session_state\": \"7502cf31-b210-4754-b919-07e1d8493fa3\"\n" +
                                      "}")
                              .withContentType(MediaType.APPLICATION_JSON));
              mockServer
                      .when(HttpRequest
                              .request("/api/v1/some-resource")
                              .withHeader("Authorization", "Bearer c29tZS10b2tlbg=="))
                      .respond(HttpResponse
                              .response("Hello from resource!"));
          }
      
          @Test
          void should_access_protected_resource_more_than_once_but_request_a_token_exactly_once(MockServerClient mockServer) {
              int resourceRequestCount = 2; // how often should the resource be requested?
      
              Stream
                      .iterate(1, i -> ++i)
                      .limit(resourceRequestCount)
                      .forEach(i -> {
                          LoggerFactory
                                  .getLogger(TheRestClientImplIT.class)
                                  .info("Performing request number: {}", i);
      
                          StepVerifier
                                  .create(theRestClient.getResource())
                                  .assertNext(response -> {
                                      assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
                                      assertThat(response.getBody()).isEqualTo("Hello from resource!");
                                  })
                                  .verifyComplete();
                      });
      
              // verify token request happened exactly once
              mockServer.verify(HttpRequest
                              .request("/token/uri"),
                      VerificationTimes.once());
      
              // verify resource request happened as often as defined
              mockServer.verify(HttpRequest
                              .request("/api/v1/some-resource")
                              .withHeader("Authorization", "Bearer c29tZS10b2tlbg=="),
                      VerificationTimes.exactly(resourceRequestCount));
          }
      }
      

      供参考,这是TheRestClient 实现:

      @Component
      public class TheRestClientImpl implements TheRestClient {
      
          private final WebClient webClient;
      
          @Autowired
          public TheRestClientImpl(WebClient.Builder webClientBuilder,
                                   @Value("${api.base-url}") String apiBaseUrl) {
              this.webClient = webClientBuilder
                      .baseUrl(apiBaseUrl)
                      .build();
          }
      
          @Override
          public Mono<ResponseEntity<String>> getResource() {
              return webClient
                      .get()
                      .uri("/some-resource")
                      .retrieve()
                      .toEntity(String.class);
          }
      }
      

      【讨论】:

        【解决方案3】:

        没错,5.2.x.RELEASE版本并没有解决这个问题。

        5.2.0.M3 版本修复了这个问题以及另一个关于“client-authentication-method=POST”的问题,该问题不起作用。因此,您可以使用它来代替 5.2.0.M2

        【讨论】:

        • 我使用的是 5.4.5,这个问题仍然存在。不敢相信 Spring 团队已经发布了这样的东西。
        【解决方案4】:

        最近我不得不将UnAuthenticatedServerOAuth2AuthorizedClientRepository 更改为WebSessionServerOAuth2AuthorizedClientRepository,因为我必须在同一个应用程序上验证网址

        @Override
        protected void configure(HttpSecurity http) {
            http
                .requestMatchers()
                .antMatchers("/rest/**")
                .authorizeRequests()
                .anyRequest().authenticated();
        }
        

        令牌问题又出现了。

        这可能是因为令牌存储在客户端存储库中,而 WebSessionServerOAuth2AuthorizedClientRepository 中的错误尚未修复。 无论如何,为了解决这个问题,我刚刚创建了一个自定义客户端存储库,扩展 UnAuthenticatedServerOAuth2AuthorizedClientRepository 并覆盖其中的 3 个方法(loadAuthorizedClientremoveAuthorizedClientsaveAuthorizedClient)将 null 传递给 Authentication 和 ServerWebExchange 参数。

        public class BypassAuthenticatedServerOAuth2AuthorizedClientRepository extends UnAuthenticatedServerOAuth2AuthorizedClientRepository {
        
            @Override
            public <T extends OAuth2AuthorizedClient> Mono<T> loadAuthorizedClient(String clientRegistrationId, Authentication authentication, ServerWebExchange serverWebExchange) {
                return super.loadAuthorizedClient(clientRegistrationId, null, null);
            }
        
            @Override
            public Mono<Void> saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication authentication, ServerWebExchange serverWebExchange) {
                return super.saveAuthorizedClient(authorizedClient, null, null);
            }
        
            @Override
            public Mono<Void> removeAuthorizedClient(String clientRegistrationId, Authentication authentication, ServerWebExchange serverWebExchange) {
                return super.removeAuthorizedClient(clientRegistrationId, null, null);
            }
        }
        
        @Configuration
        public class WebClientConfig {
        
            @Bean
            WebClient webClient(ReactiveClientRegistrationRepository clientRegistrations) {
                ServerOAuth2AuthorizedClientExchangeFilterFunction oauth = new ServerOAuth2AuthorizedClientExchangeFilterFunction(
                        clientRegistrations, new BypassAuthenticatedServerOAuth2AuthorizedClientRepository());
                oauth.setDefaultClientRegistrationId("client-registration-id");
                return WebClient.builder().filter(oauth).build();
            }
        
        }
        

        通过这种方式,我能够启用身份验证,并且令牌仅被请求一次,直到它过期。

        【讨论】:

        • 嗨,tuxy,UnAuthenticatedServerOAuth2AuthorizedClientRepository 已弃用。您还有其他解决方法吗?谢谢
        猜你喜欢
        • 2021-01-26
        • 2022-12-15
        • 2019-09-17
        • 2013-04-11
        • 2018-01-28
        • 1970-01-01
        • 2023-04-10
        • 2013-04-25
        • 2016-09-06
        相关资源
        最近更新 更多