Saltar a contenido

Caso de uso - Login

El siguiente proyecto se basa inicialmente en el artículo How To Add Authentication to Your App with Flask-Login.

A partir de él, vamos a crear una aplicación en Flask que ataca a una base de datos de MongoDB para almacenar la información del proyecto PIA Lara.

Esta primera versión de la aplicación únicamente se centra en la gestión de los usuarios, distinguiendo entre los diferentes roles:

  • Administrador: superusuario, puede crear, editar y eliminar todo tipo de usuarios.
  • Técnico: usuario que supervisará a los clientes, el cual, más adelante, puede llegar a crear textos predefinidos para los clientes.
  • Cliente: usuario final de la aplicación que, más adelante, grabará los audios.

Simplicidad

El presente caso de uso se ha organizado para intentar simplificar al máximo el código y preparar un esqueleto que facilite el crecimiento de la aplicación. Aún así, una solución basada en Django o el uso de herramientas de mapeo entre los datos y los objetos del dominio serían un punto de partida para siguientes fases del proyecto.

Estructura

Una vez descargado el proyecto y tras descomprimirlo, o clonado desde https://github.com/aitor-medrano/piafplogin, veremos que tiene la siguiente estructura:

PIAFPLOGIN/
├── migrations/
│   └── user_migration.py
├── project/
│   ├── static/
│   │   ├── pialara.js
│   │   └── pialara.png
│   ├── templates/
│   │   ├── base.html
│   │   ├── index.html
│   │   ├── login.html
│   │   └── profile.html
│   ├── __init__.py
│   ├── auth.py
│   ├── db.py
│   ├── main.py
│   └── models.py
├── .ini
└── requirements.txt

El primer paso es crear un entorno virtual:

python3 -m venv app-env

Y activarlo:

source app-env/bin/activate

A continuación instalaremos las dependencias mediante:

pip3 install -r requirements.txt

Configuración

Para configurar el proyecto, partimos del fichero .ini que reside en la raíz del mismo y contiene los datos de configuración a MongoDB y la clave secreta que utiliza Flask para encriptar la sesión:

.ini
[PROD]
SECRET_KEY = eac5e91171438960ddec0c9c469a4c3dd42e96aea462afc5ab830f78527ad80e
PIALARA_DB_URI = mongodb+srv://usuario:contraseña@cluster0.xyz.mongodb.net
PIALARA_DB_NAME = pialara

[LOCAL]
SECRET_KEY = eac5e91171438960ddec0c9c469a4c3dd42e96aea462afc5ab830f78527ad80e
PIALARA_DB_URI = localhost
PIALARA_DB_NAME = pialara

Secret Key

Para generar una clave secreta, tal como indica la documentación de Flask, podemos ejecutar el siguiente comando:

python3 -c 'import secrets; print(secrets.token_hex())'

Una vez ya hemos configurado la conexión y antes de arrancar la aplicación, vamos a cargar unos datos básicos con usuarios. Para ello, en la carpeta migrations tenemos el archivo users_migration.py, el cual lee la configuración del archivo anterior, y crea tres usuarios:

users_migration.py
from pymongo import MongoClient
from werkzeug.security import generate_password_hash
import os
import configparser

config = configparser.ConfigParser()
config.read(os.path.abspath(os.path.join(".ini")))

DB_URI = config['PROD']['PIALARA_DB_URI']
DB_NAME = config['PROD']['PIALARA_DB_NAME']
# DB_URI = config['LOCAL']['PIALARA_DB_URI']
# DB_NAME = config['LOCAL']['PIALARA_DB_NAME']

db = MongoClient(DB_URI)[DB_NAME]

usuarios = [
    {"nombre":"Admin", "email":"admin@admin.com", "password":generate_password_hash("admin", method='sha256'), "rol":"Administrador"},
    {"nombre":"Alumno", "email":"alumno@alumno.com", "password":generate_password_hash("alumno", method='sha256'), "rol":"Técnico"},
    {"nombre":"Severo Ochoa", "email":"s8a@s8a.com", "password":generate_password_hash("s8a", method='sha256'), "rol":"Cliente", "parent":"alumno@alumno.com"},
]

try:
    db.users.insert_many(usuarios)
except Exception as e:
    print(e)

Destacar que al definir los documentos con los usuarios, encriptamos la contraseña mediante la función generate_password_hash para no almacenarla en la base de datos en texto plano.

Así pues, ejecutamos la migración:

python3 migrations/users_migration.py

Finalmente podemos arrancar la aplicación:

flask --app project --debug run

Y acceder a la aplicación a través de http://127.0.0.1:5000/:

PIA Lara - Index

PIA Lara - Index en móvil

Una vez que un usuario ha entrado a la aplicación, por ejemplo, si es un administrador, dispondrá de las opciones que hemos comentado anteriormente:

PIA Lara - Administrador

Blueprints

El archivo .ini que hemos configurado previamente define unos valores que la aplicación va a cargar desde el archivo __init__.py, el cual actúa como factoría de la aplicación y le indica a Flask los blueprints a utilizar:

