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?
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))
".