Stap 16: Spam voorkomen middels een API

5.0 version
Maintained

Spam voorkomen middels een API

Iedereen kan feedback geven. Ook scripts zoals robots en spammers. We zouden een “captcha” kunnen toevoegen aan het formulier waardoor het een zeker niveau van bescherming krijgt tegen dit soort scripts. Of, we kunnen gebruik maken van een aantal externe API’s.

Ik heb besloten om de gratis Akismet service te gebruiken, om te laten zien hoe je een API kunt aanroepen en hoe je de calls “out of band” kunt maken.

Aanmelden bij Akismet

Meld je aan voor een gratis account op akismet.com en ontvang de Akismet API key.

Gebruik maken van het Symfony HTTPClient-component

In plaats van een specifieke library te gebruiken voor de Akismet API, zullen we alle API-calls direct uitvoeren. Het zelf uitvoeren van de HTTP-calls is efficiënter (en stelt ons in staat om te profiteren van alle Symfony debugging tools, zoals de integratie met de Symfony Profiler).

Gebruik de Symfony HttpClient-component om API-calls uit te voeren:

1
$ symfony composer req http-client

Een spam-checker-class bouwen

Creëer een nieuwe class onder de src/ map met de naam SpamChecker. Hierin zullen we de logica schrijven om de Akismet API aan te roepen en de antwoorden te verwerken:

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;
    }
}

De request() methode van de HTTP client verstuurt een POST request naar de Akismet URL ( $this->endpoint ) en geeft hier een reeks parameters aan mee.

De getSpamScore() method geeft, afhankelijk van de API-response één van deze 3 waarden terug:

  • 2: als de reactie “overduidelijk spam” is;
  • 1: als de reactie “mogelijk spam” kan zijn;
  • 0: als de reactie “geen spam” (ham) is.

Tip

Gebruik het speciale akismet-guaranteed-spam@example.com e-mailadres om het resultaat van een call als spam te forceren.

Omgevingsvariabelen gebruiken

De SpamChecker class is afhankelijk van een $akismetKey argument. Net als bij de upload directory, kunnen we deze injecteren via een bind container instelling:

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

We willen de waarde van de Akismet key niet hard in het services.yaml configuratiebestand coderen. In plaats daarvan gebruiken we de omgevingsvariabele ( AKISMET_KEY ).

Het is dan aan de individuele ontwikkelaar om een “echte” omgevingsvariabele in te stellen of de waarde op te slaan in een .env.local bestand:

.env.local
1
AKISMET_KEY=abcdef

Voor productie moet een “echte” environment variable gedefiniëerd worden.

Dat werkt goed, maar het beheer van veel environment variables kan omslachtig worden. Symfony heeft een “beter” alternatief als het gaat om het bewaren van secrets.

Secrets bewaren

In plaats van veel omgevingsvariabelen te gebruiken, kan Symfony een vault beheren waar je secrets kan opslaan. Een belangrijke feature hiervan is de mogelijkheid om de vault aan de repository toe te voegen (maar zonder de decryptiesleutel om de inhoud te lezen). Een andere interessante feature is dat je één vault per omgeving kan beheren.

Secrets zijn verkapte environment variables.

Voeg de Akismet key toe aan de vault:

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.

Omdat dit de eerste keer is dat we het commando uitvoeren zijn er twee keys in de config/secret/dev/ map gegenereerd. De AKISMET_KEY secret werd vervolgens in diezelfde map opgeslagen.

Voor development-secrets kan je er voor kiezen om de vault en de sleutels die in de config/secret/dev/ directory zijn gegenereerd te committen.

Secrets kunnen ook worden overschreven door een environment variable met dezelfde naam in te stellen.

Reacties controleren op spam

Een eenvoudige manier om nieuwe reacties te controleren op spam, is de spam checker aanroepen voordat de gegevens in de database worden opgeslagen:

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()]);

Controleer of het goed werkt.

Secrets beheren in productie

In productie ondersteunt SymfonyCloud het instellen van sensitive environment variables:

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

Zoals hierboven besproken is het gebruik van Symfony secrets mogelijk beter. Niet voor de veiligheid, maar om het beheer van secrets eenvoudiger te maken voor het projectteam. Alle secrets worden opgeslagen in de repository en de enige omgevingsvariabele die je moet beheren voor de productieomgeving is de decryptiesleutel. Dat maakt het voor iedereen in het team mogelijk om productie-secrets toe te voegen, zelfs als ze geen toegang hebben tot productieservers. De setup is wel iets complexer.

Genereer eerst een keypair voor gebruik in productie:

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

Voeg het Akismet secret opnieuw toe in de productie vault, maar nu met de productie waarde:

1
$ APP_ENV=prod symfony console secrets:set AKISMET_KEY

Als laatste stap configureren we de decryptiesleutel op SymfonyCloud door het instellen van een sensitive variable:

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

Je kan alle bestanden toevoegen en committen; de decryptionkey werd automatisch aan .gitignore toegevoegd, dus deze zal nooit gecommit worden. Voor meer veiligheid kan je deze van je lokale machine verwijderen, omdat deze al gedeployd is:

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

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