Spring Test & Security: come simulare l'autenticazione?


124

Stavo cercando di capire come eseguire un test unitario se i miei URL dei miei controller sono adeguatamente protetti. Nel caso in cui qualcuno cambi le cose e rimuova accidentalmente le impostazioni di sicurezza.

Il metodo del mio controller è simile a questo:

@RequestMapping("/api/v1/resource/test") 
@Secured("ROLE_USER")
public @ResonseBody String test() {
    return "test";
}

Ho impostato un WebTestEnvironment in questo modo:

import javax.annotation.Resource;
import javax.naming.NamingException;
import javax.sql.DataSource;

import org.junit.Before;
import org.junit.runner.RunWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.support.ClassPathXmlApplicationContext;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.test.context.ActiveProfiles;
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;

@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration({ 
        "file:src/main/webapp/WEB-INF/spring/security.xml",
        "file:src/main/webapp/WEB-INF/spring/applicationContext.xml",
        "file:src/main/webapp/WEB-INF/spring/servlet-context.xml" })
public class WebappTestEnvironment2 {

    @Resource
    private FilterChainProxy springSecurityFilterChain;

    @Autowired
    @Qualifier("databaseUserService")
    protected UserDetailsService userDetailsService;

    @Autowired
    private WebApplicationContext wac;

    @Autowired
    protected DataSource dataSource;

    protected MockMvc mockMvc;

    protected final Logger logger = LoggerFactory.getLogger(this.getClass());

    protected UsernamePasswordAuthenticationToken getPrincipal(String username) {

        UserDetails user = this.userDetailsService.loadUserByUsername(username);

        UsernamePasswordAuthenticationToken authentication = 
                new UsernamePasswordAuthenticationToken(
                        user, 
                        user.getPassword(), 
                        user.getAuthorities());

        return authentication;
    }

    @Before
    public void setupMockMvc() throws NamingException {

        // setup mock MVC
        this.mockMvc = MockMvcBuilders
                .webAppContextSetup(this.wac)
                .addFilters(this.springSecurityFilterChain)
                .build();
    }
}

Nel mio test effettivo ho provato a fare qualcosa del genere:

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.Test;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;

import eu.ubicon.webapp.test.WebappTestEnvironment;

public class CopyOfClaimTest extends WebappTestEnvironment {

    @Test
    public void signedIn() throws Exception {

        UsernamePasswordAuthenticationToken principal = 
                this.getPrincipal("test1");

        SecurityContextHolder.getContext().setAuthentication(principal);        

        super.mockMvc
            .perform(
                    get("/api/v1/resource/test")
//                    .principal(principal)
                    .session(session))
            .andExpect(status().isOk());
    }

}

L'ho preso qui:

Tuttavia, se si guarda da vicino, questo aiuta solo quando non si inviano richieste effettive agli URL, ma solo quando si testano i servizi a livello di funzione. Nel mio caso è stata lanciata un'eccezione "accesso negato":

org.springframework.security.access.AccessDeniedException: Access is denied
    at org.springframework.security.access.vote.AffirmativeBased.decide(AffirmativeBased.java:83) ~[spring-security-core-3.1.3.RELEASE.jar:3.1.3.RELEASE]
    at org.springframework.security.access.intercept.AbstractSecurityInterceptor.beforeInvocation(AbstractSecurityInterceptor.java:206) ~[spring-security-core-3.1.3.RELEASE.jar:3.1.3.RELEASE]
    at org.springframework.security.access.intercept.aopalliance.MethodSecurityInterceptor.invoke(MethodSecurityInterceptor.java:60) ~[spring-security-core-3.1.3.RELEASE.jar:3.1.3.RELEASE]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:172) ~[spring-aop-3.2.1.RELEASE.jar:3.2.1.RELEASE]
        ...

I seguenti due messaggi di registro sono degni di nota fondamentalmente dicendo che nessun utente è stato autenticato indicando che l'impostazione Principalnon ha funzionato o che è stata sovrascritta.

