Blog / Symfony

Symfony 8.2 : exiger une clé matérielle avant une action sensible

Symfony 8.2 note comment et quand l’utilisateur s’est authentifié : de quoi remplacer les pages « confirmez votre mot de passe » maison, piège compris.

Par Romain Barberi 7 min de lecture 8 sources

La plupart des applications Symfony qui ont quelques années ont leur page « confirmez votre mot de passe » avant de changer d’adresse email ou de supprimer un compte. Elle est presque toujours maison : un horodatage en session, un contrôleur, un if recopié dans trois actions. Symfony 8.2, attendue en novembre, rend ce mécanisme natif en deux temps : le sudo mode, présenté le 30 septembre, sait quand l’utilisateur a saisi ses identifiants ; le billet du 12 octobre ajoute comment il l’a fait (mot de passe, clé matérielle, code à usage unique). Ma position : c’est la bonne occasion de supprimer ce code maison, mais un authenticator écrit il y a cinq ans peut rendre toute la mécanique muette sans la moindre erreur, et c’est ce point qu’il faut vérifier en premier.

Ce que la 8.2 ajoute : le quand, puis le comment

Le sudo mode introduit deux attributs. IS_AUTHENTICATED_RECENTLY est accordé pendant 2 heures après une saisie d’identifiants, IS_AUTHENTICATED_VERY_RECENTLY pendant 5 minutes. Les durées se règlent avec recent_authentication_lifetime et very_recent_authentication_lifetime, en secondes. Un utilisateur revenu par un cookie remember-me n’obtient jamais ces attributs : quiconque a accès au navigateur pourrait se servir du cookie.

Le billet du 12 octobre va plus loin. Le jeton de sécurité garde désormais la liste des méthodes d’authentification prouvées pendant la session, avec l’heure de leur dernière utilisation. Les noms viennent de la RFC 8176, publiée en 2017 pour le claim amr des jetons OpenID Connect : pwd pour un mot de passe, otp pour un code à usage unique, hwk pour une clé matérielle. Symfony les expose comme constantes de la classe AuthenticationMethod, et getAuthenticationProofs() les renvoie sous la forme ['pwd' => 1789140200, 'hwk' => 1789140260].

Deux détails changent la façon de penser une politique de sécurité. D’abord, quand le même utilisateur s’authentifie à nouveau dans la session, les preuves précédentes sont conservées : une clé matérielle utilisée à la connexion reste visible après une confirmation par mot de passe trois heures plus tard. Ensuite, un voter peut désormais déclencher la ré-authentification au lieu d’un 403, en appelant requestReAuthentication() sur l’objet Vote. Jusqu’ici, seul le sudo mode le faisait.

AuthenticatorPreuve enregistrée (doc 8.2)
form_login, json_loginpwd
oidc_loginles valeurs du claim amr, à l’heure de auth_time
login_link* (non précisé)
remember_me, http_basic, access_tokenrien : jamais « récent »
authenticator maison interactif*, ou les méthodes de son AuthenticationMethodBadge

Le piège d’un vieux projet : l’authenticator maison qui ne prouve rien

Sur les montées de version Symfony que j’ai menées, la sécurité est rarement configurée comme dans la documentation. On trouve un authenticator de formulaire écrit à la main lors du passage au nouveau système de sécurité, souvent dérivé d’AbstractAuthenticator parce que c’était le plus court. C’est là que se loge le piège : l’écouteur qui enregistre les preuves, AuthenticationProofsListener, ne le fait que pour un authenticator qui implémente InteractiveAuthenticatorInterface et répond true à isInteractive(). Ajouter un AuthenticationMethodBadge au passport ne suffit pas.

Pour m’en assurer, j’ai installé la branche 8.2.x-dev de symfony/security-http et fait passer deux authenticators par l’écouteur. Les deux déclarent une clé matérielle ; le second implémente l’interface en plus :

class LegacyKeyAuthenticator extends AbstractAuthenticator
{
    public function authenticate(Request $request): Passport
    {
        return new SelfValidatingPassport(
            new UserBadge('alice'),
            [new AuthenticationMethodBadge(AuthenticationMethod::HARDWARE_KEY)],
        );
    }
    // supports(), onAuthenticationSuccess(), onAuthenticationFailure()...
}

