Обработка исключений аутентификации Spring Security с помощью @ExceptionHandler

101

Я использую 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 Security AuthenticationException. Есть ли способ заставить Spring Security AuthenticationEntryPoint и spring mvc @ExceptionHandler работать вместе?

Я использую Spring Security 3.1.4 и Spring MVC 3.2.4.

Никола
источник
9
Вы не можете ... (@)ExceptionHandlerБудет работать, только если запрос обрабатывается DispatcherServlet. Однако это исключение возникает раньше, поскольку оно вызывается объектом Filter. Таким образом, вы никогда не сможете обработать это исключение с помощью (@)ExceptionHandler.
M. Deinum
Хорошо, ты прав. Есть ли способ вернуть тело json вместе с response.sendError EntryPoint?
Никола
Похоже, вам нужно вставить настраиваемый фильтр раньше в цепочке, чтобы поймать исключение и вернуть соответствующий результат. В документации перечислены фильтры, их псевдонимы и порядок их применения: docs.spring.io/spring-security/site/docs/3.1.4.RELEASE/…
Romski
1
Если единственное место, где вам нужен JSON, просто создайте / запишите его внутри файла EntryPoint. Возможно, вы захотите построить там объект и вставить MappingJackson2HttpMessageConverterтуда.
M. Deinum
@ M.Deinum Я постараюсь построить json внутри точки входа.
Никола

Ответы:

64

Хорошо, я попробовал, как предлагалось, написать 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, как я, для целей тестирования, но вы бы сериализовали некоторый экземпляр класса.

Никола
источник
3
Пример использования Джексона: ObjectMapper mapper = new ObjectMapper (); mapper.writeValue (response.getOutputStream (), new FailResponse (401, authException.getLocalizedMessage (), «Доступ запрещен», «»));
Cyrusmith
1
Я знаю, что вопрос немного устарел, но вы зарегистрировали свой AuthenticationEntryPoint в SecurityConfig?
leventunver
1
@leventunver Здесь вы можете узнать, как зарегистрировать точку входа: stackoverflow.com/questions/24684806/… .
Никола
37

Это очень интересная проблема, заключающаяся в том, что Spring Security и Spring Web framework не совсем согласованы в способе обработки ответа. Я считаю, что он должен иметь удобную встроенную поддержку обработки сообщений об ошибках 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 или некоторых других форматах.

Виктор Вонг
источник
1
Я пытаюсь поймать ошибку, InvalidGrantExceptionно ваша версия CustomEntryPointне вызывается. Есть идеи, что мне может не хватать?
Стефан Фальк
@показать имя. Все исключения проверки подлинности , которые не могут быть пойманной AuthenticationEntryPoint и AccessDeniedHandlerтакие , как UsernameNotFoundExceptionи InvalidGrantExceptionмогут быть обработаны , AuthenticationFailureHandlerкак описано здесь .
Уилсон
26

Лучший способ, который я нашел, - это делегировать исключение в 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, чтобы отформатировать ответ так, как вы хотите.

Кристоф Борне
источник
10
Работает как шарм. Если Spring выдает ошибку, сообщающую, что существует 2 определения bean-компонентов для автоповеркивания, необходимо добавить аннотацию квалификатора: @Autowired @Qualifier ("handlerExceptionResolver") private HandlerExceptionResolver resolver;
Daividh
2
Имейте в виду, что, передав нулевой обработчик, вы @ControllerAdviceне будете работать, если вы указали basePackages в аннотации. Мне пришлось полностью удалить это, чтобы можно было вызвать обработчик.
Jarmex
Почему ты отдал @Component("restAuthenticationEntryPoint")? Зачем нужно такое имя, как restAuthenticationEntryPoint? Чтобы избежать конфликтов имен Spring?
theprogrammer
@Jarmex Что вы пропустили вместо нуля? это какой-то обработчик, верно? Должен ли я просто передать класс, помеченный @ControllerAdvice? Спасибо
theprogrammer
@theprogrammer, мне пришлось немного реструктурировать приложение, чтобы удалить параметр аннотации basePackages, чтобы обойти это - не идеально!
Jarmex
5

