Капча с помощью PIL или практический велосипед


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

Итак, имеется Django, необходимо сделать капчу для понятных целей. Полный процесс валидности описывать не буду, переду непосредственно к примеру, как получить на выходе в браузере динамическую картинку. Что из себя будет представлять капча — шестизначное число, каждая цифра будет иметь свой цвет, шрифт, величину, гулять по вертикали и горизонтали, плюс, будут гулять рандомные шумы в виде палочек.

Для начало, подключим нужные нам библиотеки:
from hashlib import md5 #поднадобится для генерации ключа
from PIL import Image, ImageDraw, ImageFont #набор инструментов из библиотеки PIL
import random #функция рандома
from StringIO import StringIO #будем сохранять картинку в оперативку

Создаем новую функцию:
def capthaGenerate(request):

Пропишем переменную, где у нас будут лежать шрифты формата .ttf, при генерации капчи, система будет рандомно брать какой-нить один шрифт для написания цифры:
path = "/nginx/project/files/static/c/"

Создаем новое изображение средствами PIL:
im = Image.new('RGBA', (200, 50), (0, 0, 0, 0))
draw = ImageDraw.Draw(im)

Определяем переменные, с которыми будем в дальнейшем работать:
number = "" #сюда занесем наше шестизначное значение из капчи, которое потом преобразуем в ключ md5
margin_left = 0 #здесь будем хранить сколько сделать отступов слева цифре в капче
margin_top = 0 #аналогично, только отступ сверху
colorNUM = ("0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f") #здесь мы перечислили все доступные варианты для RGB-значений в шестнадцатеричном формате

Далее, начинаем делать цикл в шесть заходов, по одному заходу на каждую цифру капчи. Первым делом, нам надо определит цвет цифре, для этого я прописал следующее:
font_color = "#"+str(random.randint(0,9))
y = 0
while (y < 5):
	rand = random.choice(colorNUM)
	font_color = font_color+rand
	y = y+1

Вначале определяется, скажем так, яркость цифры, пройдясь в фотошопе по палитре цветов, определил неопытным глазом, что все цвета, начинающиеся с 0 и заканчивающие 9, не имеют ярких цветов, что хорошо, в случае светлых тонов заднего фона, по сему, цифра будет видна конечному пользователю нормально. Тем самым, первым шагом идет присвоение к переменной font_color цифры 0-9. Далее, идет цикл на 5 повторений, для рандомного выбора из словаря colorNUM, тем самым мы получаем, на выходе font_color со значением, допустим "#381dcd".

С цветом определились, поехали дальше. Далее, мы рисуем линию:
#определяем рандомные значения для рисования линии
rand_x11 = random.randint(0,100)
rand_x12 = random.randint(100,200)
rand_y11 = random.randint(0,50)
rand_y12 = random.randint(0,50)
#рисуем саму линию
draw.line((rand_x11, rand_y11, rand_x12, rand_y12), fill="#a9a6a6")

Дальше нам надо выбрать рандомный шрифт для цифры. Я выбрал 10 .ttf шрифтов, в которых хорошо читаются цифры, но при этом стиль в каждом индивидуальный. Положил все в папку, путь к которой указал в переменой path. Дальше, определил переменную, которая рандомно выбирает цифру в диапазоне 1-10:
font_rand =str(random.randint(1,10))

Рандомно выбираем размер шрифта:
fontSize_rand =random.randint(30,40)

Объявляем саму переменную для подключения шрифта:
font = ImageFont.truetype(path+"fonts/"+font_rand+".ttf", fontSize_rand)

Дальше приступаем к рисованию цифры:
a=str(random.randint(0,9)) #Генерируем цифру

Рисуем цифру:
draw.text((margin_left,margin_top), a,fill=str(font_color),font=font) #Перед циклом мы дали переменным margin_left,margin_top нулевые значения, то есть, первая цифра у нас всегда имеет одно положение, однако, так же будет скакать, поскольку шрифты всегда будут разными, как и ее размер

