Gérez les exceptions d'authentification de sécurité Spring avec @ExceptionHandler

97

J'utilise Spring MVC @ControllerAdviceet @ExceptionHandlerpour gérer toutes les exceptions d'une API REST. Cela fonctionne bien pour les exceptions levées par les contrôleurs Web mvc, mais cela ne fonctionne pas pour les exceptions levées 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é Spring personnalisé qui effectue une authentification basée sur des jetons:

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 Spring Security AuthenticationException. 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.

Nicola
la source
9
Vous ne pouvez pas ... Le (@)ExceptionHandlerne fonctionnera que si la demande est gérée par le DispatcherServlet. Cependant, cette exception se produit avant cela car elle est levée par un Filter. Vous ne pourrez donc jamais gérer cette exception avec un fichier (@)ExceptionHandler.
M. Deinum
OK tu as raison. Existe-t-il un moyen de renvoyer un corps json avec le response.sendError de EntryPoint?
Nicola
On dirait que vous devez insérer un filtre personnalisé plus tôt dans la chaîne pour attraper l'exception et revenir en conséquence. La documentation liste les filtres, leurs alias et l'ordre dans lequel
Romski
1
Si le seul emplacement où vous avez besoin du JSON, construisez / écrivez-le simplement dans le fichier EntryPoint. Vous voudrez peut-être y construire l'objet et y injecter un MappingJackson2HttpMessageConverter.
M. Deinum
@ M.Deinum Je vais essayer de construire le json à l'intérieur du point d'entrée.
Nicola

Réponses:

58

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

Juste pour tester, j'ai modifié 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 façon, vous pouvez envoyer des données json personnalisées avec le 401 non autorisé même si vous utilisez Spring Security AuthenticationEntryPoint.

Évidemment, vous ne construiriez pas le json comme je l'ai fait à des fins de test, mais vous sérialiseriez une instance de classe.

Nicola
la source
3
Exemple utilisant Jackson: ObjectMapper mapper = new ObjectMapper (); mapper.writeValue (response.getOutputStream (), nouveau FailResponse (401, authException.getLocalizedMessage (), "Accès refusé", ""));
Cyrusmith
1
Je sais que la question est un peu ancienne, mais avez-vous enregistré votre AuthenticationEntryPoint sur SecurityConfig?
leventunver
1
@leventunver Vous trouverez ici comment enregistrer le point d'entrée: stackoverflow.com/questions/24684806/… .
Nicola
37

C'est un problème très intéressant que Spring Security et Spring Web Framework ne sont pas tout à fait cohérents dans la manière dont ils gèrent la réponse. Je pense qu'il doit prendre en charge nativement la gestion des messages d'erreur de MessageConvertermanière pratique.

J'ai essayé de trouver un moyen élégant d'injecter MessageConverterdans Spring Security afin qu'ils puissent attraper l'exception et les renvoyer dans un bon format en fonction de la négociation du contenu . Pourtant, 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 ne sert à rien de continuer. Il y a 3 étapes au total.

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

Cette classe ne joue pas de magie. Il stocke simplement les convertisseurs de messages et un processeur RequestResponseBodyMethodProcessor. La magie est à l'intérieur de ce processeur qui fera tout le travail, y compris la négociation de 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 didacticiels, cette classe est essentielle pour implémenter la gestion des erreurs 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 - Enregistrer le point d'entrée

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

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

Essayez avec certains cas d'échec d'authentification, rappelez-vous que l'en-tête de la demande doit inclure Accept: XXX et que vous devez obtenir l'exception au format JSON, XML ou dans d'autres formats.

Victor Wong
la source
1
J'essaye d'attraper un InvalidGrantExceptionmais ma version de votre CustomEntryPointn'est pas invoquée. Une idée de ce que je pourrais manquer?
displayname
@Afficher un nom. Toutes les exceptions d'authentification qui ne peuvent pas être interceptées AuthenticationEntryPoint et AccessDeniedHandlertelles que UsernameNotFoundExceptionet InvalidGrantExceptionpeuvent être traitées par AuthenticationFailureHandlercomme expliqué ici .
Wilson
23

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

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

