Aller au contenu

Architecture des firewalls Kirexo

Cette page explique pourquoi Kirexo sépare son authentification en deux firewalls Symfony et comment l'espace d'administration se rend invisible aux visiteurs de l'espace utilisateur. Pour la liste exhaustive des options de configuration, voir la Référence security.yaml. Pour les choix d'architecture généraux du projet, voir Architecture.

Deux espaces, deux firewalls

Kirexo distingue deux espaces qui ne s'adressent pas au même besoin :

  • l'espace utilisateur, où chacun rédige et publie ses propres articles ;
  • l'espace d'administration, qui gère les comptes utilisateurs mais jamais le contenu (cf. Architecture).

Ces deux espaces sont gardés par deux firewalls Symfony distincts, déclarés dans config/packages/security.yaml :

  • le firewall admin, qui couvre tout ce qui commence par /admin (pattern ^/admin) ;
  • le firewall user, qui couvre tout le reste (aucun pattern, donc fallback).

L'ordre de déclaration compte : Symfony retient le premier firewall dont le pattern correspond à l'URL. admin étant déclaré avant user et portant un pattern explicite, une requête /admin/... est traitée par le firewall admin ; toute autre URL retombe sur user.

Un provider commun

Les deux firewalls partagent le même provider d'utilisateurs : app_user_provider, un provider Doctrine qui charge l'entité App\Entity\User par son email. Il n'y a donc qu'une seule table d'utilisateurs et qu'une seule notion d'identité dans Kirexo — un compte peut être utilisateur, administrateur, ou les deux selon ses rôles. La séparation en deux firewalls n'est pas une séparation des comptes, mais une séparation des sessions et des parcours d'authentification : se connecter à l'espace user ne connecte pas à l'espace admin, et inversement.

Les comptes ne se créent pas via un formulaire d'inscription public : ils sont provisionnés en ligne de commande. Voir le how-to Créer un compte utilisateur en CLI.

Deux pages de login, deux ambiances

Chaque espace a sa propre page de login, servie par un controller invokable dédié :

Espace URL de login Controller Template
Utilisateur /login src/Controller/AppLoginController.php templates/login/index.html.twig
Administration /admin/login src/Controller/AppAdminLoginController.php templates/admin/login/index.html.twig

Les deux pages sont désormais habillées au design system en plein écran (elles étendent base.html.twig, sans header applicatif) et diffèrent volontairement : l'espace utilisateur met en avant un message d'écriture, tandis que l'espace admin affiche dès le login son identité back-office (coque sombre, tag « ADMIN »). Cette distinction visuelle aide à savoir d'un coup d'œil dans quel espace on se trouve. Le raisonnement de cet habillage est détaillé dans Pages de connexion et accueil de la plateforme.

Chaque controller suit la même logique : si le visiteur est déjà authentifié dans l'espace concerné, il est redirigé ; sinon, la page de login est rendue avec la dernière erreur d'authentification et le dernier email saisi (récupérés via AuthenticationUtils).

Après une connexion réussie, chaque firewall redirige vers l'accueil de son espace (default_target_path) : /u (app_user_home) pour l'utilisateur, /admin (app_admin_dashboard) pour l'admin. Ces deux pages sont décrites dans la Référence des espaces utilisateur et admin.

Bascule entre espaces

Pour un compte mixte (porteur de ROLE_USER et ROLE_ADMIN), une transition légitime entre les deux espaces existe : voir Mécanisme de bascule entre espaces.

Masquer l'admin : 404 plutôt que 403

Le point structurant de cette architecture est le masquage complet de l'espace d'administration vis-à-vis des visiteurs de l'espace utilisateur.

Le réflexe classique serait de renvoyer un 403 Forbidden à un utilisateur connecté qui tente d'accéder à /admin. Mais un 403 trahit l'existence de la ressource : il dit « ça existe, mais pas pour toi ». Pour une interface d'administration, c'est une information à ne pas divulguer. Kirexo choisit donc de renvoyer un 404 Not Found : pour un visiteur de l'espace user, l'admin n'existe tout simplement pas — y compris sa page de login /admin/login.

La mécanique : AdminAreaAccessSubscriber