14:20:34.454 [main] DEBUG o.s.s.a.i.a.MethodSecurityInterceptor - Secure object: ReflectiveMethodInvocation: public java.util.List test.TestController.test(); target is of class [test.TestController]; Attributes: [ROLE_USER]
14:20:34.454 [main] DEBUG o.s.s.a.i.a.MethodSecurityInterceptor - Previously Authenticated: org.springframework.security.authentication.AnonymousAuthenticationToken@9055e4a6: Principal: anonymousUser; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@957e: RemoteIpAddress: 127.0.0.1; SessionId: null; Granted Authorities: ROLE_ANONYMOUS

Il nome della tua azienda, eu.ubicon, viene visualizzato nell'importazione. Non è un rischio per la sicurezza?
Kyle Bridenstine

2
Ciao, grazie per il commento! Non riesco a capire perché però. È comunque un software open source. Se sei interessato, vedi bitbucket.org/ubicon/ubicon (o bitbucket.org/dmir_wue/everyaware per l'ultimo fork). Fammi sapere se mi manca qualcosa.
Martin Becker

Controlla questa soluzione (la risposta è per la primavera 4): stackoverflow.com/questions/14308341/…
Nagy Attila

Risposte:


101

Cercando una risposta non sono riuscito a trovarne nessuna semplice e flessibile allo stesso tempo, poi ho trovato Spring Security Reference e ho capito che ci sono soluzioni quasi perfette. Le soluzioni AOP spesso sono i più grandi per il test, e Spring fornisce con @WithMockUser, @WithUserDetailse @WithSecurityContext, in questo artefatto:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <version>4.2.2.RELEASE</version>
    <scope>test</scope>
</dependency>

Nella maggior parte dei casi, @WithUserDetailsraccoglie la flessibilità e la potenza di cui ho bisogno.

Come funziona @WithUserDetails?

Fondamentalmente devi solo creare un custom UserDetailsServicecon tutti i possibili profili utente che vuoi testare. Per esempio

@TestConfiguration
public class SpringSecurityWebAuxTestConfig {

    @Bean
    @Primary
    public UserDetailsService userDetailsService() {
        User basicUser = new UserImpl("Basic User", "user@company.com", "password");
        UserActive basicActiveUser = new UserActive(basicUser, Arrays.asList(
                new SimpleGrantedAuthority("ROLE_USER"),
                new SimpleGrantedAuthority("PERM_FOO_READ")
        ));

        User managerUser = new UserImpl("Manager User", "manager@company.com", "password");
        UserActive managerActiveUser = new UserActive(managerUser, Arrays.asList(
                new SimpleGrantedAuthority("ROLE_MANAGER"),
                new SimpleGrantedAuthority("PERM_FOO_READ"),
                new SimpleGrantedAuthority("PERM_FOO_WRITE"),
                new SimpleGrantedAuthority("PERM_FOO_MANAGE")
        ));

        return new InMemoryUserDetailsManager(Arrays.asList(
                basicActiveUser, managerActiveUser
        ));
    }
}

Ora abbiamo i nostri utenti pronti, quindi immagina di voler testare il controllo dell'accesso a questa funzione del controller:

@RestController
@RequestMapping("/foo")
public class FooController {

    @Secured("ROLE_MANAGER")
    @GetMapping("/salute")
    public String saluteYourManager(@AuthenticationPrincipal User activeUser)
    {
        return String.format("Hi %s. Foo salutes you!", activeUser.getUsername());
    }
}

Qui abbiamo una funzione di mappatura alla route / foo / salute e stiamo testando una sicurezza basata sui ruoli con l' @Securedannotazione, sebbene tu possa testare@PreAuthorize e @PostAuthorizepure. Creiamo due test, uno per verificare se un utente valido può vedere questa risposta di saluto e l'altro per verificare se è effettivamente vietato.

@RunWith(SpringRunner.class)
@SpringBootTest(
        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
        classes = SpringSecurityWebAuxTestConfig.class
)
@AutoConfigureMockMvc
public class WebApplicationSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @WithUserDetails("manager@company.com")
    public void givenManagerUser_whenGetFooSalute_thenOk() throws Exception
    {
        mockMvc.perform(MockMvcRequestBuilders.get("/foo/salute")
                .accept(MediaType.ALL))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("manager@company.com")));
    }

    @Test
    @WithUserDetails("user@company.com")
    public void givenBasicUser_whenGetFooSalute_thenForbidden() throws Exception
    {
        mockMvc.perform(MockMvcRequestBuilders.get("/foo/salute")
                .accept(MediaType.ALL))
                .andExpect(status().isForbidden());
    }
}

