Être connecté ne suffit pas : redemander une preuve avant une action sensible

Arnaud Pointet · · 9 min de lecture

Read this article in English

Cet article part d'un exercice, à faire dans le navigateur : Redemander le mot de passe avant les pages sensibles →

Dans la plupart des applications, l'authentification est un booléen. On est connecté ou on ne l'est pas, et une fois connecté, tout est ouvert : consulter ses factures, changer son adresse e-mail, supprimer son compte, générer une clé d'API.

Pourtant, « connecté » recouvre des situations très différentes. Une session ouverte il y a une minute avec un mot de passe et un code. Une session rouverte ce matin par un cookie « se souvenir de moi » posé il y a trois semaines. Un ordinateur resté déverrouillé pendant le déjeuner. Un cookie de session volé par une extension de navigateur. Pour lire un tableau de bord, la différence compte peu. Pour changer l'adresse qui sert à réinitialiser le mot de passe, elle compte beaucoup.

GitHub redemande le mot de passe avant de supprimer votre compte ou de créer un jeton d'accès. Votre banque demande un code avant un virement vers un nouveau bénéficiaire. C'est ce qu'on appelle l'authentification renforcée à la demande (step-up authentication) : on ne demande pas tout, tout le temps, mais une preuve supplémentaire au moment où elle compte.

Symfony 8.2 apporte enfin les briques pour le faire proprement. Mais avant le code, il y a des choix de conception, et ce sont eux qui font la différence.

Trois questions au lieu d'une

Une session authentifiée répond à une question : qui est l'utilisateur. Pour décider s'il peut faire une action sensible, il en faut deux de plus.

  • Quand a-t-il prouvé son identité pour la dernière fois ? Pas quand la session a commencé : quand il a fait un geste que lui seul pouvait faire.
  • Comment l'a-t-il prouvée ? Un mot de passe, un code reçu par SMS, une clé de sécurité, un lien magique dans sa boîte mail.

Ces questions ne sont pas nouvelles. OpenID Connect y répond depuis longtemps dans le jeton d'identité : auth_time dit quand l'utilisateur s'est authentifié, amr (authentication methods references) liste les méthodes utilisées, et le client peut exiger une authentification récente avec le paramètre max_age. La RFC 8176 normalise les valeurs d'amr : pwd pour un mot de passe, sms, otp, hwk pour une clé matérielle, et ainsi de suite. Si votre application délègue la connexion à un fournisseur d'identité, une bonne partie de cet article se règle dans la configuration de ce fournisseur, et votre application n'a qu'à lire ces informations.

Si c'est votre application qui authentifie, c'est à elle de retenir ces réponses.

Quelles actions protéger

Tout protéger revient à ne rien protéger : un utilisateur à qui l'on redemande son mot de passe toutes les dix minutes finit par le coller depuis un fichier texte, ou par quitter le service. Trois critères me servent de tri.

L'action permet-elle de prendre le contrôle du compte ? Changer l'adresse e-mail, le mot de passe, le numéro de téléphone, désactiver la double authentification, ajouter une clé de sécurité. Ce sont les cibles prioritaires, parce qu'un attaquant qui y arrive une fois n'a plus besoin de la session volée : il a le compte. Le changement d'e-mail est le plus sous-estimé : il ne ressemble pas à une action de sécurité, mais c'est par lui que passe la réinitialisation du mot de passe.

L'action est-elle irréversible, ou coûte-t-elle de l'argent ? Un virement, une commande, la suppression d'un compte ou d'un projet.

L'action fait-elle sortir des données en masse ? Un export complet, la création d'une clé d'API ou d'un jeton d'accès, l'ajout d'un membre avec des droits d'administration.

Le reste (consulter, modifier son profil public, changer ses préférences) peut généralement se contenter de la session.

