Search code examples
spring-bootkotlinspring-security

Spring Security not working after API migration to Spring Boot 3


Software versions

  • Spring Boot and dependencies: 3.2.2
  • Spring Core : 6.1.3
  • Spring Security 6.2.1
  • Jetty Server: 11.0.20
  • Languages: Java 17, Kotlin (Jetbrains Kotlin and Kotlin test library versions) 1.8.10

Below are modified code snippets for Jetty based Http Server Configuration, API Configuration and API Security Configuration which now uses a SecurityFilterChain instead of WebSecurityConfigurerAdapter.

Http Server Configuration

@Configuration
@EnableConfigurationProperties(ApiServiceProperties::class)
@ComponentScan("com......service")
@Import(value = [ApiSecurityConfig::class, WebFluxConfig::class])
class HttpServerConfig(var apiServiceProperties: ApiServiceProperties) {

    /**
     * Jetty Server Bean.
     */
    @Bean
    @SuppressWarnings("LongMethod")
    fun jettyServer(
        context: ApplicationContext,
        springSecurityFilterChain: Filter,
        mdcSetterFilter: MdcSetterFilter,
        webContextFilter: WebContextFilter
    ): Server {
        LOG.info(
            "Starting Jetty server with " + "<some custom properties are being printed here>"
        )

        .. code removed ..

        ServletContextHandler(server, "").apply {
            val servlet = JettyHttpHandlerAdapter(WebHttpHandlerBuilder.applicationContext(context).build())
            addServlet(ServletHolder(servlet), "/")

            addFilter(FilterHolder(mdcSetterFilter), "/*", EnumSet.of(DispatcherType.REQUEST))
            addFilter(FilterHolder(webContextFilter), "/*", EnumSet.of(DispatcherType.REQUEST))

            // The ping endpoint should be unsecured, therefore ignored by the security filter
            addFilter(
                FilterHolder { request: ServletRequest, response: ServletResponse, chain: FilterChain ->
                    if (request is HttpServletRequest && request.requestURI != "/v1/ping") {
                        springSecurityFilterChain.doFilter(request, response, chain)
                    } else {
                        chain.doFilter(request, response)
                    }
                },
                "/v1/*",
                EnumSet.of(DispatcherType.REQUEST)
            )
        }.start()

        .. code removed ..

        server.start()

        LOG.info("Started Jetty server.")
        return server
    }

    .. code removed ..
}

API Configuration

@Configuration
@ComponentScan(basePackages = [
    "com......security",
    "com......service"
])
@EnableConfigurationProperties(ApiServiceProperties::class)
@Import(HttpServerConfig::class)
class ApiServiceConfig : AbstractSpringBasedApplicationConfig()

API Security Configuration

@Configuration
@EnableWebSecurity
@ComponentScan("com......security", "com......service")
@EnableMethodSecurity(prePostEnabled = false, jsr250Enabled = true)
class ApiSecurityConfig(
    private val restAuthenticationEntryPoint: RestAuthenticationEntryPoint,
    private val restAuthenticationProvider: RestAuthenticationProvider
) {
    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        http
            .cors { }
            .anonymous { it.disable() }
            .httpBasic { it.disable() }
            .formLogin { it.disable() }
            .logout { it.disable() }
            .csrf { it.disable() }
            .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
            .exceptionHandling { it.authenticationEntryPoint(restAuthenticationEntryPoint) }
            .authenticationManager { authentication -> restAuthenticationProvider.authenticate(authentication) }
            .addFilterBefore(RestAuthenticationTokenFilter(), AnonymousAuthenticationFilter::class.java)
            .authorizeHttpRequests { it.requestMatchers("/**").permitAll().anyRequest().authenticated() }
        return http.build()
    }

    @Bean
    fun corsConfigurationSource(): CorsConfigurationSource = UrlBasedCorsConfigurationSource().apply {
        registerCorsConfiguration(
            "/**",
            CorsConfiguration().applyPermitDefaultValues().apply {
                allowedMethods = listOf("POST", "GET", "PUT", "DELETE", "HEAD")
            }
        )
    }
}

