Home > Mobile >  How to set a response body before sending it to client
How to set a response body before sending it to client

Time:11-23

We are working on a Spring Boot application. Any unknown errors at controllers layers are handled by the global exception handler classes and response is constructed there.

However, I see that in case of authentication at Spring authentication filter, I see that Spring sometimes return without logging or throwing any errors.

And the error message is provided by Spring in WWW-Authenticate header.

Now, in this case, if any application is not handling this scenario, I want to modify only the response body, I want to pass a JSON message explaining the error message to user in response body so that user does not have to look in header.

Is there any way to modify only the response body in Spring's OncePerRequestHeader? I don't see any method which allows me to simply modify the body.

CodePudding user response:

You could define an AuthenticationEntryPoint and use the given HttpServletResponse to write your response body as desired.

This is an example where I return a translated string as response body:

import lombok.RequiredArgsConstructor;
import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
@RequiredArgsConstructor
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final MessageSourceAccessor messages;

    /**
     * This is invoked when a user tries to access a secured REST resource without supplying valid credentials.
     * A 401 Unauthorized HTTP Status code will be returned as there is no login page to redirect to.
     */
    @Override
    public void commence(final HttpServletRequest request, 
                         final HttpServletResponse response,
                         final AuthenticationException authException) throws IOException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, messages.getMessage("error.unauthorized"));
    }
}

You then need to register your the AuthenticationEntryPoint in your Spring Security config.

Old way:

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {

    private final CustomAuthenticationEntryPoint authenticationEntryPoint;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
          // all your other security config
          .exceptionHandling().authenticationEntryPoint(authenticationEntryPoint);
    }

New way:

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfiguration {

    private final CustomAuthenticationEntryPoint authenticationEntryPoint;

    @Bean
    SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception {
        return http
          // all your other security config
          .exceptionHandling().authenticationEntryPoint(authenticationEntryPoint);
    }
}

Depending on your authentication mechanism, Spring provides a matching AuthenticationEntryPoint implementation, e.g. for OAuth it might BearerTokenAuthenticationEntryPoint. It might be useful to check what your current AuthenticationEntryPoint implementation does and copy some of the logic to your implementation, if desired.

CodePudding user response:

The filter chain of Spring Security is invoked before the request arrives to the controllers, so is normal that errors in the filter chain aren´t handled by @ControllerAdvice/@ExceptionHandler out of the box.

A little review of the spring-security arquitecture

There are two kinds of exceptions that could happen here:

  1. AccessDeniedException (see AccessDeniedHandler)
  2. AuthenticationException (or a unauthenticated user)

To handle 1 should be quite straightforward implementing and registering an AccessDeniedHandler impl

To handle 2, you should implement a custom AuthenticationEntryPoint. This component is called when the user is not authenticated or when an AuthenticationException happens.

I will let you a link to a baeldung post on the implementation. Look for the delegate approach (point 4), as that allows for a cleaner serialization of the response (using @ExceptionHandler).

CodePudding user response:

Precising, applying and testing Times answer( 1) :

You could define an AuthenticationEntryPoint and use the given HttpServletResponse to write your response body as desired.

Extending (e.g) BasicAuthenticationEntryPoint (not many configurations send this "WWW-Authenticate" header) like so:

private AuthenticationEntryPoint authenticationEntryPoint() {
  BasicAuthenticationEntryPoint result = new BasicAuthenticationEntryPoint() {
    // inline:
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
      response.addHeader( // identic/similar to super method
          "WWW-Authenticate", String.format("Basic realm=\"%s\"", getRealmName())
      );
      // subtle difference:
      response.setStatus(HttpStatus.UNAUTHORIZED.value() /*, no message! */);
      // "print" custom to "response":
      response.getWriter().format(
          "{\"error\":{\"message\":\"%s\"}}", authException.getMessage()
      );
    }
  };
  // basic specific/default:
  result.setRealmName("Realm");
  return result;
}

These tests pass:

package com.example.security.custom.entrypoint;

import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.empty;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@AutoConfigureMockMvc
@SpringBootTest(properties = {"spring.security.user.password=!test2me"})
class SecurityCustomEntrypointApplicationTests {

  @Autowired
  private MockMvc mvc;

  @Test
  public void testUnathorized() throws Exception {
    mvc
        .perform(get("/secured").with(httpBasic("unknown", "wrong")))
        .andDo(print())
        .andExpectAll(
            unauthenticated(),
            status().isUnauthorized(),
            header().exists("WWW-Authenticate"),
            content().bytes(new byte[0]) // !! no content
        );
  }

  @Test
  void testOk() throws Exception {
    mvc
        .perform(get("/secured").with(httpBasic("user", "!test2me")))
        .andDo(print())
        .andExpectAll(
            status().isOk(),
            content().string("Hello")
        );
  }

  @Test
  void testAccessDenied() throws Exception {
    mvc
        .perform(get("/secured"))
        .andDo(print())
        .andExpectAll(
            status().isUnauthorized(),
            header().exists("WWW-Authenticate"),
            jsonPath("$.error.message", not(empty()))
        );
  }
}

On this (full) app:

package com.example.security.custom.entrypoint;

import java.io.IOException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import static org.springframework.security.config.Customizer.withDefaults;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@SpringBootApplication
public class SecurityCustomEntrypointApplication {

  public static void main(String[] args) {
    SpringApplication.run(SecurityCustomEntrypointApplication.class, args);
  }

  @Controller
  static class SecuredController {

    @GetMapping("secured")
    @ResponseBody
    public String secured() {
      return "Hello";
    }
  }

  @Configuration
  static class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
      http
          .authorizeHttpRequests(
              (requests) -> requests
                  .antMatchers("/secured").authenticated()
                  .anyRequest().permitAll()
          )
          .httpBasic(withDefaults())
          .exceptionHandling()
          .authenticationEntryPoint(authenticationEntryPoint()) // ...
          ;
      return http.build();
    }

    private AuthenticationEntryPoint authenticationEntryPoint() {
      BasicAuthenticationEntryPoint result = new BasicAuthenticationEntryPoint() {
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
          response.addHeader(
              "WWW-Authenticate", String.format("Basic realm=\"%s\"", getRealmName())
          );
          response.setStatus(HttpStatus.UNAUTHORIZED.value());
          response.getWriter().format(
              "{\"error\":{\"message\":\"%s\"}}", authException.getMessage()
          );
        }
      };
      result.setRealmName("Realm");
      return result;
    }
  }
}
  • Related