Étape 16: Empêcher le spam avec une API

5.0 version
Maintained

Empêcher le spam avec une API

N’importe qui peut soumettre un commentaire, même des robots ou des spammeurs. Nous pourrions ajouter un « captcha » au formulaire pour nous protéger des robots, ou nous pouvons utiliser des API tierces.

J’ai décidé d’utiliser le service gratuit Akismet pour montrer comment appeler une API et comment faire un appel « vers l’extérieur ».

S’inscrire sur Akismet

Créez un compte gratuit sur akismet.com et récupérez la clé de l’API Akismet.

Ajouter une dépendance au composant Symfony HTTPClient

Au lieu d’utiliser une bibliothèque qui abstrait l’API d’Akismet, nous ferons directement tous les appels API. Faire nous-mêmes les appels HTTP est plus efficace (et nous permet de bénéficier de tous les outils de débogage de Symfony comme l’intégration avec le Symfony Profiler).

Pour effectuer des appels à l’API, utilisez le composant Symfony HttpClient :

1
$ symfony composer req http-client

Concevoir une classe de vérification de spam

Créez une nouvelle classe dans src/ nommée SpamChecker pour contenir la logique d’appel à l’API d’Akismet et l’interprétation de ses réponses :

src/SpamChecker.php
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
namespace App;

use App\Entity\Comment;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class SpamChecker
{
    private $client;
    private $endpoint;

    public function __construct(HttpClientInterface $client, string $akismetKey)
    {
        $this->client = $client;
        $this->endpoint = sprintf('https://%s.rest.akismet.com/1.1/comment-check', $akismetKey);
    }

    /**
     * @return int Spam score: 0: not spam, 1: maybe spam, 2: blatant spam
     *
     * @throws \RuntimeException if the call did not work
     */
    public function getSpamScore(Comment $comment, array $context): int
    {
        $response = $this->client->request('POST', $this->endpoint, [
            'body' => array_merge($context, [
                'blog' => 'https://guestbook.example.com',
                'comment_type' => 'comment',
                'comment_author' => $comment->getAuthor(),
                'comment_author_email' => $comment->getEmail(),
                'comment_content' => $comment->getText(),
                'comment_date_gmt' => $comment->getCreatedAt()->format('c'),
                'blog_lang' => 'en',
                'blog_charset' => 'UTF-8',
                'is_test' => true,
            ]),
        ]);

        $headers = $response->getHeaders();
        if ('discard' === ($headers['x-akismet-pro-tip'][0] ?? '')) {
            return 2;
        }

        $content = $response->getContent();
        if (isset($headers['x-akismet-debug-help'][0])) {
            throw new \RuntimeException(sprintf('Unable to check for spam: %s (%s).', $content, $headers['x-akismet-debug-help'][0]));
        }

        return 'true' === $content ? 1 : 0;
    }
}

La méthode request() du client HTTP soumet une requête POST à l’URL d’Akismet ($this->endpoint) et passe un tableau de paramètres.

La méthode getSpamScore() retourne 3 valeurs en fonction de la réponse de l’appel à l’API :

  • 2 : si le commentaire est un « spam flagrant » ;
  • 1 : si le commentaire pourrait être du spam ;
  • 0 : si le commentaire n’est pas du spam (ham).

Astuce

Utilisez l’adresse email spéciale akismet-guaranteed-spam@example.com pour forcer le résultat de l’appel à être du spam.

Utiliser des variables d’environnement

La classe SpamChecker utilise un argument $akismetKey. Comme pour le répertoire d’upload, nous pouvons l’injecter grâce au paramètre bind du conteneur :

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -12,6 +12,7 @@ services:
         autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
         bind:
             $photoDir: "%kernel.project_dir%/public/uploads/photos"
+            $akismetKey: "%env(AKISMET_KEY)%"

     # makes classes in src/ available to be used as services
     # this creates a service per class whose id is the fully-qualified class name

Nous ne voulons certainement pas coder en dur la valeur de la clé d’Akismet dans le fichier de configuration services.yaml, nous utilisons donc plutôt une variable d’environnement (AKISMET_KEY).

Il appartient alors à chacun de définir une variable d’environnement « réelle » ou d’en stocker la valeur dans un fichier .env.local :

.env.local
1
AKISMET_KEY=abcdef

Pour la production, une variable d’environnement « réelle » doit être définie.

Ça fonctionne bien, mais la gestion de nombreuses variables d’environnement peut devenir lourde. Dans un tel cas, Symfony a une « meilleure » alternative pour le stockage des chaînes secrètes.

Stocker des chaînes secrètes

