Test unitari con Spring Security


140

La mia azienda sta valutando Spring MVC per determinare se dovremmo utilizzarlo in uno dei nostri prossimi progetti. Finora adoro quello che ho visto e in questo momento sto dando un'occhiata al modulo Spring Security per determinare se è qualcosa che possiamo / dovremmo usare.

I nostri requisiti di sicurezza sono piuttosto basilari; un utente deve solo essere in grado di fornire un nome utente e una password per poter accedere a determinate parti del sito (ad esempio per ottenere informazioni sul proprio account); e ci sono una manciata di pagine sul sito (FAQ, Supporto, ecc.) in cui un utente anonimo dovrebbe avere accesso.

Nel prototipo che ho creato, ho archiviato un oggetto "LoginCredentials" (che contiene solo nome utente e password) in Sessione per un utente autenticato; alcuni controller controllano per vedere se questo oggetto è in sessione per ottenere un riferimento al nome utente registrato, ad esempio. Sto invece cercando di sostituire questa logica di casa con Spring Security, che avrebbe il vantaggio di rimuovere qualsiasi tipo di "come tracciamo gli utenti che hanno effettuato l'accesso?" e "come possiamo autenticare gli utenti?" dal mio controller / codice aziendale.

Sembra che Spring Security fornisca un oggetto "contestuale" (per thread) per poter accedere al nome utente / alle informazioni principali da qualsiasi punto della tua app ...

Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

... che sembra molto non-Spring come questo oggetto è un singleton (globale), in un certo senso.

La mia domanda è questa: se questo è il modo standard per accedere alle informazioni sull'utente autenticato in Spring Security, qual è il modo accettato per iniettare un oggetto Authentication in SecurityContext in modo che sia disponibile per i test delle mie unità quando i test delle unità richiedono un utente autenticato?

Devo collegarlo nel metodo di inizializzazione di ciascun caso di test?

protected void setUp() throws Exception {
    ...
    SecurityContextHolder.getContext().setAuthentication(
        new UsernamePasswordAuthenticationToken(testUser.getLogin(), testUser.getPassword()));
    ...
}

Questo sembra eccessivamente prolisso. C'è un modo più semplice?

L' SecurityContextHolderoggetto stesso sembra molto non-Spring ...

Risposte:


48

Il problema è che Spring Security non rende disponibile l'oggetto Authentication come bean nel contenitore, quindi non c'è modo di iniettarlo facilmente o di crearlo automaticamente.

Prima di iniziare a utilizzare Spring Security, dovevamo creare un bean con ambito sessione nel contenitore per archiviare il principale, iniettarlo in un "AuthenticationService" (singleton) e quindi iniettare questo bean in altri servizi che necessitavano di conoscenza del principale corrente.

Se stai implementando il tuo servizio di autenticazione, puoi sostanzialmente fare la stessa cosa: creare un bean con ambito sessione con una proprietà "principale", iniettarlo nel tuo servizio di autenticazione, fare in modo che il servizio auth abbia impostato la proprietà su auth riuscita, quindi rendere il servizio di autenticazione disponibile per altri bean quando ne hai bisogno.

Non mi dispiacerebbe molto usare SecurityContextHolder. anche se. So che è un statico / Singleton e che Spring scoraggia l'uso di tali cose, ma la loro implementazione si preoccupa di comportarsi in modo appropriato a seconda dell'ambiente: nell'ambito della sessione in un contenitore Servlet, ambito del thread in un test JUnit, ecc. Il vero fattore limitante di un Singleton è quando fornisce un'implementazione non flessibile per ambienti diversi.


Grazie, questo è un consiglio utile. Quello che ho fatto finora è fondamentalmente procedere con la chiamata di SecurityContextHolder.getContext () (attraverso alcuni metodi wrapper del mio, quindi almeno è chiamato solo da una classe).
opaco b

2
Anche se solo una nota - non credo che ServletContextHolder abbia alcun concetto di HttpSession o un modo per sapere se funziona in un ambiente web server - usa ThreadLocal a meno che non lo configuri per usare qualcos'altro (le uniche altre due modalità integrate sono InheritableThreadLocal e Global)
matt b

