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

Een Captcha maken met Flask, WTForms, SQLAlchemy, SQLite

Respecteer de privacy van uw bezoekers, verbind ze niet met een dienst van derden Captcha !

10 april 2023
post main image

In het verleden heb ik wat code geschreven voor een (text-only) Captcha voor een Flask website. Dit is een update. Hier gebruik ik een Pypi pakket om de afbeelding te genereren. Daarnaast heb ik ook een Captcha image refresh button toegevoegd.
Je kunt onderstaande code proberen, het is een (nieuwsbrief) inschrijfformulier. Hoewel dit een Captcha oplossing is voor een Flask website, kan het worden omgezet in een Captcha server.

Merk op dat ik SQLite hier alleen voor demonstratiedoeleinden gebruik. Gebruik SQLite niet in een multi-user omgeving!

Beschrijving

Onze applicatie heeft een (nieuwsbrief) inschrijf-formulier, waar de bezoeker zijn email adres kan intypen. Dit formulier wordt beschermd door een Captcha.

Telkens wanneer het formulier wordt getoond, wordt de Captcha data (token, code, afbeelding) gegenereerd en opgeslagen in een database. Wanneer het formulier wordt verzonden, gebruiken wij het token om de Captcha gegevens op te halen, en vergelijken wij de door de bezoeker ingetypte code met de in de database opgeslagen code.

Daarnaast is er een 'ververs'-knop naast de afbeelding. Wanneer daarop wordt geklikt, wordt een nieuwe afbeelding getoond.

Op de inschrijvingspagina HTML gebruiken we ook:

  • Bootstrap
  • JQuery

De Captcha verversingsknop gebruikt een Bootstrap pictogram.

The CaptchaField

Vereisten voor ons nieuwe CaptchaField:

  • Een uniek token, in een verborgen veld
  • Een img-tag die de afbeelding van de server haalt.
  • Een tekstinvoerveld waar de code kan worden ingetypt.
  • Aangepaste validatie om de getypte code te controleren.

De grootste moeilijkheid hier is het token Captcha , dat we ook in het veld moeten opslaan.

Omdat we een tekstinvoerfunctie nodig hebben om de Captcha code in te voeren, gebruiken we de StringField code uit WTForms als basis voor ons 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 ""

We overschrijven de methoden:

  • __call__
  • post_valideren

In onze nieuwe '__call__'-methode wissen we de eerder ingevoerde code, genereren we een nieuwe een Captcha en voegen dan extra HTML toe:

  • Een verborgen veld om het token op te slaan.
  • Een img-tag om de afbeelding op te halen
  • Een knop om de afbeelding te verversen

In onze nieuwe 'post_validate' methode:

  • Het token en de code ophalen uit het verzoek.
  • De database Captcha record opzoeken met behulp van het token.
  • Vergelijken de code met de opgeslagen code.

We maken een databasetabel "Captcha" aan met de volgende velden:

  • token
  • code
  • beeldgegevens

Ik heb een klasse CaptchaUtils() gemaakt om een Captcha aan te maken en een Captcha te valideren.

Zodra het bovenstaande is gedaan, hoeven we alleen nog maar te doen wanneer we een Captcha aan een formulier willen toevoegen:

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

Om de Captcha te vernieuwen, roepen we de Flask -server aan om een nieuwe Captcha te genereren. Deze oproep geeft de nieuwe:

  • captcha_token
  • captcha_image_url

Op de inschrijfpagina kunnen we dit doen met een paar regels 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);
		});
	});
});

De code

Zoals altijd maken we een virtual environment aan. Installeer de pakketten:

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

Vervolgens maken we de map 'project' aan, enz. Hier is de directory-structuur:

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

En hier zijn de bestanden in het project, het databasebestand SQLite komt in de projectdirectory:

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

Samenvatting

Een Captcha is vandaag de dag nog steeds erg nuttig. Hier hebben we een Pypi-pakket gebruikt om de afbeelding te genereren. Hoewel de 'blurring' er goed uitziet, is het kwetsbaarder voor machine-aanvallen vanwege de open source code. U kunt het waarschijnlijk beter doen met uw eigen algoritme. En door uw eigen geserveerde Captcha te gebruiken in plaats van een Captcha dienst van derden, beschermt u de privacy van uw bezoekers!

Links / credits

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/