CodeGym /Cours /Spring Security /formLogin () et POST /logout dans les tests

formLogin () et POST /logout dans les tests

Spring Security
Niveau 27, Leçon 1
Disponible

1. Rôle des tests login/logout

Si vous venez d’apprendre à écrire des tests avec .with(user("maria").roles("USER")), vous vous dites naturellement : « Bon, la sécurité est testée, on peut fêter ça ». Mais il y a un hic. Un tel test vérifie les règles d’autorisation (qui a accès à quoi), mais ne vérifie presque pas le flux d’authentification (comment une personne se connecte effectivement). Ce sont deux parties différentes du système – comme le « badge d’usine » et le « contrôle du passeport à l’entrée ».

Le scénario le plus dangereux pour un projet est celui-ci : vous avez des tests verts sur /api/me et /api/admin/users, parce que vous utilisez partout user() – et il crée honnêtement un SecurityContext authentifié directement à l’intérieur de la requête de test. Mais en réalité, votre formLogin est cassé : pas le bon PasswordEncoder, pas le bon UserDetailsService, une URL de login incorrecte, vous avez changé le paramètre username en email et vous avez oublié, ou quelqu’un a « optimisé » la config de telle sorte que UsernamePasswordAuthenticationFilter ne fonctionne plus. Résultat : le vrai utilisateur dans le navigateur ne peut pas se connecter… tandis que les tests sourient joyeusement et font comme si tout allait bien.

Donc la logique est simple : les tests user() sont des vérifications rapides et massives de la matrice d’accès, tandis que formLogin() et les tests de logout sont quelques vérifications ponctuelles mais très importantes « transversales » qui garantissent que votre login/logout stateful est vraiment vivant.

2. formLogin() dans spring-security-test

Lorsqu’un débutant essaie de tester le login manuellement pour la première fois, il fait généralement post("/login"), met les paramètres username et password, et… obtient 403 Forbidden. Ensuite commence le classique : « Spring Security est cassé », « CSRF est énervant », « éteignons CSRF », « j’ai vu sur Internet .csrf().disable() ». Et c’est là que le professeur devient triste, et quelque part dans le monde, un backend sécurisé pleure.

formLogin() est un builder prêt à l’emploi de spring-security-test qui construit une requête de login form-based correcte pour vous. Il fait ce que ferait normalement un formulaire HTML dans le navigateur : envoie un POST vers l’URL de traitement du login, ajoute les paramètres de login et mot de passe, définit le bon Content-Type, et – le plus important dans notre contexte – ajoute un token CSRF valide, car le login est une opération qui change l’état.

Il est utile de garder à l’esprit que formLogin() n’est pas un « bouton magique connecte-moi », mais simplement un moyen pratique de ne pas écrire la même chose à la main. Dans l’esprit, il imite la requête suivante :

Quoi Valeur typique
Méthode HTTP POST
URL /login (par défaut)
Content-Type application/x-www-form-urlencoded
Paramètre de login username=... (par défaut)
Paramètre de mot de passe password=... (par défaut)
CSRF présent (sinon on obtient
403
)

Si vous avez modifié la configuration de formLogin (URL ou noms des paramètres), alors le test doit changer avec elle – nous en reparlerons un peu plus tard. Pour l’instant, notre objectif est d’apprendre à tester la connexion réussie et échouée, sans transformer le test en 30 lignes de construction manuelle de requête.

3. Structure des tests avec MockMvc

Avant d’écrire les tests de login/logout, il est important d’avoir un MockMvc qui passe réellement par le SecurityFilterChain. Cette structure devrait déjà être la vôtre depuis le jour précédent, mais je vais la montrer brièvement – juste pour que le code ne reste pas en suspens dans votre esprit, comme un bug en production un vendredi soir.

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest // On démarre un vrai contexte Spring pour que SecurityFilterChain soit réel
@AutoConfigureMockMvc // On demande à Spring de configurer MockMvc par-dessus les filtres (y compris Spring Security)
class AuthFlowTest {

    @Autowired
    MockMvc mvc; // MockMvc fera passer les requêtes à travers toute la chaîne de filtres, et non « en contournant »
}

