Une alternative aux CAPTCHAs avec Symfony2

9 gravatar Par Grégoire Marchal - 05/09/2012

Encore un peu de sécurité, un domaine important, appliqué à la lutte contre le spam via les formulaires cette fois. Je ne voulais vous infliger des CAPTCHAs sur mon blog, j'ai donc cherché une méthode alternative pour éviter les bots. Je suis rapidement tombé sur le principe du "honeypot button" via cet article. L'idée est que les bots vont globalement soit soumettre le formulaire sans "cliquer" sur un bouton, soit "cliquer" sur le premier qui ressemble à un bouton "submit". Donc on en affiche plusieurs, et on vérifie côté serveur que c'est bien le bon bouton qui a été utilisé.

Symfoniquement parlant, il faut donc créer un type de champ personnalisé pour représenter les boutons à afficher. Il faut également ajouter une validation pour vérifier que le bon bouton a été utilisé.

Création du type de champ

Tout d'abord, mes boutons doivent avoir une valeur, afin d'être identifiables lors de la phase de validation. Ces valeurs sont définies en conf, dans le fichier parameters.yml, afin d'être facilement récupérables via le service container :

# app/config/parameters.yml
parameters:
    # anti spam buttons values
    antispam_button:
        good: xuitybzerpidvfugvo
        bad:  kuserhxvvkigyhsdsf

Je leur ai donné des valeurs arbitraires, je n'étais pas inspiré !

Ensuite, je crée mon type de champ à proprement parler. Je crée donc la classe AntispamButtonType. Elle implémente ContainerAwareInterface puisque l'on va faire joujou avec le service container. Je définis le parent de mon Type à choice, puisque finalement, ce groupe de boutons sert à faire un choix, choisir le bon bouton pour prouver qu'on n'est pas un robot. Voici donc cette classe :

// src/.../BlogBundle/Form/AntispamButtonType.php

class AntispamButtonType extends AbstractType implements ContainerAwareInterface
{
    protected $container;

    public function __construct(ContainerInterface $container)
    {
        $this->setContainer($container);
    }

    public function setContainer(ContainerInterface $container = null)
    {
        $this->container = $container;
    }

    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $antispamParams = $this->container->getParameter('antispam_button');

        $resolver->setDefaults(array(
            'choices'  => array(
                $antispamParams['bad']  => 'do.not.add.comment',
                $antispamParams['good'] => 'add.comment'
            ),
        ));
    }

    public function buildView(FormView $view, FormInterface $form, array $options)
    {
        parent::buildView($view, $form, $options);

        $view->vars['btn_classes'] = array('btn', 'btn btn-primary');
        $view->vars['label_prefix'] = array(null, '<i class="icon-comment icon-white"></i> ');
    }

    public function getParent()
    {
        return 'choice';
    }

    public function getName()
    {
        return 'antispambutton';
    }
}

J'utilise donc le service container pour aller récupérer les valeurs des boutons, que j'associe à leurs labels. J'ai également surchargé la méthode buildView afin de faire quelques modifications qui touchent au rendu final des boutons (classes CSS, icône dans le bouton).

Intéressons nous au rendu justement. Afin que Symfony sache comment il doit afficher ce champ, il faut définir un template twig comme ceci :

