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_requiredcasero. - Agrupamiento: Individual o en parejas conductor/navegante.
- Recursos:
- Ficheros de trabajo:
expendedora/crear_bd.pyexpendedora/infrastructure/repositorio_usuarios_sqlite.py(nuevo)expendedora/infrastructure/datos_iniciales.pyexpendedora/presentation/app.pyexpendedora/presentation/templates/login.html(nuevo)expendedora/presentation/templates/base.htmlCHANGELOG.md,README.md,docs/EJECUCION.md
Punto de partida¶
Parte del zip que entregaste al final del lab a6. Antes de empezar:
- Activa el entorno virtual del proyecto.
- Sitúate en la carpeta padre de
expendedora/. - Si la BD
expendedora.dbno existe, créala conpython expendedora/crear_bd.py. - Arranca el servidor:
python -m expendedora.presentation.app. - 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 quecurl http://localhost:5000/api/productosdevuelve 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):
Al final del fichero, antes del conn.close(), añade el bloque que crea el usuario admin:
Re-ejecuta el script para recrear la BD desde cero:
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.pypor 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éscheck_password_hashreconozca la contraseñaadminen 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.pytermina 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;"devuelve1.
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:
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.pytenemosServicioExpendedoraque recibe laMaquinaExpendedoray delega en ella. ¿Por qué este nuevo repositorio de usuarios no se inyecta enServicioExpendedorasiguiendo 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.pyexiste. - 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:
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:
Modifica el import de datos_iniciales para incluir el helper nuevo, y añade los dos imports siguientes:
Justo debajo de la línea servicio = crear_servicio_sqlite(), instancia el repositorio de usuarios:
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')):
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:
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 decirusuario no encontradovscontraseñ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/, flashBienvenido, 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 ejecutandocurl -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:
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':
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:
Lo mismo con /eliminar/<codigo>:
Y /reponer/<codigo>/<int: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.routeencima,@login_requireddebajo): primero@login_requiredenvuelveagregary devuelveenvoltorio; después@app.routeregistraenvoltoriocomo vista de/agregar. Cuando llega una petición, Flask llama aenvoltorio, que comprueba la sesión. - Incorrecto (
@login_requiredencima,@app.routedebajo): primero@app.routeregistraagregar(la función pelada) en elurl_map; después@login_requiredenvuelveagregarpero 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):
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:
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_requiredtambié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 muestraIniciar sesiónal final. No aparecenAgregarniCerrar sesión. - Pulsa
Agregaren la URL directamente (http://localhost:5000/agregar) — redirect a/login?siguiente=/agregarcon 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: adminy un botónCerrar sesión. Agregarte 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. /agregarvuelve 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 usaf.__name__para identificar cada vista, así que sin@wrapstodas tus vistas decoradas se llamanenvoltorioy colisionan. - ¿Por qué
@app.routeencima y@login_requireddebajo? Los decoradores se aplican de abajo arriba. Si@app.routeestá 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_requiredqueda colgado sin efecto. - ¿Qué pasa con
sessionsi reinicias el servidor? Depende deapp.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:
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 aServicioExpendedora? 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é
/comprarse ha quedado pública y/agregarno? 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/yinfrastructure/?
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:
python expendedora/crear_bd.pyimprime el hash;SELECT * FROM usuariosmuestra la fila conpassword_hashilegible.- Arrancas
app.py, navegas a/sin sesión: la nav muestraIniciar sesión, no apareceAgregar. GET /agregarsin sesión → 302 a/login?siguiente=/agregar+ flashNecesitas iniciar sesión....POST /loginconadmin / admin→ 302 a/agregar(respetasiguiente), flash de bienvenida, nav muestraSesión: admin · Cerrar sesión.POST /loginconadmin / mal→ re-render con 401, el nombre conservaadmin, el password se vacía.POST /loginconnoexiste / loquesea→ mismo 401 + mismo mensaje (no filtra existencia de cuentas).- Con sesión, agregas un producto nuevo desde
/agregary lo ves en/productos. - Pulsas
Cerrar sesión(botón POST) → flashSesión cerrada., nav vuelve aIniciar sesión,/agregarvuelve a redirigir. - Sin sesión,
curl http://localhost:5000/api/productos→ JSON (API sigue pública). - Sin sesión, secuencia
/insertar(formulario) →/seleccionar→/comprar→ compra completa como anónimo (la expendedora se sigue usando sin login).
Checklist de entrega¶
- Tabla
usuarioscreada enexpendedora.dbcon la cuentaadminy password hasheado. -
infrastructure/repositorio_usuarios_sqlite.pyconbuscar_por_nombreyexiste. -
infrastructure/datos_iniciales.pycon helperruta_bd_por_defecto(). -
presentation/app.pycon decorador@login_required, rutas/login//logouty la protección aplicada solo a/agregar,/eliminar,/reponer. -
presentation/templates/login.htmly nav condicional enbase.html. - Los 10 escenarios de la comprobación final superados.
-
RESPUESTAS_A7.mdcon las cinco preguntas de reflexión contestadas. -
CHANGELOG.mdcon la entrada0.11.0yREADME.md/docs/EJECUCION.mdactualizados. -
domain/,application/servicios.py,infrastructure/repositorio_sqlite.pyytests/siguen exactamente igual que al iniciar el lab (git difflo 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.