Spring Test & Security: comment simuler l'authentification?

124

J'essayais de comprendre comment tester unitaire si mes URL de mes contrôleurs sont correctement sécurisées. Juste au cas où quelqu'un change les choses et supprime accidentellement les paramètres de sécurité.

Ma méthode de contrôleur ressemble à ceci:

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

J'ai mis en place un WebTestEnvironment comme ceci:

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

Dans mon test actuel, j'ai essayé de faire quelque chose comme ceci:

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

}

J'ai ramassé ceci ici:

Pourtant, si l'on regarde de près, cela n'aide que lorsque l'on n'envoie pas de requêtes réelles aux URL, mais uniquement lors du test des services au niveau de la fonction. Dans mon cas, une exception "accès refusé" a été levée:

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]
        ...

Il convient de noter que les deux messages de journal suivants indiquent essentiellement qu'aucun utilisateur n'a été authentifié, ce qui indique que le paramètre Principaln'a pas fonctionné ou qu'il a été écrasé.

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
Martin Becker
la source
Le nom de votre société, eu.ubicon, s'affiche dans votre importation. N'est-ce pas un risque pour la sécurité?
Kyle Bridenstine
2
Salut, merci pour le commentaire! Je ne vois pas pourquoi. C'est de toute façon un logiciel open source. Si vous êtes intéressé, consultez bitbucket.org/ubicon/ubicon (ou bitbucket.org/dmir_wue/everyaware pour le dernier fork). Faites-moi savoir si quelque chose me manque.
Martin Becker
Vérifiez cette solution (la réponse est pour le printemps 4): stackoverflow.com/questions/14308341/…
Nagy Attila

Réponses:

101

En cherchant une réponse, je n'ai pas trouvé de réponse facile et flexible à la fois, puis j'ai trouvé la référence de sécurité Spring et j'ai réalisé qu'il y avait des solutions presque parfaites. Les solutions AOP sont souvent les meilleures pour les tests, et Spring les fournit avec @WithMockUser, @WithUserDetailset @WithSecurityContext, dans cet artefact:

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

Dans la plupart des cas, @WithUserDetails rassemble la flexibilité et la puissance dont j'ai besoin.

Comment fonctionne @WithUserDetails?

Fondamentalement, il vous suffit de créer une personnalisation UserDetailsServiceavec tous les profils d'utilisateurs possibles que vous souhaitez tester. Par exemple

@TestConfiguration
public class SpringSecurityWebAuxTestConfig {

    @Bean
    @Primary
    public UserDetailsService userDetailsService() {
        User basicUser = new UserImpl("Basic User", "[email protected]", "password");
        UserActive basicActiveUser = new UserActive(basicUser, Arrays.asList(
                new SimpleGrantedAuthority("ROLE_USER"),
                new SimpleGrantedAuthority("PERM_FOO_READ")
        ));

        User managerUser = new UserImpl("Manager User", "[email protected]", "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
        ));
    }
}

Maintenant, nos utilisateurs sont prêts, alors imaginez que nous voulons tester le contrôle d'accès à cette fonction de contrôleur:

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

Ici , nous avons une fonction Obtenir mappé à la route / foo / salut et nous testons une sécurité basée sur les rôles avec l' @Securedannotation, bien que vous pouvez tester @PreAuthorizeet @PostAuthorizeaussi bien. Créons deux tests, l'un pour vérifier si un utilisateur valide peut voir cette réponse de salut et l'autre pour vérifier si elle est réellement interdite.

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

    @Autowired
    private MockMvc mockMvc;

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

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

Comme vous le voyez, nous avons importé SpringSecurityWebAuxTestConfig pour fournir à nos utilisateurs des tests. Chacun utilisé sur son cas de test correspondant simplement en utilisant une annotation simple, réduisant le code et la complexité.

Mieux utiliser @WithMockUser pour une sécurité basée sur les rôles plus simple

Comme vous le voyez, vous disposez de @WithUserDetailstoute la flexibilité dont vous avez besoin pour la plupart de vos applications. Il vous permet d'utiliser des utilisateurs personnalisés avec n'importe quel GrantedAuthority, comme des rôles ou des autorisations. Mais si vous travaillez uniquement avec des rôles, les tests peuvent être encore plus faciles et vous pouvez éviter de créer une personnalisation UserDetailsService. Dans de tels cas, spécifiez une simple combinaison d'utilisateur, de mot de passe et de rôles avec @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'annotation définit les valeurs par défaut pour un utilisateur très basique. Comme dans notre cas, la route que nous testons nécessite simplement que l'utilisateur authentifié soit un gestionnaire, nous pouvons cesser d'utiliser SpringSecurityWebAuxTestConfiget faire cela.

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