Рисуем еще одну линию для шума:
rand_x11 = random.randint(0,100)
rand_x12 = random.randint(100,200)
rand_y11 = random.randint(0,50)
rand_y12 = random.randint(0,50)
draw.line((rand_x11, rand_y11, rand_x12, rand_y12), fill="#a9a6a6")

Прибавим значения отступов слева и сверху для следующих цифр:
margin_left = margin_left+random.randint(20,35) #берем предыдущее значение переменной и прибавляем 20-35 пикселей
margin_top = random.randint(0,20)

В конце цикла записываем наше значение капчи в переменную
number = number+a

Цикл закончился. Мы имеем с одной стороны картинку, с другой- в отдельной переменной ее значение. Далее нам надо вернуть зашифрованное значение и саму картинку.

Объвляем соль:
salt = "$@!SAf*$@FFVXZA_%(1512czvaRV"

Делам ключик:
key = md5(str(number+salt)).hexdigest()

Дальше, нам надо вернуть картинку, чтоб браузер мог ее отобразить пользователю. Для этого воспользуемся кодированием в base64.
Объявляем переменную для выгрузки картинки в буфер:
output = StringIO()

Выгружаем:
im.save(output, format="PNG")

Получаем значение, кодируем в base64 и чистим регулярным выражением от символов новых строк:
contents = output.getvalue().encode("base64").replace("\n", "")

Формируем строку в html вид:
img_tag = '<img value="'+key+'" src="data:image/png;base64,{0}">'.format(contents)

Очищаем буфер:
output.close()

Заканчиваем функцию, возвращаем капчу:
return img_tag

Теперь, при вызове функции capthaGenerate, мы будем получать капчу в виде:
<img src="data:image/png;base64,iVBORw0KGgo.......IAAAAASUVORK5CYII=" value="7751c855c78d509b94f3e07e3d4e28f9">

Для валидности капчи нам достаточно передать серверу значение, которое ввел пользователь и value картинки, после чего, значение пользователя привести в подобного рода ключ, применив md5+соль и сравнивать на совпадения значений, ну, или раскодировать value картинки и сравнить с ключом, введенным пользователем, как угодно душе.

На выходе получаем вот такую капчу:


Полноценной код выглядит вот так:
from hashlib import md5
from PIL import Image, ImageDraw, ImageFont
import random
from StringIO import StringIO

def capthaGenerate(request):
    path = "/nginx/project/files/static/c/"
    im = Image.new('RGBA', (200, 50), (0, 0, 0, 0))
    draw = ImageDraw.Draw(im)
    number = ""
    margin_left = 0
    margin_top = 0
    colorNUM = ("0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f")
    i = 0
    while (i < 6):
        font_color = "#"+str(random.randint(0,9))
        y = 0
        while (y < 5):
            rand = random.choice(colorNUM)
            font_color = font_color+rand
            y = y+1
        rand_x11 = random.randint(0,100)
        rand_x12 = random.randint(100,200)
        rand_y11 = random.randint(0,50)
        rand_y12 = random.randint(0,50)
        draw.line((rand_x11, rand_y11, rand_x12, rand_y12), fill="#a9a6a6")
        font_rand =str(random.randint(1,10))
        fontSize_rand =random.randint(30,40)
        font = ImageFont.truetype(path+"fonts/"+font_rand+".ttf", fontSize_rand)
        a=str(random.randint(0,9))
        draw.text((margin_left,margin_top), a,fill=str(font_color),font=font)
        rand_x11 = random.randint(0,100)
        rand_x12 = random.randint(100,200)
        rand_y11 = random.randint(0,50)
        rand_y12 = random.randint(0,50)
        draw.line((rand_x11, rand_y11, rand_x12, rand_y12), fill="#a9a6a6")
        margin_left = margin_left+random.randint(20,35)
        margin_top = random.randint(0,20)
        i = i+1
        number = number+a
    salt = "$@!SAf*$@)ASFfacnq==124-2542SFDQ!@$1512czvaRV"
    key = md5(str(number+salt)).hexdigest()
    output = StringIO()
    im.save(output, format="PNG")
    contents = output.getvalue().encode("base64").replace("\n", "")
    img_tag = '<img value="'+key+'" src="data:image/png;base64,{0}">'.format(contents)
    output.close()
    return img_tag