Custom authentication provider

@Component
class RestAuthenticationProvider(
    private val securityServiceClient: SecurityServiceClient,
    private val cryptoService: CryptoService
) : AuthenticationProvider {

    /**
     * Given a [token] and [verifiedTokenModel], return a new User with granted authorities.
     */
    private fun createAuthenticatedUser(token: String, verifiedTokenModel: VerifiedTokenModel) = User
        .withUsername(verifiedTokenModel.verifiedPrincipalModel.id)
        .password(token)
        .authorities(verifiedTokenModel.verifiedPrincipalModel.scopes.map { scope -> 
            SimpleGrantedAuthority("ROLE_${scope.toUpperCase()}")
        })
        .build()

    /**
     * Given a [verifiedTokenModel], create a JSON Web Token to represent the authorizations of the verified principal.
     */
    private fun createJwt(verifiedTokenModel: VerifiedTokenModel) = cryptoService.createAuthToken(
        .. code removed ..
    )

    override fun authenticate(authentication: Authentication): Authentication? =
        (authentication as? RestAuthenticationToken)?.token?.let { token ->
            try {
                val verifiedTokenModel = securityServiceClient.verifyToken(token)
                val user = createAuthenticatedUser(token = token, verifiedTokenModel = verifiedTokenModel)
    
                RestAuthenticationToken(
                    .. code removed ..
                    jwt = createJwt(verifiedTokenModel = verifiedTokenModel)
                )
            } catch (e: ReplyException) {
                .. code removed ..
            }
        }


    .. code removed ..
}

Below is a comparison of the new and old code for security configuration (Spring Boot 2.6.2 and Spring Core 5.3.14)

enter image description here

Postman request always receives a 403 response

enter image description here

Logs

DEBUG c.a.e.d.api.v1.security.MdcSetterFilter : Setting MDC logging context.
DEBUG c.a.e.d.a.v1.security.WebContextFilter  : Setting WebContext on message
DEBUG o.s.security.web.FilterChainProxy       : Securing GET /v1/clients/*/brands  
INFO  c.a.e.d.api.v1.config.HttpServerConfig  : Token :: <bearer token value is printed here>
...
...
DEBUG o.s.w.s.adapter.HttpWebHandlerAdapter   : [49377233] HTTP GET "/v1/clients/*/brands"
...
DEBUG s.w.r.r.m.a.RequestMappingHandlerMapping: [49377233] Mapped to com......service.ClientsApiController#listBrands(String, ServerHttpRequest)
DEBUG AuthorizationManagerBeforeMethodInterceptor: Authorizing method invocation ReflectiveMethodInvocation: public org.springframework.http.ResponseEntity com......service.ClientsApiController.listBrands(..); target is of class [com......service.ClientsApiController]

DEBUG AuthorizationManagerBeforeMethodInterceptor: Failed to authorize ReflectiveMethodInvocation: public org.springframework.http.ResponseEntity com......service.ClientsApiController.listBrands(...); target is of class [com......service.ClientsApiController] with authorization manager org.springframework.security.config.annotation.method.configuration.DeferringObservationAuthorizationManager@2323fe6a and decision AuthorityAuthorizationDecision [granted=false, authorities=[ROLE_READ_BRANDS]]
DEBUG s.w.r.r.m.a.RequestMappingHandlerAdapter: [49377233] Using @ExceptionHandler com......service.DefaultExceptionHandler#onThrowable(Throwable, ServerWebExchange)  
DEBUG o.s.w.s.adapter.HttpWebHandlerAdapter   : [49377233] Completed 403 FORBIDDEN 
  • Have tried multiple combinations of the security chain as suggested on several similar threads
  • Added a few more log statements in security configuration code to capture these events and help understand how the new flow works
  • DEBUG level log statements added inside the RestAuthenticationProvider.authenticate() are not showing up in the logs, indicating it is not getting invoked, and the flow is breaking before reaching that point.