C’est la forme de base : on démarre le contexte de l’application et on obtient MockMvc. Ensuite, il est important de se rappeler un point pratique supplémentaire : formLogin() déclenche une authentification réelle, c’est-à-dire que votre application dans le profil de test a besoin d’un vrai utilisateur dans le user store (in-memory ou base de données). Contrairement à user(), ici « l’utilisateur dans la tête du test » ne fonctionnera pas – Spring Security va le chercher honnêtement via UserDetailsService.

Si vous n’avez pas préparé d’utilisateur maria avec le mot de passe secret dans vos tests, alors formLogin().user("maria").password("secret") ne sera pas un « test vert », mais une « belle façon de dire BadCredentials ».

4. Login réussi dans les tests

Un login réussi dans un modèle stateful n’est pas « on a obtenu un 200 OK ». C’est le passage du système de l’état « anonymous » à l’état « authenticated », plus la création/utilisation d’une session, plus le comportement standard orienté navigateur (généralement une redirection). Et nous voulons capturer ce comportement dans le test, pas simplement le fait que le serveur a répondu quelque chose.

Le test le plus compact et utile d’un login réussi vérifie généralement trois choses : le statut (très probablement 3xx), l’URL de redirection (si vous l’attendez), et le fait même de l’authentification (via le matcher authenticated()). Voici un exemple :

import org.junit.jupiter.api.Test; 

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

class AuthFlowTest {

    // mvc est déjà injecté via @Autowired

    @Test
    void loginWhenValidCredentialsThenRedirectAndAuthenticated() throws Exception {
        // formLogin() simule l’envoi d’un formulaire par le navigateur (y compris CSRF), et non un « login issu de la tête du test »
        mvc.perform(formLogin().user("maria").password("secret"))
                .andExpect(status().is3xxRedirection()) // Pour form login on attend généralement une redirection, pas un 200
                .andExpect(redirectedUrl("/")) // Comportement par défaut typique : après login vers la page d’accueil
                .andExpect(authenticated().withUsername("maria")); // On enregistre le fait que l’authentification a eu lieu dans le SecurityContext
    }
}

Pourquoi authenticated() est-il important ici ? Parce que c’est une vérification au niveau Spring Security, et non au niveau « bon, il y a une redirection apparemment ». En réalité, on peut obtenir une redirection même en cas d’échec de l’authentification (par exemple, vers /login?error). Donc c’est bien quand le test enregistre explicitement : « l’utilisateur est effectivement devenu authentifié ».

Si vous voulez rendre le test un peu plus « physique » (et c’est utile pour les débutants), vous pouvez également vérifier qu’une session est apparue et qu’on peut maintenant l’utiliser pour accéder à un endpoint protégé. Il est important de ne pas transformer le test en trilogie cinématographique, mais un court scénario « login → accès à /api/me » donne une image très claire.

import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MvcResult;

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

@Test
void meEndpointWhenLoginThenOk() throws Exception {
    // Étape 1 : on se connecte « pour de vrai » pour que Spring Security crée une session
    MvcResult login = mvc.perform(formLogin().user("maria").password("secret"))
            .andReturn();

    // On récupère la session de la requête de login – c’est notre « JSESSIONID sous forme de test »
    MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false);

    // Étape 2 : on accède à l’endpoint protégé avec la même session (comme le ferait un navigateur)
    mvc.perform(get("/api/me").session(session))
            .andExpect(status().isOk());
}

Dans cet exemple, il y a une vérité « concrète » agréable : nous nous sommes connectés, avons obtenu une session, et maintenant /api/me est accessible parce que le serveur nous reconnaît par JSESSIONID (dans le test, c’est l’abstraction MockHttpSession). Un tel test est particulièrement utile lorsqu’un étudiant ne croit pas encore vraiment que « la session est une chose réelle, pas un mythe des légendes sur les servlets anciens ».

5. Login échoué dans les tests

Un login échoué n’est pas simplement « ça arrive ». En sécurité, c’est un scénario obligatoire. Et il est important non seulement pour la sécurité, mais aussi pour le diagnostic : si vous cassez accidentellement le PasswordEncoder, c’est généralement le chemin de l’authentification échouée qui se casse en premier (et c’est bien, car vous l’apprenez par un test, pas par un utilisateur dans Telegram).