Au lieu d’utiliser plusieurs variables d’environnement, Symfony peut gérer un coffre-fort où vous pouvez stocker plusieurs chaînes secrètes. L’une de ses caractéristiques les plus intéressantes est la possibilité de committer l’espace de stockage dans le dépôt (mais sans la clé pour l’ouvrir). Une autre fonctionnalité intéressante est qu’il peut gérer un coffre-fort par environnement.

Les chaînes secrètes sont des variables d’environnement déguisées.

Ajoutez la clé d’Akismet dans le coffre-fort :

1
$ symfony console secrets:set AKISMET_KEY
1
2
3
4
 Please type the secret value:
 >

 [OK] Secret "AKISMET_KEY" encrypted in "config/secrets/dev/"; you can commit it.

Comme c’est la première fois que nous exécutons cette commande, elle a généré deux clés dans le répertoire config/secret/dev/. Elle a ensuite stocké la chaîne secrète AKISMET_KEY dans ce même répertoire.

Pour les chaînes secrètes de développement, vous pouvez décider de committer l’espace de stockage et les clés qui ont été générées dans le répertoire config/secret/dev/.

Les chaînes secrètes peuvent également être écrasées en définissant une variable d’environnement du même nom.

Identifier le spam dans les commentaires

Une façon simple de vérifier la présence de spam lorsqu’un nouveau commentaire est soumis est d’appeler le vérificateur de spam avant de stocker les données dans la base de données :

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -7,6 +7,7 @@ use App\Entity\Conference;
 use App\Form\CommentFormType;
 use App\Repository\CommentRepository;
 use App\Repository\ConferenceRepository;
+use App\SpamChecker;
 use Doctrine\ORM\EntityManagerInterface;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\HttpFoundation\File\Exception\FileException;
@@ -39,7 +40,7 @@ class ConferenceController extends AbstractController
     /**
      * @Route("/conference/{slug}", name="conference")
      */
-    public function show(Request $request, Conference $conference, CommentRepository $commentRepository, string $photoDir)
+    public function show(Request $request, Conference $conference, CommentRepository $commentRepository, SpamChecker $spamChecker, string $photoDir)
     {
         $comment = new Comment();
         $form = $this->createForm(CommentFormType::class, $comment);
@@ -58,6 +59,17 @@ class ConferenceController extends AbstractController
             }

             $this->entityManager->persist($comment);
+
+            $context = [
+                'user_ip' => $request->getClientIp(),
+                'user_agent' => $request->headers->get('user-agent'),
+                'referrer' => $request->headers->get('referer'),
+                'permalink' => $request->getUri(),
+            ];
+            if (2 === $spamChecker->getSpamScore($comment, $context)) {
+                throw new \RuntimeException('Blatant spam, go away!');
+            }
+
             $this->entityManager->flush();

             return $this->redirectToRoute('conference', ['slug' => $conference->getSlug()]);

Vérifiez qu’il fonctionne bien.

Gérer les chaînes secrètes en production

En production, SymfonyCloud prend en charge le paramétrage des variables d’environnement sensibles :

1
$ symfony var:set --sensitive AKISMET_KEY=abcdef

Mais comme nous l’avons vu plus haut, l’utilisation des chaînes secrètes de Symfony pourrait être une meilleure manière de procéder. Pas en termes de sécurité, mais en termes de gestion des chaînes secrètes pour l’équipe du projet. Toutes les chaînes secrètes sont stockées dans le dépôt et la seule variable d’environnement que vous devez gérer pour la production est la clé de déchiffrement. Cela permet à tous les membres de l’équipe d’ajouter des chaînes secrètes en production même s’ils n’ont pas accès aux serveurs de production. L’installation est un peu plus compliquée cependant.

Tout d’abord, créez une paire de clés pour l’utilisation en production :

1
$ APP_ENV=prod symfony console secrets:generate-keys

Rajoutez la chaîne secrète d’Akismet dans le coffre-fort en production, mais avec sa valeur de production :

1
$ APP_ENV=prod symfony console secrets:set AKISMET_KEY

La dernière étape consiste à envoyer la clé de déchiffrement à SymfonyCloud en définissant une variable sensible :

1
$ symfony var:set --sensitive SYMFONY_DECRYPTION_SECRET=`php -r 'echo base64_encode(include("config/secrets/prod/prod.decrypt.private.php"));'`

Vous pouvez ajouter et commiter tous les fichiers ; la clé de déchiffrement a été ajoutée dans le .gitignore automatiquement, donc elle ne sera jamais enregistrée. Pour plus de sécurité, vous pouvez la retirer de votre machine locale puisqu’elle a été déployée :

1
$ rm -f config/secrets/prod/prod.decrypt.private.php

  • « Previous Étape 15: Sécuriser l’interface d’administration
  • Next » Étape 17: Tester

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.