گام 16: جلوگیری از ارسال محتوای هرز (Spam) با کمک یک API

5.0 version
Maintained

جلوگیری از ارسال محتوای هرز (Spam) با کمک یک API

هر کسی می‌تواند یک بازخورد ارسال کند. حتی ربات‌ها، تولیدکنندگان محتواهای هرز و غیره. می‌توانیم یک «کپچا (captcha)» به فرم بیافزاییم تا در برابر ربات‌ها محافظت شویم یا اینکه از APIهای شخص ثالث استفاده کنیم.

من تصمیم گرفتم تا از سرویس رایگان Akismet استفاده کنم تا نشان دهم که چگونه می‌توان یک API را فراخوانی کرده و همچنین این فراخوانی را به «خارج از باند (out of band)» منتقل کرد.

ثبت‌نام در Akismet

یک حساب کاربری رایگان در akismet.com ایجاد کرده و کلید Akismet API را دریافت نمایید.

تکیه بر کامپوننت HTTPClient سیمفونی

به جای استفاده از کتابخانه‌ای که API مربوط به Akismet را انتراعی کند، ما تمام فراخوانی‌های API را به صورت مستقیم انجام می‌دهیم. انجام فراخوانی‌های HTTP توسط خودمان بهینه‌تر است (و اجازه می‌دهد تا از تمام ابزارهای اشکال‌زدایی سیمفونی همچون یکپارچگی با نمایه‌ساز سیمفونی، بهره ببریم).

برای انجام فراخوانی‌های API، از کامپوننت HttpClient سیمفونی استفاده کنید:

1
$ symfony composer req http-client

طراحی یک کلاس بررسی‌کننده‌ی محتوای هرز

به منظور جای دادن تمام منطق مربوط به فراخوانی API مربوط به Akismet و تفسیر پاسخ‌های آن، یک کلاس جدید در پوشه‌ی src/ و با نام SpamChecker ایجاد نمایید:

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

متد request() در HTTP client، یک درخواست POST را به URL مربوط به Akismet ارسال می‌کند ($this->endpoint) و آرایه‌ای از پارامترها را پاس می‌دهد.

متد getSpamScore() با توجه به پاسخ فراخوانی API، می‌تواند ۳ مقدار مختلف برگرداند:

  • 2: اگر کامنت آشکارا یک محتوای هرز باشد؛
  • 1: اگر کامنت امکان هرز بودن داشته باشد؛
  • 0: اگر کامنت هرز نباشد (ham)؛

نکته

از آدرس رایانامه‌ی مخصوص akismet-guaranteed-spam@example.com استفاده کنید تا نتیجه‌ی فراخوانی را مجبور به هرز بودن کنید.

استفاده از متغیر‌های محیط

کلاس SpamChecker، به آرگمان $akismetKey وابسته است. همچون آدرس پوشه‌ی بارگذاری، می‌توانیم آن را از طریق تنظیم bind در کانتینر، تزریق کنیم:

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

ما مطمئناً نمی‌خواهیم که مقدار کلید Akismet را در فایل پیکربندی services.yaml هاردکد کنیم، بنابراین به جای آن از یک متغیر محیط استفاده می‌کنیم (AKISMET_KEY).

پس از این هر توسعه‌دهنده وظیفه دارد تا یک متغیر محیط «واقعی» را تنظیم یا مقدار آن را در فایل (AKISMET_KEY) ذخیره کند:

.env.local
1
AKISMET_KEY=abcdef

برای محیط عمل‌آوری، باید یک متغیر محیط «واقعی» تعریف شود.

این روش به خوبی کار می‌کند، اما مدیریت تعداد زیادی متغیر محیط ممکن است مایه‌ی زحمت شود. در این صورت سیمفونی برای ذخیره‌ی رمز‌ها، یک راه جایگزین «بهتر» دارد.

ذخیره‌ی رمز‌ها (Secrets)

به جای استفاده از تعداد زیادی متغیر محیط، سیمفونی می‌تواند یک گاوصندوق (vault) که می‌توانید تعداد زیادی رمز را در آن ذخیره کنید، مدیریت کند. یک ویژگی کلیدی این روش قابلیت commitکردن گاوصندوق در مخزن Git است (اما بدون کلید که آن را باز می‌کند). یک ویژگی عالی دیگر این است که سیمفونی می‌تواند به ازای هر محیط یک گاوصندوق را مدیریت کند.

رمز‌ها همان متغیرهای محیط در لباس مبدل هستند.

کلید Akismet را به گاوصندوق اضافه کنید:

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.

چون این اولین باری است که این فرمان را اجرا می‌کنیم، فرمان دو عدد کلید را در پوشه‌ی config/secret/dev/ تولید می‌کند. سپس رمز AKISMET_KEY را نیز در همان پوشه ذخیره می‌کند.

برای رمز‌های محیط توسعه، می‌توانید تصمیم بگیرید که گاوصندوق را به همراه کلیدهایی که برایش تولید شده و در پوشه‌ی config/secret/dev/ قرار دارد، commit کنید.

همچنین رمزها می‌توانند از طریق تنظیم یک متغیر محیط با نام یکسان، بازنویسی (override) شوند.

بررسی کامنت‌ها برای یافتن محتوای هرز

هنگامی که یک کامنت جدید ارسال می‌شود، یک راه آسان برای بررسی هرز بودن محتوا، فراخوانی بررسی‌کننده‌ی محتوای هرز (spam checker) قبل از ذخیره‌ی کامنت در پایگاه‌داده است:

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

بررسی کنید که این روش به درستی کار می‌کند.

مدیریت رمز‌ها در محیط عمل‌آوری

در محیط عمل‌آوری، SymfonyCloud از تنظیم متغیرهای محیط حساس پشتیبانی می‌کند:

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

اما همانطور که در بالا بحث شد، استفاده از رمزهای سیمفونی می‌تواند راه بهتری باشد. البته نه از لحاظ امنیت، بلکه از نظر سهولت مدیریت رمز برای تیم پروژه. به این ترتیب، تمام رمزها در مخزن ذخیره شده و تنها متغیر محیط که نیاز دارید در محیط عمل‌آوری مدیریت کنید، کلید رمزگشایی است. این روش این امکان را به وجود می‌آورد که هر یک از اعضای تیم بدون آنکه به سرورهای عمل‌آوری دسترسی داشته باشند، بتوانند رمزهای عمل‌آوری اضافه کنند.

ابتدا یک جفت کلید برای استفاده در محیط عمل‌آوری تولید کنید:

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

مجدداً رمز Akismet را به گاوصندوق عمل‌آوری اضافه کنید اما با مقدار آن در محیط عمل‌آوری:

1
$ APP_ENV=prod symfony console secrets:set AKISMET_KEY

آخرین گام، ارسال کلید رمزگشایی به SymfonyCloud از طریق تنظیم یک متغیر محیط حساس می‌باشد:

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

می‌توانید تمام فایل‌ها را اضافه کرده و commit کنید؛ کلید رمزگشایی به صورت خودکار به فایل .gitignore اضافه شده است، و بنابراین هرگز commit نخواهد شد. برای ایمنی بیشتر، می‌توانید کلید را از روی رایانه‌ی محلی خود پاک نمایید زیرا که دیگر مستقر شده است:

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

  • « Previous گام 15: امن‌سازی پشت صحنه‌ی مدیریتی
  • Next » گام 17: آزمودن (Testing)

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