L'unico inconveniente dell'uso dei bean con ambito session / request in Spring è che falliranno in un test JUnit. Quello che puoi fare è implementare un ambito personalizzato che utilizzerà la sessione / richiesta se disponibile e sarà necessario ricorrere al thread. La mia ipotesi è che Spring Security stia facendo qualcosa di simile ...
cliff.meyers il

Il mio obiettivo è costruire un'API Rest senza sessioni. Forse con un token aggiornabile. Anche se questo non ha risposto alla mia domanda, ha aiutato. Grazie
Pomagranite,

166

Fallo nel solito modo e poi inseriscilo usando SecurityContextHolder.setContext()nella tua classe di test, ad esempio:

controller:

Authentication a = SecurityContextHolder.getContext().getAuthentication();

Test:

Authentication authentication = Mockito.mock(Authentication.class);
// Mockito.whens() for your authorization object
SecurityContext securityContext = Mockito.mock(SecurityContext.class);
Mockito.when(securityContext.getAuthentication()).thenReturn(authentication);
SecurityContextHolder.setContext(securityContext);

2
@Leonardo dove dovrebbe Authentication aessere aggiunto nel controller? Come posso capire in ogni metodo di invocazione? Va bene per "primavera" solo per aggiungerlo, invece di iniettare?
Oleg Kuts,

Ma ricorda che non funzionerà con TestNG perché SecurityContextHolder mantiene la variabile thread locale, quindi condividi questa variabile tra i test ...
Łukasz Woźniczka

Fallo in @BeforeEach(JUnit5) o @Before(JUnit 4). Buono e semplice.
WesternGun

30

Senza rispondere alla domanda su come creare e iniettare oggetti di autenticazione, Spring Security 4.0 offre alcune gradite alternative quando si tratta di test. L' @WithMockUserannotazione consente allo sviluppatore di specificare un utente fittizio (con autorizzazioni opzionali, nome utente, password e ruoli) in modo ordinato:

@Test
@WithMockUser(username = "admin", authorities = { "ADMIN", "USER" })
public void getMessageWithMockUserCustomAuthorities() {
    String message = messageService.getMessage();
    ...
}

C'è anche l'opzione da usare @WithUserDetailsper emulare un UserDetailsreso dal UserDetailsService, ad es

@Test
@WithUserDetails("customUsername")
public void getMessageWithUserDetailsCustomUsername() {
    String message = messageService.getMessage();
    ...
}

Maggiori dettagli sono disponibili nei capitoli @WithMockUser e @WithUserDetails nei documenti di riferimento di Spring Security (da cui sono stati copiati gli esempi precedenti)


29

Hai perfettamente ragione a preoccuparti: le chiamate a metodi statici sono particolarmente problematiche per i test unitari in quanto non puoi facilmente deridere le tue dipendenze. Quello che ho intenzione di mostrarti è come lasciare che il contenitore Spring IoC faccia il lavoro sporco per te, lasciandoti con un codice pulito e testabile. SecurityContextHolder è una classe di framework e sebbene possa essere giusto che il tuo codice di sicurezza di basso livello sia collegato ad esso, probabilmente vorrai esporre un'interfaccia più ordinata ai tuoi componenti dell'interfaccia utente (ad esempio i controller).

cliff.meyers ha citato un modo per aggirarlo: crea il tuo tipo "principale" e inietta un'istanza nei consumatori. Il tag Spring < aop: scoped-proxy /> introdotto in 2.x combinato con una definizione del bean dell'ambito della richiesta e il supporto del metodo factory può essere il ticket per il codice più leggibile.

Potrebbe funzionare come segue:

public class MyUserDetails implements UserDetails {
    // this is your custom UserDetails implementation to serve as a principal
    // implement the Spring methods and add your own methods as appropriate
}

public class MyUserHolder {
    public static MyUserDetails getUserDetails() {
        Authentication a = SecurityContextHolder.getContext().getAuthentication();
        if (a == null) {
            return null;
        } else {
            return (MyUserDetails) a.getPrincipal();
        }
    }
}

