diff --git a/apps/tesm-license/pyproject.toml b/apps/tesm-license/pyproject.toml index 94d005f..339fbde 100644 --- a/apps/tesm-license/pyproject.toml +++ b/apps/tesm-license/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-license" -version = "2.0.3" +version = "2.0.4" description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" requires-python = ">=3.11" dependencies = [ diff --git a/packages/tesm-core/pyproject.toml b/packages/tesm-core/pyproject.toml index be37739..3a13a1c 100644 --- a/packages/tesm-core/pyproject.toml +++ b/packages/tesm-core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-core" -version = "2.0.3" +version = "2.0.4" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = [ diff --git a/packages/tesm-core/src/tesm_core/app_factory.py b/packages/tesm-core/src/tesm_core/app_factory.py index 3a6d738..406ec22 100644 --- a/packages/tesm-core/src/tesm_core/app_factory.py +++ b/packages/tesm-core/src/tesm_core/app_factory.py @@ -134,7 +134,7 @@ def create_base_app( #: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die #: Funktionsliste von TESM waere dort schlicht falsch. auth_tagline: str = "", - app_version: str = "2.0.3", + app_version: str = "2.0.4", built_in_trusted_keys: tuple[str, ...] = (), enable_license: bool = True, template_folder: str | None = None, @@ -259,6 +259,17 @@ def create_base_app( @app.after_request def _finish_request(response): # type: ignore[no-untyped-def] + # Ein Aktualisierungsdurchlauf, der mit Sitzungscookie kommt und + # unangemeldet endet, hat gerade seine Sitzung verloren. Bei einer + # geschuetzten Seite sagt das schon der Status (401); bei einer, die + # auch oeffentlich antwortet -- die Statusuebersicht etwa -- kommt eine + # 200 mit den oeffentlichen Kacheln zurueck, und der offene Tab merkt + # nichts: er zeigt weiter eine Oberflaeche, als sei alles in Ordnung. + # Deshalb ein Kopf, den der Browser unabhaengig vom Status auswerten + # kann. + if session_store.is_automatic(request) and not current_user.is_authenticated: + if request.cookies.get(app.config["SESSION_COOKIE_NAME"], ""): + response.headers["X-Session-Expired"] = "1" hsts = "" if config.security.hsts_enabled: hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains" diff --git a/packages/tesm-core/src/tesm_core/security/sessions.py b/packages/tesm-core/src/tesm_core/security/sessions.py index ae86fd6..6f8d0de 100644 --- a/packages/tesm-core/src/tesm_core/security/sessions.py +++ b/packages/tesm-core/src/tesm_core/security/sessions.py @@ -27,6 +27,7 @@ from datetime import datetime, timedelta, timezone from typing import Any from flask import Flask, Request, Response +from flask import request as aktuelle_anfrage from flask.sessions import SessionInterface, SessionMixin from werkzeug.datastructures import CallbackDict @@ -49,9 +50,63 @@ SESSION_ID_BYTES = 32 AUTO_REFRESH_HEADER = "X-Auto-Refresh" +#: Marke auf der Ansichtsfunktion. Steht an der Route, damit man sie dort sieht, +#: wo sie gilt. +FRAGMENT_ATTRIBUT = "_tesm_fragment" + + +def fragment(view: Any) -> Any: + """Kennzeichnet eine Route als Aktualisierungsausschnitt. + + Solche Routen ruft ein Zeitgeber auf, kein Mensch. Sie duerfen die Sitzung + benutzen, halten sie aber nicht am Leben -- sonst bleibt jeder offene Tab + unbegrenzt angemeldet. + + Der Kopf ``X-Auto-Refresh`` allein genuegt dafuer nicht: er setzt voraus, + dass der Browser das aktuelle Skript faehrt. Ein Tab, der seit Stunden + offen ist, faehrt das alte -- und das ist genau der Tab, um den es geht. + """ + setattr(view, FRAGMENT_ATTRIBUT, True) + return view + + +def _ist_fragment_route() -> bool: + """Gehoert die laufende Anfrage zu einer als Ausschnitt markierten Route? + + ``request.endpoint`` steht hier nicht immer schon: Flask oeffnet die Sitzung + **vor** dem Routing (``RequestContext.push`` ruft ``open_session`` und erst + danach ``match_request``). In ``save_session`` ist der Endpunkt gesetzt, in + ``open_session`` nicht -- und ohne diesen Nachtrag griff die Erkennung + genau dort nicht, wo sie gebraucht wird. + """ + from flask import current_app + + try: + endpunkt = getattr(aktuelle_anfrage, "endpoint", None) + if not endpunkt: + # Selbst nachsehen. Ein zweiter Routenabgleich je Anfrage ist + # billiger als eine Abmeldung, die nicht stattfindet. + adapter = current_app.url_map.bind_to_environ(aktuelle_anfrage.environ) + endpunkt, _ = adapter.match( + aktuelle_anfrage.path, method=aktuelle_anfrage.method + ) + ansicht = current_app.view_functions.get(str(endpunkt)) + except Exception: # noqa: BLE001 - keine Route, Umleitung, kein App-Kontext + return False + return bool(getattr(ansicht, FRAGMENT_ATTRIBUT, False)) + + def is_automatic(request: Request) -> bool: - """Kam die Anfrage von einem Zeitgeber statt von einem Menschen?""" - return request.headers.get(AUTO_REFRESH_HEADER, "") == "1" + """Kam die Anfrage von einem Zeitgeber statt von einem Menschen? + + Zwei Kennzeichen, und eines genuegt: der Kopf, den das Skript setzt, + und die Route selbst. Das zweite traegt auch dann, wenn ein lange + offener Tab noch ein aelteres Skript faehrt -- und das ist der Tab, + um den es geht. + """ + if request.headers.get(AUTO_REFRESH_HEADER, "") == "1": + return True + return _ist_fragment_route() def _now() -> datetime: @@ -239,6 +294,31 @@ class SqliteSessionInterface(SessionInterface): ), ) session.new = False + # ``save_session`` bekommt die Anfrage nicht als Argument -- anders als + # ``open_session``, wo ein Parameter denselben Namen traegt. Hier ist es + # der Anfragekontext von Flask; die Antwort wird darin erzeugt. + elif is_automatic(aktuelle_anfrage): + # Die zweite Schreibstelle, und die eigentliche Ursache dafuer, + # dass die Abmeldung nie ausgeloest hat: hier wurde das + # Leerlauffenster bei **jeder** Anfrage verlaengert, die die + # Sitzung anfasst. Die Pruefung in open_session lief damit ins + # Leere -- ein Aktualisierungsdurchlauf einer Kachel fasst die + # Sitzung an (Flash-Speicher, CSRF), und schon stand das Fenster + # wieder 30 Minuten offen. Auf dem Lizenzserver fiel es nicht + # auf: dort pollt keine Kachel. + # + # Geaenderte Sitzungsdaten muessen trotzdem erhalten bleiben -- + # sie zu verwerfen waere eine zweite Fehlerquelle. Nur die + # Fristen bleiben stehen. + conn.execute( + "UPDATE user_sessions SET user_id=?, data=?, reauth_at=? WHERE id=?", + ( + session.get("user_id"), + payload, + session.get("_reauth_at"), + session.sid, + ), + ) else: conn.execute( "UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, " @@ -247,7 +327,11 @@ class SqliteSessionInterface(SessionInterface): session.get("user_id"), payload, _iso(now), - _iso(now + timedelta(minutes=self.idle_minutes)), + # ``_idle(conn)`` und nicht ``idle_minutes``: die Zeit ist + # zur Laufzeit einstellbar, und diese Stelle hat die + # Einstellung ignoriert. Bei der Vorgabe von 30 Minuten + # fiel das nicht auf -- bei einer geaenderten schon. + _iso(now + timedelta(minutes=self._idle(conn))), session.get("_reauth_at"), session.sid, ), diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js b/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js index 97f1fa5..419691c 100644 --- a/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js @@ -66,6 +66,15 @@ export async function api(url, options = {}) { ...(options.headers || {}), }; const response = await fetch(url, opts); + // Eine Seite, die auch oeffentlich antwortet -- die Statusuebersicht etwa -- + // liefert nach dem Ablauf der Sitzung eine 200 mit den oeffentlichen + // Kacheln. Am Status ist der Verlust also nicht zu erkennen; an diesem Kopf + // schon. Behandelt wird er wie eine 401. + if (response.headers.get("X-Session-Expired") === "1") { + const abgelaufen = new Error("Die Sitzung ist abgelaufen."); + abgelaufen.status = 401; + throw abgelaufen; + } const type = response.headers.get("content-type") || ""; const payload = type.includes("application/json") ? await response.json() : await response.text(); if (!response.ok) { diff --git a/packages/tesm-core/src/tesm_core/views/logs.py b/packages/tesm-core/src/tesm_core/views/logs.py index 6a272f0..0f3b7f3 100644 --- a/packages/tesm-core/src/tesm_core/views/logs.py +++ b/packages/tesm-core/src/tesm_core/views/logs.py @@ -24,6 +24,7 @@ from .. import audit from ..auth.models import current_user, permission_required from ..auth.service import client_ip from ..extension import core, db, transaction +from ..security.sessions import fragment bp = Blueprint("logs", __name__, url_prefix="/protokolle") @@ -53,6 +54,7 @@ def live(): @bp.get("/laufend/inhalt") @permission_required("logs_live.view") +@fragment def live_fragment(): lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES) needle = (request.args.get("q") or "").strip() diff --git a/packages/tesm-licensing-server/pyproject.toml b/packages/tesm-licensing-server/pyproject.toml index f469c56..1efc73c 100644 --- a/packages/tesm-licensing-server/pyproject.toml +++ b/packages/tesm-licensing-server/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-licensing-server" -version = "2.0.3" +version = "2.0.4" description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten" requires-python = ">=3.11" -dependencies = ["tesm-licensing==2.0.3", "cryptography>=41"] +dependencies = ["tesm-licensing==2.0.4", "cryptography>=41"] [tool.setuptools.packages.find] where = ["src"] diff --git a/packages/tesm-licensing/pyproject.toml b/packages/tesm-licensing/pyproject.toml index cd0db6c..dcf6252 100644 --- a/packages/tesm-licensing/pyproject.toml +++ b/packages/tesm-licensing/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-licensing" -version = "2.0.3" +version = "2.0.4" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = ["cryptography>=42"]