为什么 Spring Boot WebClient OAuth2 (client_credentials) 要求为每个请求提供新令牌?

tux*_*uxy 6 java spring oauth-2.0 spring-boot

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

第一个应用程序使用 Reactive WebClient 来调用第二个 OAuth2 REST 应用程序。我已经配置了WebClientgrant_type“client_credentials”。

应用程序.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
Run Code Online (Sandbox Code Playgroud)
@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();
    }

}
Run Code Online (Sandbox Code Playgroud)
@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);
    }

}
Run Code Online (Sandbox Code Playgroud)

根据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":[]}
Run Code Online (Sandbox Code Playgroud)

下面是我在 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>
Run Code Online (Sandbox Code Playgroud)

clo*_*ken 6

Spring 继续前进,由于 @angus要求使用现已弃用的替代方法UnAuthenticatedServerOAuth2AuthorizedClientRepository,我想分享我的实现。这是分别使用Spring Boot 2.4.4Spring Security 5.4.5

推荐的替代方案是UnAuthenticatedServerOAuth2AuthorizedClientRepository. AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager另外,向您的 bean提供 的推荐方法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;
    }
}
Run Code Online (Sandbox Code Playgroud)

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

稍微偏离主题:如果您想防止在集成测试中完全发生对令牌 URI 的授权请求,您可能对此感兴趣


测试

下面是一个相应的src/test/resources/application.yml集成测试,使用MockServer来模拟资源服务器和授权服务器,以证明对于多个资源请求,Token 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
Run Code Online (Sandbox Code Playgroud)
@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));
    }
}
Run Code Online (Sandbox Code Playgroud)

作为参考,这是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);
    }
}
Run Code Online (Sandbox Code Playgroud)


tux*_*uxy 5

我找到了解决我的问题的方法。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>

Run Code Online (Sandbox Code Playgroud)