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 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 spring-security 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() // !#
);
}