Christophe Bornet
la source
9
Fonctionne comme un charme. Si Spring renvoie une erreur indiquant qu'il existe 2 définition de bean pour l'autowirering, vous devez ajouter l'annotation de qualificatif: @Autowired @Qualifier ("handlerExceptionResolver") private HandlerExceptionResolver resolver;
Daividh
1
Sachez qu'en passant un gestionnaire nul, votre @ControllerAdvicene fonctionnera pas si vous avez spécifié basePackages sur l'annotation. J'ai dû supprimer cela entièrement pour permettre au gestionnaire d'être appelé.
Jarmex
Pourquoi avez-vous donné @Component("restAuthenticationEntryPoint")? Pourquoi le besoin d'un nom comme restAuthenticationEntryPoint? Est-ce pour éviter certaines collisions de noms Spring?
theprogrammer le
@Jarmex Alors à la place de null, qu'avez-vous passé? c'est une sorte de gestionnaire non? Dois-je simplement passer une classe qui a été annotée avec @ControllerAdvice? Merci
theprogrammer
@theprogrammer, j'ai dû restructurer légèrement l'application pour supprimer le paramètre d'annotation basePackages pour le contourner - pas idéal!
Jarmex le
5

Dans le cas de Spring Boot et @EnableResourceServer, il est relativement facile et pratique d'étendre ResourceServerConfigurerAdapterau lieu de WebSecurityConfigurerAdapterdans la configuration Java et d'enregistrer une personnalisation AuthenticationEntryPointen remplaçant configure(ResourceServerSecurityConfigurer resources)et en utilisant resources.authenticationEntryPoint(customAuthEntryPoint())à l'intérieur de 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 y a aussi un joli OAuth2AuthenticationEntryPointqui peut être étendu (puisqu'il n'est pas définitif) et partiellement réutilisé lors de l'implémentation d'un custom AuthenticationEntryPoint. En particulier, il ajoute des en-têtes "WWW-Authenticate" avec des détails relatifs aux erreurs.

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

Vladimir Salin
la source
J'essaye ceci mais la commence()fonction de mon AuthenticationEntryPointn'est pas appelée - est-ce que je manque quelque chose?
displayname
4

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 des Jackson, Jaxb configurés ou tout ce que vous utilisez pour convertir les corps de réponse sur votre annotation MVC ou votre configuration basée sur XML avec ses sérialiseurs, désérialiseurs, etc.

Gabriel Villacis
la source
Je suis tout nouveau dans le domaine du printemps: dites-moi "comment passer l'objet messageConverter au point authenticationEntry"
Kona Suresh
Par le setter. Lorsque vous utilisez XML, vous devez créer une <property name="messageConverter" ref="myConverterBeanName"/>balise. Lorsque vous utilisez une @Configurationclasse, utilisez simplement la setMessageConverter()méthode.
Gabriel Villacis
4

Nous devons utiliser HandlerExceptionResolverdans 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 de 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;
    }
}

pouvez - vous obtenir une erreur au moment de l' exécution d' un projet en raison de multiples implémentations de HandlerExceptionResolver, Dans ce cas , vous devez ajouter @Qualifier("handlerExceptionResolver")surHandlerExceptionResolver

Vinit Solanki
la source
GenericResponseBeanest juste java pojo, pouvez-vous créer le vôtre
Vinit Solanki
2

J'ai pu gérer cela en remplaçant simplement la méthode «unsuccessfulAuthentication» dans mon filtre. Là, 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());
    }
}
user3619911
la source
1

Mise à jour: Si vous aimez et préférez voir le code directement, j'ai deux exemples pour vous, l'un utilisant Spring Security standard, ce que vous recherchez, l'autre utilisant l'équivalent de Reactive Web et Reactive Security:
- Normal Web + Jwt Security
- Reactive Jwt

Celui que j'utilise toujours pour mes points de terminaison basés sur JSON ressemble à ceci:

@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'objet devient un bean une fois que vous ajoutez le démarreur Web de printemps, mais je préfère le personnaliser, voici donc mon implémentation 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 par défaut que vous définissez 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);
    }
// ..........
}
Melardev
la source
1

Personnalisez le filtre et déterminez quel type d'anomalie, il devrait y avoir une meilleure méthode que celle-ci

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

}

Chamois
la source
0

J'utilise objectMapper. Chaque service de repos fonctionne 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)
     }}
Spin1987
la source
0

En ResourceServerConfigurerAdapterclasse, le code ci-dessous a fonctionné pour moi. http.exceptionHandling().authenticationEntryPoint(new AuthFailureHandler()).and.csrf()..n'a pas marché. C'est pourquoi je l'ai écrit séparément.

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

Implémentation d'AuthenticationEntryPoint pour détecter l'expiration du jeton et l'en-tête d'autorisation manquant.


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
la source
ne fonctionne pas .. affiche toujours le message par défaut
aswzen