Gestisci le eccezioni di autenticazione della sicurezza primaverile con @ExceptionHandler


97

Sto usando Spring MVC @ControllerAdvicee @ExceptionHandlerper gestire tutte le eccezioni di un'API REST. Funziona bene per le eccezioni lanciate dai controller web mvc ma non funziona per le eccezioni lanciate dai filtri personalizzati di sicurezza primaverile perché vengono eseguiti prima che i metodi del controller vengano richiamati.

Ho un filtro di sicurezza primaverile personalizzato che esegue un'autenticazione basata su 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 questo punto di ingresso personalizzato:

@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 con questa classe per gestire le eccezioni a livello globale:

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

Quello che devo fare è restituire un corpo JSON dettagliato anche per AuthenticationException di sicurezza primaverile. Esiste un modo per far funzionare insieme AuthenticationEntryPoint e spring mvc @ExceptionHandler di Spring Security?

Sto usando spring security 3.1.4 e spring mvc 3.2.4.


9
Non puoi ... (@)ExceptionHandlerFunzionerà solo se la richiesta viene gestita da DispatcherServlet. Tuttavia questa eccezione si verifica prima poiché viene lanciata da un file Filter. Quindi non sarai mai in grado di gestire questa eccezione con un file (@)ExceptionHandler.
M. Deinum

Ok, hai ragione. C'è un modo per restituire un corpo json insieme a response.sendError di EntryPoint?
Nicola

Sembra che sia necessario inserire un filtro personalizzato in precedenza nella catena per catturare l'eccezione e tornare di conseguenza. La documentazione elenca i filtri, i loro alias e l'ordine in cui vengono applicati: docs.spring.io/spring-security/site/docs/3.1.4.RELEASE/…
Romski

1
Se l'unica posizione di cui hai bisogno è JSON, crea / scrivi semplicemente all'interno del file EntryPoint. Potresti voler costruire l'oggetto lì e iniettare un MappingJackson2HttpMessageConverterlì.
M. Deinum

@ M.Deinum cercherò di costruire il json all'interno del punto di ingresso.
Nicola

Risposte:


58

Ok, ho provato come suggerito a scrivere il json da solo da AuthenticationEntryPoint e funziona.

Solo per il test ho cambiato AutenticationEntryPoint rimuovendo 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() + "\" }");

    }
}

In questo modo puoi inviare dati json personalizzati insieme al 401 non autorizzato anche se stai utilizzando Spring Security AuthenticationEntryPoint.

Ovviamente non costruiresti il ​​json come ho fatto io a scopo di test ma serializzeresti qualche istanza di classe.


3
Esempio di utilizzo di Jackson: ObjectMapper mapper = new ObjectMapper (); mapper.writeValue (response.getOutputStream (), new FailResponse (401, authException.getLocalizedMessage (), "Accesso negato", ""));
Cyrusmith

1
So che la domanda è un po 'vecchia, ma hai registrato il tuo AuthenticationEntryPoint in SecurityConfig?
leventunver

1
@leventunver Qui puoi trovare come registrare il punto di ingresso: stackoverflow.com/questions/24684806/… .
Nicola

37

Questo è un problema molto interessante che Spring Security e il framework Spring Web non sono abbastanza coerenti nel modo in cui gestiscono la risposta. Credo che debba supportare nativamente la gestione dei messaggi di errore conMessageConverter in modo pratico.

Ho cercato di trovare un modo elegante per iniettare MessageConverterin Spring Security in modo che potessero catturare l'eccezione e restituirli nel formato corretto in base alla negoziazione del contenuto . Tuttavia, la mia soluzione di seguito non è elegante, ma almeno usa il codice Spring.

Presumo che tu sappia come includere la libreria Jackson e JAXB, altrimenti non ha senso procedere. Ci sono 3 passaggi in totale.

Passaggio 1: creare una classe autonoma, archiviando MessageConverters

Questa classe non gioca nessuna magia. Memorizza semplicemente i convertitori di messaggi e un processore RequestResponseBodyMethodProcessor. La magia è all'interno di quel processore che farà tutto il lavoro, inclusa la negoziazione del contenuto e la conversione del corpo di risposta di conseguenza.

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

Passaggio 2: creare AuthenticationEntryPoint

Come in molti tutorial, questa classe è essenziale per implementare la gestione degli errori personalizzata.

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

Passaggio 3: registrare il punto di ingresso

Come accennato, lo faccio con Java Config. Ho solo mostrato la configurazione pertinente qui, dovrebbe esserci un'altra configurazione come la sessione senza stato , ecc.

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

Prova con alcuni casi di errore di autenticazione, ricorda che l'intestazione della richiesta dovrebbe includere Accept: XXX e dovresti ottenere l'eccezione in JSON, XML o altri formati.


1
Sto cercando di catturarne uno InvalidGrantExceptionma la mia versione del tuo CustomEntryPointnon viene richiamata. Qualche idea su cosa potrei perdermi?
displayname

@nome da visualizzare. Tutte le eccezioni di autenticazione che non possono essere rilevate AuthenticationEntryPoint e AccessDeniedHandlercome UsernameNotFoundExceptione InvalidGrantExceptionpossono essere gestite AuthenticationFailureHandlercome spiegato qui .
Wilson