Dans le login form-based stateful, par défaut, une tentative échouée se termine généralement par une redirection vers /login?error. Et c’est une sémantique tout à fait normale pour un flux navigateur. Notre test doit vérifier : le statut 3xx, la redirection correcte, et que l’authentification n’a pas eu lieu (unauthenticated()).

import org.junit.jupiter.api.Test; 

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void loginWhenWrongPasswordThenRedirectToErrorAndUnauthenticated() throws Exception {
    // Ce qui nous importe ici, c’est le chemin « échoué » de l’authentification : mot de passe incorrect
    mvc.perform(formLogin().user("maria").password("wrong"))
            .andExpect(status().is3xxRedirection()) // Dans un scénario navigateur, c’est généralement une redirection
            .andExpect(redirectedUrl("/login?error")) // URL d’erreur par défaut pour form login
            .andExpect(unauthenticated()); // On enregistre explicitement que le SecurityContext n’est pas devenu authenticated
}

Notez le point psychologiquement important : ici nous ne vérifions pas les endpoints métier, nous n’accédons pas à /api/me, nous n’essayons pas de « tout vérifier dans un seul test ». Ce test vérifie uniquement le comportement du flux de login en cas de mot de passe incorrect. Plus il est court et sans ambiguïté, plus il est facile à maintenir.

Il est parfois utile d’ajouter un autre scénario négatif : l’utilisateur n’existe pas. En termes de comportement pour le client, cela ressemble généralement à la même chose (pour des raisons de sécurité, vous ne voulez pas indiquer « cet utilisateur n’existe pas », sinon cela devient un enumeration d’utilisateurs). Mais pour les tests, c’est utile comme branche séparée, pour ne pas confondre « utilisateur introuvable » et « mot de passe incorrect ».

Requête POST /login manuelle sans CSRF

Voici une petite démonstration qui soigne très bien l’envie de désactiver CSRF « parce que les tests sont rouges ». Si vous essayez de faire un login manuellement via MockMvc mais oubliez CSRF, vous obtiendrez 403 Forbidden. Et ce n’est pas une erreur de Spring, c’est Spring qui essaie de vous sauver de mauvaises décisions.

Un test comme celui-ci (ou une expérience) se termine généralement par un 403 :

import org.junit.jupiter.api.Test; // Annotation de test
import org.springframework.http.MediaType;

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

@Test
void loginWhenManualPostWithoutCsrfThenForbidden() throws Exception {
    // Ici, nous faisons intentionnellement « comme si c’était un formulaire », mais sans csrf() – pour voir 403
    mvc.perform(post("/login")
            .contentType(MediaType.APPLICATION_FORM_URLENCODED) // Content-Type typique d’un formulaire HTML
            .param("username", "maria") // Paramètres du formulaire de login
            .param("password", "secret")) // Paramètres du formulaire de mot de passe
            .andExpect(status().isForbidden()); // CSRF vous a protégé de la « commodité »
}

Et c’est là le point le plus important : formLogin() n’est pas seulement « plus pratique », il aide aussi à ne pas oublier les détails importants du protocole. Par conséquent, dans les tests de form login, il est presque toujours raisonnable d’utiliser formLogin() plutôt que de construire la requête à la main, sauf si vous testez spécifiquement « ce qui se passe sans CSRF ».

Logout comme événement de sécurité

À propos du logout, les débutants ont souvent deux mythes. Premier : « le logout, c’est quand on supprime le cookie côté front ». Deuxième : « le logout n’est pas aussi important que le login ». Les deux mythes se transforment rapidement dans le monde réel en bugs très étranges, où l’utilisateur « s’est déconnecté » mais reste connecté une minute plus tard parce que la session sur le serveur n’a pas disparu.

Dans un modèle basé sur session, le logout est une opération qui modifie l’état de sécurité : Spring Security doit effacer le SecurityContext, généralement invalider la session, et (souvent) demander au navigateur de supprimer le cookie associé à la session (JSESSIONID). C’est précisément pourquoi le logout est une requête qui change l’état, et donc, lorsque CSRF est activé, il doit être protégé par un token CSRF.

