Search code examples
javaspringspring-bootspring-security

Setting a response status triggers an AccessDeniedException


This is a dummy project so some of the code will be example code.

Here is my Spring Boot security config:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final CookieAuthenticationFilter cookieAuthenticationFilter;

    public SecurityConfig(CookieAuthenticationFilter customFilter) {
        this.cookieAuthenticationFilter = customFilter;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .cors(AbstractHttpConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(
                        s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .authorizeHttpRequests(
                        a -> a
                                .requestMatchers("/un/**").permitAll()
                                .anyRequest().authenticated()
                )

                .addFilterBefore(cookieAuthenticationFilter, BasicAuthenticationFilter.class);
        return http.build();
    }
}

I have written CookieAuthenticationFilter for handling authentication by a cookie token. However, whenever I try to add in response.setStatus() within this filter, and then ping /test it causes the AccessDenied error to be unhandled.

Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Unable to handle the Spring Security Exception because the response is already committed.] with root cause

org.springframework.security.access.AccessDeniedException: Access Denied
(rest of the stack trace not shown)

Here is the CookieAuthenticationFilter code:

@Component
public class CookieAuthenticationFilter extends OncePerRequestFilter {
    private final AuthService authService;
    private final ObjectMapper objectMapper;

    public CookieAuthenticationFilter(
            AuthService authService,
            ObjectMapper objectMapper) {
        this.authService = authService;
        this.objectMapper = objectMapper;
    }

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
//        (code that extracts the auth cookie from the request object)

        // Fetch user associated with the token
        UserDto user = null;
        try {
            user = authService.getUserFromAuthenticationToken(
                    new AuthenticationTokenValueDto(authCookie.getValue())
            );
        } catch (CustomAuthException e) {
            // Map exception to request
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
            objectMapper.writeValue(response.getOutputStream(), CustomAuthException.MESSAGE);

            // Exit function
            filterChain.doFilter(request, response); return;
        }

        // Add authentication to context
        Authentication authentication = new PreAuthenticatedAuthenticationToken(
                user,
                authCookie.getValue(),
                List.of()
        );
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // Run the rest of the filters
        filterChain.doFilter(request, response);
    }
}

Whenever I remove the response.setStatus() and objectMapper.writeValue() lines from the code, it causes no errors. I debugged and figured out that in both cases the error AccessDenied is thrown, but it is not handled by Spring when I set the response status. I checked and its because ExceptionTranslationFilter throws a ServletException whenever response.commited() is true. And its true after running the response.setStatus() and objectMapper.writeValue() lines.

So how do I avoid triggering this error, while still being able to write custom error messages to the response body and setting response codes, in the cookie filter? Do I need to delegate error handling to another component? Do I need to turn off ExceptionTranslationFilter somehow?


Solution

  • After fiddling around, I found this solution. I'm not sure if it is a proper way of doing this, but it works for me

    I create a new CookieAuthenticationEntryPoint:

    @Component
    public class CookieAuthenticationEntryPoint implements AuthenticationEntryPoint {
        public static String COOKIE_AUTH_ERROR_REQUEST_ATTR_KEY = "CookieAuthenticationError";
    
        private final ObjectMapper objectMapper;
    
        public CookieAuthenticationEntryPoint(ObjectMapper objectMapper) {
            this.objectMapper = objectMapper;
        }
    
        @Override
        public void commence(
                HttpServletRequest request,
                HttpServletResponse response,
                AuthenticationException authException) throws IOException, ServletException {
            // Handle cookie authentication errors if they exist
            Exception cookieAuthException;
            if ((cookieAuthException = (Exception) request.getAttribute(COOKIE_AUTH_ERROR_REQUEST_ATTR_KEY)) != null) {
                // Set appropriate status code and content type
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
    
                // Write error message to response body
                objectMapper.writeValue(response.getOutputStream(),
                        Map.of(
                                "code", HttpServletResponse.SC_UNAUTHORIZED,
                                "message", cookieAuthException.getMessage()
                        ));
    
                // Exit method early
                return;
            }
    
            // Default behavior for all other cases
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        }
    }
    

    What it does is, it checks if there is an exception to be handled that is stored in the request attributes. If there is, it does the logic that I wrote in the CookieAuthenticationFilter originally.

    That section of the CookieAuthenticationFilter is refactored to this:

    ...
            } catch (CustomAuthException e) {
                // Set error as request attribute
                request.setAttribute(CookieAuthenticationEntryPoint.COOKIE_AUTH_ERROR_REQUEST_ATTR_KEY, e);
    
                // Run the rest of the filters
                filterChain.doFilter(request, response); return;
            }
    ...
    

    What that does is writes the CustomException to the request attributes, which will be picked up later by CookieAuthenticationEntryPoint.

    Finally I just needed to register CookieAuthenticationEntryPoint in the SecurityConfig, like this:

    ...
                    .exceptionHandling(e -> e.authenticationEntryPoint(cookieAuthenticationEntryPoint));
    ...
    

    I know that this is very tight coupling and using string values and all that might be a bad idea, but it works. If anyone knows of any other way, let me know