Come vedi, abbiamo importato SpringSecurityWebAuxTestConfigper fornire ai nostri utenti il ​​test. Ognuno utilizzato nel suo caso di test corrispondente semplicemente utilizzando un'annotazione semplice, riducendo il codice e la complessità.

Migliore utilizzo di @WithMockUser per una sicurezza basata sui ruoli più semplice

Come vedi @WithUserDetailsha tutta la flessibilità di cui hai bisogno per la maggior parte delle tue applicazioni. Ti consente di utilizzare utenti personalizzati con qualsiasi GrantedAuthority, come ruoli o autorizzazioni. Ma se lavori solo con i ruoli, il test può essere ancora più semplice e potresti evitare di costruire un custom UserDetailsService. In questi casi, specificare una semplice combinazione di utente, password e ruoli con @WithMockUser .

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@WithSecurityContext(
    factory = WithMockUserSecurityContextFactory.class
)
public @interface WithMockUser {
    String value() default "user";

    String username() default "";

    String[] roles() default {"USER"};

    String password() default "password";
}

L'annotazione definisce i valori predefiniti per un utente molto semplice. Poiché nel nostro caso il percorso che stiamo testando richiede solo che l'utente autenticato sia un manager, possiamo smettere di usare SpringSecurityWebAuxTestConfige farlo.

@Test
@WithMockUser(roles = "MANAGER")
public void givenManagerUser_whenGetFooSalute_thenOk() throws Exception
{
    mockMvc.perform(MockMvcRequestBuilders.get("/foo/salute")
            .accept(MediaType.ALL))
            .andExpect(status().isOk())
            .andExpect(content().string(containsString("user")));
}

Notare che ora invece di user manager@company.com stiamo ottenendo il valore predefinito fornito da @WithMockUser: user ; ma non importa perché quello che ci interessa veramente è il suo ruolo:ROLE_MANAGER .

conclusioni

Come puoi vedere con annotazioni come @WithUserDetailse @WithMockUserpossiamo passare da uno scenario di utenti autenticati a diversi scenari senza costruire classi estranee alla nostra architettura solo per fare semplici test. Ti consigliamo inoltre di vedere come funziona @WithSecurityContext per una flessibilità ancora maggiore.


Come deridere più utenti ? Ad esempio, la prima richiesta viene inviata da tom, mentre la seconda è da jerry?
ch271828n

Puoi creare una funzione in cui il tuo test è con Tom e creare un altro test con la stessa logica e testarlo con Jerry. Ci sarà un risultato particolare per ogni test, quindi ci saranno diverse asserzioni e se un test fallisce ti dirà con il suo nome quale utente / ruolo non ha funzionato. Ricorda che in una richiesta l'utente può essere solo uno, quindi specificare più utenti in una richiesta non ha senso.
EliuX

Scusa, intendo questo esempio di scenario: lo testiamo, Tom crea un articolo segreto, quindi Jerry cerca di leggerlo e Jerry non dovrebbe vederlo (poiché è segreto). Quindi, in questo caso, è un test unitario ...
ch271828n

Assomiglia più o meno allo scenario BasicUsere Manager Userfornito nella risposta. Il concetto chiave è che invece di preoccuparci degli utenti, ci preoccupiamo effettivamente dei loro ruoli, ma ciascuno di questi test, che si trova nello stesso test unitario, in realtà rappresenta query diverse. eseguita da utenti diversi (con ruoli diversi) allo stesso endpoint.
EliuX

61

Dalla Spring 4.0+, la soluzione migliore è annotare il metodo di test con @WithMockUser

@Test
@WithMockUser(username = "user1", password = "pwd", roles = "USER")
public void mytest1() throws Exception {
    mockMvc.perform(get("/someApi"))
        .andExpect(status().isOk());
}

Ricorda di aggiungere la seguente dipendenza al tuo progetto