23

Il modo migliore che ho trovato è delegare l'eccezione a 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);
    }
}

quindi puoi utilizzare @ExceptionHandler per formattare la risposta nel modo desiderato.


9
Funziona come un fascino. Se Spring genera un errore che dice che ci sono 2 definizione di bean per l'autowirering, è necessario aggiungere l'annotazione del qualificatore: @Autowired @Qualifier ("handlerExceptionResolver") private HandlerExceptionResolver resolver;
Daividh

1
Tieni presente che passando un gestore null, @ControllerAdvicenon funzionerà se hai specificato basePackages nell'annotazione. Ho dovuto rimuoverlo completamente per consentire la chiamata del gestore.
Jarmex

Perché hai dato @Component("restAuthenticationEntryPoint")? Perché la necessità di un nome come restAuthenticationEntryPoint? È per evitare alcune collisioni di nomi di primavera?
programmatore

@Jarmex Quindi al posto di null, cosa hai passato? è una specie di gestore, giusto? Devo semplicemente passare una classe che è stata annotata con @ControllerAdvice? Grazie
theprogrammer

@ theprogrammer, ho dovuto ristrutturare leggermente l'applicazione per rimuovere il parametro di annotazione basePackages per aggirarlo - non è l'ideale!
Jarmex

5

In caso di Spring Boot e @EnableResourceServer, è relativamente facile e conveniente estendere ResourceServerConfigurerAdapterinvece che WebSecurityConfigurerAdapternella configurazione Java e registrare un custom AuthenticationEntryPointsovrascrivendo configure(ResourceServerSecurityConfigurer resources)e utilizzando resources.authenticationEntryPoint(customAuthEntryPoint())all'interno del metodo.

Qualcosa come questo:

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

C'è anche un simpatico OAuth2AuthenticationEntryPointche può essere esteso (dato che non è definitivo) e parzialmente riutilizzato durante l'implementazione di un custom AuthenticationEntryPoint. In particolare, aggiunge intestazioni "WWW-Authenticate" con dettagli relativi agli errori.

Spero che questo possa aiutare qualcuno.


Ci sto provando ma la commence()funzione del mio AuthenticationEntryPointnon viene richiamata - mi manca qualcosa?
displayname

4

Prendendo le risposte da @Nicola e @Victor Wing e aggiungendo un modo più standardizzato:

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

}

Ora puoi iniettare Jackson, Jaxb o qualsiasi altra cosa che usi per convertire i corpi di risposta sulla tua annotazione MVC o configurazione basata su XML con i suoi serializzatori, deserializzatori e così via.


Sono molto nuovo all'avvio primaverile: per favore dimmi "come passare l'oggetto messageConverter al punto di autenticazione"
Kona Suresh

Attraverso il setter. Quando usi XML devi creare un <property name="messageConverter" ref="myConverterBeanName"/>tag. Quando usi una @Configurationclasse usa semplicemente il setMessageConverter()metodo.
Gabriel Villacis

4

Dobbiamo usare HandlerExceptionResolverin quel 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);
    }
}

Inoltre, è necessario aggiungere la classe del gestore delle eccezioni per restituire l'oggetto.

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

potrebbe si ottiene un errore al momento di realizzare un progetto a causa di molteplici implementazioni di HandlerExceptionResolver, In questo caso dovete aggiungere @Qualifier("handlerExceptionResolver")suHandlerExceptionResolver


GenericResponseBeanè solo java pojo, potresti crearne uno tuo
Vinit Solanki

2

Sono stato in grado di gestirlo semplicemente sovrascrivendo il metodo "unsuccessfulAuthentication" nel mio filtro. Lì, invio una risposta di errore al client con il codice di stato HTTP desiderato.

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

1

Aggiornamento: se ti piace e preferisci vedere il codice direttamente, allora ho due esempi per te, uno che utilizza lo standard Spring Security che è quello che stai cercando, l'altro utilizza l'equivalente di Reactive Web e Reactive Security:
- Normale Web + Jwt Security
- Reactive Jwt

Quello che utilizzo sempre per i miei endpoint basati su JSON è simile al seguente:

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

Il mappatore di oggetti diventa un bean una volta aggiunto il web starter di primavera, ma preferisco personalizzarlo, quindi ecco la mia implementazione per 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;
    }

Il AuthenticationEntryPoint predefinito impostato nella 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);
    }
// ..........
}

1

Personalizza il filtro e determina quale tipo di anomalia dovrebbe esserci un metodo migliore di questo

public class ExceptionFilter extends OncePerRequestFilter {

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

}


0

Sto usando objectMapper. Ogni servizio di riposo funziona principalmente con json e in una delle tue configurazioni hai già configurato un mappatore di oggetti.

Il codice è scritto in Kotlin, si spera che sia ok.

@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

In ResourceServerConfigurerAdapterclasse, il codice seguente ha funzionato per me. http.exceptionHandling().authenticationEntryPoint(new AuthFailureHandler()).and.csrf()..non ha funzionato. Ecco perché l'ho scritto come chiamata separata.

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

Implementazione di AuthenticationEntryPoint per rilevare la scadenza del token e l'intestazione di autorizzazione mancante.


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

  }
}

non funziona .. mostra ancora il messaggio predefinito
aswzen
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.