{# src/.../BlogBundle/Resources/views/Form/fields.html.twig #}

{% block antispambutton_widget %}
    {% for key, choice in choices %}
        <button name="{{ full_name }}" id="{{ id }}_{{ key }}" value="{{ choice.value }}" type="submit" class="{{ btn_classes[key] }}">
            {{ label_prefix[key] is not null ? label_prefix[key]|raw : '' }}{{ choice.label|trans }}
        </button>
    {% endfor %}
{% endblock %}

{% block antispambutton_label %}{% endblock %}

Je crée donc un bloc antispambutton_widget ("antispambutton" doit correspondre à ce qui est renvoyé par la méthode AntispamButtonType::getName()) pour le rendu du champ, et un bloc antispambutton_label vide, pour dire que je ne veux pas de libellé pour ce champ. Il faut maintenant prévenir le framework qu'il a un fichier à parser pour le rendu des formulaires. Ca se passe dans le fichier config.yml :

# app/config/config.yml
twig:
    form:
        resources:
            - 'MyCompanyBlogBundle:Form:fields.html.twig'

Notre type de champ est maintenant prêt à être utilisé ! On peut donc l'instancier dans l'un de nos formulaires. Dans mon cas, il s'agit de CommentType, pour ajouter un commentaire. Avant cela, il faut également rendre cette classe "container aware" (comme on l'avait fait pour AntispamButtonType), puisque l'on va avoir besoin du container pour instancier notre type de champ (et on en aura aussi besoin pour la validation, voir paragraphes suivants). Voici donc le résultat :

// src/.../BlogBundle/Form/CommentType.php

class CommentType extends AbstractType implements ContainerAwareInterface
{
    protected $container;

    public function __construct(ContainerInterface $container)
    {
        $this->setContainer($container);
    }

    public function setContainer(ContainerInterface $container = null)
    {
        $this->container = $container;
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            // other fields...

            ->add('button',  new AntispamButtonType($this->container), array(
                'property_path' => false,
            ))
        ;
    }
}

N'oubliez pas l'option property_path, pour préciser que ce champ n'est pas à mapper sur l'objet Comment. Nos boutons s'affichent donc bien désormais, il ne reste plus que la validation.

Validation

Pour la validation, il ne nous reste qu'à vérifier que la valeur du bouton qui a été utilisé est la valeur du bon bouton. Pour cela, il y a juste quelques lignes à ajouter dans CommentType::buildForm(). Initialement, je voulais utiliser la classe CallbackValidator, mais celle-ci est dépréciée depuis la version 2.1. Il faut donc, comme indiqué dans la description de cette classe, utiliser le form event FormEvents::POST_BIND. Voici comment :

// src/.../BlogBundle/Form/CommentType.php

public function buildForm(FormBuilderInterface $builder, array $options)
{
    $container = $this->container;

    $builder
        // other fields...

        // antispam button + validation
        ->add('button',  new AntispamButtonType($this->container), array(
            'property_path' => false,
        ))
        ->addEventListener(FormEvents::POST_BIND, function(FormEvent $event) use($container) {
            $form = $event->getForm();
            $antispamParams = $container->getParameter('antispam_button');

            if (!$form->has('button') || $form['button']->getData() !== $antispamParams['good']) {
                $form->addError(new FormError('spam.detected'));
            }
        })
    ;
}

Et voilà, désormais, une erreur est levée si on ne clique pas sur le bon bouton. Il ne reste qu'à traduire la chaîne spam.detected dans les fichiers validators.XX.yml. On a désormais un système antispam à la fois ergonomique et efficace !


Edit (10/09/2012)

Suite au commentaire pertinent de Jakub Lédl, j'ai sensiblement revu ma copie. Passer le service container de cette manière n'est pas la bonne manière de faire. Les types de champs ne sont pas sensés avoir à faire au container. La solution est de déclarer le nouveau type de champ en tant que service, et de lui injecter les paramètres directement.

Commençons par déclarer le service :

# src/.../BlogBundle/Resources/config/services.yml
services:
    form.type.antispam_button:
        class: GregoireM\Bundle\BlogBundle\Form\AntispamButtonType
        arguments:
            - %antispam_button%
        tags:
            - { name: form.type, alias: antispambutton }

Il faut ensuite modifier la classe AntispamButtonType :

class AntispamButtonType extends AbstractType
{
    protected $params;

    public function __construct(array $params)
    {
        $this->params = $params;
    }

    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'choices'  => array(
                $this->params['bad']  => 'do.not.add.comment',
                $this->params['good'] => 'add.comment'
            ),
        ));
    }

    // ...
}

On peut alors utiliser notre type de champ juste avec son alias "antispambutton". Voici donc les changements de la classe CommentType, débarrassée de son container :