'org.springframework.security:spring-security-test:4.2.3.RELEASE'

1
La primavera è meravigliosa. Grazie
TuGordoBello

Buona risposta. Inoltre, non è necessario utilizzare mockMvc, ma nel caso in cui si utilizzi ad esempio PagingAndSortingRepository da springframework.data, è possibile chiamare direttamente i metodi dal repository (che sono annotati con EL @PreAuthorize (......))
supertramp

50

Si è scoperto che SecurityContextPersistenceFilter, che fa parte della catena di filtri Spring Security, reimposta sempre my SecurityContext, che ho impostato chiamando SecurityContextHolder.getContext().setAuthentication(principal)(o utilizzando il .principal(principal)metodo). Questo filtro imposta SecurityContextin SecurityContextHoldercon SecurityContextuna SecurityContextRepository SOVRASCRITTURA quella che ho impostato in precedenza. Il repository è HttpSessionSecurityContextRepositoryper impostazione predefinita. L' HttpSessionSecurityContextRepositoryispeziona il dato HttpRequeste cerca di accedere al corrispondente HttpSession. Se esiste, proverà a leggere il file SecurityContextdal HttpSession. Se questo non riesce, il repository genera un file SecurityContext.

Quindi, la mia soluzione è passare un HttpSessioninsieme alla richiesta, che contiene SecurityContext:

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.Test;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;

import eu.ubicon.webapp.test.WebappTestEnvironment;

public class Test extends WebappTestEnvironment {

    public static class MockSecurityContext implements SecurityContext {

        private static final long serialVersionUID = -1386535243513362694L;

        private Authentication authentication;

        public MockSecurityContext(Authentication authentication) {
            this.authentication = authentication;
        }

        @Override
        public Authentication getAuthentication() {
            return this.authentication;
        }

        @Override
        public void setAuthentication(Authentication authentication) {
            this.authentication = authentication;
        }
    }

    @Test
    public void signedIn() throws Exception {

        UsernamePasswordAuthenticationToken principal = 
                this.getPrincipal("test1");

        MockHttpSession session = new MockHttpSession();
        session.setAttribute(
                HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, 
                new MockSecurityContext(principal));


        super.mockMvc
            .perform(
                    get("/api/v1/resource/test")
                    .session(session))
            .andExpect(status().isOk());
    }
}

2
Non abbiamo ancora aggiunto il supporto ufficiale per Spring Security. Vedi jira.springsource.org/browse/SEC-2015 Uno schema per come apparirà è specificato in github.com/SpringSource/spring-test-mvc/blob/master/src/test/…
Rob Winch

Non credo che creare un oggetto di autenticazione e aggiungere una sessione con l'attributo corrispondente sia così male. Pensi che questo sia un valido "aggiramento"? Il supporto diretto d'altra parte sarebbe ovviamente fantastico. Sembra abbastanza pulito. Grazie per il link!
Martin Becker

ottima soluzione. ha funzionato per me! solo un piccolo problema con la denominazione del metodo protetto getPrincipal()che a mio parere è un po 'fuorviante. idealmente avrebbe dovuto essere chiamato getAuthentication(). allo stesso modo, nel tuo signedIn()test, la variabile locale dovrebbe essere denominata autho authenticationinvece diprincipal
Tanvir

Che cos'è "getPrincipal (" test1 ") ¿?? Potresti spiegare dove si trova? Grazie in anticipo
user2992476

@ user2992476 Probabilmente restituisce un oggetto di tipo UsernamePasswordAuthenticationToken. In alternativa, crei GrantedAuthority e costruisci questo oggetto.
bluelurker

31

Aggiungi pom.xml:

    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <version>4.0.0.RC2</version>
    </dependency>

e utilizzare org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessorsper la richiesta di autorizzazione. Vedere l'utilizzo di esempio su https://github.com/rwinch/spring-security-test-blog ( https://jira.spring.io/browse/SEC-2592 ).

Aggiornare:

