Comment sécuriser des champs de formulaire avec Symfony

Dans certaines applications, il peut être nécessaire de désactiver certains champs d’un formulaire en fonction des rôles de l’utilisateur connecté.

Dans cet article, nous allons voir comment réaliser cette fonctionnalité à travers un exemple simple : un moteur de blog.

Introduction et bootstrap de l’application

Nous allons utiliser Symfony 4.1, Symfony Flex et le MakerBundle pour gagner du temps. Tout le code est publié sur Github. N’hésitez pas à cloner le dépôt pour jouer avec l’application.

Grâce à quelques commandes et quelques modifications de fichiers à la main, nous avons un blog à peu près fonctionnel en très peu de temps :

composer create-project symfony/website-skeleton form-and-security
cd form-and-security
composer req maker
bin/console make:entity Article
# Nous ajoutons juste un `title`, et un `content`
bin/console make:entity Admin
# Nous ajoutons juste un `name`
bin/console make:entity Article
# Nous ajoutons la relation `author` entre Article et Admin
bin/console doctrine:database:create
bin/console make:migration
bin/console doc:migration:migrate
bin/console make:crud Admin
bin/console make:crud Article

Maintenant, nous allons pouvoir mettre en place le composant Security de Symfony. Dans un soucis de simplicité, tous les utilisateurs auront le même mot de passe : password et ils pourront se connecter via un formulaire.

Pour ne pas alourdir cet article inutilement, nous n’allons pas détailler tout ce processus. Vous pouvez néanmoins retrouver le diff sur Github

Mise en place des règles de gestion

Pour l’administration d’un article, nous voulons que seulement les administrateurs avec le rôle ROLE_ADMIN puissent changer le titre d’un article.

Les administrateurs ayant le rôle ROLE_EDITOR pourront, quant à eux, seulement changer le contenu de l’article.

Pour avoir une expérience utilisateur agréable, nous décidons de laisser visible mais de désactiver le champ que l’utilisateur ne peut pas modifier. Visuellement, celui-ci sera donc grisé mais lisible.

Du côté expérience développeur, nous voulons pouvoir configurer la visibilité d’un champ grâce à une option sur le type :

class ArticleType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('title', TextType::class, [
                'is_granted_attribute' => 'ROLE_ADMIN',
            ])
            // ...
        ;
    }
}

Pour configurer des champs de formulaire en fonction du rôle d’un utilisateur, il faut créer une extension de formulaire.

Comme l’option is_granted_attribute sera disponible sur tous les Types de l’application, l’extension devra étendre FormType. En effet, chaque *Type fini par étendre FormType:

namespace App\Form\Extension;

use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;

class SecurityExtension extends AbstractTypeExtension
{
    private $authorizationChecker;

    public function __construct(AuthorizationCheckerInterface $authorizationChecker)
    {
        $this->authorizationChecker = $authorizationChecker;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'is_granted_attribute' => null,
        ]);
    }

    public function getExtendedType()
    {
        return FormType::class;
    }
}

Nous allons pouvoir maintenant développer la partie la plus intéressante : désactiver un widget en fonction des rôles de l’utilisateur :

class SecurityExtension extends AbstractTypeExtension
{
    public function finishView(FormView $view, FormInterface $form, array $options)
    {
        if ($this->isGranted($options)) {
            return;
        }

        $this->disableView($view);
    }

    private function isGranted(FormInterface $form, array $options)
    {
        if (!$options['is_granted_attribute']) {
            return true;
        }

        if ($this->authorizationChecker->isGranted($options['is_granted_attribute'])) {
            return true;
        }

        return false;
    }

    private function disableView(FormView $view)
    {
        $view->vars['attr']['disabled'] = true;

        foreach ($view as $child) {
            $this->disableView($child);
        }
    }
}

Si l’utilisateur connecté n’a pas le rôle qui est contenu dans l’option is_granted_attribute, alors le widget sera désactivé. C’est-à-dire qu’il aura l’attribut HTML disable="disabled" et ce de manière récursive.

Cependant, nous venons de créer une faille de sécurité dans l’application. Si un utilisateur malicieux supprime l’attribut HTML, il sera en mesure de changer la valeur du widget. Pour mitiger cette faille, il ne faut pas utiliser les données que l’utilisateur a envoyé. Un Listener dans l’extension fera l’affaire :

class SecurityExtension extends AbstractTypeExtension
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        if (!$options['is_granted_attribute']) {
            return;
        }

        $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($options) {
            if ($this->isGranted($options)) {
                return;
            }

            $event->setData($event->getForm()->getViewData());
        });
    }
}

Et Voilà ! Ce n’était pas très compliqué ?

Une façon alternative de faire

Il aurait été possible d’avoir un code plus minimaliste en utilisant une fonctionnalité peu connue de l’OptionResolver:

class SecurityExtension extends AbstractTypeExtension
{
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'is_granted_attribute' => null,
            'disabled' => function (Options $options) {
                return $this->isGranted($options);
            },
        ]);
    }
}

Ici, l’option disabled est évaluée de manière lazy en fonction des autres options. Il n’y a alors plus besoin des méthodes finishView, disableView, buildForm.

Cependant, cette façon de faire n’est pas aussi flexible que celle que nous avons vu dans cet article. Et elle nous aurait bloqué pour le chapitre suivant 😜.

Supprimer un champ au lieu de le désactiver

Dans certain cas, comme par exemple pour des données sensibles, il est préférable de ne pas afficher le champ. Pour ce faire, il faut ajouter une nouvelle option : is_granted_hide et l’utiliser dans la méthode buildForm :

class SecurityExtension extends AbstractTypeExtension
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        if (!$options['is_granted_attribute']) {
            return;
        }

        if ($options['is_granted_hide']) {
            $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options) {
                if ($this->isGranted($options)) {
                    return;
                }

                $form = $event->getForm();

                $form->getParent()->remove($form->getName());
            });
        } else {
            $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($options) {
                if ($this->isGranted($options)) {
                    return;
                }

                $event->setData($event->getForm()->getViewData());
            });
        }
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'is_granted_attribute' => null,
            'is_granted_hide' => false,
        ]);
    }
}

Que faire pour les nouveaux articles ?

Le titre d’un article est obligatoire. Comme Alice n’a pas le droit de modifier le titre d’un article elle ne peut pas créer d’article. Sacrebleu ! Il existe certain cas légitime où l’on veut désactiver ces protections.

Pour résoudre ce problème, il suffit d’ajouter une nouvelle option is_granted_disabled. Si la valeur de cette option est true, alors il suffit de ne rien faire.

Vous pouvez retrouver le diff de ce changement sur Github.

Conclusion

Le composant de formulaire, grâce notamment aux FormExtensions, permet de changer facilement le comportement d’un formulaire. Il est primordial de bien connaître ce point d’extension de Symfony dès que l’on veut réaliser des formulaires avec des règles métiers complexes.

Vous pouvez aussi retrouver tout le code de ce premier chapitre sur Github.

Dans le prochain épisode, nous verrons comment contrôler la sécurité d’un formulaire en fonction des rôles de l’utilisateur, mais aussi de l’article en cours d’édition.

Nos formations sur le sujet

  • Symfony avancée

    Décou­vrez les fonc­tion­na­li­tés et concepts avan­cés de Symfo­ny

blog comments powered by Disqus