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

Création d'un Captcha avec Flask, WTForms, SQLAlchemy, SQLite

Respectez la vie privée de vos visiteurs, ne les connectez pas à un service tiers Captcha !

10 avril 2023
post main image

Dans le passé, j'ai écrit du code pour un Captcha (texte seul) pour un site web Flask . Il s'agit d'une mise à jour. Ici, j'utilise un paquet Pypi pour générer l'image. De plus, j'ai ajouté un bouton de rafraîchissement de l'image Captcha .
Vous pouvez essayer le code ci-dessous, il s'agit d'un formulaire d'inscription à une lettre d'information. Bien qu'il s'agisse d'une solution Captcha pour un site web Flask , elle peut être convertie en un serveur Captcha .

Notez que j'utilise SQLite ici uniquement à des fins de démonstration. N'utilisez pas SQLite dans un environnement multiuser !

Description de l'application

Notre application dispose d'un formulaire d'inscription (à une lettre d'information), dans lequel le visiteur peut saisir son adresse électronique. Ce formulaire est protégé par un Captcha.

Chaque fois que le formulaire est affiché, les données du Captcha (jeton, code, image) sont générées et stockées dans une base de données. Lorsque le formulaire est soumis, nous utilisons le jeton pour récupérer les données Captcha et comparer le code tapé par le visiteur au code stocké dans la base de données.

En outre, un bouton "rafraîchir" se trouve à côté de l'image. Lorsqu'il est cliqué, une nouvelle image est affichée.

Sur la page d'abonnement HTML , nous utilisons également :

  • Bootstrap
  • JQuery

Le bouton de rafraîchissement de la page Captcha utilise une icône Bootstrap .

The CaptchaField

Exigences pour notre nouveau champ CaptchaField :

  • Un jeton unique, dans un champ caché
  • Une balise img qui extrait l'image du serveur.
  • Un champ de saisie de texte où le code peut être tapé.
  • Une validation personnalisée pour vérifier le code saisi.

La principale difficulté ici est le jeton Captcha , que nous devons également stocker dans le champ.

Comme nous avons besoin d'une fonction de saisie de texte pour entrer le code Captcha , nous utilisons le code StringField de WTForms comme base pour notre 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 ""

Nous surchargeons les méthodes :

  • __call__
  • post_validate

Dans notre nouvelle méthode "__call__", nous effaçons le code précédemment saisi, nous générons un nouveau Captcha et nous ajoutons un HTML supplémentaire :

  • Un champ caché pour stocker le jeton.
  • Une balise img pour extraire l'image
  • Un bouton pour rafraîchir l'image

Dans notre nouvelle méthode "post_validate", nous :

  • Récupère le jeton et le code de la requête.
  • Consulter l'enregistrement Captcha de la base de données à l'aide du jeton.
  • Comparer le code avec le code stocké.

Nous créons une table de base de données 'Captcha' avec les champs suivants :

  • jeton
  • code
  • données de l'image

J'ai créé une classe CaptchaUtils() pour créer un Captcha et valider un Captcha.

Une fois ces opérations effectuées, il ne reste plus qu'à ajouter un Captcha à un formulaire :

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

Pour rafraîchir la Captcha, nous appelons le serveur Flask pour générer une nouvelle Captcha. Cet appel renvoie le nouveau :

  • captcha_token
  • captcha_image_url

Sur la page d'abonnement, nous pouvons faire cela avec quelques lignes de 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);
		});
	});
});

Le code

Comme toujours, nous créons un virtual environment. Installer les paquets :

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

Ensuite, nous créons le répertoire 'project' , etc. Voici la structure du répertoire :

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

Et voici les fichiers du projet, le fichier de la base de données SQLite apparaîtra dans le répertoire du projet :

  • 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>

Résumé

Un Captcha est encore très utile aujourd'hui. Ici, nous avons utilisé un paquet Pypi pour générer l'image. Bien que le "flou" semble bon, il est plus vulnérable aux attaques de la machine à cause du code open source . Vous pouvez probablement faire mieux avec votre propre algorithme. Et en utilisant votre propre service Captcha au lieu d'un service Captcha tiers, vous protégez la vie privée de vos visiteurs !

Liens / crédits

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/