Authentification conditionnelle avec Symfony 6

Objectif

Le but de cet article est d’arriver a créer une page protégée par une authentification conditionnelle. La condition sera définie sur une entité via une de ses propriété. Nous allons créer une entité « Territoire » qui aura une propriété $public définie soit à false, soit à true. En fonction de cette valeur, nous pourrons accéder ou non a la page territoire de notre entité. Si la valeur de $public est sur false, alors nous la protégerons par une protection HTTP Basic. Le but ici n’est pas de créer une forte authentification, mais de montrer qu’il est possible d’avoir une authentification basée sur une donnée de l’entité.

Depuis la version 6.1 de Symfony, il est possible de passer une interface RequestMatcherInterface dans l’access_control de Symfony. Nous allons utiliser cette fonctionnalité pour arriver à notre objectif.

Développement de notre fonctionnalité

Création de l’entité Territoire

Il faut déjà créer notre entité Territoire. Il est important que notre entité implémente UserInterface et PasswordAuthenticatedUserInterface pour que l’authentification puisse fonctionner. Il faut noter l’usage des méthodes getRoles et getUserIdentifier qui seront utilisé par notre provider.

<?php

namespace App\Entity;

use App\Repository\TerritoireRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;

#[ORM\Entity(repositoryClass: TerritoireRepository::class)]
class Territoire implements UserInterface, PasswordAuthenticatedUserInterface
{
    // ... autres propriétés

    #[ORM\Column(length: 255)]
    private string $slug;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $password = null;

    #[ORM\Column]
    private bool $isPublic = false;

    // ... getters et setters des propriétés ci-dessus

    public function getRoles(): array
    {
        return ['TERRITOIRE_ACCESS'];
    }

    public function eraseCredentials(): void {}

    public function getUserIdentifier(): string
    {
        return $this->getSlug();
    }
}

Création du contrôleur

On crée notre contrôleur qui affichera la page territoire. L’important ici est de bien voir le nom de la route, qui sera utilisé par la suite dans notre RequestMatcher.

class TerritoireController extends AbstractController
{
    #[Route('/territoire/{slug}', name: 'app_territoire_single')]
    public function __invoke(Territoire $territoire): Response
    // ...

Création du Provider

On crée le provider dans security.yaml, il permet d’aller chercher un territoire en fonction d’un identifiant et d’un mot de passe. Ici on définit que l’identifiant devra correspondre au contenu de la propriété slug du territoire (car c’est ce qu’on a définit dans la méthode getUserIdentifier de notre entité).

security:
    role_hierarchy:
        # ... autres roles
        TERRITOIRE_ACCESS: [ROLE_USER]

    providers:
        # ... autres providers
        territoire_provider:
            entity:
                class: 'App\Entity\Territoire'
                property: 'slug'

Création du firewall

On crée ensuite notre firewall. Il sera appliqué aux URLs qui comment par le mot territoire. On lie le provider créé ci-dessus à notre nouveau firewall. La configuration realm est obligatoire, même si dans notre cas elle n’est pas plus utile que ça.

security:
    role_hierarchy:
        # ...

    providers:
        # ...

    firewalls:
        # ... autres firewalls

        territoires:
            pattern: ^/territoire
            http_basic:
                realm: Secured Area
                provider: territoire_provider

Création de notre Request Matcher personnalisé

Maintenant, on crée notre règle de contrôle d’accès. On utilise le paramètre request_matcher qui pointe sur une classe qui implémente l’interface RequestMatcherInterface. Nous la nommons App\Security\TerritoireRequestMatcher. C’est cette fonctionnalité qui a été ajouté dans Symfony 6.1. Le role ROLE_USER est nécessaire pour y accéder. Dans la hiérarchie des roles, nous avons créé un role TERRITOIRE_ACCESS qui aussi le rôle ROLE_USER.

security: 
    role_hierarchy:
        # ...

    providers:
        # ...

    firewalls:
        # ...

    access_control:
        # ... autres règles
        - { request_matcher: App\Security\TerritoireRequestMatcher, roles: ROLE_USER }
        - { path: ^/, role: PUBLIC_ACCESS }

Ensuite il faut créer la classe TerritoireRequestMatcher. C’est elle qui contiendra la logique pour définir si oui ou non la route accédée correspond bien à notre contrôle d’accès.

<?php

declare(strict_types=1);

namespace App\Security;

use App\Repository\TerritoireRepository;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestMatcherInterface;

class TerritoireRequestMatcher implements RequestMatcherInterface
{
    public function __construct(
        private readonly TerritoireRepository $territoireRepository,
    ) {}

    public function matches(Request $request): bool
    {
        if ('app_territoire_single' === $request->attributes->get('_route') && null !== $request->attributes->get('slug')) {
            $territoire = $this->territoireRepository->findOneBy(['slug' => $request->attributes->get('slug'));
            if ($territoire && !$territoire->isPublic()) {
                return true;
            }
        }

        return false;
    }
}

La méthode matches de cette classe renvoi true si la requête actuelle est concernée par le contrôle d’accès. Dans notre cas, c’est seulement si nous sommes sur la bonne route et si le territoire a la propriété public à true que cette méthode correspond. Sinon, on renvoit false, et Symfony passe à la prochaine règle d’accès.

Conclusion

Pour finir, avec ces quelques bouts de code, nous avons une fonctionnalité qui permet facilement pour un utilisateur de définir si un territoire est public (tout le monde y à accès) ou privé (protégé par une authentification HTTP Basic). Avec une administration tel qu’EasyAdmin en place, cela peut dorénavant être fait en un seul clic, comme sur la capture ci dessous :


Commentaires

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *