Trate as exceções de autenticação de segurança Spring com @ExceptionHandler

97

Estou usando Spring MVC @ControllerAdvicee @ExceptionHandlerpara lidar com todas as exceções de um REST Api. Ele funciona bem para exceções lançadas por controladores web mvc, mas não funciona para exceções lançadas por filtros personalizados de segurança Spring porque eles são executados antes de os métodos do controlador serem chamados.

Eu tenho um filtro de segurança de mola personalizado que faz uma autenticação baseada em token:

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

        }

    }

}

Com este ponto de entrada personalizado:

@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());
    }

}

E com esta classe para lidar com exceções globalmente:

@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;
    }
}

O que eu preciso fazer é retornar um corpo JSON detalhado, mesmo para Spring security AuthenticationException. Existe uma maneira de fazer o spring security AuthenticationEntryPoint e spring mvc @ExceptionHandler trabalharem juntos?

Estou usando o spring security 3.1.4 e o spring mvc 3.2.4.

Nicola
fonte
9
Você não pode ... O (@)ExceptionHandlersó funcionará se a solicitação for tratada pelo DispatcherServlet. No entanto, essa exceção ocorre antes disso, pois é lançada por a Filter. Portanto, você nunca será capaz de lidar com essa exceção com um (@)ExceptionHandler.
M. Deinum
OK, você está certo. Existe uma maneira de retornar um corpo json junto com o response.sendError do EntryPoint?
Nicola
Parece que você precisa inserir um filtro personalizado no início da cadeia para capturar a Exceção e retornar de acordo. A documentação lista os filtros, seus apelidos e a ordem em que são aplicados: docs.spring.io/spring-security/site/docs/3.1.4.RELEASE/…
Romski
1
Se o único local de que você precisa do JSON, simplesmente construa / grave-o dentro do EntryPoint. Você pode querer construir o objeto lá e injetar um MappingJackson2HttpMessageConverterali.
M. Deinum
@ M.Deinum vou tentar construir o json dentro do ponto de entrada.
Nicola

Respostas:

58

Ok, eu tentei, conforme sugerido, escrever o json eu mesmo a partir do AuthenticationEntryPoint e funciona.

Apenas para teste, alterei o AutenticationEntryPoint removendo 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() + "\" }");

    }
}

Desta forma, você pode enviar dados json personalizados junto com o 401 não autorizado, mesmo se estiver usando Spring Security AuthenticationEntryPoint.

Obviamente, você não construiria o json como eu fiz para fins de teste, mas serializaria alguma instância de classe.

Nicola
fonte
3
Exemplo usando Jackson: ObjectMapper mapper = new ObjectMapper (); mapper.writeValue (response.getOutputStream (), novo FailResponse (401, authException.getLocalizedMessage (), "Acesso negado", ""));
Cyrusmith
1
Eu sei que a pergunta é um pouco antiga, mas você registrou seu AuthenticationEntryPoint no SecurityConfig?
Leventunver de
1
@leventunver Aqui você pode descobrir como registrar o ponto de entrada: stackoverflow.com/questions/24684806/… .
Nicola
37

Este é um problema muito interessante, pois o Spring Security e o Spring Web Framework não são muito consistentes na maneira como lidam com a resposta. Acredito que ele deva oferecer suporte nativo ao manuseio de mensagens de erro de MessageConverteruma maneira prática.

Tentei encontrar uma maneira elegante de injetar MessageConverterno Spring Security para que eles pudessem capturar a exceção e devolvê-la no formato correto de acordo com a negociação de conteúdo . Ainda assim, minha solução abaixo não é elegante, mas pelo menos faz uso do código Spring.

Presumo que você saiba como incluir a biblioteca Jackson e JAXB, caso contrário, não há como continuar. Existem 3 etapas no total.

Etapa 1 - Criar uma classe autônoma, armazenando MessageConverters

Esta classe não joga mágica. Ele simplesmente armazena os conversores de mensagem e um processador RequestResponseBodyMethodProcessor. A mágica está dentro desse processador que fará todo o trabalho, incluindo negociação de conteúdo e conversão do corpo de resposta de acordo.

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;
    }
}

Etapa 2 - Criar AuthenticationEntryPoint

Como em muitos tutoriais, esta classe é essencial para implementar o tratamento de erros personalizado.

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();
        }
    }
}

Etapa 3 - Registrar o ponto de entrada

Como mencionado, faço isso com Java Config. Acabei de mostrar a configuração relevante aqui, deve haver outra configuração, como sessão sem estado , etc.

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.exceptionHandling().authenticationEntryPoint(new CustomEntryPoint());
    }
}

Tente com alguns casos de falha de autenticação, lembre-se de que o cabeçalho da solicitação deve incluir Aceitar: XXX e você deve obter a exceção em JSON, XML ou alguns outros formatos.

Victor Wong
fonte
1
Estou tentando capturar um, InvalidGrantExceptionmas minha versão de seu CustomEntryPointnão está sendo invocada. Alguma ideia do que posso estar perdendo?
displayname
@Nome em Exibição. Todas as exceções de autenticação que não podem ser capturados por AuthenticationEntryPoint e AccessDeniedHandlertais como UsernameNotFoundExceptione InvalidGrantExceptionpode ser tratado por AuthenticationFailureHandlercomo explicado aqui .
Wilson
23

