Maneje las excepciones de autenticación de seguridad de primavera con @ExceptionHandler

97

Estoy usando Spring MVC @ControllerAdvicey @ExceptionHandlerpara manejar toda la excepción de una API REST. Funciona bien para las excepciones lanzadas por los controladores web mvc, pero no funciona para las excepciones lanzadas por los filtros personalizados de seguridad de primavera porque se ejecutan antes de que se invoquen los métodos del controlador.

Tengo un filtro de seguridad de primavera personalizado que realiza una autenticación basada en 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);

        }

    }

}

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

}

Y con esta clase para manejar excepciones a nivel mundial:

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

Lo que tengo que hacer es devolver un cuerpo JSON detallado incluso para Spring Security AuthenticationException. ¿Hay alguna manera de hacer que Spring Security AuthenticationEntryPoint y spring mvc @ExceptionHandler funcionen juntos?

Estoy usando Spring Security 3.1.4 y Spring MVC 3.2.4.

Nicola
fuente
9
No puede ... El (@)ExceptionHandlersolo funcionará si la solicitud es manejada por DispatcherServlet. Sin embargo, esta excepción ocurre antes de eso, ya que es lanzada por un Filter. Por lo tanto, nunca podrá manejar esta excepción con un (@)ExceptionHandler.
M. Deinum
Esta bien, tienes razón. ¿Hay alguna manera de devolver un cuerpo json junto con el response.sendError del EntryPoint?
Nicola
Parece que necesita insertar un filtro personalizado al principio de la cadena para detectar la excepción y regresar en consecuencia. La documentación enumera los filtros, sus alias y el orden en que se aplican: docs.spring.io/spring-security/site/docs/3.1.4.RELEASE/…
Romski
1
Si es la única ubicación donde necesita el JSON, simplemente constrúyalo / escríbalo dentro de EntryPoint. Es posible que desee construir el objeto allí e inyectar un MappingJackson2HttpMessageConverterallí.
M. Deinum
@ M.Deinum Intentaré construir el json dentro del punto de entrada.
Nicola

Respuestas:

58

Ok, intenté como se sugirió escribir el json desde AuthenticationEntryPoint y funciona.

Solo para probar, cambié AutenticationEntryPoint eliminando 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() + "\" }");

    }
}

De esta manera, puede enviar datos json personalizados junto con el 401 sin autorización incluso si está utilizando Spring Security AuthenticationEntryPoint.

Obviamente, no compilaría el json como lo hice yo con fines de prueba, pero serializaría alguna instancia de clase.

Nicola
fuente
3
Ejemplo usando Jackson: ObjectMapper mapper = new ObjectMapper (); mapper.writeValue (response.getOutputStream (), new FailResponse (401, authException.getLocalizedMessage (), "Acceso denegado", ""));
Cyrusmith
1
Sé que la pregunta es un poco antigua, pero ¿registró su AuthenticationEntryPoint en SecurityConfig?
Leventunver
1
@leventunver Aquí puede encontrar cómo registrar el punto de entrada: stackoverflow.com/questions/24684806/… .
Nicola
37

Este es un problema muy interesante de que Spring Security y Spring Web framework no son del todo consistentes en la forma en que manejan la respuesta. Creo que tiene que admitir de forma nativa el manejo de mensajes de error conMessageConverter de una manera práctica.

Traté de encontrar una forma elegante de inyectar MessageConverteren Spring Security para que pudieran detectar la excepción y devolverlos en el formato correcto de acuerdo con la negociación de contenido . Aún así, mi solución a continuación no es elegante, pero al menos hace uso del código Spring.

Supongo que sabe cómo incluir la biblioteca Jackson y JAXB, de lo contrario, no tiene sentido continuar. Hay 3 pasos en total.

Paso 1: crea una clase independiente, almacena MessageConverters

Esta clase no juega a la magia. Simplemente almacena los convertidores de mensajes y un procesador RequestResponseBodyMethodProcessor. La magia está dentro de ese procesador que hará todo el trabajo, incluida la negociación de contenido y la conversión del cuerpo de respuesta en consecuencia.

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

Paso 2: crear AuthenticationEntryPoint

Como en muchos tutoriales, esta clase es esencial para implementar el manejo de errores 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();
        }
    }
}

Paso 3: registrar el punto de entrada

Como se mencionó, lo hago con Java Config. Solo muestro la configuración relevante aquí, debería haber otra configuración como sesión sin estado , etc.

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

Pruebe con algunos casos de error de autenticación, recuerde que el encabezado de la solicitud debe incluir Accept: XXX y debe obtener la excepción en JSON, XML o algunos otros formatos.

Victor Wong
fuente
1
Estoy tratando de capturar un mensaje, InvalidGrantExceptionpero mi versión de tu CustomEntryPointno se invoca. ¿Alguna idea de lo que podría estar perdiendo?
displayname
@nombre para mostrar. Todas las excepciones de autenticación que no pueden ser capturadas por AuthenticationEntryPoint y AccessDeniedHandlertales como UsernameNotFoundExceptiony InvalidGrantExceptionpueden ser manejados por AuthenticationFailureHandlercomo se explica aquí .
Wilson
23

