Tests unitaires avec Spring Security

140

Mon entreprise évalue Spring MVC pour déterminer si nous devons l'utiliser dans l'un de nos prochains projets. Jusqu'à présent, j'aime ce que j'ai vu, et en ce moment, je jette un coup d'œil au module Spring Security pour déterminer si c'est quelque chose que nous pouvons / devrions utiliser.

Nos exigences en matière de sécurité sont assez basiques; un utilisateur a juste besoin de pouvoir fournir un nom d'utilisateur et un mot de passe pour pouvoir accéder à certaines parties du site (par exemple pour obtenir des informations sur son compte); et il y a une poignée de pages sur le site (FAQ, support, etc.) auxquelles un utilisateur anonyme devrait avoir accès.

Dans le prototype que j'ai créé, j'ai stocké un objet "LoginCredentials" (qui contient juste un nom d'utilisateur et un mot de passe) dans Session pour un utilisateur authentifié; certains contrôleurs vérifient si cet objet est en session pour obtenir une référence au nom d'utilisateur connecté, par exemple. Je cherche à remplacer cette logique locale par Spring Security à la place, ce qui aurait l'avantage de supprimer toute sorte de "comment suivre les utilisateurs connectés?" et "comment authentifier les utilisateurs?" à partir de mon contrôleur / code métier.

Il semble que Spring Security fournisse un objet "context" (par thread) pour pouvoir accéder au nom d'utilisateur / aux informations principales de n'importe où dans votre application ...

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

... ce qui semble très peu Spring car cet objet est un singleton (global), d'une certaine manière.

Ma question est la suivante: si c'est le moyen standard d'accéder aux informations sur l'utilisateur authentifié dans Spring Security, quelle est la manière acceptée d'injecter un objet Authentication dans le SecurityContext afin qu'il soit disponible pour mes tests unitaires lorsque les tests unitaires nécessitent un Utilisateur authentifié?

Dois-je câbler cela dans la méthode d'initialisation de chaque cas de test?

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

Cela semble trop verbeux. Y a-t-il un moyen plus simple?

L' SecurityContextHolderobjet lui-même semble très différent du printemps ...

mat b
la source

Réponses:

48

Le problème est que Spring Security ne rend pas l'objet d'authentification disponible en tant que bean dans le conteneur, il n'y a donc aucun moyen de l'injecter ou de le câbler automatiquement hors de la boîte.

Avant de commencer à utiliser Spring Security, nous devions créer un bean de session dans le conteneur pour stocker le Principal, l'injecter dans un "AuthenticationService" (singleton), puis injecter ce bean dans d'autres services qui nécessitaient une connaissance du Principal actuel.

Si vous implémentez votre propre service d'authentification, vous pouvez en gros faire la même chose: créer un bean de session avec une propriété "principal", l'injecter dans votre service d'authentification, demander au service d'authentification de définir la propriété lors de l'authentification réussie, puis mettez le service d'authentification à la disposition des autres beans selon vos besoins.

Je ne me sentirais pas trop mal à l'idée d'utiliser SecurityContextHolder. bien que. Je sais que c'est un statique / Singleton et que Spring déconseille d'utiliser de telles choses, mais leur implémentation prend soin de se comporter de manière appropriée en fonction de l'environnement: portée par session dans un conteneur Servlet, portée par thread dans un test JUnit, etc. Le vrai facteur limitant d'un Singleton, c'est quand il fournit une implémentation qui est inflexible à différents environnements.

cliff.meyers
la source
Merci, ce sont des conseils utiles. Ce que j'ai fait jusqu'à présent, c'est essentiellement de continuer à appeler SecurityContextHolder.getContext () (à travers quelques méthodes d'encapsulation de ma part, donc au moins, il n'est appelé qu'à partir d'une classe).
matt b
2
Bien qu'une seule note - je ne pense pas que ServletContextHolder ait un concept de HttpSession ou un moyen de savoir s'il fonctionne dans un environnement de serveur Web - il utilise ThreadLocal à moins que vous ne le configuriez pour utiliser autre chose (les deux seuls autres modes intégrés sont InheritableThreadLocal and Global)
matt b
Le seul inconvénient de l'utilisation de beans à portée de session / demande dans Spring est qu'ils échoueront dans un test JUnit. Ce que vous pouvez faire est d'implémenter une étendue personnalisée qui utilisera la session / demande si disponible et qui reviendra au thread est nécessaire. Je suppose que Spring Security fait quelque chose de similaire ...
cliff.meyers
Mon objectif est de créer une API Rest sans sessions. Peut-être avec un jeton actualisable. Bien que cela n'ait pas répondu à ma question, cela a aidé. Merci
Pomagranite
166

SecurityContextHolder.setContext()Faites-le simplement de la manière habituelle, puis insérez-le en utilisant dans votre classe de test, par exemple:

Manette:

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

Tester:

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);
Léonard Eloy
la source
2
@Leonardo, où cela doit-il Authentication aêtre ajouté dans le contrôleur? Comme je peux le comprendre dans chaque invocation de méthode? Est-il acceptable pour "spring way" juste de l'ajouter, au lieu d'injecter?
Oleg Kuts
Mais rappelez-vous que cela ne fonctionnera pas avec TestNG car SecurityContextHolder contient une variable de thread local, vous partagez donc cette variable entre les tests ...
Łukasz Woźniczka
Faites-le dans @BeforeEach(JUnit5) ou @Before(JUnit 4). Bon et simple.
WesternGun
30