__init__.py
from flask import Flask
from flask_login import LoginManager
from . import db

import os
import configparser

config = configparser.ConfigParser()
config.read(os.path.abspath(os.path.join(".ini")))

def create_app():
    app = Flask(__name__)

    # cargamos la configuración
    app.config['PIALARA_DB_URI'] = config['LOCAL']['PIALARA_DB_URI']
    app.config['PIALARA_DB_NAME'] = config['LOCAL']['PIALARA_DB_NAME']
    app.config['SECRET_KEY'] = config['LOCAL']['SECRET_KEY']

    # configuramos flask-login con la ruta del login
    login_manager = LoginManager()
    login_manager.login_view = 'auth.login'
    login_manager.init_app(app)

    # función que utiliza flask-login para recuperar el usuario
    @login_manager.user_loader
    def load_user(user_id):
        return db.get_user_by_id(user_id)

    # blueprint para las rutas de autenticación
    from .auth import auth as auth_blueprint
    app.register_blueprint(auth_blueprint)

    # blueprint para la aplicación
    from .main import main as main_blueprint
    app.register_blueprint(main_blueprint)

    return app

Un blueprint permite organizar un grupo de vistas y código en módulos. En vez de registrar las vistas y el resto de código en la aplicación, se registran en el blueprint, y éste es que se registra en la aplicación en la función create_app.

En nuestro caso, vamos a empezar con dos blueprints, uno para las funciones de autenticación, y otra para las funciones de gestión de usuarios. El código de cada blueprint irá en un módulo separado. Como la gestión de usuarios necesita primero la autenticación, vamos a ver cómo funciona.

Login

Para la gestión de la autenticación, nos hemos apoyado en la librería Flask-login que facilita la gestión la sesión del usuario.

En archivo auth.py contiene la lógica del login y el logout:

auth.py
from flask import Blueprint, render_template, redirect, url_for, request, flash
from flask_login import login_user, login_required, logout_user, current_user
from werkzeug.security import check_password_hash
from . import db

auth = Blueprint('auth', __name__)

@auth.route('/login')
def login():
    return render_template('login.html')

@auth.route('/login', methods=['POST'])
def login_post():
    email = request.form.get('email')
    password = request.form.get('password')
    remember = True if request.form.get('remember') else False

    user = db.get_user(email)
    # comprobamos si el usuario existe
    # cogemos la contraseña, la hasheamos y la comparamos con la contraseña hasheada
    if not user or not check_password_hash(user.password, password):
        flash('Por favor, comprueba tus datos y vuélvelo a intentar.')
        # si el usuario no existe, o está mal la contraseña, recargamos la página
        return redirect(url_for('auth.login')) 

    # marcamos al usuario como autenticado en flask_login 
    login_user(user, remember=remember) 
    return redirect(url_for('main.profile', nombre = current_user.nombre))

@auth.route('/logout')
@login_required
def logout():
    logout_user()
    flash('Sesión cerrada con éxito')
    return redirect(url_for('auth.login'))

Los usuarios van a entrar al sistema mediante su email y una contraseña. Así pues, una vez hayamos recuperado un usuario por dicho email, creamos el hash de la contraseña recibida, y vemos si comprueba con la recuperada de la base de datos.

El método login_user de la línea 27 pertenece a la librería Flask-Login y se utiliza para indicar que el usuario ha sido autenticado, de manera que lo almacena en la sesión. La variable user es una clase propia que hemos definido nosotros con los atributos básicos de un usuario,el cual se encuentra en el archivo models.py:

models.py
from flask_login import UserMixin

class User(UserMixin):
    def __init__(self, id, email, nombre, password, rol, parent = ""):
        self.id = id
        self.email = email
        self.nombre = nombre
        self.password = password
        self.rol = rol
        self.parent = parent

    def __str__(self):
        return f"{self.email} ({self.nombre} / {self.password})"

Como se puede observar, la clase define los atributos básicos de un usuario. El atributo parent lo vamos a emplear para que los clientes almacenen el email del técnico que tienen asignado.

Plantillas

Las diferentes plantillas heredan de una plantilla base.html, la cual emplea el framework Bulma para la apariencia de la web. Su funcionamiento es muy similar a Bootstrap.

Por ejemplo, vamos a revisar un fragmento de la plantilla base para ver cómo gestionamos la visualización del menú dependiendo del rol del usuario:

base.html
...
<section class="hero is-white is-fullheight">
    <nav class="navbar is-transparent">
        <div class="navbar-brand">
            <a class="navbar-item" href="https://piafplara.es">
            <img src="{{ url_for('static', filename='pialara.png') }}" alt="PIA Lara: un proyecto que habla por ti" width="112" height="28">
            </a>
            <div class="navbar-burger burger" data-target="navbarPIALara">
            <span></span>
            <span></span>
            <span></span>
            </div>
        </div>

        <div id="navbarPIALara" class="navbar-menu">
            <div class="navbar-start">
            <a href="{{ url_for('main.index') }}" class="navbar-item">
                Inicio
            </a>
            {% if not current_user.is_authenticated %}
                <a href="{{ url_for('auth.login') }}" class="navbar-item">
                    Login
                </a>
            {% endif %}
            {% if current_user.is_authenticated %}
                <a href="{{ url_for('main.profile') }}" class="navbar-item">
                    Perfil
                </a>
                {% if current_user.rol == "Administrador" %}
                <div class="navbar-item has-dropdown is-hoverable">
                <a class="navbar-link" href="#">
                    Usuarios
                </a>
                <div class="navbar-dropdown is-hidden-mobile is-boxed">
                    <a class="navbar-item" href="{{ url_for('main.user_create') }}">
                    Alta
                    </a>
                    <a class="navbar-item" href="{{ url_for('main.user_list') }}">
                    Listado
                    </a>
                </div>
                </div>
                {% endif %}         
...

En la línea 6 utilizamos la función url_for con el parámetro static para indicarle que cargue la imagen con el logo del proyecto desde la carpeta static.

Al utilizar la librería Flask Login, tendremos siempre disponible el usuario logueado en la variable current_user. Además de las propiedades que hayamos definido en la clase, disponemos de la función is_authenticated para comprobar si está autenticado (línea 20). De igual forma, podemos comprobar el rol y condicionar el contenido dependiendo de si es Administrador, Técnico o Cliente (línea 24).

Acceso a los datos

Todo el acceso a los datos los hemos encapsulado en el archivo db.py:

db.py
from pymongo import MongoClient
from bson.objectid import ObjectId
from pymongo import ASCENDING
from flask import current_app, g
from werkzeug.local import LocalProxy
from project.models import User

def get_db():
    """
    Método de configuración para obtener una instancia de db
    """
    db = getattr(g, "_database", None)

    PIALARA_DB_URI = current_app.config["PIALARA_DB_URI"]
    PIALARA_DB_DB_NAME = current_app.config["PIALARA_DB_NAME"]

    if db is None:
        db = g._database = MongoClient(
            PIALARA_DB_URI,
            maxPoolSize=50,
            timeoutMS=2500
       )[PIALARA_DB_DB_NAME]
    return db

# Utilizamos LocalProxy para leer la variable global usando sólo db
db = LocalProxy(get_db)

La función get_db utiliza el objeto g, el cual en Flask, es un objeto especial que es único para cada petición. Se utiliza para almacenar datos que serán accesibles desde múltiples funciones durante el request. Así pues, almacenamos la conexión, mejor dicho, el pool de conexiones a MongoDB en vez de crear un nuevo pool cada vez que queramos obtener acceso a la base de datos.

A continuación, creamos un LocalProxy para leer la variable global usando sólo la referencia db, de manera que internamente cada referencia a db realmente está llamando a get_db().

A continuación, mostramos un par de métodos del mismo archivo que muestran cómo obtenemos datos desde MongoDB haciendo uso de PyMongo:

db.py
def get_all_users():
    """
    Devuelve una lista con todos los usuarios del sistema
    """
    try:
        return list(db.users.find({}).sort("nombre", ASCENDING))
    except Exception as e:
        return e

def get_user_by_id(id):
    """
    Devuelve un objeto User a partir de su id
    """
    try:
        usuario = db.users.find_one({"_id":ObjectId(id)})

        usuario_obj = User( id=usuario["_id"],
                            email=usuario.get("email"),
                            nombre=usuario.get("nombre"),
                            password=usuario.get("password"),
                            rol=usuario.get("rol"),
                            parent=usuario.get("parent"))

        return usuario_obj
    except Exception as e:
        return e
...

Cuando recuperamos un usuario por su id, lo convertimos en un objeto User para que Flask Login falicita la gestión de la autenticación. En cambio, en el listado de todos los usuarios, vamos a acceder al cursor de usuarios que ofrece MongoDB.

Referencias

Actividades

  1. (RASBD.3 / CESBD.3d / 4p) A partir del caso de uso, se pide:

    • (0.25) Configurar la URI de Mongo Atlas para atacar vuestra propia base de datos.
    • (0.25) Modificar la migración para introducir más usuarios (al menos uno más de cada rol)
    • (0.75) Cuando un usuario pulsa sobre su nombre, actualmente aparece un formulario para editar sus datos, pero no puede cambiar la contraseña. Modifica (o crea) el/los formulario/s adecuado/s para que cada usuario pueda cambiar su propia contraseña.
    • (0.75) Desde el rol Administrador, al crear un usuario, si es un cliente, debe mostrar un desplegable con todos los técnicos disponibles.
    • (1) Tanto el Técnico como el Cliente, al dar de alta o editar un cliente, almacenarán datos necesarios para el proyecto, como son el sexo, la fecha de nacimiento y la patología.
    • (1) Cuando un Técnico visualiza el listado de sus clientes, debe recuperar únicamente el nombre, el sexo, la edad y su patología.