Deux niveaux de fraîcheur suffisent souvent. Une preuve de moins de quelques heures pour les pages sensibles à consulter, comme les factures ou l'historique des connexions. Une preuve de quelques minutes pour les actions qui donnent le contrôle du compte. Les durées exactes dépendent de votre métier : une application bancaire n'a pas les mêmes contraintes qu'un outil de gestion de projet.

Redemander la bonne preuve

Redemander une preuve, oui, mais laquelle ? Deux erreurs reviennent souvent.

Redemander une preuve plus faible que celle de la connexion. Si l'utilisateur s'est connecté avec un mot de passe et une clé de sécurité, lui redemander seulement un code envoyé par e-mail avant de désactiver la double authentification ouvre un raccourci à quiconque a accès à sa boîte mail. La preuve redemandée doit être au moins aussi forte que celle que l'action permet de modifier ou de désactiver.

Croire que tous les seconds facteurs se valent. Le SMS reste très répandu, mais il est exposé au détournement de numéro (SIM swapping) : le NIST le range parmi les authentificateurs « restreints », à n'utiliser qu'en connaissance de cause et avec une alternative proposée à l'utilisateur. GitHub, par exemple, ne l'accepte pas pour son mode sudo. Une application d'authentification (TOTP) ou une clé de sécurité (WebAuthn) est plus solide. Le SMS vaut mieux que rien, mais ce n'est pas une raison pour en faire le seul rempart d'un virement important.

Et parfois, il n'y a rien à redemander : un utilisateur qui n'a jamais enregistré de téléphone ne pourra pas fournir de code. Le renvoyer vers un formulaire qu'il ne peut pas remplir ne mène nulle part. Pour lui, un refus clair, avec la marche à suivre pour ajouter un second facteur, est plus honnête qu'une impasse.

Ne pas casser le parcours

Une réauthentification réussie doit ramener l'utilisateur exactement où il allait. Sinon, il recommence, se lasse, et vous reproche à juste titre de lui faire perdre son temps.

Un piège classique : protéger seulement la requête POST qui enregistre le formulaire. L'utilisateur remplit le formulaire de changement d'adresse, l'envoie, se fait renvoyer vers la page de mot de passe… et sa saisie est perdue. Le pare-feu de Symfony, comme la plupart des mécanismes de ce genre, ne retient la page cible que pour une requête de lecture (GET), hors Ajax. Il ne peut pas rejouer un POST. La solution est simple : protégez aussi l'affichage du formulaire. La preuve est demandée avant la saisie, et elle est encore valable au moment de l'envoi.

Pour une API, la redirection vers un formulaire n'a pas de sens. La RFC 9470 (OAuth 2.0 Step-Up Authentication Challenge Protocol) décrit la réponse attendue : une 401 avec un en-tête WWW-Authenticate portant l'erreur insufficient_user_authentication, et éventuellement max_age ou acr_values pour dire ce qui manque. Le client sait alors qu'il doit relancer une authentification plus forte, et non afficher « accès refusé ».

L'écran de réauthentification est un écran de connexion

Une page qui ne demande que le mot de passe, pour un utilisateur déjà connecté, paraît anodine. C'est pourtant exactement ce que cherche quelqu'un qui a volé une session : un formulaire où tester des mots de passe, sans avoir à deviner l'identifiant. Il lui faut donc les mêmes protections qu'un formulaire de connexion : limitation du nombre d'essais, journalisation des échecs, et des codes à usage unique qui expirent vite.

Après une action sensible réussie, prévenez l'utilisateur par un autre canal. Un e-mail « votre adresse a été modifiée » envoyé à l'ancienne adresse, avec un lien pour annuler, rattrape le cas où toutes les autres protections ont été contournées. C'est le filet de sécurité le moins cher de cet article.

Enfin, une session rouverte par un cookie « se souvenir de moi » ne doit jamais compter comme une preuve récente. Le cookie prouve qu'un navigateur a été connecté un jour, pas que la personne devant l'écran connaît le mot de passe.

Avant Symfony 8.2 : un horodatage en session

