angle-uparrow-clockwisearrow-counterclockwisearrow-down-uparrow-leftatcalendarcard-listchatcheckenvelopefolderhouseinfo-circlepencilpeoplepersonperson-fillperson-plusphoneplusquestion-circlesearchtagtrashx

Создание Captcha с Flask, WTForms, SQLAlchemy, SQLite

Уважайте конфиденциальность ваших посетителей, не подключайте их к стороннему сервису Captcha !

10 апреля 2023
post main image

В прошлом я написал некоторый код (только для текста) Captcha для сайта Flask . Это обновление. Здесь я использую пакет Pypi для генерации изображения. Кроме того, я добавил кнопку обновления изображения Captcha .
Вы можете попробовать код ниже, это форма подписки (на рассылку). Хотя это решение Captcha для сайта Flask , его можно преобразовать в сервер Captcha .

Обратите внимание, что я использую SQLite здесь только в демонстрационных целях. Не используйте SQLite в среде с несколькими user !

Описание

В нашем приложении есть форма подписки (на рассылку новостей), в которую посетитель может ввести свой адрес электронной почты. Эта форма защищена Captcha.

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

Кроме того, рядом с изображением находится кнопка "обновить". При нажатии на нее отображается новое изображение.

На странице подписки HTML мы также используем:

  • Bootstrap
  • JQuery

Кнопка обновления Captcha использует иконку Bootstrap .

The CaptchaField

Требования к нашему новому полю CaptchaField:

  • Уникальный токен, в скрытом поле
  • img-тег, который берет изображение с сервера.
  • Текстовое поле ввода, в которое можно ввести код.
  • Пользовательская валидация для проверки введенного кода.

Основная сложность здесь заключается в токене Captcha , мы должны хранить его также в поле.

Поскольку нам нужна функция ввода текста для ввода кода Captcha , мы используем код StringField из WTForms как основу для нашего CaptchaField.

class StringField(Field):
    """
    This field is the base for most of the more complicated fields, and
    represents an ``<input type="text">``.
    """

    widget = widgets.TextInput()

    def process_formdata(self, valuelist):
        if valuelist:
            self.data = valuelist[0]

    def _value(self):
        return str(self.data) if self.data is not None else ""

Мы переопределяем методы:

  • __call__
  • post_validate

В нашем новом методе '__call__' мы очищаем ранее введенный код, генерируем новый Captcha и затем добавляем дополнительный HTML:

  • Скрытое поле для хранения маркера.
  • img-тег для извлечения изображения
  • Кнопка для обновления изображения

В нашем новом методе 'post_validate' мы:

  • Получаем токен и код из запроса.
  • Просматриваем запись в базе данных Captcha с помощью маркера.
  • Сравниваем код с сохраненным кодом.

Мы создаем таблицу базы данных 'Captcha' со следующими полями:

  • токен
  • код
  • данные изображения

Я создал класс CaptchaUtils() для создания Captcha и проверки Captcha.

Как только все вышеперечисленное будет сделано, нам останется только добавить Captcha в форму:

class SubscribeForm(wtf.FlaskForm):
    ...
    captcha_code = CaptchaField(
		'Enter code',
        validators=[wtv.InputRequired()]
    )
    ...

Чтобы обновить Captcha, мы вызываем сервер Flask для генерации нового Captcha. Этот вызов возвращает новое значение:

  • captcha_token
  • captcha_image_url

На странице подписки мы можем сделать это с помощью нескольких строк Javascript (JQuery):

$(document).ready(function(){
	$('#captcha-refresh-button').click(function(){
		captcha_new_url = '/captcha/new';
		$.getJSON(captcha_new_url, function(data){
			$('#captcha-token').val(data.captcha_token);
			$('#captcha-image').attr('src', data.captcha_image_url);
		});
	});
});

Код

Как обычно, мы создаем virtual environment. Устанавливаем пакеты:

pip install flask
pip install flask-wtf
pip install sqlalchemy
pip install captcha

Далее создаем директорию 'project' и т.д. Вот структура каталогов:

.
├── project
│   ├── app
│   │   ├── factory.py
│   │   └── templates
│   │       └── subscribe.html
│   └── run.py

А вот файлы в проекте, файл базы данных SQLite появится в директории проекта:

  • run.py
  • factory.py
  • subscribe.html
# run.py
from app.factory import create_app

app = create_app('development')

if __name__ == '__main__':
    app.run(
        host= '127.0.0.1',
        port=5555,
        debug=True,
        use_reloader=True,
    )
# factory.py
import datetime
import io
import logging
import os
import random
import sys
import uuid

from flask import current_app, Flask, jsonify, make_response, render_template, request, url_for

import markupsafe

import sqlalchemy as sa
import sqlalchemy.orm as orm

import flask_wtf as wtf
import wtforms as wt
import wtforms.validators as wtv
import wtforms.widgets as wtw

from captcha.image import ImageCaptcha


class Base(orm.DeclarativeBase):
    pass

class Captcha(Base):
    __tablename__ = 'captcha'

    id = sa.Column(sa.Integer, primary_key=True)
    created_on = sa.Column(sa.DateTime, server_default=sa.func.now())
    token = sa.Column(sa.String(40), index=True)
    code = sa.Column(sa.String(10))
    image = sa.Column(sa.LargeBinary)

    def __str__(self):
        return '<Captcha: id = {}, token = {}, code = {}>'.format(id, token, code)
    
# create table
engine = sa.create_engine('sqlite:///flask_captcha.sqlite', echo=True)
Base.metadata.drop_all(engine)
Base.metadata.create_all(engine)

# get scoped session for web
db_session = orm.scoped_session(orm.sessionmaker(
        autocommit=False,
        autoflush=False,
        bind=engine
    )
)


class CaptchaUtils:
    code_chars = ['A', 'b', 'C', 'd', 'e', 'f', 'h', 'K', 'M', 'N', 'p', 'R', 'S', 'T', 'W', 'X', 'Y', 'Z']
    code_nums = ['2', '3', '4', '6', '7', '8', '9']
        
    @classmethod
    def get_random_code(cls, n):
       return ''.join(random.choice(cls.code_chars + cls.code_nums) for i in range(n))

    @classmethod
    def create(cls, db):
        # 1. create captcha token 
        captcha_token = uuid.uuid4().hex
        # 2. create captcha code 
        captcha_code = cls.get_random_code(5)
        # 3. create captcha image 
        im = ImageCaptcha()
        im_buf = io.BytesIO()
        im.write(captcha_code, im_buf, format='png')
        captcha_image = im_buf.getvalue()
        # 4. store in db
        captcha = Captcha(
            token=captcha_token,
            code=captcha_code,
            image=captcha_image
        )
        db.add(captcha)
        db.commit()
        return captcha_token, url_for('captcha_image', token=captcha_token)

    @classmethod
    def validate(cls, db, token, code):
        stmt = sa.select(Captcha).where(Captcha.token == token)
        captcha = db_session.execute(stmt).scalars().first()
        if captcha is None:
            return False
        if code.lower() != captcha.code.lower():
            return False
        return True


class CaptchaField(wt.Field):
    widget = wtw.TextInput()

    def process_formdata(self, valuelist):
        if valuelist:
            self.data = valuelist[0]

    def _value(self):
        return str(self.data) if self.data is not None else ""

    def  __call__(self, *args, **kwargs):
        self.data = ''
        input_field_html = super(CaptchaField, self).__call__(*args,**kwargs)

        captcha_token, captcha_image_url = CaptchaUtils.create(db_session)

        hidden_field_html = markupsafe.Markup('<input type="hidden" name="captcha_token" value="{}" id="captcha-token">'.format(captcha_token))
        image_html = markupsafe.Markup('<img src="{}" class="border mb-2" id="captcha-image">'.format(captcha_image_url))
        button_html = markupsafe.Markup("""<button type="button" class="btn btn-outline-secondary btn-sm ms-1" id="captcha-refresh-button">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-clockwise" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg></button>""")
        break_html = markupsafe.Markup('<br>')
        return hidden_field_html + image_html + button_html + break_html + input_field_html

    def post_validate(self, form, validation_stopped):
        if not CaptchaUtils.validate(
            db_session,
            request.form.get('captcha_token', None),
            form.captcha_code.data
        ):
            raise wtv.ValidationError('Captcha code invalid')


