Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
168d10bf87 |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-license"
|
name = "tesm-license"
|
||||||
version = "2.0.3"
|
version = "2.0.4"
|
||||||
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
|
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
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"
|
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ def create_base_app(
|
|||||||
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
|
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
|
||||||
#: Funktionsliste von TESM waere dort schlicht falsch.
|
#: Funktionsliste von TESM waere dort schlicht falsch.
|
||||||
auth_tagline: str = "",
|
auth_tagline: str = "",
|
||||||
app_version: str = "2.0.3",
|
app_version: str = "2.0.4",
|
||||||
built_in_trusted_keys: tuple[str, ...] = (),
|
built_in_trusted_keys: tuple[str, ...] = (),
|
||||||
enable_license: bool = True,
|
enable_license: bool = True,
|
||||||
template_folder: str | None = None,
|
template_folder: str | None = None,
|
||||||
@@ -259,6 +259,17 @@ def create_base_app(
|
|||||||
|
|
||||||
@app.after_request
|
@app.after_request
|
||||||
def _finish_request(response): # type: ignore[no-untyped-def]
|
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 = ""
|
hsts = ""
|
||||||
if config.security.hsts_enabled:
|
if config.security.hsts_enabled:
|
||||||
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
|
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import Flask, Request, Response
|
from flask import Flask, Request, Response
|
||||||
|
from flask import request as aktuelle_anfrage
|
||||||
from flask.sessions import SessionInterface, SessionMixin
|
from flask.sessions import SessionInterface, SessionMixin
|
||||||
from werkzeug.datastructures import CallbackDict
|
from werkzeug.datastructures import CallbackDict
|
||||||
|
|
||||||
@@ -49,9 +50,63 @@ SESSION_ID_BYTES = 32
|
|||||||
AUTO_REFRESH_HEADER = "X-Auto-Refresh"
|
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:
|
def is_automatic(request: Request) -> bool:
|
||||||
"""Kam die Anfrage von einem Zeitgeber statt von einem Menschen?"""
|
"""Kam die Anfrage von einem Zeitgeber statt von einem Menschen?
|
||||||
return request.headers.get(AUTO_REFRESH_HEADER, "") == "1"
|
|
||||||
|
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:
|
def _now() -> datetime:
|
||||||
@@ -239,6 +294,31 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
session.new = False
|
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:
|
else:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, "
|
"UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, "
|
||||||
@@ -247,7 +327,11 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
session.get("user_id"),
|
session.get("user_id"),
|
||||||
payload,
|
payload,
|
||||||
_iso(now),
|
_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.get("_reauth_at"),
|
||||||
session.sid,
|
session.sid,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ export async function api(url, options = {}) {
|
|||||||
...(options.headers || {}),
|
...(options.headers || {}),
|
||||||
};
|
};
|
||||||
const response = await fetch(url, opts);
|
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 type = response.headers.get("content-type") || "";
|
||||||
const payload = type.includes("application/json") ? await response.json() : await response.text();
|
const payload = type.includes("application/json") ? await response.json() : await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from .. import audit
|
|||||||
from ..auth.models import current_user, permission_required
|
from ..auth.models import current_user, permission_required
|
||||||
from ..auth.service import client_ip
|
from ..auth.service import client_ip
|
||||||
from ..extension import core, db, transaction
|
from ..extension import core, db, transaction
|
||||||
|
from ..security.sessions import fragment
|
||||||
|
|
||||||
bp = Blueprint("logs", __name__, url_prefix="/protokolle")
|
bp = Blueprint("logs", __name__, url_prefix="/protokolle")
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ def live():
|
|||||||
|
|
||||||
@bp.get("/laufend/inhalt")
|
@bp.get("/laufend/inhalt")
|
||||||
@permission_required("logs_live.view")
|
@permission_required("logs_live.view")
|
||||||
|
@fragment
|
||||||
def live_fragment():
|
def live_fragment():
|
||||||
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
|
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
|
||||||
needle = (request.args.get("q") or "").strip()
|
needle = (request.args.get("q") or "").strip()
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing-server"
|
name = "tesm-licensing-server"
|
||||||
version = "2.0.3"
|
version = "2.0.4"
|
||||||
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
|
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["tesm-licensing==2.0.3", "cryptography>=41"]
|
dependencies = ["tesm-licensing==2.0.4", "cryptography>=41"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing"
|
name = "tesm-licensing"
|
||||||
version = "2.0.3"
|
version = "2.0.4"
|
||||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["cryptography>=42"]
|
dependencies = ["cryptography>=42"]
|
||||||
|
|||||||
Reference in New Issue
Block a user