Files
TieMeasureFlow/src/frontend/flask_app/blueprints/auth.py
T
Adriano 92f130479a fix(auth): SECRET_KEY di default rifiutato, session.permanent, logout solo POST (#33)
config.py: guard_default_secret_key, verificata a ogni richiesta (prima di
create_app, TESTING/DEBUG non sono ancora impostati dai fixture di test) —
rifiuta con RuntimeError se CLIENT_SECRET_KEY è rimasto al default committato
fuori da debug/testing. auth.py: session.permanent=True al login (senza,
PERMANENT_SESSION_LIFETIME non aveva mai effetto); /auth/logout solo POST,
con CSRF. Aggiornati i tre punti che lo chiamavano: profile.html e
navbar.html (link -> form POST), auto-logout JS in base.html (fetch POST +
redirect al login).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:37:23 +02:00

205 lines
7.3 KiB
Python

"""Authentication blueprint - login, logout, profile."""
from functools import wraps
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _
from services.api_client import api_client
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
def login_required(f):
"""Decorator to require login for protected routes."""
@wraps(f)
def decorated(*args, **kwargs):
if not session.get("api_key"):
flash(_("Effettua il login per continuare"), "warning")
return redirect(url_for("auth.login"))
return f(*args, **kwargs)
return decorated
def role_required(*roles):
"""Decorator to require one of the specified roles.
Usage:
@role_required("MeasurementTec", "Metrologist")
def my_view(): ...
"""
def decorator(f):
@wraps(f)
def decorated(*args, **kwargs):
user = session.get("user", {})
user_roles = user.get("roles", [])
if not any(r in user_roles for r in roles):
abort(403)
return f(*args, **kwargs)
return decorated
return decorator
def _post_login_landing():
"""Pick the landing endpoint based on the logged-in user's roles.
A Maker-only user must not be bounced to /measure (MeasurementTec-only),
which would raise a 403 right after login. Order of preference follows the
primary operator flow first, then editing, analysis, admin.
"""
user = session.get("user", {})
roles = user.get("roles", [])
if "MeasurementTec" in roles:
return url_for("measure.select_recipe")
if "Maker" in roles:
return url_for("maker.recipe_list")
if "Metrologist" in roles:
return url_for("statistics.dashboard")
if user.get("is_admin"):
return url_for("admin.user_list")
return url_for("auth.profile")
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
"""Login page and form handler."""
# Se già loggato, redirect alla home in base al ruolo
if session.get("api_key"):
return redirect(_post_login_landing())
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if not username or not password:
flash(_("Inserisci username e password"), "error")
return render_template("auth/login.html")
try:
response = api_client.post("/api/auth/login", data={"username": username, "password": password})
if response.get("error"):
flash(response.get("detail", _("Credenziali non valide")), "error")
return render_template("auth/login.html")
# Salva in sessione. permanent=True fa valere PERMANENT_SESSION_LIFETIME
# (8h, config.py): senza, Flask usa una sessione di durata browser e il
# limite configurato non ha mai effetto (#33).
session.permanent = True
session["api_key"] = response["api_key"]
session["user"] = response["user"]
session["user_id"] = response["user"]["id"]
# Applica preferenze utente
user = response["user"]
if user.get("language_pref"):
session["language"] = user["language_pref"]
if user.get("theme_pref"):
session["theme"] = user["theme_pref"]
# Carica impostazioni di sistema
settings_resp = api_client.get("/api/settings")
if not settings_resp.get("error"):
logo_path = settings_resp.get("company_logo_path")
if logo_path:
session["company_logo"] = logo_path
auto_logout = settings_resp.get("auto_logout_minutes")
if auto_logout:
try:
session["auto_logout_minutes"] = int(auto_logout)
except (ValueError, TypeError):
pass
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
# Redirect dopo login (in base al ruolo)
return redirect(_post_login_landing())
except Exception as e:
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
return render_template("auth/login.html")
return render_template("auth/login.html")
@auth_bp.route("/logout", methods=["POST"])
def logout():
"""Clear session and redirect to login."""
# Invalida il token sul server
if session.get("api_key"):
try:
api_client.post("/api/auth/logout")
except Exception:
pass # Ignora errori durante logout
session.clear()
flash(_("Logout effettuato"), "info")
return redirect(url_for("auth.login"))
@auth_bp.route("/set-theme", methods=["POST"])
@login_required
def set_theme():
"""Persist the user's theme toggle (light/dark) to session and profile.
Called via AJAX from the navbar theme switch so the choice survives
navigation and matches on the next login. Best-effort on the API side:
the session is updated regardless so the current browsing stays correct.
"""
theme = (request.get_json(silent=True) or {}).get("theme", "")
if theme not in ("light", "dark"):
return jsonify({"error": True, "detail": "invalid theme"}), 400
session["theme"] = theme
try:
api_client.put("/api/auth/me", data={"theme_pref": theme})
except Exception:
pass # Non-fatal: session theme is already updated for this session.
return jsonify({"ok": True, "theme": theme})
@auth_bp.route("/profile", methods=["GET", "POST"])
@login_required
def profile():
"""User profile page - change display name, language, theme."""
if request.method == "POST":
display_name = request.form.get("display_name", "").strip()
language_pref = request.form.get("language_pref", "").strip()
theme_pref = request.form.get("theme_pref", "").strip()
try:
data = {}
if display_name:
data["display_name"] = display_name
if language_pref:
data["language_pref"] = language_pref
if theme_pref:
data["theme_pref"] = theme_pref
response = api_client.put("/api/auth/me", data=data)
if response.get("error"):
flash(response.get("detail", _("Errore durante l'aggiornamento del profilo")), "error")
else:
# Aggiorna sessione
session["user"] = response
if language_pref:
session["language"] = language_pref
if theme_pref:
session["theme"] = theme_pref
flash(_("Profilo aggiornato con successo"), "success")
return redirect(url_for("auth.profile"))
except Exception as e:
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
# GET - carica dati profilo
user_data = api_client.get("/api/auth/me")
if user_data.get("error"):
flash(_("Errore nel caricamento del profilo: %(error)s", error=user_data.get("detail", "")), "error")
user_data = session.get("user", {})
else:
session["user"] = user_data
return render_template("auth/profile.html", user=user_data)