class InteractiveKeyAuthenticator extends LegacyKeyAuthenticator implements InteractiveAuthenticatorInterface
{
    public function isInteractive(): bool
    {
        return true;
    }
}

// pour chacun : LoginSuccessEvent, puis AuthenticationProofsListener::onLoginSuccess()
LegacyKeyAuthenticator : []
InteractiveKeyAuthenticator : ["hwk"]

Ce script a été exécuté le 13 octobre avec PHP 8.4.13 sur la branche 8.2 de Symfony. Le premier jeton ne porte aucune preuve. Conséquence : IS_AUTHENTICATED_RECENTLY et IS_AUTHENTICATED_VERY_RECENTLY sont refusés à tous les utilisateurs, y compris ceux qui viennent de taper leur mot de passe. Si votre entry point de ré-authentification renvoie vers ce même formulaire, l’utilisateur tourne en boucle.

Le second cas silencieux concerne les jetons maison. Un jeton qui implémente TokenInterface directement, sans hériter d’AbstractToken, n’a pas getAuthenticationProofs(). Le code du AuthenticationTrustResolver émet alors une dépréciation (la méthode deviendra obligatoire en 9.0) et répond false. Une dépréciation dans un log que personne ne lit, et une fonctionnalité de sécurité qui refuse tout : c’est exactement le genre de régression qu’on découvre en production.

Exiger un facteur fort pour une action précise

Une fois les preuves en place, la politique tient dans un voter. Le billet officiel en donne un qui exige une clé matérielle, et ne demande une nouvelle authentification qu’aux utilisateurs qui en possèdent une :

protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
    if (isset($token->getAuthenticationProofs()[AuthenticationMethod::HARDWARE_KEY])) {
        return true;
    }

    $user = $token->getUser();
    if ($user instanceof User && $user->hasSecurityKey()) {
        $vote?->requestReAuthentication($attribute);
    }

    return false;
}

L’action se protège ensuite avec #[IsGranted('IS_SECURITY_KEY_USED')]. Un utilisateur sans clé reçoit un 403, ce qui est le bon comportement : lui redemander de s’authentifier ne l’aidera pas. Un utilisateur qui a une clé mais ne l’a pas utilisée dans la session est envoyé vers l’entry point de ré-authentification, qui peut lire l’attribut refusé dans SecurityRequestAttributes::RE_AUTHENTICATION_ATTRIBUTE pour afficher l’invite de clé plutôt qu’un formulaire de mot de passe.

L’autre voie, documentée pour la 8.2, consiste à surcharger le resolver de confiance. On étend AuthenticationTrustResolver, on redéfinit isAuthenticatedVeryRecently() pour exiger, par exemple, une clé matérielle dans la session et une authentification de moins de 5 minutes, puis on remplace le service security.authentication.trust_resolver. C’est plus global : tous les IS_AUTHENTICATED_VERY_RECENTLY de l’application changent de sens d’un coup. Sur un legacy, je préfère le voter : un attribut nommé par action se relit dans le code, et se teste unitairement avec un jeton fabriqué à la main, ce qui est le cas des quelques lignes ci-dessous, exécutées dans les mêmes conditions que le script précédent :

$token = new UsernamePasswordToken(new InMemoryUser('alice', null, ['ROLE_USER']), 'main', ['ROLE_USER']);
$resolver = new AuthenticationTrustResolver();

$token->setAuthenticationProofs([AuthenticationMethod::PASSWORD => time() - 3 * 3600]);
var_dump($resolver->isAuthenticatedRecently($token));      // bool(false)

$token->setAuthenticationProofs([AuthenticationMethod::PASSWORD => time() - 60]);
var_dump($resolver->isAuthenticatedVeryRecently($token));  // bool(true)

Avec un fournisseur d’identité : demander, puis vérifier

Pour une application qui délègue la connexion à Keycloak, Entra ID ou un autre fournisseur OpenID Connect via le nouvel oidc_login, la ré-authentification se passe chez le fournisseur. Symfony le redirige avec prompt=login, et contrôle le claim auth_time du jeton d’identité pour qu’une session ancienne chez le fournisseur ne compte pas comme une connexion fraîche. Les méthodes du claim amr deviennent les preuves du jeton Symfony.

