简体   繁体   中英

How to set a response body before sending it to client

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.

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

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, eg 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.

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 (eg) BasicAuthenticationEntryPoint (not many configurations send this "WWW-Authenticate" header) like so:

private static 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 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 testWrongCredentials() 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 testCorrectCredentials() throws Exception {
    mvc
        .perform(get("/secured").with(httpBasic("user", "!test2me")))
        .andDo(print())
        .andExpectAll(
            status().isOk(),
            content().string("Hello")
        );
  }

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

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 {
      return http
          .authorizeRequests()
          .anyRequest().authenticated()
          .and()
          .httpBasic(withDefaults())
          .exceptionHandling()
          .authenticationEntryPoint(authenticationEntryPoint()) 
          // ...
          .and().build();
    }
    // @Bean (and/) or static...: you decide!;)
    private static 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;
    }
  }
}

To make it work for "wrong credentials" and "basic authentication" ( testWrongCredentials() expect json body, in form authentication it'd be easier/different ( http.formLogin().failureHandler((req, resp, exc)->{/*your code here*/})... )), or as answer to: "How to override BasicAuthenticationFilter.on[Uns|S]uccessfulAuthentication(req,resp,exc) in spring security?" (originally they are empty/no-op), we should do:

//@Bean possible resp. needed, when autowire.., for simplicity, just:
private static BasicAuthenticationFilter customBasicAuthFilter(AuthenticationManager authenticationManager) {
  return new BasicAuthenticationFilter(authenticationManager
     /*, entryPoint */) {
    @Override
    protected void onUnsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException {
      System.err.println("Aha!");
      writeToResponse(response, failed); 
    }

    // @Override ...
  };
}
// with:
private static void writeToResponse(HttpServletResponse response, Exception failed) throws IOException {
  response.getWriter().format(
      "{\"error\":{\"message\":\"%s\"}}", failed.getMessage()
  );
}

This we can use in our filterChain like:

http.addFilter(customBasicAuthFilter(authenticationManager));

IMPORTANT :

  • filter should be added before .httpBasic(...) !
  • BasicAuthenticationFilter also accepts a AuthenticationEntryPoint ..., but it is/does not the same as .exceptionHandling().authenticationEntryPoint(...) .

Actually this implicitly answers how to override any XXXFilter#anyVisibleMethod in any filter;).

To work around "spring-security-without-the-websecurityconfigureradapter" , I stuffed it into a "custom dsl" like (otherwise i get authenticationManager==null /circular refs;(:

static class CustomDsl extends AbstractHttpConfigurer<CustomDsl, HttpSecurity> {

  @Override
  public void configure(HttpSecurity http) throws Exception {
    AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
    http.addFilter(customBasicAuthFilter(authenticationManager));
  }

  public static CustomDsl customDsl() {
    return new CustomDsl();
  }
}

To use it finally like:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
  return http
      .authorizeRequests()
      .anyRequest().authenticated()
      .and()
      .apply(CustomDsl.customDsl()) // before httpBasic()!
      .and()
      .httpBasic(withDefaults())
      .exceptionHandling() // this is still needed ...
      .authenticationEntryPoint(authenticationEntryPoint()) // ... for the "anonymous" (test) case!
      .and()
      .build();
}

Then we can also modify/expect:

@Test
public void testWrongCredentials() throws Exception {
  mvc
      .perform(get("/secured").with(httpBasic("unknown", "wrong")))
      .andDo(print())
      .andExpectAll(
          unauthenticated(),
          status().isUnauthorized(),
          header().exists("WWW-Authenticate"),
          jsonPath("$.error.message").exists() // !#
      );
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM