diff --git a/apps/tesm-license/pyproject.toml b/apps/tesm-license/pyproject.toml index 983e916..491c5ca 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.4.0" +version = "2.4.1" description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm-license/src/tesm_license/blueprints/backup.py b/apps/tesm-license/src/tesm_license/blueprints/backup.py index 47589cf..3954f06 100644 --- a/apps/tesm-license/src/tesm_license/blueprints/backup.py +++ b/apps/tesm-license/src/tesm_license/blueprints/backup.py @@ -57,7 +57,7 @@ def overview(): @bp.post("/export") @permission_required("backup.export") -@reauth_required +# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS). def export(): conn = db() allowed = {category.key for category in _allowed_categories()} diff --git a/apps/tesm-license/src/tesm_license/blueprints/licenses.py b/apps/tesm-license/src/tesm_license/blueprints/licenses.py index c1d0aad..2946205 100644 --- a/apps/tesm-license/src/tesm_license/blueprints/licenses.py +++ b/apps/tesm-license/src/tesm_license/blueprints/licenses.py @@ -142,7 +142,7 @@ def issue(ticket_id: str): @bp.get("//datei") @permission_required("licenses.export") -@reauth_required +# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS). def download(license_id: str): """Laedt das vollstaendige Auslieferungs-Bundle herunter. diff --git a/packages/tesm-core/pyproject.toml b/packages/tesm-core/pyproject.toml index 7f69ea6..64c242f 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.4.0" +version = "2.4.1" 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 de5b579..b85db96 100644 --- a/packages/tesm-core/src/tesm_core/app_factory.py +++ b/packages/tesm-core/src/tesm_core/app_factory.py @@ -135,7 +135,7 @@ def create_base_app( #: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide #: Anwendungen. auth_tagline: str = "", - app_version: str = "2.4.0", + app_version: str = "2.4.1", built_in_trusted_keys: tuple[str, ...] = (), enable_license: bool = True, template_folder: str | None = None, diff --git a/packages/tesm-core/src/tesm_core/auth/models.py b/packages/tesm-core/src/tesm_core/auth/models.py index ade0eeb..419d09d 100644 --- a/packages/tesm-core/src/tesm_core/auth/models.py +++ b/packages/tesm-core/src/tesm_core/auth/models.py @@ -220,6 +220,37 @@ def login_required(view: Callable[..., Any]) -> Callable[..., Any]: return wrapper +def _verlangt_wiederanmeldung(permission: str) -> bool: + """Fragt das Rechtemodell -- ausserhalb einer Anwendung nie.""" + try: + from ..extension import core + + return bool(core().permissions.verlangt_wiederanmeldung(permission)) + except Exception: # noqa: BLE001 - ohne Modell keine zusaetzliche Huerde + return False + + +def _wiederanmeldung_frisch() -> bool: + from .service import reauth_is_fresh # zyklischen Import vermeiden + + return reauth_is_fresh() + + +def _bitte_bestaetigen(permission: str) -> Any: + """Zur Passwortbestaetigung schicken -- oder es einem Skript sagen. + + Ein Aufruf per fetch bekaeme sonst die Anmeldeseite als Antwort und wuesste + nichts damit anzufangen. + """ + if is_automatic(request) or ( + request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html + ): + abort(403, description="Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.") + session["_after_reauth"] = request.full_path if request.query_string else request.path + flash("Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.", "info") + return redirect(url_for("auth.reauth")) + + def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]: """Erzwingt Rechte serverseitig. @@ -238,6 +269,14 @@ def permission_required(*permissions: str, mode: str = "all") -> Callable[..., A ) if not ok: return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.") + # Geheimnisse und Exporte verlangen eine frische Bestaetigung -- + # das steht im Rechtemodell und wird deshalb hier abgefragt, nicht + # in jeder Route einzeln. Vorher hing es daran, dass jemand an den + # Dekorator dachte: dieselbe Lizenz herunterzuladen fragte nach, + # sie per Mail zu verschicken nicht. + heikel = [p for p in permissions if _verlangt_wiederanmeldung(p)] + if heikel and not _wiederanmeldung_frisch(): + return _bitte_bestaetigen(heikel[0]) return view(*args, **kwargs) wrapper._required_permissions = permissions # type: ignore[attr-defined] diff --git a/packages/tesm-core/src/tesm_core/rbac/model.py b/packages/tesm-core/src/tesm_core/rbac/model.py index 0c5de71..fce1ed7 100644 --- a/packages/tesm-core/src/tesm_core/rbac/model.py +++ b/packages/tesm-core/src/tesm_core/rbac/model.py @@ -74,6 +74,11 @@ class Resource: hint: str = "" #: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint). action_hints: Mapping[Action, str] = field(default_factory=dict) + #: Aktionen, die hier **keine** frische Passwortbestaetigung verlangen, + #: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als + #: stilles Weglassen in der Route: eine Ausnahme von einer + #: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss. + ohne_wiederanmeldung: tuple[Action, ...] = () def permission(self, action: Action) -> str: return f"{self.key}.{action.value}" @@ -159,6 +164,25 @@ class PermissionTree: def resource(self, key: str) -> tuple[Area, Resource] | None: return self._resources.get(key) + def verlangt_wiederanmeldung(self, permission: str) -> bool: + """Braucht dieses Recht eine frische Passwortbestaetigung? + + Die Antwort steht im Modell (``SENSITIVE_ACTIONS``) und nicht in der + Route -- sonst vergisst sie die naechste, die dazukommt. + """ + resource_key, _, action_value = permission.rpartition(".") + entry = self._resources.get(resource_key) + if entry is None: + return False + _, resource = entry + try: + action = Action(action_value) + except ValueError: + return False + if action in resource.ohne_wiederanmeldung: + return False + return action in SENSITIVE_ACTIONS + def label_for(self, permission: str) -> str: """Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs.""" if permission.endswith(".view") and permission[:-5] in self._by_key: diff --git a/packages/tesm-licensing-server/pyproject.toml b/packages/tesm-licensing-server/pyproject.toml index 26bd820..6d97124 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.4.0" +version = "2.4.1" description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten" requires-python = ">=3.11" -dependencies = ["tesm-licensing==2.4.0", "cryptography>=41"] +dependencies = ["tesm-licensing==2.4.1", "cryptography>=41"] [tool.setuptools.packages.find] where = ["src"] diff --git a/packages/tesm-licensing/pyproject.toml b/packages/tesm-licensing/pyproject.toml index 8492d35..67c8ceb 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.4.0" +version = "2.4.1" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = ["cryptography>=42"]