public class MyUserAwareController {        
    MyUserDetails currentUser;

    public void setCurrentUser(MyUserDetails currentUser) { 
        this.currentUser = currentUser;
    }

    // controller code
}

Fin qui niente di complicato, vero? In effetti probabilmente dovevi già fare gran parte di questo. Quindi, nel contesto del bean, definire un bean con ambito di richiesta per contenere l'entità:

<bean id="userDetails" class="MyUserHolder" factory-method="getUserDetails" scope="request">
    <aop:scoped-proxy/>
</bean>

<bean id="controller" class="MyUserAwareController">
    <property name="currentUser" ref="userDetails"/>
    <!-- other props -->
</bean>

Grazie alla magia di aop: scoped-proxy tag, il metodo statico getUserDetails verrà chiamato ogni volta che arriva una nuova richiesta HTTP e qualsiasi riferimento alla proprietà currentUser verrà risolto correttamente. Ora i test unitari diventano banali:

protected void setUp() {
    // existing init code

    MyUserDetails user = new MyUserDetails();
    // set up user as you wish
    controller.setCurrentUser(user);
}

Spero che questo ti aiuti!


9

Personalmente userei semplicemente Powermock insieme a Mockito o Easymock per deridere SecurityContextHolder.getSecurityContext () statico nel tuo test di unità / integrazione, ad es.

@RunWith(PowerMockRunner.class)
@PrepareForTest(SecurityContextHolder.class)
public class YourTestCase {

    @Mock SecurityContext mockSecurityContext;

    @Test
    public void testMethodThatCallsStaticMethod() {
        // Set mock behaviour/expectations on the mockSecurityContext
        when(mockSecurityContext.getAuthentication()).thenReturn(...)
        ...
        // Tell mockito to use Powermock to mock the SecurityContextHolder
        PowerMockito.mockStatic(SecurityContextHolder.class);

        // use Mockito to set up your expectation on SecurityContextHolder.getSecurityContext()
        Mockito.when(SecurityContextHolder.getSecurityContext()).thenReturn(mockSecurityContext);
        ...
    }
}

È vero che qui c'è un po 'di codice della piastra della caldaia, ovvero derisione di un oggetto di autenticazione, derisione di un SecurityContext per restituire l'autenticazione e infine derisione di SecurityContextHolder per ottenere SecurityContext, tuttavia è molto flessibile e consente di testare unità per scenari come oggetti null Authentication ecc. senza dover modificare il codice (non test)


7

L'uso di una statica in questo caso è il modo migliore per scrivere codice sicuro.

Sì, la statica è generalmente negativa - generalmente, ma in questo caso, la statica è ciò che vuoi. Poiché il contesto di sicurezza associa un principale al thread attualmente in esecuzione, il codice più sicuro accederà allo statico dal thread il più direttamente possibile. Nascondere l'accesso dietro una classe wrapper che viene iniettata fornisce a un attaccante più punti per attaccare. Non avrebbero bisogno di accedere al codice (che avrebbero difficoltà a cambiare se il jar fosse firmato), hanno solo bisogno di un modo per sovrascrivere la configurazione, che può essere fatta in fase di esecuzione o facendo scivolare un po 'di XML sul percorso di classe. Anche l'utilizzo dell'iniezione di annotazioni sarebbe sostituibile con XML esterno. Tale XML potrebbe iniettare il sistema in esecuzione con un'entità errata.


4

Ho posto la stessa domanda qui , e ho appena pubblicato una risposta che ho trovato di recente. La risposta breve è: iniettare a SecurityContext, e fare riferimento SecurityContextHoldersolo nella configurazione Spring per ottenere ilSecurityContext


3

Generale

Nel frattempo (dalla versione 3.2, nel 2013, grazie a SEC-2298 ) è possibile iniettare l'autenticazione nei metodi MVC usando l'annotazione @AuthenticationPrincipal :

@Controller
class Controller {
  @RequestMapping("/somewhere")
  public void doStuff(@AuthenticationPrincipal UserDetails myUser) {
  }
}