В случае 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» со сведениями об ошибках.

Надеюсь, это кому-то поможет.

Владимир Салин
источник
Я пытаюсь это сделать, но commence()функция my AuthenticationEntryPointне запускается - я что-то упустил?
Стефан Фальк
4

Взяв ответы от @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 с ее сериализаторами, десериализаторами и т. Д.

Габриэль Вилласис
источник
Я новичок в весенней загрузке: расскажите, пожалуйста, «как передать объект messageConverter в точку входа в аутентификацию»
Кона Суреш
Через сеттер. Когда вы используете XML, вам нужно создать <property name="messageConverter" ref="myConverterBeanName"/>тег. Когда вы используете @Configurationкласс, просто используйте setMessageConverter()метод.
Габриэль Вильясис
4

Нам нужно использовать 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

Винит Соланки
источник
GenericResponseBeanэто просто java pojo, вы можете создать свое собственное
Vinit
2

Я смог справиться с этим, просто переопределив метод 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());
    }
}
user3619911
источник
1

Обновление: если вам нравится и вы предпочитаете видеть код напрямую, у меня есть два примера для вас, один с использованием стандартной Spring Security, которую вы ищете, а другой с использованием эквивалента Reactive Web и Reactive Security:
- Нормальный Web + Jwt Security
- Reactive 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());
    }
}

Сопоставитель объектов становится bean-компонентом после добавления веб-стартера Spring, но я предпочитаю его настраивать, поэтому вот моя реализация для 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);
    }
// ..........
}
Мелардев
источник
1

Настройте фильтр и определите, что за ненормальность, должен быть лучший метод, чем этот

public class ExceptionFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
    String msg = "";
    try {
        filterChain.doFilter(request, response);
    } catch (Exception e) {
        if (e instanceof JwtException) {
            msg = e.getMessage();
        }
        response.setCharacterEncoding("UTF-8");
        response.setContentType(MediaType.APPLICATION_JSON.getType());
        response.getWriter().write(JSON.toJSONString(Resp.error(msg)));
        return;
    }
}

}

Бафф
источник
0

Я использую objectMapper. Каждая служба отдыха в основном работает с json, и в одной из ваших конфигураций вы уже настроили сопоставитель объектов.

Код написан на Котлине, надеюсь, все будет хорошо.

@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)
     }}
Спин 1987
источник
0

В ResourceServerConfigurerAdapterклассе у меня сработал фрагмент кода ниже. http.exceptionHandling().authenticationEntryPoint(new AuthFailureHandler()).and.csrf()..не работает. Поэтому я написал это как отдельный звонок.

public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {

        http.exceptionHandling().authenticationEntryPoint(new AuthFailureHandler());

        http.csrf().disable()
                .anonymous().disable()
                .authorizeRequests()
                .antMatchers(HttpMethod.OPTIONS).permitAll()
                .antMatchers("/subscribers/**").authenticated()
                .antMatchers("/requests/**").authenticated();
    }

Реализация AuthenticationEntryPoint для обнаружения истечения срока действия токена и отсутствия заголовка авторизации.


public class AuthFailureHandler implements AuthenticationEntryPoint {

  @Override
  public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e)
      throws IOException, ServletException {
    httpServletResponse.setContentType("application/json");
    httpServletResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

    if( e instanceof InsufficientAuthenticationException) {

      if( e.getCause() instanceof InvalidTokenException ){
        httpServletResponse.getOutputStream().println(
            "{ "
                + "\"message\": \"Token has expired\","
                + "\"type\": \"Unauthorized\","
                + "\"status\": 401"
                + "}");
      }
    }
    if( e instanceof AuthenticationCredentialsNotFoundException) {

      httpServletResponse.getOutputStream().println(
          "{ "
              + "\"message\": \"Missing Authorization Header\","
              + "\"type\": \"Unauthorized\","
              + "\"status\": 401"
              + "}");
    }

  }
}

Кемаль Атик
источник
не работает .. по-прежнему отображается сообщение по умолчанию
aswzen