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?
sendErrororsetStatusin your filter. It will now still call the next filter due to thefilterChain.doFilter(request, response)as it doesn't short circuit.AuthenticationExceptionwhich will then be handled by the Spring SecuirtyExceptionHandlingFilterwhich 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 thefilterchain.doFilterto the proper place in the lambdas you have.