test

Nel test unitario puoi ovviamente chiamare direttamente questo metodo. Nei test di integrazione org.springframework.test.web.servlet.MockMvcche puoi usare puoi org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user()iniettare l'utente in questo modo:

mockMvc.perform(get("/somewhere").with(user(myUserDetails)));

Questo tuttavia riempirà direttamente SecurityContext. Se vuoi assicurarti che l'utente sia caricato da una sessione nel tuo test, puoi usare questo:

mockMvc.perform(get("/somewhere").with(sessionUser(myUserDetails)));
/* ... */
private static RequestPostProcessor sessionUser(final UserDetails userDetails) {
    return new RequestPostProcessor() {
        @Override
        public MockHttpServletRequest postProcessRequest(final MockHttpServletRequest request) {
            final SecurityContext securityContext = new SecurityContextImpl();
            securityContext.setAuthentication(
                new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities())
            );
            request.getSession().setAttribute(
                HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, securityContext
            );
            return request;
        }
    };
}

2

Darei un'occhiata alle classi di test astratti di Spring e ai finti oggetti di cui si parla qui . Forniscono un modo efficace per il cablaggio automatico degli oggetti gestiti da Spring, semplificando i test di unità e integrazione.


Mentre quelle classi di test sono utili, non sono sicuro se si applicano qui. I miei test non hanno alcun concetto di ApplicationContext - non ne hanno bisogno. Tutto ciò di cui ho bisogno è assicurarmi che SecurityContext sia popolato prima dell'esecuzione del metodo di test - è semplicemente sporco doverlo impostare prima in una ThreadLocal
matt b

1

L'autenticazione è una proprietà di un thread nell'ambiente server allo stesso modo in cui è una proprietà di un processo nel sistema operativo. Avere un'istanza bean per accedere alle informazioni di autenticazione sarebbe una configurazione scomoda e un sovraccarico di cablaggio senza alcun vantaggio.

Per quanto riguarda l'autenticazione di prova, esistono diversi modi per semplificarti la vita. Il mio preferito è creare un'annotazione personalizzata @Authenticatede testare il listener di esecuzione, che lo gestisce. Controlla l' DirtiesContextTestExecutionListenerispirazione.


0

Dopo parecchio lavoro sono stato in grado di riprodurre il comportamento desiderato. Avevo emulato il login tramite MockMvc. È troppo pesante per la maggior parte dei test unitari ma utile per i test di integrazione.

Ovviamente sono disposto a vedere quelle nuove funzionalità di Spring Security 4.0 che semplificheranno i nostri test.

package [myPackage]

import static org.junit.Assert.*;

import javax.inject.Inject;
import javax.servlet.http.HttpSession;

import org.junit.Before;
import org.junit.Test;
import org.junit.experimental.runners.Enclosed;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;

@ContextConfiguration(locations={[my config file locations]})
@WebAppConfiguration
@RunWith(SpringJUnit4ClassRunner.class)
public static class getUserConfigurationTester{

    private MockMvc mockMvc;

    @Autowired
    private FilterChainProxy springSecurityFilterChain;

    @Autowired
    private MockHttpServletRequest request;

    @Autowired
    private WebApplicationContext webappContext;

    @Before  
    public void init() {  
        mockMvc = MockMvcBuilders.webAppContextSetup(webappContext)
                    .addFilters(springSecurityFilterChain)
                    .build();
    }  


    @Test
    public void testTwoReads() throws Exception{                        

    HttpSession session  = mockMvc.perform(post("/j_spring_security_check")
                        .param("j_username", "admin_001")
                        .param("j_password", "secret007"))
                        .andDo(print())
                        .andExpect(status().isMovedTemporarily())
                        .andExpect(redirectedUrl("/index"))
                        .andReturn()
                        .getRequest()
                        .getSession();

    request.setSession(session);

    SecurityContext securityContext = (SecurityContext)   session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);

    SecurityContextHolder.setContext(securityContext);

        // Your test goes here. User is logged with 
}
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.