Я не могу настроить тело ответа по умолчанию «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
недоступен?
@ch4mp, я все еще хочу вернуть 500
, но с индивидуальным сообщением, например. чтобы предоставить код ошибки, который описан в моем руководстве пользователя. Я постараюсь обновить текст проблемы.
Вероятно, вы могли бы взглянуть на реализацию JwkSetUriReactiveJwtDecoderBuilder::withIssuerLocation
и написать что-то подобное: создать экземпляр JwkSetUriReactiveJwtDecoderBuilder
с преобразованным исключением.
Затем используйте этот экземпляр JwkSetUriReactiveJwtDecoderBuilder
при настройке цепочки фильтров:
http.oauth2ResourceServer(resourceServer -> resourceServer.jwt(jwt -> jwt.jwtDecoder(myBuilder.build())));
К сожалению, NimbusReactiveJwtDecoder.JwkSetUriReactiveJwtDecoderBuilder#jwkSetUri
типа Function<WebClient, Mono<String>>
, реализованный с помощью (web) -> ReactiveJwtDecoderProviderConfigurationUtils.getConfigurationForIssuerLocation(issuer, web)
, можно установить только с помощью конструктора JwkSetUriReactiveJwtDecoderBuilder(Function<WebClient, Mono<String>>, Function<ReactiveRemoteJWKSource,Mono<Set<JWSAlgorithm>>>)
, которым является private
. Я не вижу способа реализовать функцию jwkSetUri
самостоятельно, чтобы повлиять на Mono
, возвращаемый пользователю. :/
По крайней мере, клиенты OAuth2 уже имеют информацию о том, что это внутренняя ошибка сервера и что они ничего не могут с этим поделать (но звонят вам, чтобы исправить это).
Метод 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)));
}
}
Мне просто интересно, что еще, кроме
500
, вы бы хотели вернуть в таком случае?