However I suspect that the configured AuthenticationProvider (tried using http.authenticationProvider(..) earlier, but that did not work either) and AuthenticationManager are not getting plugged in the chain for some reason. Need help from the community in guiding me to set this up correctly. Thank you.


Solution

  • I was able to finally get the above set up to work by making the following changes to the SecurityFilterChain:

    1. Removed custom RestAuthenticationTokenFilter
    2. Removed calls to set custom AuthenticationProvider/AuthenitcationManager directly on the HttpSecurity object, it does not work.
    3. Created a custom BearTokenAuthenticationConverter which contains the same logic that was present in the above custom filter to generate auth token by extracting generation bearer token from the servlet request.
    4. Added a new function to ApiSecurityConfig class to create an object of AuthenticationManager with the custom AuthenticationProvider embedded in it
    5. Then added a function to create an object of AuthenticationManagerResolver with this new AuthenticationManager embedded in it
    6. Followed by another new function to create an object of AuthenticationFilter using its constructor which takes the above created AuthenticationManagerResolver and BearerTokenAuthenticationConverter as arguments
    7. Replaced the call to set the custom RestAuthenticationTokenFilter object in the HttpSecurity using method addFilterBefore() by passing this AuthenticationFilter object instead
    8. Cleaned up unused code

    Below is the modified code snippet for the ApiSecurityConfig class

    @Configuration
    @EnableWebSecurity
    @ComponentScan("com......security", "com......service")
    @EnableMethodSecurity(prePostEnabled = false, securedEnabled = false, jsr250Enabled = true)
    class ApiSecurityConfig(
        private val restAuthenticationEntryPoint: RestAuthenticationEntryPoint,
        private val restAuthenticationProvider: RestAuthenticationProvider
    ) {
        @Bean
        fun securityFilterChain(http: HttpSecurity, authManager: AuthenticationManager): SecurityFilterChain {
            http
                .cors { }
                .anonymous { it.disable() }
                .httpBasic { it.disable() }
                .formLogin { it.disable() }
                .logout { it.disable() }
                .csrf { it.disable() }
                .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
                .exceptionHandling { it.authenticationEntryPoint(restAuthenticationEntryPoint) }
                .addFilterAfter(authenticationFilter(), AnonymousAuthenticationFilter::class.java)
                .authorizeHttpRequests {
                    it.requestMatchers(
                        OrRequestMatcher(
                        AntPathRequestMatcher("/v1/ping", "GET", false),
                        AntPathRequestMatcher("/v1/ping", "POST", false))
                    ).permitAll()
                        .anyRequest().authenticated()
                }
            return http.build()
        }
    
        @Bean
        fun authenticationManager(): AuthenticationManager {
            return AuthenticationManager { authentication: Authentication -> restAuthenticationProvider.authenticate(authentication) }
        }
    
        @Bean
        fun resolver(): AuthenticationManagerResolver<HttpServletRequest> {
            return AuthenticationManagerResolver {
                authenticationManager()
            }
        }
    
        private fun authenticationFilter(): AuthenticationFilter {
            val filter: AuthenticationFilter = AuthenticationFilter(resolver(), BearTokenAuthenticationConverter())
            filter.successHandler = AuthenticationSuccessHandler {
                    _: HttpServletRequest?, _: HttpServletResponse?, _: Authentication? -> }
    
            filter.requestMatcher = AndRequestMatcher(
                NegatedRequestMatcher(AntPathRequestMatcher("/v1/ping", "GET", false)),
                NegatedRequestMatcher(AntPathRequestMatcher("/v1/ping", "POST", false)))
            return filter
        }
    
        /**
         * Enable CORS for all paths.
         */
        @Bean
        fun corsConfigurationSource(): CorsConfigurationSource = UrlBasedCorsConfigurationSource().apply {
            registerCorsConfiguration(
                "/**",
                CorsConfiguration().applyPermitDefaultValues().apply {
                    allowedMethods = listOf("POST", "GET", "PUT", "DELETE", "HEAD")
                }
            )
        }
    
        companion object {
            private val LOG: Logger = LoggerFactory.getLogger(ApiSecurityConfig::class.java)
        }
    }