Ce comportement est porté par src/EventSubscriber/AdminAreaAccessSubscriber.php, abonné à kernel.request. Sa logique, à chaque requête principale :

  1. Si l'URL ne commence pas par /admin → rien à faire.
  2. Si le visiteur a ROLE_ADMIN (authentifié sur le firewall admin) → accès légitime, on laisse passer.
  3. Sinon, si une session du firewall user est présente (le visiteur est connecté à l'espace utilisateur) → on lève une NotFoundHttpException (404).

Un visiteur anonyme (ni user ni admin) n'est pas concerné par le 404 : il peut atteindre /admin/login normalement pour s'authentifier. Le masquage ne vise que les comptes déjà connectés à l'espace user, qui n'ont rien à faire dans l'admin par cette porte.

Pourquoi un subscriber et pas un check dans le controller

La règle « pas de check d'autorisation inline » de CLAUDE.md impose de ne pas écrire un if ($user->isAdmin()) dans le controller. Mais surtout, le masquage doit couvrir tout ^/admin, pas seulement la page de login : un seul subscriber sur kernel.request garde la mécanique en un point unique, intimement lié à la configuration des firewalls, plutôt que dispersée dans chaque controller admin.

Pourquoi lire la session plutôt qu'un firewall

Le subscriber détecte l'appartenance à l'espace user en cherchant la clé de session du token du firewall user (_security_user). Quand le subscriber s'exécute sur une URL /admin, c'est le firewall admin qui est actif — Security::getUser() ne refléterait donc pas une éventuelle session user. Lire directement la clé de session permet de savoir si le visiteur est connecté à l'espace user, indépendamment du firewall courant.

La contrepartie est un couplage au nom du firewall : la constante USER_FIREWALL_SESSION_KEY doit rester synchronisée avec le nom du firewall user. Tant qu'aucun context: partagé n'est défini dans security.yaml, la clé de session vaut _security_<nom du firewall>.

La transition légitime passe par la bascule

Si l'admin est masqué aux comptes user, comment un compte légitimement administrateur bascule-t-il vers l'espace admin ? Pas par /admin/login une fois connecté en user — ce chemin renvoie 404. La transition propre user → admin (et inversement) passe par un mécanisme de bascule dédié, réservé aux comptes mixtes : un jeton signé à usage unique reconnecte le compte sur l'autre firewall. Le détail est documenté dans Mécanisme de bascule entre espaces.

Le contrôle d'accès : access_control

En complément des firewalls, la section access_control de security.yaml applique des règles de rôle par préfixe d'URL. Leur ordre est significatif : Symfony retient la première règle dont le path correspond.

access_control:
    - { path: ^/admin/login, roles: PUBLIC_ACCESS }
    - { path: ^/admin, roles: ROLE_ADMIN }
    - { path: ^/u, roles: ROLE_USER }
    - { path: ^/login, roles: PUBLIC_ACCESS }

La règle ^/admin/login (accès public) est placée avant ^/admin (réservé à ROLE_ADMIN). Sans cet ordre, la page de login admin tomberait sous la règle ROLE_ADMIN et exigerait d'être déjà admin pour… afficher le formulaire de connexion admin — une impasse. En la déclarant en premier avec PUBLIC_ACCESS, on l'exclut de la contrainte de rôle tout en laissant le reste de ^/admin protégé.

Le masquage 404 et access_control sont complémentaires : access_control gouverne ce qu'un rôle a le droit d'atteindre (couche autorisation Symfony), tandis que AdminAreaAccessSubscriber décide si l'admin doit même avouer son existence à un visiteur de l'espace user (couche discrétion). Les deux s'appliquent sur les mêmes URLs sans se contredire.

Déconnexion cohérente : effacer les cookies remember-me des deux firewalls

La séparation en deux firewalls a une conséquence peu intuitive sur la déconnexion : par défaut, se déconnecter d'un espace ne suffit pas à se déconnecter de l'autre. Cette section explique pourquoi, et comment Kirexo rend la déconnexion globale.

Deux contextes de sécurité, deux clés de session

Chaque firewall Symfony est un système de sécurité indépendant : il possède son propre context, égal par défaut à son nom. Le token authentifié n'est donc pas stocké une fois pour toutes, mais deux fois, sous deux clés de session distinctes :

  • _security_admin pour le firewall admin ;
  • _security_user pour le firewall user.

C'est le même mécanisme que celui exploité par AdminAreaAccessSubscriber pour détecter une session user depuis le firewall admin (voir plus haut). Un compte mixte connecté aux deux espaces a donc deux tokens vivant côte à côte dans la même session PHP.

Quand la case « Se souvenir de moi » est cochée, chaque firewall pose son propre cookie persistant (cf. Référence security.yaml) :

  • REMEMBERME_ADMIN pour le firewall admin ;
  • REMEMBERME_USER pour le firewall user.

Un compte mixte qui coche l'option sur les deux formulaires repart avec deux cookies remember-me dans son navigateur.

Le LogoutListener de Symfony est, comme le reste du firewall, mono-firewall. Une déconnexion depuis l'espace user (POST /logout) :

  1. efface le token _security_user de la session ;
  2. efface le cookie remember-me de ce firewall uniquement (REMEMBERME_USER) ;
  3. invalide la session PHP entière (invalidate_session reste à true, son défaut).

Le cookie REMEMBERME_ADMIN, lui, n'est pas touché : Symfony n'efface jamais automatiquement le cookie remember-me des autres firewalls. Il subsiste dans le navigateur. À la requête suivante sur ^/admin, le RememberMeAuthenticator du firewall admin le consomme et recrée un token _security_admin dans la session fraîche.

Résultat : côté user, l'utilisateur est déconnecté ; côté admin, il est ré-authentifié en silence. Les deux contextes de sécurité sont désynchronisés, et selon la ressource atteinte, l'accès partait en erreur 500 captée par le profiler (page /_profiler/…?panel=request). C'était le bug #69.

Ce n'est pas la bascule qui est en cause

Le mécanisme de bascule détruit bien la session, mais ne touche pas non plus les cookies remember-me — il n'en souffrait pas car le jeton de bascule reloge immédiatement dans l'espace cible. Le bug tenait uniquement à la survivance d'un cookie remember-me après un logout.

La correction : effacer les deux cookies à chaque logout

Kirexo rend la déconnexion cohérente par deux volets complémentaires.

1. Un subscriber sur les deux dispatchers de firewall. src/Security/EventSubscriber/ClearRememberMeCookiesSubscriber.php écoute le LogoutEvent et ajoute à la réponse de logout l'effacement des deux cookies remember-me (REMEMBERME_USER et REMEMBERME_ADMIN), quel que soit le firewall d'origine du logout.

Deux points en font la fiabilité :

  • Le LogoutEvent est dispatché sur le dispatcher propre à chaque firewall, pas sur le dispatcher global. Le subscriber est donc enregistré explicitement sur security.event_dispatcher.user et security.event_dispatcher.admin (via config/services.yaml). Sans ce double enregistrement, l'événement ne l'atteindrait jamais.
  • Il s'exécute en priorité -64, c'est-à-dire après le DefaultLogoutListener qui pose la RedirectResponse vers target. Il mute cette réponse existante plutôt que d'en créer une concurrente ; si aucune réponse n'est encore posée, un garde défensif le fait ne rien faire.

Le cookie de suppression est reposé aux mêmes attributs que l'original (path=/, secure, httponly, samesite=lax) : un seul attribut divergent (le flag secure notamment, dérivé de REMEMBER_ME_SECURE) empêcherait le navigateur de reconnaître le cookie à supprimer.

2. Un en-tête Clear-Site-Data en défense en profondeur. Les deux blocs logout déclarent en plus clear_site_data: ['cookies'] (cf. Référence security.yaml). Symfony émet alors l'en-tête HTTP Clear-Site-Data: "cookies", qui demande au navigateur de purger les cookies du site à la déconnexion. C'est une ceinture-bretelles : l'effacement ciblé du subscriber reste la garantie testable côté serveur (les Set-Cookie expirés dans la réponse), tandis que Clear-Site-Data couvre le navigateur plus largement — sans effet en HTTP local/test, où les cookies ne sont pas secure.

On conserve invalidate_session: true : la session PHP entière doit bien être détruite. Le point de correction n'était pas la session mais la survivance des cookies remember-me de l'autre firewall.

La liste complète des firewalls, de leurs options et des règles access_control est détaillée dans la Référence security.yaml.