Sans répondre à la question sur la façon de créer et d'injecter des objets d'authentification, Spring Security 4.0 fournit des alternatives bienvenues en matière de test. L' @WithMockUserannotation permet au développeur de spécifier un utilisateur fictif (avec des droits, un nom d'utilisateur, un mot de passe et des rôles facultatifs) de manière soignée:

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

Il existe également une option à utiliser @WithUserDetailspour émuler un UserDetailsretour de UserDetailsService, par exemple

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

Plus de détails peuvent être trouvés dans les chapitres @WithMockUser et @WithUserDetails dans la documentation de référence de Spring Security (à partir de laquelle les exemples ci-dessus ont été copiés)

matsev
la source
29

Vous avez tout à fait raison d'être préoccupé - les appels de méthodes statiques sont particulièrement problématiques pour les tests unitaires car vous ne pouvez pas facilement vous moquer de vos dépendances. Ce que je vais vous montrer, c'est comment laisser le conteneur Spring IoC faire le sale boulot à votre place, en vous laissant un code soigné et testable. SecurityContextHolder est une classe de framework et même s'il est acceptable que votre code de sécurité de bas niveau y soit lié, vous souhaiterez probablement exposer une interface plus soignée à vos composants d'interface utilisateur (c'est-à-dire les contrôleurs).

cliff.meyers a mentionné un moyen de contourner le problème: créer votre propre type de «principal» et injecter une instance dans les consommateurs. La balise Spring < aop: scoped-proxy /> introduite dans 2.x combinée à une définition de bean d'étendue de requête, et le support de la méthode d'usine peuvent être le ticket pour le code le plus lisible.

Cela pourrait fonctionner comme suit:

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
}

Rien de compliqué jusqu'à présent, non? En fait, vous deviez probablement déjà faire la plupart de cela. Ensuite, dans votre contexte de bean, définissez un bean à portée de requête pour contenir le principal:

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

Grâce à la magie de la balise aop: scoped-proxy, la méthode statique getUserDetails sera appelée à chaque fois qu'une nouvelle requête HTTP arrive et toute référence à la propriété currentUser sera résolue correctement. Maintenant, les tests unitaires deviennent triviaux:

