Пришла мне тут по работе задача сделать возможность добавления товара в корзину пользователем без авторизации. Корзина, причем должна храниться на бэке для того, чтобы можно было проводить аналитику по неоформленным заказам, а также, если пользователь авторизуется, то добавлять эту корзину к его профилю для доступности ее с любых других устройств, на которых он (пользователь авторизован).
Ясное дело, что для корзины одного пользователя в сеансе оформления заказа нужен уникальный идентификатор, по которому можно определить в какую “корзину” поместить товар.
Так как на проекте мы использует REST подход к проектированию API, я подумал, что можно генерировать uuid ключ на клиенте и передавать его при каждом запросе добавления товара в корзину.
Да, все бы хорошо, но возникает потенциальная проблема паразитных запросов от недоброжелателей. Да, безусловно, есть куча всяких способов защититься от троттлинга, но все это кажется мне в этой ситуации не совсем уместным. Поэтому, я решил, что лучше уникальный идентификатор генерировать на бэкенде, а выдавать его при прохождении CAPTCHA.

Поиск готовых решений

На проекте используется Django 5.x и Django Rest Framework. Я начал поиск готовых библиотек, подходящих под мою задачу, а именно, что должно быть в готовом решении:

  • хэндлер get-запроса, который возвращает captcha_id и закодированное в base64 изображение этой самой капчи;

  • хэндлер post-запроса, который вальсирует введенные пользователем данные с картинки. В идеале, чтобы в хендлере был вызов некого метода, который возвращает True/False в зависимости от результатов валидации, введенных пользователем данных. Это было бы очень удобно для расширения логики.

Казалось бы, что там особенного, наверняка есть решения. Начал искать. Попались на глаза:

  • Django REST framework reCAPTCHA (не подходит из-за использования стороннего сервиса)

  • Django YaCaptcha (не подходит по аналогичной причине)

  • Django rest captcha (не удалось попробовать ввиду того, что библиотека не обновилась больше 2-х лет и не поддерживает Django 5.x)

  • Django Simple Captcha (не подошло по текущей бизнес-логики + посмотрел код на GitHub и мне не понравилось, ну, что поделать - вкусы у всех разные)

Ввиду того, что ничего из того, что я нашел поиском в интернете, мне не подошло, было принято решение сделать все самому. Задача не выглядит как Rocket Science, да и кто из нас не любит строить свой велосипед, так что погнали...

Реализация

Описание логики работы

Упрощенно разобьем логику работы на 2 этапа:

  • get-запрос на получение картинки капчи. На этом шаге мы генерируем 2 значения: captcha_id, captcha_value. Записываем эти значения в какое-нибудь хранилище (Postgres, Redis и т.д.) для дальнейшей проверки ввода пользователя. Так как у нас REST, то ответом будет json, который будет иметь 2 поля: captcha_id и image - картинка капчи, закодированная в base64.

  • post-запрос для валидации данных капчи, введенных пользователем. Передаваемые от пользователя данные: captcha_id, captcha_value. Получаем из хранилища данных, куда до этого мы поместили капчу, данные по captcha_id . Сравниваем переданные пользователем captcha_value со значением, которое мы получили из хранилища данных. Если значения совпадают - пользователь ввел капчу правильно, то выдаем юзеру сгенерированный uuid-токен (тут я сразу буду описывать такую логику, так как в моей задаче нужно именно такое поведение). Если капча введена правильно, удаляем ее данные из хранилища данных (также по-хорошему надо ограничить время жизни капчи в хранилище по TTL).

Программируем

Для начала создадим новое Django-приложение, или, по-нашему, пакет: python manage.py startapp captcha
Сразу добавим его в INSTALLED_APPS в settings.py:

INSTALLED_APPS = [
...
"captcha",
]

Также в настройки добавим:

  • переменную, отвечающую за длину капчи;

  • переменную для префикса капчи в кэше;

  • переменную, определяющую время жизни капчи в кэше;

  • переменную, задающую размер изображения капчи.

CAPTCHA_LENGTH = env.int("CAPTCHA_LENGTH", default=4)
CAPTCHA_CACHE_PREFIX = env.str("CAPTCHA_CACHE_PREFIX", default="captcha_")
CAPTCHA_CACHE_TTL = env.int("CAPTCHA_CACHE_TTL", default=120)
CAPTCHA_IMAGE_SIZE = env.tuple("CAPTCHA_IMAGE_SIZE", default=(90, 40))

Сделаем пакет с утилитами, в нем создадим модуль для генерации текста для капчи:

import random

from django.conf import settings


def generate_captcha_value(length: int = settings.CAPTCHA_LENGTH) -> str:
    symbols = "abcdefghijklmnopqrstuvwxyz1234567890"
    res = []

    for _ in range(length):
        res.append(random.choice(symbols))
    result = "".join(res)

    return result.upper()

Создадим модель Captcha в файле models.py

import uuid

from django.db import models
from django.utils import timezone

from captcha.utils.generate import generate_captcha_value


class CaptchaModel(models.Model):
    captcha_id = models.UUIDField(default=uuid.uuid4)
    code = models.CharField(max_length=6, default=generate_captcha_value)
    created_at = models.DateTimeField(default=timezone.now)

    class Meta:
        managed = False

Здесь я использую managed = False для того, чтобы таблица в БД не создавалась, так как данные капчи буду хранить в Redis. Далее я переопределю метод save модели для сохранения данных именно в Redis.

Добавим импорт:

from django.core.cache import cache

Сам метод save:

def save(self, *args, force_insert=False, force_update=False, using=None, update_fields=None):
    cache.set(
        f"{settings.CAPTCHA_CACHE_PREFIX}{self.captcha_id}",
        self.code,
        settings.CAPTCHA_CACHE_TTL,
    )

Теперь займемся генерацией изображения с кодом капчи.
Эту логику вынесем в сервисный слой, для этого создадим пакет services в корне пакета captcha. Далее, в пакете services создадим модуль captcha_service.py и начнем писать сам сервис:

from django.conf import settings
from PIL import Image, ImageDraw, ImageFont

from captcha.models import CaptchaModel


class CaptchaService:
    def __init__(self):
        self.captcha = CaptchaModel()
        self.captcha.save()

        self.font = ImageFont.truetype(settings.CAPTCHA_FONT_PATH, settings.CAPTCHA_FONT_SIZE)

Для отображения текста на картинке будем использовать шрифт Vera.ttf https://github.com/mps/fonts/blob/master/Vera.ttf. Скачаем этот шрифт и поместим в папку fonts внутри пакета captcha.
Добавим в settings.py нашего проекта путь к этому шрифту и размер шрифта:

CAPTCHA_FONT_PATH = os.path.join(BASE_DIR, "captcha/fonts/Vera.ttf")
CAPTCHA_FONT_SIZE = env.int("CAPTCHA_FONT_SIZE", default=22)

Теперь давайте добавим метод для генерации изображения в CaptchaService:

def _generate_image(self) -> Image.Image:
    image = Image.new("RGB", settings.CAPTCHA_IMAGE_SIZE, (128, 213, 215))
    draw = ImageDraw.Draw(image)
    text_width, text_height = self.font.getmask(self.captcha.code).size  # type: ignore
    draw_points = (
        (settings.CAPTCHA_IMAGE_SIZE[0] - text_width) // 2,
        (settings.CAPTCHA_IMAGE_SIZE[1] - text_height) // 2,
    )
    draw.text(
        draw_points,
        self.captcha.code,  # type: ignore
        font=self.font,
        fill=(255, 255, 255),
    )
    image = image.filter(ImageFilter.GaussianBlur(1.5))

    return image

Этот код генерирует объект Image.Image с заданными размерами, добавляет текст из капчи и добавляет размытие по Гауссу для осложнения автоматического распознавания текста, во всяком случае MacOS из коробки (стандартный просмотрщик умеет копировать текст с изображения) не смогла скопировать текст с капчи с такими настройками цвета фона и степени размытия.

Теперь надо сделать кодирование изображения в base64 для дальнейшей отправки клиенту (фронтэнду).

Добавим такой метод в CaptchaService:

def base_64_captcha_image(self) -> str:
    image = self._generate_image()
    buffered = BytesIO()
    image.save(buffered, format="PNG")
    return base64.b64encode(buffered.getvalue()).decode("utf-8")

Генерация капчи готова! Теперь наша задача сделать валидацию введенных пользователем данных. Для этих целей создадим еще один модуль в сервисном слое, назовем этот модуль validate_captcha.py. В этом модуле создадим сервис:

from django.conf import settings
from django.core.cache import cache


class ValidateCaptchaService:
    def __init__(self, code: str, captcha_id: str):
        self.code = code
        self.captcha_id = captcha_id
        
    def validate(self) -> bool:
        return (
            cache.get(f"{settings.CAPTCHA_CACHE_PREFIX}{self.captcha_id}", "") == self.code.upper()
        )

На этом основная работа закончена. Теперь создадим APIView для этих действий.
Но прежде сделаем сериализатор, который будет моделью ответа на запрос капчи. Создадим пакет serializers в корне пакета captcha. Там создадим модуль captcha_serializers.py.
Добавим код сериализатора:

from rest_framework import serializers


class CaptchaSerializer(serializers.Serializer):
    captcha_id = serializers.CharField()
    image = serializers.CharField()
    created_at = serializers.DateTimeField()

