Saltar a contenido

Lab guiado: Autenticación y autorización con Flask

  • Unidad: UT4 — Interfaz web con Flask
  • Sesión: Fase 7 — Autenticación y autorización: tabla usuarios, repositorio paralelo, sesión Flask, decorador @login_required casero.
  • Agrupamiento: Individual o en parejas conductor/navegante.
  • Recursos:
  • Ficheros de trabajo:
    • expendedora/crear_bd.py
    • expendedora/infrastructure/repositorio_usuarios_sqlite.py (nuevo)
    • expendedora/infrastructure/datos_iniciales.py
    • expendedora/presentation/app.py
    • expendedora/presentation/templates/login.html (nuevo)
    • expendedora/presentation/templates/base.html
    • CHANGELOG.md, README.md, docs/EJECUCION.md

Punto de partida

Parte del zip que entregaste al final del lab a6. Antes de empezar:

  1. Activa el entorno virtual del proyecto.
  2. Sitúate en la carpeta padre de expendedora/.
  3. Si la BD expendedora.db no existe, créala con python expendedora/crear_bd.py.
  4. Arranca el servidor: python -m expendedora.presentation.app.
  5. Comprueba que la app de a6 sigue funcionando: navega a /productos, agrega un producto desde /agregar, elimina uno desde /eliminar/<codigo>, observa los mensajes flash. Comprueba también que curl http://localhost:5000/api/productos devuelve JSON.

Para el resto del lab necesitarás dos terminales abiertas: una con el servidor arrancado y otra para ejecutar comandos (python expendedora/crear_bd.py, curl, sqlite3). Si tu sistema no tiene sqlite3 instalado, puedes saltar las comprobaciones que lo usan o instalarlo (sudo apt install sqlite3 en Ubuntu).

Sobre este lab

Hasta a6 la app es pública: cualquiera que llegue al servidor puede dar de alta, eliminar o reponer productos. En esta fase añadimos autenticación (¿quién eres?) y autorización (¿qué puedes hacer?). El reto no es solo técnico: es arquitectónico. Si la promesa de las capas se sostiene, añadir login solo debería tocar infrastructure/ (un fichero nuevo) y presentation/. domain/ y ServicioExpendedora no deberían enterarse. Al final del lab harás auditoría de qué tocaste y qué no — esa misma observación es la base de la Fase 8.


Antes de empezar: repasemos los conceptos

Autenticación vs autorización Autenticación responde a "¿quién eres?": el usuario teclea nombre y contraseña, el servidor verifica y guarda la respuesta en algún sitio. Autorización responde a "¿puedes hacer esto?": cada ruta protegida comprueba si el visitante actual tiene permiso. Son dos cosas distintas que a menudo se mezclan; aquí las separamos: la autenticación pasa una sola vez en /login, la autorización pasa en cada petición dentro de un decorador.

Hash ≠ cifrado Una contraseña no se guarda nunca en claro. Tampoco se cifra (porque cifrar es reversible: con la clave correcta sacas el texto original). Se guarda su hash: una huella unidireccional. Para verificar, no se descifra el hash — se vuelve a hashear lo que teclea el usuario y se compara. werkzeug.security viene con Flask y trae las dos funciones que necesitas: generate_password_hash y check_password_hash.

Sesión Flask La sesión es una cookie firmada por el servidor que viaja en cada petición del navegador. Sirve para recordar entre peticiones quién está logueado: tras un login exitoso guardas session['user'] = nombre y todas las rutas siguientes pueden consultar session.get('user'). Sobrevive a recargas y a cerrar/abrir pestañas; no sobrevive a session.clear() (logout) ni necesariamente a reiniciar el servidor si cambias app.secret_key.

Decorador @login_required Un decorador es una función que envuelve a otra para modificar su comportamiento. Ya viste decoradores en UT1 y los usamos en a3 para los manejadores de error (@app.errorhandler). Aquí escribiremos uno casero —@login_required— que antes de ejecutar una ruta comprueba si hay sesión y, si no, redirige a /login.


Paso 1 — Schema usuarios y semilla con hash crear_bd.py

Conceptos

Añadimos una segunda tabla a expendedora.db para guardar credenciales. La pareja (nombre, password_hash) es lo mínimo; añadimos también rol y activo para dejar el schema preparado por si más adelante quieres distinguir tipos de usuario o deshabilitar una cuenta sin borrarla. En este lab solo usaremos nombre, password_hash y activo; rol queda como dato dormido.

La función generate_password_hash de werkzeug.security que usaremos para hashear la contraseña añade un salt aleatorio distinto en cada llamada — eso significa que si la ejecutas dos veces con la misma contraseña, obtienes dos hashes distintos. No es un bug: es una protección contra ataques precalculados. Lo verás en la pregunta del final del paso.

Objetivo

Crear la tabla usuarios y sembrar una cuenta admin con contraseña hasheada al ejecutar crear_bd.py.

Tarea

Abre expendedora/crear_bd.py. Añade la nueva tabla después de la tabla descuentos (entre la última cursor.execute("""CREATE TABLE ... descuentos ...""") y la primera inserción de productos):

1
2
3
4
5
6
7
8
9
cursor.execute("""
    CREATE TABLE IF NOT EXISTS usuarios (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        nombre TEXT UNIQUE NOT NULL,
        password_hash TEXT NOT NULL,
        rol TEXT NOT NULL DEFAULT 'admin',
        activo INTEGER NOT NULL DEFAULT 1
    )
""")

Al final del fichero, antes del conn.close(), añade el bloque que crea el usuario admin:

1
2
3
4
5
6
7
8
9
from werkzeug.security import generate_password_hash

hash_admin = generate_password_hash('admin')
cursor.execute(
    "INSERT INTO usuarios (nombre, password_hash, rol) VALUES (?, ?, ?)",
    ('admin', hash_admin, 'admin')
)
conn.commit()
print(f"\nUsuario 'admin' creado. Hash: {hash_admin}")

Re-ejecuta el script para recrear la BD desde cero:

python expendedora/crear_bd.py

Verás impreso un hash largo del tipo scrypt:32768:8:1$AbCdEf.... Comprueba con sqlite3 que la tabla está poblada:

sqlite3 expendedora/expendedora.db "SELECT id, nombre, substr(password_hash, 1, 20) || '...', rol, activo FROM usuarios;"

Deberías ver una fila: 1|admin|scrypt:32768:8:1$AbC...|admin|1.

Mala práctica: guardar contraseñas en claro

Si en la BD aparece literalmente admin en lugar de un hash, cualquiera que abra el fichero ya tiene la contraseña. Y no solo eso: muchos usuarios reutilizan contraseñas entre servicios, así que filtrar una BD con contraseñas en claro compromete cuentas en sitios que ni siquiera son tuyos. Hashear cuesta una línea y elimina el problema.

Mala práctica: usar hash() de Python

La función incorporada hash() no sirve para contraseñas. Primero, su valor cambia entre arranques del intérprete (Python 3.3+ usa un seed aleatorio por proceso para hash()). Segundo, está optimizada para diccionarios y sets, no para resistir ataques: no tiene salt, es rapidísima y por tanto fácil de atacar por fuerza bruta. werkzeug.security.generate_password_hash usa scrypt con salt aleatorio, sí pensado para esto.

Pregunta

Vuelve a ejecutar python expendedora/crear_bd.py por segunda vez sin cambiar nada del código. ¿El hash impreso es el mismo o cambia? ¿Por qué? ¿Cómo es posible entonces que después check_password_hash reconozca la contraseña admin en ambos casos?

Respuesta

El hash cambia cada vez. generate_password_hash añade un salt aleatorio distinto en cada llamada, y ese salt forma parte del string final (es lo que va después del primer $). Por eso dos contraseñas iguales producen hashes distintos — esto evita que un atacante use tablas pre-calculadas (rainbow tables) para reventar la BD de golpe. Cuando luego llamamos a check_password_hash(hash, 'admin'), la función lee el salt del propio hash, lo aplica a 'admin' y compara. No necesita "deshashear" nada — solo repetir el proceso con el salt correcto.

Comprobación

  • python expendedora/crear_bd.py termina sin errores e imprime el hash al final.
  • sqlite3 expendedora/expendedora.db ".schema usuarios" muestra la definición de la tabla.
  • sqlite3 expendedora/expendedora.db "SELECT COUNT(*) FROM usuarios;" devuelve 1.

Paso 2 — Repositorio paralelo de usuarios infrastructure/repositorio_usuarios_sqlite.py

Conceptos

Vamos a crear un repositorio nuevo siguiendo exactamente el mismo patrón que RepositorioProductosSQLite: una clase con constructor que recibe la ruta a la BD, métodos que abren conexión, ejecutan SQL y cierran. Es deliberadamente paralelo: añadir un fichero nuevo en infrastructure/ sin tocar los existentes es exactamente lo que la arquitectura por capas autoriza.

Objetivo

Crear RepositorioUsuariosSQLite con dos métodos: buscar_por_nombre y existe. Solo lo que el login necesita.

Tarea

Crea el fichero expendedora/infrastructure/repositorio_usuarios_sqlite.py con este contenido:

"""Repositorio de usuarios con persistencia en SQLite."""

import sqlite3

from expendedora.infrastructure.errores import ErrorPersistencia


class RepositorioUsuariosSQLite:
    def __init__(self, ruta_bd):
        self._ruta_bd = ruta_bd

    def buscar_por_nombre(self, nombre):
        conn = sqlite3.connect(self._ruta_bd)
        try:
            cursor = conn.cursor()
            cursor.execute(
                "SELECT id, nombre, password_hash, rol, activo "
                "FROM usuarios WHERE nombre = ?",
                (nombre,)
            )
            return cursor.fetchone()
        except sqlite3.OperationalError as e:
            raise ErrorPersistencia(f"Error al leer usuario: {e}")
        finally:
            conn.close()

    def existe(self, nombre):
        return self.buscar_por_nombre(nombre) is not None

Sin tipado en las firmas

Fíjate que las firmas son def buscar_por_nombre(self, nombre):, no def buscar_por_nombre(self, nombre: str) -> tuple | None:. El resto del proyecto expendedora no usa anotaciones de tipo y mezclar estilos confunde. Si en tu proyecto personal decides usarlas, hazlo de forma consistente.

Por qué solo dos métodos

No hay crear_usuario, cambiar_password, eliminar_usuario, listar_usuarios. Solo buscar_por_nombre (para el login) y existe (helper que veremos cuando lo necesitemos). Construimos solo lo que el lab necesita. Cuando aparezca el caso de uso "alta de usuarios desde la web", añadiremos el método; mientras tanto, sembramos en crear_bd.py y listo.

Pregunta

En application/servicios.py tenemos ServicioExpendedora que recibe la MaquinaExpendedora y delega en ella. ¿Por qué este nuevo repositorio de usuarios no se inyecta en ServicioExpendedora siguiendo el mismo patrón? ¿Qué romperíamos si lo hiciéramos?

Respuesta

ServicioExpendedora modela la lógica de venta de productos: seleccionar, insertar, comprar, reponer. Esa es su responsabilidad. Los usuarios no forman parte del dominio de la expendedora — son una preocupación de la capa de presentación (¿quién está usando la interfaz web ahora mismo?). Si metiéramos RepositorioUsuariosSQLite dentro del servicio, estaríamos diciendo "la expendedora sabe de cuentas y contraseñas", lo cual es falso: la expendedora no sabe nada de eso. La pista la tenemos en menu.py, la otra interfaz: la consola no necesita login para usarse, y sin embargo usa el mismo servicio. Si el servicio supiera de auth, menu.py también tendría que pedir login — y no tiene sentido. Por eso la auth vive entera entre infrastructure/ (nuevo repositorio) y presentation/ (rutas y decorador). Esto es exactamente lo que la auditoría de a8 va a comprobar.

Comprobación

  • El fichero expendedora/infrastructure/repositorio_usuarios_sqlite.py existe.
  • Desde la carpeta padre del paquete, abre un REPL de Python y verifica que el repositorio funciona:
>>> from expendedora.infrastructure.repositorio_usuarios_sqlite import RepositorioUsuariosSQLite
>>> repo = RepositorioUsuariosSQLite('expendedora/expendedora.db')
>>> repo.buscar_por_nombre('admin')
(1, 'admin', 'scrypt:32768:8:1$...', 'admin', 1)
>>> repo.buscar_por_nombre('inexistente')
>>> repo.existe('admin')
True
>>> repo.existe('inexistente')
False

Paso 3 — Helper de ruta, rutas /login y /logout, plantilla login.html datos_iniciales.py, app.py, templates/login.html

Conceptos

Necesitamos instanciar el repositorio de usuarios en app.py. Para crearlo hace falta la ruta a la BD — la misma que ya usa crear_servicio_sqlite(). Esa ruta vive como constante privada en datos_iniciales.py. Tenemos que decidir cómo app.py la obtiene sin duplicar el cálculo ni romper el encapsulamiento.

Objetivo

Añadir un helper público para la ruta de la BD, crear las rutas /login y /logout, y la plantilla del formulario.

Tarea — Sub-paso 3a: Helper ruta_bd_por_defecto()

Abre expendedora/infrastructure/datos_iniciales.py. Justo después de la definición de _RUTA_BD_POR_DEFECTO, añade esta función:

def ruta_bd_por_defecto():
    return str(_RUTA_BD_POR_DEFECTO)

Por qué hace falta este helper (léelo, no te lo saltes)

La ruta de expendedora.db ya la conoce datos_iniciales.py — la guarda en la constante _RUTA_BD_POR_DEFECTO y la usa internamente cuando llamas a crear_servicio_sqlite(). El guion bajo del nombre indica que es privada: pensada para uso interno del módulo. ¿Por qué no la usamos directamente desde app.py? Porque ese guion bajo es un acuerdo entre programadores: "esto puede cambiar sin avisar". Si mañana renombramos la constante, todo lo que la usaba por fuera se rompe en silencio.

La alternativa es duplicar el cálculo en app.py: Path(__file__).resolve().parent.parent / 'expendedora.db'. Pero entonces dos sitios distintos del proyecto saben dónde vive la BD; si mueves el fichero, tienes que cambiarlo en los dos sitios. Mal trato.

La solución limpia es publicar una función pública, ruta_bd_por_defecto(), que devuelve la ruta como string. Sin guion bajo: forma parte del contrato del módulo. datos_iniciales.py sigue siendo el único que sabe cómo se calcula la ruta, y app.py solo pide "dame la ruta".

Tarea — Sub-paso 3b: Imports y repositorio en app.py

Abre expendedora/presentation/app.py. Modifica el import de Flask para incluir session:

from flask import Flask, redirect, url_for, request, render_template, flash, jsonify, session

Modifica el import de datos_iniciales para incluir el helper nuevo, y añade los dos imports siguientes:

1
2
3
from expendedora.infrastructure.datos_iniciales import crear_servicio_sqlite, ruta_bd_por_defecto
from expendedora.infrastructure.repositorio_usuarios_sqlite import RepositorioUsuariosSQLite
from werkzeug.security import check_password_hash

Justo debajo de la línea servicio = crear_servicio_sqlite(), instancia el repositorio de usuarios:

servicio = crear_servicio_sqlite()
repositorio_usuarios = RepositorioUsuariosSQLite(ruta_bd_por_defecto())

Tarea — Sub-paso 3c: Rutas /login y /logout

Añade estas dos rutas en app.py. Por orden y legibilidad, ponlas antes del bloque de rutas de la API (@app.route('/api/productos')):

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        nombre = request.form.get('nombre', '').strip()
        password = request.form.get('password', '')
        # fila tiene la forma (id, nombre, password_hash, rol, activo)
        fila = repositorio_usuarios.buscar_por_nombre(nombre)
        if fila and fila[4] == 1 and check_password_hash(fila[2], password):
            session['user'] = fila[1]
            flash(f"Bienvenido, {fila[1]}.", 'exito')
            destino = request.args.get('siguiente') or url_for('inicio')
            return redirect(destino)
        return render_template('login.html', error='Credenciales no válidas.', nombre=nombre), 401
    return render_template('login.html', error=None, nombre='')


@app.route('/logout', methods=['POST'])
def logout():
    session.pop('user', None)
    flash('Sesión cerrada.', 'info')
    return redirect(url_for('inicio'))

Lectura por posición de la tupla del repositorio