Au niveau HTTP, dans une configuration typique de Spring Security, le logout se présente comme un POST /logout avec CSRF. Et la réponse est le plus souvent une redirection, par exemple vers /login?logout (c’est un marqueur standard pratique à afficher sur la page de login). Et si vous testez une branche stateful du projet, le test de logout doit être aussi obligatoire dans votre suite de régression que le test de login.

6. Logout : tests de base

Commençons par le plus simple : une requête de logout avec un utilisateur authentifié et CSRF doit réussir et renvoyer la sémantique navigateur attendue (généralement une redirection). Nous faisons cela explicitement via un POST /logout et ajoutons csrf().

import org.junit.jupiter.api.Test; // Annotation de test

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void logoutWhenAuthenticatedAndCsrfThenRedirect() throws Exception {
    mvc.perform(post("/logout")
            .with(user("maria").roles("USER")) // On spécifie un utilisateur authentifié pour la requête
            .with(csrf())) // Logout est une opération qui change l’état, donc CSRF est obligatoire
            .andExpect(status().is3xxRedirection())
            .andExpect(redirectedUrl("/login?logout")); // Marqueur typique d’un logout réussi
}

Ce test est utile, mais il a un défaut : nous ne nous sommes pas assurés que le logout a réellement « fermé la porte ». Après tout, on peut renvoyer une redirection même avec une logique cassée (mauvaise idée, mais techniquement possible).

Par conséquent, le niveau suivant est un scénario très court : « login → accès possible → logout → accès perdu ». Oui, c’est déjà un test un peu plus intégration, mais il montre bien que la session cesse effectivement de fonctionner.

import org.junit.jupiter.api.Test; // Annotation de test
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MvcResult;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void meEndpointAfterLogoutShouldNotBeAccessible() throws Exception {
    // Étape 1 : on se connecte et on obtient une session « vivante »
    MvcResult login = mvc.perform(formLogin().user("maria").password("secret"))
            .andReturn();

    MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false); // On prend la session courante

    // Étape 2 : on vérifie que l’accès avant logout est possible
    mvc.perform(get("/api/me").session(session))
            .andExpect(status().isOk());

    // Étape 3 : on effectue le logout dans la même session + CSRF (comme le ferait un navigateur)
    mvc.perform(post("/logout").session(session).with(csrf()))
            .andExpect(status().is3xxRedirection());

    // Étape 4 : on s’assure que la même session ne donne plus accès à la ressource protégée
    mvc.perform(get("/api/me").session(session))
            .andExpect(status().is3xxRedirection()); // Généralement redirection vers /login
}

Oui, il y a un peu plus de lignes que les « 8 idéales ». Mais dans la tête de l’étudiant, une image complète se construit : la session est vivante tant qu’on est connecté ; le logout « réinitialise » l’état ; après cela, la même session ne donne plus accès. C’est exactement le test qui sauve de « j’ai apparemment ajouté un logout, mais il ne fait rien ».

7. Personnalisation du flux de login

Il arrive presque inévitablement un moment où vous cessez de vivre dans le « monde par défaut » et où le flux de login devient personnalisé. Par exemple, vous changez l’URL de traitement du login (pas /login, mais quelque chose comme /api/auth/login-form), ou vous décidez de vous connecter par email, et alors le paramètre du formulaire n’est plus username.

Et il y a une règle importante de maturation : si vous avez modifié le contrat du flux de login, les tests doivent changer avec ce contrat. Ce n’est pas « les tests gênent le développement », c’est que les tests fixent la réalité que vous créez vous-même.

Exemple : vous avez conservé le form login, mais changé l’URL de traitement. Alors dans le test, vous devez spécifier l’URL lors de la construction de la requête :

import org.junit.jupiter.api.Test; // Annotation de test

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void loginWhenCustomLoginUrlThenStillWorks() throws Exception {
    // Si on a changé loginProcessingUrl dans la config, le test doit utiliser la nouvelle URL
    mvc.perform(formLogin("/api/auth/login-form")
            .user("maria")
            .password("secret"))
            .andExpect(status().is3xxRedirection()); // on vérifie au moins le fait que le login a réussi
}

