无法在 Spring Web 客户端添加客户端凭据 (clientid/clientsecret):请求处理失败 ... 401 UNAUTHORIZED

failing to add client credentials (clientid/clientsecret) at Spring Webclient: Request processing failed ... 401 UNAUTHORIZED

我正在尝试使用 WebClient 来使用提供令牌的端点。 使用 Postman,它按预期工作。从 postman 导出的 curl 是:

curl --location --request POST 'https://mycomp.url/api/oauth/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id=xxx' \
--data-urlencode 'client_secret=yyy' \
--data-urlencode 'grant_type=client_credentials'

我正在配置基于上面相同 curl 的 webclient 调用。

这是我的 WebClient 配置:

@Configuration
class ClientConfiguration {

    @Bean
    fun webClient(): WebClient = WebClient.builder()
        .clientConnector(
            ReactorClientHttpConnector(
                HttpClient.from(
                    TcpClient
            .create()
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000)
            .doOnConnected { connection: Connection ->
                connection.addHandlerLast(ReadTimeoutHandler(10000, TimeUnit.MILLISECONDS))
                connection.addHandlerLast(WriteTimeoutHandler(10000, TimeUnit.MILLISECONDS))
            }))
        )
        .build()
}

这是网络客户端 post 以接收令牌:

@Service
class TokenService(private val webClient: WebClient) {

    fun postAsynchronous(): Mono<TokenResponse> = webClient
        .post()
        .uri(UriComponentsBuilder
            .fromHttpUrl("https://mycomp.url")
            .path("/api/oauth/token")
            .build()
            .toUri())
        .header("grant_type","client_credentials")
        .header("client_id","xxx")
        .header("client_secret","yyy")
        .header(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded") 
        .retrieve()
        .onStatus(HttpStatus::is4xxClientError) { Mono.error(RuntimeException("4XX Error ${it.statusCode()}")) }
        .onStatus(HttpStatus::is5xxServerError) { Mono.error(RuntimeException("5XX Error ${it.statusCode()}")) }
        .bodyToMono(TokenResponse::class.java)
}

这是我的build.gradle.kts(相关部分):

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.jetbrains.kotlin.jvm") version "1.4.10"
    id("org.jetbrains.kotlin.kapt") version "1.4.10"
    kotlin("plugin.spring") version "1.5.20"
    id("org.springframework.boot") version "2.4.7"
    //kotlin("jvm") version "1.5.30"

    id("io.spring.dependency-management") version "1.0.10.RELEASE"

}

val kotlinVersion: String by project
val springVersion: String by project
val projectGroupId: String by project
val projectVersion: String by project

group = projectGroupId
version = projectVersion

repositories {
    mavenLocal()
    ... some internal artifactories
    mavenCentral()
}

// add dependencies
dependencies {
    kapt(kotlin("stdlib", kotlinVersion))
    implementation(kotlin("stdlib-jdk8"))
    implementation(kotlin("reflect", kotlinVersion))

    implementation("org.springframework.boot:spring-boot-dependencies:2.4.7")
    implementation("org.springframework.boot:spring-boot-starter:2.4.7")
    implementation("org.springframework.boot:spring-boot-starter-web:2.4.7")
    implementation("org.springframework.boot:spring-boot-starter-webflux")
    implementation("org.springframework.cloud:spring-cloud-starter-openfeign:3.0.3")
    implementation("io.github.openfeign:feign-okhttp:10.2.0")

    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.11.2")
}

整个异常是:

2021/09/23 17:33:53.123 [http-nio-8080-exec-2] INFO  o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring DispatcherServlet 'dispatcherServlet'
2021/09/23 17:33:53.123 [http-nio-8080-exec-2] INFO  o.s.web.servlet.DispatcherServlet - Initializing Servlet 'dispatcherServlet'
2021/09/23 17:33:53.124 [http-nio-8080-exec-2] INFO  o.s.web.servlet.DispatcherServlet - Completed initialization in 1 ms
2021/09/23 17:33:54.396 [http-nio-8080-exec-2] ERROR o.a.c.c.C.[.[.[.[dispatcherServlet] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: 4XX Error 401 UNAUTHORIZED] with root cause
java.lang.RuntimeException: 4XX Error 401 UNAUTHORIZED
    at com.mycomp.security.TokenService$postAsynchronous.apply(TokenService.kt:32)
    Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
Error has been observed at the following site(s):
    |_ checkpoint ⇢ 401 from POST https://mycomp-url/api/oauth/token [DefaultWebClient]
Stack trace:
        at com.mycomp.security.TokenService$postAsynchronous.apply(TokenService.kt:32)
        at com.mycomp.security.TokenService$postAsynchronous.apply(TokenService.kt:15)
        at org.springframework.web.reactive.function.client.DefaultWebClient$DefaultResponseSpec$StatusHandler.apply(DefaultWebClient.java:693)
        at org.springframework.web.reactive.function.client.DefaultWebClient$DefaultResponseSpec.applyStatusHandlers(DefaultWebClient.java:652)
        at org.springframework.web.reactive.function.client.DefaultWebClient$DefaultResponseSpec.handleBodyMono(DefaultWebClient.java:621)
        at org.springframework.web.reactive.function.client.DefaultWebClient$DefaultResponseSpec.lambda$bodyToMono(DefaultWebClient.java:541)
        at reactor.core.publisher.MonoFlatMap$FlatMapMain.onNext(MonoFlatMap.java:125)

为了以防万一,我也尝试了其他方法。

我保持 webclient 不变,我只是改变了我发送凭据的方式。

首先,我创建了一个包含所有三个参数的简单 class:

data class TokenRequest(
    var grantType: String,
    var clientId: String,
    var clientSecret: String
)

然后我将webclient.post修改为

fun postAsynchronous(): Mono<TokenResponse> = webClient
    .post()
    .uri(UriComponentsBuilder
        .fromHttpUrl("https://mycomp-url")
        .path("/api/oauth/token")
        .build()
        .toUri())
    .body(BodyInserters.fromValue(TokenRequest("client_credentials","xxx", "yyy")))
    .header(HttpHeaders.CONTENT_TYPE, "application/json")
    .retrieve()
    .onStatus(HttpStatus::is4xxClientError) { Mono.error(RuntimeException("4XX Error ${it.statusCode()}")) }
    .onStatus(HttpStatus::is5xxServerError) { Mono.error(RuntimeException("5XX Error ${it.statusCode()}")) }
    .bodyToMono(TokenResponse::class.java)

我遇到了完全相同的问题:

2021/09/23 18:01:55.994 [http-nio-8080-exec-1] ERROR o.a.c.c.C.[.[.[.[dispatcherServlet] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: 4XX Error 401 UNAUTHORIZED] with root cause
java.lang.RuntimeException: 4XX Error 401 UNAUTHORIZED
    at com.mycomp.security.TokenService$postAsynchronous.apply(TokenService.kt:32)
    Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
Error has been observed at the following site(s):
    |_ checkpoint ⇢ 401 from POST https://mycomp.url/api/oauth/token [DefaultWebClient]
Stack trace:
        at com.mycomp.security.TokenService$postAsynchronous.apply(TokenService.kt:32)
        at com.mycomp.security.TokenService$postAsynchronous.apply(TokenService.kt:15)
        at org.springframework.web.reactive.function.client.DefaultWebClient$DefaultResponseSpec$StatusHandler.apply(DefaultWebClient.java:693)

*** 2021 年 10 月 7 日编辑

通过 Aniket Singla 提议,我遇到了这个新问题:

[reactor-tcp-nio-2] WARN  r.n.http.client.HttpClientConnect - [id:9270e5dc-1, L:/10.92.12.165:58268 - R:mycomp-url/x.x.x.x:443] The connection observed an error
org.springframework.web.reactive.function.UnsupportedMediaTypeException: Content type 'application/x-www-form-urlencoded' not supported for bodyType=com.mycomp.application.models.token.TokenRequest
    at org.springframework.web.reactive.function.BodyInserters.unsupportedError(BodyInserters.java:391)
    ...
[http-nio-8080-exec-1] ERROR o.a.c.c.C.[.[.[.[dispatcherServlet] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.web.reactive.function.client.WebClientRequestException: Content type 'application/x-www-form-urlencoded' not supported for bodyType=com.mycomp.application.models.token.TokenRequest; nested exception is org.springframework.web.reactive.function.UnsupportedMediaTypeException: Content type 'application/x-www-form-urlencoded' not supported for bodyType=com.mycomp.application.models.token.TokenRequest] with root cause
org.springframework.web.reactive.function.UnsupportedMediaTypeException: Content type 'application/x-www-form-urlencoded' not supported for bodyType=com.mycomp.application.models.token.TokenRequest

根据 Maciej Dobrowolski 的提议,我得到了这个新的例外:

2021/10/07 17:36:29.098 [http-nio-8080-exec-2] ERROR o.a.c.c.C.[.[.[.[dispatcherServlet] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.core.codec.DecodingException: JSON decoding error: Instantiation of [simple type, class com.mycomp.application.models.token.TokenResponse] value failed for JSON property result due to missing (therefore NULL) value for creator parameter result which is a non-nullable type; nested exception is com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException: Instantiation of [simple type, class com.mycomp.application.models.token.TokenResponse] value failed for JSON property result due to missing (therefore NULL) value for creator parameter result which is a non-nullable type
 at [Source: (io.netty.buffer.ByteBufInputStream); line: 8, column: 1] (through reference chain: com.mycomp.application.models.token.TokenResponse["result"])] with root cause
com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException: Instantiation of [simple type, class com.mycomp.application.models.token.TokenResponse] value failed for JSON property result due to missing (therefore NULL) value for creator parameter result which is a non-nullable type
 at [Source: (io.netty.buffer.ByteBufInputStream); line: 8, column: 1] (through reference chain: com.mycomp.application.models.token.TokenResponse["result"])
    at com.fasterxml.jackson.module.kotlin.KotlinValueInstantiator.createFromObjectWith(KotlinValueInstantiator.kt:112)

*** 已编辑

data class TokenResponse (
    val result: String
)

使用 --data-urlencode curl 选项,您正在向请求的 body 添加参数。在您的 Kotlin 代码中,您没有在请求的 body 中传递相同的数据,而是在 headers.

中传递相同的数据

你应该做的(模仿邮递员行为)是通过使用 BodyInserters 在请求 body 中传递 grant_typeclient_idclient_secret ,像这样:

webClient
        .post()
        .uri(UriComponentsBuilder
            .fromHttpUrl("https://mycomp.url")
            .path("/api/oauth/token")
            .build()
            .toUri())
        .body(BodyInserters.fromFormData("grant_type", "client_credentials")
                           .with("client_id", "xxx")
                           .with("client_secret", "yyy")) 
        .header(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded") 
        .retrieve()
        // ...
        

在 headers 中提供 url 编码数据将不起作用,您只需要在 headers 中告知您将使用“application/x-www-form-urlencoded”作为内容类型, else 将由 webclient 负责将 body 转换为 url 编码形式。对您的 postAsynchronous 方法进行了一些更改,应该可以解决您的问题。

fun postAsynchronous(): Mono<TokenResponse> = webClient
    .post()
    .uri(UriComponentsBuilder
        .fromHttpUrl("https://des-sts-int.mbi.cloud.ihf")
        .path("/api/oauth/token")
        .build()
        .toUri())
    .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    .body(BodyInserters.fromFormData("grant_type", "client_credentials")
                           .with("client_id", "xxx")
                           .with("client_secret", "yyy")) )
    .retrieve()
    .onStatus(HttpStatus::is4xxClientError) { Mono.error(RuntimeException("4XX Error ${it.statusCode()}")) }
    .onStatus(HttpStatus::is5xxServerError) { Mono.error(RuntimeException("5XX Error ${it.statusCode()}")) }
    .bodyToMono(TokenResponse::class.java)