fila[4] es activo y fila[2] es password_hash porque el SELECT del repositorio devuelve las columnas en el orden (id, nombre, password_hash, rol, activo). Es código posicional y frágil — si cambias el orden del SELECT, los índices dejan de cuadrar. En proyectos grandes preferirás sqlite3.Row o un dataclass. Aquí lo mantenemos así por coherencia con el resto del proyecto, que también lee tuplas posicionales.

session.pop('user', None) vs del session['user']

pop con un valor por defecto (None) elimina la clave si existe y no falla si no existe. del session['user'] levantaría KeyError si el usuario ya no estaba (por ejemplo, si alguien pulsa "Cerrar sesión" dos veces seguidas o la cookie ya se había vaciado). El patrón dict.pop(clave, default) es la forma idiomática de "elimina si está, ignora si no".

request.args.get('siguiente') vs request.form.get(...)

request.args son los parámetros de URL del estilo ?clave=valor (típicos en GET). request.form es el cuerpo de un POST con Content-Type: application/x-www-form-urlencoded. Cuando alguien intenta entrar a /agregar sin sesión, el decorador (que verás en el Paso 4) redirige a /login?siguiente=/agregar. El siguiente viaja en la URL, no en el cuerpo — por eso lo leemos de request.args. Si no hay siguiente, fallback a inicio. Esto se llama "redirect después de login" y es un patrón estándar.

Devolver una tupla (plantilla, 401)

Hasta a6 los return render_template(...) se devolvían solos: Flask asumía status 200 OK. Aquí devolvemos (plantilla, 401) para forzar el código 401 Unauthorized. Flask acepta esta tupla como respuesta y la traduce a una respuesta HTTP con el status indicado. Sin la tupla, el navegador vería 200 en una página de error — confuso para herramientas como curl, scripts de tests o el propio DevTools.

Mala práctica: logout con GET

/logout solo acepta POST. La regla de a5 es "GET no muta": un GET no debe cambiar nada en el servidor. Cerrar sesión es un cambio. En aplicaciones reales verás <a href="/logout"> con frecuencia — es una concesión a la usabilidad, pero técnicamente incorrecta porque cualquier enlace en una página, un prefetch del navegador o un <img src="/logout"> malicioso podría cerrar la sesión del usuario sin su consentimiento. Aquí mantenemos la regla.

Tarea — Sub-paso 3d: Plantilla login.html

Crea expendedora/presentation/templates/login.html con este contenido:

{% extends "base.html" %}
{% block titulo %}Iniciar sesión — Expendedora{% endblock %}
{% block contenido %}
    <h2>Iniciar sesión</h2>

    {% if error %}
        <p class="error">{{ error }}</p>
    {% endif %}

    <form method="POST">
        <label>Usuario:
            <input type="text" name="nombre" value="{{ nombre }}" required autofocus>
        </label>
        <label>Contraseña:
            <input type="password" name="password" required>
        </label>
        <button type="submit">Entrar</button>
    </form>
{% endblock %}

Conservamos el nombre, no la contraseña

Si el login falla, el campo nombre recupera lo que tecleó el alumno (value="{{ nombre }}"), pero el campo password se vacía. No es solo cuestión de UX: la contraseña no se devuelve nunca al cliente, ni siquiera la que acaba de teclear, porque podría filtrarse a través de logs o caché del navegador.

<form method=\"POST\"> sin action

Fíjate en que el formulario no lleva atributo action. Cuando se omite, el navegador envía al mismo URL en el que está la página, conservando el querystring. Esto es importante: si llegaste a /login?siguiente=/agregar y enviases a /login (sin querystring), el ?siguiente=/agregar se perdería y volverías a la raíz tras hacer login en lugar de a la página que querías.

Pregunta

El mensaje de error es siempre Credenciales no válidas. — exactamente el mismo si tecleas un nombre que no existe o si el nombre existe pero la contraseña es incorrecta. ¿Por qué no decir usuario no encontrado vs contraseña incorrecta? La segunda forma sería más útil para el usuario despistado.

Respuesta

Sería más útil, sí — y por eso es exactamente lo que no hay que hacer. Si distinguiéramos los dos mensajes, un atacante que intente forzar el sistema podría enumerar cuentas válidas probando contraseñas absurdas: cuando vea "contraseña incorrecta" sabe que el usuario sí existe, y se concentra en atacar esa cuenta. Igualando el mensaje cerramos esa fuga de información. El término técnico es username enumeration; verlo de propina al testar sistemas legacy es muy común.

Comprobación

