Обработка внутренней ошибки сервера, когда эмитент OAuth недоступен в WebFluxSecurity

Я не могу настроить тело ответа по умолчанию «500 внутренняя ошибка сервера», если эмитент (Keycloak) для моего реактивного сервера ресурсов Spring недоступен. Я хочу добавить собственный ответ в формате JSON, но, например, обычный @ExceptionHandler не работает, потому что не проходит аутентификация.

pom.xml (Spring Security v6.2.0, Spring boot v3.2.0):

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

application.yaml (Эмитент-uri намеренно неверен: я хочу имитировать, что мой сервер аутентификации Keycloak недоступен):

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: ${JWT_ISSUER_URI:http://notavailable.io/realms/my-realm}

SecurityConfig.java:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.nio.charset.StandardCharsets;

import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
@EnableWebFluxSecurity
public class SecurityConfiguration {


    @Bean
    SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        return http.authorizeExchange(exchanges -> exchanges.pathMatchers("/actuator/**")
                        .permitAll()
                        .anyExchange()
                        .hasAuthority("SCOPE_foobar"))
                .oauth2ResourceServer(oAuth2ResourceServerSpec -> oAuth2ResourceServerSpec.jwt(withDefaults())
                        .authenticationEntryPoint((webExchance, exception) -> handleAuthError(webExchance))
                        .accessDeniedHandler((webExchance, exception) -> handleAuthError(webExchance)))
                .csrf(ServerHttpSecurity.CsrfSpec::disable)
                .build();
    }

    /**
     * @SO: This handle is called if the authentication fails with 401 or 403. But it's not called if an 500 internal
     *      server error is thrown.
     */
    private Mono<Void> handleAuthError(ServerWebExchange webExchance) {
        final ServerHttpResponse response = webExchance.getResponse();
        response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
        response.getHeaders().setContentType(APPLICATION_JSON);

        final var msg = "{\"foo\":\"bar\"}".getBytes(StandardCharsets.UTF_8);
        return response.writeWith(Mono.just(response.bufferFactory().wrap(msg)));
    }
}

Если я отправлю запрос на свой сервер (есть контроллер /foobar), а issuer-uri недоступен, сервер зарегистрирует следующую трассировку стека:

Error has been observed at the following site(s):
    *__checkpoint ⇢ AuthenticationWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ ReactorContextWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ HttpHeaderWriterWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ ServerWebExchangeReactorContextWebFilter [DefaultWebFilterChain]
    *__checkpoint ⇢ org.springframework.security.web.server.WebFilterChainProxy [DefaultWebFilterChain]
    *__checkpoint ⇢ HTTP GET "/foobar" [ExceptionHandlingWebHandler]
Original Stack Trace:
        at org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager.onError(JwtReactiveAuthenticationManager.java:81)
        at reactor.core.publisher.Mono.lambda$onErrorMap$27(Mono.java:3785)
        at reactor.core.publisher.Mono.lambda$onErrorResume$29(Mono.java:3875)
        at reactor.core.publisher.FluxOnErrorResume$ResumeSubscriber.onError(FluxOnErrorResume.java:94)
[...]
Caused by: org.springframework.security.oauth2.jwt.JwtException: An error occurred while attempting to decode the Jwt: 
    at org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder.lambda$decode$2(NimbusReactiveJwtDecoder.java:171)
    at reactor.core.publisher.Mono.lambda$onErrorMap$27(Mono.java:3785)
    at reactor.core.publisher.Mono.lambda$onErrorResume$29(Mono.java:3875)
    at reactor.core.publisher.FluxOnErrorResume$ResumeSubscriber.onError(FluxOnErrorResume.java:94)
[...]
Caused by: java.lang.IllegalArgumentException: Unable to resolve the Configuration with the provided Issuer of "http://notavailable.io/realms/foobar/realms/my-realm"
    at org.springframework.security.oauth2.jwt.ReactiveJwtDecoderProviderConfigurationUtils.lambda$getConfiguration$8(ReactiveJwtDecoderProviderConfigurationUtils.java:139)
    at reactor.core.publisher.Flux.lambda$onErrorMap$28(Flux.java:7239)
    at reactor.core.publisher.Flux.lambda$onErrorResume$29(Flux.java:7292)
[...]
Caused by: org.springframework.web.reactive.function.client.WebClientRequestException: Failed to resolve 'notavailable.io' [A(1)] after 4 queries 
    at org.springframework.web.reactive.function.client.ExchangeFunctions$DefaultExchangeFunction.lambda$wrapException$9(ExchangeFunctions.java:136)

IllegalArgumentException добавляется в org.springframework.security.oauth2.jwt.ReactiveJwtDecoderProviderConfigurationUtils#getConfiguration (в строке onErrorMap), реализация которого выглядит следующим образом:

    private static Mono<Map<String, Object>> getConfiguration(String issuer, WebClient web, URI... uris) {
        String errorMessage = "Unable to resolve the Configuration with the provided Issuer of " + "\"" + issuer + "\"";
        return Flux.just(uris)
            .concatMap((uri) -> web.get().uri(uri).retrieve().bodyToMono(STRING_OBJECT_MAP))
            .flatMap((configuration) -> {
                if (configuration.get("jwks_uri") == null) {
                    return Mono.error(() -> new IllegalArgumentException("The public JWK set URI must not be null"));
                }
                return Mono.just(configuration);
            })
            .onErrorContinue((ex) -> ex instanceof WebClientResponseException
                    && ((WebClientResponseException) ex).getStatusCode().is4xxClientError(), (ex, object) -> {
                    })
            .onErrorMap(RuntimeException.class,
                    (ex) -> (ex instanceof IllegalArgumentException) ? ex
                            : new IllegalArgumentException(errorMessage, ex))
            .next()
            .switchIfEmpty(Mono.error(() -> new IllegalArgumentException(errorMessage)));
    }