A melhor maneira que encontrei é delegar a exceção ao 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);
    }
}

então você pode usar @ExceptionHandler para formatar a resposta da maneira que desejar.

Christophe Bornet
fonte
9
Funciona como um encanto. Se o Spring lançar um erro dizendo que há 2 definições de bean para autowirering, você deve adicionar a anotação do qualificador: @Autowired @Qualifier ("handlerExceptionResolver") private HandlerExceptionResolver resolver;
Daividh
1
Esteja ciente de que, ao passar um manipulador nulo, você @ControllerAdvicenão funcionará se tiver especificado basePackages na anotação. Tive que remover isso totalmente para permitir que o manipulador fosse chamado.
Jarmex
Por que você deu @Component("restAuthenticationEntryPoint")? Por que a necessidade de um nome como restAuthenticationEntryPoint? É para evitar algumas colisões de nomes do Spring?
theprogrammer
@Jarmex Então, no lugar de null, o que você passou? é algum tipo de manipulador certo? Devo apenas passar uma classe que foi anotada com @ControllerAdvice? Obrigado
programador
@theprogrammer, tive que reestruturar um pouco o aplicativo para remover o parâmetro de anotação basePackages para contorná-lo - não é o ideal!
Jarmex
5

No caso de Spring Boot e @EnableResourceServer, é relativamente fácil e conveniente estender em ResourceServerConfigurerAdaptervez de WebSecurityConfigurerAdapterna configuração Java e registrar um personalizado AuthenticationEntryPointsubstituindoconfigure(ResourceServerSecurityConfigurer resources) e usando resources.authenticationEntryPoint(customAuthEntryPoint())dentro do método.

Algo assim:

@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();
    }
}

Há também um bom OAuth2AuthenticationEntryPointque pode ser estendido (já que não é final) e parcialmente reutilizado durante a implementação de um personalizado AuthenticationEntryPoint. Em particular, ele adiciona cabeçalhos "WWW-Authenticate" com detalhes relacionados ao erro.

Espero que isso ajude alguém.

Vladimir Salin
fonte
Estou tentando fazer isso, mas a commence()função do meu AuthenticationEntryPointnão está sendo invocada - estou perdendo alguma coisa?
displayname
4

Obtendo respostas de @Nicola e @Victor Wing e adicionando uma forma mais padronizada:

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");
        }
    }

}

Agora, você pode injetar Jackson, Jaxb ou o que quer que você use para converter corpos de resposta em sua anotação MVC ou configuração baseada em XML com seus serializadores, desserializadores e assim por diante.

Gabriel Villacis
fonte
Eu sou muito novo no Spring Boot: por favor, diga-me "como passar o objeto messageConverter para o ponto de autenticação"
Kona Suresh
Através do levantador. Ao usar XML, você deve criar uma <property name="messageConverter" ref="myConverterBeanName"/>tag. Quando você usa uma @Configurationclasse apenas use o setMessageConverter()método.
Gabriel Villacis
4

Precisamos usar HandlerExceptionResolvernesse caso.

@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);
    }
}

Além disso, você precisa adicionar a classe de manipulador de exceção para retornar seu objeto.

@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;
    }
}

pode você receber um erro no momento da execução de um projeto por causa de várias implementações de HandlerExceptionResolver, Nesse caso, você tem que adicionar @Qualifier("handlerExceptionResolver")emHandlerExceptionResolver

Vinit Solanki
fonte
GenericResponseBeané apenas java pojo, você pode criar o seu próprio
Vinit Solanki
2

Consegui lidar com isso simplesmente substituindo o método 'unsuccessfulAuthentication' em meu filtro. Lá, eu envio uma resposta de erro ao cliente com o código de status HTTP desejado.

@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
fonte
1

Atualização: Se você gosta e prefere ver o código diretamente, eu tenho dois exemplos para você, um usando Spring Security padrão que é o que você está procurando, o outro usando o equivalente a Reactive Web e Reactive Security:
- Normal Segurança Web + Jwt
- Jwt reativo

O que sempre uso para meus endpoints baseados em JSON se parece com o seguinte:

@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());
    }
}

O mapeador de objetos se torna um bean assim que você adiciona o Spring Web Starter, mas eu prefiro customizá-lo, então aqui está minha implementação para 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;
    }

O AuthenticationEntryPoint padrão que você definiu na classe 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);
    }
// ..........
}
Melardev
fonte
1

Personalize o filtro e determine que tipo de anormalidade deve haver um método melhor do que este

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;
    }
}

}

Buff
fonte
0

Estou usando o objectMapper. Cada serviço Rest está trabalhando principalmente com json e em uma de suas configurações você já configurou um mapeador de objetos.

O código é escrito em Kotlin, espero que esteja tudo bem.

@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)
     }}
Spin1987
fonte
0

Na ResourceServerConfigurerAdapteraula, o código abaixo cortado funcionou para mim. http.exceptionHandling().authenticationEntryPoint(new AuthFailureHandler()).and.csrf()..não funcionou. É por isso que escrevi como chamada separada.

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();
    }

Implementação de AuthenticationEntryPoint para capturar a expiração do token e cabeçalho de autorização ausente.


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"
              + "}");
    }

  }
}
Kemal Atik
fonte
não está funcionando .. ainda mostra a mensagem padrão
aswzen