В модуле views.py пакета captcha напишем следующий код:

from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView

from captcha.serializers.captcha_serializers import CaptchaSerializer
from captcha.services.captcha_service import CaptchaService
from main.serializers.common_serializers import BadRequestSerializer


class CaptchAPIView(APIView):
    @extend_schema(
        description=("Запрос на получение Captcha"),
        responses={
            status.HTTP_200_OK: CaptchaSerializer,
            status.HTTP_400_BAD_REQUEST: BadRequestSerializer,
        },
        tags=["captcha"],
    )
    def get(self, request) -> Response:
        service = CaptchaService()
        serializer = CaptchaSerializer(
            data={
                "captcha_id": service.captcha.captcha_id,
                "created_at": service.captcha.created_at,
                "image": service.base_64_captcha_image(),
            }
        )

        if serializer.is_valid():
            return Response(serializer.data, status=status.HTTP_200_OK)
        return Response(
            BadRequestSerializer({"detail": serializer.errors}).data,
            status=status.HTTP_400_BAD_REQUEST,
        )

Здесь extend_schema - декоратор, который выводит описание метода в Swagger. Есть несколько известных решений swagger для DRF, но я в последнее время использую drf_spectacular . Кому-то может показаться слишком избыточно описывать методы при помощи декораторов, но я не вижу в этом ничего криминального. Конечно, в FastAPI все из коробки работает очень элегантно, но я уже смирился, что в DRF пока такого и близко нет и приходится использовать специальные для этого библиотеки. Кстати про Swagger и DRF, я вот такие еще использовал:

Вот код BadRequestSerializer, который возвращается в случае ошибок:

from rest_framework import serializers


class BadRequestSerializer(serializers.Serializer):
    detail = serializers.CharField()

Вообще, считается хорошей практикой создавать свои кастомные исключения Exceptions и свои стандартные наборы классов для работы с ошибками.

Ладно, продолжим. Создадим модуль urls.py в корне пакета captcha вот с таким содержимым:

from django.urls import path
from captcha.views import CaptchAPIView
urlpatterns = [    path("captcha/", CaptchAPIView.as_view(), name="captcha"),
]

В модуле urls.py в основном пакете проекта добавим этот код:

from django.urls import include
urlpatterns = [
...
path("captcha/", include("captcha.urls")),
]

Итак, осталось сделать только валидацию данных капчи, введенных пользователем. Для этого нам потребуется сериализатор входных данных и сериализатор успешного ответа для хэндлера post-запроса.

Добавим в модуль captcha_serializers.py следующее:

class CaptchUserInputSerializer(serializers.Serializer):
    code = serializers.CharField()
    captcha_id = serializers.CharField()


class CaptchResponseSerializer(serializers.Serializer):
    session_token = serializers.CharField()

Осталось совсем немного - добавить хэндлер post-запроса в класс CaptchAPIView модуля views.py пакета captcha:

@extend_schema(
    description=("Валидация капчи. Если капча валидна, то возвращается session_token"),
    request=CaptchUserInputSerializer,
    responses={
        status.HTTP_200_OK: CaptchResponseSerializer,
        status.HTTP_400_BAD_REQUEST: BadRequestSerializer,
    },
    tags=["captcha"],
)
def post(self, request) -> Response:
    service = ValidateCaptchaService(request.data["code"], request.data["captcha_id"])

    if service.validate() is True:
        serializer = CaptchResponseSerializer(data={"session_token": str(uuid.uuid4())})
        if serializer.is_valid():
            return Response(serializer.data, status=status.HTTP_200_OK)
        return Response(
            BadRequestSerializer({"detail": serializer.errors}).data,
            status=status.HTTP_400_BAD_REQUEST,
        )
    return Response(
        BadRequestSerializer({"detail": "Invalid captcha"}).data,
        status=status.HTTP_400_BAD_REQUEST,
    )

В последнем листинге не стал добавлять импорты, так как, мне кажется, тут все очевидно.

На этом все, вот такой простенький велосипед мы сегодня нагородили, будем продвигать велосипедостроение ;-)

Комментарии (2)


  1. MrSmitix
    11.06.2025 18:41

    serializer = CaptchResponseSerializer(data={"session_token": str(uuid.uuid4())})

    А что мешает просто генерировать uuid на клиенте в таком случае?) судя по коду он не сохраняется и никак не проверяется. И весь смысл капчи теряется


    1. shoytov Автор
      11.06.2025 18:41

      в реале сохраняется, в redis, тут не стал приводить полностью реализацию из-за того, что дальнейший флоу не имеет принципиального значения. При дальнейших запросах с фронта, этот текен должен передаваться в заголовках, этот момент тоже валидируется на соответствующих этапах