Фактическое поведение

Если эмитент недоступен, клиент получает ответ 500 internal server error с телом:

{
    "timestamp": "2024-06-21T16:18:13.190+00:00",
    "path": "/foobar",
    "status": 500,
    "error": "Internal Server Error",
    "requestId": "2eeaa482-2"
}

Желаемое поведение

Если эмитент недоступен, клиент получает ответ 500 internal server error с телом:

{
    "foo": "bar"
}

Я попробовал использовать @ExceptionHandler (не вызывается) и создать свой собственный JwtDecoder (я не нашел метода, который можно было бы переопределить, чтобы перехватить исключение и отправить ответ).

Кто-нибудь знает, как изменить ответ, когда issuer-URI недоступен?

Мне просто интересно, что еще, кроме 500, вы бы хотели вернуть в таком случае?

ch4mp 21.06.2024 18:52

@ch4mp, я все еще хочу вернуть 500, но с индивидуальным сообщением, например. чтобы предоставить код ошибки, который описан в моем руководстве пользователя. Я постараюсь обновить текст проблемы.

Marteng 21.06.2024 18:53
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
В компьютерном программировании биты играют важнейшую роль в представлении и манипулировании данными на двоичном уровне. Побитовые операции...
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Приходилось ли вам сталкиваться с требованиями, в которых вас могли попросить поднять тревогу или выдать ошибку, когда метод Java занимает больше...
Полный курс Java для разработчиков веб-сайтов и приложений
Полный курс Java для разработчиков веб-сайтов и приложений
Получите сертификат Java Web и Application Developer, используя наш курс.
1
2
66
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Вероятно, вы могли бы взглянуть на реализацию JwkSetUriReactiveJwtDecoderBuilder::withIssuerLocation и написать что-то подобное: создать экземпляр JwkSetUriReactiveJwtDecoderBuilder с преобразованным исключением.

Затем используйте этот экземпляр JwkSetUriReactiveJwtDecoderBuilder при настройке цепочки фильтров:

http.oauth2ResourceServer(resourceServer -> resourceServer.jwt(jwt -> jwt.jwtDecoder(myBuilder.build())));

К сожалению, NimbusReactiveJwtDecoder.JwkSetUriReactiveJwtDecoderBuilder#‌​jwkSetUri типа Function<WebClient, Mono<String>>, реализованный с помощью (web) -> ReactiveJwtDecoderProviderConfigurationUtils.getConfiguratio‌​nForIssuerLocation(i‌​ssuer, web), можно установить только с помощью конструктора JwkSetUriReactiveJwtDecoderBuilder(Function<WebClient, Mono<String>>, Function<ReactiveRemoteJWKSource,Mono<Set<JWSAlgorithm>>>), которым является private. Я не вижу способа реализовать функцию jwkSetUri самостоятельно, чтобы повлиять на Mono, возвращаемый пользователю. :/

Marteng 22.06.2024 09:53

По крайней мере, клиенты OAuth2 уже имеют информацию о том, что это внутренняя ошибка сервера и что они ничего не могут с этим поделать (но звонят вам, чтобы исправить это).

ch4mp 22.06.2024 10:46
Ответ принят как подходящий

Метод ServerHttpSecurity.OAuth2ResourceServerSpec#authenticationFailureHandler(ServerAuthenticationFailureHandler) был именно тем, который я искал. Это вызывается перед сбоем аутентификации и когда декодер JWT не может связаться с эмитентом для загрузки открытого ключа для проверки подписи JWT.

Обновленный код:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.nio.charset.StandardCharsets;

import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
@EnableWebFluxSecurity
public class SecurityConfiguration {


    @Bean
    SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        return http.authorizeExchange(exchanges -> exchanges.pathMatchers("/actuator/**")
                        .permitAll()
                        .anyExchange()
                        .hasAuthority("SCOPE_foobar"))
                .oauth2ResourceServer(oAuth2ResourceServerSpec -> oAuth2ResourceServerSpec.jwt(withDefaults())
                        .authenticationEntryPoint((webExchance, exception) -> handleAuthError(webExchance))
                        .accessDeniedHandler((webExchance, exception) -> handleAuthError(webExchance))
                        .authenticationFailureHandler(createFailureHandler()))
                .csrf(ServerHttpSecurity.CsrfSpec::disable)
                .build();
    }

    private ServerAuthenticationFailureHandler createFailureHandler() {
        return (webFilterExchange, exception) -> handleAuthError(webFilterExchange.getExchange());
    }

    private Mono<Void> handleAuthError(ServerWebExchange webExchance) {
        final ServerHttpResponse response = webExchance.getResponse();
        response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
        response.getHeaders().setContentType(APPLICATION_JSON);

        final var msg = "{\"foo\":\"bar\"}".getBytes(StandardCharsets.UTF_8);
        return response.writeWith(Mono.just(response.bufferFactory().wrap(msg)));
    }
}

Другие вопросы по теме