Pour exiger un facteur fort, un écouteur de OidcAuthorizationRequestEvent ajoute le paramètre acr_values à la requête, par exemple phrh. Cette valeur vient de la spécification OpenID Connect EAP ACR Values 1.0 et désigne une authentification résistante à l’hameçonnage, avec une clé protégée par du matériel. Mais acr_values n’est qu’une demande : le fournisseur peut authentifier l’utilisateur autrement. Symfony 8.2 range donc le claim acr du jeton d’identité dans l’attribut oidc_acr, et c’est lui que le voter doit lire. Ne jamais conclure qu’un facteur a été utilisé parce qu’on l’a demandé.

Pour un client qui a déjà un fournisseur d’identité d’entreprise, c’est un vrai gain : la double authentification des actions d’administration n’a plus besoin d’être construite dans l’application, elle se demande.

Les limites

La 8.2 n’est pas sortie : la page de version annonce novembre 2026, avec PHP 8.4 au minimum et un support jusqu’en juillet 2027. Les noms de classes et de méthodes peuvent encore bouger d’ici là, et une application en 6.4 ou en 7.4 a plusieurs marches à monter avant d’en profiter. En attendant, il reste possible de préparer le terrain : rendre ses authenticators interactifs et ses jetons héritiers d’AbstractToken ne dépend pas de la 8.2.

La mécanique ne vaut que ce que valent les preuves déclarées. Un authenticator maison qui ajoute hwk sans avoir réellement vérifié une assertion WebAuthn ment au reste de l’application, et le voter le croira. Les bundles de double authentification répandus, comme scheb/2fa-bundle (plus de 18 millions d’installations sur Packagist), ont leur propre modèle de session ; qu’ils alimentent ces nouvelles preuves n’a rien d’automatique, et c’est à contrôler par un test avant d’écrire une politique qui en dépend.

Enfin, côté OIDC, tout repose sur ce que le fournisseur met dans amr et acr. Certains n’émettent pas amr, d’autres utilisent leurs propres valeurs d’acr ; la documentation du fournisseur fait foi, pas la RFC.

Pour la méthode : les deux scripts de cet article ont été exécutés sur la branche de développement de Symfony 8.2 ; le parcours complet avec un fournisseur OpenID Connect s’appuie sur le billet officiel et la documentation, sans l’avoir reproduit.

Concrètement, par où commencer

  1. Listez les authenticators maison : grep -rn "extends AbstractAuthenticator\|implements AuthenticatorInterface" src/. Pour chacun qui traite une saisie d’identifiants, vérifiez qu’il implémente InteractiveAuthenticatorInterface ou qu’il hérite d’AbstractLoginFormAuthenticator.
  2. Cherchez les jetons maison : grep -rn "implements TokenInterface" src/. Faites-les hériter d’AbstractToken, ou ajoutez les deux méthodes de preuves.
  3. Recensez vos confirmations de mot de passe maison (horodatages en session, contrôleurs « confirm ») et notez, pour chacune, l’action qu’elle protège : ce sera la liste de vos IS_AUTHENTICATED_RECENTLY et de vos attributs de voter.
  4. Sur une branche en 8.2, écrivez un test fonctionnel par action sensible : connexion par mot de passe, attente simulée au-delà de la durée, accès attendu en ré-authentification et non en 403.
  5. Si vous passez par un fournisseur d’identité, demandez-lui ce qu’il émet dans amr et acr avant d’écrire une ligne de code.

Le plus long, dans ce chantier, ne sera pas d’écrire les voters, mais de retrouver où l’application décidait jusqu’ici qu’un utilisateur était « assez authentifié ».

Sources

Sur ce sujet, l’offre de l’atelier : Migration Symfony

À lire aussi

Un socle à migrer, une architecture à fiabiliser ?

Renfort senior en régie, ou forfait à périmètre et prix fixés : l’atelier intervient à distance sur PHP, Symfony, les systèmes distribués et l’adoption des agents de code, avec des tests de caractérisation avant chaque migration.