Si vous ne l’avez pas fait, vous êtes invités à lire les précédents articles de cette série avant d’entamer celui-ci:

Petit rappel : Le sujet de l’internationalisation ne sera pas abordé dans les exemples. Mais il est recommandé d’utiliser des clés de traduction pour les labels des widgets ainsi que pour les messages qui seront affichés à l’utilisateur.

Le contrôleur de suppression

<?php

class ArticleController extends Controller
{
    const DELETE_ARTICLE_HEADER = 'x-delete-article-token';
    const DELETE_ARTICLE_TOKEN = 'delete_article_token';
    
    // [...]
    
    /**
     * @Route("/{id}/delete", name="article_ajax_delete", methods={"DELETE"})
     */
    public function deleteAction(Request $request, Article $article)
    {
        if (!$this->isCsrfTokenValid(self::DELETE_ARTICLE_TOKEN, $request->headers->get(self::DELETE_ARTICLE_HEADER))) {
            throw $this->createAccessDeniedException();
        }
    
        $om = $this->getDoctrine()->getManager();
        $om->remove($article);
        $om->flush();
    
        $this->addFlash('success', 'The article has been deleted.');
    
        return new JsonResponse(['url' => $this->generateUrl('article_list')]);
    }
}

Le formulaire d’édition a été modifié en vue d’accueillir un bouton de suppression :

<?php

/**
 * @Route("/{id}/edit", name="article_edit", methods={"GET", "PUT"})
 */
public function editAction(Request $request, Article $article)
{
    $token = $this->get('security.csrf.token_manager')->getToken(self::DELETE_ARTICLE_TOKEN)->getValue();

    $form = $this->createForm(ArticleType::class, $article, [
        'method' => Request::METHOD_PUT,
        'action' => $this->generateUrl('article_edit', ['id' => $article->getId()]),
    ])
        ->add('submit', SubmitType::class, ['label' => 'Update', 'attr' => [
            'class' => 'btn-primary pull-right',
        ]])
        ->add('delete', ButtonType::class, ['label' => 'Delete', 'attr' => [
            'class' => 'btn-danger form-delete-button',
            'data-delete-url' => $this->generateUrl('article_ajax_delete', [
                'id' => $article->getId(),
            ]),
            'data-delete-token' => $token
        ]])
    ;

    // [...]
}

La route

Comme on peut le constater, nous avons limité le verbe HTTP à DELETE. Ceci correspond bien à la suppression d’une ressource côté serveur. Nous n’allons pas afficher de page ou de formulaire spécifique pour la suppression. Les demandes vont être faites à l’aide d’une requête AJAX. La réponse du contrôleur est donc un document JSON. Ce dernier ne contient qu’une URL vers laquelle on redirigera l’utilisateur.

Le convertisseur de paramètres (ParamConverter)

Comme le contrôleur d’édition, le convertisseur de paramètres nous permet de récupérer une entité Article. Une variable d’URL a été ajoutée, token, mais étant donné que le nom ne correspond à aucun champ de l’entité, ceci ne perturbe pas le convertisseur de paramètres.

le token CSRF

Il existe un type d’attaque Cross-Site Request Forgery, dont l’objet est de transmettre à un utilisateur authentifié une requête HTTP falsifiée qui pointe sur une action interne au site, afin qu’il l’exécute sans en avoir conscience et en utilisant ses propres droits. L’utilisateur devient donc complice d’une attaque sans même s’en rendre compte. L’attaque étant actionnée par l’utilisateur, un grand nombre de systèmes d’authentification sont contournés1.

Imaginez une URL de suppression d’article en /article/{id}/delete. Vous êtes connecté sur le site de cette URL en tant qu’administrateur ayant droit de supprimer. Si un attaquant connait cette URL, il peut mettre en place un script JS qui appelle l’URL en boucle en changeant l’id de 0 à 99999. Imaginez qu’il arrive à vous diriger sur une page où votre navigateur va exécuter le script et demander la suppression de tous vos articles. C’est plutôt embêtant.

Heureusement pour vous, quand un formulaire Symfony est utilisé, une sécurité est en place. Celle-ci est basée sur un token lié à votre session. Un champ caché _token est ajouté aux formulaires et la valeur est stockée dans votre session. Ainsi quand vous envoyez un formulaire, la valeur du champ caché et celle de votre session sont comparées pour être sûr que vous êtes bien à l’origine de l’action. Tout ceci est transparent pour le développeur et l’utilisateur.

Malheureusement, dans notre cas, nous n’utilisons pas de formulaire. Il faut donc mettre en place une vérification. Le gestionnaire de token CSRF nous permet de générer un token pour notre cas, puis de le vérifier. Nous modifions donc le controlleur pour vérifier un header x-delete-article-token. Quand on ajoute le bouton de suppression, on en profite pour lui ajouter trois attributs :

  • class : afin de mettre en place la logique JavaScript qui va faire les appels AJAX, la classe form-delete-button va nous permettre de repérer les boutons de suppression.
  • data-delete-url : il va contenir l’URL qu’il faut appeler pour la suppression avec l’id de l’article et le token. Le token est généré grâce au service security.csrf.token_manager et un identifiant pour notre cas spécifique.
  • data-delete-token : contiendra la valeur du token qui sera utilisé dans les headrs des les appels AJAX.

Ensuite quand le contrôleur de suppression est appelé, la valeur du token est vérifiée. Dans le cas où les valeurs ne correspondent pas, une erreur 403 est retournée.

Mission accomplie, l’action est maintenant protégée contre les attaques CSRF.

Attention : il faut prendre soin de ne pas utiliser de bouton sumbit pour la suppression. Ceci pour éviter de rendre la gestion du formulaire HTML plus complexe et parce qu’il ne s’agit pas réellement d’une action liée au formulaire.

Code JS

function formDeleteButton()
{
    var deleteButtons = $('.form-delete-button');
    if (0 === deleteButtons.length) {
        return;
    }

    deleteButtons.click(function (event) {
        var target = $(event.target);
        var deleteUrl = target.data('delete-url');
        var deleteToken = target.data('delete-token');

        if (!deleteUrl || !confirm('Are you sure to delete this record ?')) {
            return;
        }

        $.ajax({
            dataType: 'json',
            method: 'DELETE',
            headers: {
                'x-delete-article-token': deleteToken
            },
            url: deleteUrl,
            success: function (data) {
                if (!data.url) {
                    return;
                }

                window.location.href = data.url;
            },
            error: function () {
                alert('An unexpected error has occurred.');
            }
        });
    });
}

$(document).ready(function() {
    formDeleteButton();
});

Ce code JavaScript a besoin de jQuery pour fonctionner.

Il se charge d’exécuter une requête AJAX pour tous les éléments HTML qui possèdent la classe .form-delete-button. Le verbe HTTP de la requête doit être DELETE et le header spécifique contenant le token est ajouté. Une fois la requête effectuée, l’utilisateur est redirigé vers une autre page.

La gestion d’erreur devrait être améliorée pour que vous ayez plus de possibilités pour rattraper les erreurs ou indiquer à votre utilisateur un message plus adapté en fonction de l’erreur.

Les templates

À part l’inclusion du fichier JavaScript, aucune modification HTML n’est nécessaire, les templates restent très simple.

La série

Références

1. Article CSRF sur Wikipedia https://fr.wikipedia.org/wiki/Cross-Site_Request_Forgery