// src/.../BlogBundle/Form/CommentType.php

class CommentType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            // other fields...

            // antispam button + validation
            ->add('button', 'antispambutton', array(
                'property_path' => false,
            ))
            ->addEventListener(FormEvents::POST_BIND, function(FormEvent $event) {
                $form = $event->getForm();
                $antispamParams = $form->getConfig()
                    ->getFormFactory()
                    ->getType('antispambutton')
                    ->getParams();

                if (!$form->has('button') || $form['button']->getData() !== $antispamParams['good']) {
                    $form->addError(new FormError('spam.detected'));
                }
            })
        ;
    }

  // ...
}

Notez qu'il faut également ajouter la méthode AntispamButtonType::getParams() pour récupérer le tableau de paramètre afin de l'utiliser dans la callback de validation. Je n'ai pas trouvé de meilleur moyen de faire ça...

Retour à l'accueil

Commentaires (9)

gravatar Par Christophe, le 07/09/2012 à 12:16
Pas mal !
C'est pour éviter les jetons CSRF ? Ou c'est en complément ?
gravatar Par Grégoire Marchal, le 07/09/2012 à 12:27
Merci.
C'est en complément. Pour les attaques CSRF, il y a déjà ce qu'il faut dans Symfony : http://symfony.com/doc/current/book/forms.html#csrf-protection
gravatar Par Christophe, le 07/09/2012 à 14:01
OK. J'ai pensé un instant que c'était pour se passer des jetons CSRF, comme il y avait une erreur avec ces jetons quand on soumettait un commentaire (certainement du au cache de la page), j'avais imaginé que tu voulais t'en passer et que tu cherchais donc un autre moyen d'empêcher le spam.
Si ça vient en complément, c'est encore mieux !
gravatar Par Sylvain Com-Océan, le 26/09/2012 à 15:48
J'ai mis en place un truc très simple sur un blog qui fonctionne très très bien (100% de taux de réussite quasiment) :
mettre un champs nommé "firstname" et le cacher en css (en visiblity:hidden par exemple).
Si ce champs est rempli alors considérer le commentaire comme un spam...
gravatar Par Asmista, le 23/01/2013 à 08:41
Très sympa comme idée, je n'avais pas pensé à un tel système mais ça peut être pratique. Pour ma part j'avais plutôt mit en place la méthode de Sylvain soit le champ invisible qui doit rester vide, en général le bot le remplit et ça poste pas c'était pratique aussi.
gravatar Par Grégoire Marchal, le 24/01/2013 à 09:36
Effectivement, le coup du champ caché qui doit rester vide, ça semble bien marcher également !
gravatar Par Valentin Corre, le 13/10/2013 à 12:24
Bonjour, j'ai un petit soucis avec la traduction de spam.detected, quelqu'un aurait il une solution afin de prendre en compte la traduction ? (je l'ai bien mise dans validators.fr.yml) et j'ai bien vidé le cache, mais rien a faire, il ne le prend pas en compte... Aurais-je oublié quelque chose ?

Cordialement

Valentin Corre
gravatar Par Valentin Corre, le 15/10/2013 à 22:14
Bon, la solution était simple: j'ai rajouté ça dans fields.html.twig:
{% block form_errors %}
{% if errors|length > 0 %}
<ul class="val-error">
{% for error in errors %}
<li>{{ error.messageTemplate|trans(error.messageParameters, 'validators') }}</li>
{% endfor %}
</ul>
{% endif %}

Mais bon, maintenant j'ai un soucis avec
$antispamParams = $form->getConfig()
->getFormFactory()
->getType('antispambutton')
->getParams();
font la fonction getType est dépréciée depuis la version 2.3 de symfony
gravatar Par Grégoire Marchal, le 16/10/2013 à 08:35
Pour info, le contenu de mon fichier de traduction, c'est juste ça : https://gist.github.com/Gregoire-M/7003477
Ca devrait marcher normalement...

Commenter