Notez que maintenant, au lieu de l'utilisateur [email protected], nous obtenons la valeur par défaut fournie par @WithMockUser: utilisateur ; mais cela n'a pas d'importance car ce qui nous importe vraiment, c'est son rôle:ROLE_MANAGER .

Conclusions

Comme vous le voyez avec des annotations comme @WithUserDetailset @WithMockUsernous pouvons basculer entre différents scénarios d'utilisateurs authentifiés sans créer de classes aliénées de notre architecture juste pour faire des tests simples. Il vous est également recommandé de voir comment @WithSecurityContext fonctionne pour encore plus de flexibilité.

EliuX
la source
Comment se moquer de plusieurs utilisateurs ? Par exemple, la première demande est envoyée par tom, tandis que la seconde est par jerry?
ch271828n le
Vous pouvez créer une fonction où votre test est avec tom et créer un autre test avec la même logique et le tester avec Jerry. Il y aura un résultat particulier pour chaque test donc il y aura différentes assertions et si un test échoue, il vous dira par son nom quel utilisateur / rôle n'a pas fonctionné. N'oubliez pas que dans une demande, l'utilisateur ne peut être qu'un seul, donc spécifier plusieurs utilisateurs dans une demande n'a pas de sens.
EliuX
Désolé, je veux dire un tel exemple de scénario: nous testons cela, tom crée un article secret, puis jerry essaie de le lire, et jerry ne devrait pas le voir (car c'est secret). Donc dans ce cas, c'est un test unitaire ...
ch271828n
Cela ressemble beaucoup au scénario BasicUseret Manager Userdonné dans la réponse. Le concept clé est qu'au lieu de nous soucier des utilisateurs, nous nous soucions réellement de leurs rôles, mais chacun de ces tests, situés dans le même test unitaire, représente en fait des requêtes différentes. effectué par différents utilisateurs (avec des rôles différents) sur le même point de terminaison.
EliuX
61

Depuis Spring 4.0+, la meilleure solution est d'annoter la méthode de test avec @WithMockUser

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

N'oubliez pas d'ajouter la dépendance suivante à votre projet

'org.springframework.security:spring-security-test:4.2.3.RELEASE'
GummyBear21
la source
1
Le printemps est incroyable. Merci
TuGordoBello
Bonne réponse. De plus - vous n'avez pas besoin d'utiliser mockMvc, mais dans le cas où vous utilisez par exemple PagingAndSortingRepository de springframework.data - vous pouvez simplement appeler les méthodes du référentiel directement (qui sont annotées avec EL @PreAuthorize (......))
supertramp
50

Il s'est avéré que le SecurityContextPersistenceFilter, qui fait partie de la chaîne de filtres Spring Security, réinitialise toujours my SecurityContext, que j'ai défini en appelant SecurityContextHolder.getContext().setAuthentication(principal)(ou en utilisant la .principal(principal)méthode). Ce filtre définit le SecurityContextdans le SecurityContextHolderavec un à SecurityContextpartir d'un SecurityContextRepository OVERWRITING celui que j'ai défini précédemment. Le référentiel est un HttpSessionSecurityContextRepositorypar défaut. Le HttpSessionSecurityContextRepositoryinspecte le donné HttpRequestet essaie d'accéder au fichier correspondant HttpSession. S'il existe, il essaiera de lire le SecurityContextfichier HttpSession. Si cela échoue, le référentiel génère un fichier vide SecurityContext.

Ainsi, ma solution est de passer un HttpSessionavec la requête, qui contient le 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());
    }
}
Martin Becker
la source
2
Nous n'avons pas encore ajouté de support officiel pour Spring Security. Voir jira.springsource.org/browse/SEC-2015 Un aperçu de ce à quoi il ressemblera est spécifié dans github.com/SpringSource/spring-test-mvc/blob/master/src/test/…
Rob Winch
Je ne pense pas que créer un objet d'authentification et ajouter une session avec l'attribut correspondant soit si mauvais. Pensez-vous que ce soit une «solution de contournement» valable? Un soutien direct en revanche serait bien entendu. Ça a l'air assez soigné. Merci pour le lien!
Martin Becker
excellente solution. travaillé pour moi! juste un problème mineur avec la dénomination de la méthode protégée getPrincipal()qui, à mon avis, est un peu trompeur. idéalement, il aurait dû être nommé getAuthentication(). de même, dans votre signedIn()test, la variable locale doit être nommée authou à la authenticationplace deprincipal
Tanvir
Qu'est-ce que "getPrincipal (" test1 ") ¿?? Pourriez-vous expliquer où est-ce? Merci d'avance
user2992476
@ user2992476 Il renvoie probablement un objet de type UsernamePasswordAuthenticationToken. Vous pouvez également créer GrantedAuthority et construire cet objet.
bluelurker
31

