ch4mpy / spring-addons

Ease spring OAuth2 resource-servers configuration and testing
Apache License 2.0
552 stars 89 forks source link

IllegalArgumentException for 'any request' after upgrading from Spring Security 6.3.1 to 6.4.0-M1 #221

Closed juergenzimmermann closed 3 months ago

juergenzimmermann commented 3 months ago

Describe the bug After upgrading from Spring Boot 3.3.2 and Spring Security 6.3.1 to Spring Boot 3.4.0-M1 and Spring Security 6.4.0-M1 I get: IllegalArgumentException: A filter chain that matches any request has already been configured, which means that this filter chain [...] will never get invoked. Please use ``HttpSecurity#securityMatcher`` to ensure that there is only one filter chain configured for 'any request' and that the 'any request' filter chain is published last.

I'm using spring-addons 7.8.7 and Keycloak 25.0.2.

The full stacktrace for an incoming request being secured is in the context section at the end.

Code sample Here is the fragment of my application.yml:

com.c4-soft.springaddons.oidc:
  ops:
    - iss: ${keycloak.issuer}
      username-claim: preferred_username
      authorities:
        - path: $.realm_access.roles
          prefix: ROLE_
        - path: $.resource_access.*.roles
  resourceserver.permit-all:
    - /api/**
    - /v3/api-docs
    - /v3/api-docs.yaml
    - /auth/*
    - /actuator/**
    - /graphql
    - /graphiql

And here is the @Bean function (in Kotlin) for SecurityFilterChain:

@Bean
@Throws(Exception::class)
fun securityFilterChain(
    http: HttpSecurity,
    authenticationConverter: Converter<Jwt, AbstractAuthenticationToken>?,
): SecurityFilterChain? =
    http
        .authorizeHttpRequests { authorizeHttp ->
            val apiPathId = "$API_PATH/*"
            authorizeHttp
                .requestMatchers(OPTIONS, "$API_PATH/**").permitAll()
                .requestMatchers(GET, apiPathId, "$AUTH_PATH/me").hasAnyRole(ADMIN.name, USER.name)
                .requestMatchers(GET, API_PATH, "$API_PATH$NACHNAME_PATH/*", "/swagger-ui.html").hasRole(ADMIN.name)
                .requestMatchers(PUT, apiPathId).hasRole(ADMIN.name)
                .requestMatchers(PATCH, apiPathId).hasRole(ADMIN.name)
                .requestMatchers(DELETE, apiPathId).hasRole(ADMIN.name)
                .requestMatchers(POST, "/dev/db_populate").hasRole(ADMIN.name)
                .requestMatchers(POST, API_PATH, "$AUTH_PATH/token", "/graphql").permitAll()
                .requestMatchers(
                    EndpointRequest.to(HealthEndpoint::class.java),
                    EndpointRequest.to(PrometheusScrapeEndpoint::class.java),
                ).permitAll()
                .requestMatchers(GET, "/v3/api-docs.yaml", "/v3/api-docs", "/graphiql").permitAll()
                .requestMatchers("/error", "/error/**").permitAll()
                .anyRequest().authenticated()
        }
        .oauth2ResourceServer {
            it.jwt { configurer -> configurer.jwtAuthenticationConverter(authenticationConverter) }
        }
        .sessionManagement { it.sessionCreationPolicy(STATELESS) }
        .formLogin { it.disable() }
        .csrf { it.disable() } // NOSONAR
        .headers { it.frameOptions { config -> config.sameOrigin() } }
        .cors(Customizer.withDefaults())
        .build()

Expected behavior No exception as with Spring Security 6.3.1

Additional context Here is the stacktrace for an incoming request being secured:

2024-07-19T06:38:41.089+02:00 ERROR 23884 --- [omcat-handler-5] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'springSecurityFilterChain': Cannot create inner bean '(inner bean)#3871500' while setting constructor argument with key [1]
        at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveInnerBeanValue(BeanDefinitionValueResolver.java:421) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.BeanDefinitionValueResolver.lambda$resolveValueIfNecessary$1(BeanDefinitionValueResolver.java:153) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveInnerBean(BeanDefinitionValueResolver.java:262) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveValueIfNecessary(BeanDefinitionValueResolver.java:152) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveManagedList(BeanDefinitionValueResolver.java:460) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveValueIfNecessary(BeanDefinitionValueResolver.java:191) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.ConstructorResolver.resolveConstructorArguments(ConstructorResolver.java:691) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:206) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1371) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1208) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:563) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:523) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:336) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:296) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:334) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:204) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.context.support.AbstractApplicationContext.getBean(AbstractApplicationContext.java:1278) ~[spring-context-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.web.filter.DelegatingFilterProxy.initDelegate(DelegatingFilterProxy.java:332) ~[spring-web-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:261) ~[spring-web-6.2.0-M6.jar:6.2.0-M6]
        at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) ~[spring-web-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.2.0-M6.jar:6.2.0-M6]
        at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) ~[spring-web-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.2.0-M6.jar:6.2.0-M6]
        at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.springframework.web.filter.ServerHttpObservationFilter.doFilterInternal(ServerHttpObservationFilter.java:114) ~[spring-web-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.2.0-M6.jar:6.2.0-M6]
        at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) ~[spring-web-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-6.2.0-M6.jar:6.2.0-M6]
        at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:483) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:389) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:904) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1741) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) ~[tomcat-embed-core-10.1.26.jar:10.1.26]
        at java.base/java.lang.VirtualThread.run(VirtualThread.java:329) ~[na:na]
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name '(inner bean)#3871500' defined in org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration: Failed to instantiate [jakarta.servlet.Filter]: Factory method 'springSecurityFilterChain' threw exception with message: A filter chain that matches any request has already been configured, which means that this filter chain [DefaultSecurityFilterChain [RequestMatcher=any request, Filters=[org.springframework.security.web.session.DisableEncodeUrlFilter@7790e9a8, org.springframework.security.web.access.channel.ChannelProcessingFilter@547d7116, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@271724f, org.springframework.security.web.context.SecurityContextHolderFilter@21e4ee37, org.springframework.security.web.header.HeaderWriterFilter@44992ba3, org.springframework.web.filter.CorsFilter@53391579, org.springframework.security.web.authentication.logout.LogoutFilter@380c3fd, org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter@7546e388, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@77702805, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@7ab9d17b, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@3947fd6c, org.springframework.security.web.session.SessionManagementFilter@3149c389, org.springframework.security.web.access.ExceptionTranslationFilter@6546609e, org.springframework.security.web.access.intercept.AuthorizationFilter@5ccf2c34]]] will never get invoked. Please use `HttpSecurity#securityMatcher` to ensure that there is only one filter chain configured for 'any request' and that the 'any request' filter chain is published last.
        at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:657) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:489) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1351) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1181) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:563) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:523) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveInnerBeanValue(BeanDefinitionValueResolver.java:407) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        ... 49 common frames omitted
Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [jakarta.servlet.Filter]: Factory method 'springSecurityFilterChain' threw exception with message: A filter chain that matches any request has already been configured, which means that this filter chain [DefaultSecurityFilterChain [RequestMatcher=any request, Filters=[org.springframework.security.web.session.DisableEncodeUrlFilter@7790e9a8, org.springframework.security.web.access.channel.ChannelProcessingFilter@547d7116, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@271724f, org.springframework.security.web.context.SecurityContextHolderFilter@21e4ee37, org.springframework.security.web.header.HeaderWriterFilter@44992ba3, org.springframework.web.filter.CorsFilter@53391579, org.springframework.security.web.authentication.logout.LogoutFilter@380c3fd, org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter@7546e388, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@77702805, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@7ab9d17b, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@3947fd6c, org.springframework.security.web.session.SessionManagementFilter@3149c389, org.springframework.security.web.access.ExceptionTranslationFilter@6546609e, org.springframework.security.web.access.intercept.AuthorizationFilter@5ccf2c34]]] will never get invoked. Please use `HttpSecurity#securityMatcher` to ensure that there is only one filter chain configured for 'any request' and that the 'any request' filter chain is published last.
        at org.springframework.beans.factory.support.SimpleInstantiationStrategy.lambda$instantiate$0(SimpleInstantiationStrategy.java:199) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.util.function.ThrowingSupplier.get(ThrowingSupplier.java:58) ~[spring-core-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.util.function.ThrowingSupplier.get(ThrowingSupplier.java:46) ~[spring-core-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiateWithFactoryMethod(SimpleInstantiationStrategy.java:88) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:168) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:653) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        ... 55 common frames omitted
Caused by: java.lang.IllegalArgumentException: A filter chain that matches any request has already been configured, which means that this filter chain [DefaultSecurityFilterChain [RequestMatcher=any request, Filters=[org.springframework.security.web.session.DisableEncodeUrlFilter@7790e9a8, org.springframework.security.web.access.channel.ChannelProcessingFilter@547d7116, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@271724f, org.springframework.security.web.context.SecurityContextHolderFilter@21e4ee37, org.springframework.security.web.header.HeaderWriterFilter@44992ba3, org.springframework.web.filter.CorsFilter@53391579, org.springframework.security.web.authentication.logout.LogoutFilter@380c3fd, org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter@7546e388, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@77702805, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@7ab9d17b, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@3947fd6c, org.springframework.security.web.session.SessionManagementFilter@3149c389, org.springframework.security.web.access.ExceptionTranslationFilter@6546609e, org.springframework.security.web.access.intercept.AuthorizationFilter@5ccf2c34]]] will never get invoked. Please use `HttpSecurity#securityMatcher` to ensure that there is only one filter chain configured for 'any request' and that the 'any request' filter chain is published last.
        at org.springframework.util.Assert.isTrue(Assert.java:115) ~[spring-core-6.2.0-M6.jar:6.2.0-M6]
        at org.springframework.security.config.annotation.web.builders.WebSecurity.performBuild(WebSecurity.java:303) ~[spring-security-config-6.4.0-M1.jar:6.4.0-M1]
        at org.springframework.security.config.annotation.web.builders.WebSecurity.performBuild(WebSecurity.java:92) ~[spring-security-config-6.4.0-M1.jar:6.4.0-M1]
        at org.springframework.security.config.annotation.AbstractConfiguredSecurityBuilder.doBuild(AbstractConfiguredSecurityBuilder.java:332) ~[spring-security-config-6.4.0-M1.jar:6.4.0-M1]
        at org.springframework.security.config.annotation.AbstractSecurityBuilder.build(AbstractSecurityBuilder.java:38) ~[spring-security-config-6.4.0-M1.jar:6.4.0-M1]
        at org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration.springSecurityFilterChain(WebSecurityConfiguration.java:121) ~[spring-security-config-6.4.0-M1.jar:6.4.0-M1]
        at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) ~[na:na]
        at java.base/java.lang.reflect.Method.invoke(Method.java:580) ~[na:na]
        at org.springframework.beans.factory.support.SimpleInstantiationStrategy.lambda$instantiate$0(SimpleInstantiationStrategy.java:171) ~[spring-beans-6.2.0-M6.jar:6.2.0-M6]
        ... 60 common frames omitted
ch4mpy commented 3 months ago

As can be seen in the source, springAddonsJwtResourceServerSecurityFilterChain is one of the very few beans that are not @ConditionalOnMissingBean: the filter-chain is always created in servlet resource servers without introspection properties (without a securityMatcher, but with lowest precedence).

As you can see from the stack trace, the exception is thrown by an assert in the Spring framework. There is little that spring-addons can do about it. If you define your own filter-chain bean(s), all must have a precedence higher than "lowest" and a securityMatcher.

If you don't want to use the auto-configured resource server filter-chain, why are you using spring-addons-starter-oidc? You are apparently defining your own SecurityFilterChain bean without a securityMatcher, which means that it is intended to process all requests and that spring-addons isn't used much...

juergenzimmermann commented 3 months ago

@ch4mpy Thank you, for your immediate response. I want to use spring-addons-starter-oidc. Can you give me a hint, please, where and how I've to define my security rules if I'm doing it wrong in my @Bean function? Any hint is highly appreciated!

ch4mpy commented 3 months ago

I see at least three different solutions:

The last option would probably be close to what you had so far, which is dirty: you have two filter-chains, one completely hidden by the other.

The first two options are probably as clean, but I have a clear preference for method-security rather than access-control in configuration...

juergenzimmermann commented 3 months ago

@ch4mpy Thank you very much! Using @PreAuthorize on my controller methods works fine. How do I protect paths like /swagger-ui.html defined outside my sources like org.springdoc:springdoc-openapi?

ch4mpy commented 3 months ago

Good catch. For endpoints served from controllers we don't write, we have no other choice than access control in conf.

You might combine the first two options from my preceding answer: remove your filter-chain, use method-security in your controllers, and expose a ResourceServerExpressionInterceptUrlRegistryPostProcessor bean for endpoints exposed by dependencies.

Swagger is something I remove from runtime class path (I prefer to export the OpenAPI spec at build time, and to expose it as a static resource), In that case, exposing it publicly is not a real concern. But access to Actuator endpoints is a good sample of something I want to control at runtime (and I do with the second option from above).

juergenzimmermann commented 3 months ago

@ch4mpy Thank you very much!!! Your addon is absolutely great!