Защита от спама с помощью API
Отзыв может оставить кто угодно, включая роботов и спамеров. Поэтому, чтобы снизить поток спама, мы можем либо добавить в форму капчу, либо использовать сторонние API.
Я решил использовать бесплатный сервис Akismet, чтобы показать, как можно работать с API и как выполнять внешние запросы.
Регистрация в Akismet
Зарегистрируйте бесплатный аккаунт на akismet.com и получите ключ Akismet API.
Добавление компонента Symfony HTTPClient
Мы будем обращаться к API Akismet напрямую вместо использования соответствующей библиотеки для этого. Выполнение HTTP-запросов самостоятельно более эффективно (кроме этого даёт использовать инструменты отладки Symfony, включая профилировщик Symfony).
Создание класса для проверки на спам
В директории src/ создадим новый класс SpamChecker, который будет содержать логику отправки запроса к API Akismet и обработку его ответа:
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;
    }
}
    Метод HTTP-клиента request() отправляет POST-запрос на URL-адрес Akismet ($this->endpoint) и передаёт массив параметров.
Метод getSpamScore() возвращает 3 значения в зависимости от ответа на API-вызов:
2: если комментарий является явным спамом;1: если комментарий может быть спамом;0: если комментарий не спам (так называемый ham).
Tip
Используйте специальный адрес электронной почты akismet-guaranteed-spam@example.com, чтобы антиспам-сервис пометил такое сообщение как спам и вы таким образом смогли проверить его работу.
Использование переменных окружения
Класс SpamChecker зависит от параметра $akismetKey. Как и в случае с директорией для загруженных файлов, мы можем переместить ключ в параметр контейнера, используя свойство bind:
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:
             string $photoDir: "%kernel.project_dir%/public/uploads/photos"
+            string $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).
Затем каждый разработчик может сам определить переменную окружения в терминале или хранить ключ в файле .env.local:
1
AKISMET_KEY=abcdef
    Однако в продакшене должна быть определена только "реальная" переменная окружения в терминале.
Это неплохой рабочий вариант, хотя управление множеством переменных окружения может стать обременительным. В таком случае у Symfony есть "лучшая" альтернатива, когда речь заходит о хранении конфиденциальных данных.
Хранение конфиденциальных данных
Вместо использования множества переменных окружения, в Symfony есть хранилище, в котором можно поместить много конфиденциальных данных. Среди одной из ключевых особенностей — можно сохранить хранилище в репозитории (но без ключа, чтобы его открыть). Другое замечательное преимущество состоит в том, что для каждого окружения может быть создано собственное хранилище.
На деле такие конфиденциальные данные являются неявными переменными окружения.
Добавьте ключ 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/.
Все конфиденциальные данные также можно переопределить путём определения одноимённой переменной окружения.
Проверка комментариев на спам
При отправке нового комментария одним из простых способов проверить его на спам — вызвать антиспам-сервис перед сохранением данных в базе данных:
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;
@@ -35,7 +36,7 @@ class ConferenceController extends AbstractController
     }
     #[Route('/conference/{slug}', name: 'conference')]
-    public function show(Request $request, Conference $conference, CommentRepository $commentRepository, string $photoDir): Response
+    public function show(Request $request, Conference $conference, CommentRepository $commentRepository, SpamChecker $spamChecker, string $photoDir): Response
     {
         $comment = new Comment();
         $form = $this->createForm(CommentFormType::class, $comment);
@@ -53,6 +54,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()]);
    Убедитесь, что всё работает правильно.
Управление конфиденциальными данными в продакшене
Для продакшена Platform.sh поддерживает установку конфиденциальных переменных окружения:
1
$ symfony cloud:variable:create --sensitive=1 --level=project -y --name=env:AKISMET_KEY --value=abcdef
    Однако, как отмечалось выше, использование механизма Symfony для управления конфиденциальными данными может быть более предпочтительным вариантом. Но не с точки зрения безопасности, а в плане управления секретными данными в команде проекта. Поскольку все конфиденциальные данные хранятся в репозитории, то чтобы использовать их в продакшене нужна только специальная переменная окружения с ключом дешифрования. Благодаря такому подходу каждый участник команды может добавить новые защищённые переменные окружения для использования в продакшене, даже если у него нет к нему доступа. Хотя для настройки этого процесса нужно кое-что сделать.
Прежде всего, сгенерируйте пару ключей для использования в продакшене:
1
$ symfony console secrets:generate-keys --env=prod
    On Linux and similiar OSes, use
APP_RUNTIME_ENV=prodinstead of--env=prodas this avoids compiling the application for theprodenvironment:1$ APP_RUNTIME_ENV=prod symfony console secrets:generate-keys
Повторно добавьте ключ Akismet в хранилище продакшена, но теперь уже с его действительным значением:
1
$ symfony console secrets:set AKISMET_KEY --env=prod
    Последний шаг — отправьте ключ дешифрования в Platform.sh, установив специальную для этого переменную:
1
$ symfony cloud:variable:create --sensitive=1 --level=project -y --name=env:SYMFONY_DECRYPTION_SECRET --value=`php -r 'echo base64_encode(include("config/secrets/prod/prod.decrypt.private.php"));'`
    Можно добавить все файлы в репозиторий, так как файл с ключом дешифрования уже игнорируется в .gitignore, поэтому он никогда не будет зафиксирован. Для большей безопасности лучше удалите его с вашего компьютера, потому что он уже есть в продакшене и больше не понадобится:
1
$ rm -f config/secrets/prod/prod.decrypt.private.php