Обработать spring исключения аутентификации безопасности с помощью @ExceptionHandler
Я использую Spring MVC @ControllerAdvice
и @ExceptionHandler
для обработки всего исключения REST Api. Он отлично работает для исключений, создаваемых веб-контроллерами mvc, но он не работает для исключений, создаваемых специальными фильтрами безопасности Spring, поскольку они запускаются до вызова методов контроллера.
У меня есть пользовательский Spring фильтр безопасности, который использует аутентификацию на основе токена:
public class AegisAuthenticationFilter extends GenericFilterBean {
...
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
try {
...
} catch(AuthenticationException authenticationException) {
SecurityContextHolder.clearContext();
authenticationEntryPoint.commence(request, response, authenticationException);
}
}
}
С помощью этой настраиваемой точки входа:
@Component("restAuthenticationEntryPoint")
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint{
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authenticationException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authenticationException.getMessage());
}
}
И с этим классом обрабатывать исключения глобально:
@ControllerAdvice
public class RestEntityResponseExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler({ InvalidTokenException.class, AuthenticationException.class })
@ResponseStatus(value = HttpStatus.UNAUTHORIZED)
@ResponseBody
public RestError handleAuthenticationException(Exception ex) {
int errorCode = AegisErrorCode.GenericAuthenticationError;
if(ex instanceof AegisException) {
errorCode = ((AegisException)ex).getCode();
}
RestError re = new RestError(
HttpStatus.UNAUTHORIZED,
errorCode,
"...",
ex.getMessage());
return re;
}
}
Что мне нужно сделать, так это вернуть подробное тело JSON даже для Spring безопасности AuthenticationException. Есть ли способ сделать Spring безопасность AuthenticationEntryPoint и Spring mvc @ExceptionHandler работать вместе?
Я использую Spring security 3.1.4 и Spring mvc 3.2.4.
Ответы
Ответ 1
Хорошо, я попробовал, как предложил написать сам json из AuthenticationEntryPoint, и он работает.
Просто для тестирования я изменил AutenticationEntryPoint, удалив response.sendError
@Component("restAuthenticationEntryPoint")
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint{
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authenticationException) throws IOException, ServletException {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getOutputStream().println("{ \"error\": \"" + authenticationException.getMessage() + "\" }");
}
}
Таким образом вы можете отправлять пользовательские json-данные вместе с несанкционированным 401, даже если вы используете Spring Security AuthenticationEntryPoint.
Очевидно, что вы не будете создавать json, как я делал для тестирования, но вы бы сериализовали некоторый экземпляр класса.
Ответ 2
Это очень интересная проблема, заключающаяся в том, что Spring Безопасность и Spring веб-структура не совсем согласована в том, как они обрабатывают ответ. Я считаю, что он должен поддерживать поддержку сообщений об ошибках с помощью MessageConverter
удобным способом.
Я попытался найти элегантный способ вставить MessageConverter
в Spring Security, чтобы они могли поймать исключение и вернуть их в нужном формате в соответствии с согласованием содержимого. Тем не менее, мое решение ниже не изящно, но, по крайней мере, использует код Spring.
Я предполагаю, что вы знаете, как включить библиотеку Jackson и JAXB, иначе нет смысла продолжать. Всего 3 шага.
Шаг 1 - Создайте автономный класс, сохранив MessageConverters
Этот класс не играет никакой магии. Он просто хранит конвертеры сообщений и процессор RequestResponseBodyMethodProcessor
. Магия находится внутри этого процессора, который будет выполнять всю работу, включая согласование контента и преобразование тела ответа соответственно.
public class MessageProcessor { // Any name you like
// List of HttpMessageConverter
private List<HttpMessageConverter<?>> messageConverters;
// under org.springframework.web.servlet.mvc.method.annotation
private RequestResponseBodyMethodProcessor processor;
/**
* Below class name are copied from the framework.
* (And yes, they are hard-coded, too)
*/
private static final boolean jaxb2Present =
ClassUtils.isPresent("javax.xml.bind.Binder", MessageProcessor.class.getClassLoader());
private static final boolean jackson2Present =
ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", MessageProcessor.class.getClassLoader()) &&
ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", MessageProcessor.class.getClassLoader());
private static final boolean gsonPresent =
ClassUtils.isPresent("com.google.gson.Gson", MessageProcessor.class.getClassLoader());
public MessageProcessor() {
this.messageConverters = new ArrayList<HttpMessageConverter<?>>();
this.messageConverters.add(new ByteArrayHttpMessageConverter());
this.messageConverters.add(new StringHttpMessageConverter());
this.messageConverters.add(new ResourceHttpMessageConverter());
this.messageConverters.add(new SourceHttpMessageConverter<Source>());
this.messageConverters.add(new AllEncompassingFormHttpMessageConverter());
if (jaxb2Present) {
this.messageConverters.add(new Jaxb2RootElementHttpMessageConverter());
}
if (jackson2Present) {
this.messageConverters.add(new MappingJackson2HttpMessageConverter());
}
else if (gsonPresent) {
this.messageConverters.add(new GsonHttpMessageConverter());
}
processor = new RequestResponseBodyMethodProcessor(this.messageConverters);
}
/**
* This method will convert the response body to the desire format.
*/
public void handle(Object returnValue, HttpServletRequest request,
HttpServletResponse response) throws Exception {
ServletWebRequest nativeRequest = new ServletWebRequest(request, response);
processor.handleReturnValue(returnValue, null, new ModelAndViewContainer(), nativeRequest);
}
/**
* @return list of message converters
*/
public List<HttpMessageConverter<?>> getMessageConverters() {
return messageConverters;
}
}
Шаг 2 - Создание AuthenticationEntryPoint
Как и во многих учебниках, этот класс необходим для реализации пользовательской обработки ошибок.
public class CustomEntryPoint implements AuthenticationEntryPoint {
// The class from Step 1
private MessageProcessor processor;
public CustomEntryPoint() {
// It is up to you to decide when to instantiate
processor = new MessageProcessor();
}
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response, AuthenticationException authException)
throws IOException, ServletException {
// This object is just like the model class,
// the processor will convert it to appropriate format in response body
CustomExceptionObject returnValue = new CustomExceptionObject();
try {
processor.handle(returnValue, request, response);
} catch (Exception e) {
throw new ServletException();
}
}
}
Шаг 3 - Зарегистрируйте точку входа
Как уже упоминалось, я делаю это с помощью Java Config. Я просто показываю соответствующую конфигурацию здесь, должна быть другая конфигурация, такая как сеанс без состояния и т.д.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.exceptionHandling().authenticationEntryPoint(new CustomEntryPoint());
}
}
Попробуйте с некоторыми случаями сбоя аутентификации, помните, что заголовок запроса должен включать Accept: XXX, и вы должны получить исключение в JSON, XML или некоторых других форматах.
Ответ 3
Лучший способ, которым я нашел, - делегировать исключение в HandlerExceptionResolver
@Component("restAuthenticationEntryPoint")
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Autowired
private HandlerExceptionResolver resolver;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
resolver.resolveException(request, response, null, exception);
}
}
то вы можете использовать @ExceptionHandler для форматирования ответа так, как хотите.
Ответ 4
В случае Spring Boot и @EnableResourceServer
относительно просто и удобно расширять ResourceServerConfigurerAdapter
вместо WebSecurityConfigurerAdapter
в конфигурации Java и регистрировать пользовательский AuthenticationEntryPoint
путем переопределения configure(ResourceServerSecurityConfigurer resources)
и использования resources.authenticationEntryPoint(customAuthEntryPoint())
внутри метода.
Что-то вроде этого:
@Configuration
@EnableResourceServer
public class CommonSecurityConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.authenticationEntryPoint(customAuthEntryPoint());
}
@Bean
public AuthenticationEntryPoint customAuthEntryPoint(){
return new AuthFailureHandler();
}
}
Также есть приятный OAuth2AuthenticationEntryPoint
, который может быть расширен (поскольку он не является окончательным) и частично повторно используется при реализации пользовательского AuthenticationEntryPoint
. В частности, он добавляет заголовки "WWW-Authenticate" с деталями, связанными с ошибкой.
Надеюсь, это поможет кому-то.
Ответ 5
Принимая ответы от @Nicola и @Victor Wing и добавляя более стандартизованный способ:
import org.springframework.beans.factory.InitializingBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class UnauthorizedErrorAuthenticationEntryPoint implements AuthenticationEntryPoint, InitializingBean {
private HttpMessageConverter messageConverter;
@SuppressWarnings("unchecked")
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
MyGenericError error = new MyGenericError();
error.setDescription(exception.getMessage());
ServerHttpResponse outputMessage = new ServletServerHttpResponse(response);
outputMessage.setStatusCode(HttpStatus.UNAUTHORIZED);
messageConverter.write(error, null, outputMessage);
}
public void setMessageConverter(HttpMessageConverter messageConverter) {
this.messageConverter = messageConverter;
}
@Override
public void afterPropertiesSet() throws Exception {
if (messageConverter == null) {
throw new IllegalArgumentException("Property 'messageConverter' is required");
}
}
}
Теперь вы можете добавить сконфигурированные Jackson, Jaxb или все, что вы используете для преобразования тел ответа в аннотацию MVC или настройку на основе XML с помощью сериализаторов, десериализаторов и т.д.
Ответ 6
В этом случае нам нужно использовать HandlerExceptionResolver
.
@Component
public class RESTAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Autowired
//@Qualifier("handlerExceptionResolver")
private HandlerExceptionResolver resolver;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
resolver.resolveException(request, response, null, authException);
}
}
Кроме того, вам нужно добавить в класс обработчика исключений, чтобы вернуть ваш объект.
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(AuthenticationException.class)
public GenericResponseBean handleAuthenticationException(AuthenticationException ex, HttpServletResponse response){
GenericResponseBean genericResponseBean = GenericResponseBean.build(MessageKeys.UNAUTHORIZED);
genericResponseBean.setError(true);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return genericResponseBean;
}
}
может появиться ошибка во время выполнения проекта из-за нескольких реализаций HandlerExceptionResolver
, в этом случае вам нужно добавить @Qualifier("handlerExceptionResolver")
в HandlerExceptionResolver
Ответ 7
Я смог справиться с этим, просто переопределив метод "unsuccessfulAuthentication" в моем фильтре. там я отправляю клиенту ответ об ошибке с желаемым кодом состояния HTTP.
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) throws IOException, ServletException {
if (failed.getCause() instanceof RecordNotFoundException) {
response.sendError((HttpServletResponse.SC_NOT_FOUND), failed.getMessage());
}
}
Ответ 8
Я использую objectMapper. Каждая служба отдыха в основном работает с json, и в одной из ваших конфигураций вы уже настроили объект mapper.
Код написан в Котлине, надеюсь, все будет хорошо.
@Bean
fun objectMapper(): ObjectMapper {
val objectMapper = ObjectMapper()
objectMapper.registerModule(JodaModule())
objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
return objectMapper
}
class UnauthorizedAuthenticationEntryPoint : BasicAuthenticationEntryPoint() {
@Autowired
lateinit var objectMapper: ObjectMapper
@Throws(IOException::class, ServletException::class)
override fun commence(request: HttpServletRequest, response: HttpServletResponse, authException: AuthenticationException) {
response.addHeader("Content-Type", "application/json")
response.status = HttpServletResponse.SC_UNAUTHORIZED
val responseError = ResponseError(
message = "${authException.message}",
)
objectMapper.writeValue(response.writer, responseError)
}}
Ответ 9
Обновление: Если вам нравится и вы предпочитаете видеть код напрямую, у меня есть два примера: один использует стандартную безопасность Spring, который вам нужен, другой использует эквивалент Reactive Web и Reactive Security. :
- Обычная сеть + безопасность Jwt
- Реактивный Jwt
Тот, который я всегда использую для своих конечных точек на основе JSON, выглядит следующим образом:
@Component
public class JwtAuthEntryPoint implements AuthenticationEntryPoint {
@Autowired
ObjectMapper mapper;
private static final Logger logger = LoggerFactory.getLogger(JwtAuthEntryPoint.class);
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException e)
throws IOException, ServletException {
// Called when the user tries to access an endpoint which requires to be authenticated
// we just return unauthorizaed
logger.error("Unauthorized error. Message - {}", e.getMessage());
ServletServerHttpResponse res = new ServletServerHttpResponse(response);
res.setStatusCode(HttpStatus.UNAUTHORIZED);
res.getServletResponse().setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
res.getBody().write(mapper.writeValueAsString(new ErrorResponse("You must authenticated")).getBytes());
}
}
Объектный маппер становится компонентом после добавления весеннего веб-стартера, но я предпочитаю его настраивать, так что вот моя реализация ObjectMapper:
@Bean
public Jackson2ObjectMapperBuilder objectMapperBuilder() {
Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
builder.modules(new JavaTimeModule());
// for example: Use created_at instead of createdAt
builder.propertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE);
// skip null fields
builder.serializationInclusion(JsonInclude.Include.NON_NULL);
builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return builder;
}
AuthenticationEntryPoint по умолчанию, которое вы задали в своем классе WebSecurityConfigurerAdapter:
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// ............
@Autowired
private JwtAuthEntryPoint unauthorizedHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable()
.authorizeRequests()
// .antMatchers("/api/auth**", "/api/login**", "**").permitAll()
.anyRequest().permitAll()
.and()
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler)
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.headers().frameOptions().disable(); // otherwise H2 console is not available
// There are many ways to ways of placing our Filter in a position in the chain
// You can troubleshoot any error enabling debug(see below), it will print the chain of Filters
http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
}
// ..........
}