Et si vous avez changé le nom du paramètre de login, cela se reflète aussi :

import org.junit.jupiter.api.Test; // Annotation de test

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void loginWhenEmailParameterThenOk() throws Exception {
    // userParameter(...) doit correspondre à ce que vous attendez réellement dans le filtre d’authentification
    mvc.perform(formLogin()
            .userParameter("email") // Maintenant on se connecte avec email au lieu de username
            .user("maria@example.com")
            .password("secret"))
            .andExpect(status().is3xxRedirection()); // Dans un premier temps on vérifie « ça authentifie »
}

Ici, je ne vérifie pas intentionnellement l’URL de redirection – car avec la personnalisation, vous avez peut-être aussi changé le success handler. Dans un premier temps, il est plus utile de vérifier « ça authentifie au moins », puis ensuite de rendre le test plus strict.

Et un dernier conseil pratique : il est préférable de faire la personnalisation progressivement. D’abord, changez l’URL – corrigez le test. Ensuite, changez le paramètre – corrigez le test. Et seulement ensuite, quand tout est vert, vous pouvez imaginer une belle architecture « notre flux de login est comme dans un grand produit ». Sinon, vous obtiendrez un produit comme dans un grand produit, mais sans login pour une raison quelconque.

8. Erreurs typiques avec formLogin() et le logout

Erreur n°1 : essayer de tester le login via user() et considérer cela comme une « vérification du login ».
user() est un excellent outil pour vérifier les règles d’accès, mais il ne vérifie absolument pas que votre DaoAuthenticationProvider fonctionne, que le mot de passe est correctement encodé, que votre UserDetailsService trouve réellement l’utilisateur. Si vous n’avez pas ajouté au moins quelques tests avec formLogin(), vous ne testez pas le flux de login.

Erreur n°2 : s’attendre à un 200 OK pour un login réussi.
Dans un scénario orienté navigateur, un login réussi se termine généralement par un 302 et une redirection. Si vous attendez un 200 dans le test, vous testez soit la mauvaise chose, soit vous avez accidentellement un comportement différent (par exemple, un success handler personnalisé). Il est préférable de commencer par attendre un 3xx et authenticated(), puis d’affiner le comportement en fonction de votre contrat spécifique.

Erreur n°3 : oublier que formLogin() nécessite un vrai utilisateur dans le user store.
Les tests avec @WithMockUser fonctionnent parfaitement même sans base de données ni utilisateurs in-memory. Mais formLogin(), non. Il authentifie honnêtement. Donc soit vous devez avoir un utilisateur préparé dans la base de test / données de seed, soit une configuration de user store séparée pour les tests, soit vous lancez ces tests sur un point de contrôle correspondant du projet.

Erreur n°4 : tester le logout avec GET /logout ou avec POST sans CSRF.
Lorsque CSRF est activé, le logout doit être une requête qui change l’état avec un token. Si le token est absent, il est normal d’obtenir un 403. Et ce n’est pas « un obstacle au développement », c’est la sécurité qui fait son travail. Dans les tests, il est préférable d’être explicite : post("/logout").with(csrf()) pour éviter des différences de comportement aléatoires entre configurations.

Erreur n°5 : mélanger dans un même test login/logout et tous les endpoints métier du projet.
Parfois, on veut écrire un seul test « géant » : login, profil, brouillons, publier, logout… Un tel test échouera « pour une raison inconnue » et deviendra votre série personnelle de 12 saisons. Il est préférable de garder les tests de login/logout séparés et courts, et de vérifier les endpoints métier via les approches user() – ainsi la suite de tests reste maintenable et prévisible.

1
Mission
Spring Security, niveau 27, leçon 1
Bloqué
Connexion par formulaire réussie et échouée
Connexion par formulaire réussie et échouée
1
Mission
Spring Security, niveau 27, leçon 1
Bloqué
Deconnexion et reutilisation de la meme session
Deconnexion et reutilisation de la meme session
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION