Ajax captcha на django сайт

Капча нужна для того, чтобы формой обратной связи пользовались только люди, а не спам-боты.

Алгоритм работы встраиваемой капчи такой:

  • В форму отправки сообщений добавляем поля для капчи: закодированое значение и поле для ввода.
  • Во вью, которое отправляет сообщение будем делать проверку этого ввода.

Предлагаемый вариант изображения captcha не сохраняет картинку на сервере, а генерирует ее на лету.

  • Весь представленный код будет соответствовать моему Django окружению
  • В этой статье будет использован файл с утилитами

Приложение для отправки сообщений

Я считаю, что сообщения от клиентов необходимо не просто отправлять на почту, но и сохранять в админке.

Создадим новое приложение и установим необходимые зависимости

Консоль

(env) ./project $ pip install numpy
(env) ./project $ cd apps/
(env) ./project/apps $ ../manage.py startapp feedback 
(env) ./project/apps $ mv feedback/apps.py feedback/app.py
(env) ./project $ cd ../

Поправим файлы

./apps/feedback/__init__.py

default_app_config = "apps.feedback.app.FeedbackConfig"

./apps/feedback/app.py

# -*- coding: utf-8 -*-
from __future__ import unicode_literals

from django.apps import AppConfig


class FeedbackConfig(AppConfig):
    name = 'apps.feedback'
    verbose_name = u'Сообщения от клиентов сайта'

./apps/feedback/models.py

# -*- coding: utf-8 -*-
from __future__ import unicode_literals

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

# Сообщения от клиентов
class Message(models.Model):
    name = models.CharField(u'Имя', max_length=191)
    email = models.CharField(u'Электронная почта', max_length=191)
    message = models.TextField(u'Сообщение')
    date_pub = models.DateTimeField(u'Дата публикации', default=timezone.now)

    class Meta:
        verbose_name = u'сообщение'
        verbose_name_plural = u'Сообщения клиентов'
        ordering = ['-date_pub']

    def __unicode__(self):
        return self.name

./apps/feedback/admin.py

# -*- coding: utf-8 -*-
from __future__ import unicode_literals

from django.contrib import admin

from apps.feedback.models import Message


class MessageAdmin(admin.ModelAdmin):
    list_display = ('name', 'email', 'message')
admin.site.register(Message, MessageAdmin)

Создадим файл с формой

./apps/feedback/forms.py

# -*- coding:utf-8 -*-
from django import forms

from apps.feedback.models import Message


class MessageForm(forms.ModelForm):
    name = forms.CharField(
                    label=u'Представьтесь',
                    widget=forms.TextInput(attrs={'placeholder': u'Ваше имя'}))
    email = forms.EmailField(
                    label=u'Email',
                    widget=forms.TextInput(attrs={'placeholder': u'Почта для ответа'}))
    message = forms.CharField(
                    label=u'Текст сообщения',
                    widget=forms.Textarea(attrs={'placeholder': u'Сообщение', 'rows': 5, }),
                    max_length=2000)
    antispam_hidden = forms.CharField(widget=forms.HiddenInput())
    antispam_text = forms.CharField(
                    label=u'Строка антиспама',
                    widget=forms.TextInput(attrs={'placeholder': u'Введите код с картинки'}))

    class Meta:
        model = Message
        fields = ['name', 'email', 'message']

Для корректной работы ./apps/feedback/views.py нужно задать несколько переменных:

  • admin.site.site_title — значение задается в файле ./project/urls.py
  • MESSAGE_URL_START — значение задается в ./project/settings.py
  • MESSAGE_URL_END — значение задается в ./project/settings.py
  • EMAIL_USER — значение задается в ./project/settings.py
  • EMAIL_PASS — значение задается в ./project/settings.py

А также обработать редиректы на урлы

  • /sending_ok/ — успешная отправка сообщений
  • /sending_fail/ — ошибка при отправке сообщений
  • /spam_detected/ — ошибка при вводе кода с картинки

./apps/feedback/views.py

# -*- coding: utf-8 -*-
from __future__ import unicode_literals

import hashlib

from django.conf import settings
from django.contrib.auth.models import User
from django.contrib import admin
from django.template.response import TemplateResponse
from django.shortcuts import redirect
from django.views.decorators.csrf import csrf_exempt

from apps.feedback.models import Message
from apps.feedback.forms import MessageForm

from libs.captcha import ajax_captcha
from utils import mail

# Отобразить новую капчу и hidden поле
@csrf_exempt
def get_captcha(request):
    captcha_image, hidden_value = ajax_captcha()
    context = {
        'captcha_image': captcha_image,
        'hidden_value': hidden_value,
    }
    return TemplateResponse(request, "feedback/captcha.html", context=context)

# Отправка сообщения
def sendmessage(request):
    form = MessageForm()
    if request.POST:
        form = MessageForm(request.POST)
        if form.is_valid():
            cd = form.cleaned_data
            if hashlib.sha1(cd['antispam_text']).hexdigest() == cd['antispam_hidden']:
                try:
                    message = Message.objects.create(
                        name = cd['name'],
                        email = cd['email'],
                        message = cd['message']
                    )
                    message.save()
                    users = User.objects.all()
                    for u in users:
                        if u.groups.filter(name='message').exists() and u.email:
                            to = u.email
                            subject = u'Новое сообщение с сайта %s' % admin.site.site_title
                            text = u'''
                                <html>
                                    <body>
                                        <p>Поступило новое сообщение с сайта.</p>
                                        <p>%s</p>
                                        <p>%s</p>
                                        <p>%s</p>
                                        <p> </p>
                                        <p>%s%d%s</p>
                                    </body>
                                </html>''' % (message.name, message.email, message.message,
                                                settings.MESSAGE_URL_START, message.id, settings.MESSAGE_URL_END)
                            mail(to, subject, text.encode('UTF-8'))
                    return redirect('/sending_ok/')
                except:
                    return redirect('/sending_fail/')
            else:
                return redirect('/spam_detected/')

    context = {
        'form': form,
    }
    return TemplateResponse(request, "page.html", context=context)

Создадим файл ./apps/feedback/urls.py

./apps/feedback/urls.py

# -*- coding:utf-8 -*-
from django.conf.urls import url

from apps.feedback.views import get_captcha, sendmessage

urlpatterns = [
    url(r'^get-captcha/$', get_captcha),
    url(r'^sendmessage/$', sendmessage),
]

./project/settings.py

...
INSTALLED_APPS = [
    ...
    'apps.feedback',
    ...
]
...
EMAIL_USER = u'' # Yandex логин
EMAIL_PASS = u'' # Yandex пароль
MESSAGE_URL_START = u'https://ДОМЕН/admin/common/message/' # Начало ссылки для письма из формы обратной связи
MESSAGE_URL_END = u'/change/' # конец ссылки
...

./project/urls.py

...
from django.contrib import admin
...
# Параметры меняют название и заголовки в админке. Удобно задавать для каждого сайта
admin.site.site_header = u'' 
admin.site.site_title = u'' 
admin.site.index_title = u''
...
urlpatterns = [
    ...
    url(r'^feedback/', include('apps.feedback.urls')),
    ...
]
...

Теперь нужно сделать и применить миграции

Консоль

(env) ./project $ ./manage.py makemigrations
(env) ./project $ ./manage.py migrate

Генерация и отображение картинки в форме

Для начала нужно скачать нужный шрифт и положить его в какую-нибудь папку. В моем случае шрифт называется AvidOmnes-Medium.otf и положил я его в папку ./static/fonts/

При генерации самой картинки я использовал статью Фрактальная капча на Python.

Доработанный файл положил в папку libs/

./libs/captcha.py

# -*- coding: utf-8 -*-

# FractalCaptcha.py v 0.1
# (c) Alexandr A Alexeev 2011 | http://eax.me/

import base64
import hashlib
import io

from numpy import mgrid, exp
from PIL import Image, ImageDraw, ImageFont, ImageFilter
from random import random

from utils import getRandomString

def gaussian_grid(size = 5):
    m = size/2
    n = m+1
    x, y = mgrid[-m:n,-m:n]
    fac = exp(m**2)
    g = fac*exp(-0.5*(x**2 + y**2))
    return g.round().astype(int)

class GAUSSIAN(ImageFilter.BuiltinFilter):
    name = "Gaussian"
    gg = gaussian_grid().flatten().tolist()
    filterargs = (5,5), sum(gg), 0, tuple(gg)

def captcha(secret, width=240, height=80,
    font_ttf='/path_to_project_folder/static/fonts/AvidOmnes-Medium.otf', fontSize=60,
    blur = 1):
  mask = Image.new('RGBA', (width, height))
  font = ImageFont.truetype(font_ttf, fontSize)
  x_offset = -10
  draw = ImageDraw.Draw(mask)
  for i in range(len(secret)):
    x_offset += 20 + int(random()*20)
    y_offset = -10 + int(random()*30)
    draw.text((x_offset, y_offset), secret[i], font=font)
  angle = -10 + int(random()*15)
  mask = mask.rotate(angle)
  bg = plazma(width, height)
  fg = plazma(width, height)
  result = Image.composite(bg, fg, mask)
  if blur > 0:
    for i in range(blur):
      result = result.filter(GAUSSIAN)
  return result

def plazma(width, height):
  img = Image.new('RGB', (width, height))
  pix = img.load();
  for xy in [(0,0), (width-1, 0), (0, height-1), (width-1, height-1)]:
    rgb = []
    for i in range(3):
      rgb.append(int(random()*100))
    pix[xy[0],xy[1]] = (rgb[0], rgb[1], rgb[2])
  plazmaRec(pix, 0, 0, width-1, height-1)
  return img