Данный метод неидеален и можно много чего еще внести, к примеру, сделать рандомный цвет и толщину линий + сделать задний фон и добавить шум в виде шариков, да много чего еще, главное понять принцип работы, а там уже полет фантазии. Благодарю за внимание, надеюсь, кому-нибудь данная статья будет полезна.
Метки:
Поделиться публикацией
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама
Комментарии 18
  • +1
    Надо ещё закручивания добавить (swirl).
    • +23
      Пэхэпэшники потихоньку изучают другие языки :)
      • +16
        Добавление серого шума на цветную картинку мешает только живым людям.
        • +4
          Тоже хотел это отметить. Уровень этой капчи очень слабый по соверменным меркам.
        • +1
          # python_way? noWAY!
          colorNUM = ("0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f")
          • 0
            не спорю, слишком убого, есть что упростить и облегчить)
            • 0
              Одна из первых ссылок в гугле ведет на такое например:
              import random
              x = random.randint(0, 16777215)
              print "#%x" % x
              
              • 0
                UPD: выше написали.
            • +1
              Капча слабая.
              Одноцветные палочки можно убрать просто цветовым фильтром
              затем выделяем замкнутые контуры и распознаем с помощью простейшей нейросети.
              Написал бы пруф оф концепт, да работы много.
              • +1
                (Немного дополню ваш комментарий, на самом деле просто промахнулся и вместо комента к теме нажал ответить)
                Если автор захочет улучшить капчу — вот несколько моментов.
                Серые палочки мешают только человеку, но никак не программе распознавания, т.к. они всегда одного цвета и поэтому элементарно убираются.
                Разный цвет букв тоже мало влияет на стойкость — в большинстве случаев распознавание идёт в ЧБ. Разве что для красоты.
                И вообще надо понимать для чего вам капча. Если для защиты от случаных ботов в форме обратной связи — то подойдет, т.к. тут нет человека который настроит программу именно под данную капчу (хотя и это надо проверять программами распознавалками).
                Если вы собираетесь защищать что-то более важное, то такая капча не спасёт:
                во-первых — скрипт для распознавания профессионал напишет быстрее чем вы — саму капчу.
                во-вторых, не стоит забывать про антигейты. В случае последних неплохо было бы использовать кириллицу (конечно, если ваш сайт предназначен для русскоязычной аудитории) — не каждый антигейт с ней работает.
                • 0
                  благодарю за советы, буду дополнять
              • +2
                key = md5(str(number+salt)).hexdigest()
                Я дурак и невнимательно читаю, или действительно достаточно распознать одну-единственную картинку глазами, и потом долбить сервер известной комбинацией value + input?
                • –2
                  так number рандомный, что даст? или ты имел ввиду, что попытка 1 из 999999 раз будет удачной?
                  • +1
                    symbix прав, капчу так проверять нельзя.
                    Число для проверки капчи должно храниться на сервере (сессия, мемкэш и тп).
                    • 0
                      Смотрите, вы мне показываете капчу, на которой нарисована цифра пять и даете ключ со значением 33. Я разгадываю капчу и говорю вам: на капче с ключем 33 изображена цифра пять. Вы проверяете: 5 + соль / md5 = 33. Верно, проходи! Потом я не беру у вас капчу, а иду уже сразу на проверку: вот ваш ключ 33, а вот на капче 5. Вы проверяете, опять верно. Снова: 33, 5. Снова: верно.
                  • 0
                    въехал… и правда косяк… вот чем и хороши обсуждения — одна голова хорошо, а коллективом лучше, благодарю за замечание
                    • 0
                      Ну, прокалывайте, угу.
                      Всего-то надо реверс-функцию к sha256 написать…

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