Ajoutez pom.xml:

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

et utiliser org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessorspour la demande d'autorisation. Consultez l'exemple d'utilisation à l' adresse https://github.com/rwinch/spring-security-test-blog ( https://jira.spring.io/browse/SEC-2592 ).

Mettre à jour:

4.0.0.RC2 fonctionne pour spring-security 3.x. Pour spring-security 4 spring-security-test, faites partie de spring-security ( http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#test , la version est la même ).

La configuration est modifiée: 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();
}

Exemple d'authentification de base: http://docs.spring.io/spring-security/site/docs/4.0.x/reference/htmlsingle/#testing-http-basic-authentication .

Grigory Kislin
la source
Cela a également résolu mon problème d'obtention d'un 404 lors de la tentative de connexion via un filtre de sécurité de connexion. Merci!
Ian Newland
Salut, lors des tests comme mentionné par GKislin. J'obtiens l'erreur suivante «L'authentification a échoué UserDetailsService a renvoyé null, qui est une violation de contrat d'interface». Toute suggestion s'il vous plaît. AuthenticationRequest final auth = new AuthenticationRequest (); auth.setUsername (userId); auth.setPassword (mot de passe); mockMvc.perform (post ("/ api / auth /"). content (json (auth)). contentType (MediaType.APPLICATION_JSON));
Sanjeev le
7

Voici un exemple pour ceux qui souhaitent tester Spring MockMvc Security Config à l'aide de l'authentification de 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());

Dépendance de Maven

    <dependency>
        <groupId>commons-codec</groupId>
        <artifactId>commons-codec</artifactId>
        <version>1.3</version>
    </dependency>
Geai
la source
3

Réponse courte:

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

Après avoir effectué le formLogintest de sécurité du printemps, chacune de vos demandes sera automatiquement appelée en tant qu'utilisateur connecté.

Longue réponse:

Vérifiez cette solution (la réponse est pour le printemps 4): Comment connecter un utilisateur avec le nouveau test mvc du printemps 3.2

Nagy Attila
la source
2

Options pour éviter d'utiliser SecurityContextHolder dans les tests:

  • Option 1 : utiliser des simulacres - je veux dire des simulacres en SecurityContextHolderutilisant une bibliothèque fictive - EasyMock par exemple
  • Option 2 : encapsulez l'appel SecurityContextHolder.get...dans votre code dans un service - par exemple SecurityServiceImplavec une méthode getCurrentPrincipalqui implémente l' SecurityServiceinterface, puis dans vos tests, vous pouvez simplement créer une implémentation fictive de cette interface qui renvoie le principal souhaité sans accès à SecurityContextHolder.
Pavla Nováková
la source
Mh, peut-être que je n'ai pas une vue d'ensemble. Mon problème était que le SecurityContextPersistenceFilter remplace le SecurityContext en utilisant un SecurityContext d'un HttpSessionSecurityContextRepository, qui à son tour lit le SecurityContext à partir du HttpSession correspondant. Ainsi la solution utilisant la session. Concernant l'appel au SecurityContextHolder: J'ai édité ma réponse pour ne plus utiliser d'appel au SecurityContextHolder. Mais aussi sans introduire de bibliothèques d'emballage ou de moquerie supplémentaires. Pensez-vous que c'est une meilleure solution?
Martin Becker
Désolé, je n'ai pas compris exactement ce que vous recherchiez et je ne peux pas fournir une meilleure réponse que la solution que vous avez proposée et - cela semble être une bonne option.
Pavla Nováková
Très bien, merci. J'accepterai ma proposition comme solution pour le moment.
Martin Becker