La mejor manera que he encontrado es delegar la excepción al 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);
    }
}

luego puede usar @ExceptionHandler para formatear la respuesta de la manera que desee.

Christophe Bornet
fuente
9
Funciona de maravilla. Si Spring arroja un error que dice que hay 2 definiciones de bean para autowirering, debe agregar una anotación de calificador: @Autowired @Qualifier ("handlerExceptionResolver") private HandlerExceptionResolver resolver;
Daividh
1
Tenga en cuenta que al pasar un controlador nulo, @ControllerAdviceno funcionará si ha especificado paquetes base en la anotación. Tuve que eliminar esto por completo para permitir que se llamara al controlador.
Jarmex
¿Por qué diste @Component("restAuthenticationEntryPoint")? ¿Por qué la necesidad de un nombre como restAuthenticationEntryPoint? ¿Es para evitar algunas colisiones de nombres de Spring?
theprogrammer
@Jarmex Entonces, en lugar de nulo, ¿qué pasó? es una especie de manejador, ¿verdad? ¿Debo aprobar una clase que ha sido anotada con @ControllerAdvice? Gracias
theprogrammer
@theprogrammer, tuve que reestructurar ligeramente la aplicación para eliminar el parámetro de anotación de basePackages para evitarlo, ¡no es lo ideal!
Jarmex
5

En el caso de Spring Boot y @EnableResourceServer, es relativamente fácil y conveniente extender en ResourceServerConfigurerAdapterlugar de WebSecurityConfigurerAdapteren la configuración de Java y registrar una costumbre AuthenticationEntryPointanulando configure(ResourceServerSecurityConfigurer resources)y usandoresources.authenticationEntryPoint(customAuthEntryPoint()) dentro del método.

Algo como esto:

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

También hay algo bueno OAuth2AuthenticationEntryPointque puede extenderse (ya que no es definitivo) y reutilizarse parcialmente al implementar unAuthenticationEntryPoint . En particular, agrega encabezados "WWW-Authenticate" con detalles relacionados con el error.

Espero que esto ayude a alguien.

Vladimir Salin
fuente
Estoy intentando esto, pero la commence()función de mi AuthenticationEntryPointno se invoca, ¿me falta algo?
visualización
4

Tomando respuestas de @Nicola y @Victor Wing y agregando una forma más estandarizada:

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

}

Ahora, puede inyectar Jackson configurado, Jaxb o lo que sea que use para convertir cuerpos de respuesta en su anotación MVC o configuración basada en XML con sus serializadores, deserializadores, etc.

Gabriel Villacis
fuente
Soy muy nuevo en Spring Boot: por favor dígame "cómo pasar el objeto messageConverter al punto de autenticación"
Kona Suresh
A través del setter. Cuando usa XML, debe crear una <property name="messageConverter" ref="myConverterBeanName"/>etiqueta. Cuando use una @Configurationclase, simplemente use el setMessageConverter()método.
Gabriel Villacis
4

Necesitamos usar HandlerExceptionResolveren ese 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);
    }
}

Además, debe agregar la clase de controlador de excepciones para devolver su 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;
    }
}

puede obtener un error en el momento de la ejecución de un proyecto debido a múltiples implementaciones de HandlerExceptionResolver, En ese caso hay que añadir @Qualifier("handlerExceptionResolver")elHandlerExceptionResolver

Vinit Solanki
fuente
GenericResponseBeanes solo java pojo, puedes crear el tuyo propio
Vinit Solanki
2

Pude manejar eso simplemente anulando el método 'unsuccessfulAuthentication' en mi filtro. Allí, envío una respuesta de error al cliente con el código de estado HTTP deseado.

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

Actualización: si te gusta y prefieres ver el código directamente, tengo dos ejemplos para ti, uno que usa Spring Security estándar, que es lo que estás buscando, el otro usa el equivalente de Reactive Web y Reactive Security:
- Normal Seguridad Web + Jwt
- Jwt reactivo

El que siempre uso para mis puntos finales basados ​​en JSON se parece a lo siguiente:

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

El asignador de objetos se convierte en un bean una vez que agrega el iniciador web de primavera, pero prefiero personalizarlo, así que aquí está mi implementación 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;
    }

El AuthenticationEntryPoint predeterminado que estableció en su clase 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
fuente
1

Personalice el filtro y determine qué tipo de anomalía, debería haber un método mejor 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;
    }
}

}

Fuerte
fuente
0

Estoy usando objectMapper. Cada servicio de descanso funciona principalmente con json, y en una de sus configuraciones ya ha configurado un mapeador de objetos.

El código está escrito en Kotlin, con suerte estará bien.

@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
fuente
0

En ResourceServerConfigurerAdapterclase, el siguiente código recortado funcionó para mí. http.exceptionHandling().authenticationEntryPoint(new AuthFailureHandler()).and.csrf()..no funcionó. Por eso lo escribí como una llamada 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();
    }

Implementación de AuthenticationEntryPoint para detectar el vencimiento del token y el encabezado de autorización faltante.


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
fuente
no funciona ... todavía muestra el mensaje predeterminado
aswzen