CAPTCHA в web-приложении на Python и Starlette
programming
В web-приложении на Python и Starlette, процесс разработки которого мы пристально разглядываем в пошаговом режиме в этом цикле статей, уже в ближайшей перспективе появится страница для авторизации пользователей с формой для ввода логина и пароля. Глобальная сеть, являясь публичным пространством, в своём нынешнем состоянии кишит разнообразными спам ботами, желающими зарегистрировать аккаунт на каждом случайном сайте, авторизоваться и накидать вебмастеру кучу спама в комментарии. Дабы качественно усложнить жизнь прохвостам — хозяевам этих ботов, следует защитить авторизацию тестом CAPTCHA. В этом обзоре я покажу процесс разработки программы для автоматизированной генерации картинок с кодом капчи и отображение этих картинок в web-интерфейсе.
Пара слов о CAPTCHA
CAPTCHA-тест можно реализовать различными методами, в том числе при помощи API публичных web-сервисов. Но автор этого блога качественно недолюбливает публичные сервисы, поэтому тест для защиты формы авторизации я воспроизведу на основе обычных картинок с кодом, по-старинке. Такой тест, конечно же, не является панацеей, ибо уже сегодня можно найти несколько толковых реализаций программного распознавания капчи. Тем не менее, отсечь целый сонм зарвавшейся, не способной на серьёзный анализ школоты такой тест вполне способен. А запутать путеводную ниточку так, чтобы серьёзный анализ потребовался, совсем не сложно.
Воспроизводить картинки капчи я буду на основе пятизначного, состоящего из латинских букв и арабских цифр кода, при этом уже в ближайшей перспективе предусмотрю ротацию этих картинок после каждой попытки распознавания, и удачной, и неудачной.
Любая картинка — это графическое изображение. Графическое изображение букв и цифр можно реализовать на основе шрифта. Я буду использовать три разных шрифта. В хранилище пакетов Debian, именно эту операционную систему я использую на десктопе в процессе разработки, именно эту операционную систему я буду использовать на сервере сети, достаточно совершенно разнообразных шрифтов на все случаи жизни. Я буду использовать из всего этого многообразия три шрифта, устанавливаю их.
$ sudo apt install fonts-crosextra-caladea fonts-freefont-ttf fonts-sil-gentium
На их основе я воспроизведу код для картинки и сгенерирую набор таких картинок с помощью уже установленной в виртуальное окружение библиотеки Pillow.
Программа для генерирования картинки с кодом
В индексе PyPI довольно долго уже существует специализированный пакет (captcha) с программой для автоматизированного генерирования картинок с кодом для CAPTCHA-теста. Но устанавливать этот пакет в виртуальное окружение в этот раз я не стану, а позаимствую часть кода из Git профиля автора пакета и некоторым образом его доработаю в соответствии с требованиями специфики разрабатываемого web-приложения.
Код разрабатываемой программы я размещу в отдельном каталоге, создаю его.
Замечание: все последующие далее команды будут выполнены в терминале, в базовом каталоге приложения.
$ mkdir -p webapp/captcha/picturize
Создаю символические ссылки на только что установленные шрифты в только что созданном каталоге, исполняю последовательно следующие три команды.
$ ln -s -T /usr/share/fonts/truetype/crosextra/Caladea-Regular.ttf webapp/captcha/picturize/Caladea-Regular.ttf
$ ln -s -T /usr/share/fonts/truetype/freefont/FreeSerif.ttf webapp/captcha/picturize/FreeSerif.ttf
$ ln -s -T /usr/share/fonts/truetype/gentium/Gentium-R.ttf webapp/captcha/picturize/Gentium-R.ttf
Создаю с помощью текстового редактора Vim модуль для хранения кода программы.
$ vim webapp/captacha/picturize/picture.py
В этот файл пишу следующий код.
import glob
import io
import math
import os
import random
from PIL import Image, ImageFilter
from PIL.ImageFont import truetype
from PIL.ImageDraw import Draw
def check_fonts():
basedir = os.path.dirname(__file__)
return [os.path.realpath(each) for each in
glob.glob(os.path.join(basedir, '*.ttf'))]
def choose_color(start, end, opacity=None):
red = random.randint(start, end)
green = random.randint(start, end)
blue = random.randint(start, end)
if opacity is None:
return red, green, blue
return red, green, blue, opacity
def warp(w, h):
dx = w * random.uniform(0.1, 0.3)
dy = h * random.uniform(0.2, 0.3)
x1 = int(random.uniform(-dx, dx))
y1 = int(random.uniform(-dy, dy))
x2 = int(random.uniform(-dx, dx))
y2 = int(random.uniform(-dy, dy))
w2 = w + abs(x1) + abs(x2)
h2 = h + abs(y1) + abs(y2)
data = (x1, y1, -x1, h2 - y2, w2 + x2, h2 + y2, w2 - x2, -y1)
return w2, h2, data
def draw_character(c, draw, color):
fonts = tuple(truetype(n, s) for n in check_fonts()
for s in (40, 44, 46))
font = random.choice(fonts)
w = math.ceil(draw.textlength(c, direction='ltr', font=font))
h = math.ceil(draw.textlength(c, direction='ttb', font=font))
dx = random.randint(1, 5)
dy = random.randint(0, 6)
image = Image.new('RGBA', (w + dx, h + dy))
Draw(image).text((dx, dy), c, font=font, fill=color)
image = image.crop(image.getbbox())
image = image.rotate(random.uniform(-30, 30), Image.BILINEAR, expand=1)
w2, h2, data = warp(w, h)
image = image.resize((w2, h2))
image = image.transform((w, h), Image.QUAD, data)
return image
def compound(chars, draw, color):
images = []
for c in chars:
if random.random() > 0.5:
images.append(draw_character(' ', draw, color))
images.append(draw_character(c, draw, color))
return images
def create_captcha_image(chars, width, height, color, background):
image = Image.new('RGB', (width, height), background)
draw = Draw(image)
images = compound(chars, draw, color)
text_width = sum(i.size[0] for i in images)
width_ = max(text_width, width)
image = image.resize((width_, height))
average = int(text_width / len(chars))
rand = int(0.25 * average)
offset = int(average * 0.1)
table = [int(i * 1.97) for i in range(256)]
for each in images:
w, h = each.size
mask = each.convert('L').point(table)
image.paste(each, (offset, int((height - h) / 2)), mask)
offset = offset + w + random.randint(-rand, 0)
if width_ > width:
image = image.resize((width, height))
return image
def create_noise_dots(image, color, width=3, number=30):
draw = Draw(image)
w, h = image.size
while number:
x1 = random.randint(0, w)
y1 = random.randint(0, h)
draw.line(((x1, y1), (x1 - 1, y1 - 1)), fill=color, width=width)
number -= 1
def create_noise_curve(image, color):
w, h = image.size
x1 = random.randint(0, int(w / 5))
x2 = random.randint(w - int(w / 5), w)
y1 = random.randint(int(h / 5), h - int(h / 5))
y2 = random.randint(y1, h - int(h / 5))
points = [x1, y1, x2, y2]
end = random.randint(160, 200)
start = random.randint(0, 20)
Draw(image).arc(points, start, end, fill=color)
def generate_image(chars='hello', width=120, height=60, file_type='jpeg'):
background = choose_color(238, 255)
color = choose_color(10, 200, random.randint(220, 255))
image = create_captcha_image(chars, width, height, color, background)
create_noise_dots(image, color)
create_noise_curve(image, color)
image = image.filter(ImageFilter.SMOOTH)
out = io.BytesIO()
image.save(out, format=file_type)
out.seek(0)
return out
Комментировать заимствованный код смысла наверное нет. Обращаю ваше внимание, что целевая функция generate_image
генерирует с помощью остальных функций этого модуля и базовых инструментов Pillow бинарный код JPEG формата, заданного параметрами width
и height
разрешения, заданной с помощью параметра chars
строки. Эту функцию я и буду использовать далее для реализации своих меркантильных целей и коварных намерений. Сохраняю изменения в файл.
Определяем место хранения картинок CAPTCHA
Где хранить картинки? С этой проблемой я уже сталкивался, при разработке сервиса пользовательских аватар. Все изображения разрабатываемое web-приложение будет хранить в своей базе данных. Для картинок CAPTCHA-теста мне потребуется ещё одна SQL-таблица. В командном режиме текстового редактора Vim создаю новый файл с именем captcha.sql
.
:tabnew sql/captcha.sql
В этот файл пишу следующий код.
CREATE TABLE captchas (
picture bytea NOT NULL,
val varchar(5) UNIQUE,
suffix varchar(7) UNIQUE
);
В поле picture
этой таблицы я буду хранить бинарный код JPEG-картинки. В поле val
— изображенный на картинке пятизначный код из букв латинского алфавита и арабских цифр. В поле suffix
— семизначный код для url-адреса этого конкретного изображения. Сохраняю изменения в файл.
Создать таблицу в базе данных можно прямо в командном режиме Vim, следующей командой.
:!psql -d websitedev -f sql/captcha.sql
Понятно, что, чтобы заполнить эту таблицу данными, кроме бинарного кода JPEG, мне будут необходимы случайные последовательности (строки) заданной длины, состоящие из латинских букв и цифр. Генерировать такие последовательности я буду с помощью самой простой программы, использующей в своей основе генератор случайных чисел стандартной библиотеки Python.
Заполняем базу данных
Создаю в командном режиме Vim модуль с именем random.py
.
:edit webapp/common/random.py
Пишу в этот файл следующий код.
from random import choice, shuffle
from string import ascii_letters, ascii_lowercase, digits
async def randomize(n):
return ''.join(choice(ascii_letters + digits) for _ in range(n))
async def randomize_lower(n):
cache = list(ascii_lowercase + digits)
if n > len(cache):
return None
shuffle(cache)
return ''.join(cache[:n])
Функция randomize
для создания случайной строки использует все буквы латинского алфавита, и строчные, и прописные, а также арабские цифры, при этом допускает повторение символов. А функция randomize_lower
использует только строчные буквы латинского алфавита с арабскими цифрами и повтора символов не допускает. Сохраняю изменения в файл.
Так как значения в полях val
и suffix
таблицы captchas
в базе данных должны быть уникальными, при создании каждой новой записи в эту таблицу необходимо будет проверить вероятные повторы. Для такой проверки мне будет необходима ещё пара вспомогательных инструментов. Создаю в командном режиме Vim новый модуль с именем common.py
.
:edit webapp/captcha/common.py
И в этот файл пишу следующий код.
from webapp.common.random import randomize, randomize_lower
async def check_val(conn):
## генерирую случайную строку
val = await randomize_lower(5)
## запускаю цикл с проверкой условия
## в условии проверяю существование
## значения переменной val в одноимённом поле
## таблицы captchas базы данных
while await conn.fetchval(
'SELECT val FROM captchas WHERE val=$1', val):
## если условие выполняется, заменяю значение
## переменной val
val = await randomize_lower(5)
## возвращаюсь к проверке условия
## после завершения цикла
## возвращаю переменную val
return val
async def check_suffix(conn):
suffix = await randomize(7)
while await conn.fetchval(
'SELECT suffix FROM captchas WHERE suffix=$1', suffix):
suffix = await randomize(7)
return suffix
В этом коде функции check_val
и check_suffix
имеют сходную логику, но проверяют разные поля таблицы captchas
, при этом генерируют случайные строки разными способами.
Сохраняю изменения в файл.
На текущий момент в моём распоряжении есть все необходимые инструменты для заполнения таблицы captchas
данными, осталось собрать их в стройную логическую цепочку. Создаю в командном режиме Vim новый модуль с именем insert_captchas.py
.
:edit insert_captchas.py
Этот модуль будет исполняемым, и с его помощью я заполню таблицу captchas
данными. Пишу в этот файл следующий код.
import argparse
import asyncio
import functools
from webapp.dirs import settings
from webapp.common.pg import get_conn
from webapp.captcha.common import check_suffix, check_val
from webapp.captcha.picturize.picture import generate_image
def parse_args():
##обрабатываю аргументы командной строки
args = argparse.ArgumentParser()
##ключ -n имеет целое значение,
##по-умолчанию 500,
##контролирует количество создаваемых картинок
args.add_argument(
'-n',
action='store',
dest='num',
type=int,
default=500,
help='control the number of created captchas')
return args.parse_args()
async def gen_row():
## подключаюсь к базе данных PostgreSQL
conn = await get_conn(settings)
## генерирую уникальный пятизначный код
val = await check_val(conn)
## генерирую уникальный семизначный суффикс
suffix = await check_suffix(conn)
## определяю текущий event loop
loop = asyncio.get_running_loop()
## генерирую в соседней с текущим
## event loop ветке бинарный код JPEG
pic = await loop.run_in_executor(
None, functools.partial(generate_image, val))
## записываю полученные данные в таблицу
## captchas базы данных
await conn.execute(
'INSERT INTO captchas (picture, val, suffix) VALUES ($1, $2, $3)',
pic.read(), val, suffix)
## закрываю соединение с базой данных
await conn.close()
## закрываю файл полученной картинки
await loop.run_in_executor(
None, functools.partial(pic.close))
## вывожу на экран пятизначный код
## и суффикс полученной картинки
print(val, suffix)
async def main(num):
## заданное с помощью параметра num раз
## запускаю асинхронно функцию gen_row
for _ in range(num):
row = asyncio.create_task(gen_row())
await row
if __name__ == '__main__':
arguments = parse_args()
asyncio.run(main(arguments.num))
В этом коде главную работу исполняет асинхронная функция gen_row
, которую заданное с помощью аргумента командной строки — ключа -n
, число раз исполняет асинхронно функция main
. Поскольку main
тоже является так называемой корутиной, её я исполняю в стандартном event loop с помощью соответствующего инструмента модуля asyncio стандартной библиотеки Python.
В очередной раз сохраняю изменения в файл.
Текстовый редактор мне ещё понадобится, его окно я закрывать не буду... В соседнем терминале вхожу в корневой каталог разрабатываемого web-приложения, активирую в нём виртуальное окружение и запускаю только что разработанный модуль insert_captchas.py
на исполнение.
$ python instert_captchas.py -n 300
В этой команде я задал количество генерируемых картинок для теста CAPTCHA — 300 штук обычно бывает достаточно. Если ключ -n
опустить, то будет создано 500 картинок с уникальным кодом. Внимание на следующий снимок экрана.
Видно, что процесс пошёл, остаётся дождаться полного его завершения. В конечном итоге я получу в терминале список созданных кодов и соответствующих каждому коду суффиксов. С помощью этих данных чуть позже у меня появится возможность отобразить картинку с кодом в браузере. Об этом далее...
Отображаем картинку CAPTCHA-теста в браузере
Отображать хранящиеся в таблице captchas
базы данных PostgreSQL картинки я буду по типовому url-адресу, главным параметром в котором будет уникальный суффикс картинки. Следовательно, мне нужен новый url в карте адресов ASGI приложения и соответствующий этому url обработчик. Открываю в текстовом редакторе "инит" из базового каталога приложения.
:edit webapp/__init__.py
Именно в этом файле хранится конфигурация ASGI. Дописываю в этот файл ещё одну процедуру импорта.
from .captcha.views import show_captcha
Функция с именем show_captcha
и модуль views.py
в каталоге captcha
на текущий момент не существуют, их я создам на следующем шаге разработки. А пока нахожу в коде открытого "инита" процедуру инициализации переменной app
, и дописываю в параметр routes
этой процедуры ещё один элемент списка.
Route('/captcha/{suffix}', show_captcha, name='captcha'),
И вот как этот "инит" выглядит в окне моего терминала после внесённых правок.
Сохраняю изменения в файл.
В окне текстового редактора Vim, в его командном режиме создаю модуль views.py
в каталоге captcha
.
:edit webapp/captcha/views.py
И пишу в этот модуль следующий код.
from starlette.responses import Response
from starlette.exceptions import HTTPException
from ..common.pg import get_conn
async def show_captcha(request):
## создаю подключение к базе данных PostgreSQL
conn = await get_conn(request.app.config)
## получаю из параметра url-адреса
## суффикс запрошенного изображения
## и запрашиваю в базе данных соответствующие
## этому суффиксу данные из таблицы captchas
## сохраняю полученные данные в переменной res
res = await conn.fetchrow(
'SELECT * FROM captchas WHERE suffix = $1',
request.path_params.get('suffix'))
## закрываю соединение с базой данных
await conn.close()
## проверяю значение переменной res
## по следующему условию
if res is None:
## если условие выполняется
## генерирую 404 исключение
raise HTTPException(status_code=404, detail='Страница не найдена.')
## в ином случае создаю HTTP-ответ сервера
## на основе полученных из базы данных значений
response = Response(res.get('picture'), media_type='image/jpeg')
## обнуляю кэш в этом HTTP-ответе
response.headers.append(
'cache-control',
'max-age=0, no-store, no-cache, must-revalidate')
## возвращаю переменную response
return response
Заданная уникальным суффиксом картинка в результате ротации, о которой мы поговорим в одном из следующих выпусков этого блога чуть позже, может меняться, именно поэтому в ответе сервера обработчика show_captcha
я обнулил кэш. Работе конец, наконец-то... пришло время протестировать результаты предпринятых усилий.
Тестируем в браузере
Первое, чего я ожидаю на этот раз, это штатный запуск отладочного сервера без сбоев, ошибок и предупреждений. Внимание на следующий снимок экрана.
Сервер запустился, никаких аномалий в его выхлопе не зарегистрировано. Обращаю внимание, что в терминале до сих пор присутствует список сгенерированных программой insert_captchas значений уникальных суффиксов и соответствующих им пятизначных кодов для CAPTCHA-теста. Выбираю один из этих суффиксов и копирую его в буфер обмена.
Запускаю браузер. В адресной строке вбиваю типовой url-адрес картинки, используя суффикс из буфера обмена. Жму enter
.
Как видно на снимке экрана выше, код на картинке соответствует показанному в окне терминала значению. Bingo..!
Конечно же, можно ещё и заглянуть в базу данных и отфильтровать по этому суффиксу соответствующее ему значение из таблицы captchas
, можно проверить любой url-адрес с любым суффиксом из списка. Чем больше тестов, тем крепче уверенность в себе и разработанном коде.
Подводим промежуточный итог
Текущую версию кода, как всегда, можно найти в моём профиле на github.com по этой короткой ссылке. Обращаю ваше внимание, что в этой версии изменился состав виртуального окружения и файл requirements.txt
. Всё течёт, всё меняется, я готовлюсь к переезду на следующую версию Python.
В базе данных разрабатываемого на Python и Starlette web-приложения в результате предпринятых и описанных в этой демонстрации усилий появился набор случайных не повторяющихся картинок для CAPTCHA-теста. Это значит, что web-мастер может наконец-то приступить к вёрстке страницы авторизации и формы для ввода логина и пароля. С решением именно этой задачи я познакомлю читателей этого блога в одном из следующих его выпусков. Продолжение следует...