Upgrade von 1.x, Leerlaufabmeldung, Mitschrift im Audit
upgrade.sh -- ein Weg vom Stand SONNET5 auf diese Fassung, mit Daten. Der Ablauf braucht keine Schalter: alte Installation sichern, abschalten, neu installieren, exportieren, uebersetzen, einspielen. Die Sicherung steht bewusst vor dem Export -- ohne --instance installiert install.sh in dasselbe Verzeichnis, und wer erst exportiert und dann sichert, sichert im schlechtesten Fall nichts mehr. Gelesen wird nur aus der Kopie. Dafuer zwei neue Befehle: "legacy-export" liest eine alte Installation unmittelbar (der Vorgaenger kann nur ueber die Weboberflaeche exportieren -- fuer ein Upgrade der falsche Weg, und unmoeglich, wenn die alte Anwendung nicht mehr startet) und schreibt eine Datei im alten Format, die deshalb auch als Rueckweg taugt. "legacy-import" liest eine Datei im neuen Format ein, ueber transfer.apply_import -- dieselbe Funktion wie die Seite "Sicherung", kein zweiter Schreibpfad. Am Lizenzserver hat der Lauf gegen echte Altdaten zwei Fehler gezeigt, die jede Uebernahme unmoeglich machten: * Der alte Signaturschluessel ist URL-sicher kodiert (- und _). Das neue Protokoll prueft Base64 streng -- richtig so, aber beim Lesen einer alten Datei brach die Uebernahme mit einem Traceback ueber "kein gueltiges Base64" ab. Umkodiert wird jetzt an der Grenze zum Altformat; der kanonische Decoder bleibt streng. * Der Anbieterblock wurde aus den Einstellungen der *neuen* Installation gebaut, und ohne Endpunkt brach die Uebernahme ab. Bei einem Upgrade ist die neue Installation frisch -- dort steht nichts. Endpunkt und Anbieterdaten kommen jetzt aus der alten Installation, aber nur dort, wo neu nichts steht. Der alte Signaturschluessel wird uebernommen: sonst muesste jeder Kunde einen Ausstellerwechsel bestaetigen. Leerlaufabmeldung: sie hat nie ausgeloest. Ein offener Tab fragt seine Kacheln alle 15 Sekunden neu ab, und jede Abfrage verlaengerte das Leerlauffenster -- nachweisbar an einer Sitzung, deren last_seen_at eine Minute alt war, obwohl seit zehn Stunden niemand am Rechner sass. Der Client kennzeichnet automatische Anfragen jetzt mit einem Kopf, der Server verlaengert daraufhin nichts, und last_seen_at bleibt, was der Name sagt. Faellt die Sitzung waehrend des Pollens, antwortet der Server mit 401 statt mit einer Umleitung: der Tab laedt neu und zeigt die Anmeldemaske, statt stumm mit alten Zahlen stehenzubleiben. Im Audit ist der Name der Mitschrift jetzt ein Verweis auf die Datei -- es ist derselbe Weg wie zuvor, nur einen Klick weit. Verlinkt wird nur, wenn die Datei existiert und der Betrachter Protokolle lesen darf; der Rest des Eintrags wird maskiert, denn ein Auditeintrag enthaelt Fremdeingaben. Ausserdem: Version 2.0.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.0.2"
|
||||
version = "2.0.3"
|
||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -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.2",
|
||||
app_version: str = "2.0.3",
|
||||
built_in_trusted_keys: tuple[str, ...] = (),
|
||||
enable_license: bool = True,
|
||||
template_folder: str | None = None,
|
||||
|
||||
@@ -10,6 +10,7 @@ from flask import abort, flash, g, redirect, request, session, url_for
|
||||
from werkzeug.local import LocalProxy
|
||||
|
||||
from ..rbac.model import Action, PermissionTree
|
||||
from ..security.sessions import is_automatic
|
||||
|
||||
AUTH_SOURCES = ("local", "ldap")
|
||||
|
||||
@@ -189,6 +190,14 @@ def _deny(message: str) -> Any:
|
||||
if not current_user.is_authenticated:
|
||||
if request.method == "GET" and not request.headers.get("X-Requested-With"):
|
||||
session["_after_login"] = request.full_path if request.query_string else request.path
|
||||
# Eine automatische Aktualisierung fragt einen Ausschnitt ab und
|
||||
# erwartet HTML. Eine Umleitung auf die Anmeldeseite wuerde sie
|
||||
# dankbar entgegennehmen und eine ganze Seite in eine Kachel setzen --
|
||||
# der Tab bliebe stumm stehen, obwohl die Sitzung abgelaufen ist. 401
|
||||
# sagt es deutlich; der Browser laedt daraufhin die Seite neu und der
|
||||
# Mensch sieht die Anmeldemaske.
|
||||
if is_automatic(request):
|
||||
abort(401, description="Sitzung abgelaufen.")
|
||||
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
|
||||
abort(401, description="Anmeldung erforderlich.")
|
||||
flash("Bitte melden Sie sich an.", "info")
|
||||
|
||||
@@ -34,6 +34,25 @@ from ..db import Database
|
||||
|
||||
SESSION_ID_BYTES = 32
|
||||
|
||||
#: Kopfzeile, mit der der Browser eine Anfrage als **automatisch** kennzeichnet:
|
||||
#: ein Aktualisierungsdurchlauf einer Kachel, kein Klick eines Menschen.
|
||||
#:
|
||||
#: Ohne diese Unterscheidung ist die automatische Abmeldung wirkungslos, und das
|
||||
#: war sie auch: ein offener Tab fragt seine Bereiche alle 15 Sekunden neu ab,
|
||||
#: jede Abfrage verlaengerte das Leerlauffenster, und niemand wurde je
|
||||
#: abgemeldet -- nachweisbar an einer Sitzung, deren ``last_seen_at`` eine
|
||||
#: Minute alt war, obwohl seit zehn Stunden niemand am Rechner sass.
|
||||
#:
|
||||
#: Die Entscheidung gehoert zum Client: nur er weiss, ob ein Zeitgeber oder ein
|
||||
#: Mensch die Anfrage ausgeloest hat. Ein Angreifer koennte den Kopf setzen --
|
||||
#: er wuerde damit seine *eigene* Sitzung schneller ablaufen lassen.
|
||||
AUTO_REFRESH_HEADER = "X-Auto-Refresh"
|
||||
|
||||
|
||||
def is_automatic(request: Request) -> bool:
|
||||
"""Kam die Anfrage von einem Zeitgeber statt von einem Menschen?"""
|
||||
return request.headers.get(AUTO_REFRESH_HEADER, "") == "1"
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
@@ -154,8 +173,17 @@ class SqliteSessionInterface(SessionInterface):
|
||||
}
|
||||
# Leerlauffenster verlaengern, aber hoechstens einmal pro Minute
|
||||
# schreiben -- sonst kostet jeder AJAX-Poll einen Schreibvorgang.
|
||||
#
|
||||
# Eine automatische Abfrage verlaengert gar nichts. Sie darf die
|
||||
# Sitzung benutzen, solange diese lebt, aber sie haelt sie nicht am
|
||||
# Leben: sonst bleibt jeder offene Tab bis in alle Ewigkeit
|
||||
# angemeldet, und "Abmeldung nach 30 Minuten Leerlauf" ist eine
|
||||
# Zusage, die die Anwendung nicht einhaelt. Damit bleibt
|
||||
# ``last_seen_at`` ausserdem, was der Name sagt: der letzte
|
||||
# Zeitpunkt, an dem ein Mensch da war.
|
||||
last_seen = _parse(row.get("last_seen_at"))
|
||||
if last_seen is None or (now - last_seen) > timedelta(seconds=60):
|
||||
veraltet = last_seen is None or (now - last_seen) > timedelta(seconds=60)
|
||||
if veraltet and not is_automatic(request):
|
||||
with self.database.transaction(conn):
|
||||
conn.execute(
|
||||
"UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?",
|
||||
|
||||
@@ -45,12 +45,24 @@ function csrfToken() {
|
||||
return document.body?.dataset.csrf || "";
|
||||
}
|
||||
|
||||
/** fetch mit CSRF-Header und JSON-Erwartung. */
|
||||
/**
|
||||
* fetch mit CSRF-Header und JSON-Erwartung.
|
||||
*
|
||||
* `auto: true` kennzeichnet eine Anfrage, die ein Zeitgeber ausgeloest hat und
|
||||
* kein Mensch. Der Server verlaengert das Leerlauffenster der Sitzung dann
|
||||
* nicht -- ohne diese Unterscheidung haelt jeder offene Tab die Anmeldung
|
||||
* unbegrenzt am Leben, und die automatische Abmeldung findet nie statt.
|
||||
*
|
||||
* Der HTTP-Status haengt am geworfenen Fehler, damit der Aufrufer 401
|
||||
* ("Sitzung abgelaufen") von einem Netzfehler unterscheiden kann.
|
||||
*/
|
||||
export async function api(url, options = {}) {
|
||||
const opts = { credentials: "same-origin", ...options };
|
||||
const { auto = false, ...rest } = options;
|
||||
const opts = { credentials: "same-origin", ...rest };
|
||||
opts.headers = {
|
||||
"X-Requested-With": "fetch",
|
||||
"X-CSRF-Token": csrfToken(),
|
||||
...(auto ? { "X-Auto-Refresh": "1" } : {}),
|
||||
...(options.headers || {}),
|
||||
};
|
||||
const response = await fetch(url, opts);
|
||||
@@ -60,7 +72,9 @@ export async function api(url, options = {}) {
|
||||
const message =
|
||||
(payload && typeof payload === "object" && (payload.error || payload.message)) ||
|
||||
`Anfrage fehlgeschlagen (HTTP ${response.status})`;
|
||||
throw new Error(message);
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
@@ -556,7 +570,10 @@ function initAutoRefresh() {
|
||||
const tick = async () => {
|
||||
if (stopped || document.hidden) return schedule();
|
||||
try {
|
||||
const html = await api(region.dataset.refreshUrl, { headers: { Accept: "text/html" } });
|
||||
const html = await api(region.dataset.refreshUrl, {
|
||||
auto: true,
|
||||
headers: { Accept: "text/html" },
|
||||
});
|
||||
// Eine vollstaendige Seite gehoert nie in einen Ausschnitt. Zeigt die
|
||||
// URL versehentlich auf eine ganze Seite, waere das Ergebnis eine
|
||||
// Seite in der Seite -- bei jedem Durchlauf eine Ebene tiefer.
|
||||
@@ -588,7 +605,16 @@ function initAutoRefresh() {
|
||||
initRelativeTimes(region);
|
||||
initCountdowns(region);
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// 401 heisst: die Sitzung ist abgelaufen. Weiterpollen waere sinnlos,
|
||||
// und stehenbleiben waere irrefuehrend -- die Kacheln zeigten dann
|
||||
// beliebig alte Zahlen, als sei alles in Ordnung. Neu laden bringt die
|
||||
// Anmeldemaske mit ihrem Hinweis.
|
||||
if (error && error.status === 401) {
|
||||
stopped = true;
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
/* Netzfehler: beim naechsten Intervall erneut versuchen. */
|
||||
}
|
||||
schedule();
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<td class="fs-sm">{{ row.actor }}</td>
|
||||
<td class="mono fs-xs">{{ row.action }}</td>
|
||||
<td class="fs-sm">{{ row.target }}</td>
|
||||
<td class="fs-sm muted">{{ row.detail }}</td>
|
||||
<td class="fs-sm muted">{{ row.detail | audit_detail }}</td>
|
||||
<td class="mono fs-xs faint">{{ row.hash[:12] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
<td class="fs-sm fw-600">{{ row.actor }}</td>
|
||||
<td class="mono fs-xs">{{ row.action }}</td>
|
||||
<td class="fs-sm">{{ row.target }}</td>
|
||||
<td class="fs-sm muted break-all">{{ row.detail }}</td>
|
||||
<td class="fs-sm muted break-all">{{ row.detail | audit_detail }}</td>
|
||||
<td>
|
||||
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
|
||||
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
|
||||
|
||||
@@ -8,12 +8,13 @@ nichts, was die CSP aufweichen muesste.
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, url_for
|
||||
from markupsafe import Markup
|
||||
from markupsafe import Markup, escape
|
||||
|
||||
ICONS: dict[str, str] = {
|
||||
"grid": '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
|
||||
@@ -263,6 +264,89 @@ def asset_url(app: Flask) -> Any:
|
||||
return build
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Auditeintraege: Dateinamen anklickbar machen
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
#: Name einer Mitschrift, wie ``terminal.py`` sie bildet: Zeitstempel, Benutzer,
|
||||
#: Ziel. Der Ausdruck laesst bewusst keinen Schraegstrich zu -- der Name kommt
|
||||
#: aus einem Auditeintrag, und ein Eintrag ist Text, kein Pfad.
|
||||
MITSCHRIFT = re.compile(r"\b(\d{8}-\d{6}-[A-Za-z0-9._-]{1,140}\.log)\b")
|
||||
|
||||
#: Unterverzeichnisse unter dem Protokollverzeichnis, in denen nachgesehen wird.
|
||||
#: Der leere Eintrag steht fuer das Verzeichnis selbst.
|
||||
PROTOKOLL_ORTE = ("terminal", "")
|
||||
|
||||
|
||||
def _mitschrift_ziel(name: str) -> str | None:
|
||||
"""Der Name relativ zum Protokollverzeichnis -- oder ``None``.
|
||||
|
||||
Ein Verweis auf eine Datei, die es nicht mehr gibt, ist schlechter als
|
||||
keiner: er sieht nach einem Angebot aus und endet in 404. Protokolle werden
|
||||
aufgeraeumt, Auditeintraege bleiben -- der Fall ist die Regel, nicht die
|
||||
Ausnahme.
|
||||
"""
|
||||
from .extension import core
|
||||
|
||||
try:
|
||||
wurzel = core().config.log_dir
|
||||
except Exception: # noqa: BLE001 - ausserhalb eines App-Kontexts
|
||||
return None
|
||||
for ort in PROTOKOLL_ORTE:
|
||||
relativ = f"{ort}/{name}" if ort else name
|
||||
try:
|
||||
if (wurzel / relativ).is_file():
|
||||
return relativ
|
||||
except OSError: # pragma: no cover - Verzeichnis nicht lesbar
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def audit_detail(text: Any) -> Markup:
|
||||
"""Auditeintrag als Text -- Dateinamen darin als Verweis.
|
||||
|
||||
Der Eintrag zur Konsole nennt seine Mitschrift beim Namen. Bisher stand
|
||||
dort nur die Zeichenkette, und wer sehen wollte, was passiert ist, musste
|
||||
sie von Hand in die Protokollansicht uebertragen. Es ist derselbe Weg wie
|
||||
zuvor, nur einen Klick weit.
|
||||
|
||||
Verlinkt wird ausschliesslich, wenn die Datei existiert **und** der
|
||||
Betrachter Protokolldateien lesen darf -- sonst bliebe ein Verweis, der in
|
||||
403 endet. Der Rest des Eintrags wird maskiert; ein Auditeintrag enthaelt
|
||||
Fremdeingaben (Geraetenamen etwa) und darf niemals als HTML gelten.
|
||||
"""
|
||||
from .auth.models import current_user
|
||||
|
||||
roh = "" if text is None else str(text)
|
||||
if not roh or not MITSCHRIFT.search(roh):
|
||||
return Markup(escape(roh))
|
||||
try:
|
||||
erlaubt = bool(current_user.is_authenticated and current_user.has("logs_history.view"))
|
||||
except Exception: # noqa: BLE001 - ausserhalb einer Anfrage
|
||||
erlaubt = False
|
||||
if not erlaubt:
|
||||
return Markup(escape(roh))
|
||||
|
||||
def verweis(name: str) -> str:
|
||||
ziel = _mitschrift_ziel(name)
|
||||
if ziel is None:
|
||||
return str(escape(name))
|
||||
try:
|
||||
url = url_for("logs.history_file", name=ziel)
|
||||
except Exception: # noqa: BLE001 - Route fehlt in dieser Anwendung
|
||||
return str(escape(name))
|
||||
return f'<a href="{escape(url)}" title="Mitschrift ansehen">{escape(name)}</a>'
|
||||
|
||||
teile: list[str] = []
|
||||
ende = 0
|
||||
for treffer in MITSCHRIFT.finditer(roh):
|
||||
teile.append(str(escape(roh[ende : treffer.start()])))
|
||||
teile.append(verweis(treffer.group(1)))
|
||||
ende = treffer.end()
|
||||
teile.append(str(escape(roh[ende:])))
|
||||
return Markup("".join(teile))
|
||||
|
||||
def init_app(app: Flask) -> None:
|
||||
app.jinja_env.globals.update(
|
||||
icon=icon,
|
||||
@@ -293,6 +377,7 @@ def init_app(app: Flask) -> None:
|
||||
from_json=from_json,
|
||||
issuer_source=issuer_source,
|
||||
issuer_source_hint=issuer_source_hint,
|
||||
audit_detail=audit_detail,
|
||||
)
|
||||
app.jinja_env.trim_blocks = True
|
||||
app.jinja_env.lstrip_blocks = True
|
||||
|
||||
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-licensing-server"
|
||||
version = "2.0.2"
|
||||
version = "2.0.3"
|
||||
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["tesm-licensing==2.0.2", "cryptography>=41"]
|
||||
dependencies = ["tesm-licensing==2.0.3", "cryptography>=41"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-licensing"
|
||||
version = "2.0.2"
|
||||
version = "2.0.3"
|
||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["cryptography>=42"]
|
||||
|
||||
Reference in New Issue
Block a user