Jusqu'à Symfony 8.1, rien n'est prévu pour le « quand » ni pour le « comment ». IS_AUTHENTICATED_FULLY distingue seulement une session ouverte par un formulaire d'une session ouverte par le cookie « se souvenir de moi ». Il faut donc retenir soi-même l'heure de la dernière preuve, par exemple dans un écouteur de LoginSuccessEvent :

#[AsEventListener]
public function __invoke(LoginSuccessEvent $event): void
{
    // Le cookie « se souvenir de moi » déclenche aussi cet événement : il ne prouve rien.
    if ($event->getAuthenticatedToken() instanceof RememberMeToken) {
        return;
    }

    $event->getRequest()->getSession()->set('derniere_preuve', time());
}

Puis un voter compare cet horodatage à l'heure courante. Ça fonctionne, mais tout est à votre charge : retenir la méthode en plus de l'heure, renvoyer vers la bonne page au lieu d'un 403, ne pas oublier le cas du cookie. C'est précisément le genre de code qu'on écrit une fois, à moitié, et qu'on ne relit plus.

Ce que Symfony 8.2 apporte

Symfony 8.2 (encore en développement au moment où j'écris) intègre ce mécanisme au composant Security, en quatre briques.

La fraîcheur. Deux nouveaux attributs, IS_AUTHENTICATED_RECENTLY (moins de deux heures par défaut, la fenêtre du mode sudo de GitHub) et IS_AUTHENTICATED_VERY_RECENTLY (moins de cinq minutes), s'utilisent partout où l'on utilise IS_AUTHENTICATED_FULLY : #[IsGranted], access_control, Twig. Les durées se règlent dans security.yaml. Une session ouverte par cookie « se souvenir de moi » échoue aux deux.

La réauthentification au lieu du refus. Quand l'un de ces attributs est refusé, le pare-feu ne répond plus par un 403 : il retient la page demandée et appelle un point d'entrée de réauthentification. form_login en fournit un, qui renvoie vers la page de connexion, identifiant déjà rempli. Pour une page qui ne demande que le mot de passe, on écrit le sien en implémentant ReAuthenticationEntryPointInterface, et on le déclare sur le pare-feu avec re_authentication_entry_point.

Le « comment ». Le jeton retient désormais chaque méthode prouvée pendant la session, avec l'heure de son dernier usage, en reprenant les noms de la RFC 8176 :

$token->getAuthenticationProofs();
// ['pwd' => 1792300000, 'sms' => 1792300060]

Un authenticator déclare sa méthode par un AuthenticationMethodBadge sur le passeport. Seules les authentifications interactives comptent, et le cookie « se souvenir de moi » est explicitement exclu. Un authenticator sans badge est enregistré sous la méthode *, non précisée. Côté OpenID Connect, l'authenticator OIDC lit amr et auth_time dans le jeton d'identité pour alimenter ces preuves. Un voter peut alors exiger, par exemple, une preuve sms de moins de cinq minutes avant un virement.

Vos propres règles. Un voter peut signaler au pare-feu qu'une nouvelle preuve changerait sa réponse, avec $vote->requestReAuthentication($attribute). Le point d'entrée lit quel attribut a été refusé et choisit quoi redemander : un code pour le virement, le mot de passe pour le reste. Et le voter peut aussi ne rien signaler, pour l'utilisateur qui n'a pas de second facteur : il garde son 403. Une limite à connaître : le mécanisme ne joue que si un seul attribut est refusé. Un contrôle qui en combine plusieurs donne toujours un 403.

Le quand, le comment et le quoi redemander ont donc chacun leur réponse dans le framework. Les choix restent les vôtres : quelles actions protéger, avec quelle preuve, et pendant combien de temps.

Pour essayer

J'ai tiré trois exercices de ces nouveautés, à faire dans le navigateur, gratuits et sans compte, sur une version de développement de Symfony 8.2 :

← Tous les articles