0

I updated my Spring boot application to v3.4.4 and started getting some strange behavior when processing request with missing/expired/invalid JWT tokens. The filter chain is the following:

    @Order(2)
    @Configuration
    public class ApiConfiguration {

        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            http
                .securityContext(securityContext -> securityContext.requireExplicitSave(false))
                .securityMatcher(Constants.API_BASE_PATH + "**")
                .cors(withDefaults()).csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(requests -> requests
                    .requestMatchers(Constants.API_BASE_PATH + "login").permitAll()
                    .requestMatchers(Constants.API_BASE_PATH + "refresh").permitAll()
                    .requestMatchers(Constants.API_BASE_PATH + "users").permitAll()
                    .requestMatchers(Constants.API_BASE_PATH + "username").permitAll()
                    .requestMatchers(Constants.API_BASE_PATH + "account/reset-password").permitAll()
                    .requestMatchers(Constants.API_BASE_PATH + "emails/activation").permitAll()
                    .anyRequest().authenticated())
                .exceptionHandling(handling -> handling
                        .defaultAuthenticationEntryPointFor(apiAuthenticationEntryPoint(), apiRequestMatcher()))
                .addFilterBefore(apiAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                .sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

            return http.build();
        }

The authentication filter is as follows:

    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
        final JwtTokenUtility jwtTokenUtility = new JwtTokenUtility(appConfig.getApiJwtSecret());

        jwtTokenUtility.getToken(request).ifPresentOrElse(token -> {
            final JwtTokenStatus tokenStatus = jwtTokenUtility.getTokenStatus(token);

            switch (tokenStatus) {
                case EXPIRED, REVOKED, INVALID -> response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                case VALID -> {
                    final String subject = jwtTokenUtility.getSubject(token);
                    final UserDetails details = userDetails.loadUserByUsername(subject);
                    final UsernamePasswordAuthenticationToken authenticationToken =
                            new UsernamePasswordAuthenticationToken(details, null, details.getAuthorities());
                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                }
            }
        }, () -> {
            log.debug("Missing JWT Token");
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        });

        filterChain.doFilter(request, response);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return !request.getServletPath().startsWith(Constants.API_BASE_PATH);
    }

So, when the JWT token is invalid, an InsufficientAuthenticationException gets thrown and caught by the api entry point:

@Slf4j
public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {

    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException arg2) throws IOException {
        log.debug("Pre-authenticated entry point called. Rejecting access");
        response.sendError(response.getStatus(), "API Request Denied (Authentication Failed)");
    }
}

In prior versions of spring boot, the server would correctly respond to the request with a 401 HTTP code, but now it looks like "response.sendError" doesn't do anything. The request falls through and gets caught by the following filter chain:


    @Order()
    @Configuration
    public class WebConfiguration {

...
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http, RememberMeServices rememberMe) throws Exception {
            http
                .securityContext(securityContext -> securityContext.requireExplicitSave(false))
                .csrf(csrf -> {
                        csrf.ignoringRequestMatchers(CSRF_DISABLED_PATHS);
                        CsrfTokenRequestAttributeHandler requestHandler = new XorCsrfTokenRequestAttributeHandler();
                        requestHandler.setCsrfRequestAttributeName("_csrf");
                        csrf.csrfTokenRequestHandler(requestHandler);
                    }
                )
                .securityMatcher("/**")
                .authorizeHttpRequests(requests -> requests
                    .requestMatchers(AUTHORIZED_PATHS).permitAll()
                    .requestMatchers("/dashboard").hasAuthority("ADMIN")
                    .anyRequest().authenticated()
                )
                .headers(headers -> headers
                        .frameOptions().sameOrigin())
                .formLogin(login -> login
                        //Enable form based log in
                        .authenticationDetailsSource(mfaAuthenticationDetailsSource)
                        .loginPage("/login")
                        .usernameParameter("username")
                        .passwordParameter("password")
                        .defaultSuccessUrl("/home", true)
                        .successHandler(loginSuccessHandler)
                        //Set permitAll for all URLs associated with Form Login
                        .permitAll()
                        .failureUrl("/login-error"))
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/login?logout")
                        .deleteCookies("SESSION-ID")
                        .invalidateHttpSession(true)
                        .permitAll())
                .rememberMe(me -> me
                        .rememberMeServices(rememberMe)
                        .tokenValiditySeconds(Constants.REMEMBER_ME_TOKEN_DURATION))
                .exceptionHandling(handling -> handling
                        .defaultAuthenticationEntryPointFor(ajaxAuthenticationEntryPoint(), ajaxRequestMatcher()))
                .authenticationProvider(mfaAuthenticationProvider)
                .sessionManagement(sessionManagement -> sessionManagement.requireExplicitAuthenticationStrategy(true));

            return http.build();
        }

And the exception gets caught again by its entry point:

@Slf4j
public class AjaxAwareAuthenticationEntryPoint implements AuthenticationEntryPoint {
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException arg2) throws IOException {
        log.debug("Pre-authenticated entry point called. Rejecting access");
        response.sendError(HttpServletResponse.SC_FORBIDDEN, "Ajax Request Denied (Session Expired)");
    }
}

At this point the server respond with a 403 http code.

I tried manually flushing the response, but it doesn't do anything. Restricting the WebConfiguration's securityMatcher to something like "/gibberish/**" doesn't solve the issue because the logs shows that the ApiEntryPoint gets called multiple times (though the response is correct).

How do I block the request from cascading though the first filter chain?

3
  • The code doesn't do what you think it does. You should be throwing an exception not use sendError or setStatus in your filter. It will now still call the next filter due to the filterChain.doFilter(request, response) as it doesn't short circuit. Commented May 6 at 13:44
  • @M.Deinum Though in Spring boot's previous version it did. So you're saying that I should throw the exception in the filter when the token is invalid and then request.sendError in the EntryPoint? Commented May 6 at 13:49
  • Yes. I don't care it "worked" in the previous version, I suspect that was by chance and not by design. Especially due to servlet containers also tightening things. You should be throwing an AuthenticationException which will then be handled by the Spring Secuirty ExceptionHandlingFilter which will call the entry point (if the correct exception is thrown). I would also not use an optional here but rather a simple if/else statement or move the filterchain.doFilter to the proper place in the lambdas you have. Commented May 6 at 13:56

1 Answer 1

0

The problem stemmed from using "response.sendError(...)", which triggered a new request to /error that was intercepted by the next filter in the chain. I chose to simplify the code replacing ".defaultAuthenticationEntryPointFor(...)" with ".authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))".

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.