4.0.0.RC2 funziona per spring-security 3.x. Per spring-security 4 spring-security-test diventa parte di spring-security ( http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#test , la versione è la stessa ).

L'impostazione è cambiata: http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#test-mockmvc

public void setup() {
    mvc = MockMvcBuilders
            .webAppContextSetup(context)
            .apply(springSecurity())  
            .build();
}

Esempio per l'autenticazione di base: http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#testing-http-basic-authentication .


Questo ha anche risolto il mio problema con l'ottenimento di un 404 quando si tenta di accedere tramite un filtro di sicurezza dell'accesso. Grazie!
Ian Newland

Ciao, durante il test come menzionato da GKislin. Ricevo l'errore seguente "Autenticazione fallita UserDetailsService restituito null, che è una violazione del contratto dell'interfaccia". Qualsiasi suggerimento per favore. finale AuthenticationRequest auth = new AuthenticationRequest (); auth.setUsername (userId); auth.setPassword (password); mockMvc.perform (.. posta ( "/ api / auth /") contenuto (JSON (autenticazione)) contentType (MediaType.APPLICATION_JSON));
Sanjeev

7

Ecco un esempio per coloro che desiderano testare Spring MockMvc Security Config utilizzando l'autenticazione di base Base64.

String basicDigestHeaderValue = "Basic " + new String(Base64.encodeBase64(("<username>:<password>").getBytes()));
this.mockMvc.perform(get("</get/url>").header("Authorization", basicDigestHeaderValue).accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk());

Dipendenza da Maven

    <dependency>
        <groupId>commons-codec</groupId>
        <artifactId>commons-codec</artifactId>
        <version>1.3</version>
    </dependency>

3

Risposta breve:

@Autowired
private WebApplicationContext webApplicationContext;

@Autowired
private Filter springSecurityFilterChain;

@Before
public void setUp() throws Exception {
    final MockHttpServletRequestBuilder defaultRequestBuilder = get("/dummy-path");
    this.mockMvc = MockMvcBuilders.webAppContextSetup(this.webApplicationContext)
            .defaultRequest(defaultRequestBuilder)
            .alwaysDo(result -> setSessionBackOnRequestBuilder(defaultRequestBuilder, result.getRequest()))
            .apply(springSecurity(springSecurityFilterChain))
            .build();
}

private MockHttpServletRequest setSessionBackOnRequestBuilder(final MockHttpServletRequestBuilder requestBuilder,
                                                             final MockHttpServletRequest request) {
    requestBuilder.session((MockHttpSession) request.getSession());
    return request;
}

Dopo aver eseguito formLogindal test di sicurezza primaverile ciascuna delle tue richieste verrà automaticamente chiamata come utente connesso.

Risposta lunga:

Controlla questa soluzione (la risposta è per la primavera 4): Come accedere a un utente con il nuovo test mvc della primavera 3.2


2

Opzioni per evitare di utilizzare SecurityContextHolder nei test:

  • Opzione 1 : usa mock - intendo mock SecurityContextHolderusando una libreria mock - EasyMock per esempio
  • Opzione 2 : avvolgere la chiamata SecurityContextHolder.get...nel codice in qualche servizio, ad esempio SecurityServiceImplcon il metodo getCurrentPrincipalche implementa l' SecurityServiceinterfaccia e quindi nei test è possibile creare semplicemente un'implementazione fittizia di questa interfaccia che restituisce l'entità desiderata senza accesso a SecurityContextHolder.

Mh, forse non riesco a capire il quadro completo. Il mio problema era che SecurityContextPersistenceFilter sostituisce SecurityContext utilizzando un SecurityContext da un HttpSessionSecurityContextRepository, che a sua volta legge SecurityContext dal corrispondente HttpSession. Quindi la soluzione utilizzando la sessione. Per quanto riguarda la chiamata a SecurityContextHolder: ho modificato la mia risposta in modo da non utilizzare più una chiamata a SecurityContextHolder. Ma anche senza introdurre alcun wrapping o ulteriori librerie beffarde. Pensi che questa sia una soluzione migliore?
Martin Becker

Mi spiace non ho capito esattamente cosa stavi cercando e non posso fornire una risposta migliore della soluzione che hai trovato e - sembra essere una buona opzione.
Pavla Nováková

Va bene grazie. Per ora accetterò la mia proposta come soluzione.
Martin Becker
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.