Reinicia el servidor (Ctrl+C y vuelve a arrancar python -m expendedora.presentation.app) para que los imports nuevos se carguen. Después:

  • Navega a http://localhost:5000/login — ves el formulario.
  • Teclea admin / admin → redirect a /, flash Bienvenido, admin. visible. Vuelve atrás y ya no estás en el formulario (porque tienes sesión).
  • Cierra el navegador y vuélvelo a abrir en http://localhost:5000/. La sesión sobrevive (la cookie sigue ahí). Cómo verificar que sigues logueado lo verás en el siguiente paso, cuando añadamos el indicador en la navegación.
  • Teclea admin / mal → re-render con el error y código 401 (lo ves en las DevTools del navegador o ejecutando curl -i -X POST -d "nombre=admin&password=mal" http://localhost:5000/login).
  • Teclea noexiste / loquesea → mismo error, mismo 401.

Paso 4 — Decorador @login_required y aplicación selectiva app.py, base.html

Conceptos

Vamos a escribir un decorador casero. Un decorador es una función que recibe otra función y devuelve una función nueva que la envuelve, añadiendo comportamiento alrededor. En a3 usamos @app.errorhandler(404) — Flask trae ese decorador hecho. Aquí lo escribimos nosotros desde cero porque conceptualmente es un patrón importante de UT1 que conviene revisitar.

Objetivo

Definir @login_required, aplicarlo solo a las rutas de mantenimiento, y mostrar visualmente la sesión en la navegación.

Tarea — Sub-paso 4a: Definición del decorador

En app.py, añade el import al principio del fichero junto a los demás:

from functools import wraps

Y define el decorador antes de cualquier @app.route. Un buen sitio es justo después de la línea app.secret_key = 'cambiar-esto-en-produccion':

1
2
3
4
5
6
7
8
def login_required(f):
    @wraps(f)
    def envoltorio(*args, **kwargs):
        if 'user' not in session:
            flash('Necesitas iniciar sesión para esa acción.', 'error')
            return redirect(url_for('login', siguiente=request.path))
        return f(*args, **kwargs)
    return envoltorio

Anatomía del decorador

login_required recibe una función f (la vista que vamos a proteger) y devuelve envoltorio. Cada vez que llega una petición a una ruta decorada, Flask llama a envoltorio, no a f directamente. envoltorio mira si hay 'user' en la sesión: si no lo hay, redirige al login pasando la ruta original como parámetro siguiente para volver después; si lo hay, llama a f(*args, **kwargs) y devuelve su resultado. Los *args recogen los argumentos posicionales y **kwargs los argumentos por nombre — esa sintaxis "comodín" permite que el mismo envoltorio sirva para rutas sin parámetros (agregar()) y para rutas con uno o varios (eliminar(codigo), reponer(codigo, unidades)); el envoltorio los reenvía a f sin tocarlos.

Tarea — Sub-paso 4b: Aplicar @login_required solo a mantenimiento

Aplica el decorador solo a estas tres rutas, encima de la línea def, debajo de la línea @app.route. La ruta /agregar ya existe completa desde a6 con su cuerpo entero — aquí solo añadimos la línea del decorador, sin tocar nada más:

1
2
3
4
@app.route('/agregar', methods=['GET', 'POST'])
@login_required
def agregar():
    ...  # cuerpo ya existente de a6, no se toca

Lo mismo con /eliminar/<codigo>:

1
2
3
4
@app.route('/eliminar/<codigo>', methods=['GET', 'POST'])
@login_required
def eliminar(codigo):
    ...

Y /reponer/<codigo>/<int:unidades>:

1
2
3
4
@app.route('/reponer/<codigo>/<int:unidades>')
@login_required
def reponer(codigo, unidades):
    ...

Lo que NO debes proteger

/insertar, /seleccionar, /comprar, /cancelar siguen públicas. Cualquiera puede comprar en la expendedora: el usuario llega, mete monedas, elige producto y se va. Lo que requiere identidad es el mantenimiento de la máquina (alta de productos, eliminación, reposición de stock). Es la diferencia entre cliente y operario: el cliente usa, el operario administra.

Tampoco protegemos /api/productos ni /api/producto/<codigo> — siguen públicas. Son endpoints de lectura, sin efecto en el servidor. Si en algún momento añadieras una API REST de escritura (POST /api/productos, DELETE /api/producto/<codigo>), esas sí llevarían @login_required y devolverían 401 + JSON {"error": "..."} en lugar de redirigir a /login (porque un cliente JSON no sabe qué hacer con una redirección a una página HTML).

Mala práctica: poner @login_required ENCIMA de @app.route

Los decoradores en Python se aplican de abajo arriba: primero el más cercano a def, luego el siguiente, así hasta el de arriba del todo. Cuando llegamos a @app.route, Flask registra en su url_map lo que tiene debajo — sea la función original o un envoltorio. Por eso:

  • Correcto (@app.route encima, @login_required debajo): primero @login_required envuelve agregar y devuelve envoltorio; después @app.route registra envoltorio como vista de /agregar. Cuando llega una petición, Flask llama a envoltorio, que comprueba la sesión.
  • Incorrecto (@login_required encima, @app.route debajo): primero @app.route registra agregar (la función pelada) en el url_map; después @login_required envuelve agregar pero Flask ya guardó la versión sin proteger. El decorador parece estar puesto pero no se ejecuta nunca — la ruta sigue accesible sin sesión y no hay forma de detectarlo a ojo desde el navegador.

Es un error silencioso: el servidor no se queja, los tests "funcionan" si no hay tests específicos de auth, y la app queda con un fallo de seguridad invisible.

Tarea — Sub-paso 4c: Por qué @wraps (experimento controlado)

Vamos a comprobar a propósito qué pasa sin @wraps. Comenta esa línea del decorador (no la borres — la restauraremos en seguida):

1
2
3
4
5
def login_required(f):
    # @wraps(f)               # <-- comentar esta línea temporalmente
    def envoltorio(*args, **kwargs):
        if 'user' not in session:
            ...

Reinicia el servidor. Antes de seguir, inspecciona el url_map desde otra terminal (sin parar el servidor) ejecutando esto desde la carpeta padre de expendedora/:

python -c "from expendedora.presentation.app import app; [print(f'{r.endpoint:20s} {r.rule}') for r in app.url_map.iter_rules()]"

Verás algo así:

envoltorio           /agregar
envoltorio           /eliminar/<codigo>
envoltorio           /reponer/<codigo>/<int:unidades>
listar_productos     /productos
ver_producto         /producto/<codigo>
...

Las tres rutas protegidas tienen endpoint = envoltorio en lugar de los nombres reales (agregar, eliminar, reponer). Y como las tres comparten endpoint, también colisionan entre sí — url_for('agregar') ahora levanta BuildError porque Flask no sabe a cuál de las tres se refiere.

Ahora descomenta @wraps(f), reinicia el servidor y vuelve a ejecutar el mismo comando. Los endpoints vuelven a ser agregar, eliminar, reponer.

Eso es lo que hace @wraps: copia los metadatos —__name__, __doc__, __module__, __wrapped__— de la función original al envoltorio. Flask identifica internamente cada vista por f.__name__, así que sin @wraps todos los envoltorios se llaman igual y colisionan.

Mala práctica: olvidar @wraps

Sin @wraps, los decoradores parecen funcionar pero rompen sutilmente cualquier código que use el nombre de la función decorada. En proyectos grandes esto produce errores muy difíciles de diagnosticar. Cada decorador casero lleva @wraps. Sin excepciones.

Tarea — Sub-paso 4d: Indicador de sesión en base.html

Sin esto, no hay forma visual de saber si estás logueado. Edita expendedora/presentation/templates/base.html. Modifica el bloque <nav> para que muestre los enlaces de mantenimiento solo si hay sesión, y añade un indicador de sesión a la derecha:

<nav>
    <a href="{{ url_for('listar_productos') }}">Productos</a> ·
    <a href="{{ url_for('saldo') }}">Saldo</a> ·
    <a href="{{ url_for('ayuda') }}">Ayuda</a>
    {% if session['user'] %}
        · <a href="{{ url_for('agregar') }}">Agregar</a>
        · <span>Sesión: {{ session['user'] }}</span>
        · <form method="POST" action="{{ url_for('logout') }}" style="display:inline">
              <button type="submit">Cerrar sesión</button>
          </form>
    {% else %}
        · <a href="{{ url_for('login') }}">Iniciar sesión</a>
    {% endif %}
</nav>

session directamente en la plantilla

Flask expone el objeto session automáticamente en todas las plantillas. No hace falta pasarlo desde el route — session['user'] funciona en cualquier .html. En Python normal, dict['clave'] sobre una clave inexistente levanta KeyError; en Jinja, session['user'] cuando no hay sesión devuelve Undefined, que evalúa a falso en {% if %} sin lanzar excepción. Por eso podemos escribir {% if session['user'] %} sin protegernos antes con {% if 'user' in session %}.

Pregunta

¿Qué pasaría si pusiéramos @login_required también a /comprar? Justifica desde la lógica de negocio, no desde el verbo HTTP.

Respuesta

Romperíamos el caso de uso principal de la máquina. Una expendedora es un dispositivo de autoservicio: el cliente llega, paga y se lleva un producto, sin identificarse. Si forzáramos login para comprar, dejaría de ser una expendedora y se convertiría en una tienda online — un producto distinto, con casos de uso distintos. La distinción no es "rutas de lectura libres / rutas de escritura protegidas". Es "rutas de cliente libres / rutas de operario protegidas". Algunas rutas de operario también son de lectura (en el futuro podrías tener /admin/estadisticas solo para logueados), y algunas rutas de cliente son de escritura (/comprar modifica stock y saldo). El decorador no debe leerse desde HTTP; debe leerse desde el dominio.

Comprobación

Reinicia el servidor. Sin sesión:

  • Navega a / — la nav muestra Iniciar sesión al final. No aparecen Agregar ni Cerrar sesión.
  • Pulsa Agregar en la URL directamente (http://localhost:5000/agregar) — redirect a /login?siguiente=/agregar con flash de error.
  • Inserta dinero, selecciona un producto, compra — todo funciona como antes, sin pedir login.
  • curl http://localhost:5000/api/productos — JSON como siempre, no pide login.

Inicia sesión con admin / admin:

  • La nav muestra ahora Agregar, Sesión: admin y un botón Cerrar sesión.
  • Agregar te lleva al formulario directamente (sin redirect).
  • /eliminar/A1 (cualquier código existente) te muestra la página de confirmación.

Cierra sesión con el botón:

  • Flash Sesión cerrada. visible.
  • La nav vuelve a Iniciar sesión.
  • /agregar vuelve a redirigir al login.

Reflexión

Respuestas a las preguntas frecuentes de este paso
  • ¿Por qué @wraps? Sin @wraps, el envoltorio "tapa" la identidad de la función original: el nombre, el docstring, los atributos. Cualquier código que mire la función decorada por su nombre se rompe. Flask en particular usa f.__name__ para identificar cada vista, así que sin @wraps todas tus vistas decoradas se llaman envoltorio y colisionan.
  • ¿Por qué @app.route encima y @login_required debajo? Los decoradores se aplican de abajo arriba. Si @app.route está arriba, Flask registra lo que tiene debajo — que es el envoltorio creado por @login_required. Si los inviertes, Flask registra la función original sin proteger y @login_required queda colgado sin efecto.
  • ¿Qué pasa con session si reinicias el servidor? Depende de app.secret_key. Si la mantienes constante entre arranques (como aquí, con un string literal en código), la cookie del navegador sigue siendo válida y la sesión sobrevive al reinicio. Si la regeneras al azar en cada arranque, la cookie deja de validarse y el alumno aparece deslogueado. En producción se guarda en variable de entorno.

Paso 5 — Documentación y reflexión arquitectónica

Tarea

CHANGELOG.md

Añade al principio del fichero (encima de la entrada 0.10.0):

## [0.11.0] - (Fase 7: autenticación y autorización con Flask)

### Added
- `infrastructure/repositorio_usuarios_sqlite.py`: repositorio paralelo al de productos, con `buscar_por_nombre` y `existe`.
- `infrastructure/datos_iniciales.py`: helper público `ruta_bd_por_defecto()` para exponer la ruta de la BD a otros módulos.
- `presentation/app.py`: decorador casero `@login_required`, rutas `/login` (GET formulario / POST verifica con `check_password_hash`) y `/logout` (POST limpia sesión).
- `presentation/templates/login.html`: formulario de inicio de sesión.
- `crear_bd.py`: tabla `usuarios(id, nombre UNIQUE, password_hash, rol, activo)` y semilla `admin/admin` con hash generado por `werkzeug.security.generate_password_hash`.

### Changed
- `presentation/app.py`: rutas `/agregar`, `/eliminar/<codigo>` y `/reponer/<codigo>/<int:unidades>` protegidas con `@login_required`.
- `presentation/templates/base.html`: navegación condicional según `session['user']` — muestra `Agregar` + indicador de sesión + botón de logout solo si hay usuario, o enlace a `/login` en caso contrario.

README.md

Modifica la sección Quickstart para que crear_bd.py siembre también el usuario admin (el comando es el mismo, pero el comentario cambia):

# Instalar dependencias y crear la base de datos (productos + usuario admin)
pip install -r requirements.txt
python crear_bd.py

Añade una nueva sección al final de Uso (interfaz web):

**Autenticación:**

La app distingue entre rutas de cliente (libres) y rutas de mantenimiento (requieren login).

- **Libres**: `/`, `/productos`, `/producto/<codigo>`, `/buscar/<texto>`, `/saldo`, `/ayuda`, `/insertar`, `/seleccionar`, `/comprar`, `/cancelar`, `/api/productos`, `/api/producto/<codigo>`.
- **Protegidas**: `/agregar`, `/eliminar/<codigo>`, `/reponer/<codigo>/<int:unidades>`.

**Credenciales por defecto**: `admin / admin`. Se siembran al ejecutar `crear_bd.py`. Para cambiarlas, edita el script y vuelve a ejecutarlo (recrea la BD desde cero) o usa `UPDATE usuarios SET password_hash = ...` desde `sqlite3` con un hash generado con `generate_password_hash`.

docs/EJECUCION.md

Modifica la sección Preparar entorno para señalar el cambio en crear_bd.py:

## Crear la base de datos
```bash
python crear_bd.py
Esto crea las tablas productos, descuentos y usuarios con datos iniciales, incluida la cuenta admin/admin.
Añade al final, antes de **Errores comunes**:

```markdown
## Iniciar sesión en la interfaz web

Tras arrancar la app, abre `http://localhost:5000/login` (o pulsa `Iniciar sesión` en la nav). Credenciales por defecto: `admin / admin`. Las rutas `/agregar`, `/eliminar/<codigo>` y `/reponer/<codigo>/<int:unidades>` requieren sesión activa.

Reflexión final del lab

Crea en la raíz del proyecto (o en docs/, donde prefieras) un fichero RESPUESTAS_A7.md y responde por escrito a estas cinco preguntas. No mires los desplegables hasta haber escrito tu versión.

1. ¿Qué ficheros has creado durante este lab? ¿Cuáles has modificado?

Respuesta

Creados: infrastructure/repositorio_usuarios_sqlite.py, presentation/templates/login.html. Modificados: crear_bd.py, infrastructure/datos_iniciales.py, presentation/app.py, presentation/templates/base.html, CHANGELOG.md, README.md, docs/EJECUCION.md.

2. ¿Has tocado algún fichero de domain/? ¿Has añadido métodos a ServicioExpendedora? Si la respuesta es no, ¿por qué la autenticación ha encajado igualmente sin tocar esas capas?

Respuesta

No. domain/item.py, domain/maquina.py, domain/repositorio_productos.py y application/servicios.py están intactos. La autenticación encaja sin tocarlos porque no es una preocupación del dominio: la expendedora no sabe quién es su operario, igual que un microondas no sabe quién cocina. Solo la interfaz —la capa que decide a quién deja interactuar con el dominio— tiene que conocer la identidad del usuario. Por eso toda la auth vive entre infrastructure/ (almacenamiento de credenciales) y presentation/ (rutas y decorador).

3. ¿En qué se diferencia auth del resto de funcionalidades del proyecto desde el punto de vista de las capas?

Respuesta

Las funcionalidades anteriores (productos, ventas, formularios) se modelan como casos de uso del dominio: hay clases de dominio (Item, MaquinaExpendedora), un servicio que las orquesta (ServicioExpendedora), un repositorio para persistirlas (RepositorioProductosSQLite) y una capa de presentación que las expone. Auth no encaja en ese esquema porque no modela un caso de uso del dominio de la expendedora — es una preocupación transversal que vive solo en la frontera. Por eso no hay domain/usuario.py ni aplication/servicio_auth.py ni repositorio_usuarios.py integrado en el servicio: el repositorio existe (necesitamos guardar las credenciales en algún sitio) pero se queda en infraestructura, sin subir al dominio.

4. ¿Por qué /comprar se ha quedado pública y /agregar no? Justifica desde la lógica de negocio, no desde el verbo HTTP.

Respuesta

/comprar y /agregar son ambas rutas POST que modifican estado. Si solo nos guiáramos por HTTP, ambas tendrían que ir protegidas. Pero las distinguimos por rol del actor: /comprar la usa el cliente que se acerca a la máquina (sin identidad: cualquiera puede usarla, eso es lo que hace que sea una expendedora). /agregar la usa el operario que mantiene la máquina (con identidad: solo personas autorizadas pueden añadir productos al catálogo). La autorización se decide desde el negocio, no desde el protocolo.

5. En la próxima fase haremos una auditoría arquitectónica de toda UT4. Mira la lista de ficheros del Punto 1 y haz tu predicción: ¿qué porcentaje de líneas crees que has tocado fuera de presentation/ y infrastructure/?

Respuesta

Cero líneas. domain/, application/, tests/ no se han abierto en este lab. Esto es exactamente el resultado que la auditoría de a8 va a comprobar fichero a fichero. Si la promesa de las capas se sostiene, añadir auth no obliga a tocar el corazón del proyecto. Si no se sostiene, descubriremos los puntos de fuga.

Comprobación final — los 10 escenarios

Antes de cerrar el lab, ejecuta los 10 escenarios manuales. Marca cada uno cuando lo veas funcionar:

  1. python expendedora/crear_bd.py imprime el hash; SELECT * FROM usuarios muestra la fila con password_hash ilegible.
  2. Arrancas app.py, navegas a / sin sesión: la nav muestra Iniciar sesión, no aparece Agregar.
  3. GET /agregar sin sesión → 302 a /login?siguiente=/agregar + flash Necesitas iniciar sesión....
  4. POST /login con admin / admin → 302 a /agregar (respeta siguiente), flash de bienvenida, nav muestra Sesión: admin · Cerrar sesión.
  5. POST /login con admin / mal → re-render con 401, el nombre conserva admin, el password se vacía.
  6. POST /login con noexiste / loquesea → mismo 401 + mismo mensaje (no filtra existencia de cuentas).
  7. Con sesión, agregas un producto nuevo desde /agregar y lo ves en /productos.
  8. Pulsas Cerrar sesión (botón POST) → flash Sesión cerrada., nav vuelve a Iniciar sesión, /agregar vuelve a redirigir.
  9. Sin sesión, curl http://localhost:5000/api/productos → JSON (API sigue pública).
  10. Sin sesión, secuencia /insertar (formulario) → /seleccionar/comprar → compra completa como anónimo (la expendedora se sigue usando sin login).

Checklist de entrega

  • Tabla usuarios creada en expendedora.db con la cuenta admin y password hasheado.
  • infrastructure/repositorio_usuarios_sqlite.py con buscar_por_nombre y existe.
  • infrastructure/datos_iniciales.py con helper ruta_bd_por_defecto().
  • presentation/app.py con decorador @login_required, rutas /login//logout y la protección aplicada solo a /agregar, /eliminar, /reponer.
  • presentation/templates/login.html y nav condicional en base.html.
  • Los 10 escenarios de la comprobación final superados.
  • RESPUESTAS_A7.md con las cinco preguntas de reflexión contestadas.
  • CHANGELOG.md con la entrada 0.11.0 y README.md/docs/EJECUCION.md actualizados.
  • domain/, application/servicios.py, infrastructure/repositorio_sqlite.py y tests/ siguen exactamente igual que al iniciar el lab (git diff lo confirma si tu proyecto está versionado).

Problemas comunes

ModuleNotFoundError: No module named 'werkzeug'

Werkzeug viene incluido con Flask como dependencia; este error suele indicar que estás ejecutando con un Python distinto al del venv. Comprueba con which python (o python -c "import sys; print(sys.executable)") que estás en el intérprete del entorno virtual.

RuntimeError: The session is unavailable because no secret key was set

session necesita app.secret_key. La declaraste en a6 — comprueba que no la borraste por accidente al modificar app.py.

Login con admin / admin falla con 401 aunque la BD tiene la fila

Comprueba que check_password_hash(fila[2], password) recibe el hash en fila[2]. Si has cambiado el orden de las columnas en el SELECT, el índice ya no es 2. Imprime fila antes del if para depurar.

Tras /logout, sigues viendo Agregar en la nav

session.pop('user', None) se ejecuta, pero quizá olvidaste el redirect: la respuesta tiene que redirigir a otra URL para que la cookie nueva (sin user) llegue al navegador y la siguiente página se renderice ya sin sesión. Si devuelves un render_template directamente, la nav se renderiza con la sesión todavía cargada en el contexto de la petición actual.

El decorador funciona pero url_for('agregar') da BuildError

Olvidaste @wraps(f) en el decorador. Flask registra la ruta con el nombre del envoltorio (envoltorio) en lugar del nombre original (agregar), así que url_for('agregar') no la encuentra. Añade @wraps(f) y reinicia.

El parámetro siguiente no respeta la ruta original

En /login, comprueba que estás leyendo request.args.get('siguiente') (parámetro de URL del GET), no request.form.get('siguiente') (que sería del cuerpo del POST). El decorador pone siguiente en la URL al redirigir.

Comprueba también que el <form> de login.html no lleva action="/login" ni action="{{ url_for('login') }}". Si tiene un action que apunta a /login sin querystring, el navegador descarta el ?siguiente=... al enviar y request.args.get('siguiente') devuelve None. La forma correcta es dejar el action vacío o no ponerlo: el formulario envía a la URL actual, que conserva el querystring.

Visito /agregar sin sesión y veo el formulario en lugar de redirigir

Has invertido el orden de los decoradores. @app.route tiene que ir encima y @login_required debajo, no al revés. Si están al revés, Flask registra la función sin proteger y @login_required no llega a ejecutarse — la ruta sigue accesible y nada lo indica visualmente. Releé la admonición "Mala práctica: poner @login_required ENCIMA de @app.route" del sub-paso 4b.