web-dev-qa-db-fra.com

Gestion des exceptions d'authentification de sécurité de printemps avec @ExceptionHandler

J'utilise les codes @ControllerAdvice et @ExceptionHandler de Spring MVC pour gérer toutes les exceptions d'une api REST. Cela fonctionne bien pour les exceptions émises par les contrôleurs Web MVC, mais pas pour les exceptions émises par les filtres personnalisés de sécurité Spring, car ils s'exécutent avant que les méthodes du contrôleur ne soient appelées.

J'ai un filtre de sécurité à ressort personnalisé qui effectue une authentification basée sur un jeton:

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

        }

    }

}

Avec ce point d’entrée personnalisé:

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

}

Et avec cette classe pour gérer les exceptions globalement:

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

Ce que je dois faire est de renvoyer un corps JSON détaillé même pour AuthenticationException de sécurité Spring. Existe-t-il un moyen de faire en sorte que spring security AuthenticationEntryPoint et spring mvc @ExceptionHandler fonctionnent ensemble?

J'utilise Spring Security 3.1.4 et Spring MVC 3.2.4.

66
Nicola

Ok, j'ai essayé comme suggéré d'écrire le json moi-même à partir de AuthenticationEntryPoint et cela fonctionne.

Juste pour les tests, j'ai changé AutenticationEntryPoint en supprimant 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 cette manière, vous pouvez envoyer des données JSON personnalisées avec le 401 sans autorisation, même si vous utilisez Spring Security AuthenticationEntryPoint.

Évidemment, vous ne construirez pas le JSON comme je l’ai fait à des fins de test, mais vous seriez en train de sérialiser une instance de classe.

41
Nicola

C’est un problème très intéressant, car les structures Spring Security et Spring Web ne sont pas tout à fait cohérentes dans la manière dont elles gèrent la réponse. Je crois qu’il doit supporter nativement la gestion des messages d’erreur avec MessageConverter de manière pratique.

J'ai essayé de trouver un moyen élégant d'injecter MessageConverter dans Spring Security afin qu'ils puissent intercepter l'exception et les renvoyer dans le bon format en fonction de la négociation de contenu . Malgré tout, ma solution ci-dessous n’est pas élégante mais utilise au moins le code Spring.

Je suppose que vous savez comment inclure la bibliothèque Jackson et JAXB, sinon il est inutile de continuer. Il y a 3 étapes au total.

Étape 1 - Créez une classe autonome, stockant MessageConverters

Cette classe ne joue pas de magie. Il stocke simplement les convertisseurs de messages et un processeur RequestResponseBodyMethodProcessor. La magie réside dans ce processeur qui effectue tout le travail, y compris la négociation du contenu et la conversion du corps de la réponse en conséquence.

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

Étape 2 - Créer AuthenticationEntryPoint

Comme dans de nombreux tutoriels, cette classe est essentielle pour implémenter la gestion d'erreur personnalisée.

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

Étape 3 - Enregistrez le point d'entrée

Comme mentionné, je le fais avec Java Config. Je montre juste la configuration pertinente ici, il devrait y avoir une autre configuration telle que session stateless, etc.

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

Essayez avec quelques cas d'échec d'authentification, souvenez-vous que l'en-tête de requête doit inclure Accept: XXX et que vous devriez obtenir une exception au format JSON, XML ou dans d'autres formats.

32
Victor Wong

Le meilleur moyen que j'ai trouvé est de déléguer l'exception à 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);
    }
}

vous pouvez ensuite utiliser @ExceptionHandler pour formater la réponse comme vous le souhaitez.

12

Dans le cas de Spring Boot et de @EnableResourceServer, il est relativement facile et pratique d'étendre ResourceServerConfigurerAdapter au lieu de WebSecurityConfigurerAdapter dans la configuration Java et d'enregistrer une AuthenticationEntryPoint personnalisée en remplaçant configure(ResourceServerSecurityConfigurer resources) et en utilisant resources.authenticationEntryPoint(customAuthEntryPoint()) dans la méthode. 

Quelque chose comme ça:

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

Il existe également un Nice OAuth2AuthenticationEntryPoint qui peut être étendu (puisqu'il n'est pas final) et partiellement réutilisé lors de l'implémentation d'une AuthenticationEntryPoint personnalisée. En particulier, il ajoute des en-têtes "WWW-Authenticate" avec des détails liés aux erreurs.

J'espère que cela aidera quelqu'un. 

4
Vladimir Salin

Prendre les réponses de @Nicola et @Victor Wing et ajouter une méthode plus standardisée:

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

}

Maintenant, vous pouvez injecter une configuration de Jackson, Jaxb ou tout ce que vous utilisez pour convertir les corps de réponse sur votre annotation MVC ou votre configuration basée XML avec ses sérialiseurs, ses désérialiseurs, etc.

3
Gabriel Villacis

J'ai pu gérer cela en remplaçant simplement la méthode 'unsuccessfulAuthentication' dans mon filtre. J'envoie une réponse d'erreur au client avec le code d'état HTTP souhaité.

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

J'utilise le objectMapper. Chaque service Rest travaille principalement avec json et dans l'une de vos configurations, vous avez déjà configuré un mappeur d'objets.

Le code est écrit en Kotlin, j'espère que ça ira.

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

Mise à jour: Si vous aimez le code et que vous préférez le voir directement, je vous présente deux exemples, l'un utilisant Spring Security, qui correspond à ce que vous recherchez. , l’autre utilise l’équivalent de Reactive Web et Reactive Security:
- Sécurité Web + Jwt normale
- Jwt réactif

Celui que j'utilise toujours pour mes points de terminaison JSON ressemble à ce qui suit:

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

Le mappeur d'objets est déjà un haricot une fois que vous avez ajouté le démarreur de printemps, mais je préfère le personnaliser. Voici ce que je fais pour 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 que vous avez défini comme valeur par défaut dans votre 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);
    }
// ..........
}
0
Melardev

Nous devons utiliser HandlerExceptionResolver dans ce cas.

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

En outre, vous devez ajouter la classe du gestionnaire d'exceptions pour renvoyer votre objet.

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

vous risquez d'obtenir une erreur lors de l'exécution d'un projet en raison de plusieurs implémentations de HandlerExceptionResolver, dans ce cas, vous devez ajouter @Qualifier("handlerExceptionResolver") sur HandlerExceptionResolver

0
Vinit Solanki