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

Creación de un Captcha con Flask, WTForms, SQLAlchemy, SQLite

Respete la privacidad de sus visitantes, ¡no los conecte a un servicio Captcha de terceros!

10 abril 2023
post main image

En el pasado escribí algo de código para un (sólo texto) Captcha para un sitio web Flask . Esto es una actualización. Aquí utilizo un paquete Pypi para generar la imagen. Además también añadí un botón de actualización de imagen Captcha .
Puedes probar el código de abajo, es un formulario de suscripción (newsletter). Aunque esta es una solución Captcha para un sitio web Flask , se puede convertir en un servidor Captcha .

Tenga en cuenta que utilizo SQLite aquí sólo con fines de demostración. No utilice SQLite en un entorno multiuser .

Descripción

Nuestra aplicación tiene un formulario de suscripción (boletín de noticias), donde el visitante puede escribir su dirección de correo electrónico. Este formulario está protegido por un Captcha.

Cada vez que se muestra el formulario, se generan los datos del Captcha (token, código, imagen) y se almacenan en una base de datos. Cuando se envía el formulario, utilizamos el token para recuperar los datos Captcha y comparamos el código introducido por el visitante con el código almacenado en la base de datos.

Además, junto a la imagen hay un botón de "actualización". Al pulsarlo, se muestra una nueva imagen.

En la página de suscripción HTML también utilizamos:

  • Bootstrap
  • JQuery

El botón de actualización Captcha utiliza un icono Bootstrap .

The CaptchaField

Requisitos para nuestro nuevo CaptchaField:

  • Un token único, en un campo oculto
  • Una etiqueta img que extraiga la imagen del servidor.
  • Un campo de entrada de texto donde se puede escribir el código.
  • Validación personalizada para comprobar el código introducido.

La principal dificultad aquí es el token Captcha , debemos almacenarlo también en el campo.

Como necesitamos una función de entrada de texto para introducir el código Captcha , utilizamos el código StringField de WTForms como base para nuestro 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 ""

Reemplazamos los métodos

  • __call__
  • post_validate

En nuestro nuevo método '__call__', borramos el código introducido anteriormente, generamos un nuevo Captcha y luego añadimos HTML extra:

  • Un campo oculto para almacenar el token.
  • Una etiqueta img para extraer la imagen
  • Un botón para actualizar la imagen

En nuestro nuevo método 'post_validate':

  • Recuperamos el token y el código de la solicitud.
  • Buscamos el registro Captcha en la base de datos utilizando el token.
  • Comparamos el código con el código almacenado.

Creamos una tabla en la base de datos 'Captcha' con los siguientes campos:

  • token
  • código
  • datos de imagen

He creado una clase CaptchaUtils() para crear un Captcha y validar un Captcha.

Una vez hecho lo anterior, lo único que tenemos que hacer cuando queramos añadir un Captcha a un formulario:

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

Para actualizar el Captcha, llamamos al servidor Flask para que genere un nuevo Captcha. Esta llamada devuelve el nuevo

  • captcha_token
  • captcha_image_url

En la página de suscripción podemos hacer esto con unas pocas líneas 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);
		});
	});
});

El código

Como siempre, creamos un virtual environment. Instalamos los paquetes:

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

A continuación, creamos el directorio 'project' , etc. Esta es la estructura de directorios:

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

Y aquí están los archivos del proyecto, el archivo de la base de datos SQLite aparecerá en el directorio del proyecto:

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

Resumen

Un Captcha sigue siendo muy útil hoy en día. Aquí hemos utilizado un paquete de Pypi para generar la imagen. Aunque el 'desenfoque' se ve bien, es más vulnerable a ataques de máquina debido al código open source . Probablemente puedas hacerlo mejor con tu propio algoritmo. Y al utilizar su propio Captcha en lugar de un servicio Captcha de terceros, ¡protege la privacidad de sus visitantes!

Enlaces / créditos

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/