class SubscribeForm(wtf.FlaskForm):
    email = wt.StringField(
        'Email',
        validators=[wtv.InputRequired(), wtv.Length(min=4, max=100)]
    )
    captcha_code = CaptchaField(
        'Enter code',
        validators=[wtv.InputRequired()]
    )


def create_app(deploy_config):
    app = Flask(__name__)
    app.config['TEMPLATES_AUTO_RELOAD'] = True
    app.config.update(
        SECRET_KEY='your-secret-key',
        DEPLOY_CONFIG=deploy_config,
    )
    app.logger.setLevel(logging.DEBUG)

    # routes
    @app.route('/')
    def index():
        return 'Home'

    @app.route('/subscribe', methods=('GET', 'POST'))
    def subscribe():
        form = SubscribeForm()
        if form.validate_on_submit():
            return 'Thank you'
        return render_template(
            'subscribe.html', 
            form=form,
        )

    @app.route('/captcha/image/<token>', methods=('GET', 'POST'))
    def captcha_image(token):
        stmt = sa.select(Captcha).where(Captcha.token == token)
        captcha = db_session.execute(stmt).scalars().first()
        if captcha is None:
            return ''
        response = make_response(captcha.image)
        response.headers.set('Content-Type', 'image/png')
        return response

    @app.route('/captcha/new', methods=('GET', 'POST'))
    def captcha_new():
        captcha_token, captcha_image_url = CaptchaUtils.create(db_session)
        return jsonify({
            'captcha_token': captcha_token,
            'captcha_image_url': captcha_image_url
        })

    return app
<!DOCTYPE html>
<html lang = "en">
<head>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
</head>
<body>

<div class="container">
	<h3 class="my-3">
		Subscribe to our newsletter
	</h3>
      
	<form method="post">
		{{ form.csrf_token }}

		<div class="row mt-2">
			<div class="col-2">
				{{ form.email.label }}
			</div>
			<div class="col-10">
				{{ form.email() }}
				{% if form.email.errors %}
					<ul class="text-danger">
					{% for error in form.email.errors %}
						<li>{{ error }}</li>
					{% endfor %}
					</ul>
				{% endif %}
			</div>
		</div>	

		<div class="row mt-2">
			<div class="col-2">
				{{ form.captcha_code.label }}
			</div>
			<div class="col-10">
				{{ form.captcha_code() }}
				{% if form.captcha_code.errors %}
					<ul class="text-danger">
					{% for error in form.captcha_code.errors %}
						<li>{{ error }}</li>
					{% endfor %}
					</ul>
				{% endif %}
			</div>
		</div>	

		<div class="row mt-2">
			<div class="col-2"></div>
			<div class="col-10">
				<input type="submit" value="Submit" class="btn btn-primary">
			</div>
		</div>
	</form>

</div>      

<script src="https://code.jquery.com/jquery-3.6.4.min.js" integrity="sha256-oP6HI9z1XaZNBrJURtCoUT5SUnxFr8s3BzRl+cbzUq8=" crossorigin="anonymous"></script>
<script>
$(document).ready(function(){
	$('#captcha-refresh-button').click(function(){
		captcha_new_url = '/captcha/new';
		$.getJSON(captcha_new_url, function(data){
			$('#captcha-token').val(data.captcha_token);
			$('#captcha-image').attr('src', data.captcha_image_url);
		});
	});
});
</script>
		
</body>
</html>

Резюме

Captcha все еще очень полезен сегодня. Здесь мы использовали пакет Pypi для генерации изображения. Хотя "размытие" выглядит хорошо, оно более уязвимо для машинных атак из-за кода open source . Вероятно, вы можете добиться большего с помощью собственного алгоритма. А используя собственный обслуживаемый Captcha вместо стороннего сервиса Captcha , вы защищаете конфиденциальность своих посетителей!

Ссылки / кредиты

Another captcha implementation for Flask and WTForms
https://www.peterspython.com/en/blog/another-captcha-implementation-for-flask-and-wtforms

Captcha
https://pypi.org/project/captcha

DB Browser for SQLite
https://sqlitebrowser.org

WTForms
https://wtforms.readthedocs.io/en/3.0.x/