protected void setUp() {
    // existing init code

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

J'espère que cela t'aides!

Pavel
la source
9

Personnellement, je voudrais simplement utiliser Powermock avec Mockito ou Easymock pour simuler le SecurityContextHolder.getSecurityContext () statique dans votre test unitaire / d'intégration, par exemple

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

Certes, il y a pas mal de code de chaudière ici, c'est-à-dire simuler un objet d'authentification, simuler un SecurityContext pour renvoyer l'authentification et enfin se moquer du SecurityContextHolder pour obtenir le SecurityContext, mais c'est très flexible et vous permet de tester unitaire pour des scénarios tels que des objets d'authentification null etc. sans avoir à changer votre code (non test)


la source
7

L'utilisation d'un statique dans ce cas est la meilleure façon d'écrire du code sécurisé.

Oui, la statique est généralement mauvaise - en général, mais dans ce cas, la statique est ce que vous voulez. Puisque le contexte de sécurité associe un Principal au thread en cours d'exécution, le code le plus sécurisé accèderait à la statique depuis le thread aussi directement que possible. Cacher l'accès derrière une classe wrapper injectée fournit à un attaquant plus de points à attaquer. Ils n'auraient pas besoin d'accéder au code (qu'ils auraient du mal à changer si le jar était signé), ils ont juste besoin d'un moyen de remplacer la configuration, ce qui peut être fait au moment de l'exécution ou en glissant du XML sur le chemin de classe. Même l'utilisation de l'injection d'annotations serait remplaçable avec du XML externe. Un tel XML pourrait injecter au système en cours d'exécution un principal non autorisé.

Michael Bushe
la source
4

J'ai posé la même question moi-même ici , et je viens de publier une réponse que j'ai trouvée récemment. La réponse courte est: injectez un SecurityContext, et SecurityContextHolderfaites référence uniquement à votre configuration Spring pour obtenir leSecurityContext

Scott Bale
la source
3

Général

En attendant (depuis la version 3.2, en 2013, grâce à SEC-2298 ) l'authentification peut être injectée dans les méthodes MVC en utilisant l'annotation @AuthenticationPrincipal :

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

Des tests

Dans votre test unitaire, vous pouvez évidemment appeler cette méthode directement. Dans les tests d'intégration, org.springframework.test.web.servlet.MockMvcvous pouvez utiliser org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user()pour injecter l'utilisateur comme ceci:

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

Cela remplira cependant directement le SecurityContext. Si vous voulez vous assurer que l'utilisateur est chargé à partir d'une session dans votre test, vous pouvez utiliser ceci:

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;
        }
    };
}
Yankee
la source
2

Je voudrais jeter un œil aux classes de test abstraites de Spring et aux objets simulés dont il est question ici . Ils fournissent un moyen puissant de câbler automatiquement vos objets gérés par Spring, ce qui facilite les tests d'unité et d'intégration.

digitalsanctum
la source
Bien que ces classes de test soient utiles, je ne suis pas sûr qu'elles s'appliquent ici. Mes tests n'ont aucun concept d'ApplicationContext - ils n'en ont pas besoin. Tout ce dont j'ai besoin est de m'assurer que le SecurityContext est rempli avant que la méthode de test ne s'exécute - cela me semble juste sale de devoir d'abord le définir dans un ThreadLocal
matt b
1

L'authentification est une propriété d'un thread dans un environnement serveur de la même manière qu'une propriété d'un processus sous OS. Avoir une instance de bean pour accéder aux informations d'authentification serait une configuration peu pratique et une surcharge de câblage sans aucun avantage.

En ce qui concerne l'authentification par test, il existe plusieurs façons de vous faciliter la vie. Mon préféré est de créer une annotation personnalisée @Authenticatedet un écouteur d'exécution de test, qui le gère. Cherchez l' DirtiesContextTestExecutionListenerinspiration.

Pavel Horal
la source
0

Après pas mal de travail, j'ai pu reproduire le comportement souhaité. J'avais émulé la connexion via MockMvc. Il est trop lourd pour la plupart des tests unitaires mais utile pour les tests d'intégration.

Bien sûr, je suis prêt à voir ces nouvelles fonctionnalités dans Spring Security 4.0 qui faciliteront nos tests.

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 
}
Borjab
la source