def plazmaRec(pix, x1, y1, x2, y2):
  if (abs(x1 - x2) <= 1) and (abs(y1 - y2) <= 1):
    return
  rgb = []
  for i in range(3):
    rgb.append((pix[x1, y1][i] + pix[x1, y2][i])/2)
    rgb.append((pix[x2, y1][i] + pix[x2, y2][i])/2)
    rgb.append((pix[x1, y1][i] + pix[x2, y1][i])/2)
    rgb.append((pix[x1, y2][i] + pix[x2, y2][i])/2)
    tmp = (pix[x1, y1][i] + pix[x1, y2][i] +
           pix[x2, y1][i] + pix[x2, y2][i])/4
    diagonal =  ((x1-x2)**2 + (y1-y2)**2)**0.5
    while True:
      delta = int ( ((random() - 0.5)/100 * min(100, diagonal))*255 )
      if (tmp + delta >= 0) and (tmp + delta <= 255):
        tmp += delta
        break
    rgb.append(tmp)
  pix[x1, (y1 + y2)/2] = (rgb[0], rgb[5], rgb[10])
  pix[x2, (y1 + y2)/2]= (rgb[1], rgb[6], rgb[11])
  pix[(x1 + x2)/2, y1] = (rgb[2], rgb[7], rgb[12])
  pix[(x1 + x2)/2, y2] = (rgb[3], rgb[8], rgb[13])
  pix[(x1 + x2)/2, (y1 + y2)/2] = (rgb[4], rgb[9], rgb[14])
  plazmaRec(pix, x1, y1, (x1+x2)/2, (y1+y2)/2)
  plazmaRec(pix, (x1+x2)/2, y1, x2, (y1+y2)/2)
  plazmaRec(pix, x1, (y1+y2)/2, (x1+x2)/2, y2)
  plazmaRec(pix, (x1+x2)/2, (y1+y2)/2, x2, y2)

# Функция генерирует картинку и хэш
def ajax_captcha():
    rand_string = getRandomString(6)
    result_captcha = captcha(rand_string)
    bytes_io = io.BytesIO()
    result_captcha.save(bytes_io, format='PNG')
    img_str = base64.b64encode(bytes_io.getvalue())
    hidden_value = hashlib.sha1(rand_string).hexdigest()
    return 'data:image/png;base64,%s' % img_str, hidden_value

Также понадобится функция getRandomString из utils.py

Модификация шаблонов

Рассмотрим случай, когда форма с отправкой сообщения находится в подвале, то есть на всех страницах сайта. В этом случае нам надо генерировать картинку в контекст процессоре.

./context_processors.py

# -*- coding:utf-8 -*-

...
from apps.feedback.forms import MessageForm
from libs.captcha import ajax_captcha
...


def settings_vars(request):
    ...
    cp_captcha_image, cp_hidden_value = ajax_captcha()
    ...
    return {
        ...
        'cp_message_form': MessageForm,
        'cp_captcha_image': cp_captcha_image,
        'cp_hidden_value': cp_hidden_value,
    }

Создадим файл ./templates/feedback/captcha.html

./templates/feedback/captcha.html

<img src="{{ captcha_image }}">
<input type="hidden" value="{{ hidden_value }}" name="antispam_hidden">

Модификация подвала шаблона

./templates/_footer.hmtl

...
<form method="POST" action="/feedback/sendmessage/">{% csrf_token %}
  <p>{{ cp_message_form.name }}{% if cp_message_form.name.errors %}<br /> {{ cp_message_form.name.errors }}{% endif %}</p>
  <p>{{ cp_message_form.email }}{% if cp_message_form.email.errors %}<br /> {{ cp_message_form.email.errors }}{% endif %}</p>
  <p>{{ cp_message_form.message }}{% if cp_message_form.message.errors %}<br /> {{ cp_message_form.message.errors }}{% endif %}</p>
  <p>{{ cp_message_form.antispam_text }}{% if cp_message_form.antispam_text.errors %}<br /> {{ cp_message_form.antispam_text.errors }}{% endif %}</p>
  <a class="btnajax" style="cursor: pointer;">?</a>
  <div class="captcha">
      <img src="{{ cp_captcha_image }}">
      <input type="hidden" value="{{ cp_hidden_value }}" name="antispam_hidden">
  </div>
  <p><input type="submit" class="button alt" value="Отправить" /></p>
</form>
...
<script src="{{ STATIC_URL }}assets/js/jquery.min.js"></script>
...
<script type="text/javascript">
  $(function() {
    $('.btnajax').each(function() {
      $(this).on('click', function() {
        $('.captcha').html("Секунду...");
        var value = $(this).attr('value');
        $.ajax({
          url: '/feedback/get-captcha/',
          cache: false,
          data: { value: value },
          type: 'POST',
          success: function(html) {
            $('.captcha').html(html);
          },
        });
      });
    });
  });
</script>

На этом этапе должна отобразиться форма с сгенерированной картинкой, и при нажатии на «?» капча должна меняться.

Осталось обработать отправку сообщений и создание экземпляра класса Message

Для того, чтобы отправлять сообщения конкретным пользователям я делаю так: в админке создаю группу с названием message, и добавляю нужных пользователей в эту группу. Тогда отправка сообщений пользователям из этой группы будет выглядеть так:

for u in users:
    if u.groups.filter(name='message').exists() and u.email:

На этом все.

Пример реализованной капчи можно посмотреть на сайте СибСтрой-К

Протестировано на:

pip freeze

Django==1.11.16
numpy==1.16.5
Pillow==5.3.0

При возникновении вопросов — пишите на почту it@omoroot.ru