TESM 2.0.0 -- Neubau
Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Der Lizenzserver liegt in seinem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger, jeweils an der Stelle im Code kommentiert, an der der Fehler entstanden ist: * Der Webprozess laeuft unprivilegiert. Alles, was Root braucht, geht ueber einen einzigen Helfer mit Positivlisten fuer jedes Argument. * CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen. * Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet. * Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert. * Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft (nginx, Kea). * Kein Hostname im Lizenz-Fingerabdruck. * Zwei Installationen auf einem Host stoeren sich nicht (eigener SITE_KEY). * Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.0.0"
|
||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"Flask>=3.1",
|
||||
"cryptography>=42",
|
||||
"argon2-cffi>=23",
|
||||
"tesm-licensing>=2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
ldap = ["ldap3>=2.9"]
|
||||
legacy-hashes = ["bcrypt>=4"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
tesm_core = ["templates/**/*.html", "static/**/*"]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""tesm_core -- gemeinsames Anwendungsgeruest von TESM und TESM-Lizenzserver.
|
||||
|
||||
Enthaelt alles, was beide Anwendungen teilen: Konfiguration, Datenbankzugriff,
|
||||
Migrationen, Schluesselverwaltung, Sitzungen, CSRF, Rechtesystem, Navigation,
|
||||
Benutzer-/Gruppen-/Kontoverwaltung, Papierkorb, Diagnose und das
|
||||
Design-System.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "2.0.0"
|
||||
|
||||
from .app_factory import create_base_app
|
||||
from .config import CoreConfig, SecurityConfig, load_core_config
|
||||
from .db import Database, NestedConnectionError
|
||||
from .extension import TesmCore, core, db, transaction
|
||||
from .migrations import Migration
|
||||
from .nav import NavItem, NavTree
|
||||
from .settings_store import SettingSpec, SettingsStore
|
||||
|
||||
__all__ = [
|
||||
"CoreConfig",
|
||||
"Database",
|
||||
"Migration",
|
||||
"NavItem",
|
||||
"NavTree",
|
||||
"NestedConnectionError",
|
||||
"SecurityConfig",
|
||||
"SettingSpec",
|
||||
"SettingsStore",
|
||||
"TesmCore",
|
||||
"core",
|
||||
"create_base_app",
|
||||
"db",
|
||||
"load_core_config",
|
||||
"transaction",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,374 @@
|
||||
"""Anwendungsfabrik: verdrahtet alle Bausteine zu einer lauffaehigen Flask-App.
|
||||
|
||||
Jede der beiden Anwendungen ruft ``create_base_app()`` und ergaenzt danach nur
|
||||
noch ihre eigenen Blueprints. Alles, was beide teilen -- Datenbank,
|
||||
Migrationen, Sitzungen, CSRF, Header, Protokollierung, Rechtesystem,
|
||||
Navigation, Benutzer-/Gruppenverwaltung, Kontoseite, Diagnose -- entsteht hier
|
||||
genau einmal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Sequence
|
||||
|
||||
from flask import Blueprint, Flask, g, render_template, request
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from . import audit, logging_setup, ui
|
||||
from .config import CoreConfig
|
||||
from .core_schema import CORE_MIGRATIONS
|
||||
from .db import Database
|
||||
from .extension import TesmCore, close_db, core, db
|
||||
from .keystore import Keystore, load_or_create_secret_key
|
||||
from .licensing_client import LicenseManager
|
||||
from .migrations import Migration
|
||||
from .nav import NavTree
|
||||
from .rbac import service as rbac
|
||||
from .rbac.model import PermissionTree, RolePreset
|
||||
from .security import csrf
|
||||
from .security import sessions as session_store
|
||||
from .security.sessions import SqliteSessionInterface
|
||||
from .settings_store import SettingSpec, SettingsStore
|
||||
from .sysops import SysOps
|
||||
from .auth import directory
|
||||
from .auth import routes as auth_routes
|
||||
from .auth import service as auth_service
|
||||
from .auth.models import ANONYMOUS, current_user
|
||||
from .views import account as account_views
|
||||
from .views import admin as admin_views
|
||||
from .views import diagnostics as diagnostics_views
|
||||
from .views import hostsettings as hostsettings_views
|
||||
from .views import logs as logs_views
|
||||
from .views import trash as trash_views
|
||||
|
||||
CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
SettingSpec("nav_order", [], "json", "Reihenfolge der Navigation", section="intern"),
|
||||
SettingSpec("nav_child_order", {}, "json", "Reihenfolge der Unterpunkte", section="intern"),
|
||||
SettingSpec(
|
||||
"trash_retention_days",
|
||||
30,
|
||||
"int",
|
||||
"Aufbewahrung im Papierkorb (Tage)",
|
||||
hint="Nach dieser Frist werden Eintraege beim naechsten Aufruf endgueltig entfernt.",
|
||||
section="system",
|
||||
minimum=1,
|
||||
maximum=3650,
|
||||
),
|
||||
SettingSpec("timezone", "", "str", "Zeitzone des Hosts", section="system"),
|
||||
SettingSpec("hostname", "", "str", "Hostname", section="system"),
|
||||
SettingSpec(
|
||||
"log_rotate_interval",
|
||||
"weekly",
|
||||
"str",
|
||||
"Rotationsintervall der Protokolle",
|
||||
section="system",
|
||||
choices=(("daily", "taeglich"), ("weekly", "woechentlich"), ("monthly", "monatlich")),
|
||||
),
|
||||
SettingSpec(
|
||||
"log_rotate_keep", 4, "int", "Aufbewahrte Rotationen", section="system", minimum=1, maximum=52
|
||||
),
|
||||
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
|
||||
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
|
||||
SettingSpec(
|
||||
"license_server_ca",
|
||||
"",
|
||||
"str",
|
||||
"Zertifikat des Lizenzservers (PEM)",
|
||||
section="license",
|
||||
),
|
||||
SettingSpec("web_domain", "", "str", "Domain", section="web"),
|
||||
SettingSpec("web_https_enabled", False, "bool", "HTTPS aktiv", section="web"),
|
||||
SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", section="web"),
|
||||
SettingSpec("web_redirect_http", True, "bool", "HTTP auf HTTPS umleiten", section="web"),
|
||||
SettingSpec("web_cert_path", "", "str", "Zertifikat (Pfad)", section="web"),
|
||||
SettingSpec("web_key_path", "", "str", "Privater Schluessel (Pfad)", section="web"),
|
||||
SettingSpec("web_acme_email", "", "str", "E-Mail fuer Let's Encrypt", section="web"),
|
||||
SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"),
|
||||
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
|
||||
# im Regelbetrieb bleibt es bei 80 und 443.
|
||||
SettingSpec(
|
||||
"web_http_port", 80, "int", "HTTP-Port", section="web", minimum=1, maximum=65535
|
||||
),
|
||||
SettingSpec(
|
||||
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
|
||||
),
|
||||
SettingSpec(
|
||||
"web_max_body_mb", 128, "int", "Maximale Anfragegroesse (MB)", section="web",
|
||||
minimum=1, maximum=8192,
|
||||
),
|
||||
*directory.SETTINGS,
|
||||
)
|
||||
|
||||
|
||||
def create_base_app(
|
||||
*,
|
||||
config: CoreConfig,
|
||||
permissions: PermissionTree,
|
||||
nav: NavTree,
|
||||
role_presets: Sequence[RolePreset],
|
||||
app_migrations: dict[str, Sequence[Migration]] | None = None,
|
||||
settings_specs: Sequence[SettingSpec] = (),
|
||||
import_name: str = "tesm_core",
|
||||
app_short: str = "TESM",
|
||||
app_edition: str = "",
|
||||
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = kein
|
||||
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
|
||||
#: Funktionsliste von TESM waere dort schlicht falsch.
|
||||
auth_tagline: str = "",
|
||||
app_version: str = "2.0.0",
|
||||
built_in_trusted_keys: tuple[str, ...] = (),
|
||||
enable_license: bool = True,
|
||||
template_folder: str | None = None,
|
||||
static_folder: str | None = None,
|
||||
) -> Flask:
|
||||
app = Flask(
|
||||
import_name,
|
||||
template_folder=template_folder or "templates",
|
||||
static_folder=static_folder or "static",
|
||||
static_url_path="/assets",
|
||||
)
|
||||
|
||||
# Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und
|
||||
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
|
||||
# Anwendung zusaetzlich ihre eigenen templates/static mitbringen, ohne dass
|
||||
# sich Pfade in die Quere kommen.
|
||||
core_bp = Blueprint(
|
||||
"tesm_core",
|
||||
"tesm_core",
|
||||
template_folder="templates",
|
||||
static_folder="static/tesm_core",
|
||||
static_url_path="/core-assets",
|
||||
)
|
||||
app.register_blueprint(core_bp)
|
||||
|
||||
config.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
config.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
app.config.update(
|
||||
SECRET_KEY=load_or_create_secret_key(config.secret_key_file),
|
||||
# site_key statt app_key: Cookies unterscheiden keine Ports. Zwei
|
||||
# Instanzen derselben Anwendung auf einem Host wuerden sich sonst
|
||||
# gegenseitig die Sitzung ueberschreiben.
|
||||
SESSION_COOKIE_NAME=f"{config.site_key.replace('-', '_')}_session",
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SECURE=config.security.cookie_secure,
|
||||
SESSION_COOKIE_SAMESITE=config.security.cookie_samesite,
|
||||
MAX_CONTENT_LENGTH=config.security.max_content_length,
|
||||
JSON_SORT_KEYS=False,
|
||||
TRAP_HTTP_EXCEPTIONS=False,
|
||||
PREFERRED_URL_SCHEME="https" if config.security.cookie_secure else "http",
|
||||
TEMPLATES_AUTO_RELOAD=config.debug,
|
||||
EXPLAIN_TEMPLATE_LOADING=False,
|
||||
)
|
||||
app.debug = config.debug
|
||||
|
||||
logging_setup.init_app(app, log_dir=config.log_dir, debug=config.debug)
|
||||
|
||||
database = Database(config.db_path)
|
||||
settings = SettingsStore([*CORE_SETTINGS, *settings_specs])
|
||||
keystore = Keystore(config.data_key_file)
|
||||
|
||||
extension = TesmCore(
|
||||
config=config,
|
||||
database=database,
|
||||
keystore=keystore,
|
||||
permissions=permissions,
|
||||
nav=nav,
|
||||
settings=settings,
|
||||
role_presets=tuple(role_presets),
|
||||
migrations={"core": CORE_MIGRATIONS, **(app_migrations or {})},
|
||||
sysops=SysOps(app_key=config.app_key, site_key=config.site_key),
|
||||
)
|
||||
if enable_license:
|
||||
extension.license = LicenseManager(
|
||||
database=database,
|
||||
settings=settings,
|
||||
license_path=config.license_path,
|
||||
key_path=config.license_key_path,
|
||||
app_key=config.app_key,
|
||||
built_in_trusted_keys=built_in_trusted_keys,
|
||||
)
|
||||
app.extensions["tesm"] = extension
|
||||
|
||||
app.session_interface = SqliteSessionInterface(
|
||||
database,
|
||||
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
||||
idle_minutes=config.security.session_idle_minutes,
|
||||
absolute_hours=config.security.session_absolute_hours,
|
||||
)
|
||||
|
||||
# -- Schema und Startzustand ------------------------------------------ #
|
||||
applied = extension.run_migrations()
|
||||
with database.session() as conn, database.transaction(conn):
|
||||
created = rbac.ensure_presets(conn, permissions, extension.role_presets)
|
||||
if applied or created:
|
||||
audit.log(
|
||||
conn,
|
||||
actor="system",
|
||||
action="system.startup_migration",
|
||||
detail="; ".join([*applied, *(f"Gruppe {name} angelegt" for name in created)]),
|
||||
severity="notice",
|
||||
)
|
||||
session_store.prune(conn)
|
||||
if extension.license is not None:
|
||||
with database.session() as conn:
|
||||
extension.license.reload(conn)
|
||||
|
||||
# -- Request-Lebenszyklus --------------------------------------------- #
|
||||
csrf.init_app(app)
|
||||
ui.init_app(app)
|
||||
app.teardown_appcontext(close_db)
|
||||
|
||||
@app.before_request
|
||||
def _prepare_request() -> None:
|
||||
g.csp_nonce = secrets.token_urlsafe(16)
|
||||
auth_service.load_request_user()
|
||||
|
||||
@app.after_request
|
||||
def _finish_request(response): # type: ignore[no-untyped-def]
|
||||
hsts = ""
|
||||
if config.security.hsts_enabled:
|
||||
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
|
||||
return csrf.apply_security_headers(
|
||||
response, nonce=getattr(g, "csp_nonce", ""), hsts=hsts
|
||||
)
|
||||
|
||||
# -- Template-Kontext -------------------------------------------------- #
|
||||
from .rbac.model import ACTION_META
|
||||
|
||||
@app.context_processor
|
||||
def _inject_context() -> dict[str, Any]:
|
||||
user = getattr(g, "user", ANONYMOUS)
|
||||
conn = db()
|
||||
order = settings.get(conn, "nav_order") or nav.default_order()
|
||||
child_order = settings.get(conn, "nav_child_order") or {}
|
||||
pills: list[dict[str, str]] = []
|
||||
if extension.license is not None and user.is_authenticated:
|
||||
pill = extension.license.topbar_pill()
|
||||
if pill:
|
||||
pills.append(pill)
|
||||
pills.extend(extension.registry.get("status_pill_builder", lambda _u: [])(user))
|
||||
|
||||
nav_items = nav.resolve(user, order=order, child_order=child_order)
|
||||
palette = [
|
||||
{**entry, "url": _safe_url(entry["endpoint"])}
|
||||
for entry in nav.searchable(user)
|
||||
if entry["endpoint"]
|
||||
]
|
||||
return {
|
||||
"app_name": config.app_name,
|
||||
"app_short": app_short,
|
||||
"app_edition": app_edition,
|
||||
"auth_tagline": auth_tagline,
|
||||
"app_version": app_version,
|
||||
"current_year": datetime.now(timezone.utc).year,
|
||||
"current_user": current_user,
|
||||
"nav_items": nav_items,
|
||||
"palette_entries": palette,
|
||||
"status_pills": pills,
|
||||
"csp_nonce": getattr(g, "csp_nonce", ""),
|
||||
"permission_tree": permissions,
|
||||
"action_meta": ACTION_META,
|
||||
"license_state": extension.license.state if extension.license else None,
|
||||
"license_active": extension.license.active() if extension.license else True,
|
||||
"module_active": (
|
||||
extension.license.module_active if extension.license else (lambda _n: True)
|
||||
),
|
||||
"feature_allowed": (
|
||||
extension.license.feature_allowed if extension.license else (lambda _n: True)
|
||||
),
|
||||
"migration_warning": bool(extension.registry.get("migration_warning")),
|
||||
}
|
||||
|
||||
# -- Fehlerseiten ------------------------------------------------------ #
|
||||
@app.errorhandler(HTTPException)
|
||||
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
|
||||
if _wants_json():
|
||||
return {
|
||||
"error": error.name,
|
||||
"message": error.description,
|
||||
"request_id": getattr(g, "request_id", ""),
|
||||
}, error.code or 500
|
||||
return (
|
||||
render_template(
|
||||
"tesm_core/error.html",
|
||||
code=error.code or 500,
|
||||
name=error.name,
|
||||
description=error.description,
|
||||
request_id=getattr(g, "request_id", ""),
|
||||
back_url=request.referrer or "/",
|
||||
),
|
||||
error.code or 500,
|
||||
)
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def _unhandled(error: Exception): # type: ignore[no-untyped-def]
|
||||
app.logger.exception("Unbehandelter Fehler: %s", error)
|
||||
if _wants_json():
|
||||
return {
|
||||
"error": "Interner Fehler",
|
||||
"message": "Der Vorgang konnte nicht abgeschlossen werden.",
|
||||
"request_id": getattr(g, "request_id", ""),
|
||||
}, 500
|
||||
return (
|
||||
render_template(
|
||||
"tesm_core/error.html",
|
||||
code=500,
|
||||
name="Interner Fehler",
|
||||
description=(
|
||||
"Der Vorgang konnte nicht abgeschlossen werden. Der Fehler wurde "
|
||||
"protokolliert."
|
||||
),
|
||||
request_id=getattr(g, "request_id", ""),
|
||||
back_url=request.referrer or "/",
|
||||
),
|
||||
500,
|
||||
)
|
||||
|
||||
# -- Gemeinsame Blueprints -------------------------------------------- #
|
||||
app.register_blueprint(auth_routes.bp)
|
||||
app.register_blueprint(admin_views.bp)
|
||||
app.register_blueprint(account_views.bp)
|
||||
app.register_blueprint(diagnostics_views.bp)
|
||||
app.register_blueprint(trash_views.bp)
|
||||
app.register_blueprint(hostsettings_views.bp)
|
||||
app.register_blueprint(logs_views.bp)
|
||||
with app.app_context():
|
||||
trash_views.register_core_providers()
|
||||
|
||||
app.add_url_rule("/gesundheit", "health", _health, methods=["GET"])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _safe_url(endpoint: str) -> str:
|
||||
from flask import url_for
|
||||
|
||||
try:
|
||||
return url_for(endpoint)
|
||||
except Exception: # noqa: BLE001 - Endpunkt existiert in dieser App nicht
|
||||
return ""
|
||||
|
||||
|
||||
def _wants_json() -> bool:
|
||||
if request.path.startswith("/api/"):
|
||||
return True
|
||||
accept = request.accept_mimetypes
|
||||
return bool(accept.accept_json and not accept.accept_html)
|
||||
|
||||
|
||||
def _health() -> tuple[dict[str, Any], int]:
|
||||
"""Gesundheitsendpunkt fuer install.sh und Monitoring.
|
||||
|
||||
Bewusst ohne Anmeldung, aber ohne jede verwertbare Information: nur
|
||||
"laeuft die App und antwortet die Datenbank".
|
||||
"""
|
||||
extension = core()
|
||||
try:
|
||||
with extension.database.session() as conn:
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
except Exception: # noqa: BLE001
|
||||
return {"status": "degraded"}, 503
|
||||
return {"status": "ok", "version": extension.config.app_key}, 200
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Aenderungsprotokoll mit Hash-Kette.
|
||||
|
||||
Ein Audit-Log, das der Administrator selbst manipulieren kann, ist kein
|
||||
Audit-Log. Der Vorgaenger schrieb einfache Zeilen in eine Tabelle -- wer
|
||||
Schreibzugriff auf die SQLite-Datei hatte (auf dem Zielsystem: jeder root, und
|
||||
die App lief als root), konnte Eintraege spurlos entfernen.
|
||||
|
||||
Hier bindet jede Zeile den Hash ihrer Vorgaengerin ein:
|
||||
|
||||
hash_n = sha256(hash_(n-1) || kanonische Felder von Zeile n)
|
||||
|
||||
Eine geloeschte oder geaenderte Zeile bricht die Kette an genau dieser Stelle.
|
||||
``verify_chain()`` findet den Bruch; die Diagnoseseite zeigt ihn an. Das
|
||||
verhindert Manipulation nicht, macht sie aber nachweisbar -- und genau darum
|
||||
geht es bei einem Protokoll.
|
||||
|
||||
Archivierung: ab ``archive_threshold`` Zeilen werden die aeltesten vollstaendigen
|
||||
Kalendertage in Dateien ``audit-JJJJ-MM-TT.jsonl`` ausgelagert, inklusive ihrer
|
||||
Hashes, sodass die Kette ueber die Archivgrenze hinweg pruefbar bleibt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .db import Database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GENESIS = "0" * 64
|
||||
ARCHIVE_THRESHOLD = 20000
|
||||
ARCHIVE_TARGET = 15000
|
||||
|
||||
SEVERITIES = ("info", "notice", "warning", "critical")
|
||||
|
||||
|
||||
def _canonical(row: dict[str, Any]) -> bytes:
|
||||
payload = {
|
||||
"ts": row.get("ts", ""),
|
||||
"actor": row.get("actor", ""),
|
||||
"actor_id": row.get("actor_id"),
|
||||
"action": row.get("action", ""),
|
||||
"target": row.get("target", ""),
|
||||
"detail": row.get("detail", ""),
|
||||
"ip": row.get("ip", ""),
|
||||
"request_id": row.get("request_id", ""),
|
||||
"severity": row.get("severity", "info"),
|
||||
}
|
||||
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def compute_hash(prev_hash: str, row: dict[str, Any]) -> str:
|
||||
return hashlib.sha256((prev_hash or GENESIS).encode("ascii") + _canonical(row)).hexdigest()
|
||||
|
||||
|
||||
def log(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
actor: str,
|
||||
action: str,
|
||||
target: str = "",
|
||||
detail: str = "",
|
||||
actor_id: int | None = None,
|
||||
ip: str = "",
|
||||
request_id: str = "",
|
||||
severity: str = "info",
|
||||
ts: datetime | None = None,
|
||||
) -> int:
|
||||
"""Schreibt einen Protokolleintrag auf der uebergebenen Verbindung.
|
||||
|
||||
Wichtig: **immer** die bestehende Verbindung nutzen. Eine Protokollfunktion,
|
||||
die selbst eine Verbindung oeffnet, blockiert die laufende Transaktion --
|
||||
das war im Vorgaenger eine reale Deadlock-Ursache.
|
||||
"""
|
||||
if severity not in SEVERITIES:
|
||||
severity = "info"
|
||||
row = {
|
||||
"ts": (ts or datetime.now(timezone.utc)).isoformat(timespec="seconds"),
|
||||
"actor": actor or "system",
|
||||
"actor_id": actor_id,
|
||||
"action": action,
|
||||
"target": target,
|
||||
"detail": detail,
|
||||
"ip": ip,
|
||||
"request_id": request_id,
|
||||
"severity": severity,
|
||||
}
|
||||
prev_hash = (
|
||||
Database.value(conn, "SELECT hash FROM audit_log ORDER BY id DESC LIMIT 1") or GENESIS
|
||||
)
|
||||
row_hash = compute_hash(str(prev_hash), row)
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO audit_log (ts, actor, actor_id, action, target, detail, ip, request_id, "
|
||||
"severity, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
row["ts"],
|
||||
row["actor"],
|
||||
row["actor_id"],
|
||||
row["action"],
|
||||
row["target"],
|
||||
row["detail"],
|
||||
row["ip"],
|
||||
row["request_id"],
|
||||
row["severity"],
|
||||
prev_hash,
|
||||
row_hash,
|
||||
),
|
||||
)
|
||||
return int(cursor.lastrowid or 0)
|
||||
|
||||
|
||||
def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
|
||||
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
|
||||
sql = "SELECT * FROM audit_log ORDER BY id"
|
||||
params: tuple[Any, ...] = ()
|
||||
if limit:
|
||||
sql = "SELECT * FROM (SELECT * FROM audit_log ORDER BY id DESC LIMIT ?) ORDER BY id"
|
||||
params = (limit,)
|
||||
|
||||
checked = 0
|
||||
expected_prev: str | None = None
|
||||
for row in conn.execute(sql, params):
|
||||
data = {key: row[key] for key in row.keys()}
|
||||
if expected_prev is not None and data["prev_hash"] != expected_prev:
|
||||
return {
|
||||
"ok": False,
|
||||
"checked": checked,
|
||||
"broken_at": data["id"],
|
||||
"reason": "Vorgaenger-Hash passt nicht -- eine Zeile wurde entfernt oder eingefuegt.",
|
||||
}
|
||||
if compute_hash(str(data["prev_hash"]), data) != data["hash"]:
|
||||
return {
|
||||
"ok": False,
|
||||
"checked": checked,
|
||||
"broken_at": data["id"],
|
||||
"reason": "Zeileninhalt passt nicht zum gespeicherten Hash -- Eintrag geaendert.",
|
||||
}
|
||||
expected_prev = str(data["hash"])
|
||||
checked += 1
|
||||
return {"ok": True, "checked": checked, "broken_at": None, "reason": ""}
|
||||
|
||||
|
||||
def search(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
query: str = "",
|
||||
action: str = "",
|
||||
actor: str = "",
|
||||
severity: str = "",
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if query:
|
||||
clauses.append("(target LIKE ? OR detail LIKE ? OR action LIKE ? OR actor LIKE ?)")
|
||||
params.extend([f"%{query}%"] * 4)
|
||||
if action:
|
||||
clauses.append("action = ?")
|
||||
params.append(action)
|
||||
if actor:
|
||||
clauses.append("actor = ?")
|
||||
params.append(actor)
|
||||
if severity:
|
||||
clauses.append("severity = ?")
|
||||
params.append(severity)
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
params.extend([limit, offset])
|
||||
return Database.all(
|
||||
conn,
|
||||
f"SELECT * FROM audit_log {where} ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
tuple(params),
|
||||
)
|
||||
|
||||
|
||||
def count(conn: sqlite3.Connection) -> int:
|
||||
return int(Database.value(conn, "SELECT COUNT(*) FROM audit_log", default=0) or 0)
|
||||
|
||||
|
||||
def distinct_actions(conn: sqlite3.Connection) -> list[str]:
|
||||
return [
|
||||
str(row["action"])
|
||||
for row in Database.all(conn, "SELECT DISTINCT action FROM audit_log ORDER BY action")
|
||||
]
|
||||
|
||||
|
||||
def archive_old_rows(
|
||||
conn: sqlite3.Connection,
|
||||
archive_dir: Path,
|
||||
*,
|
||||
threshold: int = ARCHIVE_THRESHOLD,
|
||||
target: int = ARCHIVE_TARGET,
|
||||
) -> list[Path]:
|
||||
"""Laagert ganze Kalendertage aus, bis die Tabelle unter ``target`` liegt.
|
||||
|
||||
Nur *vollstaendige* Tage werden ausgelagert -- der laufende Tag bleibt immer
|
||||
in der Datenbank, damit die Auditseite nie halbe Tage zeigt.
|
||||
"""
|
||||
total = count(conn)
|
||||
if total <= threshold:
|
||||
return []
|
||||
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
today = datetime.now(timezone.utc).date().isoformat()
|
||||
written: list[Path] = []
|
||||
|
||||
while total > target:
|
||||
# Nach Zeitstempel, nicht nach id: Eintraege koennen mit einem
|
||||
# nachgereichten ts geschrieben worden sein (Import, Migration).
|
||||
oldest_day = Database.value(conn, "SELECT MIN(substr(ts,1,10)) FROM audit_log")
|
||||
if not oldest_day or str(oldest_day) >= today:
|
||||
break
|
||||
rows = Database.all(
|
||||
conn,
|
||||
"SELECT * FROM audit_log WHERE substr(ts,1,10)=? ORDER BY id",
|
||||
(str(oldest_day),),
|
||||
)
|
||||
if not rows:
|
||||
break
|
||||
path = archive_dir / f"audit-{oldest_day}.jsonl"
|
||||
with path.open("a", encoding="utf-8", newline="\n") as stream:
|
||||
for row in rows:
|
||||
stream.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
conn.execute("DELETE FROM audit_log WHERE substr(ts,1,10)=?", (str(oldest_day),))
|
||||
written.append(path)
|
||||
total -= len(rows)
|
||||
logger.info("Auditlog: %s Zeilen von %s nach %s ausgelagert", len(rows), oldest_day, path)
|
||||
|
||||
return written
|
||||
|
||||
|
||||
def archived_days(archive_dir: Path) -> list[dict[str, Any]]:
|
||||
"""Liste der Archivdateien fuer die Verlaufsseite."""
|
||||
if not archive_dir.exists():
|
||||
return []
|
||||
entries: list[dict[str, Any]] = []
|
||||
for path in sorted(archive_dir.glob("audit-*.jsonl"), reverse=True):
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
entries.append(
|
||||
{
|
||||
"day": path.stem.removeprefix("audit-"),
|
||||
"path": path,
|
||||
"size": stat.st_size,
|
||||
"modified": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
),
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def read_archived_day(archive_dir: Path, day: str) -> list[dict[str, Any]]:
|
||||
path = archive_dir / f"audit-{day}.jsonl"
|
||||
if not path.is_file():
|
||||
return []
|
||||
rows: list[dict[str, Any]] = []
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
for line in stream:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except ValueError:
|
||||
continue
|
||||
return rows
|
||||
|
||||
|
||||
def summarize_by_day(rows: Iterable[dict[str, Any]]) -> list[tuple[str, int]]:
|
||||
buckets: dict[str, int] = defaultdict(int)
|
||||
for row in rows:
|
||||
buckets[str(row.get("ts", ""))[:10]] += 1
|
||||
return sorted(buckets.items(), reverse=True)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Authentisierung: Benutzerobjekt, Anmeldedienst, Verzeichnisanbindung."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import (
|
||||
ANONYMOUS,
|
||||
AnonymousUser,
|
||||
Group,
|
||||
User,
|
||||
admin_required,
|
||||
current_user,
|
||||
login_required,
|
||||
permission_required,
|
||||
reauth_required,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ANONYMOUS",
|
||||
"AnonymousUser",
|
||||
"Group",
|
||||
"User",
|
||||
"admin_required",
|
||||
"current_user",
|
||||
"login_required",
|
||||
"permission_required",
|
||||
"reauth_required",
|
||||
]
|
||||
@@ -0,0 +1,581 @@
|
||||
"""Anmeldung gegen Active Directory / LDAP (Same Sign-On).
|
||||
|
||||
"Same Sign-On", nicht "Single Sign-On": der Benutzer tippt sein
|
||||
Verzeichnispasswort weiterhin auf der normalen Anmeldeseite ein; es wird bei
|
||||
jeder Anmeldung live gegen das Verzeichnis geprueft, nie hier gespeichert.
|
||||
|
||||
Sicherheitsaenderungen gegenueber dem Vorgaenger:
|
||||
|
||||
* **Transportverschluesselung ist Pflicht** (LDAPS oder StartTLS). Der
|
||||
Vorgaenger erlaubte unverschluesseltes LDAP auf Port 389 ohne Warnung -- das
|
||||
Passwort ging im Klartext ueber das Netz. Wer es dennoch braucht, muss
|
||||
``ldap_allow_insecure`` ausdruecklich setzen; das wird bei jeder Anmeldung
|
||||
protokolliert.
|
||||
* **Serverzertifikat wird geprueft.** Der Vorgaenger validierte es nicht --
|
||||
LDAPS ohne Validierung schuetzt nur gegen passives Mitlesen, nicht gegen
|
||||
einen Man-in-the-Middle.
|
||||
* Das Bind-Passwort des Dienstkontos liegt AES-GCM-verschluesselt in
|
||||
``service_accounts`` (an den Zweck gebunden), nicht Fernet-verschluesselt
|
||||
ohne Kontext.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
import sqlite3
|
||||
import ssl
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
from .. import audit
|
||||
from ..db import Database
|
||||
from ..extension import core
|
||||
from ..settings_store import SettingSpec
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_PURPOSE = "ldap_bind"
|
||||
AAD_LDAP_BIND = "service_accounts.ldap_bind"
|
||||
|
||||
#: userAccountControl-Bit fuer "Konto deaktiviert".
|
||||
ACCOUNTDISABLE = 0x0002
|
||||
#: OID fuer rekursive Gruppenauflösung in AD.
|
||||
MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
|
||||
|
||||
Status = Literal["ok", "invalid", "unavailable", "disabled"]
|
||||
|
||||
SETTINGS: tuple[SettingSpec, ...] = (
|
||||
SettingSpec("ldap_enabled", False, "bool", "Verzeichnisanmeldung aktiv", section="ldap"),
|
||||
SettingSpec("ldap_host", "", "str", "Server (Hostname)", section="ldap"),
|
||||
SettingSpec("ldap_port", 636, "int", "Port", section="ldap", minimum=1, maximum=65535),
|
||||
SettingSpec(
|
||||
"ldap_security",
|
||||
"ldaps",
|
||||
"str",
|
||||
"Transport",
|
||||
section="ldap",
|
||||
choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")),
|
||||
),
|
||||
SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat pruefen", section="ldap"),
|
||||
SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
|
||||
SettingSpec(
|
||||
"ldap_allow_insecure",
|
||||
False,
|
||||
"bool",
|
||||
"Unverschluesselte Verbindung ausdruecklich erlauben",
|
||||
hint="Nur fuer Testumgebungen. Das Passwort geht dabei im Klartext ueber das Netz.",
|
||||
section="ldap",
|
||||
),
|
||||
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
|
||||
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
|
||||
SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"),
|
||||
SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis uebernehmen", section="ldap"),
|
||||
SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60),
|
||||
)
|
||||
|
||||
_USERNAME_RE = re.compile(r"^[A-Za-z0-9._\-@ ]{1,128}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DirectoryProfile:
|
||||
username: str
|
||||
dn: str
|
||||
first_name: str = ""
|
||||
last_name: str = ""
|
||||
email: str = ""
|
||||
group_dns: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DirectoryResult:
|
||||
status: Status
|
||||
profile: DirectoryProfile | None = None
|
||||
message: str = ""
|
||||
warnings: tuple[str, ...] = field(default=())
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def is_enabled(conn: sqlite3.Connection) -> bool:
|
||||
settings = core().settings
|
||||
if not settings.get(conn, "ldap_enabled"):
|
||||
return False
|
||||
return bool(settings.get(conn, "ldap_host"))
|
||||
|
||||
|
||||
def bind_credentials(conn: sqlite3.Connection) -> tuple[str, str]:
|
||||
"""Benutzer und Passwort des Bind-Dienstkontos (entschluesselt)."""
|
||||
row = Database.one(
|
||||
conn,
|
||||
"SELECT username, password_encrypted FROM service_accounts WHERE purpose=?",
|
||||
(SERVICE_PURPOSE,),
|
||||
)
|
||||
if row is None:
|
||||
return "", ""
|
||||
encrypted = str(row.get("password_encrypted") or "")
|
||||
if not encrypted:
|
||||
return str(row.get("username") or ""), ""
|
||||
try:
|
||||
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
|
||||
except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt
|
||||
logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschluesselt werden")
|
||||
return str(row.get("username") or ""), ""
|
||||
return str(row.get("username") or ""), secret
|
||||
|
||||
|
||||
def store_bind_credentials(conn: sqlite3.Connection, username: str, password: str | None) -> None:
|
||||
"""Speichert das Dienstkonto. ``password=None`` laesst das bestehende unveraendert."""
|
||||
existing = Database.one(
|
||||
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,)
|
||||
)
|
||||
encrypted = str(existing.get("password_encrypted") or "") if existing else ""
|
||||
if password:
|
||||
encrypted = core().keystore.encrypt(password, aad=AAD_LDAP_BIND)
|
||||
conn.execute(
|
||||
"INSERT INTO service_accounts (purpose, username, password_encrypted, updated_at) "
|
||||
"VALUES (?,?,?,?) ON CONFLICT(purpose) DO UPDATE SET username=excluded.username, "
|
||||
"password_encrypted=excluded.password_encrypted, updated_at=excluded.updated_at",
|
||||
(SERVICE_PURPOSE, username.strip(), encrypted, _now()),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Verbindung
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _tls_object(config: dict[str, Any]):
|
||||
from ldap3 import Tls
|
||||
|
||||
verify = bool(config["ldap_verify_cert"])
|
||||
ca_file = str(config["ldap_ca_file"] or "") or None
|
||||
return Tls(
|
||||
validate=ssl.CERT_REQUIRED if verify else ssl.CERT_NONE,
|
||||
version=ssl.PROTOCOL_TLS_CLIENT,
|
||||
ca_certs_file=ca_file,
|
||||
)
|
||||
|
||||
|
||||
def _server(config: dict[str, Any]):
|
||||
from ldap3 import Server
|
||||
|
||||
security = str(config["ldap_security"])
|
||||
return Server(
|
||||
str(config["ldap_host"]),
|
||||
port=int(config["ldap_port"]),
|
||||
use_ssl=security == "ldaps",
|
||||
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
|
||||
get_info="ALL",
|
||||
connect_timeout=int(config["ldap_timeout"]),
|
||||
)
|
||||
|
||||
|
||||
def _connect(config: dict[str, Any], user: str, password: str):
|
||||
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
|
||||
from ldap3 import Connection
|
||||
|
||||
security = str(config["ldap_security"])
|
||||
if security == "none" and not config["ldap_allow_insecure"]:
|
||||
raise PermissionError(
|
||||
"Unverschluesselte Verzeichnisverbindungen sind deaktiviert. "
|
||||
"Nutzen Sie LDAPS oder StartTLS."
|
||||
)
|
||||
connection = Connection(
|
||||
_server(config),
|
||||
user=user or None,
|
||||
password=password or None,
|
||||
auto_bind=False,
|
||||
raise_exceptions=False,
|
||||
receive_timeout=int(config["ldap_timeout"]),
|
||||
)
|
||||
if security == "starttls":
|
||||
if not connection.open():
|
||||
raise ConnectionError("Verbindung zum Verzeichnisdienst fehlgeschlagen")
|
||||
if not connection.start_tls():
|
||||
raise ConnectionError(
|
||||
f"StartTLS fehlgeschlagen: {connection.result.get('description', 'unbekannt')}"
|
||||
)
|
||||
if not connection.bind():
|
||||
return connection, False
|
||||
return connection, True
|
||||
|
||||
|
||||
def _read_config(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
settings = core().settings
|
||||
return settings.get_many(conn, [spec.key for spec in SETTINGS])
|
||||
|
||||
|
||||
def _base_dn(connection, config: dict[str, Any]) -> str:
|
||||
configured = str(config["ldap_base_dn"] or "").strip()
|
||||
if configured:
|
||||
return configured
|
||||
info = getattr(connection.server, "info", None)
|
||||
naming = getattr(info, "other", {}).get("defaultNamingContext") if info else None
|
||||
if isinstance(naming, list) and naming:
|
||||
return str(naming[0])
|
||||
if isinstance(naming, str):
|
||||
return naming
|
||||
contexts = getattr(info, "naming_contexts", None) if info else None
|
||||
if contexts:
|
||||
return str(contexts[0])
|
||||
return ""
|
||||
|
||||
|
||||
def _escape(value: str) -> str:
|
||||
"""RFC-4515-Escaping fuer Filterwerte -- verhindert LDAP-Injection."""
|
||||
replacements = {"\\": r"\5c", "*": r"\2a", "(": r"\28", ")": r"\29", "\x00": r"\00", "/": r"\2f"}
|
||||
return "".join(replacements.get(char, char) for char in value)
|
||||
|
||||
|
||||
def authenticate(conn: sqlite3.Connection, username: str, password: str) -> DirectoryResult:
|
||||
"""Suchen-und-Binden gegen das Verzeichnis."""
|
||||
try:
|
||||
import ldap3 # noqa: F401
|
||||
except ImportError:
|
||||
return DirectoryResult("unavailable", message="Das Paket ldap3 ist nicht installiert.")
|
||||
|
||||
if not _USERNAME_RE.match(username or ""):
|
||||
return DirectoryResult("invalid")
|
||||
|
||||
config = _read_config(conn)
|
||||
if not config.get("ldap_enabled") or not config.get("ldap_host"):
|
||||
return DirectoryResult("disabled")
|
||||
|
||||
bind_user, bind_password = bind_credentials(conn)
|
||||
warnings: list[str] = []
|
||||
if str(config["ldap_security"]) == "none":
|
||||
warnings.append("Unverschluesselte Verzeichnisverbindung in Benutzung")
|
||||
if not config["ldap_verify_cert"]:
|
||||
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprueft")
|
||||
|
||||
try:
|
||||
service_conn, bound = _connect(config, bind_user, bind_password)
|
||||
except (PermissionError, ConnectionError) as exc:
|
||||
logger.error("Verzeichnisdienst: %s", exc)
|
||||
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
|
||||
except (socket.error, ssl.SSLError, OSError) as exc:
|
||||
logger.error("Verzeichnisdienst nicht erreichbar: %s", exc)
|
||||
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
|
||||
|
||||
if not bound:
|
||||
detail = service_conn.result.get("description", "unbekannt") if service_conn.result else ""
|
||||
logger.error("Bind des Dienstkontos fehlgeschlagen: %s", detail)
|
||||
return DirectoryResult(
|
||||
"unavailable",
|
||||
message=f"Anmeldung des Dienstkontos fehlgeschlagen ({detail}).",
|
||||
warnings=tuple(warnings),
|
||||
)
|
||||
|
||||
try:
|
||||
base = _base_dn(service_conn, config)
|
||||
if not base:
|
||||
return DirectoryResult(
|
||||
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
|
||||
)
|
||||
|
||||
attribute = str(config["ldap_user_attribute"] or "sAMAccountName")
|
||||
email_attribute = str(config["ldap_email_attribute"] or "userPrincipalName")
|
||||
wanted = ["distinguishedName", "givenName", "sn", "userAccountControl", email_attribute, attribute]
|
||||
|
||||
service_conn.search(
|
||||
search_base=base,
|
||||
search_filter=f"(&(objectClass=user)({attribute}={_escape(username)}))",
|
||||
attributes=list(dict.fromkeys(wanted)),
|
||||
size_limit=2,
|
||||
)
|
||||
entries = list(service_conn.entries)
|
||||
if len(entries) != 1:
|
||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||
|
||||
entry = entries[0]
|
||||
dn = str(entry.entry_dn)
|
||||
control = _attr_int(entry, "userAccountControl")
|
||||
if control is not None and control & ACCOUNTDISABLE:
|
||||
return DirectoryResult("invalid", message="Konto im Verzeichnis deaktiviert.", warnings=tuple(warnings))
|
||||
|
||||
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
|
||||
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
|
||||
user_conn, user_bound = _connect(config, dn, password)
|
||||
try:
|
||||
if not user_bound:
|
||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||
finally:
|
||||
try:
|
||||
user_conn.unbind()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
group_dns: tuple[str, ...] = ()
|
||||
if config["ldap_group_sync"]:
|
||||
group_dns = _resolve_groups(service_conn, base, dn)
|
||||
|
||||
profile = DirectoryProfile(
|
||||
username=_attr(entry, attribute) or username,
|
||||
dn=dn,
|
||||
first_name=_attr(entry, "givenName"),
|
||||
last_name=_attr(entry, "sn"),
|
||||
email=_attr(entry, email_attribute),
|
||||
group_dns=group_dns,
|
||||
)
|
||||
return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
|
||||
finally:
|
||||
try:
|
||||
service_conn.unbind()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def _attr(entry: Any, name: str) -> str:
|
||||
try:
|
||||
value = entry[name].value
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
if isinstance(value, (list, tuple)):
|
||||
return str(value[0]) if value else ""
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _attr_int(entry: Any, name: str) -> int | None:
|
||||
raw = _attr(entry, name)
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]:
|
||||
"""Rekursive Gruppenauflösung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
|
||||
try:
|
||||
connection.search(
|
||||
search_base=base,
|
||||
search_filter=f"(member:{MATCHING_RULE_IN_CHAIN}:={_escape(user_dn)})",
|
||||
attributes=["distinguishedName", "cn"],
|
||||
size_limit=500,
|
||||
)
|
||||
return tuple(str(entry.entry_dn) for entry in connection.entries)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
|
||||
return ()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Schattenkonto
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def sync_account(conn: sqlite3.Connection, profile: DirectoryProfile) -> dict[str, Any]:
|
||||
"""Legt das lokale Schattenkonto an bzw. aktualisiert es und mappt Gruppen."""
|
||||
from ..rbac import service as rbac
|
||||
|
||||
row = rbac.find_user_by_username(conn, profile.username)
|
||||
with core().database.transaction(conn):
|
||||
if row is None:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, auth_source, first_name, "
|
||||
"last_name, email, created_at, updated_at) VALUES (?,?,0,'ldap',?,?,?,?,?)",
|
||||
(
|
||||
profile.username,
|
||||
"", # Kein lokales Passwort -- Anmeldung laeuft immer ueber das Verzeichnis.
|
||||
profile.first_name,
|
||||
profile.last_name,
|
||||
profile.email,
|
||||
_now(),
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
user_id = int(cursor.lastrowid or 0)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=profile.username,
|
||||
actor_id=user_id,
|
||||
action="auth.directory_account_created",
|
||||
target=profile.username,
|
||||
detail=profile.dn,
|
||||
severity="notice",
|
||||
)
|
||||
else:
|
||||
user_id = int(row["id"])
|
||||
conn.execute(
|
||||
"UPDATE users SET first_name=?, last_name=?, email=?, auth_source='ldap', "
|
||||
"updated_at=? WHERE id=?",
|
||||
(profile.first_name, profile.last_name, profile.email, _now(), user_id),
|
||||
)
|
||||
|
||||
_apply_group_mappings(conn, user_id, profile.group_dns)
|
||||
|
||||
result = rbac.get_user(conn, user_id)
|
||||
assert result is not None
|
||||
return result
|
||||
|
||||
|
||||
def _apply_group_mappings(
|
||||
conn: sqlite3.Connection, user_id: int, group_dns: tuple[str, ...]
|
||||
) -> None:
|
||||
"""Setzt die Gruppen additiv aus den Verzeichnisgruppen.
|
||||
|
||||
Bewusst *ersetzend*: wer im Verzeichnis aus einer Gruppe entfernt wurde,
|
||||
verliert das Recht bei der naechsten Anmeldung. Manuell in der Anwendung
|
||||
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
|
||||
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
|
||||
"""
|
||||
if not group_dns:
|
||||
return
|
||||
mappings = Database.all(
|
||||
conn, "SELECT ad_group_dn, app_group_id FROM ldap_group_mappings"
|
||||
)
|
||||
if not mappings:
|
||||
return
|
||||
mapped_group_ids = {int(row["app_group_id"]) for row in mappings}
|
||||
lowered = {dn.lower() for dn in group_dns}
|
||||
wanted = {
|
||||
int(row["app_group_id"])
|
||||
for row in mappings
|
||||
if str(row["ad_group_dn"]).lower() in lowered
|
||||
}
|
||||
|
||||
current = {int(row["id"]) for row in _user_group_ids(conn, user_id)}
|
||||
# Alles, was ueber ein Mapping kommt, wird neu gesetzt; alles andere bleibt.
|
||||
keep = {gid for gid in current if gid not in mapped_group_ids}
|
||||
final = keep | wanted
|
||||
|
||||
if final == current:
|
||||
return
|
||||
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||
[(user_id, gid) for gid in sorted(final)],
|
||||
)
|
||||
|
||||
|
||||
def _user_group_ids(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
|
||||
return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,))
|
||||
|
||||
|
||||
def search_directory_users(
|
||||
conn: sqlite3.Connection, query: str, *, limit: int = 20
|
||||
) -> list[dict[str, str]]:
|
||||
"""Live-Suche fuer die Vorab-Zuweisung von Verzeichniskonten."""
|
||||
if len(query.strip()) < 2:
|
||||
return []
|
||||
try:
|
||||
import ldap3 # noqa: F401
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
config = _read_config(conn)
|
||||
if not config.get("ldap_enabled") or not config.get("ldap_host"):
|
||||
return []
|
||||
bind_user, bind_password = bind_credentials(conn)
|
||||
try:
|
||||
connection, bound = _connect(config, bind_user, bind_password)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
if not bound:
|
||||
return []
|
||||
try:
|
||||
base = _base_dn(connection, config)
|
||||
attribute = str(config["ldap_user_attribute"] or "sAMAccountName")
|
||||
email_attribute = str(config["ldap_email_attribute"] or "userPrincipalName")
|
||||
escaped = _escape(query.strip())
|
||||
connection.search(
|
||||
search_base=base,
|
||||
search_filter=(
|
||||
f"(&(objectClass=user)(|({attribute}={escaped}*)"
|
||||
f"(givenName={escaped}*)(sn={escaped}*)(displayName={escaped}*)))"
|
||||
),
|
||||
attributes=[attribute, "givenName", "sn", email_attribute],
|
||||
size_limit=limit,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"username": _attr(entry, attribute),
|
||||
"first_name": _attr(entry, "givenName"),
|
||||
"last_name": _attr(entry, "sn"),
|
||||
"email": _attr(entry, email_attribute),
|
||||
"dn": str(entry.entry_dn),
|
||||
}
|
||||
for entry in connection.entries
|
||||
if _attr(entry, attribute)
|
||||
]
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
finally:
|
||||
try:
|
||||
connection.unbind()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def search_directory_groups(conn: sqlite3.Connection, query: str, *, limit: int = 20) -> list[dict[str, str]]:
|
||||
"""Live-Suche nach Verzeichnisgruppen fuer die Mapping-Oberflaeche."""
|
||||
if len(query.strip()) < 2:
|
||||
return []
|
||||
try:
|
||||
import ldap3 # noqa: F401
|
||||
except ImportError:
|
||||
return []
|
||||
config = _read_config(conn)
|
||||
if not config.get("ldap_enabled") or not config.get("ldap_host"):
|
||||
return []
|
||||
bind_user, bind_password = bind_credentials(conn)
|
||||
try:
|
||||
connection, bound = _connect(config, bind_user, bind_password)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
if not bound:
|
||||
return []
|
||||
try:
|
||||
base = _base_dn(connection, config)
|
||||
escaped = _escape(query.strip())
|
||||
connection.search(
|
||||
search_base=base,
|
||||
search_filter=f"(&(objectClass=group)(cn={escaped}*))",
|
||||
attributes=["cn", "distinguishedName"],
|
||||
size_limit=limit,
|
||||
)
|
||||
return [
|
||||
{"name": _attr(entry, "cn"), "dn": str(entry.entry_dn)} for entry in connection.entries
|
||||
]
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
finally:
|
||||
try:
|
||||
connection.unbind()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def test_connection(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Verbindungstest fuer die Einstellungsseite -- ohne Anmeldung eines Benutzers."""
|
||||
try:
|
||||
import ldap3 # noqa: F401
|
||||
except ImportError:
|
||||
return {"ok": False, "message": "Das Paket ldap3 ist nicht installiert."}
|
||||
config = _read_config(conn)
|
||||
if not config.get("ldap_host"):
|
||||
return {"ok": False, "message": "Es ist kein Server konfiguriert."}
|
||||
bind_user, bind_password = bind_credentials(conn)
|
||||
try:
|
||||
connection, bound = _connect(config, bind_user, bind_password)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"ok": False, "message": str(exc)}
|
||||
if not bound:
|
||||
detail = connection.result.get("description", "unbekannt") if connection.result else ""
|
||||
return {"ok": False, "message": f"Anmeldung des Dienstkontos fehlgeschlagen ({detail})."}
|
||||
try:
|
||||
base = _base_dn(connection, config)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": f"Verbindung erfolgreich. Basis-DN: {base or 'nicht ermittelbar'}",
|
||||
"base_dn": base,
|
||||
}
|
||||
finally:
|
||||
try:
|
||||
connection.unbind()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Benutzerobjekt, Rechtepruefung und Zugriffsdekoratoren."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Iterable, Sequence
|
||||
|
||||
from flask import abort, flash, g, redirect, request, session, url_for
|
||||
from werkzeug.local import LocalProxy
|
||||
|
||||
from ..rbac.model import Action, PermissionTree
|
||||
|
||||
AUTH_SOURCES = ("local", "ldap")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Group:
|
||||
id: int
|
||||
name: str
|
||||
is_system: bool = False
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class User:
|
||||
"""Angemeldeter Benutzer. Unveraenderlich fuer die Dauer eines Requests."""
|
||||
|
||||
id: int
|
||||
username: str
|
||||
is_admin: bool = False
|
||||
is_locked: bool = False
|
||||
auth_source: str = "local"
|
||||
first_name: str = ""
|
||||
last_name: str = ""
|
||||
email: str = ""
|
||||
avatar_filename: str = ""
|
||||
totp_enabled: bool = False
|
||||
must_change_password: bool = False
|
||||
groups: tuple[Group, ...] = ()
|
||||
granted: frozenset[str] = frozenset()
|
||||
_tree: PermissionTree | None = field(default=None, repr=False, compare=False)
|
||||
_effective: frozenset[str] | None = field(default=None, repr=False, compare=False)
|
||||
|
||||
# -- Anzeige ------------------------------------------------------- #
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
full = f"{self.first_name} {self.last_name}".strip()
|
||||
return full or self.username
|
||||
|
||||
@property
|
||||
def initials(self) -> str:
|
||||
parts = [p for p in (self.first_name, self.last_name) if p]
|
||||
if parts:
|
||||
return "".join(p[0] for p in parts[:2]).upper()
|
||||
return (self.username[:2] or "?").upper()
|
||||
|
||||
@property
|
||||
def role_label(self) -> str:
|
||||
if self.is_admin:
|
||||
return "Administrator"
|
||||
if self.groups:
|
||||
return ", ".join(group.name for group in self.groups)
|
||||
return "Ohne Gruppe"
|
||||
|
||||
@property
|
||||
def is_directory_account(self) -> bool:
|
||||
return self.auth_source == "ldap"
|
||||
|
||||
# -- Rechte -------------------------------------------------------- #
|
||||
|
||||
@property
|
||||
def effective_permissions(self) -> frozenset[str]:
|
||||
"""Rechte nach Anwendung der Bereichs-Kill-Switches."""
|
||||
if self._effective is None:
|
||||
tree = self._tree
|
||||
self._effective = (
|
||||
frozenset(tree.effective(self.granted)) if tree is not None else frozenset()
|
||||
)
|
||||
return self._effective
|
||||
|
||||
def has(self, *permissions: str) -> bool:
|
||||
"""True, wenn **alle** angegebenen Rechte greifen. Admins duerfen immer alles."""
|
||||
if self.is_admin:
|
||||
return True
|
||||
if not permissions:
|
||||
return True
|
||||
return all(permission in self.effective_permissions for permission in permissions)
|
||||
|
||||
def has_any(self, permissions: Iterable[str]) -> bool:
|
||||
if self.is_admin:
|
||||
return True
|
||||
effective = self.effective_permissions
|
||||
return any(permission in effective for permission in permissions)
|
||||
|
||||
def can(self, resource: str, action: Action | str) -> bool:
|
||||
value = action.value if isinstance(action, Action) else str(action)
|
||||
return self.has(f"{resource}.{value}")
|
||||
|
||||
def can_any(self, resource: str, *actions: Action | str) -> bool:
|
||||
return self.has_any(
|
||||
f"{resource}.{a.value if isinstance(a, Action) else a}" for a in actions
|
||||
)
|
||||
|
||||
def as_audit_actor(self) -> str:
|
||||
return self.username
|
||||
|
||||
|
||||
class AnonymousUser:
|
||||
"""Nicht angemeldet. Hat keine Rechte -- auch nicht ``view``."""
|
||||
|
||||
id = 0
|
||||
username = ""
|
||||
is_admin = False
|
||||
is_locked = False
|
||||
auth_source = "anonymous"
|
||||
first_name = ""
|
||||
last_name = ""
|
||||
email = ""
|
||||
avatar_filename = ""
|
||||
totp_enabled = False
|
||||
must_change_password = False
|
||||
groups: tuple[Group, ...] = ()
|
||||
granted: frozenset[str] = frozenset()
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "Gast"
|
||||
|
||||
@property
|
||||
def initials(self) -> str:
|
||||
return "?"
|
||||
|
||||
@property
|
||||
def role_label(self) -> str:
|
||||
return "Nicht angemeldet"
|
||||
|
||||
@property
|
||||
def is_directory_account(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def effective_permissions(self) -> frozenset[str]:
|
||||
return frozenset()
|
||||
|
||||
def has(self, *permissions: str) -> bool:
|
||||
return False
|
||||
|
||||
def has_any(self, permissions: Iterable[str]) -> bool:
|
||||
return False
|
||||
|
||||
def can(self, resource: str, action: Action | str) -> bool:
|
||||
return False
|
||||
|
||||
def can_any(self, resource: str, *actions: Action | str) -> bool:
|
||||
return False
|
||||
|
||||
def as_audit_actor(self) -> str:
|
||||
return "anonym"
|
||||
|
||||
|
||||
ANONYMOUS = AnonymousUser()
|
||||
|
||||
|
||||
def _lookup_user() -> User | AnonymousUser:
|
||||
return getattr(g, "user", ANONYMOUS)
|
||||
|
||||
|
||||
#: Aktueller Benutzer -- in Templates und Views gleich benutzbar.
|
||||
current_user: Any = LocalProxy(_lookup_user)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Dekoratoren
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _deny(message: str) -> Any:
|
||||
"""Nicht angemeldet -> Login; angemeldet aber ohne Recht -> 403."""
|
||||
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
|
||||
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")
|
||||
return redirect(url_for("auth.login"))
|
||||
abort(403, description=message)
|
||||
|
||||
|
||||
def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@functools.wraps(view)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
if not current_user.is_authenticated:
|
||||
return _deny("Anmeldung erforderlich.")
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
|
||||
"""Erzwingt Rechte serverseitig.
|
||||
|
||||
``mode="any"`` fuer Seiten, die mehrere unabhaengige Rechte akzeptieren --
|
||||
wichtig, weil im Vorgaenger eine Seite mit reinem Export-Recht nicht
|
||||
erreichbar war, obwohl das Recht vergeben war.
|
||||
"""
|
||||
|
||||
def decorator(view: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@functools.wraps(view)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
ok = (
|
||||
current_user.has_any(permissions)
|
||||
if mode == "any"
|
||||
else current_user.has(*permissions)
|
||||
)
|
||||
if not ok:
|
||||
return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.")
|
||||
return view(*args, **kwargs)
|
||||
|
||||
wrapper._required_permissions = permissions # type: ignore[attr-defined]
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def admin_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Nur echte Administratoren -- nicht ueber Gruppenrechte delegierbar.
|
||||
|
||||
Fuer Grenzen, die einen lokalen Admin *nicht* ueberleben muessen, aber auch
|
||||
nicht an eine Gruppe vergeben werden sollen (Rechtevergabe selbst,
|
||||
Navigationsreihenfolge, Schluesselrotation).
|
||||
"""
|
||||
|
||||
@functools.wraps(view)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
if not current_user.is_authenticated or not current_user.is_admin:
|
||||
return _deny("Diese Aktion ist Administratoren vorbehalten.")
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def reauth_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Verlangt eine frische Passworteingabe ("sudo-Modus").
|
||||
|
||||
Vorgeschaltet bei allem, was Geheimnisse offenlegt oder Daten aus dem System
|
||||
heraustraegt: Klartextpasswoerter, Export, Schluesselrotation, 2FA-Aenderung.
|
||||
Ein uebernommenes Sitzungscookie allein reicht dafuer nicht mehr aus.
|
||||
"""
|
||||
|
||||
@functools.wraps(view)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
from .service import reauth_is_fresh # zyklischen Import vermeiden
|
||||
|
||||
if not current_user.is_authenticated:
|
||||
return _deny("Anmeldung erforderlich.")
|
||||
if not reauth_is_fresh():
|
||||
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"))
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def build_user(
|
||||
row: dict[str, Any],
|
||||
*,
|
||||
groups: Sequence[Group],
|
||||
granted: Iterable[str],
|
||||
tree: PermissionTree,
|
||||
) -> User:
|
||||
return User(
|
||||
id=int(row["id"]),
|
||||
username=str(row["username"]),
|
||||
is_admin=bool(row.get("is_admin")),
|
||||
is_locked=bool(row.get("is_locked")),
|
||||
auth_source=str(row.get("auth_source") or "local"),
|
||||
first_name=str(row.get("first_name") or ""),
|
||||
last_name=str(row.get("last_name") or ""),
|
||||
email=str(row.get("email") or ""),
|
||||
avatar_filename=str(row.get("avatar_filename") or ""),
|
||||
totp_enabled=bool(row.get("totp_secret")),
|
||||
must_change_password=bool(row.get("must_change_password")),
|
||||
groups=tuple(groups),
|
||||
granted=frozenset(granted),
|
||||
_tree=tree,
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Anmelde-Routen: Login, zweiter Faktor, Abmeldung, erneute Bestaetigung."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||
|
||||
from ..extension import core, db
|
||||
from . import directory, service
|
||||
from .models import current_user, login_required
|
||||
|
||||
bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
def _safe_next(candidate: str | None, fallback_endpoint: str = "index") -> str:
|
||||
"""Verhindert Open Redirects: nur pfad-relative Ziele derselben Anwendung."""
|
||||
default = url_for(fallback_endpoint)
|
||||
if not candidate:
|
||||
return default
|
||||
parsed = urlparse(candidate)
|
||||
if parsed.scheme or parsed.netloc or not candidate.startswith("/"):
|
||||
return default
|
||||
return candidate
|
||||
|
||||
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(_safe_next(session.pop("_after_login", None)))
|
||||
|
||||
conn = db()
|
||||
username = ""
|
||||
if request.method == "POST":
|
||||
username = (request.form.get("username") or "").strip()
|
||||
password = request.form.get("password") or ""
|
||||
outcome = service.authenticate(
|
||||
conn, username, password, ip=service.client_ip()
|
||||
)
|
||||
|
||||
if outcome.status == "needs_totp" and outcome.user_row:
|
||||
service.start_totp_challenge(outcome.user_row)
|
||||
return redirect(url_for("auth.second_factor"))
|
||||
|
||||
if outcome.ok and outcome.user_row:
|
||||
user = service.complete_login(
|
||||
conn,
|
||||
outcome.user_row,
|
||||
ip=service.client_ip(),
|
||||
secret=password,
|
||||
group_dns=outcome.group_dns,
|
||||
)
|
||||
flash(f"Willkommen, {user.display_name}.", "success")
|
||||
if user.must_change_password:
|
||||
flash("Bitte legen Sie zuerst ein neues Passwort fest.", "warning")
|
||||
return redirect(url_for("account.security"))
|
||||
return redirect(_safe_next(session.pop("_after_login", None)))
|
||||
|
||||
flash(outcome.message or service.GENERIC_FAILURE, "danger")
|
||||
|
||||
return render_template(
|
||||
"tesm_core/login.html",
|
||||
username=username,
|
||||
directory_enabled=directory.is_enabled(conn),
|
||||
public_dashboard_url=core().registry.get("public_dashboard_url", ""),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/login/zweiter-faktor", methods=["GET", "POST"])
|
||||
def second_factor():
|
||||
conn = db()
|
||||
user_row = service.pending_totp_user(conn)
|
||||
if user_row is None:
|
||||
flash("Die Anmeldung ist abgelaufen. Bitte erneut versuchen.", "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
if request.method == "POST":
|
||||
outcome = service.verify_second_factor(
|
||||
conn, user_row, request.form.get("code") or "", ip=service.client_ip()
|
||||
)
|
||||
if outcome.ok:
|
||||
# Kein ``secret``: das Passwort liegt seit dem ersten Schritt nicht
|
||||
# mehr vor, und es fuer den zweiten Schritt in der Sitzung
|
||||
# zwischenzulagern waere ein schlechter Tausch. Freigaben, die die
|
||||
# Anmeldung des Benutzers weiterreichen, binden sich bei aktivem
|
||||
# zweitem Faktor deshalb nicht ein -- Freigaben mit hinterlegtem
|
||||
# Konto schon.
|
||||
user = service.complete_login(conn, user_row, ip=service.client_ip())
|
||||
flash(f"Willkommen, {user.display_name}.", "success")
|
||||
return redirect(_safe_next(session.pop("_after_login", None)))
|
||||
flash(outcome.message, "danger")
|
||||
|
||||
return render_template("tesm_core/second_factor.html")
|
||||
|
||||
|
||||
@bp.post("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
service.logout(db())
|
||||
flash("Sie wurden abgemeldet.", "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@bp.get("/logout")
|
||||
def logout_get():
|
||||
"""Abmeldung ueber einen Link -- fuer den Abbruch des 2FA-Schritts.
|
||||
|
||||
Aendert nur die eigene Sitzung und ist damit kein CSRF-relevanter Vorgang
|
||||
mit Datenwirkung; die eigentliche Abmeldung im angemeldeten Bereich laeuft
|
||||
trotzdem ueber POST mit Token.
|
||||
"""
|
||||
if current_user.is_authenticated:
|
||||
service.logout(db())
|
||||
else:
|
||||
session.clear()
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@bp.route("/bestaetigen", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def reauth():
|
||||
target = _safe_next(session.get("_after_reauth"))
|
||||
if request.method == "POST":
|
||||
password = request.form.get("password") or ""
|
||||
if service.confirm_password(db(), current_user, password):
|
||||
service.mark_reauth()
|
||||
session.pop("_after_reauth", None)
|
||||
return redirect(target)
|
||||
flash("Das Passwort ist nicht korrekt.", "danger")
|
||||
|
||||
return render_template(
|
||||
"tesm_core/reauth.html",
|
||||
cancel_url=url_for("index"),
|
||||
reauth_minutes=core().config.security.reauth_valid_minutes,
|
||||
)
|
||||
@@ -0,0 +1,499 @@
|
||||
"""Anmeldung, Sitzungsaufbau, erneute Passwortbestaetigung.
|
||||
|
||||
Ablauf einer Anmeldung (bewusst in dieser Reihenfolge):
|
||||
|
||||
1. Ratenbegrenzung pro IP **und** pro Benutzername pruefen -- bevor irgendein
|
||||
Hash berechnet wird.
|
||||
2. Lokales Konto suchen. Existiert es mit ``auth_source='local'``, wird das
|
||||
Passwort gegen den Argon2-Hash geprueft; existiert es nicht oder als
|
||||
Verzeichniskonto, greift (falls konfiguriert) die LDAP-Pruefung.
|
||||
3. Bei Erfolg: gesperrt? -> abweisen. 2FA aktiv? -> Zwischenschritt.
|
||||
4. Sitzung rotieren (neue ID), Rechte laden, Erfolg protokollieren.
|
||||
|
||||
Bei jedem Fehlschlag wird dieselbe, nichts verratende Meldung ausgegeben und
|
||||
dieselbe Rechenzeit verbraucht (``passwords.dummy_verify``) -- die Antwort
|
||||
erlaubt keinen Rueckschluss darauf, ob ein Konto existiert.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
from flask import current_app, g, request, session
|
||||
|
||||
from .. import audit
|
||||
from ..db import Database
|
||||
from ..extension import core, db
|
||||
from ..rbac import service as rbac
|
||||
from ..security import passwords, ratelimit
|
||||
from ..security import sessions as session_store
|
||||
from .models import ANONYMOUS, Group, User, build_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SESSION_USER_KEY = "user_id"
|
||||
SESSION_PENDING_KEY = "_pending_user_id"
|
||||
SESSION_REAUTH_KEY = "_reauth_at"
|
||||
|
||||
BUCKET_LOGIN_USER = "login:user"
|
||||
BUCKET_LOGIN_IP = "login:ip"
|
||||
BUCKET_TOTP = "totp:user"
|
||||
|
||||
GENERIC_FAILURE = "Benutzername oder Passwort ist falsch."
|
||||
|
||||
Status = Literal["ok", "invalid", "locked", "rate_limited", "needs_totp", "directory_unavailable"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthOutcome:
|
||||
status: Status
|
||||
message: str = ""
|
||||
user_row: dict[str, Any] | None = None
|
||||
retry_after: int = 0
|
||||
#: Verzeichnisgruppen der laufenden Anmeldung. Sie stehen nur hier zur
|
||||
#: Verfuegung -- gespeichert werden sie bewusst nicht, denn beim naechsten
|
||||
#: Mal koennen es andere sein.
|
||||
group_dns: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.status == "ok"
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
return value.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def client_ip() -> str:
|
||||
"""Client-IP unter Beruecksichtigung vertrauenswuerdiger Proxy-Hops."""
|
||||
hops = core().config.security.trusted_proxy_hops
|
||||
if hops > 0:
|
||||
forwarded = request.headers.get("X-Forwarded-For", "")
|
||||
if forwarded:
|
||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||
if parts:
|
||||
# Der letzte vertrauenswuerdige Hop steht von rechts gezaehlt.
|
||||
index = max(0, len(parts) - hops)
|
||||
return parts[index]
|
||||
return request.remote_addr or ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Benutzer laden
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def load_user(conn: sqlite3.Connection, user_id: int) -> User | None:
|
||||
row = Database.one(
|
||||
conn, "SELECT * FROM users WHERE id=? AND deleted_at IS NULL", (user_id,)
|
||||
)
|
||||
if row is None or row.get("is_locked"):
|
||||
return None
|
||||
groups = [
|
||||
Group(
|
||||
id=int(entry["id"]),
|
||||
name=str(entry["name"]),
|
||||
is_system=bool(entry["is_system"]),
|
||||
is_default=bool(entry["is_default"]),
|
||||
)
|
||||
for entry in rbac.user_groups(conn, user_id)
|
||||
]
|
||||
return build_user(
|
||||
row,
|
||||
groups=groups,
|
||||
granted=rbac.granted_permissions(conn, user_id),
|
||||
tree=core().permissions,
|
||||
)
|
||||
|
||||
|
||||
def load_request_user() -> None:
|
||||
"""``before_request``-Hook: setzt ``g.user``."""
|
||||
g.user = ANONYMOUS
|
||||
user_id = session.get(SESSION_USER_KEY)
|
||||
if not user_id:
|
||||
return
|
||||
user = load_user(db(), int(user_id))
|
||||
if user is None:
|
||||
# Konto geloescht, gesperrt oder Rechte entzogen -> Sitzung beenden.
|
||||
session.clear()
|
||||
return
|
||||
g.user = user
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Anmeldung
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def authenticate(
|
||||
conn: sqlite3.Connection, username: str, password: str, *, ip: str
|
||||
) -> AuthOutcome:
|
||||
security = core().config.security
|
||||
name = (username or "").strip()
|
||||
if not name or not password:
|
||||
return AuthOutcome("invalid", GENERIC_FAILURE)
|
||||
|
||||
ip_verdict = ratelimit.check(
|
||||
conn,
|
||||
BUCKET_LOGIN_IP,
|
||||
ip or "unbekannt",
|
||||
limit=security.login_max_attempts_per_ip,
|
||||
window_minutes=security.login_lockout_minutes,
|
||||
)
|
||||
if ip_verdict.blocked:
|
||||
return AuthOutcome(
|
||||
"rate_limited",
|
||||
"Zu viele Anmeldeversuche von dieser Adresse. Bitte spaeter erneut versuchen.",
|
||||
retry_after=ip_verdict.retry_after_seconds,
|
||||
)
|
||||
|
||||
user_verdict = ratelimit.check(
|
||||
conn,
|
||||
BUCKET_LOGIN_USER,
|
||||
name.lower(),
|
||||
limit=security.login_max_attempts,
|
||||
window_minutes=security.login_lockout_minutes,
|
||||
)
|
||||
if user_verdict.blocked:
|
||||
return AuthOutcome(
|
||||
"rate_limited",
|
||||
"Dieses Konto ist nach mehreren Fehlversuchen voruebergehend gesperrt.",
|
||||
retry_after=user_verdict.retry_after_seconds,
|
||||
)
|
||||
|
||||
row = rbac.find_user_by_username(conn, name)
|
||||
outcome = _verify_credentials(conn, row, name, password)
|
||||
|
||||
if not outcome.ok:
|
||||
with core().database.transaction(conn):
|
||||
ratelimit.record(conn, BUCKET_LOGIN_IP, ip or "unbekannt")
|
||||
ratelimit.record(conn, BUCKET_LOGIN_USER, name.lower())
|
||||
audit.log(
|
||||
conn,
|
||||
actor=name,
|
||||
action="auth.login_failed",
|
||||
target=name,
|
||||
detail=outcome.status,
|
||||
ip=ip,
|
||||
severity="warning",
|
||||
request_id=getattr(g, "request_id", ""),
|
||||
)
|
||||
return outcome
|
||||
|
||||
user_row = outcome.user_row or {}
|
||||
if bool(user_row.get("is_locked")):
|
||||
with core().database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor=str(user_row.get("username") or name),
|
||||
action="auth.login_locked",
|
||||
target=str(user_row.get("username") or name),
|
||||
ip=ip,
|
||||
severity="warning",
|
||||
)
|
||||
return AuthOutcome("locked", "Dieses Konto ist gesperrt. Bitte wenden Sie sich an die IT.")
|
||||
|
||||
with core().database.transaction(conn):
|
||||
ratelimit.clear(conn, BUCKET_LOGIN_USER, name.lower())
|
||||
|
||||
if str(user_row.get("totp_secret") or ""):
|
||||
return AuthOutcome("needs_totp", "", user_row=user_row, group_dns=outcome.group_dns)
|
||||
|
||||
if core().config.security.require_totp_for_admins and bool(user_row.get("is_admin")):
|
||||
# Kein harter Block: der Admin kommt herein, muss aber sofort 2FA
|
||||
# einrichten (die Kontoseite fuehrt ihn dorthin).
|
||||
logger.warning(
|
||||
"Administrator %s hat keine 2FA, obwohl sie verlangt wird", user_row.get("username")
|
||||
)
|
||||
|
||||
# ``outcome`` mitfuehren, nicht neu bauen: die Verzeichnisgruppen der
|
||||
# laufenden Anmeldung stehen nur dort. Wer hier ein frisches AuthOutcome
|
||||
# erzeugt, verliert sie lautlos -- und mit ihnen jede Freigabe, die daran
|
||||
# haengt.
|
||||
return AuthOutcome("ok", user_row=user_row, group_dns=outcome.group_dns)
|
||||
|
||||
|
||||
def _verify_credentials(
|
||||
conn: sqlite3.Connection, row: dict[str, Any] | None, name: str, password: str
|
||||
) -> AuthOutcome:
|
||||
from . import directory # lokaler Import: ldap3 ist optional
|
||||
|
||||
directory_enabled = directory.is_enabled(conn)
|
||||
|
||||
if row is not None and str(row.get("auth_source")) == "local":
|
||||
if not passwords.verify_password(str(row.get("password_hash") or ""), password):
|
||||
return AuthOutcome("invalid", GENERIC_FAILURE)
|
||||
if passwords.needs_rehash(str(row.get("password_hash") or "")):
|
||||
with core().database.transaction(conn):
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash=?, updated_at=? WHERE id=?",
|
||||
(passwords.hash_password(password), _iso(_now()), row["id"]),
|
||||
)
|
||||
return AuthOutcome("ok", user_row=row)
|
||||
|
||||
if not directory_enabled:
|
||||
passwords.dummy_verify()
|
||||
return AuthOutcome("invalid", GENERIC_FAILURE)
|
||||
|
||||
result = directory.authenticate(conn, name, password)
|
||||
if result.status == "unavailable":
|
||||
return AuthOutcome(
|
||||
"directory_unavailable",
|
||||
"Der Verzeichnisdienst ist derzeit nicht erreichbar. "
|
||||
"Bitte versuchen Sie es spaeter erneut.",
|
||||
)
|
||||
if result.status != "ok" or result.profile is None:
|
||||
return AuthOutcome("invalid", GENERIC_FAILURE)
|
||||
|
||||
user_row = directory.sync_account(conn, result.profile)
|
||||
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
|
||||
|
||||
|
||||
def complete_login(
|
||||
conn: sqlite3.Connection,
|
||||
user_row: dict[str, Any],
|
||||
*,
|
||||
ip: str,
|
||||
secret: str = "",
|
||||
group_dns: tuple[str, ...] = (),
|
||||
) -> User:
|
||||
"""Baut die Sitzung auf. Rotiert die Sitzungs-ID (Session-Fixation).
|
||||
|
||||
``secret`` ist das Passwort dieser Anmeldung. Es wird **nicht** gespeichert
|
||||
und nicht in die Sitzung geschrieben; es wird ausschliesslich an die
|
||||
angemeldeten Haken weitergereicht und ist danach weg. Gebraucht wird es
|
||||
genau von einem: der Einbindung von Dateifreigaben, die sich mit der
|
||||
Anmeldung des Benutzers am Fileserver anmeldet.
|
||||
"""
|
||||
from ..security import csrf
|
||||
|
||||
with core().database.transaction(conn):
|
||||
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
|
||||
conn.execute(
|
||||
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=str(user_row["username"]),
|
||||
actor_id=int(user_row["id"]),
|
||||
action="auth.login",
|
||||
target=str(user_row["username"]),
|
||||
detail=f"Quelle: {user_row.get('auth_source') or 'local'}",
|
||||
ip=ip,
|
||||
request_id=getattr(g, "request_id", ""),
|
||||
)
|
||||
|
||||
session.clear()
|
||||
session[SESSION_USER_KEY] = int(user_row["id"])
|
||||
session[SESSION_REAUTH_KEY] = _iso(_now())
|
||||
session["_ip"] = ip
|
||||
session["_ua"] = (request.user_agent.string or "")[:200]
|
||||
csrf.rotate_token()
|
||||
|
||||
user = load_user(conn, int(user_row["id"]))
|
||||
if user is None: # pragma: no cover - direkt nach Pruefung praktisch unmoeglich
|
||||
raise RuntimeError("Benutzer konnte nach der Anmeldung nicht geladen werden")
|
||||
g.user = user
|
||||
_run_login_hooks(conn, user, secret=secret, group_dns=group_dns)
|
||||
return user
|
||||
|
||||
|
||||
def _run_login_hooks(
|
||||
conn: sqlite3.Connection, user: User, *, secret: str, group_dns: tuple[str, ...]
|
||||
) -> None:
|
||||
"""Ruft die angemeldeten Haken auf -- ohne die Anmeldung zu gefaehrden.
|
||||
|
||||
Ein Haken, der scheitert, darf niemanden aussperren: was hier passiert, ist
|
||||
Zusatzarbeit (Freigaben einbinden), nicht die Anmeldung selbst. Deshalb
|
||||
wird jeder Fehler protokolliert und geschluckt.
|
||||
"""
|
||||
for hook in core().login_hooks:
|
||||
try:
|
||||
hook(conn, user, secret, group_dns)
|
||||
except Exception: # pragma: no cover - haengt am Fileserver
|
||||
current_app.logger.exception(
|
||||
"Anmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
|
||||
)
|
||||
|
||||
|
||||
def logout(conn: sqlite3.Connection) -> None:
|
||||
user = getattr(g, "user", ANONYMOUS)
|
||||
if user.is_authenticated:
|
||||
# Vor dem Protokolleintrag, solange die Sitzung noch steht: die Haken
|
||||
# raeumen auf, was zu ihr gehoert (z. B. eingebundene Freigaben).
|
||||
for hook in core().logout_hooks:
|
||||
try:
|
||||
hook(conn, user)
|
||||
except Exception: # pragma: no cover - haengt am Fileserver
|
||||
current_app.logger.exception(
|
||||
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
|
||||
)
|
||||
with core().database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor=user.username,
|
||||
actor_id=user.id,
|
||||
action="auth.logout",
|
||||
target=user.username,
|
||||
ip=client_ip(),
|
||||
)
|
||||
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
|
||||
g.user = ANONYMOUS
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Zweiter Faktor
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def start_totp_challenge(user_row: dict[str, Any]) -> None:
|
||||
session[SESSION_PENDING_KEY] = int(user_row["id"])
|
||||
session["_pending_since"] = _iso(_now())
|
||||
|
||||
|
||||
def pending_totp_user(conn: sqlite3.Connection) -> dict[str, Any] | None:
|
||||
user_id = session.get(SESSION_PENDING_KEY)
|
||||
if not user_id:
|
||||
return None
|
||||
started = session.get("_pending_since")
|
||||
if started:
|
||||
try:
|
||||
if _now() - datetime.fromisoformat(str(started)) > timedelta(minutes=5):
|
||||
session.pop(SESSION_PENDING_KEY, None)
|
||||
return None
|
||||
except ValueError:
|
||||
pass
|
||||
return Database.one(
|
||||
conn, "SELECT * FROM users WHERE id=? AND deleted_at IS NULL", (int(user_id),)
|
||||
)
|
||||
|
||||
|
||||
def verify_second_factor(
|
||||
conn: sqlite3.Connection, user_row: dict[str, Any], code: str, *, ip: str
|
||||
) -> AuthOutcome:
|
||||
from ..security import totp
|
||||
|
||||
security = core().config.security
|
||||
subject = str(user_row["id"])
|
||||
verdict = ratelimit.check(
|
||||
conn, BUCKET_TOTP, subject, limit=8, window_minutes=security.login_lockout_minutes
|
||||
)
|
||||
if verdict.blocked:
|
||||
return AuthOutcome(
|
||||
"rate_limited",
|
||||
"Zu viele Fehlversuche. Bitte warten Sie einen Moment.",
|
||||
retry_after=verdict.retry_after_seconds,
|
||||
)
|
||||
|
||||
cleaned = (code or "").strip()
|
||||
if totp.verify_code(str(user_row.get("totp_secret") or ""), cleaned):
|
||||
with core().database.transaction(conn):
|
||||
ratelimit.clear(conn, BUCKET_TOTP, subject)
|
||||
return AuthOutcome("ok", user_row=user_row)
|
||||
|
||||
if _consume_recovery_code(conn, int(user_row["id"]), cleaned, ip=ip):
|
||||
return AuthOutcome("ok", user_row=user_row)
|
||||
|
||||
with core().database.transaction(conn):
|
||||
ratelimit.record(conn, BUCKET_TOTP, subject)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=str(user_row["username"]),
|
||||
actor_id=int(user_row["id"]),
|
||||
action="auth.totp_failed",
|
||||
target=str(user_row["username"]),
|
||||
ip=ip,
|
||||
severity="warning",
|
||||
)
|
||||
return AuthOutcome("invalid", "Der Code ist nicht korrekt.")
|
||||
|
||||
|
||||
def _consume_recovery_code(
|
||||
conn: sqlite3.Connection, user_id: int, code: str, *, ip: str
|
||||
) -> bool:
|
||||
candidates = Database.all(
|
||||
conn,
|
||||
"SELECT id, code_hash FROM user_recovery_codes WHERE user_id=? AND used_at IS NULL",
|
||||
(user_id,),
|
||||
)
|
||||
normalized = code.strip().lower()
|
||||
for entry in candidates:
|
||||
if passwords.verify_password(str(entry["code_hash"]), normalized):
|
||||
with core().database.transaction(conn):
|
||||
conn.execute(
|
||||
"UPDATE user_recovery_codes SET used_at=? WHERE id=?",
|
||||
(_iso(_now()), entry["id"]),
|
||||
)
|
||||
remaining = int(
|
||||
Database.value(
|
||||
conn,
|
||||
"SELECT COUNT(*) FROM user_recovery_codes "
|
||||
"WHERE user_id=? AND used_at IS NULL",
|
||||
(user_id,),
|
||||
default=0,
|
||||
)
|
||||
or 0
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=str(
|
||||
Database.value(conn, "SELECT username FROM users WHERE id=?", (user_id,))
|
||||
or ""
|
||||
),
|
||||
actor_id=user_id,
|
||||
action="auth.recovery_code_used",
|
||||
detail=f"Verbleibende Codes: {remaining}",
|
||||
ip=ip,
|
||||
severity="notice",
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Erneute Bestaetigung ("sudo-Modus")
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def reauth_is_fresh() -> bool:
|
||||
stamp = session.get(SESSION_REAUTH_KEY)
|
||||
if not stamp:
|
||||
return False
|
||||
try:
|
||||
moment = datetime.fromisoformat(str(stamp))
|
||||
except ValueError:
|
||||
return False
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
window = timedelta(minutes=core().config.security.reauth_valid_minutes)
|
||||
return _now() - moment <= window
|
||||
|
||||
|
||||
def mark_reauth() -> None:
|
||||
session[SESSION_REAUTH_KEY] = _iso(_now())
|
||||
|
||||
|
||||
def confirm_password(conn: sqlite3.Connection, user: User, password: str) -> bool:
|
||||
"""Prueft das Passwort des angemeldeten Benutzers erneut.
|
||||
|
||||
Fuer Verzeichniskonten laeuft die Pruefung gegen das Verzeichnis -- sonst
|
||||
koennten LDAP-Nutzer den sudo-Modus nie erreichen.
|
||||
"""
|
||||
from . import directory
|
||||
|
||||
row = rbac.get_user(conn, user.id)
|
||||
if row is None:
|
||||
return False
|
||||
if str(row.get("auth_source")) == "local":
|
||||
return passwords.verify_password(str(row.get("password_hash") or ""), password)
|
||||
if not directory.is_enabled(conn):
|
||||
return False
|
||||
return directory.authenticate(conn, user.username, password).status == "ok"
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Der Befehl ``web-setup`` -- HTTPS einrichten, ohne nginx von Hand anzufassen.
|
||||
|
||||
Beide Anwendungen registrieren ihn; die Logik steht darum genau einmal hier.
|
||||
|
||||
Warum es diesen Befehl ueberhaupt gibt: die nginx-Site wird ausschliesslich aus
|
||||
der Vorlage in :mod:`tesm_core.system.webserver` erzeugt -- das ist die
|
||||
Konsequenz aus dem alias-Fehler des Vorgaengers, der jahrelang ueberlebte, weil
|
||||
irgendwann jemand die Datei von Hand angelegt hatte. Ein Installationsskript,
|
||||
das sich seine eigene TLS-Konfiguration zusammensetzt, waere derselbe Fehler in
|
||||
neu. Stattdessen setzt dieser Befehl die Einstellungen und laesst die Anwendung
|
||||
schreiben -- genau wie die Oberflaeche es tut.
|
||||
|
||||
Aufruf aus ``install.sh``:
|
||||
|
||||
tesm-admin web-setup --https --self-signed --hsts --apply
|
||||
tesm-admin web-setup --https --domain tesm.example.org --acme-email a@b.c --apply
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from .system import webserver
|
||||
|
||||
|
||||
def _config_from_settings(extension: Any, conn: Any) -> webserver.WebConfig:
|
||||
values = extension.settings.get_many(
|
||||
conn,
|
||||
[
|
||||
"web_domain",
|
||||
"web_https_enabled",
|
||||
"web_hsts_enabled",
|
||||
"web_redirect_http",
|
||||
"web_cert_path",
|
||||
"web_key_path",
|
||||
"web_acme_webroot",
|
||||
"web_max_body_mb",
|
||||
"web_http_port",
|
||||
"web_https_port",
|
||||
],
|
||||
)
|
||||
return webserver.WebConfig(
|
||||
app_key=extension.config.site_key,
|
||||
upstream=str(extension.registry.get("upstream", "127.0.0.1:5000")),
|
||||
static_root=str(
|
||||
extension.registry.get("static_root", extension.config.base_dir / "static")
|
||||
),
|
||||
domain=str(values["web_domain"] or ""),
|
||||
https_enabled=bool(values["web_https_enabled"]),
|
||||
hsts_enabled=bool(values["web_hsts_enabled"]),
|
||||
redirect_http=bool(values["web_redirect_http"]),
|
||||
cert_path=str(values["web_cert_path"] or ""),
|
||||
key_path=str(values["web_key_path"] or ""),
|
||||
acme_webroot=str(
|
||||
values["web_acme_webroot"] or f"/var/www/{extension.config.site_key}-acme"
|
||||
),
|
||||
websocket_path=str(extension.registry.get("websocket_path", "")),
|
||||
max_body_mb=int(values["web_max_body_mb"] or 128),
|
||||
http_port=int(values["web_http_port"] or 80),
|
||||
https_port=int(values["web_https_port"] or 443),
|
||||
)
|
||||
|
||||
|
||||
def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
||||
"""Setzt die Webserver-Einstellungen und schreibt optional die Site-Datei."""
|
||||
sysops = extension.sysops
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
values: dict[str, Any] = {}
|
||||
|
||||
if args.domain is not None:
|
||||
try:
|
||||
values["web_domain"] = webserver.validate_domain(args.domain)
|
||||
except ValueError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 2
|
||||
if args.acme_email:
|
||||
values["web_acme_email"] = args.acme_email.strip()
|
||||
if args.max_body_mb:
|
||||
values["web_max_body_mb"] = int(args.max_body_mb)
|
||||
for flag, key in (("http_port", "web_http_port"), ("https_port", "web_https_port")):
|
||||
port = getattr(args, flag, None)
|
||||
if port:
|
||||
if not 1 <= int(port) <= 65535:
|
||||
print(f"Ungueltiger Port: {port}", file=sys.stderr)
|
||||
return 2
|
||||
values[key] = int(port)
|
||||
|
||||
with extension.database.transaction(conn):
|
||||
if values:
|
||||
extension.settings.set_many(conn, values, actor="install")
|
||||
|
||||
# -- Zertifikat --------------------------------------------------- #
|
||||
config = _config_from_settings(extension, conn)
|
||||
cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unveraendert."}
|
||||
|
||||
if args.acme:
|
||||
if sysops is None or not sysops.available:
|
||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
||||
return 2
|
||||
cert = webserver.request_certificate(
|
||||
sysops,
|
||||
domain=config.domain,
|
||||
email=str(extension.settings.get(conn, "web_acme_email") or ""),
|
||||
webroot=config.acme_webroot,
|
||||
)
|
||||
if not cert["ok"] and args.self_signed:
|
||||
# Kein oeffentliches Zertifikat bekommen -- lieber
|
||||
# selbstsigniertes HTTPS als gar keins. Der Aufrufer erfaehrt
|
||||
# beides.
|
||||
print(f"Let's Encrypt fehlgeschlagen: {cert.get('detail', cert['message'])}")
|
||||
print("Weiche auf ein selbstsigniertes Zertifikat aus.")
|
||||
args.acme = False
|
||||
elif not cert["ok"]:
|
||||
print(cert.get("detail") or cert["message"], file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.self_signed and not args.acme:
|
||||
if sysops is None or not sysops.available:
|
||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
||||
return 2
|
||||
suggested = webserver.local_names()
|
||||
cert = webserver.create_self_signed(
|
||||
sysops,
|
||||
common_name=config.domain or (suggested[0] if suggested else "localhost"),
|
||||
alt_names=[*suggested, "localhost"],
|
||||
)
|
||||
if not cert["ok"]:
|
||||
print(cert.get("detail") or cert["message"], file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if cert.get("cert_path"):
|
||||
with extension.database.transaction(conn):
|
||||
extension.settings.set_many(
|
||||
conn,
|
||||
{"web_cert_path": cert["cert_path"], "web_key_path": cert["key_path"]},
|
||||
actor="install",
|
||||
)
|
||||
print(cert["message"])
|
||||
|
||||
# -- HTTPS-Schalter ------------------------------------------------ #
|
||||
config = _config_from_settings(extension, conn)
|
||||
if args.https:
|
||||
if not (config.cert_path and config.key_path):
|
||||
print(
|
||||
"HTTPS verlangt Zertifikat und Schluessel -- bitte --self-signed "
|
||||
"oder --acme angeben.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
with extension.database.transaction(conn):
|
||||
extension.settings.set_many(
|
||||
conn,
|
||||
{
|
||||
"web_https_enabled": True,
|
||||
"web_hsts_enabled": bool(args.hsts),
|
||||
"web_redirect_http": not args.no_redirect,
|
||||
},
|
||||
actor="install",
|
||||
)
|
||||
|
||||
config = _config_from_settings(extension, conn)
|
||||
rendered = webserver.render(config)
|
||||
|
||||
if args.print_config:
|
||||
print(rendered)
|
||||
return 0
|
||||
|
||||
if not args.apply:
|
||||
print("Einstellungen gespeichert. Mit --apply wird nginx neu geschrieben.")
|
||||
return 0
|
||||
|
||||
if sysops is None or not sysops.available:
|
||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
||||
return 2
|
||||
result = webserver.apply_config(sysops, rendered)
|
||||
print(result["message"])
|
||||
if not result["ok"]:
|
||||
print(result.get("detail", ""), file=sys.stderr)
|
||||
return 1
|
||||
if config.https_enabled:
|
||||
detail = f"HTTPS auf {config.https_port}, {config.http_port} leitet um"
|
||||
else:
|
||||
detail = f"HTTP auf {config.http_port}"
|
||||
print(f"nginx: {config.server_name} -> {config.upstream} -- {detail}")
|
||||
return 0
|
||||
|
||||
|
||||
def add_web_setup(sub: Any, runner: Any) -> None:
|
||||
"""Haengt ``web-setup`` an einen ``argparse``-Subparser."""
|
||||
parser = sub.add_parser(
|
||||
"web-setup",
|
||||
help="nginx und TLS einrichten (Port 80/443)",
|
||||
description=(
|
||||
"Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. "
|
||||
"Die Datei wird immer vollstaendig neu geschrieben -- eine von Hand "
|
||||
"bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--https", action="store_true", help="HTTPS auf Port 443 aktivieren")
|
||||
parser.add_argument(
|
||||
"--self-signed",
|
||||
action="store_true",
|
||||
help="Selbstsigniertes Zertifikat erzeugen (geschlossene Netze ohne Domain)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--acme",
|
||||
action="store_true",
|
||||
help="Zertifikat per Let's Encrypt anfordern (Domain muss oeffentlich zeigen)",
|
||||
)
|
||||
parser.add_argument("--domain", help="Oeffentlicher Name dieser Installation")
|
||||
parser.add_argument("--acme-email", help="Kontaktadresse fuer Let's Encrypt")
|
||||
parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen")
|
||||
parser.add_argument(
|
||||
"--no-redirect",
|
||||
action="store_true",
|
||||
help="Port 80 nicht auf HTTPS umleiten (Sonderfall)",
|
||||
)
|
||||
parser.add_argument("--max-body-mb", type=int, help="Obergrenze fuer Uploads")
|
||||
parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)")
|
||||
parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)")
|
||||
parser.add_argument("--apply", action="store_true", help="nginx schreiben, pruefen, neu laden")
|
||||
parser.add_argument(
|
||||
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
|
||||
)
|
||||
parser.set_defaults(func=runner)
|
||||
|
||||
|
||||
__all__ = ["add_web_setup", "run_web_setup"]
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Konfiguration aus Umgebungsvariablen -- typisiert, an einer Stelle.
|
||||
|
||||
Im Vorgaengerprojekt waren Pfade und Konstanten quer durch eine mehrere
|
||||
tausend Zeilen lange ``app.py`` verstreut. Hier gibt es genau ein Objekt, das
|
||||
beim Start gebaut, validiert und danach nicht mehr veraendert wird.
|
||||
|
||||
Namensraum: jede Anwendung bekommt ein eigenes Env-Praefix (``TESM_`` bzw.
|
||||
``TESM_LICENSE_``), damit beide Dienste auf demselben Host ohne Kollisionen
|
||||
laufen koennen -- inklusive der Pfade, die im Vorgaenger kollidierten
|
||||
(netplan-Override!).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field, replace
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _env(prefix: str, name: str, default: str = "") -> str:
|
||||
return os.environ.get(f"{prefix}{name}", default).strip()
|
||||
|
||||
|
||||
def _env_bool(prefix: str, name: str, default: bool) -> bool:
|
||||
raw = _env(prefix, name)
|
||||
if not raw:
|
||||
return default
|
||||
return raw.lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _env_int(prefix: str, name: str, default: int) -> int:
|
||||
raw = _env(prefix, name)
|
||||
try:
|
||||
return int(raw) if raw else default
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SecurityConfig:
|
||||
"""Alles, was das Sicherheitsverhalten steuert. Defaults sind die sichere Wahl."""
|
||||
|
||||
#: Cookie nur ueber HTTPS ausliefern. In Produktion immer True (nginx terminiert TLS).
|
||||
cookie_secure: bool = True
|
||||
cookie_samesite: str = "Lax"
|
||||
#: Leerlauf-Zeitfenster einer Sitzung.
|
||||
session_idle_minutes: int = 60
|
||||
#: Harte Obergrenze unabhaengig von Aktivitaet.
|
||||
session_absolute_hours: int = 12
|
||||
#: Wie lange eine erneute Passwortabfrage ("sudo-Modus") gilt.
|
||||
reauth_valid_minutes: int = 15
|
||||
#: Fehlversuche bis zur temporaeren Sperre eines Kontos.
|
||||
login_max_attempts: int = 8
|
||||
login_lockout_minutes: int = 15
|
||||
#: Fehlversuche pro IP im gleichen Zeitfenster (unabhaengig vom Konto).
|
||||
login_max_attempts_per_ip: int = 30
|
||||
#: Mindestlaenge lokaler Passwoerter.
|
||||
password_min_length: int = 12
|
||||
#: 2FA fuer Administratoren erzwingen.
|
||||
require_totp_for_admins: bool = False
|
||||
#: HSTS-Header setzen (nur sinnvoll, wenn TLS tatsaechlich terminiert wird).
|
||||
hsts_enabled: bool = False
|
||||
hsts_max_age: int = 15552000
|
||||
#: Vertrauenswuerdige Proxy-Hops fuer X-Forwarded-For (0 = Header ignorieren).
|
||||
trusted_proxy_hops: int = 1
|
||||
#: Maximale Groesse eines Uploads in Bytes (None = unbegrenzt).
|
||||
max_content_length: int | None = 64 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CoreConfig:
|
||||
"""Basiskonfiguration, die beide Anwendungen teilen."""
|
||||
|
||||
app_key: str
|
||||
"""Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units."""
|
||||
|
||||
site_key: str
|
||||
"""Name *dieser Installation* auf dem Host, z. B. ``tesm-opus``.
|
||||
|
||||
Weicht vom ``app_key`` ab, sobald neben der Hauptinstallation eine zweite
|
||||
Instanz laeuft. Alles, was ausserhalb von ``/srv/<name>`` liegt und damit
|
||||
zwischen Instanzen kollidieren koennte -- nginx-Site, netplan-Datei,
|
||||
logrotate-Regel, Zertifikatsverzeichnis, Name des Sitzungscookies --, wird
|
||||
ueber diesen Schluessel benannt und **nicht** ueber den ``app_key``.
|
||||
|
||||
Ohne diese Trennung wuerde eine Testinstanz beim Speichern der
|
||||
Webserver-Einstellungen die Site-Datei der Produktion ueberschreiben, und
|
||||
beide Instanzen wuerden sich auf demselben Host dasselbe Sitzungscookie
|
||||
teilen (Cookies unterscheiden keine Ports).
|
||||
"""
|
||||
|
||||
app_name: str
|
||||
"""Anzeigename in der Oberflaeche."""
|
||||
|
||||
env_prefix: str
|
||||
base_dir: Path
|
||||
data_dir: Path
|
||||
log_dir: Path
|
||||
db_path: Path
|
||||
secret_key_file: Path
|
||||
data_key_file: Path
|
||||
license_path: Path
|
||||
license_key_path: Path
|
||||
#: Adresse, auf der der Anwendungsserver lauscht -- exakt das, was nginx
|
||||
#: als ``proxy_pass``-Ziel braucht. Kommt aus derselben Umgebungsdatei wie
|
||||
#: der ``--bind``-Parameter von gunicorn, damit beide nie auseinanderlaufen.
|
||||
#: Vorher stand hier ein fest verdrahtetes ``127.0.0.1:5000``; eine zweite
|
||||
#: Instanz auf Port 5100 erzeugte damit eine nginx-Site, die auf die
|
||||
#: *andere* Installation zeigte.
|
||||
bind: str = "127.0.0.1:5000"
|
||||
debug: bool = False
|
||||
#: Instanz laeuft als echter Webprozess (gesetzt durch die systemd-Unit).
|
||||
web_process: bool = False
|
||||
security: SecurityConfig = field(default_factory=SecurityConfig)
|
||||
#: Zusaetzliche, app-spezifische Werte.
|
||||
extra: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return not self.debug
|
||||
|
||||
def path_in_data(self, name: str) -> Path:
|
||||
return self.data_dir / name
|
||||
|
||||
|
||||
def load_core_config(
|
||||
*,
|
||||
app_key: str,
|
||||
app_name: str,
|
||||
env_prefix: str,
|
||||
default_base: Path | None = None,
|
||||
default_bind: str = "127.0.0.1:5000",
|
||||
) -> CoreConfig:
|
||||
"""Liest die Konfiguration aus der Umgebung.
|
||||
|
||||
Ohne gesetzte Env-Vars laeuft alles in einem Unterverzeichnis
|
||||
``instance/<app_key>`` relativ zum aktuellen Arbeitsverzeichnis -- damit ist
|
||||
die Anwendung ohne jede Vorbereitung lokal startbar, ohne je nach
|
||||
``/srv``/``/var/log`` zu greifen.
|
||||
"""
|
||||
base = Path(_env(env_prefix, "BASE_DIR") or (default_base or Path.cwd() / "instance" / app_key))
|
||||
base = base.expanduser().resolve()
|
||||
data_dir = Path(_env(env_prefix, "DATA_DIR") or base / "data").expanduser()
|
||||
log_dir = Path(_env(env_prefix, "LOG_DIR") or base / "logs").expanduser()
|
||||
|
||||
security = SecurityConfig(
|
||||
cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", True),
|
||||
cookie_samesite=_env(env_prefix, "COOKIE_SAMESITE") or "Lax",
|
||||
session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 60),
|
||||
session_absolute_hours=_env_int(env_prefix, "SESSION_ABSOLUTE_HOURS", 12),
|
||||
reauth_valid_minutes=_env_int(env_prefix, "REAUTH_VALID_MINUTES", 15),
|
||||
login_max_attempts=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS", 8),
|
||||
login_lockout_minutes=_env_int(env_prefix, "LOGIN_LOCKOUT_MINUTES", 15),
|
||||
login_max_attempts_per_ip=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS_PER_IP", 30),
|
||||
password_min_length=_env_int(env_prefix, "PASSWORD_MIN_LENGTH", 12),
|
||||
require_totp_for_admins=_env_bool(env_prefix, "REQUIRE_TOTP_FOR_ADMINS", False),
|
||||
hsts_enabled=_env_bool(env_prefix, "HSTS_ENABLED", False),
|
||||
hsts_max_age=_env_int(env_prefix, "HSTS_MAX_AGE", 15552000),
|
||||
trusted_proxy_hops=_env_int(env_prefix, "TRUSTED_PROXY_HOPS", 1),
|
||||
max_content_length=_env_int(env_prefix, "MAX_CONTENT_LENGTH", 64 * 1024 * 1024) or None,
|
||||
)
|
||||
|
||||
debug = _env_bool(env_prefix, "DEBUG", False)
|
||||
if debug:
|
||||
# Lokale Entwicklung laeuft ueber http://127.0.0.1 -- ein Secure-Cookie
|
||||
# wuerde dort nie gesetzt und die Anmeldung schlicht nicht funktionieren.
|
||||
# dataclasses.replace, nicht __dict__: SecurityConfig hat slots und
|
||||
# damit gar kein __dict__ -- der alte Ausdruck warf beim Start einen
|
||||
# AttributeError, und zwar genau im Entwicklungsmodus, den das README
|
||||
# empfiehlt.
|
||||
security = replace(
|
||||
security, cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", False)
|
||||
)
|
||||
|
||||
bind = (_env(env_prefix, "BIND") or default_bind).strip()
|
||||
if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind):
|
||||
raise ValueError(f"Ungueltige Bind-Adresse: {bind!r}")
|
||||
|
||||
site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower()
|
||||
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key):
|
||||
raise ValueError(f"Ungueltiger SITE_KEY: {site_key!r}")
|
||||
|
||||
return CoreConfig(
|
||||
app_key=app_key,
|
||||
site_key=site_key,
|
||||
bind=bind,
|
||||
app_name=_env(env_prefix, "APP_NAME") or app_name,
|
||||
env_prefix=env_prefix,
|
||||
base_dir=base,
|
||||
data_dir=data_dir,
|
||||
log_dir=log_dir,
|
||||
db_path=Path(_env(env_prefix, "DB_PATH") or data_dir / "app.db").expanduser(),
|
||||
secret_key_file=Path(
|
||||
_env(env_prefix, "SECRET_KEY_FILE") or data_dir / "secret.key"
|
||||
).expanduser(),
|
||||
data_key_file=Path(_env(env_prefix, "DATA_KEY_FILE") or data_dir / "data.keys").expanduser(),
|
||||
license_path=Path(_env(env_prefix, "LICENSE_PATH") or data_dir / "license.json").expanduser(),
|
||||
license_key_path=Path(
|
||||
_env(env_prefix, "LICENSE_KEY_PATH") or data_dir / "license_key.json"
|
||||
).expanduser(),
|
||||
debug=debug,
|
||||
web_process=_env(env_prefix, "WEB_PROCESS") == "1",
|
||||
security=security,
|
||||
)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Migrationen der Komponente ``core``: Benutzer, Gruppen, Sitzungen, Audit, Settings.
|
||||
|
||||
Diese Tabellen sind in beiden Anwendungen identisch. Neue Aenderungen kommen
|
||||
als **neue** Migration mit neuer Versionsnummer dazu -- bestehende Migrationen
|
||||
werden nie editiert (die Pruefsummenwarnung des Runners weist darauf hin).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .migrations import Migration
|
||||
|
||||
V1_BASELINE = """
|
||||
-- Benutzerkonten. 'local' = eigenes Passwort, 'ldap' = Schattenkonto aus dem
|
||||
-- Verzeichnisdienst (Passwort wird dort geprueft, nie hier gespeichert).
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_locked INTEGER NOT NULL DEFAULT 0,
|
||||
auth_source TEXT NOT NULL DEFAULT 'local',
|
||||
first_name TEXT NOT NULL DEFAULT '',
|
||||
last_name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
avatar_filename TEXT NOT NULL DEFAULT '',
|
||||
totp_secret TEXT NOT NULL DEFAULT '',
|
||||
totp_confirmed_at TEXT,
|
||||
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||
password_changed_at TEXT,
|
||||
last_login_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
-- Eindeutigkeit nur unter den *nicht* geloeschten Konten: ein Name im
|
||||
-- Papierkorb blockiert die Neuanlage nicht dauerhaft, solange die Anwendung
|
||||
-- den Konflikt bewusst behandelt.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_live
|
||||
ON users(username) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_users_deleted ON users(deleted_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL COLLATE NOCASE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
preset_key TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_live
|
||||
ON groups(name) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_permissions (
|
||||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
permission TEXT NOT NULL,
|
||||
PRIMARY KEY (group_id, permission)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_groups (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, group_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_groups_group ON user_groups(group_id);
|
||||
|
||||
-- Einmal-Codes fuer den Verlust des Authenticators. Gespeichert wird nur ein
|
||||
-- Argon2-Hash -- ein Datenbankleck gibt die Codes nicht her.
|
||||
CREATE TABLE IF NOT EXISTS user_recovery_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
used_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_recovery_user ON user_recovery_codes(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
idle_expires_at TEXT NOT NULL,
|
||||
absolute_expires_at TEXT NOT NULL,
|
||||
reauth_at TEXT,
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
revoked_at TEXT,
|
||||
revoked_reason TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id, revoked_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_idle ON user_sessions(idle_expires_at);
|
||||
|
||||
-- Sliding-Window-Log der Ratenbegrenzung (Anmeldeversuche, Portal-Anfragen, API).
|
||||
CREATE TABLE IF NOT EXISTS rate_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bucket TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
ts TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_lookup ON rate_events(bucket, subject, ts);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- Aenderungsprotokoll mit Hash-Kette: jede Zeile bindet den Hash ihrer
|
||||
-- Vorgaengerin ein. Eine nachtraeglich geloeschte oder veraenderte Zeile bricht
|
||||
-- die Kette und wird von der Pruefroutine gefunden.
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
actor_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
request_id TEXT NOT NULL DEFAULT '',
|
||||
severity TEXT NOT NULL DEFAULT 'info',
|
||||
prev_hash TEXT NOT NULL DEFAULT '',
|
||||
hash TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
|
||||
|
||||
-- Dienstkonten (z. B. LDAP-Bind). Das Passwort liegt AES-GCM-verschluesselt vor,
|
||||
-- gebunden an den Verwendungszweck als AAD.
|
||||
CREATE TABLE IF NOT EXISTS service_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
purpose TEXT NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
password_encrypted TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ad_group_dn TEXT NOT NULL UNIQUE,
|
||||
ad_group_name TEXT NOT NULL,
|
||||
app_group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
V2_TRUSTED_KEYS = """
|
||||
-- Gepinnte Aussteller-Schluessel der Lizenzpruefung (Trust on First Use bzw.
|
||||
-- im Build mitgeliefert). Der Vorgaenger vertraute allein dem in der
|
||||
-- Lizenzdatei eingebetteten Schluessel.
|
||||
CREATE TABLE IF NOT EXISTS trusted_issuer_keys (
|
||||
key_id TEXT PRIMARY KEY,
|
||||
public_key TEXT NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT 'tofu',
|
||||
added_at TEXT NOT NULL,
|
||||
added_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
"""
|
||||
|
||||
V3_JOBS = """
|
||||
-- Persistente Auftragsverfolgung fuer Hintergrundlaeufe (Wartung, Import,
|
||||
-- Berichte). Im Vorgaenger lebte dieser Zustand ausschliesslich im
|
||||
-- Prozessspeicher und war nach einem Dienst-Neustart weg -- inklusive der
|
||||
-- Information, ob ein Lauf ueberhaupt stattgefunden hat.
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
subject TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
output TEXT NOT NULL DEFAULT '',
|
||||
started_by TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_kind_state ON jobs(kind, state);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
|
||||
"""
|
||||
|
||||
CORE_MIGRATIONS: tuple[Migration, ...] = (
|
||||
Migration(version=1, name="baseline", sql=V1_BASELINE),
|
||||
Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS),
|
||||
Migration(version=3, name="jobs", sql=V3_JOBS),
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Datenbankzugriff auf SQLite -- eine Verbindung pro Request, Schreibsperren strukturell.
|
||||
|
||||
Die schmerzhafteste Fehlerklasse des Vorgaengerprojekts war
|
||||
``sqlite3.OperationalError: database is locked``: eine Hilfsfunktion oeffnete
|
||||
still eine *zweite* Verbindung, waehrend die aeussere Funktion noch eine offene
|
||||
Schreibtransaktion hielt. Das ist hier nicht mehr durch Disziplin, sondern
|
||||
strukturell verhindert:
|
||||
|
||||
* Pro Request existiert genau **eine** Verbindung (in ``flask.g``).
|
||||
* ``Database.transaction()`` zaehlt die Verschachtelungstiefe. Wer waehrend
|
||||
einer offenen Schreibtransaktion ``Database.connect()`` aufruft, bekommt
|
||||
sofort einen ``NestedConnectionError`` mit Klartext-Erklaerung -- nicht erst
|
||||
nach 15 Sekunden Busy-Timeout einen Deadlock.
|
||||
* Verschachtelte ``transaction()``-Aufrufe auf derselben Verbindung sind
|
||||
erlaubt und werden zu einem einzigen Commit zusammengefasst (Savepoints),
|
||||
damit Service-Funktionen sich gefahrlos gegenseitig aufrufen koennen.
|
||||
|
||||
Zusaetzlich: WAL-Modus (paralleles Lesen waehrend eines Schreibers),
|
||||
``foreign_keys=ON`` (im Vorgaenger nie aktiviert -- Fremdschluessel waren
|
||||
Dekoration) und ``busy_timeout=15000``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class NestedConnectionError(RuntimeError):
|
||||
"""Zweite Verbindung waehrend einer offenen Schreibtransaktion."""
|
||||
|
||||
|
||||
class Row(sqlite3.Row):
|
||||
"""``sqlite3.Row`` mit ``dict``-artigem ``get`` und Mapping-Konvertierung."""
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
try:
|
||||
return self[key]
|
||||
except (IndexError, KeyError):
|
||||
return default
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {key: self[key] for key in self.keys()}
|
||||
|
||||
|
||||
def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
return {key: row[key] for key in row.keys()}
|
||||
|
||||
|
||||
def rows_to_dicts(rows: Sequence[sqlite3.Row]) -> list[dict[str, Any]]:
|
||||
return [{key: row[key] for key in row.keys()} for row in rows]
|
||||
|
||||
|
||||
class Database:
|
||||
"""Zugriffsobjekt fuer eine SQLite-Datei."""
|
||||
|
||||
def __init__(self, path: Path | str, *, timeout: float = 15.0) -> None:
|
||||
self.path = Path(path)
|
||||
self.timeout = timeout
|
||||
self._state = threading.local()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Verbindungen
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _depth(self) -> int:
|
||||
return int(getattr(self._state, "depth", 0))
|
||||
|
||||
def _set_depth(self, value: int) -> None:
|
||||
self._state.depth = value
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
"""Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft."""
|
||||
if self._depth() > 0:
|
||||
raise NestedConnectionError(
|
||||
"Es laeuft bereits eine Schreibtransaktion auf dieser Datenbank. "
|
||||
"Reiche die bestehende Verbindung durch, statt eine neue zu oeffnen "
|
||||
"(sonst blockiert SQLite sich selbst)."
|
||||
)
|
||||
return self._raw_connect()
|
||||
|
||||
def _raw_connect(self) -> sqlite3.Connection:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fresh = not self.path.exists()
|
||||
conn = sqlite3.connect(
|
||||
self.path,
|
||||
timeout=self.timeout,
|
||||
isolation_level=None, # Transaktionen steuern wir selbst, explizit.
|
||||
check_same_thread=False,
|
||||
)
|
||||
conn.row_factory = Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.execute("PRAGMA busy_timeout=15000")
|
||||
conn.execute("PRAGMA secure_delete=ON")
|
||||
if fresh:
|
||||
# Die Datei enthaelt Passwort-Hashes, verschluesselte Zugangsdaten und
|
||||
# Sitzungen -- sie gehoert niemandem ausser dem Dienstbenutzer.
|
||||
try:
|
||||
os.chmod(self.path, 0o600)
|
||||
except OSError: # pragma: no cover - z. B. Windows/NTFS
|
||||
pass
|
||||
return conn
|
||||
|
||||
@contextmanager
|
||||
def session(self) -> Iterator[sqlite3.Connection]:
|
||||
"""Kurzlebige Verbindung fuer Skripte und Hintergrund-Threads."""
|
||||
conn = self.connect()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Transaktionen
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@contextmanager
|
||||
def transaction(self, conn: sqlite3.Connection | None = None) -> Iterator[sqlite3.Connection]:
|
||||
"""Schreibtransaktion mit ``BEGIN IMMEDIATE``.
|
||||
|
||||
``BEGIN IMMEDIATE`` nimmt die Schreibsperre sofort statt erst beim ersten
|
||||
Schreibzugriff -- damit scheitert ein konkurrierender Schreiber sauber am
|
||||
Busy-Timeout, statt mitten in der Transaktion in einen
|
||||
``SQLITE_BUSY_SNAPSHOT`` zu laufen.
|
||||
"""
|
||||
own_connection = conn is None
|
||||
if own_connection:
|
||||
conn = self._raw_connect()
|
||||
assert conn is not None
|
||||
|
||||
depth = self._depth()
|
||||
savepoint = f"sp_{depth}"
|
||||
self._set_depth(depth + 1)
|
||||
try:
|
||||
if depth == 0:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
else:
|
||||
conn.execute(f"SAVEPOINT {savepoint}")
|
||||
yield conn
|
||||
except BaseException:
|
||||
try:
|
||||
if depth == 0:
|
||||
conn.execute("ROLLBACK")
|
||||
else:
|
||||
conn.execute(f"ROLLBACK TO {savepoint}")
|
||||
except sqlite3.Error: # pragma: no cover - Verbindung schon tot
|
||||
pass
|
||||
raise
|
||||
else:
|
||||
if depth == 0:
|
||||
conn.execute("COMMIT")
|
||||
else:
|
||||
conn.execute(f"RELEASE {savepoint}")
|
||||
finally:
|
||||
self._set_depth(depth)
|
||||
if own_connection:
|
||||
conn.close()
|
||||
|
||||
@property
|
||||
def in_transaction(self) -> bool:
|
||||
return self._depth() > 0
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Bequeme Abfragehelfer (immer parametrisiert -- kein String-Basteln)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@staticmethod
|
||||
def one(
|
||||
conn: sqlite3.Connection, sql: str, params: Sequence[Any] | Mapping[str, Any] = ()
|
||||
) -> dict[str, Any] | None:
|
||||
return row_to_dict(conn.execute(sql, params).fetchone())
|
||||
|
||||
@staticmethod
|
||||
def all(
|
||||
conn: sqlite3.Connection, sql: str, params: Sequence[Any] | Mapping[str, Any] = ()
|
||||
) -> list[dict[str, Any]]:
|
||||
return rows_to_dicts(conn.execute(sql, params).fetchall())
|
||||
|
||||
@staticmethod
|
||||
def value(
|
||||
conn: sqlite3.Connection,
|
||||
sql: str,
|
||||
params: Sequence[Any] | Mapping[str, Any] = (),
|
||||
default: Any = None,
|
||||
) -> Any:
|
||||
row = conn.execute(sql, params).fetchone()
|
||||
if row is None:
|
||||
return default
|
||||
return row[0]
|
||||
|
||||
@staticmethod
|
||||
def column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
return any(row["name"] == column for row in rows)
|
||||
|
||||
@staticmethod
|
||||
def table_exists(conn: sqlite3.Connection, table: str) -> bool:
|
||||
return (
|
||||
conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type IN ('table','view') AND name=?",
|
||||
(table,),
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Die zentrale Erweiterung: haelt alles zusammen, was eine App braucht.
|
||||
|
||||
Statt globaler Modulvariablen (der Vorgaenger hatte ein Dutzend davon, u. a.
|
||||
``_license_state`` und die DB-Pfade) haengt der gesamte Anwendungszustand an
|
||||
einem Objekt in ``app.extensions["tesm"]``. Zugriff ueber ``core()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable, Sequence
|
||||
|
||||
from flask import Flask, current_app, g
|
||||
|
||||
from .config import CoreConfig
|
||||
from .db import Database
|
||||
from .keystore import Keystore
|
||||
from .migrations import Migration, MigrationRunner
|
||||
from .nav import NavTree
|
||||
from .rbac.model import PermissionTree, RolePreset
|
||||
from .security.passwords import PasswordPolicy
|
||||
from .settings_store import SettingsStore
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .licensing_client import LicenseManager
|
||||
from .sysops import SysOps
|
||||
|
||||
|
||||
@dataclass
|
||||
class TesmCore:
|
||||
"""Anwendungsweiter Zustand. Nach ``init_app`` unveraendert."""
|
||||
|
||||
config: CoreConfig
|
||||
database: Database
|
||||
keystore: Keystore
|
||||
permissions: PermissionTree
|
||||
nav: NavTree
|
||||
settings: SettingsStore
|
||||
role_presets: tuple[RolePreset, ...] = ()
|
||||
migrations: dict[str, Sequence[Migration]] = field(default_factory=dict)
|
||||
license: "LicenseManager | None" = None
|
||||
sysops: "SysOps | None" = None
|
||||
#: App-spezifische Erweiterungen (z. B. Papierkorb-Register, Job-Registry).
|
||||
registry: dict[str, Any] = field(default_factory=dict)
|
||||
#: Wird nach einer erfolgreichen Anmeldung aufgerufen:
|
||||
#: ``(conn, user, secret, group_dns)``. ``secret`` ist das Passwort dieser
|
||||
#: Anmeldung -- es wird nirgends gespeichert und ist nach dem Aufruf weg.
|
||||
#: Ein Haken, der scheitert, sperrt niemanden aus (siehe
|
||||
#: ``auth.service._run_login_hooks``).
|
||||
login_hooks: list[Callable[..., None]] = field(default_factory=list)
|
||||
#: Wird vor dem Verwerfen der Sitzung aufgerufen: ``(conn, user)``. Was die
|
||||
#: Anmeldung aufgebaut hat, wird hier wieder abgebaut -- sonst bliebe es
|
||||
#: bestehen, bis eine Zeitgrenze greift.
|
||||
logout_hooks: list[Callable[..., None]] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def password_policy(self) -> PasswordPolicy:
|
||||
return PasswordPolicy(min_length=self.config.security.password_min_length)
|
||||
|
||||
@property
|
||||
def audit_archive_dir(self) -> Path:
|
||||
return self.config.log_dir / "audit"
|
||||
|
||||
def run_migrations(self) -> list[str]:
|
||||
runner = MigrationRunner(self.database)
|
||||
applied: list[str] = []
|
||||
for component, migrations in self.migrations.items():
|
||||
applied.extend(runner.run(component, migrations))
|
||||
return applied
|
||||
|
||||
def migration_status(self) -> list[dict[str, object]]:
|
||||
return MigrationRunner(self.database).status(list(self.migrations))
|
||||
|
||||
|
||||
def core(app: Flask | None = None) -> TesmCore:
|
||||
target = app or current_app
|
||||
try:
|
||||
return target.extensions["tesm"] # type: ignore[return-value]
|
||||
except KeyError as exc: # pragma: no cover - Programmierfehler
|
||||
raise RuntimeError("tesm_core ist fuer diese Anwendung nicht initialisiert") from exc
|
||||
|
||||
|
||||
def db() -> sqlite3.Connection:
|
||||
"""Die Verbindung dieses Requests. Genau eine pro Request, nie eine zweite.
|
||||
|
||||
Diese Funktion ist der einzige zugelassene Weg, im Request-Kontext an eine
|
||||
Verbindung zu kommen. Wer stattdessen selbst ``Database.connect()`` aufruft,
|
||||
riskiert genau den Deadlock, an dem der Vorgaenger mehrfach hing.
|
||||
"""
|
||||
connection = getattr(g, "_tesm_conn", None)
|
||||
if connection is None:
|
||||
connection = core().database.connect()
|
||||
g._tesm_conn = connection
|
||||
return connection
|
||||
|
||||
|
||||
def close_db(_exception: BaseException | None = None) -> None:
|
||||
connection = getattr(g, "_tesm_conn", None)
|
||||
if connection is not None:
|
||||
g._tesm_conn = None
|
||||
connection.close()
|
||||
|
||||
|
||||
def transaction() -> Any:
|
||||
"""Kurzform fuer ``core().database.transaction(db())``."""
|
||||
return core().database.transaction(db())
|
||||
|
||||
|
||||
def with_transaction(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Dekorator: fuehrt eine Funktion innerhalb einer Schreibtransaktion aus."""
|
||||
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
with transaction() as conn:
|
||||
return fn(conn, *args, **kwargs)
|
||||
|
||||
wrapper.__name__ = fn.__name__
|
||||
wrapper.__doc__ = fn.__doc__
|
||||
return wrapper
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Schluesselverwaltung und Feldverschluesselung (AES-256-GCM, rotierbar).
|
||||
|
||||
Der Vorgaenger nutzte Fernet mit einem einzigen, nie rotierbaren Schluessel in
|
||||
``fernet.key``. Fernet ist in Ordnung, aber:
|
||||
|
||||
* Ein Schluesselwechsel war nicht vorgesehen -- ein kompromittierter Schluessel
|
||||
haette bedeutet, alle Zugangsdaten von Hand neu einzugeben.
|
||||
* Es gab keine zugeordneten Daten (AAD), also keine kryptographische Bindung
|
||||
eines Chiffrats an den Ort, an dem es steht. Ein Chiffrat aus Tabelle A liess
|
||||
sich in Tabelle B einspielen.
|
||||
|
||||
Hier: ein versionierter Schluesselbund (``data.keys``, Modus 0600) mit
|
||||
AES-256-GCM. Jedes Chiffrat traegt die Schluessel-ID im Klartext-Praefix, damit
|
||||
alte Werte nach einer Rotation weiter lesbar bleiben. ``aad`` bindet ein
|
||||
Chiffrat an seinen Verwendungszweck (z. B. ``credentials.password:42``).
|
||||
|
||||
Format: ``v1.<key_id>.<base64url(nonce)>.<base64url(ciphertext)>``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
_PREFIX = "v1"
|
||||
_NONCE_BYTES = 12
|
||||
_KEY_BYTES = 32
|
||||
|
||||
|
||||
class DecryptionError(Exception):
|
||||
"""Chiffrat gehoert nicht zu diesem Schluesselbund oder wurde manipuliert."""
|
||||
|
||||
|
||||
def _b64e(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _b64d(text: str) -> bytes:
|
||||
padded = text + "=" * (-len(text) % 4)
|
||||
return base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||
|
||||
|
||||
def write_private_file(path: Path, content: str) -> None:
|
||||
"""Schreibt eine Datei mit Modus 0600, atomar (erst tmp, dann ersetzen)."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
||||
handle = os.open(tmp, flags, 0o600)
|
||||
try:
|
||||
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
||||
stream.write(content)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
except BaseException:
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
os.replace(tmp, path)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError: # pragma: no cover - Windows
|
||||
pass
|
||||
|
||||
|
||||
def check_private_file(path: Path) -> list[str]:
|
||||
"""Meldet zu weite Dateirechte -- die Diagnoseseite zeigt das an."""
|
||||
problems: list[str] = []
|
||||
try:
|
||||
mode = path.stat().st_mode
|
||||
except OSError:
|
||||
return problems
|
||||
if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH):
|
||||
problems.append(f"{path} ist fuer Gruppe/Andere lesbar oder schreibbar (erwartet 0600)")
|
||||
return problems
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Key:
|
||||
key_id: str
|
||||
material: bytes
|
||||
created_at: str
|
||||
|
||||
|
||||
class Keystore:
|
||||
"""Rotierbarer Schluesselbund fuer Feldverschluesselung."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
self._keys: dict[str, _Key] = {}
|
||||
self._active: str = ""
|
||||
self._load_or_create()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _load_or_create(self) -> None:
|
||||
if self.path.exists():
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
for entry in raw.get("keys", []):
|
||||
key = _Key(
|
||||
key_id=str(entry["id"]),
|
||||
material=_b64d(str(entry["key"])),
|
||||
created_at=str(entry.get("created_at", "")),
|
||||
)
|
||||
if len(key.material) != _KEY_BYTES:
|
||||
raise ValueError(f"Schluessel {key.key_id} hat die falsche Laenge")
|
||||
self._keys[key.key_id] = key
|
||||
self._active = str(raw.get("active") or "")
|
||||
if self._active not in self._keys:
|
||||
raise ValueError("Aktiver Schluessel fehlt im Schluesselbund")
|
||||
return
|
||||
self._active = "1"
|
||||
self._keys = {
|
||||
"1": _Key(
|
||||
key_id="1",
|
||||
material=secrets.token_bytes(_KEY_BYTES),
|
||||
created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
)
|
||||
}
|
||||
self._persist()
|
||||
|
||||
def _persist(self) -> None:
|
||||
payload = {
|
||||
"active": self._active,
|
||||
"keys": [
|
||||
{"id": key.key_id, "key": _b64e(key.material), "created_at": key.created_at}
|
||||
for key in sorted(self._keys.values(), key=lambda k: int(k.key_id))
|
||||
],
|
||||
}
|
||||
write_private_file(self.path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@property
|
||||
def active_key_id(self) -> str:
|
||||
return self._active
|
||||
|
||||
def key_ids(self) -> list[str]:
|
||||
return sorted(self._keys, key=int)
|
||||
|
||||
def rotate(self) -> str:
|
||||
"""Legt einen neuen aktiven Schluessel an. Alte bleiben zum Lesen erhalten."""
|
||||
new_id = str(max(int(k) for k in self._keys) + 1)
|
||||
self._keys[new_id] = _Key(
|
||||
key_id=new_id,
|
||||
material=secrets.token_bytes(_KEY_BYTES),
|
||||
created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
)
|
||||
self._active = new_id
|
||||
self._persist()
|
||||
return new_id
|
||||
|
||||
def drop_key(self, key_id: str) -> None:
|
||||
"""Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
|
||||
if key_id == self._active:
|
||||
raise ValueError("Der aktive Schluessel kann nicht entfernt werden")
|
||||
self._keys.pop(key_id, None)
|
||||
self._persist()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def encrypt(self, plaintext: str, *, aad: str) -> str:
|
||||
key = self._keys[self._active]
|
||||
nonce = secrets.token_bytes(_NONCE_BYTES)
|
||||
ciphertext = AESGCM(key.material).encrypt(
|
||||
nonce, plaintext.encode("utf-8"), aad.encode("utf-8")
|
||||
)
|
||||
return f"{_PREFIX}.{key.key_id}.{_b64e(nonce)}.{_b64e(ciphertext)}"
|
||||
|
||||
def decrypt(self, token: str, *, aad: str) -> str:
|
||||
try:
|
||||
prefix, key_id, nonce_b64, ct_b64 = token.split(".", 3)
|
||||
except ValueError as exc:
|
||||
raise DecryptionError("Chiffrat hat ein unbekanntes Format") from exc
|
||||
if prefix != _PREFIX:
|
||||
raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
|
||||
key = self._keys.get(key_id)
|
||||
if key is None:
|
||||
raise DecryptionError(f"Schluessel {key_id} ist nicht im Schluesselbund")
|
||||
try:
|
||||
plain = AESGCM(key.material).decrypt(
|
||||
_b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
|
||||
)
|
||||
except (InvalidTag, ValueError) as exc:
|
||||
raise DecryptionError(
|
||||
"Chiffrat konnte nicht entschluesselt werden (falscher Schluessel, "
|
||||
"manipulierte Daten oder falscher Verwendungszweck)"
|
||||
) from exc
|
||||
return plain.decode("utf-8")
|
||||
|
||||
def needs_reencrypt(self, token: str) -> bool:
|
||||
"""True, wenn das Chiffrat noch unter einem alten Schluessel liegt."""
|
||||
parts = token.split(".", 3)
|
||||
return len(parts) == 4 and parts[1] != self._active
|
||||
|
||||
|
||||
def load_or_create_secret_key(path: Path) -> bytes:
|
||||
"""Flask-Secret-Key: 64 Byte, Datei mit Modus 0600, beim ersten Start erzeugt."""
|
||||
if path.exists():
|
||||
raw = path.read_text(encoding="utf-8").strip()
|
||||
if raw:
|
||||
return _b64d(raw)
|
||||
material = secrets.token_bytes(64)
|
||||
write_private_file(path, _b64e(material) + "\n")
|
||||
return material
|
||||
@@ -0,0 +1,778 @@
|
||||
"""Lizenzverwaltung auf Client-Seite (TESM) und fuer die eigene Lizenz des Servers.
|
||||
|
||||
Zustaendig fuer: Lizenzdatei laden und verifizieren, Aussteller pinnen,
|
||||
Aktivierung/Deaktivierung/Heartbeat (online und offline), Modul-Freischaltung
|
||||
und die Anzeige in der Topbar.
|
||||
|
||||
Wesentliche Aenderungen gegenueber dem Vorgaenger:
|
||||
|
||||
* **Vertrauensanker.** Die Signatur wird gegen gepinnte Aussteller-Schluessel
|
||||
geprueft (Tabelle ``trusted_issuer_keys``). Beim ersten Import wird der
|
||||
Schluessel per Trust-on-First-Use uebernommen und dabei protokolliert; jede
|
||||
spaetere Lizenz eines *anderen* Ausstellers wird abgelehnt, bis ein
|
||||
Administrator den neuen Schluessel ausdruecklich freigibt. Der Vorgaenger
|
||||
pruefte ausschliesslich gegen den Schluessel *in derselben Datei* -- damit war
|
||||
jede selbst signierte Datei "gueltig".
|
||||
* **Trennung von Dokument und Client-Schluessel** (siehe ``tesm_licensing``):
|
||||
``license.json`` (0640) und ``license_key.json`` (0600).
|
||||
* **Ein Gate, eine Wahrheit.** Der Vorgaenger hatte eine Docstring, die mehr
|
||||
behauptete als der Code durchsetzte (LDAP/NGINX waren nur im Template
|
||||
gegated). Hier gibt es genau eine Funktion ``require_license()`` und eine
|
||||
Tabelle in ``LICENSED_FEATURES`` -- was dort steht, wird serverseitig
|
||||
durchgesetzt, ohne Ausnahme.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import tesm_licensing as lic
|
||||
|
||||
from . import audit
|
||||
from .db import Database
|
||||
from .keystore import write_private_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SETTING_STATUS = "license_activation_status"
|
||||
SETTING_PENDING = "license_pending_request"
|
||||
SETTING_LAST_HEARTBEAT = "license_last_heartbeat_at"
|
||||
SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
|
||||
SETTING_EXPORT_GRACE = "license_export_grace"
|
||||
|
||||
STATUS_INACTIVE = "inactive"
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_ACTIVE = "active"
|
||||
|
||||
API_PATHS = {
|
||||
"activate": "/api/v1/licenses/activate",
|
||||
"deactivate": "/api/v1/licenses/deactivate",
|
||||
"heartbeat": "/api/v1/licenses/heartbeat",
|
||||
}
|
||||
|
||||
#: Funktionen, die eine gueltige, aktivierte Lizenz brauchen. Diese Tabelle ist
|
||||
#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie.
|
||||
LICENSED_FEATURES: dict[str, str] = {
|
||||
"directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren",
|
||||
"nginx": "Webserver-Einstellungen aendern",
|
||||
"export": "Daten exportieren",
|
||||
"trash_restore": "Papierkorb wiederherstellen und endgueltig loeschen",
|
||||
"monitor_interval": "Pruefintervall der Ueberwachung aendern",
|
||||
"auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall",
|
||||
}
|
||||
|
||||
|
||||
def describe_transport_error(exc: BaseException, endpoint: str) -> str:
|
||||
"""Uebersetzt einen Netzfehler in etwas, mit dem man arbeiten kann.
|
||||
|
||||
"War nicht erreichbar" ist bei einem Zertifikatsfehler schlicht falsch --
|
||||
der Server war sehr wohl erreichbar, nur nicht vertrauenswuerdig. Wer das
|
||||
nicht weiss, sucht am falschen Ende.
|
||||
"""
|
||||
text = str(exc)
|
||||
if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text:
|
||||
detail = "Das Zertifikat konnte nicht geprueft werden"
|
||||
if "self-signed" in text or "self signed" in text:
|
||||
detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat"
|
||||
return (
|
||||
f"{detail} ({endpoint}). Hinterlegen Sie sein Zertifikat unter "
|
||||
"Einstellungen -> Lizenz, oder geben Sie dem Lizenzserver ein Zertifikat "
|
||||
"einer anerkannten Stelle."
|
||||
)
|
||||
if "Name or service not known" in text or "nodename nor servname" in text:
|
||||
return f"Der Name im Endpunkt ({endpoint}) laesst sich nicht aufloesen."
|
||||
if "Connection refused" in text:
|
||||
return f"{endpoint} nimmt keine Verbindung an."
|
||||
if "timed out" in text or "timeout" in text.lower():
|
||||
return f"{endpoint} hat nicht rechtzeitig geantwortet."
|
||||
return f"Der Lizenzserver ({endpoint}) war nicht erreichbar: {text[:160]}"
|
||||
|
||||
|
||||
class LicenseError(Exception):
|
||||
"""Fehler beim Import oder bei der Aktivierung -- Text ist fuer Anwender gedacht."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LicenseState:
|
||||
"""Zwischengespeicherter Lizenzzustand. Wird bei jeder Aenderung neu gebaut."""
|
||||
|
||||
present: bool = False
|
||||
valid: bool = False
|
||||
issuer_pinned: bool = False
|
||||
activated: bool = False
|
||||
document: dict[str, Any] = field(default_factory=dict)
|
||||
status: lic.LicenseStatus | None = None
|
||||
error: str = ""
|
||||
warnings: tuple[str, ...] = ()
|
||||
last_heartbeat_at: str = ""
|
||||
heartbeat_error: str = ""
|
||||
pending_action: str = ""
|
||||
|
||||
@property
|
||||
def role(self) -> str:
|
||||
return self.status.role if self.status else "customer"
|
||||
|
||||
@property
|
||||
def modules(self) -> frozenset[str]:
|
||||
return self.status.modules if self.status else frozenset()
|
||||
|
||||
@property
|
||||
def customer_name(self) -> str:
|
||||
return str((self.status.customer if self.status else {}).get("name") or "")
|
||||
|
||||
@property
|
||||
def vendor(self) -> dict[str, Any]:
|
||||
return dict(self.status.vendor) if self.status else {}
|
||||
|
||||
|
||||
class LicenseManager:
|
||||
"""Haelt den Lizenzzustand und fuehrt die Handshakes durch."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
database: Database,
|
||||
settings: Any,
|
||||
license_path: Path,
|
||||
key_path: Path,
|
||||
app_key: str,
|
||||
built_in_trusted_keys: tuple[str, ...] = (),
|
||||
) -> None:
|
||||
self.database = database
|
||||
self.settings = settings
|
||||
self.license_path = Path(license_path)
|
||||
self.key_path = Path(key_path)
|
||||
self.app_key = app_key
|
||||
self.built_in_trusted_keys = tuple(built_in_trusted_keys)
|
||||
self._state = LicenseState()
|
||||
self._lock = threading.RLock()
|
||||
self._heartbeat_thread: threading.Thread | None = None
|
||||
self._report_builder: Callable[[sqlite3.Connection], dict[str, Any]] | None = None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Zustand
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@property
|
||||
def state(self) -> LicenseState:
|
||||
return self._state
|
||||
|
||||
def trusted_keys(self, conn: sqlite3.Connection) -> list[str]:
|
||||
rows = Database.all(conn, "SELECT public_key FROM trusted_issuer_keys")
|
||||
return [*self.built_in_trusted_keys, *(str(row["public_key"]) for row in rows)]
|
||||
|
||||
def reload(self, conn: sqlite3.Connection) -> LicenseState:
|
||||
"""Liest Lizenzdatei und Status neu ein."""
|
||||
with self._lock:
|
||||
state = LicenseState()
|
||||
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
|
||||
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
|
||||
pending = self._pending(conn)
|
||||
state.pending_action = str(pending.get("action") or "") if pending else ""
|
||||
|
||||
if not self.license_path.is_file():
|
||||
state.error = "Es ist keine Lizenzdatei installiert."
|
||||
self._state = state
|
||||
return state
|
||||
|
||||
try:
|
||||
document = json.loads(self.license_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
state.error = f"Die Lizenzdatei ist unlesbar: {exc}"
|
||||
self._state = state
|
||||
return state
|
||||
|
||||
if isinstance(document, dict) and "license" in document:
|
||||
# Jemand hat das Auslieferungs-Bundle direkt hierher kopiert.
|
||||
document, _ = lic.split_bundle(document)
|
||||
|
||||
state.present = True
|
||||
state.document = document
|
||||
trusted = self.trusted_keys(conn)
|
||||
try:
|
||||
lic.verify_license(document, trusted_master_keys=trusted or None)
|
||||
state.valid = True
|
||||
state.issuer_pinned = bool(trusted)
|
||||
except lic.UntrustedIssuer as exc:
|
||||
state.error = str(exc)
|
||||
except lic.LicensingError as exc:
|
||||
state.error = str(exc)
|
||||
|
||||
if state.valid:
|
||||
state.status = lic.license_status(
|
||||
document, issuer_pinned=state.issuer_pinned
|
||||
)
|
||||
state.warnings = state.status.warnings
|
||||
|
||||
state.activated = (
|
||||
str(self.settings.get(conn, SETTING_STATUS) or STATUS_INACTIVE) == STATUS_ACTIVE
|
||||
)
|
||||
self._state = state
|
||||
return state
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Gates
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def active(self) -> bool:
|
||||
"""Grundvoraussetzung fuer alles Lizenzpflichtige."""
|
||||
state = self._state
|
||||
return bool(
|
||||
state.valid
|
||||
and state.activated
|
||||
and state.status is not None
|
||||
and state.status.modules_active
|
||||
)
|
||||
|
||||
def module_active(self, name: str) -> bool:
|
||||
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) freigeschaltet?"""
|
||||
return self.active() and name in self._state.modules
|
||||
|
||||
def feature_allowed(self, feature: str) -> bool:
|
||||
"""Lizenzpflichtige Einzelfunktion.
|
||||
|
||||
Ausnahme ``export``: bleibt erlaubt, wenn der lizenzlose Zustand durch
|
||||
eine vom Kunden selbst ausgeloeste Deaktivierung entstanden ist
|
||||
(Systemwechsel) -- sonst waeren die eigenen Daten eingeschlossen.
|
||||
"""
|
||||
if feature == "export" and not self.active():
|
||||
with self.database.session() as conn:
|
||||
return str(self.settings.get(conn, SETTING_EXPORT_GRACE) or "") == "1"
|
||||
return self.active()
|
||||
|
||||
def require(self, feature: str) -> None:
|
||||
if not self.feature_allowed(feature):
|
||||
raise LicenseError(
|
||||
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gueltige, "
|
||||
"aktivierte Lizenz nicht moeglich."
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Import
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def import_bundle(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
raw: bytes | str,
|
||||
*,
|
||||
actor: str,
|
||||
allow_new_issuer: bool = False,
|
||||
) -> LicenseState:
|
||||
"""Nimmt eine hochgeladene Lizenzdatei an.
|
||||
|
||||
``allow_new_issuer`` ist nur fuer den ausdruecklichen Fall gedacht, dass
|
||||
ein Administrator einen Aussteller-Wechsel bestaetigt (z. B. Wechsel des
|
||||
Lizenzservers). Ohne dieses Kennzeichen wird eine Datei mit unbekanntem
|
||||
Aussteller abgelehnt, sobald schon einer gepinnt ist.
|
||||
"""
|
||||
text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except ValueError as exc:
|
||||
raise LicenseError("Die Datei ist kein gueltiges JSON.") from exc
|
||||
|
||||
try:
|
||||
document, client_key = lic.split_bundle(payload)
|
||||
except lic.LicensingError as exc:
|
||||
raise LicenseError(str(exc)) from exc
|
||||
|
||||
try:
|
||||
lic.verify_license(document) # innere Konsistenz
|
||||
except lic.LicensingError as exc:
|
||||
raise LicenseError(f"Die Lizenzdatei ist ungueltig: {exc}") from exc
|
||||
|
||||
master_pubkey = str(document.get("master_pubkey") or "")
|
||||
known = self.trusted_keys(conn)
|
||||
if known and master_pubkey not in set(known) and not allow_new_issuer:
|
||||
raise LicenseError(
|
||||
"Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt "
|
||||
f"(Schluessel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
|
||||
"Wechsel des Ausstellers ausdruecklich bestaetigen."
|
||||
)
|
||||
|
||||
if not client_key and not self.key_path.is_file():
|
||||
raise LicenseError(
|
||||
"Der Datei fehlt der Client-Schluessel. Bitte das vollstaendige "
|
||||
"Lizenz-Bundle des Lizenzservers verwenden."
|
||||
)
|
||||
|
||||
# Reihenfolge: erst Schluessel pinnen, dann Dateien schreiben, dann
|
||||
# Aktivierungsstatus zuruecksetzen -- ein Abbruch dazwischen hinterlaesst
|
||||
# keinen halb gueltigen Zustand.
|
||||
if master_pubkey not in set(known):
|
||||
conn.execute(
|
||||
"INSERT INTO trusted_issuer_keys (key_id, public_key, label, source, added_at, added_by) "
|
||||
"VALUES (?,?,?,?,?,?) ON CONFLICT(key_id) DO NOTHING",
|
||||
(
|
||||
lic.key_id(master_pubkey),
|
||||
master_pubkey,
|
||||
str((document.get("vendor") or {}).get("name") or "Lizenzserver"),
|
||||
"tofu" if not known else "admin-confirmed",
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
actor,
|
||||
),
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=actor,
|
||||
action="license.issuer_pinned",
|
||||
target=lic.key_id(master_pubkey),
|
||||
detail="Trust on first use" if not known else "Aussteller-Wechsel bestaetigt",
|
||||
severity="notice",
|
||||
)
|
||||
|
||||
self.license_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.license_path.write_text(
|
||||
json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
try:
|
||||
self.license_path.chmod(0o640)
|
||||
except OSError:
|
||||
pass
|
||||
if client_key:
|
||||
write_private_file(
|
||||
self.key_path,
|
||||
json.dumps({"license_id": document.get("license_id"), "client_key": client_key})
|
||||
+ "\n",
|
||||
)
|
||||
|
||||
self.settings.set(conn, SETTING_STATUS, STATUS_INACTIVE, actor=actor)
|
||||
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
||||
self.settings.set(conn, SETTING_EXPORT_GRACE, "", actor=actor)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=actor,
|
||||
action="license.imported",
|
||||
target=str(document.get("license_id") or ""),
|
||||
detail=f"Typ {document.get('type')}, Rolle {document.get('role')}",
|
||||
severity="notice",
|
||||
)
|
||||
return self.reload(conn)
|
||||
|
||||
def client_key(self) -> str:
|
||||
if not self.key_path.is_file():
|
||||
return ""
|
||||
try:
|
||||
data = json.loads(self.key_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
return str(data.get("client_key") or "")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Handshake
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _pending(self, conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
raw = self.settings.get(conn, SETTING_PENDING) or ""
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(raw) if isinstance(raw, str) else dict(raw)
|
||||
except ValueError:
|
||||
return {}
|
||||
|
||||
def _endpoint(self, action: str) -> str:
|
||||
base = str(self._state.document.get("master_endpoint") or "").rstrip("/")
|
||||
if not base:
|
||||
return ""
|
||||
return base + API_PATHS[action]
|
||||
|
||||
def build_request(
|
||||
self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
if not self._state.valid:
|
||||
raise LicenseError("Es ist keine gueltige Lizenz installiert.")
|
||||
key = self.client_key()
|
||||
if not key:
|
||||
raise LicenseError(
|
||||
"Der Client-Schluessel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren."
|
||||
)
|
||||
return lic.build_client_request(
|
||||
action, self._state.document, key, hostname=hostname, report=report
|
||||
)
|
||||
|
||||
def handshake(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
action: str,
|
||||
*,
|
||||
actor: str,
|
||||
hostname: str = "",
|
||||
report: dict[str, Any] | None = None,
|
||||
timeout: int = 12,
|
||||
) -> dict[str, Any]:
|
||||
"""Fuehrt eine Aktion durch. Rueckgabe beschreibt das Ergebnis fuer die UI.
|
||||
|
||||
``{"ok": bool, "transport": "online"|"offline", "code": str, "message": str}``
|
||||
"""
|
||||
request_payload = self.build_request(action, hostname=hostname, report=report)
|
||||
endpoint = self._endpoint(action)
|
||||
|
||||
reason = ""
|
||||
if endpoint:
|
||||
try:
|
||||
response = self._post_json(
|
||||
endpoint,
|
||||
request_payload,
|
||||
timeout=timeout,
|
||||
ca_pem=str(self.settings.get(conn, "license_server_ca") or ""),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - jeder Netzfehler fuehrt offline weiter
|
||||
reason = describe_transport_error(exc, endpoint)
|
||||
logger.info("Online-Handshake fehlgeschlagen (%s): %s", action, exc)
|
||||
else:
|
||||
return self._apply_response(
|
||||
conn, action, request_payload, response, actor=actor, transport="online"
|
||||
)
|
||||
else:
|
||||
reason = (
|
||||
"In dieser Lizenz ist kein Lizenzserver hinterlegt -- sie kann sich nur "
|
||||
"ueber den Offline-Weg aktivieren."
|
||||
)
|
||||
|
||||
# Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und
|
||||
# Antwort liegen in der Praxis eine E-Mail und ein Arbeitstag; der Code
|
||||
# muss ein Neuladen der Seite ueberstehen.
|
||||
code = lic.encode_code(request_payload)
|
||||
self.settings.set(
|
||||
conn,
|
||||
SETTING_PENDING,
|
||||
json.dumps(
|
||||
{
|
||||
"action": action,
|
||||
"nonce": request_payload["nonce"],
|
||||
"fingerprint": request_payload["fingerprint"],
|
||||
"license_id": request_payload["license_id"],
|
||||
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"code": code,
|
||||
"reason": reason,
|
||||
}
|
||||
),
|
||||
actor=actor,
|
||||
)
|
||||
if action == "activate":
|
||||
self.settings.set(conn, SETTING_STATUS, STATUS_PENDING, actor=actor)
|
||||
self.reload(conn)
|
||||
return {
|
||||
"ok": False,
|
||||
"transport": "offline",
|
||||
"code": code,
|
||||
"message": (
|
||||
(reason + " " if reason else "")
|
||||
+ "Der Offline-Weg steht offen: uebermitteln Sie den angezeigten Code an "
|
||||
"Ihren Ansprechpartner und tragen Sie die Antwort hier ein."
|
||||
),
|
||||
}
|
||||
|
||||
def submit_offline_response(
|
||||
self, conn: sqlite3.Connection, code: str, *, actor: str
|
||||
) -> dict[str, Any]:
|
||||
"""Nimmt den Antwortcode des Lizenzservers an (Offline-Weg)."""
|
||||
pending = self._pending(conn)
|
||||
if not pending:
|
||||
raise LicenseError("Es liegt keine offene Anfrage vor.")
|
||||
try:
|
||||
response = lic.decode_code(code)
|
||||
except ValueError as exc:
|
||||
raise LicenseError(f"Der Code ist unlesbar: {exc}") from exc
|
||||
return self._apply_response(
|
||||
conn,
|
||||
str(pending.get("action") or ""),
|
||||
{
|
||||
"license_id": str(pending.get("license_id") or ""),
|
||||
"fingerprint": str(pending.get("fingerprint") or ""),
|
||||
"nonce": str(pending.get("nonce") or ""),
|
||||
},
|
||||
response,
|
||||
actor=actor,
|
||||
transport="offline",
|
||||
)
|
||||
|
||||
def _apply_response(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
action: str,
|
||||
request_payload: dict[str, Any],
|
||||
response: dict[str, Any],
|
||||
*,
|
||||
actor: str,
|
||||
transport: str,
|
||||
) -> dict[str, Any]:
|
||||
master_pubkey = str(self._state.document.get("master_pubkey") or "")
|
||||
current_seq = int(self._state.document.get("seq") or 1)
|
||||
try:
|
||||
lic.verify_master_response(
|
||||
response,
|
||||
master_pubkey,
|
||||
expected_license_id=str(request_payload["license_id"]),
|
||||
expected_fingerprint=str(request_payload["fingerprint"]),
|
||||
expected_action=action,
|
||||
expected_nonce=str(request_payload["nonce"]),
|
||||
current_seq=current_seq,
|
||||
)
|
||||
except lic.LicensingError as exc:
|
||||
raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gueltig: {exc}") from exc
|
||||
|
||||
status = str(response.get("status") or "")
|
||||
detail = str(response.get("detail") or "")
|
||||
update = response.get("license_update")
|
||||
|
||||
if isinstance(update, dict):
|
||||
try:
|
||||
lic.verify_license(update, trusted_master_keys=self.trusted_keys(conn) or None)
|
||||
except lic.LicensingError as exc:
|
||||
logger.warning("Mitgeliefertes Lizenz-Update verworfen: %s", exc)
|
||||
else:
|
||||
self.license_path.write_text(
|
||||
json.dumps(update, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=actor,
|
||||
action="license.updated_by_master",
|
||||
target=str(update.get("license_id") or ""),
|
||||
detail=f"Revision {update.get('seq')}",
|
||||
severity="notice",
|
||||
)
|
||||
|
||||
if status == "revoked":
|
||||
self.settings.set(conn, SETTING_STATUS, STATUS_INACTIVE, actor=actor)
|
||||
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=actor,
|
||||
action="license.revoked_by_master",
|
||||
target=str(request_payload["license_id"]),
|
||||
severity="warning",
|
||||
)
|
||||
self.reload(conn)
|
||||
return {
|
||||
"ok": False,
|
||||
"transport": transport,
|
||||
"code": "",
|
||||
"message": "Diese Lizenz wurde vom Lizenzserver widerrufen.",
|
||||
}
|
||||
|
||||
if status != "ok":
|
||||
self.reload(conn)
|
||||
return {
|
||||
"ok": False,
|
||||
"transport": transport,
|
||||
"code": "",
|
||||
"message": detail or f"Der Lizenzserver hat die Anfrage abgelehnt ({status}).",
|
||||
}
|
||||
|
||||
if action == "activate":
|
||||
self.settings.set(conn, SETTING_STATUS, STATUS_ACTIVE, actor=actor)
|
||||
self.settings.set(conn, SETTING_EXPORT_GRACE, "", actor=actor)
|
||||
message = "Die Lizenz ist jetzt aktiv."
|
||||
elif action == "deactivate":
|
||||
self.settings.set(conn, SETTING_STATUS, STATUS_INACTIVE, actor=actor)
|
||||
# Der Kunde hat selbst deaktiviert (Systemwechsel): Export bleibt
|
||||
# erlaubt, damit er seine Daten mitnehmen kann.
|
||||
self.settings.set(conn, SETTING_EXPORT_GRACE, "1", actor=actor)
|
||||
message = "Die Lizenz wurde deaktiviert und kann auf einem anderen System aktiviert werden."
|
||||
else:
|
||||
self.settings.set(
|
||||
conn,
|
||||
SETTING_LAST_HEARTBEAT,
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
actor=actor,
|
||||
)
|
||||
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor)
|
||||
message = "Verbindung zum Lizenzserver bestaetigt."
|
||||
|
||||
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=actor,
|
||||
action=f"license.{action}",
|
||||
target=str(request_payload["license_id"]),
|
||||
detail=f"Transport: {transport}",
|
||||
severity="notice",
|
||||
)
|
||||
self.reload(conn)
|
||||
return {"ok": True, "transport": transport, "code": "", "message": message}
|
||||
|
||||
@staticmethod
|
||||
def _tls_context(ca_pem: str) -> Any:
|
||||
"""Prueft gegen ein hinterlegtes Zertifikat -- oder gegen die Systemanker.
|
||||
|
||||
Nie ohne Pruefung: das Lizenzprotokoll ist zwar selbst signiert und
|
||||
gegen Wiedereinspielung geschuetzt, aber das ist kein Grund, den
|
||||
Transport offen zu lassen.
|
||||
"""
|
||||
import ssl
|
||||
|
||||
context = ssl.create_default_context()
|
||||
pem = (ca_pem or "").strip()
|
||||
if not pem:
|
||||
return context
|
||||
try:
|
||||
# cadata nimmt PEM direkt entgegen -- keine temporaere Datei, die
|
||||
# liegen bleiben oder mitgelesen werden koennte.
|
||||
context.load_verify_locations(cadata=pem)
|
||||
except (ssl.SSLError, ValueError) as exc:
|
||||
raise ValueError(
|
||||
"Das hinterlegte Zertifikat des Lizenzservers ist unlesbar: " + str(exc)
|
||||
) from exc
|
||||
return context
|
||||
|
||||
@staticmethod
|
||||
def _post_json(
|
||||
url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = ""
|
||||
) -> dict[str, Any]:
|
||||
if not url.startswith(("https://", "http://")):
|
||||
raise ValueError("Ungueltiger Endpunkt")
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request( # noqa: S310 - Schema oben geprueft
|
||||
url,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
context = LicenseManager._tls_context(ca_pem) if url.startswith("https://") else None
|
||||
try:
|
||||
with urllib.request.urlopen( # noqa: S310
|
||||
request, timeout=timeout, context=context
|
||||
) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except ValueError:
|
||||
raise
|
||||
if isinstance(parsed, dict) and "signature" in parsed:
|
||||
return parsed # Auch abweisende Antworten sind signiert.
|
||||
raise
|
||||
return json.loads(body)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Heartbeat im Hintergrund
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def set_report_builder(self, builder: Callable[[sqlite3.Connection], dict[str, Any]]) -> None:
|
||||
"""Nur der Lizenzserver setzt das: Bericht an den eigenen Master."""
|
||||
self._report_builder = builder
|
||||
|
||||
def start_heartbeat(self, *, hostname: str = "") -> None:
|
||||
if self._heartbeat_thread is not None:
|
||||
return
|
||||
|
||||
def loop() -> None:
|
||||
# Erster Lauf verzoegert: der Dienst soll erst sauber hochkommen.
|
||||
time.sleep(90)
|
||||
while True:
|
||||
try:
|
||||
self.run_heartbeat(hostname=hostname)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Heartbeat fehlgeschlagen: %s", exc)
|
||||
time.sleep(lic.HEARTBEAT_INTERVAL_SECONDS)
|
||||
|
||||
thread = threading.Thread(target=loop, name="license-heartbeat", daemon=True)
|
||||
thread.start()
|
||||
self._heartbeat_thread = thread
|
||||
logger.info("Lizenz-Heartbeat gestartet (Intervall %ss)", lic.HEARTBEAT_INTERVAL_SECONDS)
|
||||
|
||||
def run_heartbeat(self, *, hostname: str = "") -> dict[str, Any]:
|
||||
with self.database.session() as conn:
|
||||
self.reload(conn)
|
||||
if not self._state.valid or not self._state.activated:
|
||||
return {"ok": False, "message": "Keine aktive Lizenz."}
|
||||
report = None
|
||||
if self._report_builder is not None and self._state.role == "license_server":
|
||||
report = self._report_builder(conn)
|
||||
try:
|
||||
with self.database.transaction(conn):
|
||||
return self.handshake(
|
||||
conn, "heartbeat", actor="system", hostname=hostname, report=report
|
||||
)
|
||||
except LicenseError as exc:
|
||||
with self.database.transaction(conn):
|
||||
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, str(exc), actor="system")
|
||||
return {"ok": False, "message": str(exc)}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Anzeige
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def topbar_pill(self) -> dict[str, str] | None:
|
||||
"""Zustandsanzeige fuer die Topbar. Reihenfolge = Dringlichkeit."""
|
||||
state = self._state
|
||||
if not state.present:
|
||||
return {
|
||||
"label": "Keine Lizenz",
|
||||
"css_class": "status-pill--danger",
|
||||
"dot": "1",
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": "Es ist keine Lizenzdatei installiert.",
|
||||
}
|
||||
if not state.valid:
|
||||
return {
|
||||
"label": "Lizenz ungueltig",
|
||||
"css_class": "status-pill--danger",
|
||||
"dot": "1",
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": state.error,
|
||||
}
|
||||
if not state.activated:
|
||||
return {
|
||||
"label": "Lizenz nicht aktiviert",
|
||||
"css_class": "status-pill--warning",
|
||||
"dot": "1",
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": "Die Lizenz muss noch aktiviert werden.",
|
||||
}
|
||||
status = state.status
|
||||
if status is None:
|
||||
return None
|
||||
if status.expired:
|
||||
days = status.days_since_expiry or 0
|
||||
return {
|
||||
"label": f"Lizenz abgelaufen (seit {days} Tagen)",
|
||||
"css_class": "status-pill--danger",
|
||||
"dot": "1",
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": (
|
||||
"Die Kulanzfrist laeuft noch, Funktionen bleiben vorerst verfuegbar."
|
||||
if status.grace_active
|
||||
else "Lizenzpflichtige Funktionen sind abgeschaltet."
|
||||
),
|
||||
}
|
||||
if status.expiring_soon:
|
||||
return {
|
||||
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
|
||||
"css_class": "status-pill--warning",
|
||||
"dot": "1",
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": "Bitte rechtzeitig verlaengern.",
|
||||
}
|
||||
if state.last_heartbeat_at:
|
||||
try:
|
||||
last = lic.parse_iso(state.last_heartbeat_at)
|
||||
days = (lic.utcnow() - last).days
|
||||
except ValueError:
|
||||
days = 0
|
||||
if days >= lic.HEARTBEAT_WARNING_DAYS:
|
||||
return {
|
||||
"label": f"Lizenzserver seit {days} Tagen nicht erreichbar",
|
||||
"css_class": "status-pill--warning",
|
||||
"dot": "1",
|
||||
"dot_class": "",
|
||||
"title": "Rein informativ -- es wird nichts abgeschaltet.",
|
||||
}
|
||||
return None
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Protokollierung: strukturierte Zeilen, Vorgangsnummer, Rotation via logrotate.
|
||||
|
||||
Jede Anfrage bekommt eine ``request_id``. Sie steht in jeder Logzeile, in
|
||||
Fehlerseiten und im Audit-Log -- damit ein Anruf "bei mir kam Fehler 500"
|
||||
in Sekunden auffindbar ist. Der Vorgaenger loggte ohne Korrelations-ID.
|
||||
|
||||
Rotation macht ``logrotate`` mit ``copytruncate`` (siehe ``deploy/``), nicht
|
||||
Python: mehrere Prozesse (Web, Monitor) schreiben in dasselbe Verzeichnis, und
|
||||
keiner von ihnen kann ein Reopen-Signal verarbeiten.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, g, has_request_context, request
|
||||
|
||||
ACCESS_LOGGER = "tesm.access"
|
||||
|
||||
|
||||
class RequestFormatter(logging.Formatter):
|
||||
"""Textformat fuer Menschen, mit Vorgangsnummer und Benutzer."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
if has_request_context():
|
||||
record.request_id = getattr(g, "request_id", "-")
|
||||
record.actor = getattr(getattr(g, "user", None), "username", "-") or "-"
|
||||
else:
|
||||
record.request_id = getattr(record, "request_id", "-")
|
||||
record.actor = getattr(record, "actor", "-")
|
||||
return super().format(record)
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
"""Maschinenlesbares Format -- fuer die Weiterverarbeitung in einem SIEM."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload = {
|
||||
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime(record.created)),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
if has_request_context():
|
||||
payload["request_id"] = getattr(g, "request_id", "")
|
||||
payload["path"] = request.path
|
||||
payload["method"] = request.method
|
||||
payload["actor"] = getattr(getattr(g, "user", None), "username", "") or ""
|
||||
if record.exc_info:
|
||||
payload["exception"] = self.formatException(record.exc_info)
|
||||
for key, value in getattr(record, "extra_fields", {}).items():
|
||||
payload[key] = value
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def init_app(app: Flask, *, log_dir: Path, debug: bool, json_logs: bool = False) -> None:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
level = logging.DEBUG if debug else logging.INFO
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
for handler in list(root.handlers):
|
||||
root.removeHandler(handler)
|
||||
|
||||
formatter: logging.Formatter = (
|
||||
JsonFormatter()
|
||||
if json_logs
|
||||
else RequestFormatter(
|
||||
"%(asctime)s %(levelname)-7s [%(request_id)s] %(actor)s %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
|
||||
stream = logging.StreamHandler(sys.stderr)
|
||||
stream.setFormatter(formatter)
|
||||
root.addHandler(stream)
|
||||
|
||||
try:
|
||||
app_log = log_dir / "app.log"
|
||||
file_handler = logging.FileHandler(app_log, encoding="utf-8", delay=True)
|
||||
file_handler.setFormatter(formatter)
|
||||
file_handler.setLevel(level)
|
||||
root.addHandler(file_handler)
|
||||
if app_log.exists():
|
||||
try:
|
||||
os.chmod(app_log, 0o640)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError as exc: # pragma: no cover - z. B. fehlende Rechte
|
||||
root.warning("Konnte app.log nicht oeffnen: %s", exc)
|
||||
|
||||
# Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces.
|
||||
access = logging.getLogger(ACCESS_LOGGER)
|
||||
access.propagate = False
|
||||
access.setLevel(logging.INFO)
|
||||
try:
|
||||
access_handler = logging.FileHandler(log_dir / "access.log", encoding="utf-8", delay=True)
|
||||
access_handler.setFormatter(
|
||||
JsonFormatter() if json_logs else logging.Formatter("%(asctime)s %(message)s")
|
||||
)
|
||||
access.addHandler(access_handler)
|
||||
except OSError: # pragma: no cover
|
||||
pass
|
||||
|
||||
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
||||
|
||||
@app.before_request
|
||||
def _assign_request_id() -> None:
|
||||
incoming = request.headers.get("X-Request-Id", "")
|
||||
g.request_id = incoming[:64] if incoming.isascii() and incoming else uuid.uuid4().hex[:16]
|
||||
g.request_started = time.perf_counter()
|
||||
|
||||
@app.after_request
|
||||
def _log_access(response): # type: ignore[no-untyped-def]
|
||||
if request.path.startswith("/static/"):
|
||||
return response
|
||||
duration = (time.perf_counter() - getattr(g, "request_started", time.perf_counter())) * 1000
|
||||
actor = getattr(getattr(g, "user", None), "username", "") or "-"
|
||||
access.info(
|
||||
"%s %s %s %s %s %.1fms",
|
||||
getattr(g, "request_id", "-"),
|
||||
actor,
|
||||
request.method,
|
||||
request.full_path if request.query_string else request.path,
|
||||
response.status_code,
|
||||
duration,
|
||||
)
|
||||
response.headers.setdefault("X-Request-Id", getattr(g, "request_id", ""))
|
||||
return response
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Versionierte Schema-Migrationen.
|
||||
|
||||
Der Vorgaenger hatte eine ``_ensure_schema()``-Funktion, die bei jedem Start
|
||||
rund 35 idempotente ``ALTER TABLE``-Blocks durchlief, plus Sentinel-Zeilen in
|
||||
der ``settings``-Tabelle als Marker fuer Einmal-Migrationen. Das funktioniert,
|
||||
ist aber nicht nachvollziehbar: welche Migration lief wann, in welcher
|
||||
Reihenfolge, mit welchem Inhalt?
|
||||
|
||||
Hier: eine ``schema_migrations``-Tabelle mit ``(component, version)``, einer
|
||||
Pruefsumme des Migrationsinhalts und Zeitstempel. Jede Migration laeuft genau
|
||||
einmal, in einer eigenen Transaktion, in Versionsreihenfolge.
|
||||
|
||||
``component`` trennt die Namensraeume: ``core`` bringt Benutzer/Gruppen/
|
||||
Sitzungen/Audit mit, jede Anwendung ihre Fachtabellen. Dadurch koennen beide
|
||||
Anwendungen dieselben Core-Migrationen nutzen, ohne dass sich Versionsnummern
|
||||
in die Quere kommen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import sqlite3
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .db import Database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MigrationFn = Callable[[sqlite3.Connection], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Migration:
|
||||
"""Eine Schemaaenderung.
|
||||
|
||||
``sql`` wird als Skript ausgefuehrt, danach ggf. ``fn`` fuer Datenmigrationen,
|
||||
die sich in reinem SQL nicht sinnvoll ausdruecken lassen.
|
||||
|
||||
``allow_integrity_error``: fuer nachtraeglich eingefuehrte Unique-Indizes.
|
||||
Trifft so ein Index auf bereits vorhandene, verletzende Altdaten, darf der
|
||||
Anwendungsstart nicht crashen -- er loggt und laeuft weiter. Genau dieses
|
||||
Muster hat sich im Vorgaengerprojekt bewaehrt.
|
||||
"""
|
||||
|
||||
version: int
|
||||
name: str
|
||||
sql: str = ""
|
||||
fn: MigrationFn | None = None
|
||||
allow_integrity_error: bool = False
|
||||
tags: tuple[str, ...] = field(default=())
|
||||
|
||||
@property
|
||||
def checksum(self) -> str:
|
||||
payload = f"{self.version}:{self.name}:{self.sql}".encode("utf-8")
|
||||
if self.fn is not None:
|
||||
payload += f":fn={self.fn.__name__}".encode("utf-8")
|
||||
return hashlib.sha256(payload).hexdigest()[:16]
|
||||
|
||||
|
||||
def split_statements(script: str) -> list[str]:
|
||||
"""Zerlegt ein SQL-Skript in einzelne Anweisungen.
|
||||
|
||||
Warum nicht ``executescript``: das setzt in Pythons sqlite3-Treiber
|
||||
**implizit ein COMMIT** ab, bevor es laeuft. Innerhalb einer laufenden
|
||||
Transaktion zerreisst das genau die Atomizitaet, die eine Migration
|
||||
braucht. Also: selbst zerlegen und Anweisung fuer Anweisung ausfuehren.
|
||||
|
||||
Der Splitter beruecksichtigt Zeichenketten, Zeilen- und Blockkommentare;
|
||||
``BEGIN ... END``-Bloecke (Trigger) werden zusammengehalten.
|
||||
"""
|
||||
statements: list[str] = []
|
||||
current: list[str] = []
|
||||
index = 0
|
||||
length = len(script)
|
||||
depth = 0 # BEGIN/END-Tiefe fuer Trigger
|
||||
|
||||
while index < length:
|
||||
char = script[index]
|
||||
nxt = script[index + 1] if index + 1 < length else ""
|
||||
|
||||
if char == "-" and nxt == "-":
|
||||
end = script.find("\n", index)
|
||||
index = length if end == -1 else end + 1
|
||||
continue
|
||||
if char == "/" and nxt == "*":
|
||||
end = script.find("*/", index + 2)
|
||||
index = length if end == -1 else end + 2
|
||||
continue
|
||||
if char in "'\"":
|
||||
quote = char
|
||||
current.append(char)
|
||||
index += 1
|
||||
while index < length:
|
||||
current.append(script[index])
|
||||
if script[index] == quote:
|
||||
if index + 1 < length and script[index + 1] == quote:
|
||||
current.append(script[index + 1])
|
||||
index += 2
|
||||
continue
|
||||
index += 1
|
||||
break
|
||||
index += 1
|
||||
continue
|
||||
|
||||
if char == ";" and depth == 0:
|
||||
statement = "".join(current).strip()
|
||||
if statement:
|
||||
statements.append(statement)
|
||||
current = []
|
||||
index += 1
|
||||
continue
|
||||
|
||||
current.append(char)
|
||||
upper_tail = "".join(current[-6:]).upper()
|
||||
if upper_tail.endswith("BEGIN"):
|
||||
depth += 1
|
||||
elif upper_tail.endswith("END") and depth > 0:
|
||||
depth -= 1
|
||||
index += 1
|
||||
|
||||
tail = "".join(current).strip()
|
||||
if tail:
|
||||
statements.append(tail)
|
||||
return statements
|
||||
|
||||
|
||||
def run_script(conn: sqlite3.Connection, script: str) -> None:
|
||||
for statement in split_statements(script):
|
||||
conn.execute(statement)
|
||||
|
||||
|
||||
_BOOTSTRAP = """
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
component TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL,
|
||||
PRIMARY KEY (component, version)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
class MigrationRunner:
|
||||
"""Fuehrt Migrationen mehrerer Komponenten gegen eine Datenbank aus."""
|
||||
|
||||
def __init__(self, database: Database) -> None:
|
||||
self.database = database
|
||||
|
||||
def applied(self, conn: sqlite3.Connection, component: str) -> dict[int, dict[str, str]]:
|
||||
rows = Database.all(
|
||||
conn,
|
||||
"SELECT version, name, checksum, applied_at FROM schema_migrations WHERE component=?",
|
||||
(component,),
|
||||
)
|
||||
return {int(row["version"]): row for row in rows}
|
||||
|
||||
def run(self, component: str, migrations: Sequence[Migration]) -> list[str]:
|
||||
"""Wendet fehlende Migrationen an. Rueckgabe: Namen der ausgefuehrten."""
|
||||
ordered = sorted(migrations, key=lambda m: m.version)
|
||||
versions = [m.version for m in ordered]
|
||||
if len(set(versions)) != len(versions):
|
||||
duplicates = sorted({v for v in versions if versions.count(v) > 1})
|
||||
raise ValueError(f"Doppelte Migrationsversionen in {component}: {duplicates}")
|
||||
|
||||
executed: list[str] = []
|
||||
with self.database.session() as conn:
|
||||
run_script(conn, _BOOTSTRAP)
|
||||
known = self.applied(conn, component)
|
||||
|
||||
for migration in ordered:
|
||||
record = known.get(migration.version)
|
||||
if record is not None:
|
||||
if record["checksum"] != migration.checksum:
|
||||
logger.warning(
|
||||
"Migration %s/%s (%s) wurde nachtraeglich geaendert "
|
||||
"(Pruefsumme %s != %s). Bereits angewendete Migrationen werden "
|
||||
"nicht erneut ausgefuehrt -- neue Aenderungen brauchen eine neue "
|
||||
"Versionsnummer.",
|
||||
component,
|
||||
migration.version,
|
||||
migration.name,
|
||||
record["checksum"],
|
||||
migration.checksum,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
with self.database.transaction(conn):
|
||||
if migration.sql.strip():
|
||||
run_script(conn, migration.sql)
|
||||
if migration.fn is not None:
|
||||
migration.fn(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO schema_migrations "
|
||||
"(component, version, name, checksum, applied_at) VALUES (?,?,?,?,?)",
|
||||
(
|
||||
component,
|
||||
migration.version,
|
||||
migration.name,
|
||||
migration.checksum,
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
if not migration.allow_integrity_error:
|
||||
raise
|
||||
logger.error(
|
||||
"Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht "
|
||||
"angewendet werden. Der Start laeuft weiter; die Daten muessen "
|
||||
"manuell bereinigt werden.",
|
||||
component,
|
||||
migration.version,
|
||||
migration.name,
|
||||
)
|
||||
continue
|
||||
executed.append(f"{component}/{migration.version} {migration.name}")
|
||||
logger.info("Migration angewendet: %s/%s %s", component, migration.version, migration.name)
|
||||
return executed
|
||||
|
||||
def status(self, components: Sequence[str]) -> list[dict[str, object]]:
|
||||
"""Uebersicht fuer die Diagnoseseite."""
|
||||
with self.database.session() as conn:
|
||||
run_script(conn, _BOOTSTRAP)
|
||||
return Database.all(
|
||||
conn,
|
||||
"SELECT component, version, name, checksum, applied_at FROM schema_migrations "
|
||||
f"WHERE component IN ({','.join('?' * len(components))}) "
|
||||
"ORDER BY component, version",
|
||||
tuple(components),
|
||||
)
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Navigation: Struktur, Sichtbarkeit, Reihenfolge.
|
||||
|
||||
Uebernommen vom Vorgaenger (das Muster war gut), aber mit zwei Aenderungen:
|
||||
|
||||
* Sichtbarkeit haengt an einer Liste von Rechten **plus** optional einer
|
||||
Bedingung. Im Vorgaenger war das eine ``if/elif``-Kette in
|
||||
``_nav_key_visible()`` -- jede neue Seite bedeutete einen Eingriff in diese
|
||||
Funktion. Hier deklariert jeder Eintrag selbst, was er braucht.
|
||||
* "Sichtbar, wenn *irgendeines* der Rechte greift" ist der Default. Im
|
||||
Vorgaenger war eine Seite mit reinem Export-Recht unerreichbar, weil die
|
||||
Navigation nur auf ``.view`` prueft.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Iterable, Sequence
|
||||
|
||||
VisibilityFn = Callable[[Any], bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NavItem:
|
||||
key: str
|
||||
label: str
|
||||
icon: str
|
||||
endpoint: str = ""
|
||||
#: Sichtbar, wenn eines dieser Rechte greift (leer = immer sichtbar).
|
||||
permissions: tuple[str, ...] = ()
|
||||
#: Zusaetzliche Bedingung, z. B. Modul-Lizenz oder vorhandene Freigaben.
|
||||
condition: VisibilityFn | None = field(default=None, compare=False)
|
||||
children: tuple["NavItem", ...] = ()
|
||||
badge: Callable[[], str] | None = field(default=None, compare=False)
|
||||
hint: str = ""
|
||||
|
||||
@property
|
||||
def is_group(self) -> bool:
|
||||
return bool(self.children)
|
||||
|
||||
|
||||
class NavTree:
|
||||
"""Navigationsbaum mit laufzeitkonfigurierbarer Reihenfolge."""
|
||||
|
||||
def __init__(self, items: Sequence[NavItem]) -> None:
|
||||
self.items: tuple[NavItem, ...] = tuple(items)
|
||||
self._by_key: dict[str, NavItem] = {}
|
||||
for item in self.items:
|
||||
self._by_key[item.key] = item
|
||||
for child in item.children:
|
||||
self._by_key[child.key] = child
|
||||
|
||||
def item(self, key: str) -> NavItem | None:
|
||||
return self._by_key.get(key)
|
||||
|
||||
def default_order(self) -> list[str]:
|
||||
return [item.key for item in self.items]
|
||||
|
||||
def default_child_order(self, group_key: str) -> list[str]:
|
||||
item = self._by_key.get(group_key)
|
||||
return [child.key for child in item.children] if item else []
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def visible(self, item: NavItem, user: Any) -> bool:
|
||||
if item.permissions and not user.has_any(item.permissions):
|
||||
return False
|
||||
if item.condition is not None and not item.condition(user):
|
||||
return False
|
||||
return True
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
user: Any,
|
||||
*,
|
||||
order: Sequence[str] | None = None,
|
||||
child_order: dict[str, Sequence[str]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Liefert die gefilterte, sortierte Navigation fuer das Template."""
|
||||
child_order = child_order or {}
|
||||
result: list[dict[str, Any]] = []
|
||||
|
||||
for item in self._ordered(self.items, order):
|
||||
if item.is_group:
|
||||
children = [
|
||||
child
|
||||
for child in self._ordered(item.children, child_order.get(item.key))
|
||||
if self.visible(child, user)
|
||||
]
|
||||
if not children:
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"key": item.key,
|
||||
"label": item.label,
|
||||
"icon": item.icon,
|
||||
"endpoint": "",
|
||||
"hint": item.hint,
|
||||
"children": [self._as_dict(child) for child in children],
|
||||
}
|
||||
)
|
||||
continue
|
||||
if not self.visible(item, user):
|
||||
continue
|
||||
result.append(self._as_dict(item))
|
||||
return result
|
||||
|
||||
def _as_dict(self, item: NavItem) -> dict[str, Any]:
|
||||
return {
|
||||
"key": item.key,
|
||||
"label": item.label,
|
||||
"icon": item.icon,
|
||||
"endpoint": item.endpoint,
|
||||
"hint": item.hint,
|
||||
"badge": item.badge() if item.badge else "",
|
||||
"children": [],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _ordered(
|
||||
items: Sequence[NavItem], order: Sequence[str] | None
|
||||
) -> list[NavItem]:
|
||||
if not order:
|
||||
return list(items)
|
||||
index = {key: position for position, key in enumerate(order)}
|
||||
# Unbekannte Schluessel (neu dazugekommene Seiten) landen hinten, statt
|
||||
# zu verschwinden -- der Vorgaenger verlor sie stillschweigend.
|
||||
return sorted(items, key=lambda item: (index.get(item.key, len(index)), item.label))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def sanitize_order(self, keys: Iterable[str]) -> list[str]:
|
||||
known = self.default_order()
|
||||
chosen = [key for key in keys if key in known]
|
||||
return chosen + [key for key in known if key not in chosen]
|
||||
|
||||
def sanitize_child_order(self, group_key: str, keys: Iterable[str]) -> list[str]:
|
||||
known = self.default_child_order(group_key)
|
||||
chosen = [key for key in keys if key in known]
|
||||
return chosen + [key for key in known if key not in chosen]
|
||||
|
||||
def searchable(self, user: Any) -> list[dict[str, str]]:
|
||||
"""Flache Liste aller sichtbaren Seiten -- Datenbasis der Befehlspalette."""
|
||||
entries: list[dict[str, str]] = []
|
||||
for item in self.items:
|
||||
if item.is_group:
|
||||
for child in item.children:
|
||||
if self.visible(child, user):
|
||||
entries.append(
|
||||
{
|
||||
"label": child.label,
|
||||
"group": item.label,
|
||||
"endpoint": child.endpoint,
|
||||
"icon": child.icon,
|
||||
}
|
||||
)
|
||||
elif self.visible(item, user):
|
||||
entries.append(
|
||||
{"label": item.label, "group": "", "endpoint": item.endpoint, "icon": item.icon}
|
||||
)
|
||||
return entries
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Rechtesystem: Modell (Bereiche/Ressourcen/Aktionen) und Verwaltungsdienste."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .model import (
|
||||
ACTION_META,
|
||||
IMPLIED_BY,
|
||||
SENSITIVE_ACTIONS,
|
||||
Action,
|
||||
Area,
|
||||
PermissionTree,
|
||||
Resource,
|
||||
RolePreset,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ACTION_META",
|
||||
"Action",
|
||||
"Area",
|
||||
"IMPLIED_BY",
|
||||
"PermissionTree",
|
||||
"Resource",
|
||||
"RolePreset",
|
||||
"SENSITIVE_ACTIONS",
|
||||
]
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Ressourcen, die beide Anwendungen teilen.
|
||||
|
||||
``tesm_core`` bringt Benutzer-, Gruppen-, Papierkorb-, Protokoll- und
|
||||
Diagnoseseiten selbst mit. Damit deren Rechtepruefungen greifen, muessen die
|
||||
zugehoerigen Ressourcenschluessel im Rechtebaum der Anwendung existieren --
|
||||
darum stehen sie hier und werden von beiden Apps eingebunden statt zweimal
|
||||
getippt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .model import Action, Area, Resource
|
||||
|
||||
A = Action
|
||||
|
||||
USERS = Resource(
|
||||
key="users",
|
||||
label="Benutzer",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
||||
hint="Konten anlegen, sperren, Gruppen zuordnen",
|
||||
action_hints={
|
||||
A.EDIT: "Stammdaten, Sperre und Gruppenzuordnung aendern.",
|
||||
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
|
||||
},
|
||||
)
|
||||
|
||||
GROUPS = Resource(
|
||||
key="groups",
|
||||
label="Gruppen und Rechte",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
||||
hint="Rechtematrix pflegen",
|
||||
action_hints={
|
||||
A.EDIT: "Rechte vergeben -- nur Rechte, die man selbst besitzt.",
|
||||
},
|
||||
)
|
||||
|
||||
SETTINGS_SYSTEM = Resource(
|
||||
key="settings_system",
|
||||
label="Systemeinstellungen",
|
||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
||||
hint="Hostname, Zeitzone, Protokollrotation, Navigation",
|
||||
action_hints={A.EXECUTE: "Systemaktionen ausloesen (Dienste, Netzwerk uebernehmen)."},
|
||||
)
|
||||
|
||||
SETTINGS_LICENSE = Resource(
|
||||
key="settings_license",
|
||||
label="Lizenz",
|
||||
actions=(A.VIEW, A.EDIT),
|
||||
hint="Lizenzdatei einspielen, aktivieren, deaktivieren",
|
||||
)
|
||||
|
||||
SETTINGS_DIRECTORY = Resource(
|
||||
key="settings_directory",
|
||||
label="Verzeichnisdienst (LDAP/AD)",
|
||||
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
|
||||
hint="Anbindung und Gruppen-Zuordnung",
|
||||
action_hints={
|
||||
A.EXECUTE: "Verbindungstest ausfuehren.",
|
||||
A.SECRETS: "Bind-Passwort des Dienstkontos setzen.",
|
||||
},
|
||||
)
|
||||
|
||||
SETTINGS_WEB = Resource(
|
||||
key="settings_web",
|
||||
label="Webserver und TLS",
|
||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
||||
hint="Domain, Zertifikat, HSTS",
|
||||
action_hints={A.EXECUTE: "Konfiguration uebernehmen, Zertifikat anfordern."},
|
||||
)
|
||||
|
||||
SETTINGS_NETWORK = Resource(
|
||||
key="settings_network",
|
||||
label="Netzwerkeinstellungen",
|
||||
actions=(A.VIEW, A.EDIT),
|
||||
hint="IP-Adresse, Gateway, DNS des Hosts",
|
||||
)
|
||||
|
||||
BACKUP = Resource(
|
||||
key="backup",
|
||||
label="Sicherung und Uebertragung",
|
||||
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
|
||||
hint="Verschluesselter Export und Import",
|
||||
action_hints={
|
||||
A.EXECUTE: "Eine Sicherung einspielen.",
|
||||
A.EXPORT: "Sicherung erzeugen und herunterladen.",
|
||||
A.SECRETS: "Zugangsdaten in die Sicherung einschliessen.",
|
||||
},
|
||||
)
|
||||
|
||||
TRASH = Resource(
|
||||
key="trash",
|
||||
label="Papierkorb",
|
||||
actions=(A.VIEW, A.EDIT, A.DELETE),
|
||||
hint="Geloeschtes wiederherstellen oder endgueltig entfernen",
|
||||
action_hints={
|
||||
A.EDIT: "Eintraege wiederherstellen.",
|
||||
A.DELETE: "Eintraege endgueltig entfernen.",
|
||||
},
|
||||
)
|
||||
|
||||
DIAGNOSTICS = Resource(
|
||||
key="diagnostics",
|
||||
label="Diagnose",
|
||||
actions=(A.VIEW,),
|
||||
hint="Schemastand, Protokollintegritaet, Schluesselrechte",
|
||||
)
|
||||
|
||||
LOGS_LIVE = Resource(
|
||||
key="logs_live",
|
||||
label="Laufendes Protokoll",
|
||||
actions=(A.VIEW,),
|
||||
)
|
||||
|
||||
LOGS_HISTORY = Resource(
|
||||
key="logs_history",
|
||||
label="Protokollverlauf",
|
||||
actions=(A.VIEW, A.DELETE, A.EXPORT),
|
||||
hint="Rotierte und archivierte Protokolle",
|
||||
action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."},
|
||||
)
|
||||
|
||||
LOGS_AUDIT = Resource(
|
||||
key="logs_audit",
|
||||
label="Aenderungsprotokoll",
|
||||
actions=(A.VIEW, A.EXPORT),
|
||||
hint="Wer hat wann was geaendert",
|
||||
)
|
||||
|
||||
|
||||
def administration_area(*extra: Resource) -> Area:
|
||||
"""Bereich "Verwaltung" -- Basis fuer beide Anwendungen."""
|
||||
return Area(
|
||||
key="administration",
|
||||
label="Verwaltung",
|
||||
icon="sliders",
|
||||
hint="Benutzer, Rechte, Systemeinstellungen. Ohne diesen Zugang bleibt der "
|
||||
"gesamte Verwaltungsbereich verborgen.",
|
||||
resources=(
|
||||
USERS,
|
||||
GROUPS,
|
||||
SETTINGS_SYSTEM,
|
||||
SETTINGS_LICENSE,
|
||||
SETTINGS_DIRECTORY,
|
||||
SETTINGS_WEB,
|
||||
SETTINGS_NETWORK,
|
||||
BACKUP,
|
||||
TRASH,
|
||||
DIAGNOSTICS,
|
||||
*extra,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def observability_area(*extra: Resource) -> Area:
|
||||
"""Bereich "Protokolle"."""
|
||||
return Area(
|
||||
key="observability",
|
||||
label="Protokolle",
|
||||
icon="terminal",
|
||||
resources=(LOGS_LIVE, LOGS_HISTORY, LOGS_AUDIT, *extra),
|
||||
)
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Rechtemodell: Bereiche, Ressourcen, Aktionen.
|
||||
|
||||
Das Vorgaengermodell kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern")
|
||||
das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes
|
||||
bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber:
|
||||
|
||||
* Wer jemandem erlauben wollte, Geraete zu pflegen, musste ihm zwangslaeufig
|
||||
auch das Loeschen erlauben. Das ist genau die Trennung, die man in einer
|
||||
Verwaltungsanwendung am haeufigsten braucht.
|
||||
* Dieselbe Spalte bedeutete in verschiedenen Zeilen Verschiedenes -- eine
|
||||
Rechtematrix, die man erklaeren muss, statt sie zu lesen.
|
||||
|
||||
Neues Modell: ein festes, ueberall gleich bedeutendes Aktionsvokabular. Welche
|
||||
Aktionen eine Ressource ueberhaupt kennt, entscheidet die Ressource; die Matrix
|
||||
rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen.
|
||||
|
||||
Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein
|
||||
``<bereich>.view``-Recht. Ohne dieses greift kein einziges Recht innerhalb des
|
||||
Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer
|
||||
Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein
|
||||
eingestellten Einzelrechte zu verlieren.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Iterable, Mapping, Sequence
|
||||
|
||||
|
||||
class Action(str, Enum):
|
||||
"""Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen."""
|
||||
|
||||
VIEW = "view"
|
||||
CREATE = "create"
|
||||
EDIT = "edit"
|
||||
DELETE = "delete"
|
||||
EXECUTE = "execute"
|
||||
EXPORT = "export"
|
||||
SECRETS = "secrets"
|
||||
|
||||
|
||||
#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix.
|
||||
ACTION_META: dict[Action, tuple[str, str, str]] = {
|
||||
Action.VIEW: ("R", "Ansehen", "Datensaetze und Seiten dieses Bereichs lesen."),
|
||||
Action.CREATE: ("C", "Anlegen", "Neue Datensaetze erstellen."),
|
||||
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensaetze aendern -- ohne Loeschen."),
|
||||
Action.DELETE: ("D", "Loeschen", "Datensaetze loeschen bzw. endgueltig entfernen."),
|
||||
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen ausloesen (Neustart, Dienste, Jobs, Import)."),
|
||||
Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."),
|
||||
Action.SECRETS: (
|
||||
"S",
|
||||
"Geheimnisse",
|
||||
"Passwoerter und Schluessel im Klartext sehen oder mitnehmen.",
|
||||
),
|
||||
}
|
||||
|
||||
#: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus).
|
||||
SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT})
|
||||
|
||||
#: ``view`` ist Voraussetzung fuer alles andere in derselben Zeile.
|
||||
IMPLIED_BY: dict[Action, tuple[Action, ...]] = {
|
||||
action: (Action.VIEW,) for action in Action if action is not Action.VIEW
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Resource:
|
||||
"""Eine Zeile der Rechtematrix."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
actions: tuple[Action, ...]
|
||||
hint: str = ""
|
||||
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
|
||||
action_hints: Mapping[Action, str] = field(default_factory=dict)
|
||||
|
||||
def permission(self, action: Action) -> str:
|
||||
return f"{self.key}.{action.value}"
|
||||
|
||||
def has(self, action: Action) -> bool:
|
||||
return action in self.actions
|
||||
|
||||
def permissions(self) -> tuple[str, ...]:
|
||||
return tuple(self.permission(action) for action in self.actions)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Area:
|
||||
"""Ein Bereich (Gruppe von Ressourcen) mit eigenem Kill-Switch."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
icon: str
|
||||
resources: tuple[Resource, ...]
|
||||
hint: str = ""
|
||||
|
||||
@property
|
||||
def view_key(self) -> str:
|
||||
return f"{self.key}.view"
|
||||
|
||||
def columns(self) -> tuple[Action, ...]:
|
||||
"""Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen."""
|
||||
present = {action for resource in self.resources for action in resource.actions}
|
||||
return tuple(action for action in Action if action in present)
|
||||
|
||||
def resource(self, key: str) -> Resource | None:
|
||||
for resource in self.resources:
|
||||
if resource.key == key:
|
||||
return resource
|
||||
return None
|
||||
|
||||
|
||||
class PermissionTree:
|
||||
"""Aus Bereichen abgeleitete Nachschlagestrukturen.
|
||||
|
||||
Wird beim Import einmal gebaut und danach nur gelesen.
|
||||
"""
|
||||
|
||||
def __init__(self, areas: Sequence[Area]) -> None:
|
||||
self.areas: tuple[Area, ...] = tuple(areas)
|
||||
self._by_key: dict[str, Area] = {}
|
||||
self._parent: dict[str, str] = {}
|
||||
self._all: list[str] = []
|
||||
self._resources: dict[str, tuple[Area, Resource]] = {}
|
||||
|
||||
seen_keys: set[str] = set()
|
||||
for area in self.areas:
|
||||
if area.key in seen_keys:
|
||||
raise ValueError(f"Doppelter Bereichsschluessel: {area.key}")
|
||||
seen_keys.add(area.key)
|
||||
self._by_key[area.key] = area
|
||||
self._all.append(area.view_key)
|
||||
for resource in area.resources:
|
||||
if resource.key in seen_keys:
|
||||
raise ValueError(f"Doppelter Ressourcenschluessel: {resource.key}")
|
||||
seen_keys.add(resource.key)
|
||||
if Action.VIEW not in resource.actions:
|
||||
raise ValueError(
|
||||
f"Ressource {resource.key} muss die Aktion 'view' anbieten -- "
|
||||
"sie ist Voraussetzung fuer jede andere Aktion."
|
||||
)
|
||||
self._resources[resource.key] = (area, resource)
|
||||
for permission in resource.permissions():
|
||||
self._all.append(permission)
|
||||
self._parent[permission] = area.view_key
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def all_keys(self) -> tuple[str, ...]:
|
||||
return tuple(self._all)
|
||||
|
||||
def area(self, key: str) -> Area | None:
|
||||
return self._by_key.get(key)
|
||||
|
||||
def parent_view_key(self, permission: str) -> str | None:
|
||||
return self._parent.get(permission)
|
||||
|
||||
def resource(self, key: str) -> tuple[Area, Resource] | None:
|
||||
return self._resources.get(key)
|
||||
|
||||
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:
|
||||
return f"Bereich {self._by_key[permission[:-5]].label}: Zugang"
|
||||
resource_key, _, action_value = permission.rpartition(".")
|
||||
entry = self._resources.get(resource_key)
|
||||
if entry is None:
|
||||
return permission
|
||||
_, resource = entry
|
||||
try:
|
||||
action = Action(action_value)
|
||||
except ValueError:
|
||||
return permission
|
||||
return f"{resource.label}: {ACTION_META[action][1]}"
|
||||
|
||||
def sanitize(self, permissions: Iterable[str]) -> list[str]:
|
||||
"""Wirft unbekannte Rechte weg und ergaenzt implizierte.
|
||||
|
||||
Regeln:
|
||||
* Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles
|
||||
Weiterschleppen von Altrechten nach einem Refactoring).
|
||||
* Jede Aktion impliziert ``view`` derselben Zeile.
|
||||
* Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des
|
||||
Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der
|
||||
Bearbeiter wuerde sich wundern.
|
||||
"""
|
||||
known = set(self._all)
|
||||
result: set[str] = {p for p in permissions if p in known}
|
||||
for permission in list(result):
|
||||
resource_key, _, action_value = permission.rpartition(".")
|
||||
if resource_key in self._resources:
|
||||
try:
|
||||
action = Action(action_value)
|
||||
except ValueError:
|
||||
continue
|
||||
for implied in IMPLIED_BY.get(action, ()):
|
||||
candidate = f"{resource_key}.{implied.value}"
|
||||
if candidate in known:
|
||||
result.add(candidate)
|
||||
for area in self.areas:
|
||||
if any(p in result for resource in area.resources for p in resource.permissions()):
|
||||
result.add(area.view_key)
|
||||
return sorted(result)
|
||||
|
||||
def effective(self, permissions: Iterable[str]) -> set[str]:
|
||||
"""Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen."""
|
||||
granted = set(permissions)
|
||||
active = {key for key in granted if key.endswith(".view") and key[:-5] in self._by_key}
|
||||
return {
|
||||
key
|
||||
for key in granted
|
||||
if (parent := self._parent.get(key)) is None or parent in active
|
||||
}
|
||||
|
||||
def expand_preset(self, preset: "RolePreset") -> list[str]:
|
||||
return self.sanitize(preset.permissions(self))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RolePreset:
|
||||
"""Benannte Rechtevorlage -- wird beim ersten Start als Gruppe angelegt.
|
||||
|
||||
Der Vorgaenger kannte nur "Admin" und eine Standardgruppe "Benutzer"; jede
|
||||
weitere Rolle musste jeder Kunde von Hand zusammenklicken. Vorlagen machen
|
||||
aus der haeufigsten Aufgabe einen Startpunkt.
|
||||
"""
|
||||
|
||||
key: str
|
||||
name: str
|
||||
description: str
|
||||
#: ``(ressource, aktionen)``-Paare, ``"*"`` fuer alle Aktionen der Ressource.
|
||||
grants: tuple[tuple[str, tuple[Action, ...] | str], ...]
|
||||
is_default: bool = False
|
||||
"""Standardgruppe: wird jedem neuen Nicht-Admin automatisch zugeordnet."""
|
||||
|
||||
def permissions(self, tree: PermissionTree) -> list[str]:
|
||||
result: list[str] = []
|
||||
for resource_key, actions in self.grants:
|
||||
entry = tree.resource(resource_key)
|
||||
if entry is None:
|
||||
continue
|
||||
_, resource = entry
|
||||
wanted = resource.actions if actions == "*" else tuple(a for a in actions if resource.has(a)) # type: ignore[arg-type]
|
||||
result.extend(resource.permission(action) for action in wanted)
|
||||
return result
|
||||
@@ -0,0 +1,717 @@
|
||||
"""Benutzer- und Gruppenverwaltung inklusive Eskalationsschutz.
|
||||
|
||||
Die wichtigste inhaltliche Korrektur gegenueber dem Vorgaenger steckt in
|
||||
``guard_no_amplification``:
|
||||
|
||||
Im Vorgaenger konnte jeder, der das Recht ``groups.edit`` besass, einer Gruppe
|
||||
*beliebige* Rechte zuweisen -- also auch Rechte, die er selbst nicht hatte --
|
||||
und sich anschliessend selbst in diese Gruppe setzen. Das Rechtesystem war
|
||||
damit fuer jeden Gruppenverwalter effektiv offen. Hier gilt: wer selbst kein
|
||||
Recht X hat, kann Recht X auch nicht vergeben. Entziehen ist immer erlaubt.
|
||||
Echte Administratoren sind davon ausgenommen (sie haben ohnehin alles).
|
||||
|
||||
Weitere fest verdrahtete Grenzen, die kein Gruppenrecht aushebeln kann:
|
||||
|
||||
* Das Admin-Kennzeichen setzt oder entfernt nur ein echter Administrator.
|
||||
* Ein bestehendes Administratorkonto bearbeitet, sperrt oder loescht nur ein
|
||||
echter Administrator.
|
||||
* Es muss immer mindestens ein entsperrtes Administratorkonto uebrig bleiben.
|
||||
* Niemand kann sich selbst das Admin-Kennzeichen entziehen oder sich selbst
|
||||
sperren (haeufigster Weg, sich komplett auszusperren).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable, Sequence
|
||||
|
||||
from ..db import Database
|
||||
from ..security import passwords
|
||||
from .model import Action, PermissionTree, RolePreset
|
||||
|
||||
|
||||
class PermissionDenied(Exception):
|
||||
"""Verstoss gegen eine feste Sicherheitsgrenze -- wird als Klartext angezeigt."""
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""Eingabefehler, der dem Benutzer erklaert wird."""
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Gruppen
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GroupRecord:
|
||||
id: int
|
||||
name: str
|
||||
description: str
|
||||
is_system: bool
|
||||
is_default: bool
|
||||
preset_key: str
|
||||
permissions: tuple[str, ...]
|
||||
member_count: int
|
||||
deleted_at: str | None = None
|
||||
|
||||
|
||||
def list_groups(
|
||||
conn: sqlite3.Connection, *, include_deleted: bool = False
|
||||
) -> list[GroupRecord]:
|
||||
where = "" if include_deleted else "WHERE g.deleted_at IS NULL"
|
||||
rows = Database.all(
|
||||
conn,
|
||||
f"""
|
||||
SELECT g.*,
|
||||
(SELECT COUNT(*) FROM user_groups ug
|
||||
JOIN users u ON u.id = ug.user_id AND u.deleted_at IS NULL
|
||||
WHERE ug.group_id = g.id) AS member_count
|
||||
FROM groups g {where}
|
||||
ORDER BY g.is_system DESC, g.name COLLATE NOCASE
|
||||
""",
|
||||
)
|
||||
perms = _permissions_by_group(conn)
|
||||
return [
|
||||
GroupRecord(
|
||||
id=int(row["id"]),
|
||||
name=str(row["name"]),
|
||||
description=str(row["description"] or ""),
|
||||
is_system=bool(row["is_system"]),
|
||||
is_default=bool(row["is_default"]),
|
||||
preset_key=str(row["preset_key"] or ""),
|
||||
permissions=tuple(perms.get(int(row["id"]), ())),
|
||||
member_count=int(row["member_count"] or 0),
|
||||
deleted_at=row["deleted_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _permissions_by_group(conn: sqlite3.Connection) -> dict[int, list[str]]:
|
||||
result: dict[int, list[str]] = {}
|
||||
for row in Database.all(conn, "SELECT group_id, permission FROM group_permissions"):
|
||||
result.setdefault(int(row["group_id"]), []).append(str(row["permission"]))
|
||||
for values in result.values():
|
||||
values.sort()
|
||||
return result
|
||||
|
||||
|
||||
def get_group(conn: sqlite3.Connection, group_id: int) -> GroupRecord | None:
|
||||
for group in list_groups(conn, include_deleted=True):
|
||||
if group.id == group_id:
|
||||
return group
|
||||
return None
|
||||
|
||||
|
||||
def group_permissions(conn: sqlite3.Connection, group_id: int) -> list[str]:
|
||||
return [
|
||||
str(row["permission"])
|
||||
for row in Database.all(
|
||||
conn,
|
||||
"SELECT permission FROM group_permissions WHERE group_id=? ORDER BY permission",
|
||||
(group_id,),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def guard_no_amplification(
|
||||
actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht"
|
||||
) -> None:
|
||||
"""Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt."""
|
||||
if getattr(actor, "is_admin", False):
|
||||
return
|
||||
missing = sorted(p for p in added if not actor.has(p))
|
||||
if missing:
|
||||
labels = ", ".join(tree.label_for(p) for p in missing[:6])
|
||||
more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)"
|
||||
raise PermissionDenied(
|
||||
f"Sie koennen kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}."
|
||||
)
|
||||
|
||||
|
||||
def create_group(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
permissions: Sequence[str],
|
||||
tree: PermissionTree,
|
||||
actor: Any,
|
||||
preset_key: str = "",
|
||||
is_system: bool = False,
|
||||
is_default: bool = False,
|
||||
) -> int:
|
||||
clean_name = (name or "").strip()
|
||||
if not clean_name:
|
||||
raise ValidationError("Der Gruppenname darf nicht leer sein.")
|
||||
if len(clean_name) > 64:
|
||||
raise ValidationError("Der Gruppenname ist zu lang (maximal 64 Zeichen).")
|
||||
existing = Database.one(
|
||||
conn, "SELECT id, deleted_at FROM groups WHERE name=? COLLATE NOCASE", (clean_name,)
|
||||
)
|
||||
if existing is not None:
|
||||
if existing["deleted_at"]:
|
||||
raise ValidationError(
|
||||
f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. "
|
||||
"Stellen Sie sie wieder her oder loeschen Sie sie endgueltig."
|
||||
)
|
||||
raise ValidationError(f"Eine Gruppe namens {clean_name!r} existiert bereits.")
|
||||
|
||||
wanted = tree.sanitize(permissions)
|
||||
guard_no_amplification(actor, tree, wanted)
|
||||
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
|
||||
"created_at, updated_at) VALUES (?,?,?,?,?,?,?)",
|
||||
(clean_name, (description or "").strip(), int(is_system), int(is_default), preset_key, _now(), _now()),
|
||||
)
|
||||
group_id = int(cursor.lastrowid or 0)
|
||||
_write_permissions(conn, group_id, wanted)
|
||||
return group_id
|
||||
|
||||
|
||||
def _write_permissions(conn: sqlite3.Connection, group_id: int, permissions: Sequence[str]) -> None:
|
||||
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
|
||||
conn.executemany(
|
||||
"INSERT INTO group_permissions (group_id, permission) VALUES (?,?)",
|
||||
[(group_id, permission) for permission in permissions],
|
||||
)
|
||||
|
||||
|
||||
def update_group(
|
||||
conn: sqlite3.Connection,
|
||||
group_id: int,
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
permissions: Sequence[str] | None = None,
|
||||
tree: PermissionTree,
|
||||
actor: Any,
|
||||
unlock_system: bool = False,
|
||||
) -> list[str]:
|
||||
"""Aendert eine Gruppe. Rueckgabe: Liste der Aenderungen fuer das Audit-Log."""
|
||||
group = get_group(conn, group_id)
|
||||
if group is None or group.deleted_at:
|
||||
raise ValidationError("Diese Gruppe existiert nicht (mehr).")
|
||||
|
||||
changes: list[str] = []
|
||||
|
||||
if name is not None and name.strip() != group.name:
|
||||
if group.is_system:
|
||||
raise PermissionDenied("Systemgruppen koennen nicht umbenannt werden.")
|
||||
clean = name.strip()
|
||||
if not clean:
|
||||
raise ValidationError("Der Gruppenname darf nicht leer sein.")
|
||||
clash = Database.one(
|
||||
conn,
|
||||
"SELECT id FROM groups WHERE name=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL",
|
||||
(clean, group_id),
|
||||
)
|
||||
if clash is not None:
|
||||
raise ValidationError(f"Eine Gruppe namens {clean!r} existiert bereits.")
|
||||
conn.execute("UPDATE groups SET name=?, updated_at=? WHERE id=?", (clean, _now(), group_id))
|
||||
changes.append(f"Name: {group.name} -> {clean}")
|
||||
|
||||
if description is not None and description.strip() != group.description:
|
||||
conn.execute(
|
||||
"UPDATE groups SET description=?, updated_at=? WHERE id=?",
|
||||
(description.strip(), _now(), group_id),
|
||||
)
|
||||
changes.append("Beschreibung geaendert")
|
||||
|
||||
if permissions is not None:
|
||||
if group.is_system and not unlock_system:
|
||||
raise PermissionDenied(
|
||||
"Die Rechte dieser Systemgruppe sind gesperrt. "
|
||||
"Sie muessen sie zuerst ausdruecklich freischalten."
|
||||
)
|
||||
wanted = tree.sanitize(permissions)
|
||||
added = sorted(set(wanted) - set(group.permissions))
|
||||
removed = sorted(set(group.permissions) - set(wanted))
|
||||
guard_no_amplification(actor, tree, added)
|
||||
if added or removed:
|
||||
_write_permissions(conn, group_id, wanted)
|
||||
conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id))
|
||||
if added:
|
||||
changes.append("Rechte hinzugefuegt: " + ", ".join(tree.label_for(p) for p in added))
|
||||
if removed:
|
||||
changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed))
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
def soft_delete_group(conn: sqlite3.Connection, group_id: int) -> None:
|
||||
group = get_group(conn, group_id)
|
||||
if group is None:
|
||||
raise ValidationError("Diese Gruppe existiert nicht.")
|
||||
if group.is_system:
|
||||
raise PermissionDenied("Systemgruppen koennen nicht geloescht werden.")
|
||||
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id))
|
||||
|
||||
|
||||
def restore_group(conn: sqlite3.Connection, group_id: int) -> None:
|
||||
group = get_group(conn, group_id)
|
||||
if group is None:
|
||||
raise ValidationError("Diese Gruppe existiert nicht.")
|
||||
clash = Database.one(
|
||||
conn,
|
||||
"SELECT id FROM groups WHERE name=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL",
|
||||
(group.name, group_id),
|
||||
)
|
||||
if clash is not None:
|
||||
raise ValidationError(
|
||||
f"Es existiert bereits eine aktive Gruppe namens {group.name!r}. "
|
||||
"Benennen Sie diese zuerst um."
|
||||
)
|
||||
conn.execute("UPDATE groups SET deleted_at=NULL WHERE id=?", (group_id,))
|
||||
|
||||
|
||||
def purge_group(conn: sqlite3.Connection, group_id: int) -> None:
|
||||
# Mitgliedschaften und Rechte haengen per ON DELETE CASCADE dran.
|
||||
conn.execute("DELETE FROM groups WHERE id=?", (group_id,))
|
||||
|
||||
|
||||
def ensure_presets(
|
||||
conn: sqlite3.Connection, tree: PermissionTree, presets: Sequence[RolePreset]
|
||||
) -> list[str]:
|
||||
"""Legt fehlende Vorlagen-Gruppen an.
|
||||
|
||||
Wichtig -- Unterschied zum Vorgaenger: eine bereits existierende Gruppe wird
|
||||
**nicht** bei jedem Start auf den Vorlagen-Rechtesatz zurueckgesetzt. Der
|
||||
Vorgaenger tat das fuer die Standardgruppe "Benutzer", wodurch jede
|
||||
Anpassung durch den Kunden beim naechsten Neustart verschwand. Wer die
|
||||
Vorlage neu anwenden will, tut das ausdruecklich in der Oberflaeche.
|
||||
"""
|
||||
created: list[str] = []
|
||||
for preset in presets:
|
||||
row = Database.one(
|
||||
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
|
||||
)
|
||||
if row is not None:
|
||||
continue
|
||||
permissions = tree.expand_preset(preset)
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
|
||||
"created_at, updated_at) VALUES (?,?,?,?,?,?,?)",
|
||||
(
|
||||
preset.name,
|
||||
preset.description,
|
||||
1 if preset.is_default else 0,
|
||||
1 if preset.is_default else 0,
|
||||
preset.key,
|
||||
_now(),
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
_write_permissions(conn, int(cursor.lastrowid or 0), permissions)
|
||||
created.append(preset.name)
|
||||
return created
|
||||
|
||||
|
||||
def default_group_id(conn: sqlite3.Connection) -> int | None:
|
||||
value = Database.value(
|
||||
conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1"
|
||||
)
|
||||
return int(value) if value is not None else None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Benutzer
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def granted_permissions(conn: sqlite3.Connection, user_id: int) -> list[str]:
|
||||
"""Vereinigung der Rechte aller (nicht geloeschten) Gruppen des Benutzers."""
|
||||
return [
|
||||
str(row["permission"])
|
||||
for row in Database.all(
|
||||
conn,
|
||||
"""
|
||||
SELECT DISTINCT gp.permission
|
||||
FROM user_groups ug
|
||||
JOIN groups g ON g.id = ug.group_id AND g.deleted_at IS NULL
|
||||
JOIN group_permissions gp ON gp.group_id = g.id
|
||||
WHERE ug.user_id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def user_groups(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
|
||||
return Database.all(
|
||||
conn,
|
||||
"SELECT g.id, g.name, g.is_system, g.is_default FROM user_groups ug "
|
||||
"JOIN groups g ON g.id = ug.group_id AND g.deleted_at IS NULL "
|
||||
"WHERE ug.user_id=? ORDER BY g.name COLLATE NOCASE",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
|
||||
def list_users(
|
||||
conn: sqlite3.Connection, *, include_deleted: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
where = "" if include_deleted else "WHERE u.deleted_at IS NULL"
|
||||
users = Database.all(
|
||||
conn,
|
||||
f"""
|
||||
SELECT u.id, u.username, u.is_admin, u.is_locked, u.auth_source, u.first_name,
|
||||
u.last_name, u.email, u.avatar_filename, u.last_login_at, u.created_at,
|
||||
u.deleted_at, u.must_change_password,
|
||||
CASE WHEN u.totp_secret <> '' THEN 1 ELSE 0 END AS totp_enabled
|
||||
FROM users u {where}
|
||||
ORDER BY u.username COLLATE NOCASE
|
||||
""",
|
||||
)
|
||||
memberships: dict[int, list[str]] = {}
|
||||
for row in Database.all(
|
||||
conn,
|
||||
"SELECT ug.user_id, g.name FROM user_groups ug "
|
||||
"JOIN groups g ON g.id = ug.group_id AND g.deleted_at IS NULL",
|
||||
):
|
||||
memberships.setdefault(int(row["user_id"]), []).append(str(row["name"]))
|
||||
for user in users:
|
||||
user["group_names"] = sorted(memberships.get(int(user["id"]), []))
|
||||
return users
|
||||
|
||||
|
||||
def get_user(conn: sqlite3.Connection, user_id: int) -> dict[str, Any] | None:
|
||||
return Database.one(conn, "SELECT * FROM users WHERE id=?", (user_id,))
|
||||
|
||||
|
||||
def find_user_by_username(
|
||||
conn: sqlite3.Connection, username: str, *, include_deleted: bool = False
|
||||
) -> dict[str, Any] | None:
|
||||
suffix = "" if include_deleted else " AND deleted_at IS NULL"
|
||||
return Database.one(
|
||||
conn,
|
||||
f"SELECT * FROM users WHERE username=? COLLATE NOCASE{suffix}",
|
||||
(username,),
|
||||
)
|
||||
|
||||
|
||||
def count_active_admins(conn: sqlite3.Connection, *, exclude_id: int | None = None) -> int:
|
||||
sql = "SELECT COUNT(*) FROM users WHERE is_admin=1 AND is_locked=0 AND deleted_at IS NULL"
|
||||
params: tuple[Any, ...] = ()
|
||||
if exclude_id is not None:
|
||||
sql += " AND id<>?"
|
||||
params = (exclude_id,)
|
||||
return int(Database.value(conn, sql, params, default=0) or 0)
|
||||
|
||||
|
||||
def guard_target_account(actor: Any, target: dict[str, Any]) -> None:
|
||||
"""Ein Administratorkonto darf nur ein echter Administrator anfassen."""
|
||||
if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False):
|
||||
raise PermissionDenied(
|
||||
"Administratorkonten koennen nur von Administratoren bearbeitet werden."
|
||||
)
|
||||
|
||||
|
||||
def guard_last_admin(conn: sqlite3.Connection, user_id: int, *, action: str) -> None:
|
||||
row = get_user(conn, user_id)
|
||||
if row is None or not row.get("is_admin"):
|
||||
return
|
||||
if count_active_admins(conn, exclude_id=user_id) == 0:
|
||||
raise PermissionDenied(
|
||||
f"{action} nicht moeglich: es muss mindestens ein entsperrtes "
|
||||
"Administratorkonto uebrig bleiben."
|
||||
)
|
||||
|
||||
|
||||
def _assignable_groups(
|
||||
conn: sqlite3.Connection, actor: Any, tree: PermissionTree, group_ids: Sequence[int]
|
||||
) -> list[int]:
|
||||
"""Prueft, ob der Bearbeiter diese Gruppen zuweisen darf (kein Rechtezuwachs)."""
|
||||
if getattr(actor, "is_admin", False):
|
||||
return [int(gid) for gid in group_ids]
|
||||
result: list[int] = []
|
||||
for gid in group_ids:
|
||||
permissions = group_permissions(conn, int(gid))
|
||||
guard_no_amplification(actor, tree, permissions, what="Gruppenrecht")
|
||||
result.append(int(gid))
|
||||
return result
|
||||
|
||||
|
||||
def set_user_groups(
|
||||
conn: sqlite3.Connection,
|
||||
user_id: int,
|
||||
group_ids: Sequence[int],
|
||||
*,
|
||||
actor: Any,
|
||||
tree: PermissionTree,
|
||||
) -> None:
|
||||
allowed = _assignable_groups(conn, actor, tree, group_ids)
|
||||
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||
[(user_id, gid) for gid in allowed],
|
||||
)
|
||||
|
||||
|
||||
def create_user(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
is_admin: bool,
|
||||
first_name: str = "",
|
||||
last_name: str = "",
|
||||
email: str = "",
|
||||
group_ids: Sequence[int] = (),
|
||||
actor: Any,
|
||||
tree: PermissionTree,
|
||||
policy: passwords.PasswordPolicy,
|
||||
auth_source: str = "local",
|
||||
must_change_password: bool = False,
|
||||
) -> int:
|
||||
clean = (username or "").strip()
|
||||
if not clean:
|
||||
raise ValidationError("Der Benutzername darf nicht leer sein.")
|
||||
if len(clean) > 64 or any(ch.isspace() for ch in clean):
|
||||
raise ValidationError("Der Benutzername darf keine Leerzeichen enthalten (max. 64 Zeichen).")
|
||||
|
||||
existing = find_user_by_username(conn, clean, include_deleted=True)
|
||||
if existing is not None:
|
||||
if existing["deleted_at"]:
|
||||
raise ValidationError(
|
||||
f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto "
|
||||
"wieder her oder loeschen Sie es endgueltig."
|
||||
)
|
||||
raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.")
|
||||
|
||||
if is_admin and not getattr(actor, "is_admin", False):
|
||||
raise PermissionDenied("Nur Administratoren koennen weitere Administratoren anlegen.")
|
||||
|
||||
if auth_source == "local":
|
||||
problems = policy.check(password, username=clean)
|
||||
if problems:
|
||||
raise ValidationError(" ".join(problems))
|
||||
password_hash = passwords.hash_password(password)
|
||||
else:
|
||||
password_hash = "" # Verzeichniskonto: Passwort wird nie hier geprueft.
|
||||
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, auth_source, first_name, "
|
||||
"last_name, email, must_change_password, password_changed_at, created_at, updated_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
clean,
|
||||
password_hash,
|
||||
int(is_admin),
|
||||
auth_source,
|
||||
first_name.strip(),
|
||||
last_name.strip(),
|
||||
email.strip(),
|
||||
int(must_change_password),
|
||||
_now() if auth_source == "local" else None,
|
||||
_now(),
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
user_id = int(cursor.lastrowid or 0)
|
||||
|
||||
targets = list(group_ids)
|
||||
if not is_admin and not targets:
|
||||
fallback = default_group_id(conn)
|
||||
if fallback is not None:
|
||||
targets = [fallback]
|
||||
set_user_groups(conn, user_id, targets, actor=actor, tree=tree)
|
||||
return user_id
|
||||
|
||||
|
||||
def update_user(
|
||||
conn: sqlite3.Connection,
|
||||
user_id: int,
|
||||
*,
|
||||
actor: Any,
|
||||
tree: PermissionTree,
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
email: str | None = None,
|
||||
is_admin: bool | None = None,
|
||||
is_locked: bool | None = None,
|
||||
group_ids: Sequence[int] | None = None,
|
||||
) -> list[str]:
|
||||
target = get_user(conn, user_id)
|
||||
if target is None or target["deleted_at"]:
|
||||
raise ValidationError("Dieses Konto existiert nicht (mehr).")
|
||||
guard_target_account(actor, target)
|
||||
|
||||
changes: list[str] = []
|
||||
directory = str(target.get("auth_source")) == "ldap"
|
||||
|
||||
if directory and any(value is not None for value in (first_name, last_name, email)):
|
||||
# Beim naechsten Login wuerden diese Felder ohnehin aus dem Verzeichnis
|
||||
# ueberschrieben -- die Bearbeitung waere eine Illusion.
|
||||
raise ValidationError(
|
||||
"Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung "
|
||||
"aus dem Verzeichnis uebernommen und koennen hier nicht geaendert werden."
|
||||
)
|
||||
|
||||
fields: dict[str, Any] = {}
|
||||
for column, value in (
|
||||
("first_name", first_name),
|
||||
("last_name", last_name),
|
||||
("email", email),
|
||||
):
|
||||
if value is not None and value.strip() != str(target.get(column) or ""):
|
||||
fields[column] = value.strip()
|
||||
changes.append(f"{column} geaendert")
|
||||
|
||||
if is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
|
||||
if not getattr(actor, "is_admin", False):
|
||||
raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator aendern.")
|
||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
||||
raise PermissionDenied("Sie koennen Ihr eigenes Admin-Kennzeichen nicht aendern.")
|
||||
if not is_admin:
|
||||
guard_last_admin(conn, user_id, action="Herabstufen")
|
||||
fields["is_admin"] = int(bool(is_admin))
|
||||
changes.append("Administrator: " + ("ja" if is_admin else "nein"))
|
||||
|
||||
if is_locked is not None and bool(is_locked) != bool(target["is_locked"]):
|
||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
||||
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht sperren.")
|
||||
if is_locked:
|
||||
guard_last_admin(conn, user_id, action="Sperren")
|
||||
fields["is_locked"] = int(bool(is_locked))
|
||||
changes.append("Gesperrt: " + ("ja" if is_locked else "nein"))
|
||||
|
||||
if fields:
|
||||
assignments = ", ".join(f"{column}=?" for column in fields)
|
||||
conn.execute(
|
||||
f"UPDATE users SET {assignments}, updated_at=? WHERE id=?",
|
||||
(*fields.values(), _now(), user_id),
|
||||
)
|
||||
|
||||
if group_ids is not None:
|
||||
before = {int(row["id"]) for row in user_groups(conn, user_id)}
|
||||
after = {int(gid) for gid in group_ids}
|
||||
if before != after:
|
||||
set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree)
|
||||
changes.append("Gruppenzuordnung geaendert")
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
def set_password(
|
||||
conn: sqlite3.Connection,
|
||||
user_id: int,
|
||||
new_password: str,
|
||||
*,
|
||||
policy: passwords.PasswordPolicy,
|
||||
must_change: bool = False,
|
||||
) -> None:
|
||||
row = get_user(conn, user_id)
|
||||
if row is None:
|
||||
raise ValidationError("Dieses Konto existiert nicht.")
|
||||
if str(row.get("auth_source")) == "ldap":
|
||||
raise ValidationError(
|
||||
"Verzeichniskonten haben hier kein Passwort -- es wird im Verzeichnisdienst gepflegt."
|
||||
)
|
||||
problems = policy.check(new_password, username=str(row["username"]))
|
||||
if problems:
|
||||
raise ValidationError(" ".join(problems))
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash=?, must_change_password=?, password_changed_at=?, "
|
||||
"updated_at=? WHERE id=?",
|
||||
(passwords.hash_password(new_password), int(must_change), _now(), _now(), user_id),
|
||||
)
|
||||
|
||||
|
||||
def soft_delete_user(conn: sqlite3.Connection, user_id: int, *, actor: Any) -> str:
|
||||
target = get_user(conn, user_id)
|
||||
if target is None or target["deleted_at"]:
|
||||
raise ValidationError("Dieses Konto existiert nicht (mehr).")
|
||||
guard_target_account(actor, target)
|
||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
||||
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht loeschen.")
|
||||
guard_last_admin(conn, user_id, action="Loeschen")
|
||||
|
||||
if str(target.get("auth_source")) == "ldap":
|
||||
# Ein Verzeichniskonto ist nur ein Schattenkonto: es legt sich bei der
|
||||
# naechsten gueltigen Anmeldung von selbst wieder an. Ein Papierkorb
|
||||
# waere hier bedeutungslos.
|
||||
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
||||
return "endgueltig"
|
||||
|
||||
conn.execute("UPDATE users SET deleted_at=?, updated_at=? WHERE id=?", (_now(), _now(), user_id))
|
||||
conn.execute(
|
||||
"UPDATE user_sessions SET revoked_at=?, revoked_reason='user-deleted' "
|
||||
"WHERE user_id=? AND revoked_at IS NULL",
|
||||
(_now(), user_id),
|
||||
)
|
||||
return "papierkorb"
|
||||
|
||||
|
||||
def restore_user(conn: sqlite3.Connection, user_id: int) -> None:
|
||||
row = get_user(conn, user_id)
|
||||
if row is None:
|
||||
raise ValidationError("Dieses Konto existiert nicht.")
|
||||
clash = Database.one(
|
||||
conn,
|
||||
"SELECT id FROM users WHERE username=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL",
|
||||
(row["username"], user_id),
|
||||
)
|
||||
if clash is not None:
|
||||
raise ValidationError(
|
||||
f"Es existiert bereits ein aktives Konto namens {row['username']!r}."
|
||||
)
|
||||
conn.execute("UPDATE users SET deleted_at=NULL, updated_at=? WHERE id=?", (_now(), user_id))
|
||||
|
||||
|
||||
def purge_user(conn: sqlite3.Connection, user_id: int) -> None:
|
||||
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
|
||||
|
||||
|
||||
def matrix_state(
|
||||
tree: PermissionTree, permissions: Iterable[str]
|
||||
) -> dict[str, dict[str, bool]]:
|
||||
"""Hilfsstruktur fuer das Rendering der Rechtematrix."""
|
||||
granted = set(permissions)
|
||||
state: dict[str, dict[str, bool]] = {}
|
||||
for area in tree.areas:
|
||||
state[area.key] = {"view": area.view_key in granted}
|
||||
for resource in area.resources:
|
||||
state[resource.key] = {
|
||||
action.value: resource.permission(action) in granted for action in resource.actions
|
||||
}
|
||||
return state
|
||||
|
||||
|
||||
def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> list[str]:
|
||||
"""Kurzfassung fuer Listenansichten: ``Bereich: Ressource (RCUD)``."""
|
||||
granted = set(permissions)
|
||||
lines: list[str] = []
|
||||
for area in tree.areas:
|
||||
if area.view_key not in granted:
|
||||
continue
|
||||
parts: list[str] = []
|
||||
for resource in area.resources:
|
||||
letters = "".join(
|
||||
ACTION_LETTERS[action]
|
||||
for action in resource.actions
|
||||
if resource.permission(action) in granted
|
||||
)
|
||||
if letters:
|
||||
parts.append(f"{resource.label} ({letters})")
|
||||
if parts:
|
||||
lines.append(f"{area.label}: " + ", ".join(parts))
|
||||
return lines
|
||||
|
||||
|
||||
ACTION_LETTERS: dict[Action, str] = {
|
||||
Action.VIEW: "R",
|
||||
Action.CREATE: "C",
|
||||
Action.EDIT: "U",
|
||||
Action.DELETE: "D",
|
||||
Action.EXECUTE: "X",
|
||||
Action.EXPORT: "E",
|
||||
Action.SECRETS: "S",
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Sicherheitsbausteine: Passwoerter, Sitzungen, CSRF, Ratenbegrenzung, TOTP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import csrf, passwords, ratelimit, sessions, totp
|
||||
|
||||
__all__ = ["csrf", "passwords", "ratelimit", "sessions", "totp"]
|
||||
@@ -0,0 +1,180 @@
|
||||
"""CSRF-Schutz und Header-Haertung.
|
||||
|
||||
Der Vorgaenger hatte **keinen** CSRF-Schutz: jede Aenderung lief ueber ein
|
||||
gewoehnliches POST-Formular ohne Token. Eine praeparierte Seite konnte damit im
|
||||
Namen eines eingeloggten Admins Geraete loeschen oder Benutzer anlegen.
|
||||
|
||||
Hier drei Schichten:
|
||||
|
||||
1. ``SameSite=Lax`` am Sitzungscookie (blockt Cross-Site-POSTs bereits im Browser).
|
||||
2. Ein Token pro Sitzung, das in jedem Formular mitgeschickt und in konstanter
|
||||
Zeit verglichen wird.
|
||||
3. Origin-/Referer-Pruefung als Rueckfallebene fuer Browser ohne SameSite.
|
||||
|
||||
Alle drei sind unabhaengig voneinander wirksam.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import secrets
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Flask, Response, abort, current_app, request, session
|
||||
|
||||
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})
|
||||
FIELD_NAME = "csrf_token"
|
||||
HEADER_NAME = "X-CSRF-Token"
|
||||
_SESSION_KEY = "_csrf"
|
||||
|
||||
|
||||
class CsrfError(Exception):
|
||||
"""Wird als HTTP 403 mit erklaerender Seite ausgeliefert."""
|
||||
|
||||
|
||||
def token() -> str:
|
||||
"""Aktuelles Token der Sitzung, bei Bedarf neu erzeugt."""
|
||||
value = session.get(_SESSION_KEY)
|
||||
if not value:
|
||||
value = secrets.token_urlsafe(32)
|
||||
session[_SESSION_KEY] = value
|
||||
return str(value)
|
||||
|
||||
|
||||
def rotate_token() -> str:
|
||||
"""Neues Token -- nach Anmeldung/Abmeldung und Rechtewechsel."""
|
||||
session.pop(_SESSION_KEY, None)
|
||||
return token()
|
||||
|
||||
|
||||
def _submitted_token() -> str:
|
||||
for source in (request.form, request.args):
|
||||
value = source.get(FIELD_NAME)
|
||||
if value:
|
||||
return value
|
||||
header = request.headers.get(HEADER_NAME)
|
||||
if header:
|
||||
return header
|
||||
if request.is_json:
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
except Exception: # pragma: no cover - defensiv
|
||||
payload = {}
|
||||
if isinstance(payload, dict) and payload.get(FIELD_NAME):
|
||||
return str(payload[FIELD_NAME])
|
||||
return ""
|
||||
|
||||
|
||||
def _origin_ok() -> bool:
|
||||
"""Prueft Origin bzw. Referer gegen den Host der Anfrage."""
|
||||
origin = request.headers.get("Origin") or ""
|
||||
referer = request.headers.get("Referer") or ""
|
||||
candidate = origin or referer
|
||||
if not candidate:
|
||||
# Kein Origin/Referer: manche Clients (curl, alte Proxies) senden keinen.
|
||||
# Das Token allein muss dann reichen.
|
||||
return True
|
||||
parsed = urlparse(candidate)
|
||||
if not parsed.netloc:
|
||||
return False
|
||||
|
||||
# Hinter einem Reverse Proxy kann der Host je nach Konfiguration mit oder
|
||||
# ohne Port ankommen. Beide Formen gelten als derselbe Ursprung; ein
|
||||
# *anderer* Host tut das nie.
|
||||
expected: set[str] = {request.host}
|
||||
forwarded_host = (request.headers.get("X-Forwarded-Host") or "").split(",")[0].strip()
|
||||
if forwarded_host:
|
||||
expected.add(forwarded_host)
|
||||
forwarded_port = (request.headers.get("X-Forwarded-Port") or "").strip()
|
||||
if forwarded_port:
|
||||
expected.update(f"{host.split(':')[0]}:{forwarded_port}" for host in set(expected))
|
||||
# Zusaetzlich die portlose Schreibweise -- aber bewusst **keine** beliebigen
|
||||
# Ports: ein anderer Dienst auf demselben Host waere ein anderer Ursprung.
|
||||
expected.update(host.split(":")[0] for host in set(expected))
|
||||
# Und die Standardports, falls der Proxy sie weglaesst.
|
||||
scheme_port = "443" if request.headers.get("X-Forwarded-Proto") == "https" else "80"
|
||||
expected.update(f"{host.split(':')[0]}:{scheme_port}" for host in set(expected))
|
||||
|
||||
return parsed.netloc in expected
|
||||
|
||||
|
||||
def protect() -> None:
|
||||
"""``before_request``-Hook."""
|
||||
if request.method in SAFE_METHODS:
|
||||
return
|
||||
view = current_app.view_functions.get(request.endpoint or "")
|
||||
if getattr(view, "_csrf_exempt", False):
|
||||
return
|
||||
if not _origin_ok():
|
||||
abort(403, description="Die Anfrage kam von einer fremden Herkunft (Origin/Referer).")
|
||||
expected = session.get(_SESSION_KEY) or ""
|
||||
provided = _submitted_token()
|
||||
if not expected or not provided:
|
||||
abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.")
|
||||
if not hmac.compare_digest(str(expected), str(provided)):
|
||||
abort(403, description="Das Sicherheitstoken des Formulars ist ungueltig. Bitte Seite neu laden.")
|
||||
|
||||
|
||||
def exempt(view):
|
||||
"""Dekorator fuer Endpunkte mit eigener Authentisierung (Maschinen-APIs).
|
||||
|
||||
Bewusst sparsam einsetzen: nur fuer Routen, die *nicht* ueber Cookies
|
||||
autorisiert werden. Eine cookie-autorisierte Route ohne CSRF-Schutz ist eine
|
||||
Luecke.
|
||||
"""
|
||||
view._csrf_exempt = True # type: ignore[attr-defined]
|
||||
return view
|
||||
|
||||
|
||||
def init_app(app: Flask) -> None:
|
||||
app.before_request(protect)
|
||||
app.jinja_env.globals["csrf_token"] = token
|
||||
app.jinja_env.globals["csrf_field_name"] = FIELD_NAME
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Sicherheits-Header
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def content_security_policy(nonce: str) -> str:
|
||||
"""Strikte CSP.
|
||||
|
||||
Kein ``unsafe-inline`` fuer Skripte -- jedes Inline-Script traegt den Nonce
|
||||
dieses Requests. Styles brauchen ``unsafe-inline``, weil Jinja an einzelnen
|
||||
Stellen berechnete Breiten (Fortschrittsbalken) als ``style``-Attribut
|
||||
setzt; Style-Attribute sind kein Skriptausfuehrungsvektor.
|
||||
"""
|
||||
return "; ".join(
|
||||
[
|
||||
"default-src 'self'",
|
||||
f"script-src 'self' 'nonce-{nonce}'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'none'",
|
||||
"object-src 'none'",
|
||||
"worker-src 'self'",
|
||||
"manifest-src 'self'",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def apply_security_headers(response: Response, *, nonce: str, hsts: str = "") -> Response:
|
||||
response.headers.setdefault("Content-Security-Policy", content_security_policy(nonce))
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "same-origin")
|
||||
response.headers.setdefault(
|
||||
"Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()",
|
||||
)
|
||||
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
|
||||
response.headers.setdefault("Cross-Origin-Resource-Policy", "same-origin")
|
||||
response.headers.setdefault("X-Permitted-Cross-Domain-Policies", "none")
|
||||
if hsts:
|
||||
response.headers.setdefault("Strict-Transport-Security", hsts)
|
||||
return response
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Passwort-Hashing: Argon2id, mit Uebergangspfad von bcrypt.
|
||||
|
||||
Der Vorgaenger nutzte bcrypt (via Flask-Bcrypt). bcrypt ist nicht kaputt, aber
|
||||
Argon2id ist der aktuelle Stand (speicherhart, damit deutlich teurer auf GPUs)
|
||||
und die Empfehlung des OWASP-Passwort-Cheatsheets.
|
||||
|
||||
Bestehende bcrypt-Hashes werden weiter akzeptiert und beim naechsten
|
||||
erfolgreichen Login transparent auf Argon2id migriert -- ein Import einer alten
|
||||
Datenbank sperrt also niemanden aus.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
|
||||
|
||||
# Parameter nach OWASP-Empfehlung (m=64 MiB, t=3, p=2). Bei ~50 ms pro Hash auf
|
||||
# der Ziel-VM bleibt der Login fluessig und Offline-Angriffe teuer.
|
||||
_HASHER = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=2, hash_len=32, salt_len=16)
|
||||
|
||||
_BCRYPT_RE = re.compile(r"^\$2[aby]\$\d{2}\$")
|
||||
|
||||
#: Passwoerter, die selbst mit 12 Zeichen nichts wert sind. Kurze, kuratierte
|
||||
#: Liste -- eine vollstaendige Breach-Datenbank gehoert nicht in dieses Repo.
|
||||
_FORBIDDEN = {
|
||||
"passwort1234",
|
||||
"password1234",
|
||||
"administrator",
|
||||
"qwertzuiop12",
|
||||
"123456789012",
|
||||
"tesmtesmtesm",
|
||||
"willkommen12",
|
||||
"geheim123456",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PasswordPolicy:
|
||||
min_length: int = 12
|
||||
require_classes: int = 2
|
||||
"""Anzahl verschiedener Zeichenklassen (klein/gross/Ziffer/Sonderzeichen)."""
|
||||
|
||||
def check(self, password: str, *, username: str = "") -> list[str]:
|
||||
"""Gibt eine Liste von Klartext-Problemen zurueck (leer = in Ordnung)."""
|
||||
problems: list[str] = []
|
||||
normalized = normalize(password)
|
||||
if len(normalized) < self.min_length:
|
||||
problems.append(f"Mindestens {self.min_length} Zeichen erforderlich.")
|
||||
classes = sum(
|
||||
bool(pattern.search(normalized))
|
||||
for pattern in (
|
||||
# Kleinbuchstaben inkl. Latin-1-Supplement (Umlaute, Akzente).
|
||||
re.compile("[a-zß-ÿ]"),
|
||||
re.compile("[A-ZÀ-Þ]"),
|
||||
re.compile(r"[0-9]"),
|
||||
re.compile("[^A-Za-z0-9À-ÿ]"),
|
||||
)
|
||||
)
|
||||
if classes < self.require_classes:
|
||||
problems.append(
|
||||
f"Mindestens {self.require_classes} verschiedene Zeichenarten "
|
||||
"(Klein-/Grossbuchstaben, Ziffern, Sonderzeichen)."
|
||||
)
|
||||
if normalized.lower() in _FORBIDDEN:
|
||||
problems.append("Dieses Passwort ist zu haeufig und damit unsicher.")
|
||||
if username and len(username) >= 3 and username.lower() in normalized.lower():
|
||||
problems.append("Das Passwort darf den Benutzernamen nicht enthalten.")
|
||||
if len(set(normalized)) <= 3 and normalized:
|
||||
problems.append("Das Passwort besteht aus zu wenigen verschiedenen Zeichen.")
|
||||
return problems
|
||||
|
||||
|
||||
def normalize(password: str) -> str:
|
||||
"""NFKC-Normalisierung.
|
||||
|
||||
Ohne das schlaegt eine Anmeldung fehl, wenn ein Passwort mit Umlauten auf
|
||||
einem macOS-Client (NFD) gesetzt und auf Windows (NFC) eingegeben wird.
|
||||
"""
|
||||
return unicodedata.normalize("NFKC", password or "")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _HASHER.hash(normalize(password))
|
||||
|
||||
|
||||
def is_legacy_hash(stored: str) -> bool:
|
||||
return bool(_BCRYPT_RE.match(stored or ""))
|
||||
|
||||
|
||||
def verify_password(stored: str, password: str) -> bool:
|
||||
"""Prueft ein Passwort gegen einen Argon2id- oder bcrypt-Hash.
|
||||
|
||||
Laeuft bei leerem/kaputtem Hash bewusst in einen echten Argon2-Verify gegen
|
||||
einen Dummy-Hash, damit die Antwortzeit nicht verraet, ob ein Konto
|
||||
existiert (Timing-Orakel).
|
||||
"""
|
||||
stored = stored or ""
|
||||
if is_legacy_hash(stored):
|
||||
return _verify_bcrypt(stored, password)
|
||||
try:
|
||||
return _HASHER.verify(stored, normalize(password))
|
||||
except (VerifyMismatchError, VerificationError, InvalidHashError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _verify_bcrypt(stored: str, password: str) -> bool:
|
||||
try:
|
||||
import bcrypt # lokaler Import: nur fuer Altbestaende noetig
|
||||
except ImportError: # pragma: no cover
|
||||
return False
|
||||
try:
|
||||
return bcrypt.checkpw(normalize(password).encode("utf-8"), stored.encode("utf-8"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def needs_rehash(stored: str) -> bool:
|
||||
"""True, wenn der Hash veraltet ist (bcrypt oder zu schwache Argon2-Parameter)."""
|
||||
if is_legacy_hash(stored):
|
||||
return True
|
||||
try:
|
||||
return _HASHER.check_needs_rehash(stored)
|
||||
except (InvalidHashError, ValueError):
|
||||
return True
|
||||
|
||||
|
||||
def dummy_verify() -> None:
|
||||
"""Verbrennt dieselbe Rechenzeit wie ein echter Verify.
|
||||
|
||||
Wird aufgerufen, wenn ein Benutzername nicht existiert -- so ist die
|
||||
Antwortzeit von "Konto existiert nicht" nicht von "falsches Passwort" zu
|
||||
unterscheiden.
|
||||
"""
|
||||
global _DUMMY_HASH
|
||||
if not _DUMMY_HASH:
|
||||
_DUMMY_HASH = _HASHER.hash(secrets.token_urlsafe(24))
|
||||
try:
|
||||
_HASHER.verify(_DUMMY_HASH, "nicht-das-richtige-passwort")
|
||||
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
||||
pass
|
||||
|
||||
|
||||
_DUMMY_HASH = ""
|
||||
|
||||
|
||||
def constant_time_equals(left: str, right: str) -> bool:
|
||||
return hmac.compare_digest((left or "").encode("utf-8"), (right or "").encode("utf-8"))
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Ratenbegrenzung und Kontosperre, in SQLite persistiert.
|
||||
|
||||
Der Vorgaenger hatte keinerlei Bremse: ein Skript konnte beliebig schnell
|
||||
Passwoerter gegen ``/login`` durchprobieren, und ein Neustart des Dienstes
|
||||
haette einen In-Memory-Zaehler ohnehin geleert.
|
||||
|
||||
Hier: ein Sliding-Window-Log in der Tabelle ``rate_events``. Persistent, damit
|
||||
ein Dienst-Neustart die Bremse nicht aufhebt, und mit derselben Verbindung
|
||||
nutzbar, die der Request schon offen hat (keine zweite Verbindung -> kein
|
||||
Deadlock).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from ..db import Database
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RateVerdict:
|
||||
allowed: bool
|
||||
remaining: int
|
||||
retry_after_seconds: int
|
||||
|
||||
@property
|
||||
def blocked(self) -> bool:
|
||||
return not self.allowed
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
return value.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def record(conn: sqlite3.Connection, bucket: str, key: str) -> None:
|
||||
"""Vermerkt ein Ereignis (z. B. einen Fehlversuch)."""
|
||||
conn.execute(
|
||||
"INSERT INTO rate_events (bucket, subject, ts) VALUES (?,?,?)",
|
||||
(bucket, key, _iso(_now())),
|
||||
)
|
||||
|
||||
|
||||
def check(
|
||||
conn: sqlite3.Connection,
|
||||
bucket: str,
|
||||
key: str,
|
||||
*,
|
||||
limit: int,
|
||||
window_minutes: int,
|
||||
) -> RateVerdict:
|
||||
"""Prueft, ob noch Versuche frei sind. Zaehlt selbst nichts hoch."""
|
||||
horizon = _iso(_now() - timedelta(minutes=window_minutes))
|
||||
rows = Database.all(
|
||||
conn,
|
||||
"SELECT ts FROM rate_events WHERE bucket=? AND subject=? AND ts>=? ORDER BY ts",
|
||||
(bucket, key, horizon),
|
||||
)
|
||||
used = len(rows)
|
||||
if used < limit:
|
||||
return RateVerdict(allowed=True, remaining=limit - used, retry_after_seconds=0)
|
||||
|
||||
oldest = datetime.fromisoformat(rows[0]["ts"])
|
||||
if oldest.tzinfo is None:
|
||||
oldest = oldest.replace(tzinfo=timezone.utc)
|
||||
retry_after = int((oldest + timedelta(minutes=window_minutes) - _now()).total_seconds())
|
||||
return RateVerdict(allowed=False, remaining=0, retry_after_seconds=max(1, retry_after))
|
||||
|
||||
|
||||
def clear(conn: sqlite3.Connection, bucket: str, key: str) -> None:
|
||||
"""Loescht die Ereignisse eines Subjekts -- nach erfolgreicher Anmeldung."""
|
||||
conn.execute("DELETE FROM rate_events WHERE bucket=? AND subject=?", (bucket, key))
|
||||
|
||||
|
||||
def prune(conn: sqlite3.Connection, *, keep_hours: int = 48) -> int:
|
||||
"""Raeumt alte Ereignisse ab. Laeuft im Wartungslauf mit."""
|
||||
horizon = _iso(_now() - timedelta(hours=keep_hours))
|
||||
cursor = conn.execute("DELETE FROM rate_events WHERE ts < ?", (horizon,))
|
||||
return cursor.rowcount or 0
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Serverseitige Sitzungen in SQLite.
|
||||
|
||||
Der Vorgaenger nutzte Flasks signierte Cookie-Sitzungen. Das ist bequem, hat
|
||||
aber drei Nachteile, die fuer eine Verwaltungsanwendung mit Adminrechten
|
||||
relevant sind:
|
||||
|
||||
1. **Nicht widerrufbar.** Ein gestohlenes Cookie bleibt bis zum Ablauf gueltig;
|
||||
"alle Sitzungen beenden" ist unmoeglich.
|
||||
2. **Kein Leerlauf-Timeout.** Cookie-Sitzungen kennen nur ein absolutes Ablauf-
|
||||
datum, keine Inaktivitaet.
|
||||
3. **Sitzungsinhalt liegt beim Client.** Rotieren des Flask-Secret-Keys wirft
|
||||
alle Nutzer heraus; ausserdem ist jede Sitzungsvariable im Browser sichtbar
|
||||
(signiert, aber nicht verschluesselt).
|
||||
|
||||
Hier: eine Zeile pro Sitzung in ``user_sessions``, der Cookie traegt nur eine
|
||||
256-Bit-Zufalls-ID. Damit sind Widerruf, Leerlauf-Timeout, ein absolutes
|
||||
Maximum, eine Uebersicht "meine aktiven Sitzungen" und Sitzungsrotation beim
|
||||
Rechtewechsel moeglich.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, Request, Response
|
||||
from flask.sessions import SessionInterface, SessionMixin
|
||||
from werkzeug.datastructures import CallbackDict
|
||||
|
||||
from ..db import Database
|
||||
|
||||
SESSION_ID_BYTES = 32
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
return value.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _parse(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class ServerSession(CallbackDict, SessionMixin):
|
||||
"""Sitzungsobjekt, das seine Aenderungen selbst als "dirty" markiert."""
|
||||
|
||||
def __init__(self, initial: dict[str, Any] | None = None, sid: str = "", *, new: bool = False):
|
||||
def on_update(_self: Any) -> None:
|
||||
self.modified = True
|
||||
|
||||
super().__init__(initial or {}, on_update)
|
||||
self.sid = sid
|
||||
self.new = new
|
||||
self.modified = False
|
||||
self.destroyed = False
|
||||
#: Von der Datenbank geladene Metadaten (nur lesend fuer den Aufrufer).
|
||||
self.meta: dict[str, Any] = {}
|
||||
|
||||
def destroy(self) -> None:
|
||||
"""Sitzung serverseitig verwerfen (Logout)."""
|
||||
self.clear()
|
||||
self.destroyed = True
|
||||
self.modified = True
|
||||
|
||||
|
||||
class SqliteSessionInterface(SessionInterface):
|
||||
"""Flask-Sitzungsschnittstelle gegen die Tabelle ``user_sessions``."""
|
||||
|
||||
session_class = ServerSession
|
||||
pickle_based = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
database: Database,
|
||||
*,
|
||||
cookie_name: str,
|
||||
idle_minutes: int,
|
||||
absolute_hours: int,
|
||||
) -> None:
|
||||
self.database = database
|
||||
self.cookie_name = cookie_name
|
||||
self.idle_minutes = idle_minutes
|
||||
self.absolute_hours = absolute_hours
|
||||
|
||||
# -- Flask-Schnittstelle ------------------------------------------- #
|
||||
|
||||
def open_session(self, app: Flask, request: Request) -> ServerSession:
|
||||
sid = request.cookies.get(self.cookie_name, "")
|
||||
if not sid or len(sid) < 40:
|
||||
return self.session_class(new=True)
|
||||
|
||||
with self.database.session() as conn:
|
||||
row = Database.one(
|
||||
conn,
|
||||
"SELECT * FROM user_sessions WHERE id=? AND revoked_at IS NULL",
|
||||
(sid,),
|
||||
)
|
||||
if row is None:
|
||||
return self.session_class(new=True)
|
||||
|
||||
now = _now()
|
||||
idle_deadline = _parse(row.get("idle_expires_at"))
|
||||
absolute_deadline = _parse(row.get("absolute_expires_at"))
|
||||
if (idle_deadline and now > idle_deadline) or (
|
||||
absolute_deadline and now > absolute_deadline
|
||||
):
|
||||
with self.database.transaction(conn):
|
||||
conn.execute(
|
||||
"UPDATE user_sessions SET revoked_at=?, revoked_reason='expired' "
|
||||
"WHERE id=?",
|
||||
(_iso(now), sid),
|
||||
)
|
||||
return self.session_class(new=True)
|
||||
|
||||
try:
|
||||
data = json.loads(row.get("data") or "{}")
|
||||
except ValueError:
|
||||
data = {}
|
||||
|
||||
session = self.session_class(data, sid)
|
||||
session.meta = {
|
||||
"user_id": row.get("user_id"),
|
||||
"created_at": row.get("created_at"),
|
||||
"last_seen_at": row.get("last_seen_at"),
|
||||
"reauth_at": row.get("reauth_at"),
|
||||
"absolute_expires_at": row.get("absolute_expires_at"),
|
||||
"ip": row.get("ip"),
|
||||
"user_agent": row.get("user_agent"),
|
||||
}
|
||||
# Leerlauffenster verlaengern, aber hoechstens einmal pro Minute
|
||||
# schreiben -- sonst kostet jeder AJAX-Poll einen Schreibvorgang.
|
||||
last_seen = _parse(row.get("last_seen_at"))
|
||||
if last_seen is None or (now - last_seen) > timedelta(seconds=60):
|
||||
with self.database.transaction(conn):
|
||||
conn.execute(
|
||||
"UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?",
|
||||
(_iso(now), _iso(now + timedelta(minutes=self.idle_minutes)), sid),
|
||||
)
|
||||
return session
|
||||
|
||||
def save_session(self, app: Flask, session: ServerSession, response: Response) -> None: # type: ignore[override]
|
||||
name = self.cookie_name
|
||||
domain = self.get_cookie_domain(app)
|
||||
path = self.get_cookie_path(app)
|
||||
|
||||
if session.destroyed:
|
||||
if session.sid:
|
||||
with self.database.session() as conn, self.database.transaction(conn):
|
||||
conn.execute(
|
||||
"UPDATE user_sessions SET revoked_at=?, revoked_reason='logout' WHERE id=?",
|
||||
(_iso(_now()), session.sid),
|
||||
)
|
||||
response.delete_cookie(name, domain=domain, path=path)
|
||||
return
|
||||
|
||||
if not session:
|
||||
# Leere Sitzung: nichts speichern, aber ein evtl. vorhandenes Cookie
|
||||
# nicht anfassen (sonst wuerde jeder anonyme Request es loeschen).
|
||||
if session.modified and session.sid:
|
||||
response.delete_cookie(name, domain=domain, path=path)
|
||||
return
|
||||
|
||||
if not session.modified and not session.new:
|
||||
return
|
||||
|
||||
now = _now()
|
||||
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
|
||||
with self.database.session() as conn, self.database.transaction(conn):
|
||||
if session.new or not session.sid:
|
||||
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
|
||||
conn.execute(
|
||||
"INSERT INTO user_sessions "
|
||||
"(id, user_id, created_at, last_seen_at, idle_expires_at, "
|
||||
" absolute_expires_at, ip, user_agent, data) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
session.sid,
|
||||
session.get("user_id"),
|
||||
_iso(now),
|
||||
_iso(now),
|
||||
_iso(now + timedelta(minutes=self.idle_minutes)),
|
||||
_iso(now + timedelta(hours=self.absolute_hours)),
|
||||
session.get("_ip", ""),
|
||||
session.get("_ua", ""),
|
||||
payload,
|
||||
),
|
||||
)
|
||||
session.new = False
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, "
|
||||
"idle_expires_at=?, reauth_at=? WHERE id=?",
|
||||
(
|
||||
session.get("user_id"),
|
||||
payload,
|
||||
_iso(now),
|
||||
_iso(now + timedelta(minutes=self.idle_minutes)),
|
||||
session.get("_reauth_at"),
|
||||
session.sid,
|
||||
),
|
||||
)
|
||||
|
||||
response.set_cookie(
|
||||
name,
|
||||
session.sid,
|
||||
httponly=True,
|
||||
secure=self.get_cookie_secure(app),
|
||||
samesite=self.get_cookie_samesite(app),
|
||||
domain=domain,
|
||||
path=path,
|
||||
max_age=None, # Sitzungscookie: verschwindet beim Schliessen des Browsers.
|
||||
)
|
||||
response.vary.add("Cookie")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Verwaltung
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def rotate(conn: sqlite3.Connection, session: ServerSession, *, reason: str = "privilege-change") -> str:
|
||||
"""Erzeugt eine neue Sitzungs-ID und entwertet die alte (Session-Fixation).
|
||||
|
||||
Wird bei jeder Anmeldung und bei jeder Rechteaenderung aufgerufen.
|
||||
"""
|
||||
old_sid = session.sid
|
||||
if old_sid:
|
||||
conn.execute(
|
||||
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
|
||||
(_iso(_now()), reason, old_sid),
|
||||
)
|
||||
session.sid = ""
|
||||
session.new = True
|
||||
session.modified = True
|
||||
return old_sid
|
||||
|
||||
|
||||
def revoke(conn: sqlite3.Connection, sid: str, *, reason: str = "manual") -> None:
|
||||
conn.execute(
|
||||
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=? AND revoked_at IS NULL",
|
||||
(_iso(_now()), reason, sid),
|
||||
)
|
||||
|
||||
|
||||
def revoke_all_for_user(
|
||||
conn: sqlite3.Connection, user_id: int, *, except_sid: str = "", reason: str = "manual"
|
||||
) -> int:
|
||||
cursor = conn.execute(
|
||||
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? "
|
||||
"WHERE user_id=? AND revoked_at IS NULL AND id <> ?",
|
||||
(_iso(_now()), reason, user_id, except_sid),
|
||||
)
|
||||
return cursor.rowcount or 0
|
||||
|
||||
|
||||
def active_for_user(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
|
||||
return Database.all(
|
||||
conn,
|
||||
"SELECT id, created_at, last_seen_at, absolute_expires_at, ip, user_agent "
|
||||
"FROM user_sessions WHERE user_id=? AND revoked_at IS NULL "
|
||||
"AND idle_expires_at > ? ORDER BY last_seen_at DESC",
|
||||
(user_id, _iso(_now())),
|
||||
)
|
||||
|
||||
|
||||
def prune(conn: sqlite3.Connection, *, keep_days: int = 30) -> int:
|
||||
"""Entfernt abgelaufene und widerrufene Sitzungen."""
|
||||
horizon = _iso(_now() - timedelta(days=keep_days))
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM user_sessions WHERE (revoked_at IS NOT NULL AND revoked_at < ?) "
|
||||
"OR idle_expires_at < ?",
|
||||
(horizon, horizon),
|
||||
)
|
||||
return cursor.rowcount or 0
|
||||
@@ -0,0 +1,84 @@
|
||||
"""TOTP (RFC 6238) und Wiederherstellungscodes -- ohne externe Abhaengigkeit.
|
||||
|
||||
Zwei-Faktor-Authentisierung gab es im Vorgaengerprojekt nicht. Sie ist hier
|
||||
optional pro Benutzer aktivierbar und kann fuer Administratoren erzwungen
|
||||
werden (``SecurityConfig.require_totp_for_admins``).
|
||||
|
||||
Implementiert mit ``hmac``/``hashlib`` aus der Standardbibliothek -- 60 Zeilen
|
||||
gegenueber einer weiteren Abhaengigkeit im Deployment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import struct
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
DIGITS = 6
|
||||
PERIOD = 30
|
||||
#: Toleranz in Zeitschritten (+/- 1 Schritt = +/- 30 s Uhrendrift).
|
||||
WINDOW = 1
|
||||
SECRET_BYTES = 20
|
||||
RECOVERY_CODE_COUNT = 8
|
||||
|
||||
|
||||
def generate_secret() -> str:
|
||||
"""Base32-Geheimnis, wie es Authenticator-Apps erwarten (ohne Padding)."""
|
||||
return base64.b32encode(secrets.token_bytes(SECRET_BYTES)).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _decode_secret(secret: str) -> bytes:
|
||||
cleaned = (secret or "").strip().replace(" ", "").upper()
|
||||
cleaned += "=" * (-len(cleaned) % 8)
|
||||
return base64.b32decode(cleaned, casefold=True)
|
||||
|
||||
|
||||
def code_at(secret: str, counter: int) -> str:
|
||||
digest = hmac.new(_decode_secret(secret), struct.pack(">Q", counter), hashlib.sha1).digest()
|
||||
offset = digest[-1] & 0x0F
|
||||
truncated = struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF
|
||||
return str(truncated % (10**DIGITS)).zfill(DIGITS)
|
||||
|
||||
|
||||
def current_code(secret: str, *, now: float | None = None) -> str:
|
||||
return code_at(secret, int((now if now is not None else time.time()) // PERIOD))
|
||||
|
||||
|
||||
def verify_code(secret: str, code: str, *, now: float | None = None) -> bool:
|
||||
"""Prueft einen Code mit Drift-Toleranz, in konstanter Zeit pro Kandidat."""
|
||||
cleaned = "".join(ch for ch in (code or "") if ch.isdigit())
|
||||
if len(cleaned) != DIGITS:
|
||||
return False
|
||||
counter = int((now if now is not None else time.time()) // PERIOD)
|
||||
hit = False
|
||||
for step in range(-WINDOW, WINDOW + 1):
|
||||
candidate = counter + step
|
||||
if candidate < 0: # nur bei Testzeitpunkten nahe der Epoche moeglich
|
||||
continue
|
||||
# Nicht fruehzeitig abbrechen: alle Kandidaten pruefen, damit die Laufzeit
|
||||
# nicht verraet, welcher Zeitschritt gepasst hat.
|
||||
hit |= hmac.compare_digest(code_at(secret, candidate), cleaned)
|
||||
return hit
|
||||
|
||||
|
||||
def provisioning_uri(secret: str, *, account: str, issuer: str) -> str:
|
||||
"""``otpauth://``-URI fuer den QR-Code."""
|
||||
label = quote(f"{issuer}:{account}", safe="")
|
||||
return (
|
||||
f"otpauth://totp/{label}?secret={secret}&issuer={quote(issuer, safe='')}"
|
||||
f"&algorithm=SHA1&digits={DIGITS}&period={PERIOD}"
|
||||
)
|
||||
|
||||
|
||||
def format_secret(secret: str) -> str:
|
||||
"""Gruppiert das Geheimnis in Vierergruppen -- fuer die manuelle Eingabe."""
|
||||
return " ".join(secret[i : i + 4] for i in range(0, len(secret), 4))
|
||||
|
||||
|
||||
def generate_recovery_codes(count: int = RECOVERY_CODE_COUNT) -> list[str]:
|
||||
"""Einmal-Codes fuer den Fall eines verlorenen Authenticators."""
|
||||
return [f"{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}" for _ in range(count)]
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Typisierter Key/Value-Store fuer Laufzeiteinstellungen.
|
||||
|
||||
Der Vorgaenger nutzte eine ``settings``-Tabelle als reinen String-Store; jeder
|
||||
Aufrufer parste selbst und erfand seinen eigenen Default. Hier gibt es eine
|
||||
Registry: jede Einstellung deklariert Typ, Default, Label und Bereich. Damit
|
||||
sind Formulare, Import/Export und die Diagnoseseite generisch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable, Literal, Sequence
|
||||
|
||||
from .db import Database
|
||||
|
||||
Kind = Literal["str", "int", "bool", "json", "secret"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SettingSpec:
|
||||
key: str
|
||||
default: Any = ""
|
||||
kind: Kind = "str"
|
||||
label: str = ""
|
||||
hint: str = ""
|
||||
section: str = "allgemein"
|
||||
#: Wertebereich fuer ``int``.
|
||||
minimum: int | None = None
|
||||
maximum: int | None = None
|
||||
#: Auswahlliste fuer ``str``: ``[(wert, label), ...]``.
|
||||
choices: tuple[tuple[str, str], ...] = ()
|
||||
#: ``secret``-Werte werden in der Oberflaeche nie zurueckgegeben, nur ersetzt.
|
||||
export: bool = True
|
||||
|
||||
def parse(self, raw: str | None) -> Any:
|
||||
if raw is None:
|
||||
return self.default
|
||||
if self.kind == "int":
|
||||
try:
|
||||
value = int(str(raw).strip())
|
||||
except (TypeError, ValueError):
|
||||
return self.default
|
||||
if self.minimum is not None:
|
||||
value = max(self.minimum, value)
|
||||
if self.maximum is not None:
|
||||
value = min(self.maximum, value)
|
||||
return value
|
||||
if self.kind == "bool":
|
||||
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
||||
if self.kind == "json":
|
||||
try:
|
||||
return json.loads(raw) if raw else self.default
|
||||
except ValueError:
|
||||
return self.default
|
||||
return str(raw)
|
||||
|
||||
def serialize(self, value: Any) -> str:
|
||||
if self.kind == "bool":
|
||||
return "1" if value else "0"
|
||||
if self.kind == "json":
|
||||
return json.dumps(value, separators=(",", ":"), sort_keys=True)
|
||||
if self.kind == "int":
|
||||
return str(int(value))
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
class SettingsStore:
|
||||
"""Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung."""
|
||||
|
||||
def __init__(self, specs: Iterable[SettingSpec]) -> None:
|
||||
self._specs: dict[str, SettingSpec] = {spec.key: spec for spec in specs}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def register(self, specs: Iterable[SettingSpec]) -> None:
|
||||
for spec in specs:
|
||||
self._specs[spec.key] = spec
|
||||
|
||||
def spec(self, key: str) -> SettingSpec:
|
||||
return self._specs.get(key) or SettingSpec(key=key)
|
||||
|
||||
def specs(self, section: str | None = None) -> list[SettingSpec]:
|
||||
values = list(self._specs.values())
|
||||
if section is not None:
|
||||
values = [spec for spec in values if spec.section == section]
|
||||
return values
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def get(self, conn: sqlite3.Connection, key: str, default: Any = None) -> Any:
|
||||
spec = self.spec(key)
|
||||
raw = Database.value(conn, "SELECT value FROM settings WHERE key=?", (key,))
|
||||
if raw is None:
|
||||
return spec.default if default is None else default
|
||||
return spec.parse(raw)
|
||||
|
||||
def get_many(self, conn: sqlite3.Connection, keys: Sequence[str]) -> dict[str, Any]:
|
||||
if not keys:
|
||||
return {}
|
||||
placeholders = ",".join("?" * len(keys))
|
||||
rows = Database.all(
|
||||
conn, f"SELECT key, value FROM settings WHERE key IN ({placeholders})", tuple(keys)
|
||||
)
|
||||
stored = {row["key"]: row["value"] for row in rows}
|
||||
return {key: self.spec(key).parse(stored.get(key)) for key in keys}
|
||||
|
||||
def section_values(self, conn: sqlite3.Connection, section: str) -> dict[str, Any]:
|
||||
keys = [spec.key for spec in self.specs(section)]
|
||||
return self.get_many(conn, keys)
|
||||
|
||||
def set(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
key: str,
|
||||
value: Any,
|
||||
*,
|
||||
actor: str = "",
|
||||
) -> None:
|
||||
"""Setzt einen Wert. Nutzt die uebergebene Verbindung -- niemals eine eigene.
|
||||
|
||||
Genau hier lag im Vorgaenger eine Deadlock-Quelle: ``set_setting()``
|
||||
oeffnete intern eine zweite Verbindung und lief dadurch im Import-Pfad in
|
||||
``database is locked``.
|
||||
"""
|
||||
spec = self.spec(key)
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?,?,?,?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, "
|
||||
"updated_at=excluded.updated_at, updated_by=excluded.updated_by",
|
||||
(
|
||||
key,
|
||||
spec.serialize(value),
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
actor,
|
||||
),
|
||||
)
|
||||
|
||||
def set_many(
|
||||
self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = ""
|
||||
) -> list[str]:
|
||||
"""Setzt mehrere Werte, gibt die tatsaechlich geaenderten Schluessel zurueck."""
|
||||
changed: list[str] = []
|
||||
current = self.get_many(conn, list(values))
|
||||
for key, value in values.items():
|
||||
spec = self.spec(key)
|
||||
if spec.serialize(current.get(key)) == spec.serialize(value):
|
||||
continue
|
||||
self.set(conn, key, value, actor=actor)
|
||||
changed.append(key)
|
||||
return changed
|
||||
|
||||
def delete(self, conn: sqlite3.Connection, key: str) -> None:
|
||||
conn.execute("DELETE FROM settings WHERE key=?", (key,))
|
||||
|
||||
def all_values(self, conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
rows = Database.all(conn, "SELECT key, value FROM settings")
|
||||
stored = {row["key"]: row["value"] for row in rows}
|
||||
result = {key: spec.parse(stored.get(key)) for key, spec in self._specs.items()}
|
||||
# Auch Schluessel ohne Spezifikation ausliefern (App-eigene Ad-hoc-Werte).
|
||||
for key, raw in stored.items():
|
||||
result.setdefault(key, raw)
|
||||
return result
|
||||
@@ -0,0 +1,720 @@
|
||||
/* ==========================================================================
|
||||
TESM Design-System -- Teil 1: Tokens, Reset, Layout
|
||||
--------------------------------------------------------------------------
|
||||
Kein Bootstrap, kein Tailwind, kein Build-Schritt. Handgeschriebenes CSS in
|
||||
Layern, damit die Spezifitaet vorhersehbar bleibt.
|
||||
|
||||
Gegenueber dem Vorgaenger (eine 1550-Zeilen-Datei, flach, ohne Layer):
|
||||
- Tokens sind konsequent semantisch (--surface-2 statt --bg-card-hover),
|
||||
damit Themes ohne Umbenennen von Komponenten getauscht werden koennen.
|
||||
- Drei Theme-Zustaende: System (Default), hell, dunkel. Der Vorgaenger kannte
|
||||
nur "dunkel oder hell" und ignorierte die Systemeinstellung.
|
||||
- Fokusringe sind ueberall sichtbar (:focus-visible), Kontraste erfuellen
|
||||
WCAG AA, und prefers-reduced-motion wird respektiert.
|
||||
========================================================================== */
|
||||
|
||||
@layer tokens, reset, layout, components, utilities;
|
||||
|
||||
@layer tokens {
|
||||
:root {
|
||||
/* -- Markenfarbe: WiS-Orange, in eine vollstaendige Skala aufgezogen ---- */
|
||||
--brand-050: #fff4ec;
|
||||
--brand-100: #ffe3d1;
|
||||
--brand-200: #ffc3a1;
|
||||
--brand-300: #ff9f6b;
|
||||
--brand-400: #ff8639;
|
||||
--brand-500: #ff7100;
|
||||
--brand-600: #e05f00;
|
||||
--brand-700: #b84c00;
|
||||
--brand-800: #8a3900;
|
||||
|
||||
/* -- Neutrale, leicht kuehle Grauskala ---------------------------------- */
|
||||
--neutral-000: #ffffff;
|
||||
--neutral-025: #fafafb;
|
||||
--neutral-050: #f4f5f7;
|
||||
--neutral-100: #eaecf0;
|
||||
--neutral-200: #d7dae1;
|
||||
--neutral-300: #b8bdc9;
|
||||
--neutral-400: #8f96a6;
|
||||
--neutral-500: #6b7383;
|
||||
--neutral-600: #4d5462;
|
||||
--neutral-700: #363c48;
|
||||
--neutral-800: #23272f;
|
||||
--neutral-850: #1a1d24;
|
||||
--neutral-900: #14171c;
|
||||
--neutral-950: #0d0f13;
|
||||
|
||||
/* -- Semantische Statusfarben ------------------------------------------ */
|
||||
--green-400: #34d399;
|
||||
--green-500: #16a34a;
|
||||
--green-600: #15803d;
|
||||
--red-400: #f87171;
|
||||
--red-500: #dc2626;
|
||||
--red-600: #b91c1c;
|
||||
--amber-400: #fbbf24;
|
||||
--amber-500: #d97706;
|
||||
--blue-400: #60a5fa;
|
||||
--blue-500: #2563eb;
|
||||
--violet-400: #a78bfa;
|
||||
|
||||
/* -- Abstaende (4er-Raster) -------------------------------------------- */
|
||||
--sp-1: 4px;
|
||||
--sp-2: 8px;
|
||||
--sp-3: 12px;
|
||||
--sp-4: 16px;
|
||||
--sp-5: 20px;
|
||||
--sp-6: 24px;
|
||||
--sp-8: 32px;
|
||||
--sp-10: 40px;
|
||||
--sp-12: 48px;
|
||||
--sp-16: 64px;
|
||||
|
||||
/* -- Radien ------------------------------------------------------------ */
|
||||
--r-xs: 5px;
|
||||
--r-sm: 8px;
|
||||
--r-md: 11px;
|
||||
--r-lg: 16px;
|
||||
--r-xl: 22px;
|
||||
--r-full: 999px;
|
||||
|
||||
/* -- Typografie -------------------------------------------------------- */
|
||||
--font-sans: "Inter var", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono",
|
||||
"Cascadia Mono", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
--fs-xs: 0.6875rem; /* 11px */
|
||||
--fs-sm: 0.8125rem; /* 13px */
|
||||
--fs-base: 0.875rem; /* 14px */
|
||||
--fs-md: 0.9375rem; /* 15px */
|
||||
--fs-lg: 1.0625rem; /* 17px */
|
||||
--fs-xl: 1.3125rem; /* 21px */
|
||||
--fs-2xl: 1.625rem; /* 26px */
|
||||
--fs-3xl: 2rem;
|
||||
--lh-tight: 1.2;
|
||||
--lh-normal: 1.5;
|
||||
--lh-relaxed: 1.65;
|
||||
|
||||
/* -- Struktur ---------------------------------------------------------- */
|
||||
--sidebar-w: 258px;
|
||||
--sidebar-rail-w: 68px;
|
||||
--topbar-h: 60px;
|
||||
--content-max: 1680px;
|
||||
|
||||
/* -- Bewegung ---------------------------------------------------------- */
|
||||
--ease: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--dur-fast: 120ms;
|
||||
--dur: 180ms;
|
||||
--dur-slow: 300ms;
|
||||
|
||||
--z-sticky: 30;
|
||||
--z-drawer: 60;
|
||||
--z-modal: 80;
|
||||
--z-toast: 100;
|
||||
}
|
||||
|
||||
/* -- Helles Theme (Basis) ------------------------------------------------ */
|
||||
:root,
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
|
||||
--canvas: var(--neutral-050);
|
||||
--surface-1: var(--neutral-000);
|
||||
--surface-2: var(--neutral-025);
|
||||
--surface-3: var(--neutral-100);
|
||||
--surface-inset: var(--neutral-050);
|
||||
--surface-overlay: rgba(20, 23, 28, 0.45);
|
||||
|
||||
--border: #e2e5ea;
|
||||
--border-strong: #cdd2db;
|
||||
--border-subtle: #eef0f4;
|
||||
|
||||
--text: #171a20;
|
||||
--text-muted: #5b6371;
|
||||
--text-faint: #868d9b;
|
||||
--text-inverse: #ffffff;
|
||||
|
||||
--accent: var(--brand-600);
|
||||
|
||||
/* Marke. Eigene Token statt --accent: das Zeichen hat seine eigenen
|
||||
Farbwerte und soll sich nicht mitverschieben, wenn der Akzent sich
|
||||
aendert. */
|
||||
--logo-plate: #edf1f2;
|
||||
--logo-plate-line: #c1cbce;
|
||||
--logo-mark: #b5750e;
|
||||
--logo-text: #172026;
|
||||
--accent-hover: var(--brand-700);
|
||||
--accent-text: #ffffff;
|
||||
--accent-soft: var(--brand-050);
|
||||
--accent-soft-text: var(--brand-700);
|
||||
--accent-ring: rgba(224, 95, 0, 0.32);
|
||||
|
||||
--success: var(--green-600);
|
||||
--success-soft: #e8f7ee;
|
||||
--danger: var(--red-600);
|
||||
--danger-soft: #fdecec;
|
||||
--warning: var(--amber-500);
|
||||
--warning-soft: #fdf3e2;
|
||||
--info: var(--blue-500);
|
||||
--info-soft: #eaf1fe;
|
||||
--neutral-pill: #eef0f4;
|
||||
--neutral-pill-text: #5b6371;
|
||||
|
||||
--shadow-xs: 0 1px 2px rgba(16, 20, 28, 0.06);
|
||||
--shadow-sm: 0 2px 6px rgba(16, 20, 28, 0.07), 0 1px 2px rgba(16, 20, 28, 0.05);
|
||||
--shadow-md: 0 8px 20px -6px rgba(16, 20, 28, 0.14), 0 2px 6px rgba(16, 20, 28, 0.06);
|
||||
--shadow-lg: 0 22px 48px -16px rgba(16, 20, 28, 0.24), 0 4px 12px rgba(16, 20, 28, 0.08);
|
||||
--shadow-accent: 0 6px 18px -6px rgba(224, 95, 0, 0.45);
|
||||
}
|
||||
|
||||
/* -- Dunkles Theme ------------------------------------------------------- */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
/* Marke, dunkle Fassung. */
|
||||
--logo-plate: #10151b;
|
||||
--logo-plate-line: transparent;
|
||||
--logo-mark: #e2a63c;
|
||||
--logo-text: #e7ecee;
|
||||
|
||||
color-scheme: dark;
|
||||
|
||||
--canvas: #0b0d11;
|
||||
--surface-1: #14171c;
|
||||
--surface-2: #191d23;
|
||||
--surface-3: #21262e;
|
||||
--surface-inset: #101318;
|
||||
--surface-overlay: rgba(5, 6, 9, 0.66);
|
||||
|
||||
--border: #262b34;
|
||||
--border-strong: #333944;
|
||||
--border-subtle: #1e222a;
|
||||
|
||||
--text: #eceef2;
|
||||
--text-muted: #a0a8b6;
|
||||
--text-faint: #6f7787;
|
||||
--text-inverse: #14171c;
|
||||
|
||||
--accent: var(--brand-500);
|
||||
--accent-hover: var(--brand-400);
|
||||
--accent-text: #1a0d02;
|
||||
--accent-soft: rgba(255, 113, 0, 0.14);
|
||||
--accent-soft-text: var(--brand-300);
|
||||
--accent-ring: rgba(255, 113, 0, 0.42);
|
||||
|
||||
--success: var(--green-400);
|
||||
--success-soft: rgba(52, 211, 153, 0.14);
|
||||
--danger: var(--red-400);
|
||||
--danger-soft: rgba(248, 113, 113, 0.15);
|
||||
--warning: var(--amber-400);
|
||||
--warning-soft: rgba(251, 191, 36, 0.15);
|
||||
--info: var(--blue-400);
|
||||
--info-soft: rgba(96, 165, 250, 0.15);
|
||||
--neutral-pill: rgba(160, 168, 182, 0.14);
|
||||
--neutral-pill-text: #a0a8b6;
|
||||
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.45);
|
||||
--shadow-md: 0 10px 26px -8px rgba(0, 0, 0, 0.6);
|
||||
--shadow-lg: 0 26px 56px -18px rgba(0, 0, 0, 0.72);
|
||||
--shadow-accent: 0 6px 20px -8px rgba(255, 113, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
/* Marke, dunkle Fassung. */
|
||||
--logo-plate: #10151b;
|
||||
--logo-plate-line: transparent;
|
||||
--logo-mark: #e2a63c;
|
||||
--logo-text: #e7ecee;
|
||||
|
||||
color-scheme: dark;
|
||||
|
||||
--canvas: #0b0d11;
|
||||
--surface-1: #14171c;
|
||||
--surface-2: #191d23;
|
||||
--surface-3: #21262e;
|
||||
--surface-inset: #101318;
|
||||
--surface-overlay: rgba(5, 6, 9, 0.66);
|
||||
|
||||
--border: #262b34;
|
||||
--border-strong: #333944;
|
||||
--border-subtle: #1e222a;
|
||||
|
||||
--text: #eceef2;
|
||||
--text-muted: #a0a8b6;
|
||||
--text-faint: #6f7787;
|
||||
--text-inverse: #14171c;
|
||||
|
||||
--accent: var(--brand-500);
|
||||
--accent-hover: var(--brand-400);
|
||||
--accent-text: #1a0d02;
|
||||
--accent-soft: rgba(255, 113, 0, 0.14);
|
||||
--accent-soft-text: var(--brand-300);
|
||||
--accent-ring: rgba(255, 113, 0, 0.42);
|
||||
|
||||
--success: var(--green-400);
|
||||
--success-soft: rgba(52, 211, 153, 0.14);
|
||||
--danger: var(--red-400);
|
||||
--danger-soft: rgba(248, 113, 113, 0.15);
|
||||
--warning: var(--amber-400);
|
||||
--warning-soft: rgba(251, 191, 36, 0.15);
|
||||
--info: var(--blue-400);
|
||||
--info-soft: rgba(96, 165, 250, 0.15);
|
||||
--neutral-pill: rgba(160, 168, 182, 0.14);
|
||||
--neutral-pill-text: #a0a8b6;
|
||||
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.45);
|
||||
--shadow-md: 0 10px 26px -8px rgba(0, 0, 0, 0.6);
|
||||
--shadow-lg: 0 26px 56px -18px rgba(0, 0, 0, 0.72);
|
||||
--shadow-accent: 0 6px 20px -8px rgba(255, 113, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@layer reset {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
background: var(--canvas);
|
||||
color: var(--text);
|
||||
font: var(--fs-base) / var(--lh-normal) var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd, ul, ol {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
line-height: var(--lh-tight);
|
||||
font-weight: 640;
|
||||
letter-spacing: -0.011em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
h1 { font-size: var(--fs-2xl); }
|
||||
h2 { font-size: var(--fs-xl); }
|
||||
h3 { font-size: var(--fs-lg); }
|
||||
h4 { font-size: var(--fs-md); }
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
img, svg, video, canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
button, input, select, textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
code, kbd, pre, samp {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
hr {
|
||||
height: 1px;
|
||||
margin: var(--sp-5) 0;
|
||||
border: 0;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--r-xs);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-soft-text);
|
||||
}
|
||||
|
||||
::placeholder { color: var(--text-faint); opacity: 1; }
|
||||
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* Scrollbars: dezent, aber sichtbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border: 3px solid transparent;
|
||||
border-radius: var(--r-full);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.sidebar, .topbar, .toast-stack, .no-print { display: none !important; }
|
||||
.main { margin: 0 !important; }
|
||||
body { background: #fff; color: #000; }
|
||||
}
|
||||
}
|
||||
|
||||
@layer layout {
|
||||
.app-shell {
|
||||
display: flex;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* -- Seitenleiste ------------------------------------------------------- */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: var(--z-drawer);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--sidebar-w);
|
||||
background: var(--surface-1);
|
||||
border-right: 1px solid var(--border);
|
||||
transition: width var(--dur) var(--ease), transform var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.sidebar__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
height: var(--topbar-h);
|
||||
padding: 0 var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__logo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
/* Die Wortmarke bestimmt die Breite, nicht die Seitenleiste: sonst
|
||||
zerlaeuft der Schriftzug bei schmalem Fenster. */
|
||||
max-width: 168px;
|
||||
}
|
||||
.sidebar__logo svg { width: 100%; height: auto; display: block; }
|
||||
.sidebar__edition {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.sidebar__title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.sidebar__title strong {
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.sidebar__title span {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar__nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: var(--sp-3) var(--sp-2) var(--sp-4);
|
||||
}
|
||||
|
||||
.nav-group__label {
|
||||
padding: var(--sp-4) var(--sp-3) var(--sp-2);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 620;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 8px var(--sp-3);
|
||||
margin-bottom: 2px;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-base);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: background var(--dur-fast), color var(--dur-fast);
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-item[aria-current="page"] {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-soft-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-item__icon {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke-width: 1.75;
|
||||
}
|
||||
.nav-item__label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.nav-item__badge {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--r-full);
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nav-tree > summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 8px var(--sp-3);
|
||||
margin-bottom: 2px;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-base);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
transition: background var(--dur-fast);
|
||||
}
|
||||
.nav-tree > summary::-webkit-details-marker { display: none; }
|
||||
.nav-tree > summary:hover { background: var(--surface-3); color: var(--text); }
|
||||
.nav-tree__chevron {
|
||||
margin-left: auto;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
transition: transform var(--dur) var(--ease);
|
||||
}
|
||||
.nav-tree[open] > summary > .nav-tree__chevron { transform: rotate(90deg); }
|
||||
.nav-tree[open] > summary { color: var(--text); }
|
||||
.nav-tree__children {
|
||||
margin: 2px 0 var(--sp-2) 0;
|
||||
padding-left: calc(var(--sp-3) + 9px);
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
margin-left: calc(var(--sp-3) + 9px);
|
||||
}
|
||||
.nav-tree__children .nav-item { padding-left: var(--sp-3); }
|
||||
|
||||
.sidebar__footer {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-2);
|
||||
border-radius: var(--r-md);
|
||||
min-width: 0;
|
||||
}
|
||||
.user-chip:hover { background: var(--surface-3); text-decoration: none; }
|
||||
.user-chip__meta { min-width: 0; flex: 1; }
|
||||
.user-chip__name {
|
||||
display: block;
|
||||
font-size: var(--fs-base);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.user-chip__role {
|
||||
display: block;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-faint);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* -- Schmale Leiste (eingeklappt) --------------------------------------- */
|
||||
body[data-sidebar="rail"] .sidebar { width: var(--sidebar-rail-w); }
|
||||
body[data-sidebar="rail"] .main { margin-left: var(--sidebar-rail-w); }
|
||||
body[data-sidebar="rail"] .sidebar__title,
|
||||
body[data-sidebar="rail"] .nav-item__label,
|
||||
body[data-sidebar="rail"] .nav-item__badge,
|
||||
body[data-sidebar="rail"] .nav-group__label,
|
||||
body[data-sidebar="rail"] .nav-tree__chevron,
|
||||
body[data-sidebar="rail"] .user-chip__meta,
|
||||
body[data-sidebar="rail"] .sidebar__actions-label { display: none; }
|
||||
body[data-sidebar="rail"] .nav-tree__children {
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
body[data-sidebar="rail"] .nav-item,
|
||||
body[data-sidebar="rail"] .nav-tree > summary { justify-content: center; padding: 9px; }
|
||||
|
||||
/* -- Hauptbereich ------------------------------------------------------- */
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: var(--sidebar-w);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: margin-left var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-sticky);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
height: var(--topbar-h);
|
||||
padding: 0 var(--sp-5);
|
||||
background: color-mix(in srgb, var(--canvas) 82%, transparent);
|
||||
backdrop-filter: blur(12px) saturate(1.4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.topbar__heading { min-width: 0; flex: 1; }
|
||||
.topbar__title {
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: 640;
|
||||
letter-spacing: -0.012em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.topbar__sub {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-faint);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.topbar__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: var(--content-max);
|
||||
padding: var(--sp-6) var(--sp-5) var(--sp-12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-5);
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
color: var(--text-faint);
|
||||
font-size: var(--fs-xs);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-4);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* -- Raster ------------------------------------------------------------- */
|
||||
.grid { display: grid; gap: var(--sp-4); }
|
||||
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
|
||||
.grid--3 { grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }
|
||||
.grid--4 { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
|
||||
.grid--tiles { grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); }
|
||||
.grid--split { grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); align-items: start; }
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.row--between { justify-content: space-between; }
|
||||
.row--end { justify-content: flex-end; }
|
||||
.row--tight { gap: var(--sp-2); }
|
||||
.stack { display: flex; flex-direction: column; gap: var(--sp-4); }
|
||||
.stack--tight { gap: var(--sp-2); }
|
||||
|
||||
/* -- Mobil -------------------------------------------------------------- */
|
||||
.sidebar__scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: calc(var(--z-drawer) - 1);
|
||||
background: var(--surface-overlay);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
body[data-sidebar="open"] .sidebar { transform: none; }
|
||||
body[data-sidebar="open"] .sidebar__scrim { opacity: 1; pointer-events: auto; }
|
||||
.main, body[data-sidebar="rail"] .main { margin-left: 0; }
|
||||
body[data-sidebar="rail"] .sidebar { width: var(--sidebar-w); }
|
||||
body[data-sidebar="rail"] .sidebar__title,
|
||||
body[data-sidebar="rail"] .nav-item__label,
|
||||
body[data-sidebar="rail"] .nav-group__label,
|
||||
body[data-sidebar="rail"] .nav-tree__chevron,
|
||||
body[data-sidebar="rail"] .user-chip__meta { display: revert; }
|
||||
.content { padding: var(--sp-4) var(--sp-4) var(--sp-10); }
|
||||
.topbar { padding: 0 var(--sp-4); }
|
||||
.grid--split { grid-template-columns: 1fr; }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="32" height="32">
|
||||
<!-- TESM-Zeichen, dunkle Fassung. Feste Farben, kein currentColor: ein
|
||||
Favicon wird vom Browser ausserhalb der Seite gezeichnet, dort greifen
|
||||
weder Themen-Token noch prefers-color-scheme zuverlaessig. Die dunkle
|
||||
Fassung steht auf hellen wie auf dunklen Tableisten gut. -->
|
||||
<rect x="1" y="1" width="62" height="62" rx="14" fill="#10151B"/>
|
||||
<g transform="translate(5,20.19) scale(0.16875)">
|
||||
<circle cx="298" cy="70" r="15" fill="#E2A63C" opacity="0.22"/>
|
||||
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
|
||||
fill="none" stroke="#E2A63C" stroke-width="3.2" stroke-linecap="round"
|
||||
stroke-linejoin="round" opacity="0.85"/>
|
||||
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
|
||||
font-weight="800" font-size="82" letter-spacing="2" fill="#E7ECEE">TESM</text>
|
||||
<circle cx="298" cy="70" r="8" fill="#E2A63C"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,842 @@
|
||||
/* ==========================================================================
|
||||
TESM -- Frontend-Verhalten
|
||||
--------------------------------------------------------------------------
|
||||
Ein ES-Modul, keine Abhaengigkeiten, kein Build-Schritt. Alles laeuft ueber
|
||||
deklarative data-Attribute, damit Templates kein eigenes Inline-Skript
|
||||
brauchen -- Voraussetzung fuer die strikte CSP (kein 'unsafe-inline').
|
||||
|
||||
Konventionen:
|
||||
data-behavior="..." ein Verhalten an ein Element binden
|
||||
data-* Parameter des Verhaltens
|
||||
========================================================================== */
|
||||
|
||||
"use strict";
|
||||
|
||||
const STORAGE = {
|
||||
theme: "tesm.theme",
|
||||
sidebar: "tesm.sidebar",
|
||||
sections: "tesm.sections",
|
||||
};
|
||||
|
||||
/* -- Kleine Helfer -------------------------------------------------------- */
|
||||
|
||||
const $ = (selector, scope = document) => scope.querySelector(selector);
|
||||
const $$ = (selector, scope = document) => Array.from(scope.querySelectorAll(selector));
|
||||
|
||||
function store(key, value) {
|
||||
try {
|
||||
if (value === null) localStorage.removeItem(key);
|
||||
else localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* Privatmodus o. Ä. -- Einstellungen sind dann nur fluechtig. */
|
||||
}
|
||||
}
|
||||
|
||||
function read(key, fallback = null) {
|
||||
try {
|
||||
const value = localStorage.getItem(key);
|
||||
return value === null ? fallback : value;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function csrfToken() {
|
||||
return document.body?.dataset.csrf || "";
|
||||
}
|
||||
|
||||
/** fetch mit CSRF-Header und JSON-Erwartung. */
|
||||
export async function api(url, options = {}) {
|
||||
const opts = { credentials: "same-origin", ...options };
|
||||
opts.headers = {
|
||||
"X-Requested-With": "fetch",
|
||||
"X-CSRF-Token": csrfToken(),
|
||||
...(options.headers || {}),
|
||||
};
|
||||
const response = await fetch(url, opts);
|
||||
const type = response.headers.get("content-type") || "";
|
||||
const payload = type.includes("application/json") ? await response.json() : await response.text();
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(payload && typeof payload === "object" && (payload.error || payload.message)) ||
|
||||
`Anfrage fehlgeschlagen (HTTP ${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/* -- Toasts --------------------------------------------------------------- */
|
||||
|
||||
let toastStack = null;
|
||||
|
||||
export function showToast(message, type = "info", { timeout = 6000 } = {}) {
|
||||
if (!message) return;
|
||||
if (!toastStack) {
|
||||
toastStack = document.createElement("div");
|
||||
toastStack.className = "toast-stack";
|
||||
toastStack.setAttribute("role", "status");
|
||||
toastStack.setAttribute("aria-live", "polite");
|
||||
document.body.appendChild(toastStack);
|
||||
}
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast toast--${type}`;
|
||||
const body = document.createElement("div");
|
||||
body.className = "toast__body";
|
||||
body.textContent = message;
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "toast__close";
|
||||
close.setAttribute("aria-label", "Meldung schliessen");
|
||||
close.textContent = "×";
|
||||
toast.append(body, close);
|
||||
toastStack.appendChild(toast);
|
||||
|
||||
let timer = 0;
|
||||
const dismiss = () => {
|
||||
window.clearTimeout(timer);
|
||||
toast.classList.add("is-leaving");
|
||||
window.setTimeout(() => toast.remove(), 180);
|
||||
};
|
||||
close.addEventListener("click", dismiss);
|
||||
if (timeout > 0) timer = window.setTimeout(dismiss, timeout);
|
||||
return dismiss;
|
||||
}
|
||||
|
||||
function initFlashes() {
|
||||
const node = $("#flashed-messages");
|
||||
if (!node) return;
|
||||
let entries = [];
|
||||
try {
|
||||
entries = JSON.parse(node.textContent || "[]");
|
||||
} catch {
|
||||
entries = [];
|
||||
}
|
||||
entries.forEach(([category, message]) => {
|
||||
const type = { error: "danger", danger: "danger", success: "success", warning: "warning" }[
|
||||
category
|
||||
] || "info";
|
||||
showToast(message, type, { timeout: type === "danger" ? 0 : 6000 });
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Theme (System / hell / dunkel) --------------------------------------- */
|
||||
|
||||
function applyTheme(mode) {
|
||||
const root = document.documentElement;
|
||||
if (mode === "light" || mode === "dark") root.dataset.theme = mode;
|
||||
else delete root.dataset.theme;
|
||||
$$('[data-behavior="theme-toggle"] [data-theme-option]').forEach((btn) => {
|
||||
btn.setAttribute("aria-pressed", String(btn.dataset.themeOption === mode));
|
||||
});
|
||||
}
|
||||
|
||||
function initTheme() {
|
||||
const mode = read(STORAGE.theme, "system");
|
||||
applyTheme(mode);
|
||||
$$('[data-behavior="theme-toggle"]').forEach((group) => {
|
||||
group.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-theme-option]");
|
||||
if (!button) return;
|
||||
const next = button.dataset.themeOption;
|
||||
store(STORAGE.theme, next === "system" ? null : next);
|
||||
applyTheme(next);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Seitenleiste --------------------------------------------------------- */
|
||||
|
||||
function initSidebar() {
|
||||
const body = document.body;
|
||||
const desktop = () => window.matchMedia("(min-width: 1001px)").matches;
|
||||
const saved = read(STORAGE.sidebar, "full");
|
||||
if (desktop() && saved === "rail") body.dataset.sidebar = "rail";
|
||||
|
||||
$$('[data-behavior="sidebar-toggle"]').forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
if (desktop()) {
|
||||
const rail = body.dataset.sidebar === "rail";
|
||||
body.dataset.sidebar = rail ? "full" : "rail";
|
||||
store(STORAGE.sidebar, rail ? "full" : "rail");
|
||||
} else {
|
||||
body.dataset.sidebar = body.dataset.sidebar === "open" ? "closed" : "open";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const scrim = $(".sidebar__scrim");
|
||||
scrim?.addEventListener("click", () => {
|
||||
body.dataset.sidebar = "closed";
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && body.dataset.sidebar === "open") body.dataset.sidebar = "closed";
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Aufklappbare Bereiche (Zustand bleibt erhalten) ---------------------- */
|
||||
|
||||
function initCollapsibles() {
|
||||
let state = {};
|
||||
try {
|
||||
state = JSON.parse(read(STORAGE.sections, "{}")) || {};
|
||||
} catch {
|
||||
state = {};
|
||||
}
|
||||
$$("details[data-persist]").forEach((element) => {
|
||||
const key = element.dataset.persist;
|
||||
if (key in state) element.open = Boolean(state[key]);
|
||||
element.addEventListener("toggle", () => {
|
||||
state[key] = element.open;
|
||||
store(STORAGE.sections, JSON.stringify(state));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Dialoge -------------------------------------------------------------- */
|
||||
|
||||
function openDialog(dialog) {
|
||||
if (!dialog) return;
|
||||
if (typeof dialog.showModal === "function") dialog.showModal();
|
||||
else dialog.setAttribute("open", "");
|
||||
const focusable = dialog.querySelector(
|
||||
"[autofocus], input:not([type=hidden]):not([disabled]), select, textarea, button"
|
||||
);
|
||||
focusable?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function initDialogs() {
|
||||
document.addEventListener("click", (event) => {
|
||||
const opener = event.target.closest("[data-dialog-open]");
|
||||
if (opener) {
|
||||
event.preventDefault();
|
||||
const dialog = document.getElementById(opener.dataset.dialogOpen);
|
||||
if (dialog) {
|
||||
// Felder aus data-fill-* des Auslösers in das Formular uebernehmen.
|
||||
Object.entries(opener.dataset).forEach(([key, value]) => {
|
||||
if (!key.startsWith("fill")) return;
|
||||
const name = key.slice(4);
|
||||
const target = dialog.querySelector(`[data-fill-target="${name}"]`);
|
||||
if (!target) return;
|
||||
if ("value" in target) target.value = value;
|
||||
else target.textContent = value;
|
||||
});
|
||||
openDialog(dialog);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const closer = event.target.closest("[data-dialog-close]");
|
||||
if (closer) {
|
||||
event.preventDefault();
|
||||
closer.closest("dialog")?.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Klick auf den Hintergrund schliesst -- aber nur, wenn wirklich daneben
|
||||
// geklickt wurde (nicht bei einem Drag aus dem Dialog heraus).
|
||||
$$("dialog").forEach((dialog) => {
|
||||
dialog.addEventListener("mousedown", (event) => {
|
||||
dialog.dataset.outside = String(event.target === dialog);
|
||||
});
|
||||
dialog.addEventListener("click", (event) => {
|
||||
if (event.target === dialog && dialog.dataset.outside === "true") dialog.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Bestaetigungsdialog (ersetzt window.confirm) ------------------------- */
|
||||
|
||||
function initConfirmations() {
|
||||
const dialog = $("#confirm-dialog");
|
||||
if (!dialog) return;
|
||||
const titleNode = $("[data-confirm-title]", dialog);
|
||||
const textNode = $("[data-confirm-text]", dialog);
|
||||
const okButton = $("[data-confirm-ok]", dialog);
|
||||
let pending = null;
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const trigger = event.target.closest("[data-confirm]");
|
||||
if (!trigger || trigger.dataset.confirmed === "1") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
pending = trigger;
|
||||
titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestaetigen";
|
||||
textNode.textContent = trigger.dataset.confirm;
|
||||
okButton.textContent = trigger.dataset.confirmOk || "Fortfahren";
|
||||
okButton.className = `btn btn--${trigger.dataset.confirmStyle || "danger"}`;
|
||||
openDialog(dialog);
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
okButton.addEventListener("click", () => {
|
||||
dialog.close();
|
||||
if (!pending) return;
|
||||
const trigger = pending;
|
||||
pending = null;
|
||||
trigger.dataset.confirmed = "1";
|
||||
unsavedGuard.disable();
|
||||
if (trigger.form && trigger.type === "submit") trigger.click();
|
||||
else if (trigger.tagName === "FORM") trigger.submit();
|
||||
else trigger.click();
|
||||
delete trigger.dataset.confirmed;
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Schutz vor ungespeicherten Aenderungen ------------------------------ */
|
||||
|
||||
const unsavedGuard = {
|
||||
dirty: false,
|
||||
armed: true,
|
||||
disable() {
|
||||
this.armed = false;
|
||||
},
|
||||
init() {
|
||||
const forms = $$("form:not([data-no-guard])").filter((form) =>
|
||||
form.querySelector("input:not([type=hidden]):not([type=search]), select, textarea")
|
||||
);
|
||||
forms.forEach((form) => {
|
||||
const snapshot = new FormData(form);
|
||||
form.addEventListener("input", () => {
|
||||
this.dirty = !sameForm(snapshot, new FormData(form));
|
||||
});
|
||||
form.addEventListener("submit", () => {
|
||||
this.dirty = false;
|
||||
this.armed = false;
|
||||
});
|
||||
});
|
||||
window.addEventListener("beforeunload", (event) => {
|
||||
if (!this.dirty || !this.armed) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function sameForm(a, b) {
|
||||
const left = Array.from(a.entries()).map(([k, v]) => `${k}=${v}`).sort().join("&");
|
||||
const right = Array.from(b.entries()).map(([k, v]) => `${k}=${v}`).sort().join("&");
|
||||
return left === right;
|
||||
}
|
||||
|
||||
/* -- Tabellen: Sortieren und Filtern ------------------------------------- */
|
||||
|
||||
function cellValue(row, index) {
|
||||
const cell = row.cells[index];
|
||||
if (!cell) return "";
|
||||
return (cell.dataset.sortValue ?? cell.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
function initTables() {
|
||||
$$('table[data-behavior="sortable"]').forEach((table) => {
|
||||
const body = table.tBodies[0];
|
||||
if (!body) return;
|
||||
$$("thead th.sortable", table).forEach((header, index) => {
|
||||
header.tabIndex = 0;
|
||||
const activate = () => {
|
||||
const ascending = header.getAttribute("aria-sort") !== "ascending";
|
||||
$$("thead th", table).forEach((other) => other.removeAttribute("aria-sort"));
|
||||
header.setAttribute("aria-sort", ascending ? "ascending" : "descending");
|
||||
const rows = Array.from(body.rows);
|
||||
const numeric = rows.every((row) => {
|
||||
const value = cellValue(row, index).replace(/[^\d,.\-]/g, "").replace(",", ".");
|
||||
return value === "" || !Number.isNaN(Number(value));
|
||||
});
|
||||
rows.sort((rowA, rowB) => {
|
||||
const a = cellValue(rowA, index);
|
||||
const b = cellValue(rowB, index);
|
||||
const result = numeric
|
||||
? Number(a.replace(/[^\d,.\-]/g, "").replace(",", ".") || 0) -
|
||||
Number(b.replace(/[^\d,.\-]/g, "").replace(",", ".") || 0)
|
||||
: a.localeCompare(b, "de", { numeric: true, sensitivity: "base" });
|
||||
return ascending ? result : -result;
|
||||
});
|
||||
rows.forEach((row) => body.appendChild(row));
|
||||
};
|
||||
header.addEventListener("click", activate);
|
||||
header.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
activate();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$$("[data-filter-target]").forEach((input) => {
|
||||
const apply = () => {
|
||||
const needle = input.value.trim().toLowerCase();
|
||||
const target = document.getElementById(input.dataset.filterTarget);
|
||||
if (!target) return;
|
||||
const rows = target.tagName === "TABLE" ? Array.from(target.tBodies[0]?.rows || []) : $$("[data-filter-item]", target);
|
||||
let visible = 0;
|
||||
rows.forEach((row) => {
|
||||
const haystack = (row.dataset.filterText || row.textContent || "").toLowerCase();
|
||||
const match = !needle || haystack.includes(needle);
|
||||
row.hidden = !match;
|
||||
if (match) visible += 1;
|
||||
});
|
||||
const counter = input.dataset.filterCount
|
||||
? document.getElementById(input.dataset.filterCount)
|
||||
: null;
|
||||
if (counter) counter.textContent = String(visible);
|
||||
const emptyNode = input.dataset.filterEmpty
|
||||
? document.getElementById(input.dataset.filterEmpty)
|
||||
: null;
|
||||
if (emptyNode) emptyNode.hidden = visible !== 0;
|
||||
};
|
||||
input.addEventListener("input", apply);
|
||||
if (input.value) apply();
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Rechtematrix: Kill-Switch und Zeilenlogik --------------------------- */
|
||||
|
||||
function initPermissionMatrix() {
|
||||
$$('[data-behavior="permission-matrix"]').forEach((table) => {
|
||||
const syncArea = (areaKey) => {
|
||||
const master = $(`[data-area-switch="${areaKey}"]`, table);
|
||||
if (!master) return;
|
||||
$$(`tr[data-area="${areaKey}"]`, table).forEach((row) => {
|
||||
row.classList.toggle("is-locked", !master.checked);
|
||||
// Nur echte Rechte-Felder: alles ohne name-Attribut ist Darstellung,
|
||||
// kein Bedienelement, und darf hier nicht freigeschaltet werden.
|
||||
$$('input[type="checkbox"][name]', row).forEach((box) => {
|
||||
box.disabled = !master.checked;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
$$("[data-area-switch]", table).forEach((master) => {
|
||||
const areaKey = master.dataset.areaSwitch;
|
||||
syncArea(areaKey);
|
||||
master.addEventListener("change", () => syncArea(areaKey));
|
||||
});
|
||||
|
||||
table.addEventListener("change", (event) => {
|
||||
const box = event.target;
|
||||
if (!(box instanceof HTMLInputElement) || !box.dataset.action) return;
|
||||
const row = box.closest("tr");
|
||||
if (!row) return;
|
||||
const viewBox = $('input[data-action="view"]', row);
|
||||
if (box.dataset.action === "view") {
|
||||
// "Ansehen" abwaehlen entfernt alles andere dieser Zeile.
|
||||
if (!box.checked) {
|
||||
$$('input[type="checkbox"][name]', row).forEach((other) => {
|
||||
if (other !== box) other.checked = false;
|
||||
});
|
||||
}
|
||||
} else if (box.checked && viewBox) {
|
||||
viewBox.checked = true; // Jede Aktion setzt "Ansehen" voraus.
|
||||
}
|
||||
const areaKey = row.dataset.area;
|
||||
const master = areaKey ? $(`[data-area-switch="${areaKey}"]`, table) : null;
|
||||
if (master && box.checked && !master.checked) {
|
||||
master.checked = true;
|
||||
master.dispatchEvent(new Event("change"));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Automatische Aktualisierung von Teilbereichen ----------------------- */
|
||||
|
||||
function initAutoRefresh() {
|
||||
|
||||
// -- Verzeichnisgruppe suchen ------------------------------------------- #
|
||||
//
|
||||
// Ein AD-Gruppen-DN wird nicht getippt: er ist lang, enthaelt Kommas und
|
||||
// Leerzeichen, und ein Tippfehler faellt erst bei der naechsten Anmeldung
|
||||
// auf -- als stille Nicht-Zuordnung, die niemand mit dem Formular in
|
||||
// Verbindung bringt.
|
||||
$$("[data-behavior='group-picker']").forEach((picker) => {
|
||||
const query = picker.querySelector("[data-group-query]");
|
||||
const button = picker.querySelector("[data-group-search]");
|
||||
const results = picker.querySelector("[data-group-results]");
|
||||
const status = picker.querySelector("[data-group-status]");
|
||||
const nameField = document.querySelector(picker.dataset.nameTarget);
|
||||
const dnField = document.querySelector(picker.dataset.dnTarget);
|
||||
if (!query || !button || !results || !nameField || !dnField) return;
|
||||
|
||||
const show = (text) => { if (status) status.textContent = text; };
|
||||
|
||||
const pick = (entry) => {
|
||||
nameField.value = entry.name || "";
|
||||
dnField.value = entry.dn || "";
|
||||
results.hidden = true;
|
||||
show(`Uebernommen: ${entry.name || entry.dn}`);
|
||||
};
|
||||
|
||||
const search = async () => {
|
||||
const term = query.value.trim();
|
||||
if (term.length < 2) { show("Bitte mindestens zwei Zeichen eingeben."); return; }
|
||||
button.disabled = true;
|
||||
show("Suche laeuft ...");
|
||||
results.innerHTML = "";
|
||||
try {
|
||||
const url = `${picker.dataset.searchUrl}?q=${encodeURIComponent(term)}`;
|
||||
const data = await api(url);
|
||||
const entries = (data && data.results) || [];
|
||||
if (!entries.length) {
|
||||
results.hidden = true;
|
||||
show("Keine Gruppe gefunden. Ist ein Dienstkonto hinterlegt?");
|
||||
return;
|
||||
}
|
||||
entries.forEach((entry) => {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "btn btn--sm btn--ghost";
|
||||
row.style.justifyContent = "flex-start";
|
||||
row.style.textAlign = "left";
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = entry.name || "(ohne Namen)";
|
||||
const dn = document.createElement("span");
|
||||
dn.className = "fs-xs faint break-all";
|
||||
dn.style.marginLeft = "8px";
|
||||
dn.textContent = entry.dn || "";
|
||||
row.append(name, dn);
|
||||
row.addEventListener("click", () => pick(entry));
|
||||
results.appendChild(row);
|
||||
});
|
||||
results.hidden = false;
|
||||
show(`${entries.length} Treffer -- zum Uebernehmen anklicken.`);
|
||||
} catch (error) {
|
||||
results.hidden = true;
|
||||
show("Die Suche ist fehlgeschlagen. Laeuft die Verbindung zum Verzeichnis?");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
button.addEventListener("click", search);
|
||||
query.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") { event.preventDefault(); search(); }
|
||||
});
|
||||
});
|
||||
|
||||
// -- Transport bestimmt den Port ----------------------------------------- #
|
||||
//
|
||||
// 636 fuer LDAPS, 389 sonst. Wer den Transport umstellt und den Port stehen
|
||||
// laesst, bekommt eine Verbindung, die auf gut Glueck scheitert -- und der
|
||||
// Fehler ("Zeitlimit") sagt nichts ueber die Ursache.
|
||||
$$("[data-behavior='transport-port']").forEach((select) => {
|
||||
const port = document.querySelector(select.dataset.portTarget);
|
||||
if (!port) return;
|
||||
const standard = { ldaps: "636", starttls: "389", none: "389" };
|
||||
select.addEventListener("change", () => {
|
||||
const expected = standard[select.value];
|
||||
if (!expected) return;
|
||||
// Einen abweichenden, absichtlich gesetzten Port nicht ueberschreiben --
|
||||
// nur einen, der zum vorherigen Transport gehoerte.
|
||||
const known = Object.values(standard);
|
||||
if (port.value === "" || known.includes(port.value)) port.value = expected;
|
||||
});
|
||||
});
|
||||
|
||||
$$("[data-refresh-url]").forEach((region) => {
|
||||
// Number(x || 15000) haette "0" zu 15000 gemacht -- eine Null ist falsy.
|
||||
// Bei einer Zahl aus einem Attribut ist || die falsche Vorgabe; gemeint
|
||||
// war "kein Wert angegeben", nicht "Wert ist 0".
|
||||
const raw = region.dataset.refreshInterval;
|
||||
const interval = raw === undefined || raw === "" ? 15000 : Number(raw);
|
||||
if (!Number.isFinite(interval) || interval <= 0) return; // ausdruecklich aus
|
||||
let timer = 0;
|
||||
let stopped = false;
|
||||
|
||||
const tick = async () => {
|
||||
if (stopped || document.hidden) return schedule();
|
||||
try {
|
||||
const html = await api(region.dataset.refreshUrl, { 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.
|
||||
if (typeof html === "string" && /<(!doctype|html|body)\b/i.test(html)) {
|
||||
console.warn("Aktualisierung geliefert: ganze Seite statt Ausschnitt --", region.id);
|
||||
stopped = true;
|
||||
return;
|
||||
}
|
||||
if (typeof html === "string") {
|
||||
// Gescrollt wird selten der Bereich selbst, sondern das Element
|
||||
// darin (bei Protokollen das <pre class="log-view">). Beim Ersetzen
|
||||
// von innerHTML entsteht ein neues Element -- mit scrollTop 0. Genau
|
||||
// deshalb sprang ein laufendes Auftragsprotokoll bei jeder
|
||||
// Aktualisierung an den Anfang zurueck.
|
||||
const scrollerOf = (el) => el.querySelector(".log-view, [data-scroll]") || el;
|
||||
const before = scrollerOf(region);
|
||||
const position = before.scrollTop;
|
||||
// "Am Ende" grosszuegig auslegen: wer mitliest, soll mitgezogen
|
||||
// werden, ohne exakt ganz unten stehen zu muessen.
|
||||
const following =
|
||||
before.scrollHeight - before.scrollTop - before.clientHeight < 48;
|
||||
|
||||
region.innerHTML = html;
|
||||
|
||||
const after = scrollerOf(region);
|
||||
after.scrollTop = following ? after.scrollHeight : position;
|
||||
region.dispatchEvent(new CustomEvent("tesm:refreshed", { bubbles: true }));
|
||||
initRelativeTimes(region);
|
||||
initCountdowns(region);
|
||||
}
|
||||
} catch {
|
||||
/* Netzfehler: beim naechsten Intervall erneut versuchen. */
|
||||
}
|
||||
schedule();
|
||||
};
|
||||
|
||||
const schedule = () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(tick, interval);
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) schedule();
|
||||
});
|
||||
schedule();
|
||||
region.addEventListener("tesm:stop-refresh", () => {
|
||||
stopped = true;
|
||||
window.clearTimeout(timer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Zeitangaben ---------------------------------------------------------- */
|
||||
|
||||
const RELATIVE = new Intl.RelativeTimeFormat("de", { numeric: "auto" });
|
||||
const UNITS = [
|
||||
["year", 31536000],
|
||||
["month", 2592000],
|
||||
["day", 86400],
|
||||
["hour", 3600],
|
||||
["minute", 60],
|
||||
["second", 1],
|
||||
];
|
||||
|
||||
function relativeTime(iso) {
|
||||
const target = new Date(iso);
|
||||
if (Number.isNaN(target.getTime())) return "";
|
||||
const seconds = (target.getTime() - Date.now()) / 1000;
|
||||
for (const [unit, size] of UNITS) {
|
||||
if (Math.abs(seconds) >= size || unit === "second") {
|
||||
return RELATIVE.format(Math.round(seconds / size), unit);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function initRelativeTimes(scope = document) {
|
||||
$$("[data-relative-time]", scope).forEach((node) => {
|
||||
const iso = node.dataset.relativeTime;
|
||||
const text = relativeTime(iso);
|
||||
if (text) {
|
||||
node.textContent = text;
|
||||
if (!node.title) node.title = new Date(iso).toLocaleString("de-DE");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initCountdowns(scope = document) {
|
||||
$$("[data-countdown-to]", scope).forEach((node) => {
|
||||
const target = new Date(node.dataset.countdownTo).getTime();
|
||||
if (Number.isNaN(target)) return;
|
||||
const render = () => {
|
||||
const remaining = Math.max(0, Math.floor((target - Date.now()) / 1000));
|
||||
const hours = Math.floor(remaining / 3600);
|
||||
const minutes = Math.floor((remaining % 3600) / 60);
|
||||
const seconds = remaining % 60;
|
||||
node.textContent =
|
||||
hours > 0
|
||||
? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
|
||||
: `${minutes}:${String(seconds).padStart(2, "0")}`;
|
||||
if (remaining === 0) {
|
||||
window.clearInterval(handle);
|
||||
node.textContent = node.dataset.countdownDone || "0:00";
|
||||
}
|
||||
};
|
||||
render();
|
||||
const handle = window.setInterval(render, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
/* -- In die Zwischenablage ----------------------------------------------- */
|
||||
|
||||
function initClipboard() {
|
||||
document.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("[data-copy]");
|
||||
if (!button) return;
|
||||
event.preventDefault();
|
||||
const source = button.dataset.copy.startsWith("#")
|
||||
? document.querySelector(button.dataset.copy)
|
||||
: null;
|
||||
const text = source ? source.value ?? source.textContent : button.dataset.copy;
|
||||
try {
|
||||
await navigator.clipboard.writeText((text || "").trim());
|
||||
showToast(button.dataset.copyMessage || "In die Zwischenablage kopiert.", "success", {
|
||||
timeout: 2500,
|
||||
});
|
||||
} catch {
|
||||
showToast("Kopieren nicht moeglich -- bitte manuell markieren.", "warning");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Befehlspalette (Strg+K) --------------------------------------------- */
|
||||
|
||||
function initPalette() {
|
||||
const dialog = $("#command-palette");
|
||||
if (!dialog) return;
|
||||
const input = $(".palette__input", dialog);
|
||||
const list = $(".palette__list", dialog);
|
||||
let entries = [];
|
||||
try {
|
||||
entries = JSON.parse($("#palette-data")?.textContent || "[]");
|
||||
} catch {
|
||||
entries = [];
|
||||
}
|
||||
let active = 0;
|
||||
|
||||
const render = () => {
|
||||
const needle = input.value.trim().toLowerCase();
|
||||
const matches = entries
|
||||
.filter((entry) => !needle || `${entry.label} ${entry.group}`.toLowerCase().includes(needle))
|
||||
.slice(0, 40);
|
||||
active = 0;
|
||||
list.innerHTML = "";
|
||||
if (!matches.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "palette__item faint";
|
||||
empty.textContent = "Keine Treffer";
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
matches.forEach((entry, index) => {
|
||||
const item = document.createElement("a");
|
||||
item.className = `palette__item${index === 0 ? " is-active" : ""}`;
|
||||
item.href = entry.url;
|
||||
item.innerHTML = `<span>${escapeHtml(entry.label)}</span>`;
|
||||
if (entry.group) {
|
||||
const group = document.createElement("span");
|
||||
group.className = "palette__group";
|
||||
group.textContent = entry.group;
|
||||
item.appendChild(group);
|
||||
}
|
||||
list.appendChild(item);
|
||||
});
|
||||
};
|
||||
|
||||
const move = (delta) => {
|
||||
const items = $$(".palette__item[href]", list);
|
||||
if (!items.length) return;
|
||||
items[active]?.classList.remove("is-active");
|
||||
active = (active + delta + items.length) % items.length;
|
||||
items[active].classList.add("is-active");
|
||||
items[active].scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
input.value = "";
|
||||
render();
|
||||
openDialog(dialog);
|
||||
}
|
||||
});
|
||||
$$('[data-behavior="open-palette"]').forEach((button) =>
|
||||
button.addEventListener("click", () => {
|
||||
input.value = "";
|
||||
render();
|
||||
openDialog(dialog);
|
||||
})
|
||||
);
|
||||
input.addEventListener("input", render);
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
move(1);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
move(-1);
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
$$(".palette__item[href]", list)[active]?.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
const node = document.createElement("span");
|
||||
node.textContent = value;
|
||||
return node.innerHTML;
|
||||
}
|
||||
|
||||
/* -- Formulare gegen Doppelabsenden -------------------------------------- */
|
||||
|
||||
function initSubmitGuards() {
|
||||
document.addEventListener("submit", (event) => {
|
||||
const form = event.target;
|
||||
if (!(form instanceof HTMLFormElement) || form.dataset.allowMultiSubmit) return;
|
||||
const buttons = $$('button[type="submit"], input[type="submit"]', form);
|
||||
window.setTimeout(() => {
|
||||
buttons.forEach((button) => {
|
||||
button.disabled = true;
|
||||
if (button.dataset.busyLabel) button.textContent = button.dataset.busyLabel;
|
||||
});
|
||||
}, 0);
|
||||
// Falls die Navigation scheitert (Validierungsfehler serverseitig),
|
||||
// Buttons nach kurzer Zeit wieder freigeben.
|
||||
window.setTimeout(() => buttons.forEach((button) => (button.disabled = false)), 12000);
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Kachelfilter (Dashboard) -------------------------------------------- */
|
||||
|
||||
function initTileFilters() {
|
||||
$$('[data-behavior="tile-filter"]').forEach((group) => {
|
||||
const targetId = group.dataset.filterScope;
|
||||
const scope = targetId ? document.getElementById(targetId) : document;
|
||||
const apply = (value) => {
|
||||
$$("[data-tile-section]", scope).forEach((section) => {
|
||||
section.hidden = Boolean(value) && section.dataset.tileSection !== value;
|
||||
});
|
||||
$$("[data-filter-value]", group).forEach((button) => {
|
||||
button.setAttribute("aria-pressed", String(button.dataset.filterValue === value));
|
||||
});
|
||||
};
|
||||
group.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-filter-value]");
|
||||
if (!button) return;
|
||||
const value = button.getAttribute("aria-pressed") === "true" ? "" : button.dataset.filterValue;
|
||||
apply(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Start ---------------------------------------------------------------- */
|
||||
|
||||
function boot() {
|
||||
initTheme();
|
||||
initSidebar();
|
||||
initFlashes();
|
||||
initCollapsibles();
|
||||
initDialogs();
|
||||
initConfirmations();
|
||||
initTables();
|
||||
initPermissionMatrix();
|
||||
initAutoRefresh();
|
||||
initRelativeTimes();
|
||||
initCountdowns();
|
||||
initClipboard();
|
||||
initPalette();
|
||||
initSubmitGuards();
|
||||
initTileFilters();
|
||||
unsavedGuard.init();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", boot, { once: true });
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
|
||||
window.tesm = { showToast, api, openDialog, relativeTime };
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Privilegierte Systemaktionen ueber einen einzigen, engen Helfer.
|
||||
|
||||
Der Vorgaenger lief als ``root`` -- inklusive Flask, Jinja, Template-Rendering,
|
||||
Datei-Uploads und LDAP-Client. Eine einzige Luecke in irgendeinem dieser Pfade
|
||||
haette vollstaendige Kontrolle ueber den Host bedeutet. Die eigene
|
||||
Dokumentation vermerkte das als "bewusst, mit Verbesserungshinweis".
|
||||
|
||||
Hier ist der Verbesserungshinweis umgesetzt:
|
||||
|
||||
* Der Webprozess laeuft als unprivilegierter Dienstbenutzer.
|
||||
* Alles, was Root braucht (systemctl, nginx, netplan, hostnamectl, mount.cifs,
|
||||
apt, certbot), laeuft ueber **ein** Skript: ``tesm-helper``. Es gehoert root,
|
||||
ist per ``sudoers`` mit ``NOPASSWD`` nur fuer genau diesen Pfad erlaubt und
|
||||
validiert jedes Argument gegen eine Positivliste.
|
||||
* Die Sicherheitsgrenze ist damit klein, an einer Stelle und pruefbar -- statt
|
||||
ueber Tausende Zeilen Anwendungscode verteilt.
|
||||
|
||||
Lesende Systemabfragen (``systemctl is-active``, ``ip addr``) brauchen keine
|
||||
Rechte und laufen direkt. Nur Aenderungen gehen ueber den Helfer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_HELPER = Path("/usr/local/lib/tesm/tesm-helper")
|
||||
#: Verben, die der Helfer kennt. Muss mit dem Skript in ``deploy/`` uebereinstimmen.
|
||||
HELPER_VERBS = (
|
||||
"service-status",
|
||||
"service-start",
|
||||
"service-stop",
|
||||
"service-restart",
|
||||
"service-enable",
|
||||
"service-disable",
|
||||
"nginx-test",
|
||||
"nginx-reload",
|
||||
"nginx-write",
|
||||
"nginx-apply",
|
||||
"netplan-write",
|
||||
"netplan-apply",
|
||||
"hostname-set",
|
||||
"timezone-set",
|
||||
"kea-write",
|
||||
"logrotate-write",
|
||||
"mount-cifs",
|
||||
"umount",
|
||||
"apt-install",
|
||||
"certbot-issue",
|
||||
"selfsigned-issue",
|
||||
"file-read",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Result:
|
||||
ok: bool
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
code: int = 0
|
||||
|
||||
@property
|
||||
def output(self) -> str:
|
||||
return (self.stdout + ("\n" + self.stderr if self.stderr else "")).strip()
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
return self.stderr.strip() or self.stdout.strip()
|
||||
|
||||
|
||||
UNAVAILABLE = Result(
|
||||
ok=False,
|
||||
stderr=(
|
||||
"Systemaktionen sind in dieser Umgebung nicht verfuegbar "
|
||||
"(Helfer nicht installiert oder kein Linux-Host)."
|
||||
),
|
||||
code=127,
|
||||
)
|
||||
|
||||
|
||||
class SysOps:
|
||||
"""Zugang zu privilegierten Aktionen. In der Entwicklung ein No-Op."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
app_key: str,
|
||||
site_key: str = "",
|
||||
helper: Path | None = None,
|
||||
timeout: int = 120,
|
||||
enabled: bool = True,
|
||||
) -> None:
|
||||
self.app_key = app_key
|
||||
#: Name dieser Installation auf dem Host. Alles, was ausserhalb von
|
||||
#: /srv/<name> liegt, wird darueber benannt -- sonst schreibt eine
|
||||
#: Testinstanz in die Systemdateien der Produktion.
|
||||
self.site_key = site_key or app_key
|
||||
self.helper = Path(helper or os.environ.get("TESM_HELPER", DEFAULT_HELPER))
|
||||
self.timeout = timeout
|
||||
self.enabled = enabled
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Ist der Helfer *vorhanden*? Sagt noch nichts darueber, ob er laeuft."""
|
||||
return self.enabled and self.helper.is_file() and os.name == "posix"
|
||||
|
||||
def selftest(self) -> Result:
|
||||
"""Ruft den Helfer wirklich auf -- die einzige ehrliche Auskunft.
|
||||
|
||||
Dass die Datei existiert, hat nichts zu bedeuten. Auf beiden Testhosts
|
||||
stand monatelang "Helfer erreichbar", waehrend jeder Aufruf an
|
||||
``NoNewPrivileges`` scheiterte und damit jede Systemaktion tot war.
|
||||
Geprueft wird ``service-status nginx``: der Helfer beantwortet das
|
||||
immer, egal wie es dem Dienst geht. ``nginx-test`` waere der falsche
|
||||
Kandidat -- sein Fehlschlag hiesse "Konfiguration kaputt", nicht
|
||||
"Helfer kaputt", und die Seite meldete dann das Falsche.
|
||||
"""
|
||||
if not self.available:
|
||||
return Result(False, "", f"Der Helfer {self.helper} fehlt.", 1)
|
||||
result = self.run("service-status", "nginx")
|
||||
if not result.ok and "no new privileges" in result.output.lower():
|
||||
return Result(
|
||||
False,
|
||||
"",
|
||||
"sudo wird durch 'NoNewPrivileges' der systemd-Unit blockiert. Ohne sudo "
|
||||
"kann der Dienst den Helfer nicht aufrufen -- keine Systemaktion "
|
||||
"funktioniert. In der Unit 'NoNewPrivileges=yes' entfernen und "
|
||||
"'systemctl daemon-reload && systemctl restart' ausfuehren.",
|
||||
result.code,
|
||||
)
|
||||
if not result.ok and "a password is required" in result.output.lower():
|
||||
return Result(
|
||||
False,
|
||||
"",
|
||||
"sudo verlangt ein Passwort. Die sudoers-Regel fuer den Helfer fehlt oder "
|
||||
"greift nicht.",
|
||||
result.code,
|
||||
)
|
||||
return result
|
||||
|
||||
def _sudo_prefix(self) -> list[str]:
|
||||
if os.geteuid() == 0 if hasattr(os, "geteuid") else False:
|
||||
return []
|
||||
sudo = shutil.which("sudo")
|
||||
return [sudo, "-n"] if sudo else []
|
||||
|
||||
def run(self, verb: str, *args: str, stdin: str = "", timeout: int | None = None) -> Result:
|
||||
"""Ruft den Helfer auf. Argumente werden nie durch eine Shell geschickt."""
|
||||
if verb not in HELPER_VERBS:
|
||||
raise ValueError(f"Unbekanntes Helfer-Verb: {verb!r}")
|
||||
if not self.available:
|
||||
return UNAVAILABLE
|
||||
command = [*self._sudo_prefix(), str(self.helper), verb, *[str(a) for a in args]]
|
||||
try:
|
||||
completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell
|
||||
command,
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout or self.timeout,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return Result(False, stderr=f"Zeitlimit ueberschritten ({verb}).", code=124)
|
||||
except OSError as exc:
|
||||
return Result(False, stderr=str(exc), code=126)
|
||||
return Result(
|
||||
ok=completed.returncode == 0,
|
||||
stdout=completed.stdout or "",
|
||||
stderr=completed.stderr or "",
|
||||
code=completed.returncode,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Lesende Abfragen -- ohne Rechte, direkt
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@staticmethod
|
||||
def read_command(command: Sequence[str], *, timeout: int = 10) -> Result:
|
||||
"""Fuehrt eine rein lesende Systemabfrage aus."""
|
||||
if not command:
|
||||
return Result(False, stderr="Leeres Kommando", code=2)
|
||||
binary = shutil.which(command[0])
|
||||
if binary is None:
|
||||
return Result(False, stderr=f"{command[0]} ist nicht installiert.", code=127)
|
||||
try:
|
||||
completed = subprocess.run( # noqa: S603
|
||||
[binary, *command[1:]],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return Result(False, stderr="Zeitlimit ueberschritten.", code=124)
|
||||
except OSError as exc:
|
||||
return Result(False, stderr=str(exc), code=126)
|
||||
return Result(
|
||||
ok=completed.returncode == 0,
|
||||
stdout=completed.stdout or "",
|
||||
stderr=completed.stderr or "",
|
||||
code=completed.returncode,
|
||||
)
|
||||
|
||||
def service_state(self, unit: str) -> dict[str, str]:
|
||||
"""``{"active": ..., "enabled": ..., "installed": ...}`` fuer eine Unit."""
|
||||
installed = shutil.which("systemctl") is not None
|
||||
if not installed:
|
||||
return {"active": "unbekannt", "enabled": "unbekannt", "installed": "nein"}
|
||||
active = self.read_command(["systemctl", "is-active", unit]).stdout.strip() or "unknown"
|
||||
enabled = self.read_command(["systemctl", "is-enabled", unit]).stdout.strip() or "unknown"
|
||||
# ``list-unit-files`` erwartet ein Muster **mit** Endung: fuer
|
||||
# "kea-dhcp4-server" liefert es nichts, fuer
|
||||
# "kea-dhcp4-server.service" die Zeile. Ohne diese Ergaenzung stand
|
||||
# ueberall "installiert: nein" -- auch bei laufendem Dienst, was zum
|
||||
# Installieren-Knopf verleitet hat.
|
||||
pattern = unit if "." in unit.rsplit("/", 1)[-1] else f"{unit}.service"
|
||||
exists = self.read_command(
|
||||
["systemctl", "list-unit-files", pattern, "--no-legend"]
|
||||
).stdout.strip()
|
||||
return {
|
||||
"active": active,
|
||||
"enabled": enabled,
|
||||
"installed": "ja" if exists else "nein",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Bequeme Wrapper
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def service(self, action: str, unit: str) -> Result:
|
||||
verb = f"service-{action}"
|
||||
return self.run(verb, unit)
|
||||
|
||||
def nginx_test(self) -> Result:
|
||||
return self.run("nginx-test")
|
||||
|
||||
def nginx_reload(self) -> Result:
|
||||
return self.run("nginx-reload")
|
||||
|
||||
def write_nginx(self, content: str) -> Result:
|
||||
return self.run("nginx-write", self.site_key, stdin=content)
|
||||
|
||||
def apply_nginx(self, content: str) -> Result:
|
||||
"""Schreiben, pruefen und neu laden -- als eine Operation mit Ruecknahme.
|
||||
|
||||
Bewusst nicht als drei Aufrufe: ``nginx-write`` aktiviert die Site
|
||||
sofort. Zwischen Schreiben und einem fehlgeschlagenen ``nginx -t`` lag
|
||||
sonst ein Zeitfenster, in dem eine kaputte Konfiguration aktiviert war
|
||||
-- der naechste Reload durch irgendwen haette den ganzen Webserver
|
||||
lahmgelegt, auch fuer alle anderen Sites.
|
||||
"""
|
||||
return self.run("nginx-apply", self.site_key, stdin=content, timeout=60)
|
||||
|
||||
def write_netplan(self, content: str) -> Result:
|
||||
return self.run("netplan-write", self.site_key, stdin=content)
|
||||
|
||||
def apply_netplan(self) -> Result:
|
||||
return self.run("netplan-apply", timeout=90)
|
||||
|
||||
def set_hostname(self, hostname: str) -> Result:
|
||||
return self.run("hostname-set", hostname)
|
||||
|
||||
def set_timezone(self, timezone: str) -> Result:
|
||||
return self.run("timezone-set", timezone)
|
||||
|
||||
def write_kea_config(self, content: str) -> Result:
|
||||
return self.run("kea-write", stdin=content)
|
||||
|
||||
def write_logrotate(self, content: str) -> Result:
|
||||
return self.run("logrotate-write", self.site_key, stdin=content)
|
||||
|
||||
def mount_cifs(self, unc: str, target: str, username: str, password: str, domain: str = "") -> Result:
|
||||
# Das Passwort geht ueber stdin an den Helfer, der es in eine
|
||||
# 0600-Credentials-Datei schreibt. Nie als Argument (Prozessliste!),
|
||||
# nie ueber die Umgebung (/proc/<pid>/environ).
|
||||
payload = f"username={username}\npassword={password}\n" + (
|
||||
f"domain={domain}\n" if domain else ""
|
||||
)
|
||||
return self.run("mount-cifs", unc, target, stdin=payload, timeout=45)
|
||||
|
||||
def umount(self, target: str) -> Result:
|
||||
return self.run("umount", target, timeout=45)
|
||||
|
||||
def issue_self_signed(self, common_name: str, *alt_names: str) -> Result:
|
||||
"""Erzeugt ein selbstsigniertes Zertifikat fuer diese Installation.
|
||||
|
||||
Fuer Geraete in geschlossenen Netzen ohne oeffentliche Domain -- dort
|
||||
gibt es kein Let's Encrypt, und ohne Zertifikat gaebe es kein HTTPS.
|
||||
"""
|
||||
return self.run("selfsigned-issue", self.site_key, common_name, *alt_names, timeout=90)
|
||||
|
||||
def apt_install(self, *packages: str) -> Result:
|
||||
return self.run("apt-install", *packages, timeout=600)
|
||||
|
||||
def certbot_issue(self, domain: str, email: str, webroot: str) -> Result:
|
||||
return self.run("certbot-issue", domain, email, webroot, timeout=300)
|
||||
|
||||
def read_privileged_file(self, path: str) -> Result:
|
||||
"""Liest eine Datei, die dem Dienstbenutzer nicht gehoert (z. B. Kea-Log)."""
|
||||
return self.run("file-read", path)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Host-Ebene: Netzwerkeinstellungen und Webserver-Konfiguration.
|
||||
|
||||
Beide Anwendungen brauchen das identisch. Die Namensraeume sind ueber
|
||||
``app_key`` getrennt -- der Vorgaenger schrieb aus beiden Apps in dieselbe
|
||||
netplan-Override-Datei und ueberschrieb sich damit gegenseitig.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import hostnet, webserver
|
||||
|
||||
__all__ = ["hostnet", "webserver"]
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Netzwerkeinstellungen des Hosts -- mit Sicherheitsnetz.
|
||||
|
||||
Eine falsche IP-Adresse auf einem Host, den man nur ueber das Netz erreicht,
|
||||
ist ein Ausflug ins Rechenzentrum. Darum gilt hier dasselbe Muster wie im
|
||||
Vorgaenger, nur sauberer umgesetzt:
|
||||
|
||||
1. Aenderung wird geschrieben und angewandt.
|
||||
2. Ein Zeitgeber laeuft (``REVERT_SECONDS``).
|
||||
3. Bestaetigt der Administrator nicht rechtzeitig **von der neuen Adresse aus**,
|
||||
wird automatisch zurueckgerollt.
|
||||
|
||||
Zwei Aenderungen gegenueber dem Vorgaenger:
|
||||
|
||||
* **Eigener Dateiname pro Anwendung.** Der Vorgaenger schrieb aus *beiden*
|
||||
Anwendungen nach ``/etc/netplan/90-tesm.yaml``. Liefen sie auf demselben Host,
|
||||
ueberschrieb eine die Konfiguration der anderen. Hier heisst die Datei
|
||||
``90-<app_key>.yaml``.
|
||||
* **DNS-Anzeige ueber ``resolvectl``**, nicht ``/etc/resolv.conf`` -- unter
|
||||
systemd-resolved steht dort immer nur der Stub 127.0.0.53.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from ..sysops import SysOps
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REVERT_SECONDS = 45
|
||||
|
||||
BACKEND_NETPLAN = "netplan"
|
||||
BACKEND_NETWORK_MANAGER = "NetworkManager"
|
||||
BACKEND_DHCPCD = "dhcpcd"
|
||||
BACKEND_UNKNOWN = "unbekannt"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class InterfaceInfo:
|
||||
name: str
|
||||
addresses: list[str] = field(default_factory=list)
|
||||
state: str = "unbekannt"
|
||||
mac: str = ""
|
||||
gateway: str = ""
|
||||
dns: list[str] = field(default_factory=list)
|
||||
method: str = "unbekannt"
|
||||
|
||||
|
||||
def detect_backend(sysops: SysOps) -> str:
|
||||
"""Erkennt das aktive Netzwerk-Backend -- rein lesend."""
|
||||
if shutil.which("netplan"):
|
||||
return BACKEND_NETPLAN
|
||||
if sysops.read_command(["systemctl", "is-active", "NetworkManager"]).stdout.strip() == "active":
|
||||
return BACKEND_NETWORK_MANAGER
|
||||
if sysops.read_command(["systemctl", "is-active", "dhcpcd"]).stdout.strip() == "active":
|
||||
return BACKEND_DHCPCD
|
||||
return BACKEND_UNKNOWN
|
||||
|
||||
|
||||
def list_interfaces(sysops: SysOps) -> list[InterfaceInfo]:
|
||||
result: dict[str, InterfaceInfo] = {}
|
||||
addr = sysops.read_command(["ip", "-o", "addr", "show"])
|
||||
for line in addr.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
name = parts[1]
|
||||
entry = result.setdefault(name, InterfaceInfo(name=name))
|
||||
if parts[2] in {"inet", "inet6"}:
|
||||
entry.addresses.append(parts[3])
|
||||
|
||||
link = sysops.read_command(["ip", "-o", "link", "show"])
|
||||
for line in link.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
name = parts[1].rstrip(":").split("@")[0]
|
||||
entry = result.setdefault(name, InterfaceInfo(name=name))
|
||||
if "state" in parts:
|
||||
entry.state = parts[parts.index("state") + 1]
|
||||
if "link/ether" in parts:
|
||||
entry.mac = parts[parts.index("link/ether") + 1]
|
||||
|
||||
routes = sysops.read_command(["ip", "route", "show", "default"])
|
||||
for line in routes.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if "via" in parts and "dev" in parts:
|
||||
gateway = parts[parts.index("via") + 1]
|
||||
device = parts[parts.index("dev") + 1]
|
||||
if device in result:
|
||||
result[device].gateway = gateway
|
||||
|
||||
for entry in result.values():
|
||||
entry.dns = read_dns(sysops, entry.name)
|
||||
entry.method = "unbekannt"
|
||||
|
||||
return [entry for name, entry in sorted(result.items()) if name != "lo"]
|
||||
|
||||
|
||||
def read_dns(sysops: SysOps, interface: str) -> list[str]:
|
||||
"""Liest die DNS-Server per ``resolvectl dns <iface>``.
|
||||
|
||||
Mehrzeilige Ausgabe beachten: IPv6-Fortsetzungszeilen enthalten selbst
|
||||
Doppelpunkte -- ein naives ``split(":")`` zerlegt sie falsch.
|
||||
"""
|
||||
if not shutil.which("resolvectl"):
|
||||
return []
|
||||
result = sysops.read_command(["resolvectl", "dns", interface])
|
||||
if not result.ok:
|
||||
return []
|
||||
servers: list[str] = []
|
||||
for line in result.stdout.splitlines():
|
||||
text = line.strip()
|
||||
if not text:
|
||||
continue
|
||||
if text.lower().startswith("link"):
|
||||
_, _, tail = text.partition(":")
|
||||
text = tail.strip()
|
||||
for token in text.split():
|
||||
try:
|
||||
ipaddress.ip_address(token)
|
||||
except ValueError:
|
||||
continue
|
||||
servers.append(token)
|
||||
return servers
|
||||
|
||||
|
||||
def validate_static(
|
||||
*, address: str, prefix: Any, gateway: str, dns: str
|
||||
) -> tuple[str, int, str, list[str]]:
|
||||
try:
|
||||
prefix_length = int(prefix)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("Die Praefixlaenge muss eine Zahl sein (z. B. 24).") from exc
|
||||
if not 1 <= prefix_length <= 32:
|
||||
raise ValueError("Die Praefixlaenge muss zwischen 1 und 32 liegen.")
|
||||
try:
|
||||
interface_ip = ipaddress.ip_interface(f"{address.strip()}/{prefix_length}")
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{address!r} ist keine gueltige IPv4-Adresse.") from exc
|
||||
|
||||
gateway_clean = (gateway or "").strip()
|
||||
if gateway_clean:
|
||||
try:
|
||||
gateway_ip = ipaddress.ip_address(gateway_clean)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{gateway_clean!r} ist keine gueltige Gateway-Adresse.") from exc
|
||||
if gateway_ip not in interface_ip.network:
|
||||
raise ValueError(
|
||||
f"Das Gateway {gateway_clean} liegt nicht im Netz {interface_ip.network}. "
|
||||
"So waere der Host nach der Aenderung nicht mehr erreichbar."
|
||||
)
|
||||
|
||||
servers: list[str] = []
|
||||
for token in re.split(r"[\s,;]+", dns or ""):
|
||||
if not token:
|
||||
continue
|
||||
try:
|
||||
ipaddress.ip_address(token)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{token!r} ist keine gueltige DNS-Adresse.") from exc
|
||||
servers.append(token)
|
||||
|
||||
return str(interface_ip.ip), prefix_length, gateway_clean, servers
|
||||
|
||||
|
||||
def render_netplan(
|
||||
*,
|
||||
interface: str,
|
||||
dhcp: bool,
|
||||
address: str = "",
|
||||
prefix: int = 24,
|
||||
gateway: str = "",
|
||||
dns: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Erzeugt eine Override-Datei fuer genau **ein** Interface.
|
||||
|
||||
Nur der Block des betroffenen Interfaces steht darin. Ein vollstaendiger
|
||||
Neuaufbau der Netzwerkkonfiguration wuerde andere, unveraenderte Interfaces
|
||||
zuruecksetzen -- ein realer Fehler des Vorgaengerprojekts.
|
||||
"""
|
||||
lines = [
|
||||
"# Von TESM verwaltet. Diese Datei enthaelt ausschliesslich das unten",
|
||||
"# genannte Interface; alle anderen bleiben bei ihrer",
|
||||
"# Distributionskonfiguration.",
|
||||
"network:",
|
||||
" version: 2",
|
||||
" ethernets:",
|
||||
f" {interface}:",
|
||||
]
|
||||
if dhcp:
|
||||
lines.append(" dhcp4: true")
|
||||
else:
|
||||
lines.append(" dhcp4: false")
|
||||
lines.append(f" addresses: [{address}/{prefix}]")
|
||||
if gateway:
|
||||
lines.append(" routes:")
|
||||
lines.append(f" - to: default")
|
||||
lines.append(f" via: {gateway}")
|
||||
if dns:
|
||||
lines.append(" nameservers:")
|
||||
lines.append(f" addresses: [{', '.join(dns)}]")
|
||||
return "\n".join(lines) + "\n"
|
||||
@@ -0,0 +1,379 @@
|
||||
"""nginx-Konfiguration und TLS-Zertifikate.
|
||||
|
||||
Der Vorgaenger schrieb die Site-Datei nur, wenn sie **noch nicht existierte**.
|
||||
Das hat produktiv realen Schaden verursacht: eine sehr alte Datei mit einem
|
||||
falschen ``alias``-Pfad blieb ueber beliebig viele Updates bestehen, obwohl das
|
||||
Repo-Template den Fehler laengst behoben hatte -- Ergebnis: alle statischen
|
||||
Dateien 404, Oberflaeche komplett ohne Design.
|
||||
|
||||
Loesung hier: die Datei wird **immer** aus der Vorlage erzeugt. Alles, was ein
|
||||
Administrator sonst per Hand hineinschrieb (Domain, Ports, Zertifikate, HSTS),
|
||||
ist Einstellung in der Anwendung und fliesst in die Vorlage ein. Damit gibt es
|
||||
keinen Grund mehr, eine erzeugte Datei per Hand zu bearbeiten -- und keinen
|
||||
Grund, sie beim Update in Ruhe zu lassen.
|
||||
|
||||
Zielzustand im Betrieb: Port 80 leitet auf 443 um (ausser dem ACME-Pfad),
|
||||
Port 443 terminiert TLS, HSTS wird gesetzt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..sysops import SysOps
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REVERT_SECONDS = 45
|
||||
DOMAIN_RE = re.compile(r"^(?=.{1,253}$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WebConfig:
|
||||
app_key: str
|
||||
upstream: str
|
||||
static_root: str
|
||||
domain: str = ""
|
||||
https_enabled: bool = False
|
||||
hsts_enabled: bool = False
|
||||
redirect_http: bool = True
|
||||
cert_path: str = ""
|
||||
key_path: str = ""
|
||||
acme_webroot: str = "/var/www/tesm-acme"
|
||||
websocket_path: str = ""
|
||||
max_body_mb: int = 512
|
||||
#: Abweichende Ports braucht nur eine Testinstanz neben einer laufenden
|
||||
#: Produktion. Der Regelfall -- und das Ziel jeder echten Installation --
|
||||
#: ist 80 und 443.
|
||||
http_port: int = 80
|
||||
https_port: int = 443
|
||||
#: ``http2 on;`` gibt es erst ab nginx 1.25.1. Aeltere Versionen kennen nur
|
||||
#: ``listen ... ssl http2;`` und weisen die neue Direktive mit
|
||||
#: "unknown directive" ab -- der ganze Webserver startet dann nicht mehr.
|
||||
#: Auf POETEST (nginx 1.24) genau so passiert. Vorgabe ist deshalb die alte
|
||||
#: Schreibweise: sie funktioniert ueberall und erzeugt auf neuen Versionen
|
||||
#: nur eine Warnung.
|
||||
http2_directive: bool = False
|
||||
|
||||
@property
|
||||
def server_name(self) -> str:
|
||||
return self.domain or "_"
|
||||
|
||||
@property
|
||||
def default_marker(self) -> str:
|
||||
"""``default_server`` fuer die Site ohne eigenen Namen.
|
||||
|
||||
``server_name _;`` ist bei nginx kein Sonderfall -- es ist nur ein Name,
|
||||
auf den kein echter Host-Header passt. Ohne ``default_server`` wird die
|
||||
*zuerst geladene* Site zur Vorgabe, und das ist die alphabetisch erste
|
||||
Datei in ``sites-enabled``. Auf einem Host mit zwei Anwendungen
|
||||
entscheidet dann der Zufall des Dateinamens, wer Anfragen ohne
|
||||
passenden Namen bekommt.
|
||||
|
||||
Mit Domain gibt es keinen Grund, Vorgabe zu sein -- dann greift der
|
||||
Name. Zwei namenlose Sites auf demselben Port weist nginx ab; das ist
|
||||
richtig so und wird beim Uebernehmen sichtbar.
|
||||
"""
|
||||
return "" if self.domain else " default_server"
|
||||
|
||||
@property
|
||||
def https_suffix(self) -> str:
|
||||
"""``:8443`` bei abweichendem Port, sonst leer -- fuer die Umleitung."""
|
||||
return "" if self.https_port == 443 else f":{self.https_port}"
|
||||
|
||||
|
||||
def validate_domain(value: str) -> str:
|
||||
text = (value or "").strip().lower().rstrip(".")
|
||||
if not text:
|
||||
return ""
|
||||
if not DOMAIN_RE.match(text):
|
||||
raise ValueError(f"{text!r} ist kein gueltiger Domainname.")
|
||||
return text
|
||||
|
||||
|
||||
def render(config: WebConfig) -> str:
|
||||
"""Erzeugt die vollstaendige Site-Konfiguration."""
|
||||
body = f"{max(1, config.max_body_mb)}m"
|
||||
# Kopfzeilen und proxy_pass -- ohne Zeitlimits. Die kommen je Block dazu,
|
||||
# denn nginx lehnt eine doppelte Direktive im selben Block ab (auf POETEST
|
||||
# real passiert: der Websocket-Block erbte 120s und setzte noch einmal
|
||||
# 3600s, "proxy_read_timeout directive is duplicate", nginx startete nicht).
|
||||
common_proxy = [
|
||||
# $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf
|
||||
# abweichendem Port schlaegt die Origin-Pruefung der Anwendung sonst fehl.
|
||||
" proxy_set_header Host $http_host;",
|
||||
" proxy_set_header X-Real-IP $remote_addr;",
|
||||
" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;",
|
||||
" proxy_set_header X-Forwarded-Proto $scheme;",
|
||||
" proxy_set_header X-Forwarded-Host $http_host;",
|
||||
" proxy_set_header X-Forwarded-Port $server_port;",
|
||||
f" proxy_pass http://{config.upstream};",
|
||||
" proxy_http_version 1.1;",
|
||||
]
|
||||
|
||||
lines: list[str] = [
|
||||
f"# Von TESM erzeugt ({config.app_key}). Aenderungen bitte in der Anwendung",
|
||||
"# vornehmen -- diese Datei wird bei jeder Uebernahme neu geschrieben.",
|
||||
"",
|
||||
]
|
||||
|
||||
# ---- Port 80 --------------------------------------------------------- #
|
||||
lines += [
|
||||
"server {",
|
||||
f" listen {config.http_port}{config.default_marker};",
|
||||
f" listen [::]:{config.http_port}{config.default_marker};",
|
||||
f" server_name {config.server_name};",
|
||||
"",
|
||||
" # ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar --",
|
||||
" # sonst kann Let's Encrypt nie erneuern.",
|
||||
" location /.well-known/acme-challenge/ {",
|
||||
f" alias {config.acme_webroot.rstrip('/')}/.well-known/acme-challenge/;",
|
||||
" default_type \"text/plain\";",
|
||||
" }",
|
||||
"",
|
||||
]
|
||||
if config.https_enabled and config.redirect_http:
|
||||
lines += [
|
||||
" location / {",
|
||||
f" return 308 https://$host{config.https_suffix}$request_uri;",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
else:
|
||||
lines += _site_body(config, common_proxy, body, tls=False)
|
||||
lines += ["}", ""]
|
||||
|
||||
# ---- Port 443 -------------------------------------------------------- #
|
||||
if config.https_enabled:
|
||||
lines += [
|
||||
"server {",
|
||||
f" listen {config.https_port} ssl"
|
||||
f"{config.default_marker}{'' if config.http2_directive else ' http2'};",
|
||||
f" listen [::]:{config.https_port} ssl"
|
||||
f"{config.default_marker}{'' if config.http2_directive else ' http2'};",
|
||||
*([" http2 on;"] if config.http2_directive else []),
|
||||
f" server_name {config.server_name};",
|
||||
"",
|
||||
f" ssl_certificate {config.cert_path};",
|
||||
f" ssl_certificate_key {config.key_path};",
|
||||
" ssl_protocols TLSv1.2 TLSv1.3;",
|
||||
" ssl_prefer_server_ciphers off;",
|
||||
" ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:"
|
||||
"ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:"
|
||||
"ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;",
|
||||
" ssl_session_cache shared:TESM:10m;",
|
||||
" ssl_session_timeout 1d;",
|
||||
" ssl_session_tickets off;",
|
||||
]
|
||||
# OCSP-Stapling nur mit einem Zertifikat einer echten CA. Ein
|
||||
# selbstsigniertes hat keinen OCSP-Responder; nginx warnt dann bei
|
||||
# jedem Start ("ssl_stapling ignored") -- eine Warnung, die niemand
|
||||
# abstellen kann und die echte Warnungen untergehen laesst.
|
||||
if config.domain:
|
||||
lines += [" ssl_stapling on;", " ssl_stapling_verify on;"]
|
||||
lines += [""]
|
||||
if config.hsts_enabled:
|
||||
lines.append(
|
||||
' add_header Strict-Transport-Security "max-age=15552000; '
|
||||
'includeSubDomains" always;'
|
||||
)
|
||||
lines.append("")
|
||||
lines += _site_body(config, common_proxy, body, tls=True)
|
||||
lines += ["}", ""]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _site_body(config: WebConfig, common_proxy: list[str], body: str, *, tls: bool) -> list[str]:
|
||||
lines = [
|
||||
f" client_max_body_size {body};",
|
||||
" # Groesser als das Limit der Anwendung, damit deren eigene, erklaerende",
|
||||
" # Fehlerseite gewinnt statt der generischen 413 von nginx.",
|
||||
"",
|
||||
" location /assets/ {",
|
||||
f" alias {config.static_root.rstrip('/')}/;",
|
||||
" expires 7d;",
|
||||
" access_log off;",
|
||||
" add_header Cache-Control \"public, max-age=604800, immutable\";",
|
||||
" }",
|
||||
"",
|
||||
" location /core-assets/ {",
|
||||
f" alias {config.static_root.rstrip('/')}-core/;",
|
||||
" expires 7d;",
|
||||
" access_log off;",
|
||||
" }",
|
||||
"",
|
||||
]
|
||||
if config.websocket_path:
|
||||
# Eine SSH-Sitzung im Browser steht minutenlang still, ohne tot zu sein.
|
||||
lines += [
|
||||
f" location {config.websocket_path} {{",
|
||||
" proxy_set_header Upgrade $http_upgrade;",
|
||||
' proxy_set_header Connection "upgrade";',
|
||||
*common_proxy,
|
||||
" proxy_read_timeout 3600s;",
|
||||
" proxy_send_timeout 3600s;",
|
||||
" proxy_buffering off;",
|
||||
" }",
|
||||
"",
|
||||
]
|
||||
lines += [
|
||||
" location / {",
|
||||
*common_proxy,
|
||||
" proxy_read_timeout 120s;",
|
||||
" proxy_send_timeout 120s;",
|
||||
" }",
|
||||
]
|
||||
return lines
|
||||
|
||||
|
||||
def apply_config(sysops: SysOps, content: str) -> dict[str, Any]:
|
||||
"""Schreibt die Konfiguration, prueft sie und laedt nginx neu.
|
||||
|
||||
Ein einziger Helferaufruf, nicht drei. Der Helfer nimmt die Aenderung bei
|
||||
einem fehlgeschlagenen ``nginx -t`` selbst zurueck; danach ist der Zustand
|
||||
auf der Platte garantiert derselbe wie vorher.
|
||||
|
||||
Das war ein echter Fehler in der ersten Fassung: ``nginx-write`` verlinkt
|
||||
die Datei sofort nach ``sites-enabled``. Wurde sie anschliessend abgelehnt,
|
||||
blieb sie aktiviert liegen -- der laufende nginx merkte nichts, aber der
|
||||
naechste Reload (auch fuer eine ganz andere Site) scheiterte. Aus einer
|
||||
abgelehnten Eingabe wurde ein Ausfall des Webservers.
|
||||
"""
|
||||
result = sysops.apply_nginx(content)
|
||||
if not result.ok:
|
||||
return {
|
||||
"ok": False,
|
||||
"message": "nginx hat die Konfiguration abgelehnt -- der alte Stand gilt weiter.",
|
||||
"detail": result.output,
|
||||
}
|
||||
return {"ok": True, "message": "Konfiguration uebernommen.", "detail": result.output}
|
||||
|
||||
|
||||
def request_certificate(
|
||||
sysops: SysOps, *, domain: str, email: str, webroot: str
|
||||
) -> dict[str, Any]:
|
||||
"""Fordert ein Let's-Encrypt-Zertifikat per Webroot an.
|
||||
|
||||
Bewusst ``certbot certonly --webroot`` und nicht das nginx-Plugin: das
|
||||
Plugin bearbeitet die Site-Datei selbst und wuerde die von der Anwendung
|
||||
erzeugte Konfiguration verbiegen.
|
||||
"""
|
||||
if not domain:
|
||||
return {"ok": False, "message": "Es ist keine Domain konfiguriert."}
|
||||
if not email:
|
||||
return {"ok": False, "message": "Fuer Let's Encrypt ist eine E-Mail-Adresse noetig."}
|
||||
result = sysops.certbot_issue(domain, email, webroot)
|
||||
if not result.ok:
|
||||
return {"ok": False, "message": "certbot ist fehlgeschlagen.", "detail": result.output}
|
||||
return {
|
||||
"ok": True,
|
||||
"message": f"Zertifikat fuer {domain} ausgestellt.",
|
||||
"detail": result.output,
|
||||
"cert_path": f"/etc/letsencrypt/live/{domain}/fullchain.pem",
|
||||
"key_path": f"/etc/letsencrypt/live/{domain}/privkey.pem",
|
||||
}
|
||||
|
||||
|
||||
def create_self_signed(
|
||||
sysops: SysOps, *, common_name: str, alt_names: Sequence[str] = ()
|
||||
) -> dict[str, Any]:
|
||||
"""Erzeugt ein selbstsigniertes Zertifikat fuer diese Installation.
|
||||
|
||||
Der Regelfall im Zielumfeld ist ein Geraet in einem geschlossenen Netz:
|
||||
keine oeffentliche Domain, kein Let's Encrypt, oft nicht einmal ein
|
||||
aufloesbarer Name -- nur eine IP. Ohne diesen Weg gaebe es dort ueberhaupt
|
||||
kein HTTPS, und Port 443 bliebe leer.
|
||||
|
||||
Der Browser zeigt eine Warnung; das ist der ehrliche Preis. Verschluesselt
|
||||
ist die Verbindung trotzdem, und das Sitzungscookie darf endlich
|
||||
``Secure`` tragen.
|
||||
"""
|
||||
name = (common_name or "").strip().lower().rstrip(".")
|
||||
if not name:
|
||||
return {"ok": False, "message": "Es fehlt ein Name fuer das Zertifikat."}
|
||||
if not re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", name):
|
||||
return {"ok": False, "message": f"{name!r} ist als Zertifikatsname nicht zulaessig."}
|
||||
|
||||
cleaned: list[str] = []
|
||||
for entry in alt_names:
|
||||
value = (entry or "").strip().lower().rstrip(".")
|
||||
if not value or value == name or value in cleaned:
|
||||
continue
|
||||
if re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", value):
|
||||
cleaned.append(value)
|
||||
|
||||
result = sysops.issue_self_signed(name, *cleaned)
|
||||
if not result.ok:
|
||||
return {
|
||||
"ok": False,
|
||||
"message": "Das Zertifikat konnte nicht erzeugt werden.",
|
||||
"detail": result.output,
|
||||
}
|
||||
paths = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||
if len(paths) != 2:
|
||||
return {"ok": False, "message": "Unerwartete Antwort des Helfers.", "detail": result.output}
|
||||
return {
|
||||
"ok": True,
|
||||
"message": (
|
||||
f"Selbstsigniertes Zertifikat fuer {name} erzeugt. Browser zeigen dafuer eine "
|
||||
"Warnung -- fuer ein oeffentlich erreichbares System bitte Let's Encrypt nutzen."
|
||||
),
|
||||
"cert_path": paths[0],
|
||||
"key_path": paths[1],
|
||||
}
|
||||
|
||||
|
||||
def local_names() -> list[str]:
|
||||
"""Hostname und eigene IP-Adressen -- Vorschlag fuer die Namen im Zertifikat."""
|
||||
names: list[str] = []
|
||||
try:
|
||||
hostname = socket.gethostname().strip().lower()
|
||||
except OSError: # pragma: no cover - defensiv
|
||||
hostname = ""
|
||||
for candidate in (hostname, f"{hostname}.local" if hostname else ""):
|
||||
if candidate and candidate not in names:
|
||||
names.append(candidate)
|
||||
try:
|
||||
for info in socket.getaddrinfo(hostname or "localhost", None, socket.AF_INET):
|
||||
address = info[4][0]
|
||||
if address and not address.startswith("127.") and address not in names:
|
||||
names.append(address)
|
||||
except OSError: # pragma: no cover - defensiv
|
||||
pass
|
||||
return names
|
||||
|
||||
|
||||
def certificate_info(cert_path: str) -> dict[str, Any]:
|
||||
"""Liest Aussteller und Ablauf eines Zertifikats -- ohne externes Werkzeug."""
|
||||
path = Path(cert_path or "")
|
||||
try:
|
||||
if not path.is_file():
|
||||
return {"present": False}
|
||||
from cryptography import x509
|
||||
|
||||
certificate = x509.load_pem_x509_certificate(path.read_bytes())
|
||||
except PermissionError:
|
||||
# Kein Grund, die ganze Seite abzuwerfen: das Zertifikat kann
|
||||
# ausserhalb der Reichweite des Dienstbenutzers liegen. nginx liest es
|
||||
# trotzdem -- nur die Anzeige von Aussteller und Ablauf entfaellt.
|
||||
return {"present": True, "error": "Die Anwendung darf diese Datei nicht lesen."}
|
||||
except OSError as exc:
|
||||
return {"present": True, "error": str(exc)}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"present": True, "error": str(exc)}
|
||||
return {
|
||||
"present": True,
|
||||
"subject": certificate.subject.rfc4514_string(),
|
||||
"issuer": certificate.issuer.rfc4514_string(),
|
||||
"not_valid_after": certificate.not_valid_after_utc.isoformat(timespec="seconds"),
|
||||
"not_valid_before": certificate.not_valid_before_utc.isoformat(timespec="seconds"),
|
||||
"serial": format(certificate.serial_number, "x"),
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{# Auswahlfeld fuer eine Verzeichnisgruppe.
|
||||
|
||||
Der DN einer AD-Gruppe ist nichts, was man abtippt: er ist lang, enthaelt
|
||||
Kommas und Leerzeichen und ein Tippfehler faellt erst bei der naechsten
|
||||
Anmeldung auf -- als stille Nicht-Zuordnung. Deshalb gesucht statt getippt.
|
||||
|
||||
Das Suchergebnis kommt aus dem Verzeichnis selbst; die Felder werden dabei
|
||||
beide gefuellt (Anzeigename und DN). Getippt werden darf trotzdem: bei einer
|
||||
Umgebung ohne Dienstkonto ist die Suche nicht moeglich, und dann ist ein
|
||||
eingefuegter DN besser als gar keine Zuordnung.
|
||||
|
||||
Erwartet: name_field, dn_field (Feldnamen), scope (eindeutiges Kuerzel),
|
||||
optional name_value / dn_value und required.
|
||||
#}
|
||||
{% macro group_picker(name_field, dn_field, scope, name_value='', dn_value='',
|
||||
required=False, label='Verzeichnisgruppe') %}
|
||||
<div class="field field--full" data-behavior="group-picker"
|
||||
data-search-url="{{ url_for('hostsettings.directory_group_search') }}"
|
||||
data-name-target="#gp-name-{{ scope }}" data-dn-target="#gp-dn-{{ scope }}">
|
||||
<span class="field__label">{{ label }}</span>
|
||||
<div class="row row--tight">
|
||||
<input class="input" type="search" id="gp-query-{{ scope }}"
|
||||
placeholder="Gruppe suchen, z. B. GG_TESM ..." autocomplete="off"
|
||||
data-group-query>
|
||||
<button class="btn btn--sm" type="button" data-group-search>
|
||||
{{ icon('search', size=14) }} Suchen
|
||||
</button>
|
||||
</div>
|
||||
<div class="fs-xs faint mt-1" data-group-status></div>
|
||||
<div class="stack stack--tight mt-2" data-group-results hidden></div>
|
||||
|
||||
<div class="form-grid mt-2">
|
||||
<label class="field">
|
||||
<span class="field__label">Name</span>
|
||||
<input class="input" type="text" id="gp-name-{{ scope }}" name="{{ name_field }}"
|
||||
value="{{ name_value }}" placeholder="GG_TESM_Betrieb" autocomplete="off">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">DN{% if required %} <span class="req">*</span>{% endif %}</span>
|
||||
<input class="input input--mono fs-xs" type="text" id="gp-dn-{{ scope }}"
|
||||
name="{{ dn_field }}" value="{{ dn_value }}" {% if required %}required{% endif %}
|
||||
placeholder="CN=GG_TESM_Betrieb,OU=Gruppen,DC=firma,DC=local" autocomplete="off">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% if lines %}
|
||||
<pre class="log-view">{% for line in lines %}{{ line }}
|
||||
{% endfor %}</pre>
|
||||
{% else %}
|
||||
<div class="empty"><div class="empty__title">Keine Zeilen</div>
|
||||
<p class="muted">Es liegen (noch) keine passenden Protokollzeilen vor.</p></div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,121 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, alert, pill, empty_state %}
|
||||
|
||||
{% block page_title %}Mein Konto{% endblock %}
|
||||
{% block page_sub %}{{ current_user.username }} · {{ current_user.role_label }}{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
<a class="btn btn--sm" href="{{ url_for('account.security') }}">{{ icon('lock', size=15) }} Sicherheit</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="grid grid--split">
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('users', size=17) }} Stammdaten</div></div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('account.update_profile') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
{% if current_user.is_directory_account %}
|
||||
{{ alert('Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder
|
||||
Anmeldung uebernommen.', 'info') }}
|
||||
{% endif %}
|
||||
<div class="form-grid">
|
||||
{{ field('first_name', 'Vorname', value=profile.first_name,
|
||||
disabled=current_user.is_directory_account) }}
|
||||
{{ field('last_name', 'Nachname', value=profile.last_name,
|
||||
disabled=current_user.is_directory_account) }}
|
||||
{{ field('email', 'E-Mail', value=profile.email, type='email',
|
||||
disabled=current_user.is_directory_account, extra_class='field--full') }}
|
||||
</div>
|
||||
</div>
|
||||
{% if not current_user.is_directory_account %}
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Meine wirksamen Rechte</div>
|
||||
<div class="card__sub">
|
||||
Vereinigung aller Gruppen, nach Anwendung der Bereichsschalter
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
{% if current_user.is_admin %}
|
||||
{{ alert('Sie sind Administrator und duerfen unabhaengig von Gruppen alles.', 'warning') }}
|
||||
{% elif permissions %}
|
||||
<div class="row row--tight">
|
||||
{% for permission in permissions %}
|
||||
<span class="badge" title="{{ permission }}">{{ tree.label_for(permission) }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Keine Rechte', 'Sie sehen nur diese Kontoseite.', 'lock') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('grid', size=17) }} Profilbild</div></div>
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
<div class="row">
|
||||
{% if profile.avatar_filename %}
|
||||
<img class="avatar avatar--lg"
|
||||
src="{{ url_for('account.avatar', filename=profile.avatar_filename) }}" alt="">
|
||||
{% else %}
|
||||
<span class="avatar avatar--lg" aria-hidden="true">{{ current_user.initials }}</span>
|
||||
{% endif %}
|
||||
<div class="fs-sm muted">PNG, JPG, WEBP oder GIF, maximal 2 MB.</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('account.upload_avatar') }}"
|
||||
enctype="multipart/form-data" class="stack stack--tight" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input class="input" type="file" name="avatar" accept="image/png,image/jpeg,image/webp,image/gif" required>
|
||||
<button class="btn btn--sm" type="submit">Hochladen</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('shield-users', size=17) }} Gruppen</div></div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
{% if groups %}
|
||||
<div class="row row--tight">
|
||||
{% for group in groups %}{{ pill(group.name, 'accent' if group.is_default else '') }}{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="faint fs-sm">Keiner Gruppe zugeordnet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Konto</div></div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Benutzername</dt><dd class="mono">{{ current_user.username }}</dd>
|
||||
<dt>Quelle</dt><dd>{{ 'Verzeichnisdienst' if current_user.is_directory_account else 'Lokal' }}</dd>
|
||||
<dt>Zwei-Faktor</dt>
|
||||
<dd>{% if profile.totp_secret %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('inaktiv', 'warning') }}{% endif %}</dd>
|
||||
<dt>Angelegt</dt><dd>{{ profile.created_at | dt }}</dd>
|
||||
<dt>Letzte Anmeldung</dt><dd>{{ profile.last_login_at | dt }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,181 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, alert, pill, empty_state %}
|
||||
|
||||
{% block page_title %}Sicherheit{% endblock %}
|
||||
{% block page_sub %}Passwort, zweiter Faktor und aktive Sitzungen{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('account.overview') }}">Mein Konto</a></span><span>Sicherheit</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if recovery_codes %}
|
||||
<section class="card card--accent">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('key', size=17) }} Wiederherstellungscodes</div>
|
||||
<div class="card__sub">
|
||||
Jetzt sichern – sie werden nur dieses eine Mal angezeigt. Jeder Code
|
||||
funktioniert genau einmal.
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn--sm" type="button" data-copy="#recovery-codes"
|
||||
data-copy-message="Codes kopiert.">{{ icon('copy', size=14) }} Kopieren</button>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<pre class="code-block" id="recovery-codes">{% for code in recovery_codes %}{{ code }}
|
||||
{% endfor %}</pre>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid--split">
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Passwort aendern</div></div>
|
||||
</div>
|
||||
{% if current_user.is_directory_account %}
|
||||
<div class="card__body">
|
||||
{{ alert('Ihr Passwort wird im Verzeichnisdienst gepflegt und dort geaendert.', 'info') }}
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('account.change_password') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
{{ field('current_password', 'Aktuelles Passwort', type='password', required=True,
|
||||
autocomplete='current-password') }}
|
||||
{{ field('new_password', 'Neues Passwort', type='password', required=True,
|
||||
autocomplete='new-password',
|
||||
hint_text='Mindestens ' ~ policy.min_length ~ ' Zeichen und zwei verschiedene Zeichenarten.') }}
|
||||
{{ field('repeat_password', 'Neues Passwort wiederholen', type='password', required=True,
|
||||
autocomplete='new-password') }}
|
||||
{{ alert('Nach der Aenderung werden alle anderen Sitzungen beendet.', 'info') }}
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Passwort aendern</button></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Zwei-Faktor-Authentisierung
|
||||
{% if totp_enabled %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('inaktiv', 'warning') }}{% endif %}
|
||||
</div>
|
||||
<div class="card__sub">Zeitbasierte Einmalcodes (TOTP), kompatibel mit jeder Authenticator-App</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
{% if pending_secret %}
|
||||
{{ alert('Richten Sie den Eintrag in Ihrer App ein und bestaetigen Sie mit einem aktuellen Code. Erst dann wird die Anmeldung umgestellt.', 'warning') }}
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Geheimnis</dt>
|
||||
<dd><span class="mono" id="totp-secret">{{ pending_secret_grouped }}</span>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-copy="#totp-secret">
|
||||
{{ icon('copy', size=13) }}</button></dd>
|
||||
<dt>Konto</dt><dd>{{ current_user.username }}</dd>
|
||||
<dt>Verfahren</dt><dd>TOTP, SHA-1, 6 Stellen, 30 Sekunden</dd>
|
||||
</dl>
|
||||
<details class="mt-2">
|
||||
<summary class="fs-sm muted">otpauth-URI anzeigen</summary>
|
||||
<pre class="code-block code-block--wrap mt-2">{{ pending_uri }}</pre>
|
||||
</details>
|
||||
<form method="post" action="{{ url_for('account.totp_confirm') }}" class="row row--tight">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input class="input input--mono" type="text" name="code" required maxlength="6"
|
||||
inputmode="numeric" placeholder="123456" style="max-width:130px;text-align:center">
|
||||
<button class="btn btn--primary" type="submit">Bestaetigen</button>
|
||||
</form>
|
||||
{% elif totp_enabled %}
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Aktiviert am</dt><dd>{{ profile.totp_confirmed_at | dt }}</dd>
|
||||
<dt>Unbenutzte Wiederherstellungscodes</dt>
|
||||
<dd>{{ recovery_remaining }}{% if recovery_remaining <= 2 %}
|
||||
<span class="warning-text"> – bitte erneuern</span>{% endif %}</dd>
|
||||
</dl>
|
||||
<div class="row row--tight">
|
||||
<form method="post" action="{{ url_for('account.recovery_codes') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit"
|
||||
data-confirm="Neue Codes erzeugen? Die bisherigen verlieren ihre Gueltigkeit."
|
||||
data-confirm-title="Codes erneuern" data-confirm-ok="Erneuern" data-confirm-style="primary">
|
||||
Codes erneuern</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('account.totp_disable') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Zwei-Faktor-Authentisierung wirklich deaktivieren?"
|
||||
data-confirm-title="Zwei-Faktor deaktivieren" data-confirm-ok="Deaktivieren">
|
||||
Deaktivieren</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if require_totp_for_admins and current_user.is_admin %}
|
||||
{{ alert('In dieser Installation ist ein zweiter Faktor fuer Administratoren verpflichtend. Bitte jetzt einrichten.', 'danger') }}
|
||||
{% endif %}
|
||||
<p class="muted fs-sm">
|
||||
Ein zweiter Faktor schuetzt Ihr Konto auch dann, wenn Ihr Passwort bekannt wird.
|
||||
</p>
|
||||
<form method="post" action="{{ url_for('account.totp_start') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--primary" type="submit">Einrichtung starten</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('activity', size=17) }} Meine Sitzungen</div>
|
||||
<div class="card__sub">{{ sessions | length }} aktiv</div>
|
||||
</div>
|
||||
{% if sessions | length > 1 %}
|
||||
<form method="post" action="{{ url_for('account.revoke_other_sessions') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Alle anderen Sitzungen beenden?"
|
||||
data-confirm-title="Sitzungen beenden" data-confirm-ok="Beenden">
|
||||
Andere beenden</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card__body--flush">
|
||||
{% if sessions %}
|
||||
<table class="table table--compact">
|
||||
<tbody>
|
||||
{% for entry in sessions %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-600 fs-sm">
|
||||
{{ entry.ip or 'unbekannte Adresse' }}
|
||||
{% if entry.id == current_sid %}{{ pill('diese Sitzung', 'online') }}{% endif %}
|
||||
</div>
|
||||
<div class="fs-xs faint truncate" style="max-width:280px">{{ entry.user_agent or '-' }}</div>
|
||||
<div class="fs-xs faint">
|
||||
aktiv <span data-relative-time="{{ entry.last_seen_at }}">{{ entry.last_seen_at | dt }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if entry.id != current_sid %}
|
||||
<form method="post" action="{{ url_for('account.revoke_session', sid=entry.id) }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost" type="submit">Beenden</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
{{ empty_state('Keine Sitzungen', '', 'activity') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,190 @@
|
||||
{# ==========================================================================
|
||||
Grundgeruest aller angemeldeten Seiten.
|
||||
Bloecke: extra_head, page_title, page_sub, breadcrumb, topbar_extra,
|
||||
content, dialogs, scripts
|
||||
========================================================================== #}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>{% block title %}{{ self.page_title() }} · {{ app_name }}{% endblock %}</title>
|
||||
<link rel="icon" href="{{ asset_url('img/favicon.svg') }}" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="{{ asset_url('css/base.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset_url('css/components.css') }}">
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body data-csrf="{{ csrf_token() }}" data-sidebar="full">
|
||||
|
||||
<div class="app-shell">
|
||||
<div class="sidebar__scrim" aria-hidden="true"></div>
|
||||
|
||||
<aside class="sidebar" aria-label="Hauptnavigation">
|
||||
<div class="sidebar__brand">
|
||||
{# Die Wortmarke traegt den Namen selbst -- ein zweiter Textblock daneben
|
||||
waere eine Doppelung. Eine einzige Zeichnung, deren Farben aus den
|
||||
Themen-Token kommen: zwei per Media Query versteckte SVG waeren bei
|
||||
"System" und beim Umschalten von Hand falsch. #}
|
||||
<span class="sidebar__logo" role="img" aria-label="{{ app_name }}">
|
||||
<svg viewBox="0 0 312 112" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="298" cy="66" r="14" fill="var(--logo-mark)" opacity="0.2"/>
|
||||
<polyline points="0,66 90,66 100,66 108,18 116,114 124,66 134,66 200,66 210,66
|
||||
218,32 226,100 234,66 244,66 300,66"
|
||||
fill="none" stroke="var(--logo-mark)" stroke-width="3.2"
|
||||
stroke-linecap="round" stroke-linejoin="round" opacity="0.88"/>
|
||||
<text x="10" y="94" font-family="var(--font-mono)" font-weight="800"
|
||||
font-size="82" letter-spacing="2" fill="var(--logo-text)">TESM</text>
|
||||
<circle cx="298" cy="66" r="8" fill="var(--logo-mark)"/>
|
||||
</svg>
|
||||
<span class="sidebar__edition">{{ app_edition }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar__nav">
|
||||
{% for item in nav_items %}
|
||||
{% if item.children %}
|
||||
<details class="nav-tree" data-persist="nav-{{ item.key }}"
|
||||
{% if item.children | selectattr('endpoint', 'equalto', request.endpoint) | list %}open{% endif %}>
|
||||
<summary>
|
||||
{{ icon(item.icon, size=18, extra_class='nav-item__icon') }}
|
||||
<span class="nav-item__label">{{ item.label }}</span>
|
||||
{{ icon('chevron-right', size=15, extra_class='nav-tree__chevron') }}
|
||||
</summary>
|
||||
<div class="nav-tree__children">
|
||||
{% for child in item.children %}
|
||||
<a class="nav-item" href="{{ url_for(child.endpoint) }}"
|
||||
{% if child.endpoint == request.endpoint %}aria-current="page"{% endif %}>
|
||||
{{ icon(child.icon, size=17, extra_class='nav-item__icon') }}
|
||||
<span class="nav-item__label">{{ child.label }}</span>
|
||||
{% if child.badge %}<span class="nav-item__badge">{{ child.badge }}</span>{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<a class="nav-item" href="{{ url_for(item.endpoint) }}"
|
||||
{% if item.endpoint == request.endpoint %}aria-current="page"{% endif %}
|
||||
title="{{ item.hint or item.label }}">
|
||||
{{ icon(item.icon, size=18, extra_class='nav-item__icon') }}
|
||||
<span class="nav-item__label">{{ item.label }}</span>
|
||||
{% if item.badge %}<span class="nav-item__badge">{{ item.badge }}</span>{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
<div class="sidebar__footer">
|
||||
{% if current_user.is_authenticated %}
|
||||
<a class="user-chip" href="{{ url_for('account.overview') }}">
|
||||
{% if current_user.avatar_filename %}
|
||||
<img class="avatar" src="{{ url_for('account.avatar', filename=current_user.avatar_filename) }}" alt="">
|
||||
{% else %}
|
||||
<span class="avatar" aria-hidden="true">{{ current_user.initials }}</span>
|
||||
{% endif %}
|
||||
<span class="user-chip__meta">
|
||||
<span class="user-chip__name">{{ current_user.display_name }}</span>
|
||||
<span class="user-chip__role">{{ current_user.role_label }}</span>
|
||||
</span>
|
||||
</a>
|
||||
<div class="row row--tight mt-2">
|
||||
<div class="btn-group" data-behavior="theme-toggle" role="group" aria-label="Farbschema">
|
||||
<button type="button" class="btn btn--sm btn--ghost" data-theme-option="system"
|
||||
title="Systemeinstellung folgen" aria-pressed="true">{{ icon('monitor', size=14) }}</button>
|
||||
<button type="button" class="btn btn--sm btn--ghost" data-theme-option="light"
|
||||
title="Helles Design" aria-pressed="false">{{ icon('sun', size=14) }}</button>
|
||||
<button type="button" class="btn btn--sm btn--ghost" data-theme-option="dark"
|
||||
title="Dunkles Design" aria-pressed="false">{{ icon('moon', size=14) }}</button>
|
||||
</div>
|
||||
<span class="flex-1"></span>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost" type="submit" title="Abmelden">
|
||||
{{ icon('log-out', size=15) }}<span class="sidebar__actions-label">Abmelden</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<a class="btn btn--primary btn--block" href="{{ url_for('auth.login') }}">Anmelden</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<button class="btn btn--icon btn--ghost" type="button" data-behavior="sidebar-toggle"
|
||||
aria-label="Navigation ein-/ausklappen">{{ icon('menu', size=18) }}</button>
|
||||
|
||||
<div class="topbar__heading">
|
||||
{% block breadcrumb %}{% endblock %}
|
||||
<div class="topbar__title">{% block page_title %}{{ app_name }}{% endblock %}</div>
|
||||
{% set sub %}{% block page_sub %}{% endblock %}{% endset %}
|
||||
{% if sub | trim %}<div class="topbar__sub">{{ sub }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="topbar__right">
|
||||
{% block topbar_extra %}{% endblock %}
|
||||
{% for pill in status_pills %}
|
||||
<span class="status-pill {{ pill.css_class }}" {% if pill.title %}title="{{ pill.title }}"{% endif %}>
|
||||
{% if pill.dot %}<span class="status-pill__dot {{ pill.dot_class }}"></span>{% endif %}
|
||||
{{ pill.label }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
<button class="btn btn--icon btn--ghost hide-sm" type="button" data-behavior="open-palette"
|
||||
aria-label="Schnellsuche (Strg+K)" title="Schnellsuche (Strg+K)">{{ icon('search', size=17) }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content" id="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="page-footer">
|
||||
<span>{{ app_name }} {{ app_version }} · © {{ current_year }} WiS GmbH</span>
|
||||
<span class="row row--tight">
|
||||
{% if migration_warning %}
|
||||
<a class="danger-text" href="{{ url_for('diagnostics.overview') }}">Schemawarnung</a>
|
||||
{% endif %}
|
||||
<span class="faint">Angemeldet als {{ current_user.username or 'Gast' }}</span>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# -- Flash-Meldungen: als JSON, von app.js in Toasts verwandelt ----------- #}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
<script type="application/json" id="flashed-messages">{{ messages | tojson }}</script>
|
||||
{% endwith %}
|
||||
|
||||
{# -- Befehlspalette ------------------------------------------------------- #}
|
||||
<dialog class="palette" id="command-palette" aria-label="Schnellsuche">
|
||||
<input class="palette__input" type="search" placeholder="Seite oder Aktion suchen ..."
|
||||
aria-label="Suchbegriff" autocomplete="off">
|
||||
<div class="palette__list"></div>
|
||||
<div class="palette__hint">
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd> waehlen</span>
|
||||
<span><kbd>Enter</kbd> oeffnen</span>
|
||||
<span><kbd>Esc</kbd> schliessen</span>
|
||||
</div>
|
||||
</dialog>
|
||||
<script type="application/json" id="palette-data">{{ palette_entries | tojson }}</script>
|
||||
|
||||
{# -- Bestaetigungsdialog (ersetzt window.confirm) ------------------------- #}
|
||||
<dialog class="modal modal--narrow" id="confirm-dialog" aria-labelledby="confirm-title">
|
||||
<div class="modal__header">
|
||||
<div class="modal__title" id="confirm-title" data-confirm-title>Bitte bestaetigen</div>
|
||||
</div>
|
||||
<div class="modal__body"><p data-confirm-text></p></div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--danger" type="button" data-confirm-ok>Fortfahren</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
{% block dialogs %}{% endblock %}
|
||||
|
||||
<script type="module" src="{{ asset_url('js/app.js') }}" nonce="{{ csp_nonce }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,190 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import alert, pill, stat %}
|
||||
|
||||
{% block page_title %}Diagnose{% endblock %}
|
||||
{% block page_sub %}Schemastand, Protokollintegritaet, Schluessel und Systemzugriff{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="grid grid--4">
|
||||
{{ stat('Benutzer', counts['Benutzer']) }}
|
||||
{{ stat('Gruppen', counts['Gruppen']) }}
|
||||
{{ stat('Aktive Sitzungen', counts['Aktive Sitzungen']) }}
|
||||
{{ stat('Protokollzeilen', counts['Protokollzeilen']) }}
|
||||
</div>
|
||||
|
||||
<div class="grid grid--2">
|
||||
<section class="card {{ 'card--danger' if not chain.ok }}">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('shield', size=17) }} Aenderungsprotokoll
|
||||
{% if chain.ok %}{{ pill('unversehrt', 'online') }}{% else %}{{ pill('Kette gebrochen', 'danger') }}{% endif %}
|
||||
</div>
|
||||
<div class="card__sub">{{ chain.checked }} Zeilen geprueft (Hash-Kette)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
{% if chain.ok %}
|
||||
<p class="muted fs-sm">
|
||||
Jede Zeile bindet den Hash ihrer Vorgaengerin ein. Eine nachtraeglich entfernte oder
|
||||
geaenderte Zeile wuerde hier auffallen.
|
||||
</p>
|
||||
{% else %}
|
||||
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
|
||||
'Manipulation oder Datenverlust') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card {{ 'card--danger' if key_problems }}">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('key', size=17) }} Schluessel und Dateirechte
|
||||
{% if key_problems %}{{ pill('Problem', 'danger') }}{% else %}{{ pill('in Ordnung', 'online') }}{% endif %}
|
||||
</div>
|
||||
<div class="card__sub">Aktiver Datenschluessel: {{ keystore_active }} (vorhanden: {{ keystore_keys | join(', ') }})</div>
|
||||
</div>
|
||||
{% if current_user.is_admin %}
|
||||
<form method="post" action="{{ url_for('diagnostics.rotate_keys') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit"
|
||||
data-confirm="Neuen Datenschluessel anlegen und gespeicherte Geheimnisse neu verschluesseln? Der alte Schluessel bleibt zum Lesen erhalten."
|
||||
data-confirm-title="Schluessel rotieren" data-confirm-ok="Rotieren" data-confirm-style="primary">
|
||||
{{ icon('refresh', size=14) }} Rotieren
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card__body">
|
||||
{% if key_problems %}
|
||||
<ul class="stack stack--tight fs-sm danger-text">
|
||||
{% for problem in key_problems %}<li>{{ problem }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted fs-sm">
|
||||
Alle Schluesseldateien liegen mit Modus 0600. Feldverschluesselung ist AES-256-GCM mit
|
||||
rotierbarem Schluesselbund; jedes Chiffrat ist an seinen Verwendungszweck gebunden.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('database', size=17) }} Schemastand</div>
|
||||
<div class="card__sub">
|
||||
{{ db_path }} · {{ db_size | bytes }} · {{ migrations | length }} angewendete Migrationen
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact" data-behavior="sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">Komponente</th>
|
||||
<th class="sortable numeric">Version</th>
|
||||
<th class="sortable">Migration</th>
|
||||
<th>Pruefsumme</th>
|
||||
<th class="sortable">Angewendet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in migrations %}
|
||||
<tr>
|
||||
<td class="fw-600">{{ entry.component }}</td>
|
||||
<td class="numeric">{{ entry.version }}</td>
|
||||
<td>{{ entry.name }}</td>
|
||||
<td class="mono fs-xs faint">{{ entry.checksum }}</td>
|
||||
<td class="fs-sm">{{ entry.applied_at | dt }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid grid--2">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('server', size=17) }} Systemzugriff
|
||||
{% if not sysops_available %}{{ pill('nicht verfuegbar', 'warning') }}
|
||||
{% elif sysops_selftest and sysops_selftest.ok %}{{ pill('Helfer antwortet', 'online') }}
|
||||
{% else %}{{ pill('Helfer antwortet nicht', 'danger') }}{% endif %}
|
||||
</div>
|
||||
<div class="card__sub">{{ sysops_helper or 'kein Pfad konfiguriert' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<p class="muted fs-sm">
|
||||
Der Webprozess laeuft unprivilegiert. Alles, was Root-Rechte braucht, geht durch genau
|
||||
ein Helferskript mit Positivliste – nicht durch die Anwendung selbst.
|
||||
</p>
|
||||
{% if not sysops_available %}
|
||||
{{ alert('Ohne Helfer sind Systemaktionen (Dienste, Netzwerk, Zertifikate) deaktiviert.
|
||||
Alles andere funktioniert normal -- das ist der erwartete Zustand in einer
|
||||
Entwicklungsumgebung.', 'info') }}
|
||||
{% elif sysops_selftest and not sysops_selftest.ok %}
|
||||
{# Geprueft wird ein echter Aufruf, nicht die blosse Existenz der Datei.
|
||||
Genau daran lag es einmal: die Seite meldete "erreichbar", waehrend
|
||||
jeder Aufruf scheiterte. #}
|
||||
{{ alert(sysops_selftest.message or 'Der Helfer laesst sich nicht aufrufen.',
|
||||
'danger', 'Systemaktionen funktionieren nicht') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Laufzeit</div></div>
|
||||
{% if current_user.is_admin %}
|
||||
<form method="post" action="{{ url_for('diagnostics.housekeeping') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Aufraeumen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<dl class="kv fs-sm">
|
||||
{% for label, value in runtime.items() %}
|
||||
<dt>{{ label }}</dt><dd>{{ value }}</dd>
|
||||
{% endfor %}
|
||||
<dt>Protokollverzeichnis</dt><dd class="mono">{{ log_dir }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if license_state %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('key', size=17) }} Lizenzzustand
|
||||
{% if license_state.valid and license_state.activated %}{{ pill('aktiv', 'online') }}
|
||||
{% elif license_state.valid %}{{ pill('nicht aktiviert', 'warning') }}
|
||||
{% else %}{{ pill('ungueltig', 'danger') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Datei vorhanden</dt><dd>{{ license_state.present | yes_no }}</dd>
|
||||
<dt>Signatur gueltig</dt><dd>{{ license_state.valid | yes_no }}</dd>
|
||||
<dt>Aussteller gepinnt</dt><dd>{{ license_state.issuer_pinned | yes_no }}</dd>
|
||||
<dt>Rolle</dt><dd>{{ license_state.role }}</dd>
|
||||
<dt>Module</dt><dd>{{ license_state.modules | sort | join(', ') or '–' | safe }}</dd>
|
||||
<dt>Letzter Heartbeat</dt><dd>{{ license_state.last_heartbeat_at | dt }}</dd>
|
||||
{% if license_state.error %}<dt>Fehler</dt><dd class="danger-text">{{ license_state.error }}</dd>{% endif %}
|
||||
{% if license_state.warnings %}
|
||||
<dt>Hinweise</dt><dd class="warning-text">{{ license_state.warnings | join('; ') }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "tesm_core/standalone.html" %}
|
||||
{% block heading %}{{ code }} – {{ name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="alert alert--{{ 'danger' if code >= 500 else 'warning' }}">
|
||||
{{ icon('alert', size=18) }}
|
||||
<div class="alert__body">{{ description }}</div>
|
||||
</div>
|
||||
{% if request_id %}
|
||||
<p class="fs-xs faint center">Vorgangsnummer fuer die Fehlersuche: <span class="mono">{{ request_id }}</span></p>
|
||||
{% endif %}
|
||||
<div class="row row--end">
|
||||
<a class="btn" href="{{ url_for('auth.login') if code in (401, 403) and not current_user.is_authenticated else back_url }}">
|
||||
{{ 'Zur Anmeldung' if code in (401, 403) and not current_user.is_authenticated else 'Zurueck' }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,122 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, textarea_field, pill, alert, permission_matrix, empty_state %}
|
||||
|
||||
{% block page_title %}{{ group.name }}{% endblock %}
|
||||
{% block page_sub %}{{ group.member_count }} Mitglied{{ '' if group.member_count == 1 else 'er' }} · {{ group.permissions | length }} Rechte{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('admin.groups') }}">Gruppen</a></span>
|
||||
<span>{{ group.name }}</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set locked = group.is_system and not unlocked %}
|
||||
|
||||
{% if group.is_system %}
|
||||
{% if locked %}
|
||||
<div class="alert alert--warning">
|
||||
{{ icon('lock', size=18) }}
|
||||
<div class="alert__body">
|
||||
<div class="alert__title">Systemgruppe</div>
|
||||
Name und Rechte sind gesperrt. Anders als im Vorgaenger werden die Rechte
|
||||
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurueckgesetzt –
|
||||
Anpassungen bleiben erhalten.
|
||||
{% if current_user.has('groups.edit') %}
|
||||
<a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, freischalten=1) }}">
|
||||
{{ icon('unlock', size=14) }} Rechte freischalten
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ alert('Die Rechte dieser Systemgruppe sind zur Bearbeitung freigeschaltet.', 'warning') }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('admin.group_update', group_id=group.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
{% if not locked %}<input type="hidden" name="permissions_submitted" value="1">{% endif %}
|
||||
{% if unlocked %}<input type="hidden" name="unlock_system" value="1">{% endif %}
|
||||
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('settings', size=17) }} Stammdaten</div>
|
||||
</div>
|
||||
{% if group.preset_key and current_user.is_admin %}
|
||||
<button class="btn btn--sm" type="submit"
|
||||
formaction="{{ url_for('admin.group_apply_preset', group_id=group.id) }}"
|
||||
data-confirm="Rechte dieser Gruppe auf die Vorlage zuruecksetzen? Bestehende Anpassungen gehen verloren."
|
||||
data-confirm-title="Vorlage anwenden" data-confirm-ok="Zuruecksetzen" data-confirm-style="primary">
|
||||
{{ icon('refresh', size=14) }} Vorlage anwenden
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
{{ field('name', 'Name', value=group.name, required=True, disabled=group.is_system,
|
||||
hint_text='Systemgruppen koennen nicht umbenannt werden.' if group.is_system else '') }}
|
||||
{{ textarea_field('description', 'Beschreibung', value=group.description, rows=2,
|
||||
extra_class='field--full') }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Rechtematrix</div>
|
||||
<div class="card__sub">
|
||||
Der Bereichsschalter ist ein Hauptschalter: ohne ihn greift kein Recht des Bereichs.
|
||||
Jede Aktion setzt „Ansehen“ derselben Zeile voraus.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
{{ permission_matrix(tree, action_meta, state, disabled=locked or not can_edit) }}
|
||||
</div>
|
||||
{% if can_edit and not locked %}
|
||||
<div class="card__footer">
|
||||
<a class="btn" href="{{ url_for('admin.groups') }}">Abbrechen</a>
|
||||
<button class="btn btn--primary" type="submit">Rechte speichern</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('users', size=17) }} Mitglieder</div>
|
||||
<div class="card__sub">Zuordnung erfolgt in der Benutzerverwaltung</div>
|
||||
</div>
|
||||
{% if current_user.has('users.view') %}
|
||||
<a class="btn btn--sm" href="{{ url_for('admin.users') }}">Benutzerverwaltung</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if members %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Benutzer</th><th>Name</th><th>Quelle</th></tr></thead>
|
||||
<tbody>
|
||||
{% for member in members %}
|
||||
<tr>
|
||||
<td class="fw-600">{{ member.username }}
|
||||
{% if member.is_admin %}{{ pill('Admin', 'accent') }}{% endif %}
|
||||
</td>
|
||||
<td>{{ (member.first_name ~ ' ' ~ member.last_name) | trim or '–' | safe }}</td>
|
||||
<td>{{ 'Verzeichnis' if member.auth_source == 'ldap' else 'Lokal' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Keine Mitglieder', 'Diese Gruppe ist noch niemandem zugeordnet.', 'users') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, textarea_field, pill, modal, alert, permission_matrix, empty_state %}
|
||||
|
||||
{% block page_title %}Gruppen und Rechte{% endblock %}
|
||||
{% block page_sub %}{{ groups | length }} Gruppen · Rechte addieren sich ueber alle Gruppen eines Benutzers{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{% if current_user.has('groups.create') %}
|
||||
<button class="btn btn--primary btn--sm" type="button" data-dialog-open="group-new">
|
||||
{{ icon('plus', size=15) }} Gruppe anlegen
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Wer selbst ein Recht nicht besitzt, kann es auch nicht vergeben. Diese Regel gilt fuer
|
||||
alle ausser echten Administratoren und verhindert, dass sich ein Gruppenverwalter ueber eine
|
||||
neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
|
||||
|
||||
<div class="grid grid--2">
|
||||
{% for group in groups %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('shield-users', size=17) }}
|
||||
<a href="{{ url_for('admin.group_detail', group_id=group.id) }}">{{ group.name }}</a>
|
||||
{% if group.is_system %}{{ pill('System', 'accent') }}{% endif %}
|
||||
{% if group.is_default %}{{ pill('Standard', 'info') }}{% endif %}
|
||||
</div>
|
||||
<div class="card__sub">
|
||||
{{ group.description or 'Ohne Beschreibung' }} ·
|
||||
{{ group.member_count }} Mitglied{{ '' if group.member_count == 1 else 'er' }}
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.has('groups.delete') and not group.is_system %}
|
||||
<form method="post" action="{{ url_for('admin.group_delete', group_id=group.id) }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Gruppe {{ group.name }} loeschen? Sie landet im Papierkorb; Mitglieder verlieren die zugehoerigen Rechte."
|
||||
data-confirm-title="Gruppe loeschen" data-confirm-ok="Loeschen"
|
||||
aria-label="Loeschen">{{ icon('trash', size=15) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card__body">
|
||||
{% if summaries[group.id] %}
|
||||
<ul class="stack stack--tight fs-sm">
|
||||
{% for line in summaries[group.id] %}<li class="muted">{{ line }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="faint fs-sm">Keine Rechte vergeben – Mitglieder sehen nur ihr eigenes Konto.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<a class="btn btn--sm" href="{{ url_for('admin.group_detail', group_id=group.id) }}">
|
||||
Rechte bearbeiten
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if not groups %}
|
||||
{{ empty_state('Keine Gruppen', 'Ohne Gruppen hat niemand ausser Administratoren Rechte.', 'shield-users') }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block dialogs %}
|
||||
{% if current_user.has('groups.create') %}
|
||||
{% call modal('group-new', 'Gruppe anlegen', 'Rechte koennen anschliessend feiner eingestellt werden', 'wide') %}
|
||||
<form method="post" action="{{ url_for('admin.group_create') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">
|
||||
{{ field('name', 'Name', required=True) }}
|
||||
{{ textarea_field('description', 'Beschreibung', rows=2) }}
|
||||
{{ permission_matrix(tree, action_meta, {}, disabled=False) }}
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--primary" type="submit">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% extends "tesm_core/standalone.html" %}
|
||||
{% block heading %}Anmelden{% endblock %}
|
||||
{% block subheading %}{{ app_name }} – {{ app_edition }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post" class="stack" data-no-guard autocomplete="on">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<label class="field">
|
||||
<span class="field__label">Benutzername</span>
|
||||
<input class="input" type="text" name="username" value="{{ username }}" required autofocus
|
||||
autocomplete="username" autocapitalize="none" spellcheck="false">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Passwort</span>
|
||||
<input class="input" type="password" name="password" required autocomplete="current-password">
|
||||
</label>
|
||||
<button class="btn btn--primary btn--lg btn--block" type="submit" data-busy-label="Anmelden ...">
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
{% if directory_enabled %}
|
||||
<p class="fs-xs faint center">
|
||||
Verzeichniskonten melden sich mit ihrem gewohnten Anmeldenamen und Netzwerkpasswort an.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if public_dashboard_url %}
|
||||
<p class="center fs-sm"><a href="{{ public_dashboard_url }}">Zur oeffentlichen Statusuebersicht</a></p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% block page_title %}Archiv {{ day }}{% endblock %}
|
||||
{% block page_sub %}{{ rows | length }} archivierte Aenderungen{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb"><span><a href="{{ url_for('logs.history') }}">Verlauf</a></span><span>{{ day }}</span></nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact" data-behavior="sortable">
|
||||
<thead><tr>
|
||||
<th class="sortable">Zeit</th><th class="sortable">Benutzer</th>
|
||||
<th class="sortable">Aktion</th><th>Ziel</th><th>Detail</th><th>Hash</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td class="fs-sm nowrap">{{ row.ts | dt }}</td>
|
||||
<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="mono fs-xs faint">{{ row.hash[:12] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,94 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import pill, empty_state, alert %}
|
||||
|
||||
{% block page_title %}Aenderungsprotokoll{% endblock %}
|
||||
{% block page_sub %}{{ total }} Eintraege · Hash-Kette {{ 'unversehrt' if chain.ok else 'gebrochen' }}{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{% if current_user.has('logs_audit.export') %}
|
||||
<a class="btn btn--sm" href="{{ url_for('logs.audit_export') }}">{{ icon('download', size=15) }} CSV</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if not chain.ok %}
|
||||
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
|
||||
'Das Protokoll wurde veraendert') }}
|
||||
{% else %}
|
||||
{{ alert('Jeder Eintrag bindet den Hash seines Vorgaengers ein. Eine nachtraeglich entfernte
|
||||
oder geaenderte Zeile faellt bei der Pruefung auf -- ' ~ chain.checked ~ ' Zeilen geprueft.',
|
||||
'success') }}
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<form method="get" class="table-toolbar" data-no-guard>
|
||||
<span class="search-input">
|
||||
{{ icon('search', size=15) }}
|
||||
<input class="input" type="search" name="q" value="{{ filters.q }}" placeholder="Ziel, Detail, Aktion ...">
|
||||
</span>
|
||||
<select class="select" name="aktion" style="width:auto;max-width:240px">
|
||||
<option value="">alle Aktionen</option>
|
||||
{% for action in actions %}
|
||||
<option value="{{ action }}" {% if action == filters.aktion %}selected{% endif %}>{{ action }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select class="select" name="stufe" style="width:auto">
|
||||
<option value="">alle Stufen</option>
|
||||
{% for level in ['info', 'notice', 'warning', 'critical'] %}
|
||||
<option value="{{ level }}" {% if level == filters.stufe %}selected{% endif %}>{{ level }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input class="input" type="text" name="benutzer" value="{{ filters.benutzer }}"
|
||||
placeholder="Benutzer" style="width:150px">
|
||||
<button class="btn btn--sm" type="submit">Filtern</button>
|
||||
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zuruecksetzen</a>
|
||||
</form>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact" data-behavior="sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">Zeit</th>
|
||||
<th class="sortable">Benutzer</th>
|
||||
<th class="sortable">Aktion</th>
|
||||
<th>Ziel</th>
|
||||
<th>Detail</th>
|
||||
<th class="sortable">Stufe</th>
|
||||
<th>Herkunft</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td class="fs-sm nowrap" data-sort-value="{{ row.ts }}">{{ row.ts | dt }}</td>
|
||||
<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>
|
||||
{% if row.severity == 'critical' %}{{ pill('kritisch', 'danger') }}
|
||||
{% elif row.severity == 'warning' %}{{ pill('Warnung', 'warning') }}
|
||||
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'info') }}
|
||||
{% else %}{{ pill('Info', '') }}{% endif %}
|
||||
</td>
|
||||
<td class="mono fs-xs faint">{{ row.ip or '–' | safe }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not rows %}{{ empty_state('Keine Eintraege', 'Zu diesem Filter gibt es nichts.', 'history') }}{% endif %}
|
||||
|
||||
<div class="card__footer" style="justify-content:space-between">
|
||||
<span class="fs-sm muted">Seite {{ page }}</span>
|
||||
<span class="row row--tight">
|
||||
{% if page > 1 %}
|
||||
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page-1, **filters) }}">Zurueck</a>
|
||||
{% endif %}
|
||||
{% if rows | length == per_page %}
|
||||
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page+1, **filters) }}">Weiter</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% block page_title %}{{ name }}{% endblock %}
|
||||
{% block page_sub %}Rotierte Protokolldatei · letzte {{ lines | length }} Zeilen{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb"><span><a href="{{ url_for('logs.history') }}">Verlauf</a></span><span>{{ name }}</span></nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div class="card__header"><div class="flex-1"><div class="card__title">{{ icon('file-text', size=17) }} Inhalt</div></div></div>
|
||||
{% include "tesm_core/_log_lines.html" %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,84 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import empty_state, alert %}
|
||||
|
||||
{% block page_title %}Protokollverlauf{% endblock %}
|
||||
{% block page_sub %}{{ rotated | length }} rotierte Dateien · {{ archived | length }} archivierte Audit-Tage{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Rotiert wird per logrotate mit copytruncate -- keiner der schreibenden Prozesse
|
||||
(Ueberwachung, Web, Kea) kann ein Reopen-Signal verarbeiten.', 'info') }}
|
||||
|
||||
<div class="grid grid--2">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('file-text', size=17) }} Rotierte Dateien</div>
|
||||
<div class="card__sub">{{ log_dir }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if rotated %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Datei</th><th class="numeric">Groesse</th><th>Geaendert</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in rotated %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('logs.history_file', name=entry.name) }}">{{ entry.name }}</a></td>
|
||||
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
|
||||
<td class="fs-sm">{{ entry.modified | int | string }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Noch nichts rotiert', 'logrotate hat bisher keine Kopie angelegt.', 'clock') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('history', size=17) }} Archivierte Aenderungen</div>
|
||||
<div class="card__sub">Aus der Datenbank ausgelagerte Kalendertage</div>
|
||||
</div>
|
||||
{% if archived and current_user.has('logs_history.export') %}
|
||||
<form method="post" action="{{ url_for('logs.history_export') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit">{{ icon('download', size=14) }} Als ZIP</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if archived %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Tag</th><th class="numeric">Groesse</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in archived %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('logs.history_archive', day=entry.day) }}">{{ entry.day }}</a></td>
|
||||
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
|
||||
<td class="fs-sm faint">{{ entry.modified | dt }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if current_user.has('logs_history.delete') %}
|
||||
<div class="card__footer">
|
||||
<form method="post" action="{{ url_for('logs.history_purge') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Alle {{ archived | length }} Archivdateien endgueltig vom Server loeschen? Bitte vorher als ZIP sichern."
|
||||
data-confirm-title="Archiv loeschen" data-confirm-ok="Loeschen">
|
||||
Archiv vom Server entfernen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ empty_state('Kein Archiv', 'Es wurden noch keine Audit-Tage ausgelagert.', 'history') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% block page_title %}Laufendes Protokoll{% endblock %}
|
||||
{% block page_sub %}{{ path }} · letzte {{ line_count }} Zeilen{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
<form method="get" class="row row--tight" data-no-guard>
|
||||
<span class="search-input">
|
||||
{{ icon('search', size=15) }}
|
||||
<input class="input" type="search" name="q" value="{{ query }}" placeholder="Filter ...">
|
||||
</span>
|
||||
<select class="select" name="lines" style="width:auto">
|
||||
{% for option in [200, 400, 1000, 2000] %}
|
||||
<option value="{{ option }}" {% if option == line_count %}selected{% endif %}>{{ option }} Zeilen</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn--sm" type="submit">Anwenden</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('terminal', size=17) }} Erreichbarkeit und PoE</div>
|
||||
<div class="card__sub">Aktualisiert sich automatisch alle 10 Sekunden</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="live-log" data-refresh-url="{{ url_for('logs.live_fragment', lines=line_count, q=query) }}"
|
||||
data-refresh-interval="10000">
|
||||
{% include "tesm_core/_log_lines.html" %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,222 @@
|
||||
{# ==========================================================================
|
||||
Wiederverwendbare Bausteine. Import in Templates:
|
||||
{% from "tesm_core/macros.html" import field, card_header, pill, ... %}
|
||||
========================================================================== #}
|
||||
|
||||
{% macro csrf(token) -%}
|
||||
<input type="hidden" name="csrf_token" value="{{ token }}">
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro hint(text) -%}
|
||||
{% if text %}<span class="hint-icon" tabindex="0" title="{{ text }}" aria-label="Hinweis: {{ text }}">?</span>{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro field(name, label, value='', type='text', required=False, hint_text='', placeholder='',
|
||||
autocomplete='', readonly=False, disabled=False, error='', extra_class='', attrs='') -%}
|
||||
<label class="field {{ extra_class }}">
|
||||
<span class="field__label">
|
||||
{{ label }}{% if required %}<span class="req" aria-hidden="true">*</span>{% endif %}
|
||||
{{ hint(hint_text) }}
|
||||
</span>
|
||||
<input class="input" type="{{ type }}" name="{{ name }}" value="{{ value if value is not none else '' }}"
|
||||
{% if required %}required{% endif %}
|
||||
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
|
||||
{% if autocomplete %}autocomplete="{{ autocomplete }}"{% endif %}
|
||||
{% if readonly %}readonly{% endif %}
|
||||
{% if disabled %}disabled{% endif %}
|
||||
{% if error %}aria-invalid="true"{% endif %} {{ attrs | safe }}>
|
||||
{% if error %}<span class="field__error">{{ error }}</span>{% endif %}
|
||||
</label>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro select_field(name, label, options, selected='', hint_text='', required=False, disabled=False, extra_class='', attrs='') -%}
|
||||
<label class="field {{ extra_class }}">
|
||||
<span class="field__label">
|
||||
{{ label }}{% if required %}<span class="req" aria-hidden="true">*</span>{% endif %}
|
||||
{{ hint(hint_text) }}
|
||||
</span>
|
||||
<select class="select" name="{{ name }}" {% if required %}required{% endif %} {% if disabled %}disabled{% endif %} {{ attrs | safe }}>
|
||||
{% for value, text in options %}
|
||||
<option value="{{ value }}" {% if value|string == selected|string %}selected{% endif %}>{{ text }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro textarea_field(name, label, value='', rows=4, hint_text='', placeholder='', extra_class='', mono=False) -%}
|
||||
<label class="field {{ extra_class }}">
|
||||
<span class="field__label">{{ label }}{{ hint(hint_text) }}</span>
|
||||
<textarea class="textarea {% if mono %}input--mono{% endif %}" name="{{ name }}" rows="{{ rows }}"
|
||||
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}>{{ value or '' }}</textarea>
|
||||
</label>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro checkbox(name, label, checked=False, value='1', hint_text='', disabled=False) -%}
|
||||
<label class="check {% if disabled %}check--disabled{% endif %}">
|
||||
<input type="checkbox" name="{{ name }}" value="{{ value }}"
|
||||
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
|
||||
<span>{{ label }} {{ hint(hint_text) }}</span>
|
||||
</label>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro switch(name, label, checked=False, value='1', disabled=False) -%}
|
||||
<label class="switch">
|
||||
<input type="checkbox" name="{{ name }}" value="{{ value }}"
|
||||
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
|
||||
<span>{{ label }}</span>
|
||||
</label>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro pill(label, kind='') -%}
|
||||
<span class="pill {% if kind %}pill--{{ kind }}{% endif %}">{{ label }}</span>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro badge(label) -%}<span class="badge">{{ label }}</span>{%- endmacro %}
|
||||
|
||||
{% macro card_header(title, sub='', icon_name='') -%}
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{% if icon_name %}{{ icon(icon_name, size=17) }}{% endif %}{{ title }}</div>
|
||||
{% if sub %}<div class="card__sub">{{ sub }}</div>{% endif %}
|
||||
</div>
|
||||
{{ caller() if caller is defined else '' }}
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro empty_state(title, text='', icon_name='info', node_id='') -%}
|
||||
<div class="empty" {% if node_id %}id="{{ node_id }}"{% endif %}>
|
||||
{{ icon(icon_name, size=34) }}
|
||||
<div class="empty__title">{{ title }}</div>
|
||||
{% if text %}<p class="muted">{{ text }}</p>{% endif %}
|
||||
{{ caller() if caller is defined else '' }}
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro alert(text, kind='info', title='') -%}
|
||||
<div class="alert alert--{{ kind }}">
|
||||
{{ icon({'info': 'info', 'success': 'check', 'warning': 'alert', 'danger': 'alert'}[kind], size=18) }}
|
||||
<div class="alert__body">
|
||||
{% if title %}<div class="alert__title">{{ title }}</div>{% endif %}
|
||||
{{ text }}
|
||||
</div>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro search_box(target_id, placeholder='Suchen ...', count_id='', empty_id='') -%}
|
||||
<span class="search-input">
|
||||
{{ icon('search', size=15) }}
|
||||
<input class="input" type="search" placeholder="{{ placeholder }}"
|
||||
data-filter-target="{{ target_id }}"
|
||||
{% if count_id %}data-filter-count="{{ count_id }}"{% endif %}
|
||||
{% if empty_id %}data-filter-empty="{{ empty_id }}"{% endif %}
|
||||
aria-label="{{ placeholder }}">
|
||||
</span>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro stat(label, value, kind='', hint_text='', href='', filter_value='') -%}
|
||||
{% if href %}
|
||||
<a class="stat {% if kind %}stat--{{ kind }}{% endif %}" href="{{ href }}">
|
||||
{% elif filter_value %}
|
||||
<button type="button" class="stat {% if kind %}stat--{{ kind }}{% endif %}"
|
||||
data-filter-value="{{ filter_value }}" aria-pressed="false">
|
||||
{% else %}
|
||||
<div class="stat {% if kind %}stat--{{ kind }}{% endif %}">
|
||||
{% endif %}
|
||||
<span class="stat__label">{{ label }}</span>
|
||||
<span class="stat__value">{{ value }}</span>
|
||||
{% if hint_text %}<span class="stat__hint">{{ hint_text }}</span>{% endif %}
|
||||
{% if href %}</a>{% elif filter_value %}</button>{% else %}</div>{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro modal(node_id, title, sub='', size='') -%}
|
||||
<dialog class="modal {% if size %}modal--{{ size }}{% endif %}" id="{{ node_id }}"
|
||||
aria-labelledby="{{ node_id }}-title">
|
||||
<div class="modal__header">
|
||||
<div class="flex-1">
|
||||
<div class="modal__title" id="{{ node_id }}-title">{{ title }}</div>
|
||||
{% if sub %}<div class="modal__sub">{{ sub }}</div>{% endif %}
|
||||
</div>
|
||||
<button class="btn btn--icon btn--ghost btn--sm" type="button" data-dialog-close
|
||||
aria-label="Schliessen">{{ icon('x', size=15) }}</button>
|
||||
</div>
|
||||
{{ caller() }}
|
||||
</dialog>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro kv_row(label, value) -%}
|
||||
<dt>{{ label }}</dt><dd>{{ value if value not in (None, '') else '–' | safe }}</dd>
|
||||
{%- endmacro %}
|
||||
|
||||
{#- Rechtematrix: einmal geschrieben, in beiden Anwendungen benutzt -#}
|
||||
{% macro permission_matrix(tree, action_meta, state, disabled=False, field_prefix='permissions') -%}
|
||||
<div class="table-wrap">
|
||||
<table class="matrix" data-behavior="permission-matrix">
|
||||
{% for area in tree.areas %}
|
||||
{% set columns = area.columns() %}
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="min-width:240px">{{ area.label }}</th>
|
||||
{% for action in columns %}
|
||||
<th title="{{ action_meta[action][1] }} – {{ action_meta[action][2] }}">
|
||||
{{ action_meta[action][0] }}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="matrix__area">
|
||||
<td class="matrix__killswitch">
|
||||
<label class="check">
|
||||
<input type="checkbox" name="{{ field_prefix }}" value="{{ area.view_key }}"
|
||||
data-area-switch="{{ area.key }}"
|
||||
{% if state.get(area.key, {}).get('view') %}checked{% endif %}
|
||||
{% if disabled %}disabled{% endif %}>
|
||||
<span>Zugang zum Bereich {{ area.label }}{% if area.hint %} {{ hint(area.hint) }}{% endif %}</span>
|
||||
</label>
|
||||
</td>
|
||||
{% for action in columns %}<td></td>{% endfor %}
|
||||
</tr>
|
||||
{% for resource in area.resources %}
|
||||
<tr data-area="{{ area.key }}">
|
||||
<td>
|
||||
<span class="matrix__resource-label">
|
||||
{{ resource.label }}
|
||||
{% if resource.hint %}<small>{{ resource.hint }}</small>{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
{% for action in columns %}
|
||||
<td>
|
||||
{% if resource.has(action) %}
|
||||
<label class="check" title="{{ resource.action_hints.get(action) or action_meta[action][2] }}">
|
||||
<input type="checkbox" name="{{ field_prefix }}"
|
||||
value="{{ resource.permission(action) }}"
|
||||
data-action="{{ action.value }}"
|
||||
{% if state.get(resource.key, {}).get(action.value) %}checked{% endif %}
|
||||
{% if disabled %}disabled{% endif %}>
|
||||
<span class="sr-only">{{ resource.label }} {{ action_meta[action][1] }}</span>
|
||||
</label>
|
||||
{% else %}
|
||||
{# Bewusst *kein* Eingabefeld: ein deaktiviertes Kaestchen hat das
|
||||
JS beim Einschalten des Bereichs wieder freigegeben, es liess
|
||||
sich anhaken, hatte aber kein name-Attribut -- es sah erteilt
|
||||
aus und war nach dem Speichern weg. Was es nicht gibt, ist
|
||||
hier deshalb gar kein Bedienelement. #}
|
||||
<span class="matrix__na" title="Fuer diese Zeile nicht vorgesehen."></span>
|
||||
<span class="sr-only">nicht vorgesehen</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
<div class="matrix__legend mt-3">
|
||||
{% for action, meta in action_meta.items() %}
|
||||
<span><b>{{ meta[0] }}</b> {{ meta[1] }} – {{ meta[2] }}</span>
|
||||
{% endfor %}
|
||||
<span><span class="matrix__na"></span> Fuer diese Zeile nicht vorgesehen –
|
||||
die Spalte gehoert zu einer anderen Zeile desselben Bereichs.</span>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "tesm_core/standalone.html" %}
|
||||
{% block heading %}Bitte bestaetigen{% endblock %}
|
||||
{% block subheading %}Diese Aktion verlangt eine erneute Eingabe Ihres Passworts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="alert alert--info">
|
||||
{{ icon('info', size=18) }}
|
||||
<div class="alert__body">
|
||||
Sie greifen auf Geheimnisse oder einen Datenexport zu. Ein uebernommenes
|
||||
Sitzungscookie allein reicht dafuer nicht aus – die Bestaetigung gilt
|
||||
danach {{ reauth_minutes }} Minuten.
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" class="stack" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<label class="field">
|
||||
<span class="field__label">Passwort von {{ current_user.username }}</span>
|
||||
<input class="input" type="password" name="password" required autofocus autocomplete="current-password">
|
||||
</label>
|
||||
<div class="row row--end">
|
||||
<a class="btn" href="{{ cancel_url }}">Abbrechen</a>
|
||||
<button class="btn btn--primary" type="submit">Bestaetigen</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "tesm_core/standalone.html" %}
|
||||
{% block heading %}Zweiter Faktor{% endblock %}
|
||||
{% block subheading %}Code aus Ihrer Authenticator-App{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post" class="stack" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<label class="field">
|
||||
<span class="field__label">6-stelliger Code</span>
|
||||
<input class="input input--mono" type="text" name="code" required autofocus
|
||||
inputmode="numeric" autocomplete="one-time-code" maxlength="14"
|
||||
placeholder="123456" style="letter-spacing:.22em;text-align:center;font-size:1.15rem">
|
||||
<span class="field__hint">
|
||||
Alternativ koennen Sie einen Ihrer Wiederherstellungscodes eingeben.
|
||||
</span>
|
||||
</label>
|
||||
<button class="btn btn--primary btn--lg btn--block" type="submit">Bestaetigen</button>
|
||||
</form>
|
||||
<p class="center fs-sm"><a href="{{ url_for('auth.logout_get') }}">Abbrechen und zurueck zur Anmeldung</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,55 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import empty_state, alert, search_box %}
|
||||
|
||||
{% block page_title %}Aktive Sitzungen{% endblock %}
|
||||
{% block page_sub %}{{ sessions | length }} laufende Sitzungen · serverseitig gespeichert und jederzeit widerrufbar{% endblock %}
|
||||
|
||||
{% block topbar_extra %}{{ search_box('sessions-table', 'Benutzer oder IP ...') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Sitzungen liegen in der Datenbank, nicht im Cookie. Ein widerrufener Eintrag ist sofort
|
||||
ungueltig -- auch wenn das Cookie noch existiert.', 'info') }}
|
||||
|
||||
<section class="card">
|
||||
<div class="table-wrap">
|
||||
<table class="table" id="sessions-table" data-behavior="sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">Benutzer</th>
|
||||
<th class="sortable">Angemeldet</th>
|
||||
<th class="sortable">Letzte Aktivitaet</th>
|
||||
<th class="sortable">Laeuft ab</th>
|
||||
<th class="sortable">IP</th>
|
||||
<th>Client</th>
|
||||
<th class="actions">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in sessions %}
|
||||
<tr data-filter-text="{{ entry.username }} {{ entry.ip }} {{ entry.user_agent }}">
|
||||
<td class="fw-600">{{ entry.username or 'anonym' }}</td>
|
||||
<td class="fs-sm" data-sort-value="{{ entry.created_at }}">{{ entry.created_at | dt }}</td>
|
||||
<td class="fs-sm" data-sort-value="{{ entry.last_seen_at }}">
|
||||
<span data-relative-time="{{ entry.last_seen_at }}">{{ entry.last_seen_at | dt }}</span>
|
||||
</td>
|
||||
<td class="fs-sm" data-sort-value="{{ entry.absolute_expires_at }}">{{ entry.absolute_expires_at | dt }}</td>
|
||||
<td class="mono fs-sm">{{ entry.ip or '–' | safe }}</td>
|
||||
<td class="fs-xs faint truncate" style="max-width:260px" title="{{ entry.user_agent }}">
|
||||
{{ entry.user_agent or '–' | safe }}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="{{ url_for('admin.session_revoke', sid=entry.id) }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Sitzung von {{ entry.username or 'anonym' }} beenden?"
|
||||
data-confirm-title="Sitzung beenden" data-confirm-ok="Beenden">Beenden</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not sessions %}{{ empty_state('Keine aktiven Sitzungen', '', 'activity') }}{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,167 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/_group_picker.html" import group_picker %}
|
||||
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill, empty_state %}
|
||||
|
||||
{% block page_title %}Verzeichnisdienst{% endblock %}
|
||||
{% block page_sub %}
|
||||
LDAP / Active Directory ·
|
||||
{% if values.ldap_enabled %}aktiv{% else %}nicht aktiv{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
||||
|
||||
{{ alert('Same Sign-On, nicht Single Sign-On: der Benutzer tippt sein Verzeichnispasswort auf der
|
||||
normalen Anmeldeseite ein. Es wird bei jeder Anmeldung live gegen das Verzeichnis geprueft und
|
||||
niemals hier gespeichert.', 'info') }}
|
||||
|
||||
{% if values.ldap_security == 'none' %}
|
||||
{{ alert('Die Verbindung ist unverschluesselt. Das Passwort geht dabei im Klartext ueber das
|
||||
Netz -- nur fuer Testumgebungen vertretbar.', 'danger', 'Unverschluesselt') }}
|
||||
{% elif not values.ldap_verify_cert %}
|
||||
{{ alert('Das Serverzertifikat wird nicht geprueft. Damit schuetzt die Verschluesselung nur
|
||||
gegen passives Mitlesen, nicht gegen einen Man-in-the-Middle.', 'warning',
|
||||
'Zertifikat ungeprueft') }}
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid--split">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('users', size=17) }} Verbindung</div></div>
|
||||
{% if current_user.has('settings_directory.execute') %}
|
||||
<form method="post" action="{{ url_for('hostsettings.directory_test') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit" data-busy-label="Teste ...">
|
||||
{{ icon('activity', size=14) }} Verbindung testen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('hostsettings.directory_save') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
{{ checkbox('ldap_enabled', 'Verzeichnisanmeldung aktiv', checked=values.ldap_enabled,
|
||||
disabled=bool(license_hint)) }}
|
||||
<div class="form-grid">
|
||||
{{ field('ldap_host', 'Server', value=values.ldap_host, disabled=bool(license_hint),
|
||||
placeholder='dc01.firma.local') }}
|
||||
{{ field('ldap_port', 'Port', value=values.ldap_port, type='number',
|
||||
disabled=bool(license_hint), attrs='id="ldap-port"') }}
|
||||
{# Der Transport setzt den Port mit. Wer umstellt und den Port stehen
|
||||
laesst, bekommt eine Verbindung, die auf gut Glueck scheitert --
|
||||
und die Meldung ("Zeitlimit") sagt nichts ueber die Ursache. Ein
|
||||
absichtlich abweichender Port bleibt erhalten. #}
|
||||
{{ select_field('ldap_security', 'Transport', specs['ldap_security'].choices,
|
||||
selected=values.ldap_security, disabled=bool(license_hint),
|
||||
attrs='data-behavior="transport-port" data-port-target="#ldap-port"') }}
|
||||
{{ field('ldap_timeout', 'Zeitlimit (s)', value=values.ldap_timeout, type='number',
|
||||
disabled=bool(license_hint)) }}
|
||||
{{ field('ldap_ca_file', 'CA-Zertifikat (Pfad)', value=values.ldap_ca_file,
|
||||
disabled=bool(license_hint), extra_class='field--full',
|
||||
hint_text='Leer = Systemvertrauensspeicher.') }}
|
||||
{{ field('ldap_base_dn', 'Basis-DN', value=values.ldap_base_dn,
|
||||
disabled=bool(license_hint), extra_class='field--full',
|
||||
hint_text='Leer = automatisch per RootDSE ermitteln.') }}
|
||||
{{ field('ldap_user_attribute', 'Anmeldeattribut', value=values.ldap_user_attribute,
|
||||
disabled=bool(license_hint)) }}
|
||||
{{ field('ldap_email_attribute', 'E-Mail-Attribut', value=values.ldap_email_attribute,
|
||||
disabled=bool(license_hint)) }}
|
||||
</div>
|
||||
<div class="stack stack--tight">
|
||||
{{ checkbox('ldap_verify_cert', 'Serverzertifikat pruefen', checked=values.ldap_verify_cert,
|
||||
disabled=bool(license_hint)) }}
|
||||
{{ checkbox('ldap_group_sync', 'Gruppen aus dem Verzeichnis uebernehmen',
|
||||
checked=values.ldap_group_sync, disabled=bool(license_hint)) }}
|
||||
{{ checkbox('ldap_allow_insecure', 'Unverschluesselte Verbindung ausdruecklich erlauben',
|
||||
checked=values.ldap_allow_insecure, disabled=bool(license_hint),
|
||||
hint_text=specs['ldap_allow_insecure'].hint) }}
|
||||
</div>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend>Dienstkonto fuer die Suche (nur Lesezugriff noetig)</legend>
|
||||
<div class="form-grid">
|
||||
{{ field('bind_username', 'Benutzer', value=bind_username, autocomplete='off',
|
||||
disabled=not current_user.has('settings_directory.secrets')) }}
|
||||
{{ field('bind_password', 'Passwort (leer = unveraendert)', type='password',
|
||||
autocomplete='new-password',
|
||||
disabled=not current_user.has('settings_directory.secrets')) }}
|
||||
</div>
|
||||
{% if not current_user.has('settings_directory.secrets') %}
|
||||
<p class="fs-xs faint mt-2">
|
||||
Zum Setzen des Bind-Passworts fehlt Ihnen das Recht „Geheimnisse“.
|
||||
</p>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit" {% if license_hint %}disabled{% endif %}>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('shield-users', size=17) }} Gruppen-Zuordnung</div>
|
||||
<div class="card__sub">
|
||||
Verzeichnisgruppe → Gruppe in dieser Anwendung, bei jeder Anmeldung neu ausgewertet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if mappings %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Verzeichnisgruppe</th><th>Gruppe hier</th><th class="actions"></th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in mappings %}
|
||||
<tr>
|
||||
<td class="fs-sm">{{ entry.ad_group_name }}
|
||||
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
|
||||
<td>{{ pill(entry.group_name or 'unbekannt', 'accent') }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('hostsettings.directory_mapping_delete', mapping_id=entry.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Zuordnung entfernen? Betroffene Benutzer verlieren die Gruppe bei der naechsten Anmeldung."
|
||||
data-confirm-title="Zuordnung entfernen" data-confirm-ok="Entfernen">
|
||||
{{ icon('trash', size=14) }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Keine Zuordnungen',
|
||||
'Ohne Zuordnung erhalten Verzeichniskonten nur die Standardgruppe.', 'shield-users') }}
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has('settings_directory.edit') %}
|
||||
<form method="post" action="{{ url_for('hostsettings.directory_mapping_add') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
{{ group_picker('ad_group_name', 'ad_group_dn', 'mapping', required=True) }}
|
||||
{# Ohne Ziel ist die Zuordnung sinnlos -- deshalb Pflichtfeld. #}
|
||||
<label class="field">
|
||||
<span class="field__label">
|
||||
Gruppe in dieser Anwendung<span class="req" aria-hidden="true">*</span>
|
||||
</span>
|
||||
<select class="select" name="app_group_id" required>
|
||||
{% for group in groups %}
|
||||
<option value="{{ group.id }}">{{ group.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn" type="submit">Zuordnung speichern</button></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,108 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, alert, pill, empty_state %}
|
||||
|
||||
{% block page_title %}Netzwerkeinstellungen{% endblock %}
|
||||
{% block page_sub %}Backend: {{ backend }} · Sicherheitsnetz {{ revert_seconds }} s{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Nach einer Aenderung laeuft ein Zeitgeber. Bestaetigen Sie nicht innerhalb von
|
||||
' ~ revert_seconds ~ ' Sekunden von der neuen Adresse aus, wird automatisch
|
||||
zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
||||
'Automatischer Rueckfall') }}
|
||||
|
||||
{% if pending %}
|
||||
<section class="card card--danger">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('alert', size=17) }} Aenderung wartet auf Bestaetigung</div>
|
||||
<div class="card__sub">Interface {{ pending.interface }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<form method="post" action="{{ url_for('hostsettings.network_confirm') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--primary" type="submit">Verbindung bestaetigen</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if not sysops_available %}
|
||||
{{ alert('Systemhelfer nicht verfuegbar -- die Anzeige ist rein lesend.', 'info') }}
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('network', size=17) }} Interfaces</div></div>
|
||||
</div>
|
||||
{% if interfaces %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Name</th><th>Zustand</th><th>Adressen</th><th>Gateway</th><th>DNS</th><th>MAC</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in interfaces %}
|
||||
<tr>
|
||||
<td class="fw-600 mono">{{ entry.name }}</td>
|
||||
<td>{% if entry.state == 'UP' %}{{ pill('UP', 'online') }}{% else %}{{ pill(entry.state, '') }}{% endif %}</td>
|
||||
<td class="mono fs-sm">{{ entry.addresses | join(', ') or '–' | safe }}</td>
|
||||
<td class="mono fs-sm">{{ entry.gateway or '–' | safe }}</td>
|
||||
<td class="mono fs-sm">{{ entry.dns | join(', ') or '–' | safe }}</td>
|
||||
<td class="mono fs-xs faint">{{ entry.mac }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Keine Interfaces gelesen', 'Das System gibt hier nichts zurueck.', 'network') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if current_user.has('settings_network.edit') and interfaces %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('sliders', size=17) }} Aendern</div>
|
||||
<div class="card__sub">
|
||||
Es wird nur der Block des gewaehlten Interfaces geschrieben (Datei
|
||||
<span class="mono">{{ netplan_path }}</span>) – andere Interfaces bleiben
|
||||
unberuehrt.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('hostsettings.network_apply') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span class="field__label">Interface</span>
|
||||
<select class="select" name="interface" required>
|
||||
{% for entry in interfaces %}<option value="{{ entry.name }}">{{ entry.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Verfahren</span>
|
||||
<select class="select" name="mode">
|
||||
<option value="dhcp">Adresse per DHCP beziehen</option>
|
||||
<option value="static">Feste Adresse</option>
|
||||
</select>
|
||||
</label>
|
||||
{{ field('address', 'IP-Adresse', placeholder='192.168.1.10') }}
|
||||
{{ field('prefix', 'Praefixlaenge', value='24', type='number') }}
|
||||
{{ field('gateway', 'Gateway', placeholder='192.168.1.1') }}
|
||||
{{ field('dns', 'DNS-Server', placeholder='192.168.1.1, 9.9.9.9') }}
|
||||
</div>
|
||||
{{ alert('Das Gateway muss im angegebenen Netz liegen -- sonst waere der Host nach der
|
||||
Aenderung nicht mehr erreichbar. Das wird vor dem Schreiben geprueft.', 'info') }}
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--danger" type="submit"
|
||||
data-confirm="Netzwerkeinstellungen jetzt uebernehmen? Die Verbindung kann kurz abbrechen. Ohne Bestaetigung wird nach {{ revert_seconds }} Sekunden zurueckgerollt."
|
||||
data-confirm-title="Netzwerk aendern" data-confirm-ok="Uebernehmen">
|
||||
Uebernehmen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,180 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, checkbox, alert, pill %}
|
||||
|
||||
{% block page_title %}Webserver und TLS{% endblock %}
|
||||
{% block page_sub %}
|
||||
{{ config.domain or 'ohne Domain' }} ·
|
||||
{% if config.https_enabled %}HTTPS aktiv{% else %}nur HTTP{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
||||
{% if not sysops_available %}
|
||||
{{ alert('Systemhelfer nicht verfuegbar -- die Konfiguration kann hier erzeugt und angesehen,
|
||||
aber nicht angewandt werden.', 'info') }}
|
||||
{% endif %}
|
||||
|
||||
{{ alert('Die Site-Datei wird bei jeder Uebernahme vollstaendig neu geschrieben.
|
||||
Alles, was ein Administrator sonst per Hand hineinschreiben wuerde, ist hier Einstellung. Der
|
||||
Vorgaenger schrieb die Datei nur, wenn sie noch nicht existierte -- ein einmal falscher Pfad
|
||||
blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.', 'info',
|
||||
'Warum die Datei immer neu geschrieben wird') }}
|
||||
|
||||
<div class="grid grid--split">
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('server', size=17) }} Einstellungen</div></div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('hostsettings.web_save') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
<div class="form-grid">
|
||||
{{ field('web_domain', 'Domain', value=config.domain,
|
||||
placeholder='tesm.firma.de', disabled=bool(license_hint),
|
||||
hint_text='Leer = Zugriff ueber IP-Adresse.') }}
|
||||
{{ field('web_acme_email', 'E-Mail fuer Let\'s Encrypt', value=acme_email, type='email',
|
||||
disabled=bool(license_hint)) }}
|
||||
{{ field('web_cert_path', 'Zertifikat (Pfad)', value=config.cert_path,
|
||||
disabled=bool(license_hint), extra_class='field--full') }}
|
||||
{{ field('web_key_path', 'Privater Schluessel (Pfad)', value=config.key_path,
|
||||
disabled=bool(license_hint), extra_class='field--full') }}
|
||||
{{ field('web_acme_webroot', 'ACME-Webroot', value=config.acme_webroot,
|
||||
disabled=bool(license_hint), extra_class='field--full') }}
|
||||
</div>
|
||||
<div class="stack stack--tight">
|
||||
{{ checkbox('web_https_enabled', 'HTTPS auf Port 443 anbieten',
|
||||
checked=config.https_enabled, disabled=bool(license_hint)) }}
|
||||
{{ checkbox('web_redirect_http', 'HTTP (Port 80) auf HTTPS umleiten',
|
||||
checked=config.redirect_http, disabled=bool(license_hint),
|
||||
hint_text='Der ACME-Pfad bleibt dabei erreichbar -- sonst koennte Let\'s
|
||||
Encrypt nie erneuern.') }}
|
||||
{{ checkbox('web_hsts_enabled', 'HSTS-Header senden',
|
||||
checked=config.hsts_enabled, disabled=bool(license_hint),
|
||||
hint_text='Nur mit funktionierendem HTTPS aktivieren: Browser merken sich
|
||||
die Vorgabe und verweigern danach jeden HTTP-Zugriff.') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit" {% if license_hint %}disabled{% endif %}>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<details class="card" data-persist="nginx-preview">
|
||||
<summary class="card__header" style="cursor:pointer">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte nginx-Konfiguration</div>
|
||||
<div class="card__sub">Vorschau der Datei, die geschrieben wuerde</div>
|
||||
</div>
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</summary>
|
||||
<pre class="log-view">{{ preview }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('shield', size=17) }} Zertifikat</div>
|
||||
<div class="card__sub">
|
||||
{% if certificate.present %}vorhanden{% else %}nicht vorhanden{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
{% if certificate.present and not certificate.get('error') %}
|
||||
<dl class="kv fs-sm">
|
||||
<dt>Inhaber</dt><dd class="break-all">{{ certificate.subject }}</dd>
|
||||
<dt>Aussteller</dt><dd class="break-all">{{ certificate.issuer }}</dd>
|
||||
<dt>Gueltig bis</dt><dd>{{ certificate.not_valid_after | dt }}</dd>
|
||||
<dt>Seriennummer</dt><dd class="mono fs-xs break-all">{{ certificate.serial }}</dd>
|
||||
</dl>
|
||||
{% elif certificate.get('error') %}
|
||||
{{ alert('Zertifikat nicht lesbar: ' ~ certificate.error, 'danger') }}
|
||||
{% else %}
|
||||
<p class="muted fs-sm">
|
||||
Fordern Sie ein Zertifikat per Let's Encrypt an oder hinterlegen Sie eigene Pfade.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has('settings_web.execute') %}
|
||||
<form method="post" action="{{ url_for('hostsettings.web_certificate') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit"
|
||||
{% if license_hint or not certbot_available or not config.domain %}disabled{% endif %}
|
||||
data-busy-label="Fordere an ..."
|
||||
data-confirm="Zertifikat fuer {{ config.domain }} per Let's Encrypt anfordern? Die Domain muss oeffentlich auf diesen Host zeigen."
|
||||
data-confirm-title="Zertifikat anfordern" data-confirm-ok="Anfordern"
|
||||
data-confirm-style="primary">
|
||||
{{ icon('lock', size=14) }} Let's-Encrypt-Zertifikat anfordern
|
||||
</button>
|
||||
</form>
|
||||
{% if not certbot_available %}
|
||||
<p class="fs-xs faint">certbot ist auf diesem Host nicht installiert.</p>
|
||||
{% elif not config.domain %}
|
||||
<p class="fs-xs faint">
|
||||
Fuer Let's Encrypt wird oben eine Domain gebraucht, die oeffentlich auf
|
||||
diesen Host zeigt.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
<p class="muted fs-sm">
|
||||
Kein oeffentlicher Name? Dann ein eigenes Zertifikat erzeugen. Browser
|
||||
zeigen dafuer eine Warnung, die Verbindung ist aber verschluesselt --
|
||||
und das Sitzungscookie darf endlich <span class="mono">Secure</span> tragen.
|
||||
</p>
|
||||
{% if local_names %}
|
||||
<p class="fs-xs faint">
|
||||
Wird ausgestellt auf:
|
||||
<span class="mono">{{ (config.domain or local_names[0]) }}</span>
|
||||
{% if local_names|length > 1 %}
|
||||
(zusaetzlich {{ local_names[1:] | join(', ') }})
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('hostsettings.web_self_signed') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit"
|
||||
{% if license_hint or not sysops_available %}disabled{% endif %}
|
||||
data-busy-label="Erzeuge ..."
|
||||
data-confirm="Ein selbstsigniertes Zertifikat erzeugen und HTTPS aktivieren? Ein bereits vorhandenes selbstsigniertes Zertifikat dieser Installation wird ersetzt."
|
||||
data-confirm-title="Selbstsigniertes Zertifikat"
|
||||
data-confirm-ok="Erzeugen" data-confirm-style="primary">
|
||||
{{ icon('shield', size=14) }} Selbstsigniertes Zertifikat erzeugen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if current_user.has('settings_web.execute') %}
|
||||
<section class="card card--accent">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('upload', size=17) }} Uebernehmen</div>
|
||||
<div class="card__sub">Schreiben, <span class="mono">nginx -t</span>, dann neu laden</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<form method="post" action="{{ url_for('hostsettings.web_apply') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--primary btn--block" type="submit"
|
||||
{% if license_hint or not sysops_available %}disabled{% endif %}
|
||||
data-busy-label="Uebernehme ..."
|
||||
data-confirm="Konfiguration schreiben, pruefen und nginx neu laden? Eine fehlerhafte Konfiguration wird vorher abgewiesen."
|
||||
data-confirm-title="nginx uebernehmen" data-confirm-ok="Uebernehmen"
|
||||
data-confirm-style="primary">
|
||||
Konfiguration uebernehmen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,80 @@
|
||||
{# ==========================================================================
|
||||
Grundgeruest fuer Seiten ohne Anwendungsrahmen: Anmeldung, zweiter Faktor,
|
||||
Kundenportal, Self-Service.
|
||||
|
||||
Wichtiger Unterschied zu base.html: Flash-Meldungen werden hier **sichtbar
|
||||
serverseitig** gerendert, nicht ueber die JSON-Bruecke. Der Vorgaenger hatte
|
||||
dieselbe Trennung; sie ist wichtig, weil diese Seiten auch ohne JavaScript
|
||||
nutzbar bleiben muessen (und weil Tests hier direkt auf Text pruefen).
|
||||
========================================================================== #}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>{% block title %}{{ self.heading() }} · {{ app_name }}{% endblock %}</title>
|
||||
<link rel="icon" href="{{ asset_url('img/favicon.svg') }}" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="{{ asset_url('css/base.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset_url('css/components.css') }}">
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body data-csrf="{{ csrf_token() }}">
|
||||
<div class="auth-page">
|
||||
{% if auth_tagline %}
|
||||
{# Wortmarke mit Funktionszeile als tatsaechlicher Seitenhintergrund --
|
||||
uebernommen vom Vorgaenger. Bewusst inline und mit Themen-Tokens statt
|
||||
zweier Dateien fuer hell und dunkel: bei der Einstellung "System" und
|
||||
beim Umschalten von Hand waere die versteckte Variante die falsche.
|
||||
``aria-hidden``, weil der Name schon in der Ueberschrift steht. #}
|
||||
<div class="auth-backdrop" aria-hidden="true">
|
||||
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(150,15)">
|
||||
<circle cx="298" cy="70" r="15" fill="var(--logo-mark)" opacity="0.22"/>
|
||||
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70
|
||||
218,35 226,105 234,70 244,70 300,70"
|
||||
fill="none" stroke="var(--logo-mark)" stroke-width="3.2"
|
||||
stroke-linecap="round" stroke-linejoin="round" opacity="0.85"/>
|
||||
<text x="14" y="98" font-family="var(--font-mono)" font-weight="800"
|
||||
font-size="82" letter-spacing="2" fill="var(--logo-text)">TESM</text>
|
||||
<circle cx="298" cy="70" r="8" fill="var(--logo-mark)"/>
|
||||
</g>
|
||||
<text x="310" y="178" text-anchor="middle" font-family="var(--font-sans)"
|
||||
font-weight="600" font-size="13" letter-spacing="1.5"
|
||||
fill="var(--logo-mark)">{{ auth_tagline }}</text>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="auth-card {% block card_class %}{% endblock %}">
|
||||
<div class="auth-brand">
|
||||
<span class="auth-brand__mark" aria-hidden="true">{{ app_short }}</span>
|
||||
<h1>{% block heading %}{{ app_name }}{% endblock %}</h1>
|
||||
{% set sub %}{% block subheading %}{% endblock %}{% endset %}
|
||||
{% if sub | trim %}<p>{{ sub }}</p>{% endif %}
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="stack stack--tight">
|
||||
{% for category, message in messages %}
|
||||
{% set kind = {'error': 'danger', 'danger': 'danger', 'success': 'success',
|
||||
'warning': 'warning'}.get(category, 'info') %}
|
||||
<div class="alert alert--{{ kind }}" role="alert">
|
||||
{{ icon('info' if kind == 'info' else ('check' if kind == 'success' else 'alert'), size=18) }}
|
||||
<div class="alert__body">{{ message }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<p class="auth-footnote">{{ app_name }} {{ app_version }} · © {{ current_year }} WiS GmbH</p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="{{ asset_url('js/app.js') }}" nonce="{{ csp_nonce }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import empty_state, alert, pill %}
|
||||
|
||||
{% block page_title %}Papierkorb{% endblock %}
|
||||
{% block page_sub %}{{ total }} Eintraege · Aufbewahrung {{ retention_days }} Tage{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ alert('Ansehen ist immer moeglich. Wiederherstellen und endgueltiges Loeschen erfordern eine
|
||||
gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Seite entfernt.',
|
||||
'info') }}
|
||||
|
||||
{% for section in sections %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon(section.icon, size=17) }} {{ section.label }}
|
||||
{{ pill(section.entries | length, '') }}
|
||||
</div>
|
||||
{% if section.hint %}<div class="card__sub">{{ section.hint }}</div>{% endif %}
|
||||
</div>
|
||||
{% if section.entries and current_user.has('trash.delete') %}
|
||||
<form method="post" action="{{ url_for('trash.purge_all') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="kind" value="{{ section.key }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Alle {{ section.entries | length }} Eintraege unter {{ section.label }} endgueltig loeschen? Das laesst sich nicht rueckgaengig machen."
|
||||
data-confirm-title="Endgueltig loeschen" data-confirm-ok="Endgueltig loeschen">
|
||||
Bereich leeren
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if section.entries %}
|
||||
<div class="table-wrap">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>Eintrag</th><th>Detail</th><th>Geloescht</th><th class="actions">Aktion</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in section.entries %}
|
||||
<tr>
|
||||
<td class="fw-600">{{ entry.label }}</td>
|
||||
<td class="muted fs-sm">{{ entry.detail }}</td>
|
||||
<td class="fs-sm"><span data-relative-time="{{ entry.deleted_at }}">{{ entry.deleted_at | dt }}</span></td>
|
||||
<td class="actions">
|
||||
{% if current_user.has('trash.edit') %}
|
||||
<form method="post" style="display:inline" data-no-guard
|
||||
action="{{ url_for('trash.restore', kind=section.key, ident=entry.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm" type="submit">Wiederherstellen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if current_user.has('trash.delete') %}
|
||||
<form method="post" style="display:inline" data-no-guard
|
||||
action="{{ url_for('trash.purge', kind=section.key, ident=entry.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="{{ entry.label }} endgueltig loeschen?"
|
||||
data-confirm-title="Endgueltig loeschen" data-confirm-ok="Loeschen">
|
||||
Endgueltig
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state('Nichts im Papierkorb', '', section.icon) }}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,238 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, select_field, checkbox, pill, search_box, modal, empty_state, alert %}
|
||||
|
||||
{% block page_title %}Benutzer{% endblock %}
|
||||
{% block page_sub %}{{ users | length }} Konten · Rechte kommen ausschliesslich aus Gruppen{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{{ search_box('users-table', 'Benutzer suchen ...', count_id='users-visible') }}
|
||||
{% if current_user.has('users.create') %}
|
||||
<button class="btn btn--primary btn--sm" type="button" data-dialog-open="user-new">
|
||||
{{ icon('plus', size=15) }} Konto anlegen
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div class="table-toolbar">
|
||||
<span class="muted fs-sm"><span id="users-visible">{{ users | length }}</span> sichtbar</span>
|
||||
<span class="table-toolbar__spacer"></span>
|
||||
{% if directory_enabled and current_user.has('users.create') %}
|
||||
<button class="btn btn--sm" type="button" data-dialog-open="user-directory">
|
||||
{{ icon('users', size=15) }} Aus Verzeichnis uebernehmen
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if current_user.is_admin %}
|
||||
<a class="btn btn--sm" href="{{ url_for('admin.sessions') }}">{{ icon('activity', size=15) }} Sitzungen</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="table" id="users-table" data-behavior="sortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">Benutzer</th>
|
||||
<th class="sortable">Name</th>
|
||||
<th class="sortable">Quelle</th>
|
||||
<th>Gruppen</th>
|
||||
<th class="sortable">Status</th>
|
||||
<th class="sortable">Letzte Anmeldung</th>
|
||||
<th class="actions">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr data-filter-text="{{ user.username }} {{ user.first_name }} {{ user.last_name }} {{ user.email }} {{ user.group_names | join(' ') }}">
|
||||
<td>
|
||||
<div class="row row--tight">
|
||||
<span class="avatar avatar--sm" aria-hidden="true">
|
||||
{{ (user.first_name[:1] ~ user.last_name[:1]) | upper if user.first_name else user.username[:2] | upper }}
|
||||
</span>
|
||||
<div>
|
||||
<div class="fw-600">{{ user.username }}</div>
|
||||
{% if user.email %}<div class="fs-xs faint">{{ user.email }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ (user.first_name ~ ' ' ~ user.last_name) | trim or '–' | safe }}</td>
|
||||
<td>
|
||||
{% if user.auth_source == 'ldap' %}
|
||||
{{ pill('Verzeichnis', 'info') }}
|
||||
{% else %}
|
||||
{{ pill('Lokal', '') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">
|
||||
{% if user.is_admin %}{{ pill('Administrator', 'accent') }}{% endif %}
|
||||
{{ user.group_names | join(', ') or ('–' | safe if not user.is_admin else '') }}
|
||||
</td>
|
||||
<td>
|
||||
{% if user.is_locked %}{{ pill('Gesperrt', 'danger') }}
|
||||
{% else %}{{ pill('Aktiv', 'online') }}{% endif %}
|
||||
{% if user.totp_enabled %}<span title="Zwei-Faktor aktiv">{{ icon('lock', size=14) }}</span>{% endif %}
|
||||
{% if user.must_change_password %}{{ pill('Passwortwechsel', 'warning') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm" data-sort-value="{{ user.last_login_at or '' }}">
|
||||
{% if user.last_login_at %}
|
||||
<span data-relative-time="{{ user.last_login_at }}">{{ user.last_login_at | dt }}</span>
|
||||
{% else %}<span class="faint">noch nie</span>{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if current_user.has('users.edit') %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="user-edit-{{ user.id }}" aria-label="Bearbeiten">
|
||||
{{ icon('pencil', size=15) }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if current_user.has('users.delete') %}
|
||||
<form method="post" action="{{ url_for('admin.user_delete', user_id=user.id) }}"
|
||||
style="display:inline" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||
data-confirm="Konto {{ user.username }} wirklich loeschen?{% if user.auth_source == 'ldap' %} Verzeichniskonten werden sofort endgueltig entfernt.{% else %} Es landet im Papierkorb.{% endif %}"
|
||||
data-confirm-title="Konto loeschen" data-confirm-ok="Loeschen"
|
||||
aria-label="Loeschen">{{ icon('trash', size=15) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not users %}
|
||||
{{ empty_state('Keine Konten vorhanden', 'Legen Sie das erste Konto an.', 'users') }}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{{ alert('Rechte werden ausschliesslich ueber Gruppen vergeben. Das Admin-Kennzeichen ist davon
|
||||
unabhaengig und kann nur von Administratoren gesetzt werden; mindestens ein entsperrtes
|
||||
Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block dialogs %}
|
||||
{% if current_user.has('users.create') %}
|
||||
{% call modal('user-new', 'Konto anlegen', 'Lokales Konto mit eigenem Passwort') %}
|
||||
<form method="post" action="{{ url_for('admin.user_create') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">
|
||||
<div class="form-grid">
|
||||
{{ field('username', 'Benutzername', required=True, autocomplete='off',
|
||||
extra_class='field--full') }}
|
||||
{{ field('first_name', 'Vorname') }}
|
||||
{{ field('last_name', 'Nachname') }}
|
||||
{{ field('email', 'E-Mail', type='email', extra_class='field--full') }}
|
||||
{{ field('password', 'Passwort', type='password', required=True, autocomplete='new-password',
|
||||
hint_text='Mindestens ' ~ policy.min_length ~ ' Zeichen, zwei verschiedene Zeichenarten.',
|
||||
extra_class='field--full') }}
|
||||
</div>
|
||||
<fieldset class="fieldset">
|
||||
<legend>Gruppen</legend>
|
||||
<div class="stack stack--tight">
|
||||
{% for group in groups %}
|
||||
{{ checkbox('group_ids', group.name ~ (' (Standard)' if group.is_default else ''),
|
||||
checked=group.is_default, value=group.id) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
{% if current_user.is_admin %}
|
||||
{{ checkbox('is_admin', 'Administrator (darf alles, unabhaengig von Gruppen)') }}
|
||||
{% endif %}
|
||||
{{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }}
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--primary" type="submit">Konto anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
|
||||
{% if directory_enabled %}
|
||||
{% call modal('user-directory', 'Aus Verzeichnis uebernehmen',
|
||||
'Konto vorbereiten, damit Gruppen vor der ersten Anmeldung sitzen') %}
|
||||
<form method="post" action="{{ url_for('admin.directory_adopt') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">
|
||||
{{ field('username', 'Anmeldename im Verzeichnis', required=True, autocomplete='off') }}
|
||||
<div class="form-grid">
|
||||
{{ field('first_name', 'Vorname') }}
|
||||
{{ field('last_name', 'Nachname') }}
|
||||
</div>
|
||||
{{ field('email', 'E-Mail', type='email') }}
|
||||
<fieldset class="fieldset">
|
||||
<legend>Gruppen</legend>
|
||||
<div class="stack stack--tight">
|
||||
{% for group in groups %}
|
||||
{{ checkbox('group_ids', group.name, checked=group.is_default, value=group.id) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
{{ alert('Name und E-Mail werden bei der ersten echten Anmeldung aus dem Verzeichnis
|
||||
uebernommen und ueberschrieben.', 'info') }}
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--primary" type="submit">Vorbereiten</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has('users.edit') %}
|
||||
{% for user in users %}
|
||||
{% call modal('user-edit-' ~ user.id, 'Konto ' ~ user.username,
|
||||
'Verzeichniskonto' if user.auth_source == 'ldap' else 'Lokales Konto') %}
|
||||
<form method="post" action="{{ url_for('admin.user_update', user_id=user.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="modal__body">
|
||||
{% if user.auth_source == 'ldap' %}
|
||||
{{ alert('Stammdaten und Passwort pflegt der Verzeichnisdienst. Hier lassen sich nur
|
||||
Sperre und Gruppen setzen.', 'info') }}
|
||||
{% else %}
|
||||
<div class="form-grid">
|
||||
{{ field('first_name', 'Vorname', value=user.first_name) }}
|
||||
{{ field('last_name', 'Nachname', value=user.last_name) }}
|
||||
</div>
|
||||
{{ field('email', 'E-Mail', value=user.email, type='email') }}
|
||||
{{ field('new_password', 'Neues Passwort setzen (optional)', type='password',
|
||||
autocomplete='new-password',
|
||||
hint_text='Setzt das Passwort zurueck, erzwingt einen Wechsel und beendet alle Sitzungen des Kontos.') }}
|
||||
{% endif %}
|
||||
<fieldset class="fieldset">
|
||||
<legend>Gruppen</legend>
|
||||
<input type="hidden" name="group_ids_submitted" value="1">
|
||||
<div class="stack stack--tight">
|
||||
{% for group in groups %}
|
||||
{{ checkbox('group_ids', group.name, checked=group.name in user.group_names, value=group.id) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="stack stack--tight">
|
||||
{% if current_user.is_admin and user.id != current_user.id %}
|
||||
{{ checkbox('is_admin', 'Administrator', checked=user.is_admin) }}
|
||||
{% elif user.is_admin %}
|
||||
{{ pill('Administrator', 'accent') }}
|
||||
{% endif %}
|
||||
{% if user.id != current_user.id %}
|
||||
{{ checkbox('is_locked', 'Konto gesperrt', checked=user.is_locked) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__footer modal__footer--split">
|
||||
<button class="btn btn--sm" type="submit" formaction="{{ url_for('admin.user_revoke_sessions', user_id=user.id) }}"
|
||||
data-confirm="Alle Sitzungen von {{ user.username }} beenden?"
|
||||
data-confirm-title="Sitzungen beenden" data-confirm-ok="Beenden" data-confirm-style="danger">
|
||||
Sitzungen beenden
|
||||
</button>
|
||||
<span class="row row--tight">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
<button class="btn btn--primary" type="submit">Speichern</button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Verschluesselter Umschlag fuer Sicherungen -- gemeinsam von beiden Anwendungen.
|
||||
|
||||
Format 3. Was hier liegt, ist der kryptographische Teil: Passphrase ableiten,
|
||||
verschluesseln, entschluesseln, Zwischenstand halten. *Welche* Kategorien es
|
||||
gibt und wie sie gelesen/geschrieben werden, entscheidet jede Anwendung selbst.
|
||||
|
||||
Argon2id statt PBKDF2 (der Vorgaenger nutzte 390.000 PBKDF2-Iterationen --
|
||||
ordentlich, aber nicht speicherhart) und AES-256-GCM statt Fernet, mit dem
|
||||
Umschlagkopf als zugeordneten Daten: wer Salz oder Kategorienliste manipuliert,
|
||||
bricht die Entschluesselung.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import secrets
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from argon2.low_level import Type, hash_secret_raw
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
from .db import Database
|
||||
|
||||
FORMAT_VERSION = 3
|
||||
|
||||
KDF_TIME_COST = 4
|
||||
KDF_MEMORY_COST = 131072 # 128 MiB
|
||||
KDF_PARALLELISM = 2
|
||||
KDF_SALT_BYTES = 16
|
||||
KEY_BYTES = 32
|
||||
NONCE_BYTES = 12
|
||||
|
||||
MIN_PASSPHRASE_LENGTH = 12
|
||||
PREVIEW_JOB_KIND = "backup.preview"
|
||||
PREVIEW_TTL_MINUTES = 30
|
||||
|
||||
|
||||
class TransferError(Exception):
|
||||
"""Fehler mit Klartextmeldung fuer die Oberflaeche."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Category:
|
||||
"""Eine waehlbare Kategorie einer Sicherung."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
hint: str = ""
|
||||
#: Enthaelt Geheimnisse im Klartext (verlangt ``backup.secrets``).
|
||||
sensitive: bool = False
|
||||
#: Nur echte Administratoren duerfen sie sichern/einspielen.
|
||||
admin_only: bool = False
|
||||
|
||||
|
||||
def _b64e(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _b64d(text: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _derive_key(passphrase: str, salt: bytes, params: dict[str, int]) -> bytes:
|
||||
return hash_secret_raw(
|
||||
secret=passphrase.encode("utf-8"),
|
||||
salt=salt,
|
||||
time_cost=int(params["t"]),
|
||||
memory_cost=int(params["m"]),
|
||||
parallelism=int(params["p"]),
|
||||
hash_len=KEY_BYTES,
|
||||
type=Type.ID,
|
||||
)
|
||||
|
||||
|
||||
def seal(payload: dict[str, Any], passphrase: str, *, app_tag: str) -> str:
|
||||
if len(passphrase or "") < MIN_PASSPHRASE_LENGTH:
|
||||
raise TransferError(
|
||||
f"Die Passphrase muss mindestens {MIN_PASSPHRASE_LENGTH} Zeichen haben -- sie ist "
|
||||
"der einzige Schutz dieser Datei."
|
||||
)
|
||||
salt = secrets.token_bytes(KDF_SALT_BYTES)
|
||||
params = {"t": KDF_TIME_COST, "m": KDF_MEMORY_COST, "p": KDF_PARALLELISM}
|
||||
header = {
|
||||
"app": app_tag,
|
||||
"format": FORMAT_VERSION,
|
||||
"created_at": _now().isoformat(timespec="seconds"),
|
||||
"categories": sorted(payload.keys()),
|
||||
"kdf": {"name": "argon2id", "salt": _b64e(salt), **params},
|
||||
}
|
||||
key = _derive_key(passphrase, salt, params)
|
||||
nonce = secrets.token_bytes(NONCE_BYTES)
|
||||
aad = json.dumps(header, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
ciphertext = AESGCM(key).encrypt(
|
||||
nonce, json.dumps(payload, ensure_ascii=False).encode("utf-8"), aad
|
||||
)
|
||||
return json.dumps(
|
||||
{**header, "nonce": _b64e(nonce), "payload": _b64e(ciphertext)},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
|
||||
def unseal(
|
||||
raw: bytes | str, passphrase: str, *, app_tag: str
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
|
||||
try:
|
||||
envelope = json.loads(text)
|
||||
except ValueError as exc:
|
||||
raise TransferError("Die Datei ist kein gueltiges JSON.") from exc
|
||||
if not isinstance(envelope, dict):
|
||||
raise TransferError("Die Datei enthaelt kein Objekt.")
|
||||
if envelope.get("app") != app_tag:
|
||||
raise TransferError(
|
||||
f"Diese Sicherung gehoert zu einer anderen Anwendung ({envelope.get('app')!r})."
|
||||
)
|
||||
if int(envelope.get("format") or 0) != FORMAT_VERSION:
|
||||
raise TransferError(
|
||||
f"Nicht unterstuetztes Sicherungsformat ({envelope.get('format')!r}, "
|
||||
f"erwartet {FORMAT_VERSION})."
|
||||
)
|
||||
kdf = envelope.get("kdf") or {}
|
||||
if kdf.get("name") != "argon2id":
|
||||
raise TransferError("Unbekanntes Schluesselableitungsverfahren.")
|
||||
|
||||
header = {
|
||||
key: envelope[key]
|
||||
for key in ("app", "format", "created_at", "categories", "kdf")
|
||||
if key in envelope
|
||||
}
|
||||
aad = json.dumps(header, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
try:
|
||||
key = _derive_key(
|
||||
passphrase,
|
||||
_b64d(str(kdf["salt"])),
|
||||
{
|
||||
"t": int(kdf.get("t") or KDF_TIME_COST),
|
||||
"m": int(kdf.get("m") or KDF_MEMORY_COST),
|
||||
"p": int(kdf.get("p") or KDF_PARALLELISM),
|
||||
},
|
||||
)
|
||||
plain = AESGCM(key).decrypt(
|
||||
_b64d(str(envelope["nonce"])), _b64d(str(envelope["payload"])), aad
|
||||
)
|
||||
except (InvalidTag, KeyError, ValueError) as exc:
|
||||
raise TransferError(
|
||||
"Die Datei konnte nicht geoeffnet werden. Passphrase falsch oder Datei beschaedigt."
|
||||
) from exc
|
||||
return json.loads(plain.decode("utf-8")), header
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Zwischenstand des Imports
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def store_preview(conn: sqlite3.Connection, payload: dict[str, Any], *, actor: str) -> str:
|
||||
"""Legt die geoeffnete Sicherung in der Datenbank ab.
|
||||
|
||||
Der Vorgaenger hielt diesen Zwischenstand in einer Modulvariable -- einer der
|
||||
Gruende, warum die Anwendung mit genau einem Worker laufen *musste*.
|
||||
"""
|
||||
token = secrets.token_urlsafe(18)
|
||||
conn.execute("DELETE FROM jobs WHERE kind=? AND started_by=?", (PREVIEW_JOB_KIND, actor))
|
||||
conn.execute(
|
||||
"INSERT INTO jobs (id, kind, subject, state, message, output, started_by, created_at, "
|
||||
"updated_at) VALUES (?,?,?,'pending','',?,?,?,?)",
|
||||
(
|
||||
token,
|
||||
PREVIEW_JOB_KIND,
|
||||
"Import-Vorschau",
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
actor,
|
||||
_now().isoformat(timespec="seconds"),
|
||||
_now().isoformat(timespec="seconds"),
|
||||
),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def load_preview(conn: sqlite3.Connection, token: str, *, actor: str) -> dict[str, Any]:
|
||||
row = Database.one(
|
||||
conn,
|
||||
"SELECT output, created_at FROM jobs WHERE id=? AND kind=? AND started_by=?",
|
||||
(token, PREVIEW_JOB_KIND, actor),
|
||||
)
|
||||
if row is None:
|
||||
raise TransferError("Der Zwischenstand ist nicht mehr vorhanden. Bitte neu hochladen.")
|
||||
created = datetime.fromisoformat(str(row["created_at"]))
|
||||
if created.tzinfo is None:
|
||||
created = created.replace(tzinfo=timezone.utc)
|
||||
if _now() - created > timedelta(minutes=PREVIEW_TTL_MINUTES):
|
||||
conn.execute("DELETE FROM jobs WHERE id=?", (token,))
|
||||
raise TransferError("Der Zwischenstand ist abgelaufen. Bitte neu hochladen.")
|
||||
return json.loads(str(row["output"]))
|
||||
|
||||
|
||||
def drop_preview(conn: sqlite3.Connection, token: str) -> None:
|
||||
conn.execute("DELETE FROM jobs WHERE id=? AND kind=?", (token, PREVIEW_JOB_KIND))
|
||||
|
||||
|
||||
def summarize(payload: dict[str, Any], categories: tuple[Category, ...]) -> list[dict[str, Any]]:
|
||||
summary: list[dict[str, Any]] = []
|
||||
for category in categories:
|
||||
if category.key not in payload:
|
||||
continue
|
||||
data = payload[category.key]
|
||||
if isinstance(data, list):
|
||||
count = len(data)
|
||||
elif isinstance(data, dict):
|
||||
count = sum(len(value) if isinstance(value, list) else 1 for value in data.values())
|
||||
else:
|
||||
count = 1
|
||||
summary.append(
|
||||
{
|
||||
"key": category.key,
|
||||
"label": category.label,
|
||||
"hint": category.hint,
|
||||
"count": count,
|
||||
"sensitive": category.sensitive,
|
||||
"admin_only": category.admin_only,
|
||||
}
|
||||
)
|
||||
return summary
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Darstellungshelfer: Icons, Formatierung, Template-Globals.
|
||||
|
||||
Die Icons sind Pfadfragmente eines 24x24-Rasters mit ``stroke="currentColor"``
|
||||
-- kein Icon-Font, kein externes Sprite, keine zusaetzliche HTTP-Anfrage und
|
||||
nichts, was die CSP aufweichen muesste.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, url_for
|
||||
from markupsafe import Markup
|
||||
|
||||
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"/>',
|
||||
"cpu": '<rect x="5" y="5" width="14" height="14" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>',
|
||||
"switch": '<rect x="2" y="7" width="20" height="10" rx="2"/><path d="M6 11v2M10 11v2M14 11v2M18 11v2"/>',
|
||||
"network": '<rect x="9" y="2" width="6" height="6" rx="1.5"/><rect x="2" y="16" width="6" height="6" rx="1.5"/><rect x="16" y="16" width="6" height="6" rx="1.5"/><path d="M12 8v4M5 16v-2h14v2"/>',
|
||||
"key": '<circle cx="8" cy="15" r="4"/><path d="m10.8 12.2 8.2-8.2M17 5l3 3M14 8l3 3"/>',
|
||||
"wrench": '<path d="M14.5 3.5a5 5 0 0 0-6.9 6.1L3 14.2V21h6.8l4.6-4.6a5 5 0 0 0 6.1-6.9l-3 3-3.9-.9-.9-3.9 3-3z"/>',
|
||||
"users": '<path d="M16 20v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 20v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8"/>',
|
||||
"shield-users": '<path d="M12 2 4 5v6c0 5 3.4 9.3 8 11 4.6-1.7 8-6 8-11V5l-8-3z"/><circle cx="12" cy="10" r="2.2"/><path d="M8.5 16c.7-1.6 2-2.4 3.5-2.4s2.8.8 3.5 2.4"/>',
|
||||
"folder": '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>',
|
||||
"sliders": '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>',
|
||||
"server": '<rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/>',
|
||||
"trash": '<path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13M10 11v6M14 11v6"/>',
|
||||
"terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m7 9 3 3-3 3M13 15h4"/>',
|
||||
"clock": '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
||||
"history": '<path d="M3 12a9 9 0 1 0 3-6.7M3 4v4h4"/><path d="M12 8v4l3 2"/>',
|
||||
"transfer": '<path d="M4 8h13l-3-3M20 16H7l3 3"/>',
|
||||
"shield": '<path d="M12 2 4 5v6c0 5 3.4 9.3 8 11 4.6-1.7 8-6 8-11V5l-8-3z"/>',
|
||||
"search": '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
|
||||
"menu": '<path d="M4 6h16M4 12h16M4 18h16"/>',
|
||||
"chevron-right": '<path d="m9 6 6 6-6 6"/>',
|
||||
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
|
||||
"sun": '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"/>',
|
||||
"moon": '<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"/>',
|
||||
"monitor": '<rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>',
|
||||
"log-out": '<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3M10 8l-4 4 4 4M6 12h10"/>',
|
||||
"settings": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.9 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>',
|
||||
"plus": '<path d="M12 5v14M5 12h14"/>',
|
||||
"pencil": '<path d="M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3z"/>',
|
||||
"x": '<path d="M6 6l12 12M18 6 6 18"/>',
|
||||
"check": '<path d="m5 13 4 4L19 7"/>',
|
||||
"alert": '<path d="M12 3 2 20h20L12 3z"/><path d="M12 9v5M12 17h.01"/>',
|
||||
"info": '<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>',
|
||||
"download": '<path d="M12 3v12M7 11l5 5 5-5M4 21h16"/>',
|
||||
"upload": '<path d="M12 21V9M7 13l5-5 5 5M4 4h16"/>',
|
||||
"refresh": '<path d="M20 12a8 8 0 1 1-2.3-5.7M20 4v4h-4"/>',
|
||||
"play": '<path d="M7 4l12 8-12 8V4z"/>',
|
||||
"power": '<path d="M12 3v9"/><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/>',
|
||||
"lock": '<rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
|
||||
"unlock": '<rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 7.5-2"/>',
|
||||
"mail": '<rect x="2" y="5" width="20" height="14" rx="2"/><path d="m3 7 9 6 9-6"/>',
|
||||
"building": '<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M9 7h2M13 7h2M9 11h2M13 11h2M9 15h2M13 15h2M10 21v-3h4v3"/>',
|
||||
"ticket": '<path d="M3 9a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2 2 2 0 0 0 0 6 2 2 0 0 1-2 2H5a2 2 0 0 1-2-2 2 2 0 0 0 0-6z"/><path d="M9 7v10"/>',
|
||||
"file-text": '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5z"/><path d="M14 3v5h5M9 13h6M9 17h4"/>',
|
||||
"activity": '<path d="M3 12h4l3-8 4 16 3-8h4"/>',
|
||||
"database": '<ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/>',
|
||||
"eye": '<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/>',
|
||||
"copy": '<rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/>',
|
||||
"external": '<path d="M14 4h6v6M20 4 11 13M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4"/>',
|
||||
"filter": '<path d="M3 5h18l-7 8v6l-4-2v-4L3 5z"/>',
|
||||
"calendar": '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>',
|
||||
"link": '<path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1"/>',
|
||||
"puzzle": '<path d="M9 3h2a2 2 0 0 1 0 4h3v3a2 2 0 0 1 4 0v3h-3v3a2 2 0 0 1-4 0H8v-3H5a2 2 0 0 1 0-4h3V3z"/>',
|
||||
"credit-card": '<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20M6 15h4"/>',
|
||||
"map-pin": '<path d="M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11z"/><circle cx="12" cy="10" r="2.5"/>',
|
||||
"share": '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4"/>',
|
||||
"hard-drive": '<rect x="2" y="13" width="20" height="8" rx="2"/><path d="M5 13 8 4h8l3 9M6 17h.01M10 17h.01"/>',
|
||||
}
|
||||
|
||||
|
||||
def icon(name: str, *, size: int = 18, extra_class: str = "") -> Markup:
|
||||
"""Rendert ein Icon als Inline-SVG."""
|
||||
path = ICONS.get(name) or ICONS["info"]
|
||||
classes = f"icon icon--{name}" + (f" {extra_class}" if extra_class else "")
|
||||
return Markup(
|
||||
f'<svg class="{classes}" width="{size}" height="{size}" viewBox="0 0 24 24" '
|
||||
'fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" '
|
||||
f'stroke-linejoin="round" aria-hidden="true">{path}</svg>'
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Formatierung
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def format_bytes(value: Any) -> str:
|
||||
try:
|
||||
size = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return "-"
|
||||
if size < 1024:
|
||||
return f"{int(size)} B"
|
||||
units = ("KB", "MB", "GB", "TB")
|
||||
index = min(int(math.log(size, 1024)) - 1, len(units) - 1)
|
||||
return f"{size / (1024 ** (index + 1)):.1f} {units[index]}".replace(".", ",")
|
||||
|
||||
|
||||
def format_datetime(value: Any, *, with_time: bool = True) -> str:
|
||||
if not value:
|
||||
return "-"
|
||||
if isinstance(value, datetime):
|
||||
moment = value
|
||||
else:
|
||||
text = str(value).strip()
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
moment = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return str(value)
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
local = moment.astimezone()
|
||||
return local.strftime("%d.%m.%Y %H:%M") if with_time else local.strftime("%d.%m.%Y")
|
||||
|
||||
|
||||
def format_date(value: Any) -> str:
|
||||
return format_datetime(value, with_time=False)
|
||||
|
||||
|
||||
def format_duration(seconds: Any) -> str:
|
||||
try:
|
||||
total = int(seconds)
|
||||
except (TypeError, ValueError):
|
||||
return "-"
|
||||
if total < 60:
|
||||
return f"{total} s"
|
||||
minutes, secs = divmod(total, 60)
|
||||
if minutes < 60:
|
||||
return f"{minutes} min {secs} s" if secs else f"{minutes} min"
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
if hours < 24:
|
||||
return f"{hours} h {minutes} min" if minutes else f"{hours} h"
|
||||
days, hours = divmod(hours, 24)
|
||||
return f"{days} d {hours} h" if hours else f"{days} d"
|
||||
|
||||
|
||||
def format_number(value: Any) -> str:
|
||||
try:
|
||||
return f"{int(value):n}".replace(",", ".")
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
|
||||
|
||||
def yes_no(value: Any) -> str:
|
||||
return "ja" if value else "nein"
|
||||
|
||||
|
||||
def from_json(value: Any) -> Any:
|
||||
"""Parst einen JSON-String aus der Datenbank -- fuer Modul-/Rechtelisten.
|
||||
|
||||
Wirft nie: kaputte Daten fuehren zu einer leeren Liste, nicht zu einer
|
||||
Fehlerseite.
|
||||
"""
|
||||
if value in (None, ""):
|
||||
return []
|
||||
if isinstance(value, (list, dict)):
|
||||
return value
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
return _json.loads(str(value))
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Registrierung
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def asset_url(app: Flask) -> Any:
|
||||
"""``asset_url('css/base.css')`` mit Cache-Buster aus der Dateizeit."""
|
||||
cache: dict[str, str] = {}
|
||||
|
||||
def build(filename: str, blueprint: str = "tesm_core") -> str:
|
||||
if filename in cache:
|
||||
return cache[filename]
|
||||
endpoint = f"{blueprint}.static" if blueprint else "static"
|
||||
try:
|
||||
url = url_for(endpoint, filename=filename)
|
||||
except Exception: # noqa: BLE001 - Blueprint ohne static-Ordner
|
||||
url = url_for("static", filename=filename)
|
||||
version = ""
|
||||
try:
|
||||
static_folder = app.blueprints[blueprint].static_folder if blueprint else app.static_folder
|
||||
if static_folder:
|
||||
path = Path(static_folder) / filename
|
||||
if path.is_file():
|
||||
version = str(int(path.stat().st_mtime))
|
||||
except (KeyError, OSError):
|
||||
version = ""
|
||||
result = f"{url}?v={version}" if version else url
|
||||
if not app.debug:
|
||||
cache[filename] = result
|
||||
return result
|
||||
|
||||
return build
|
||||
|
||||
|
||||
def init_app(app: Flask) -> None:
|
||||
app.jinja_env.globals.update(
|
||||
icon=icon,
|
||||
asset_url=asset_url(app),
|
||||
# Kleine Python-Builtins, die in Templates immer wieder gebraucht werden.
|
||||
# Jinja bringt sie nicht mit; ohne sie muesste jedes Template den Umweg
|
||||
# ueber eigene Filter gehen.
|
||||
bool=bool,
|
||||
int=int,
|
||||
str=str,
|
||||
len=len,
|
||||
min=min,
|
||||
max=max,
|
||||
sorted=sorted,
|
||||
dict=dict,
|
||||
list=list,
|
||||
set=set,
|
||||
enumerate=enumerate,
|
||||
)
|
||||
app.jinja_env.filters.update(
|
||||
bytes=format_bytes,
|
||||
dt=format_datetime,
|
||||
date=format_date,
|
||||
duration=format_duration,
|
||||
number=format_number,
|
||||
yes_no=yes_no,
|
||||
from_json=from_json,
|
||||
)
|
||||
app.jinja_env.trim_blocks = True
|
||||
app.jinja_env.lstrip_blocks = True
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Blueprints, die beide Anwendungen teilen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import account, admin, diagnostics, trash
|
||||
|
||||
__all__ = ["account", "admin", "diagnostics", "trash"]
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Eigenes Konto: Stammdaten, Passwort, zweiter Faktor, eigene Sitzungen.
|
||||
|
||||
Bewusst getrennt von der Benutzerverwaltung: hier aendert jeder nur sich
|
||||
selbst, ohne irgendein Recht aus der Rechtematrix.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
abort,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_from_directory,
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from .. import audit
|
||||
from ..auth.models import current_user, login_required, reauth_required
|
||||
from ..auth.service import client_ip, confirm_password
|
||||
from ..db import Database
|
||||
from ..extension import core, db, transaction
|
||||
from ..rbac import service as rbac
|
||||
from ..security import passwords, sessions as session_store, totp
|
||||
|
||||
bp = Blueprint("account", __name__, url_prefix="/konto")
|
||||
|
||||
ALLOWED_AVATAR_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||
MAX_AVATAR_BYTES = 2 * 1024 * 1024
|
||||
|
||||
#: Magische Bytes je erlaubtem Bildtyp. Der Vorgaenger prueft nur die Endung --
|
||||
#: damit laesst sich beliebiger Inhalt als ".png" hochladen.
|
||||
_MAGIC = {
|
||||
b"\x89PNG\r\n\x1a\n": "png",
|
||||
b"\xff\xd8\xff": "jpeg",
|
||||
b"GIF87a": "gif",
|
||||
b"GIF89a": "gif",
|
||||
b"RIFF": "webp",
|
||||
}
|
||||
|
||||
|
||||
def _avatar_dir() -> Path:
|
||||
path = core().config.data_dir / "avatars"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _audit(conn, action: str, detail: str = "", severity: str = "info") -> None:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action=action,
|
||||
target=current_user.username,
|
||||
detail=detail,
|
||||
ip=client_ip(),
|
||||
severity=severity,
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@login_required
|
||||
def overview():
|
||||
conn = db()
|
||||
row = rbac.get_user(conn, current_user.id) or {}
|
||||
return render_template(
|
||||
"tesm_core/account.html",
|
||||
profile=row,
|
||||
groups=rbac.user_groups(conn, current_user.id),
|
||||
permissions=sorted(current_user.effective_permissions),
|
||||
tree=core().permissions,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/stammdaten")
|
||||
@login_required
|
||||
def update_profile():
|
||||
if current_user.is_directory_account:
|
||||
flash(
|
||||
"Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder "
|
||||
"Anmeldung uebernommen.",
|
||||
"info",
|
||||
)
|
||||
return redirect(url_for("account.overview"))
|
||||
with transaction() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET first_name=?, last_name=?, email=?, updated_at=? WHERE id=?",
|
||||
(
|
||||
(request.form.get("first_name") or "").strip()[:64],
|
||||
(request.form.get("last_name") or "").strip()[:64],
|
||||
(request.form.get("email") or "").strip()[:190],
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
current_user.id,
|
||||
),
|
||||
)
|
||||
_audit(conn, "account.profile_updated")
|
||||
flash("Stammdaten gespeichert.", "success")
|
||||
return redirect(url_for("account.overview"))
|
||||
|
||||
|
||||
@bp.post("/profilbild")
|
||||
@login_required
|
||||
def upload_avatar():
|
||||
upload = request.files.get("avatar")
|
||||
if upload is None or not upload.filename:
|
||||
flash("Keine Datei ausgewaehlt.", "danger")
|
||||
return redirect(url_for("account.overview"))
|
||||
|
||||
suffix = Path(upload.filename).suffix.lower()
|
||||
if suffix not in ALLOWED_AVATAR_SUFFIXES:
|
||||
flash("Erlaubt sind PNG, JPG, WEBP und GIF.", "danger")
|
||||
return redirect(url_for("account.overview"))
|
||||
|
||||
head = upload.stream.read(16)
|
||||
upload.stream.seek(0)
|
||||
if not any(head.startswith(magic) for magic in _MAGIC):
|
||||
flash("Die Datei ist kein Bild.", "danger")
|
||||
return redirect(url_for("account.overview"))
|
||||
|
||||
data = upload.stream.read(MAX_AVATAR_BYTES + 1)
|
||||
if len(data) > MAX_AVATAR_BYTES:
|
||||
flash("Das Bild ist zu gross (maximal 2 MB).", "danger")
|
||||
return redirect(url_for("account.overview"))
|
||||
|
||||
filename = f"{current_user.id}-{secrets.token_hex(8)}{suffix}"
|
||||
target = _avatar_dir() / secure_filename(filename)
|
||||
target.write_bytes(data)
|
||||
|
||||
with transaction() as conn:
|
||||
previous = Database.value(
|
||||
conn, "SELECT avatar_filename FROM users WHERE id=?", (current_user.id,)
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE users SET avatar_filename=?, updated_at=? WHERE id=?",
|
||||
(target.name, datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id),
|
||||
)
|
||||
_audit(conn, "account.avatar_updated")
|
||||
if previous:
|
||||
(_avatar_dir() / str(previous)).unlink(missing_ok=True)
|
||||
flash("Profilbild aktualisiert.", "success")
|
||||
return redirect(url_for("account.overview"))
|
||||
|
||||
|
||||
@bp.get("/profilbild/<path:filename>")
|
||||
@login_required
|
||||
def avatar(filename: str):
|
||||
safe = secure_filename(filename)
|
||||
if safe != filename or Path(safe).suffix.lower() not in ALLOWED_AVATAR_SUFFIXES:
|
||||
abort(404)
|
||||
return send_from_directory(_avatar_dir(), safe, max_age=3600)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Sicherheit
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/sicherheit")
|
||||
@login_required
|
||||
def security():
|
||||
conn = db()
|
||||
row = rbac.get_user(conn, current_user.id) or {}
|
||||
pending_secret = session.get("_totp_setup", "")
|
||||
return render_template(
|
||||
"tesm_core/account_security.html",
|
||||
profile=row,
|
||||
policy=core().password_policy,
|
||||
sessions=session_store.active_for_user(conn, current_user.id),
|
||||
current_sid=getattr(session, "sid", ""),
|
||||
totp_enabled=bool(row.get("totp_secret")),
|
||||
pending_secret=pending_secret,
|
||||
pending_uri=(
|
||||
totp.provisioning_uri(
|
||||
pending_secret, account=current_user.username, issuer=core().config.app_name
|
||||
)
|
||||
if pending_secret
|
||||
else ""
|
||||
),
|
||||
pending_secret_grouped=totp.format_secret(pending_secret) if pending_secret else "",
|
||||
recovery_remaining=int(
|
||||
Database.value(
|
||||
conn,
|
||||
"SELECT COUNT(*) FROM user_recovery_codes WHERE user_id=? AND used_at IS NULL",
|
||||
(current_user.id,),
|
||||
default=0,
|
||||
)
|
||||
or 0
|
||||
),
|
||||
recovery_codes=session.pop("_recovery_codes", []),
|
||||
require_totp_for_admins=core().config.security.require_totp_for_admins,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/passwort")
|
||||
@login_required
|
||||
def change_password():
|
||||
conn = db()
|
||||
if current_user.is_directory_account:
|
||||
flash("Verzeichniskonten aendern ihr Passwort im Verzeichnisdienst.", "info")
|
||||
return redirect(url_for("account.security"))
|
||||
|
||||
current = request.form.get("current_password") or ""
|
||||
new = request.form.get("new_password") or ""
|
||||
repeat = request.form.get("repeat_password") or ""
|
||||
|
||||
if not confirm_password(conn, current_user, current):
|
||||
flash("Das aktuelle Passwort ist nicht korrekt.", "danger")
|
||||
return redirect(url_for("account.security"))
|
||||
if new != repeat:
|
||||
flash("Die beiden neuen Passwoerter stimmen nicht ueberein.", "danger")
|
||||
return redirect(url_for("account.security"))
|
||||
if passwords.constant_time_equals(current, new):
|
||||
flash("Das neue Passwort muss sich vom alten unterscheiden.", "danger")
|
||||
return redirect(url_for("account.security"))
|
||||
|
||||
try:
|
||||
with transaction():
|
||||
rbac.set_password(conn, current_user.id, new, policy=core().password_policy)
|
||||
# Alle anderen Sitzungen beenden: nach einem Passwortwechsel soll
|
||||
# eine ggf. uebernommene Sitzung nicht weiterlaufen.
|
||||
revoked = session_store.revoke_all_for_user(
|
||||
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="password-change"
|
||||
)
|
||||
_audit(
|
||||
conn,
|
||||
"account.password_changed",
|
||||
f"{revoked} weitere Sitzungen beendet",
|
||||
severity="notice",
|
||||
)
|
||||
except rbac.ValidationError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("account.security"))
|
||||
|
||||
flash("Passwort geaendert. Andere Sitzungen wurden beendet.", "success")
|
||||
return redirect(url_for("account.security"))
|
||||
|
||||
|
||||
@bp.post("/zwei-faktor/starten")
|
||||
@login_required
|
||||
@reauth_required
|
||||
def totp_start():
|
||||
session["_totp_setup"] = totp.generate_secret()
|
||||
flash("Scannen Sie den Code und bestaetigen Sie mit einem gueltigen Zahlencode.", "info")
|
||||
return redirect(url_for("account.security"))
|
||||
|
||||
|
||||
@bp.post("/zwei-faktor/bestaetigen")
|
||||
@login_required
|
||||
@reauth_required
|
||||
def totp_confirm():
|
||||
secret = session.get("_totp_setup", "")
|
||||
if not secret:
|
||||
flash("Es laeuft keine Einrichtung. Bitte neu starten.", "danger")
|
||||
return redirect(url_for("account.security"))
|
||||
if not totp.verify_code(secret, request.form.get("code") or ""):
|
||||
flash("Der Code ist nicht korrekt. Bitte erneut versuchen.", "danger")
|
||||
return redirect(url_for("account.security"))
|
||||
|
||||
codes = totp.generate_recovery_codes()
|
||||
with transaction() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET totp_secret=?, totp_confirmed_at=?, updated_at=? WHERE id=?",
|
||||
(
|
||||
secret,
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
current_user.id,
|
||||
),
|
||||
)
|
||||
conn.execute("DELETE FROM user_recovery_codes WHERE user_id=?", (current_user.id,))
|
||||
conn.executemany(
|
||||
"INSERT INTO user_recovery_codes (user_id, code_hash, created_at) VALUES (?,?,?)",
|
||||
[
|
||||
(
|
||||
current_user.id,
|
||||
passwords.hash_password(code),
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
)
|
||||
for code in codes
|
||||
],
|
||||
)
|
||||
_audit(conn, "account.totp_enabled", severity="notice")
|
||||
|
||||
session.pop("_totp_setup", None)
|
||||
session["_recovery_codes"] = codes
|
||||
flash("Zwei-Faktor-Authentisierung aktiviert. Bitte die Wiederherstellungscodes sichern.", "success")
|
||||
return redirect(url_for("account.security"))
|
||||
|
||||
|
||||
@bp.post("/zwei-faktor/deaktivieren")
|
||||
@login_required
|
||||
@reauth_required
|
||||
def totp_disable():
|
||||
if core().config.security.require_totp_for_admins and current_user.is_admin:
|
||||
flash(
|
||||
"Fuer Administratoren ist die Zwei-Faktor-Authentisierung in dieser "
|
||||
"Installation verpflichtend.",
|
||||
"danger",
|
||||
)
|
||||
return redirect(url_for("account.security"))
|
||||
with transaction() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET totp_secret='', totp_confirmed_at=NULL, updated_at=? WHERE id=?",
|
||||
(datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id),
|
||||
)
|
||||
conn.execute("DELETE FROM user_recovery_codes WHERE user_id=?", (current_user.id,))
|
||||
_audit(conn, "account.totp_disabled", severity="warning")
|
||||
flash("Zwei-Faktor-Authentisierung deaktiviert.", "info")
|
||||
return redirect(url_for("account.security"))
|
||||
|
||||
|
||||
@bp.post("/zwei-faktor/codes-erneuern")
|
||||
@login_required
|
||||
@reauth_required
|
||||
def recovery_codes():
|
||||
codes = totp.generate_recovery_codes()
|
||||
with transaction() as conn:
|
||||
conn.execute("DELETE FROM user_recovery_codes WHERE user_id=?", (current_user.id,))
|
||||
conn.executemany(
|
||||
"INSERT INTO user_recovery_codes (user_id, code_hash, created_at) VALUES (?,?,?)",
|
||||
[
|
||||
(
|
||||
current_user.id,
|
||||
passwords.hash_password(code),
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
)
|
||||
for code in codes
|
||||
],
|
||||
)
|
||||
_audit(conn, "account.recovery_codes_renewed", severity="notice")
|
||||
session["_recovery_codes"] = codes
|
||||
flash("Neue Wiederherstellungscodes erzeugt. Die alten gelten nicht mehr.", "success")
|
||||
return redirect(url_for("account.security"))
|
||||
|
||||
|
||||
@bp.post("/sitzungen/<sid>/beenden")
|
||||
@login_required
|
||||
def revoke_session(sid: str):
|
||||
conn = db()
|
||||
owner = Database.value(conn, "SELECT user_id FROM user_sessions WHERE id=?", (sid,))
|
||||
if owner is None or int(owner) != current_user.id:
|
||||
abort(404)
|
||||
with transaction():
|
||||
session_store.revoke(conn, sid, reason="self")
|
||||
_audit(conn, "account.session_revoked", sid[:12])
|
||||
flash("Sitzung beendet.", "success")
|
||||
return redirect(url_for("account.security"))
|
||||
|
||||
|
||||
@bp.post("/sitzungen/alle-beenden")
|
||||
@login_required
|
||||
def revoke_other_sessions():
|
||||
with transaction() as conn:
|
||||
count = session_store.revoke_all_for_user(
|
||||
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="self"
|
||||
)
|
||||
_audit(conn, "account.sessions_revoked", f"{count} Sitzungen")
|
||||
flash(f"{count} weitere Sitzung(en) beendet.", "success")
|
||||
return redirect(url_for("account.security"))
|
||||
@@ -0,0 +1,440 @@
|
||||
"""Benutzer- und Gruppenverwaltung -- identisch in beiden Anwendungen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for
|
||||
|
||||
from .. import audit
|
||||
from ..auth import directory
|
||||
from ..auth.models import admin_required, current_user, login_required, permission_required
|
||||
from ..auth.service import client_ip
|
||||
from ..db import Database
|
||||
from ..extension import core, db, transaction
|
||||
from ..rbac import service as rbac
|
||||
from ..rbac.model import ACTION_META, Action
|
||||
from ..security import sessions as session_store
|
||||
|
||||
bp = Blueprint("admin", __name__, url_prefix="/verwaltung")
|
||||
|
||||
|
||||
def _audit(conn: Any, action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action=action,
|
||||
target=target,
|
||||
detail=detail,
|
||||
ip=client_ip(),
|
||||
severity=severity,
|
||||
)
|
||||
|
||||
|
||||
def _int_list(name: str) -> list[int]:
|
||||
values: list[int] = []
|
||||
for raw in request.form.getlist(name):
|
||||
try:
|
||||
values.append(int(raw))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return values
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Benutzer
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/benutzer")
|
||||
@permission_required("users.view")
|
||||
def users():
|
||||
conn = db()
|
||||
return render_template(
|
||||
"tesm_core/users.html",
|
||||
users=rbac.list_users(conn),
|
||||
groups=[group for group in rbac.list_groups(conn)],
|
||||
directory_enabled=directory.is_enabled(conn),
|
||||
policy=core().password_policy,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/benutzer/neu")
|
||||
@permission_required("users.create")
|
||||
def user_create():
|
||||
conn = db()
|
||||
try:
|
||||
with transaction():
|
||||
user_id = rbac.create_user(
|
||||
conn,
|
||||
username=request.form.get("username", ""),
|
||||
password=request.form.get("password", ""),
|
||||
is_admin=request.form.get("is_admin") == "1",
|
||||
first_name=request.form.get("first_name", ""),
|
||||
last_name=request.form.get("last_name", ""),
|
||||
email=request.form.get("email", ""),
|
||||
group_ids=_int_list("group_ids"),
|
||||
actor=current_user,
|
||||
tree=core().permissions,
|
||||
policy=core().password_policy,
|
||||
must_change_password=request.form.get("must_change_password") == "1",
|
||||
)
|
||||
_audit(
|
||||
conn,
|
||||
"user.created",
|
||||
request.form.get("username", ""),
|
||||
"Administrator" if request.form.get("is_admin") == "1" else "Benutzer",
|
||||
severity="notice",
|
||||
)
|
||||
flash(f"Konto {request.form.get('username')} wurde angelegt.", "success")
|
||||
return redirect(url_for("admin.users", highlight=user_id))
|
||||
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("admin.users"))
|
||||
|
||||
|
||||
@bp.post("/benutzer/<int:user_id>")
|
||||
@permission_required("users.edit")
|
||||
def user_update(user_id: int):
|
||||
conn = db()
|
||||
try:
|
||||
with transaction():
|
||||
changes = rbac.update_user(
|
||||
conn,
|
||||
user_id,
|
||||
actor=current_user,
|
||||
tree=core().permissions,
|
||||
first_name=request.form.get("first_name"),
|
||||
last_name=request.form.get("last_name"),
|
||||
email=request.form.get("email"),
|
||||
is_admin=request.form.get("is_admin") == "1",
|
||||
is_locked=request.form.get("is_locked") == "1",
|
||||
group_ids=_int_list("group_ids") if "group_ids" in request.form else None,
|
||||
)
|
||||
if request.form.get("new_password"):
|
||||
rbac.set_password(
|
||||
conn,
|
||||
user_id,
|
||||
request.form["new_password"],
|
||||
policy=core().password_policy,
|
||||
must_change=True,
|
||||
)
|
||||
changes.append("Passwort zurueckgesetzt")
|
||||
session_store.revoke_all_for_user(conn, user_id, reason="password-reset")
|
||||
target = rbac.get_user(conn, user_id) or {}
|
||||
if changes:
|
||||
_audit(
|
||||
conn,
|
||||
"user.updated",
|
||||
str(target.get("username") or user_id),
|
||||
"; ".join(changes),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
|
||||
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("admin.users"))
|
||||
|
||||
|
||||
@bp.post("/benutzer/<int:user_id>/loeschen")
|
||||
@permission_required("users.delete")
|
||||
def user_delete(user_id: int):
|
||||
conn = db()
|
||||
try:
|
||||
with transaction():
|
||||
target = rbac.get_user(conn, user_id) or {}
|
||||
mode = rbac.soft_delete_user(conn, user_id, actor=current_user)
|
||||
_audit(
|
||||
conn,
|
||||
"user.deleted",
|
||||
str(target.get("username") or user_id),
|
||||
f"Modus: {mode}",
|
||||
severity="warning",
|
||||
)
|
||||
flash(
|
||||
"Konto endgueltig entfernt (Verzeichniskonto)."
|
||||
if mode == "endgueltig"
|
||||
else "Konto in den Papierkorb verschoben.",
|
||||
"success",
|
||||
)
|
||||
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("admin.users"))
|
||||
|
||||
|
||||
@bp.post("/benutzer/<int:user_id>/sitzungen-beenden")
|
||||
@permission_required("users.edit")
|
||||
def user_revoke_sessions(user_id: int):
|
||||
conn = db()
|
||||
target = rbac.get_user(conn, user_id)
|
||||
if target is None:
|
||||
abort(404)
|
||||
rbac.guard_target_account(current_user, target)
|
||||
with transaction():
|
||||
count = session_store.revoke_all_for_user(conn, user_id, reason="admin")
|
||||
_audit(
|
||||
conn,
|
||||
"user.sessions_revoked",
|
||||
str(target["username"]),
|
||||
f"{count} Sitzungen",
|
||||
severity="notice",
|
||||
)
|
||||
flash(f"{count} Sitzung(en) beendet.", "success")
|
||||
return redirect(url_for("admin.users"))
|
||||
|
||||
|
||||
@bp.get("/benutzer/verzeichnis-suche")
|
||||
@permission_required("users.create")
|
||||
def directory_search():
|
||||
conn = db()
|
||||
query = (request.args.get("q") or "").strip()
|
||||
existing = {
|
||||
str(row["username"]).lower() for row in rbac.list_users(conn, include_deleted=True)
|
||||
}
|
||||
results = [
|
||||
entry
|
||||
for entry in directory.search_directory_users(conn, query)
|
||||
if entry["username"].lower() not in existing
|
||||
]
|
||||
return jsonify({"results": results})
|
||||
|
||||
|
||||
@bp.post("/benutzer/verzeichnis-uebernehmen")
|
||||
@permission_required("users.create")
|
||||
def directory_adopt():
|
||||
"""Legt ein Verzeichniskonto vorab an, damit Gruppen vor dem ersten Login sitzen."""
|
||||
conn = db()
|
||||
username = (request.form.get("username") or "").strip()
|
||||
if not username:
|
||||
flash("Kein Benutzername angegeben.", "danger")
|
||||
return redirect(url_for("admin.users"))
|
||||
try:
|
||||
with transaction():
|
||||
user_id = rbac.create_user(
|
||||
conn,
|
||||
username=username,
|
||||
password="",
|
||||
is_admin=False,
|
||||
first_name=request.form.get("first_name", ""),
|
||||
last_name=request.form.get("last_name", ""),
|
||||
email=request.form.get("email", ""),
|
||||
group_ids=_int_list("group_ids"),
|
||||
actor=current_user,
|
||||
tree=core().permissions,
|
||||
policy=core().password_policy,
|
||||
auth_source="ldap",
|
||||
)
|
||||
_audit(conn, "user.directory_adopted", username, severity="notice")
|
||||
flash(f"Verzeichniskonto {username} vorbereitet.", "success")
|
||||
return redirect(url_for("admin.users", highlight=user_id))
|
||||
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("admin.users"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Gruppen
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/gruppen")
|
||||
@permission_required("groups.view")
|
||||
def groups():
|
||||
conn = db()
|
||||
tree = core().permissions
|
||||
entries = rbac.list_groups(conn)
|
||||
return render_template(
|
||||
"tesm_core/groups.html",
|
||||
groups=entries,
|
||||
summaries={
|
||||
group.id: rbac.summarize_permissions(tree, group.permissions) for group in entries
|
||||
},
|
||||
tree=tree,
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/gruppen/<int:group_id>")
|
||||
@permission_required("groups.view")
|
||||
def group_detail(group_id: int):
|
||||
conn = db()
|
||||
group = rbac.get_group(conn, group_id)
|
||||
if group is None or group.deleted_at:
|
||||
abort(404)
|
||||
tree = core().permissions
|
||||
return render_template(
|
||||
"tesm_core/group_detail.html",
|
||||
group=group,
|
||||
tree=tree,
|
||||
state=rbac.matrix_state(tree, group.permissions),
|
||||
members=Database.all(
|
||||
conn,
|
||||
"SELECT u.id, u.username, u.first_name, u.last_name, u.is_admin, u.auth_source "
|
||||
"FROM user_groups ug JOIN users u ON u.id = ug.user_id "
|
||||
"WHERE ug.group_id=? AND u.deleted_at IS NULL ORDER BY u.username COLLATE NOCASE",
|
||||
(group_id,),
|
||||
),
|
||||
can_edit=current_user.has("groups.edit"),
|
||||
unlocked=request.args.get("freischalten") == "1",
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/gruppen/neu")
|
||||
@permission_required("groups.create")
|
||||
def group_create():
|
||||
conn = db()
|
||||
try:
|
||||
with transaction():
|
||||
group_id = rbac.create_group(
|
||||
conn,
|
||||
name=request.form.get("name", ""),
|
||||
description=request.form.get("description", ""),
|
||||
permissions=request.form.getlist("permissions"),
|
||||
tree=core().permissions,
|
||||
actor=current_user,
|
||||
)
|
||||
_audit(conn, "group.created", request.form.get("name", ""), severity="notice")
|
||||
flash("Gruppe angelegt.", "success")
|
||||
return redirect(url_for("admin.group_detail", group_id=group_id))
|
||||
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("admin.groups"))
|
||||
|
||||
|
||||
@bp.post("/gruppen/<int:group_id>")
|
||||
@permission_required("groups.edit")
|
||||
def group_update(group_id: int):
|
||||
conn = db()
|
||||
try:
|
||||
with transaction():
|
||||
changes = rbac.update_group(
|
||||
conn,
|
||||
group_id,
|
||||
name=request.form.get("name"),
|
||||
description=request.form.get("description"),
|
||||
permissions=request.form.getlist("permissions")
|
||||
if "permissions_submitted" in request.form
|
||||
else None,
|
||||
tree=core().permissions,
|
||||
actor=current_user,
|
||||
unlock_system=request.form.get("unlock_system") == "1",
|
||||
)
|
||||
group = rbac.get_group(conn, group_id)
|
||||
if changes:
|
||||
_audit(
|
||||
conn,
|
||||
"group.updated",
|
||||
group.name if group else str(group_id),
|
||||
"; ".join(changes),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
|
||||
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("admin.group_detail", group_id=group_id))
|
||||
|
||||
|
||||
@bp.post("/gruppen/<int:group_id>/loeschen")
|
||||
@permission_required("groups.delete")
|
||||
def group_delete(group_id: int):
|
||||
conn = db()
|
||||
try:
|
||||
with transaction():
|
||||
group = rbac.get_group(conn, group_id)
|
||||
rbac.soft_delete_group(conn, group_id)
|
||||
_audit(
|
||||
conn,
|
||||
"group.deleted",
|
||||
group.name if group else str(group_id),
|
||||
severity="warning",
|
||||
)
|
||||
flash("Gruppe in den Papierkorb verschoben.", "success")
|
||||
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("admin.groups"))
|
||||
|
||||
|
||||
@bp.post("/gruppen/<int:group_id>/vorlage-anwenden")
|
||||
@admin_required
|
||||
def group_apply_preset(group_id: int):
|
||||
"""Setzt eine Gruppe auf ihre Vorlage zurueck -- ausdruecklich, nie automatisch."""
|
||||
conn = db()
|
||||
group = rbac.get_group(conn, group_id)
|
||||
if group is None:
|
||||
abort(404)
|
||||
preset = next((p for p in core().role_presets if p.key == group.preset_key), None)
|
||||
if preset is None:
|
||||
flash("Fuer diese Gruppe ist keine Vorlage hinterlegt.", "info")
|
||||
return redirect(url_for("admin.group_detail", group_id=group_id))
|
||||
tree = core().permissions
|
||||
with transaction():
|
||||
rbac.update_group(
|
||||
conn,
|
||||
group_id,
|
||||
permissions=tree.expand_preset(preset),
|
||||
tree=tree,
|
||||
actor=current_user,
|
||||
unlock_system=True,
|
||||
)
|
||||
_audit(conn, "group.preset_applied", group.name, preset.key, severity="notice")
|
||||
flash(f"Rechte der Vorlage {preset.name} uebernommen.", "success")
|
||||
return redirect(url_for("admin.group_detail", group_id=group_id))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Sitzungen
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/sitzungen")
|
||||
@admin_required
|
||||
def sessions():
|
||||
conn = db()
|
||||
rows = Database.all(
|
||||
conn,
|
||||
"SELECT s.*, u.username FROM user_sessions s LEFT JOIN users u ON u.id = s.user_id "
|
||||
"WHERE s.revoked_at IS NULL AND s.idle_expires_at > datetime('now') "
|
||||
"ORDER BY s.last_seen_at DESC LIMIT 500",
|
||||
)
|
||||
return render_template("tesm_core/sessions.html", sessions=rows)
|
||||
|
||||
|
||||
@bp.post("/sitzungen/<sid>/beenden")
|
||||
@admin_required
|
||||
def session_revoke(sid: str):
|
||||
with transaction() as conn:
|
||||
session_store.revoke(conn, sid, reason="admin")
|
||||
_audit(conn, "session.revoked", sid[:12], severity="notice")
|
||||
flash("Sitzung beendet.", "success")
|
||||
return redirect(url_for("admin.sessions"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Navigationsreihenfolge (nur echte Administratoren)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/navigation")
|
||||
@admin_required
|
||||
def nav_order():
|
||||
extension = core()
|
||||
order = extension.nav.sanitize_order(request.form.getlist("order"))
|
||||
child_order: dict[str, list[str]] = {}
|
||||
for item in extension.nav.items:
|
||||
if not item.children:
|
||||
continue
|
||||
submitted = request.form.getlist(f"child_order_{item.key}")
|
||||
if submitted:
|
||||
child_order[item.key] = extension.nav.sanitize_child_order(item.key, submitted)
|
||||
with transaction() as conn:
|
||||
extension.settings.set(conn, "nav_order", order, actor=current_user.username)
|
||||
extension.settings.set(conn, "nav_child_order", child_order, actor=current_user.username)
|
||||
_audit(conn, "system.nav_order_changed", severity="info")
|
||||
flash("Reihenfolge der Navigation gespeichert.", "success")
|
||||
return redirect(request.referrer or url_for("index"))
|
||||
|
||||
|
||||
#: Kurzbezeichnungen der Aktionen -- von Templates und Audit-Texten genutzt.
|
||||
ACTION_LABELS: dict[Action, str] = {
|
||||
action: meta[1] for action, meta in ACTION_META.items()
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Diagnoseseite: Schemastand, Protokollintegritaet, Dateirechte, Lizenzzustand.
|
||||
|
||||
Diese Seite gab es im Vorgaenger nicht. Sie beantwortet die Fragen, die bei
|
||||
jedem Zwischenfall zuerst gestellt werden -- laeuft das Schema auf dem
|
||||
erwarteten Stand, ist das Aenderungsprotokoll unversehrt, liegen die
|
||||
Schluesseldateien mit korrekten Rechten, ist der privilegierte Helfer
|
||||
erreichbar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, url_for
|
||||
|
||||
from .. import audit
|
||||
from ..auth.models import admin_required, current_user, permission_required
|
||||
from ..db import Database
|
||||
from ..extension import core, db, transaction
|
||||
from ..keystore import check_private_file
|
||||
from ..security import ratelimit
|
||||
from ..security import sessions as session_store
|
||||
|
||||
bp = Blueprint("diagnostics", __name__, url_prefix="/diagnose")
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("diagnostics.view")
|
||||
def overview():
|
||||
extension = core()
|
||||
conn = db()
|
||||
config = extension.config
|
||||
|
||||
chain = audit.verify_chain(conn, limit=5000)
|
||||
key_problems: list[str] = []
|
||||
for path in (config.data_key_file, config.secret_key_file, config.license_key_path):
|
||||
key_problems.extend(check_private_file(path))
|
||||
|
||||
counts = {
|
||||
"Benutzer": Database.value(conn, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL", default=0),
|
||||
"Gruppen": Database.value(conn, "SELECT COUNT(*) FROM groups WHERE deleted_at IS NULL", default=0),
|
||||
"Aktive Sitzungen": Database.value(
|
||||
conn,
|
||||
"SELECT COUNT(*) FROM user_sessions WHERE revoked_at IS NULL "
|
||||
"AND idle_expires_at > datetime('now')",
|
||||
default=0,
|
||||
),
|
||||
"Protokollzeilen": audit.count(conn),
|
||||
}
|
||||
|
||||
try:
|
||||
db_size = config.db_path.stat().st_size
|
||||
except OSError:
|
||||
db_size = 0
|
||||
|
||||
return render_template(
|
||||
"tesm_core/diagnostics.html",
|
||||
migrations=extension.migration_status(),
|
||||
chain=chain,
|
||||
key_problems=key_problems,
|
||||
counts=counts,
|
||||
db_size=db_size,
|
||||
db_path=str(config.db_path),
|
||||
log_dir=str(config.log_dir),
|
||||
keystore_active=extension.keystore.active_key_id,
|
||||
keystore_keys=extension.keystore.key_ids(),
|
||||
sysops_available=bool(extension.sysops and extension.sysops.available),
|
||||
sysops_helper=str(extension.sysops.helper) if extension.sysops else "",
|
||||
# Nicht "liegt die Datei da", sondern "laesst er sich aufrufen". Der
|
||||
# Unterschied hat einmal gekostet: die Seite meldete "erreichbar",
|
||||
# waehrend jeder Aufruf an NoNewPrivileges scheiterte.
|
||||
sysops_selftest=(extension.sysops.selftest() if extension.sysops else None),
|
||||
license_state=extension.license.state if extension.license else None,
|
||||
runtime={
|
||||
"Python": sys.version.split()[0],
|
||||
"Plattform": platform.platform(),
|
||||
"Prozess": f"PID {os.getpid()}",
|
||||
"Webprozess-Kennzeichen": "gesetzt" if config.web_process else "nicht gesetzt",
|
||||
"Debug": "ja" if config.debug else "nein",
|
||||
"Cookie Secure": "ja" if config.security.cookie_secure else "nein",
|
||||
"HSTS": "ja" if config.security.hsts_enabled else "nein",
|
||||
"2FA-Pflicht fuer Admins": "ja" if config.security.require_totp_for_admins else "nein",
|
||||
},
|
||||
now=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/aufraeumen")
|
||||
@admin_required
|
||||
def housekeeping():
|
||||
"""Raeumt abgelaufene Sitzungen, alte Ratenbegrenzungs-Ereignisse und Protokolle."""
|
||||
extension = core()
|
||||
with transaction() as conn:
|
||||
sessions_removed = session_store.prune(conn)
|
||||
rate_removed = ratelimit.prune(conn)
|
||||
archived = audit.archive_old_rows(conn, extension.audit_archive_dir)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="system.housekeeping",
|
||||
detail=(
|
||||
f"{sessions_removed} Sitzungen, {rate_removed} Ratenereignisse, "
|
||||
f"{len(archived)} Protokolltage archiviert"
|
||||
),
|
||||
severity="notice",
|
||||
)
|
||||
flash(
|
||||
f"Aufgeraeumt: {sessions_removed} Sitzungen, {rate_removed} Ratenereignisse, "
|
||||
f"{len(archived)} archivierte Protokolltage.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("diagnostics.overview"))
|
||||
|
||||
|
||||
@bp.post("/schluessel-rotieren")
|
||||
@admin_required
|
||||
def rotate_keys():
|
||||
"""Legt einen neuen Datenschluessel an und verschluesselt bestehende Werte neu.
|
||||
|
||||
Der Vorgaenger hatte genau einen, nie wechselbaren Fernet-Schluessel. Hier
|
||||
bleibt der alte Schluessel zum Lesen erhalten, bis alle Werte umgezogen sind.
|
||||
"""
|
||||
extension = core()
|
||||
keystore = extension.keystore
|
||||
new_id = keystore.rotate()
|
||||
migrated = 0
|
||||
|
||||
reencrypt = extension.registry.get("reencrypt_secrets")
|
||||
with transaction() as conn:
|
||||
if callable(reencrypt):
|
||||
migrated = int(reencrypt(conn, keystore))
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="system.data_key_rotated",
|
||||
target=new_id,
|
||||
detail=f"{migrated} Werte neu verschluesselt",
|
||||
severity="notice",
|
||||
)
|
||||
flash(
|
||||
f"Neuer Datenschluessel {new_id} aktiv, {migrated} gespeicherte Geheimnisse "
|
||||
"wurden neu verschluesselt.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("diagnostics.overview"))
|
||||
@@ -0,0 +1,529 @@
|
||||
"""Einstellungen auf Host-Ebene: Netzwerk, Webserver/TLS, Verzeichnisdienst.
|
||||
|
||||
In beiden Anwendungen identisch -- darum genau einmal hier. Jede App bringt
|
||||
zusaetzlich nur ihre eigene Systemseite mit.
|
||||
|
||||
Lizenzpflicht wird serverseitig durchgesetzt (``license.require``). Der
|
||||
Vorgaenger pruefte Verzeichnisdienst und Webserver nur im Template; ein direkter
|
||||
POST kam trotz fehlender Lizenz durch, obwohl die Dokumentation das Gegenteil
|
||||
behauptete.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
|
||||
|
||||
from .. import audit
|
||||
from ..auth import directory
|
||||
from ..auth.models import current_user, permission_required
|
||||
from ..auth.service import client_ip
|
||||
from ..db import Database
|
||||
from ..extension import core, db, transaction
|
||||
from ..licensing_client import LicenseError
|
||||
from ..rbac import service as rbac
|
||||
from ..system import hostnet, webserver
|
||||
|
||||
bp = Blueprint("hostsettings", __name__, url_prefix="/einstellungen")
|
||||
|
||||
PENDING_NETWORK_KEY = "pending_network_revert"
|
||||
|
||||
|
||||
def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
|
||||
with transaction() as conn:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action=action,
|
||||
target=target,
|
||||
detail=detail,
|
||||
ip=client_ip(),
|
||||
severity=severity,
|
||||
)
|
||||
|
||||
|
||||
def _license_hint(feature: str) -> str:
|
||||
manager = core().license
|
||||
if manager is None:
|
||||
return ""
|
||||
try:
|
||||
manager.require(feature)
|
||||
except LicenseError as exc:
|
||||
return str(exc)
|
||||
return ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Netzwerk
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/netzwerk")
|
||||
@permission_required("settings_network.view")
|
||||
def network():
|
||||
extension = core()
|
||||
sysops = extension.sysops
|
||||
return render_template(
|
||||
"tesm_core/settings_network.html",
|
||||
interfaces=hostnet.list_interfaces(sysops) if sysops else [],
|
||||
backend=hostnet.detect_backend(sysops) if sysops else hostnet.BACKEND_UNKNOWN,
|
||||
revert_seconds=hostnet.REVERT_SECONDS,
|
||||
pending=extension.registry.get(PENDING_NETWORK_KEY),
|
||||
sysops_available=bool(sysops and sysops.available),
|
||||
netplan_path=f"/etc/netplan/90-{extension.config.site_key}.yaml",
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/netzwerk")
|
||||
@permission_required("settings_network.edit")
|
||||
def network_apply():
|
||||
extension = core()
|
||||
sysops = extension.sysops
|
||||
if sysops is None or not sysops.available:
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
||||
return redirect(url_for("hostsettings.network"))
|
||||
|
||||
interface = (request.form.get("interface") or "").strip()
|
||||
if interface not in {entry.name for entry in hostnet.list_interfaces(sysops)}:
|
||||
flash("Unbekanntes Interface.", "danger")
|
||||
return redirect(url_for("hostsettings.network"))
|
||||
|
||||
dhcp = request.form.get("mode") == "dhcp"
|
||||
try:
|
||||
if dhcp:
|
||||
content = hostnet.render_netplan(interface=interface, dhcp=True)
|
||||
else:
|
||||
address, prefix, gateway, dns = hostnet.validate_static(
|
||||
address=request.form.get("address", ""),
|
||||
prefix=request.form.get("prefix", "24"),
|
||||
gateway=request.form.get("gateway", ""),
|
||||
dns=request.form.get("dns", ""),
|
||||
)
|
||||
content = hostnet.render_netplan(
|
||||
interface=interface,
|
||||
dhcp=False,
|
||||
address=address,
|
||||
prefix=prefix,
|
||||
gateway=gateway,
|
||||
dns=dns,
|
||||
)
|
||||
except ValueError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("hostsettings.network"))
|
||||
|
||||
previous = sysops.run("file-read", f"/etc/netplan/90-{extension.config.site_key}.yaml")
|
||||
write = sysops.write_netplan(content)
|
||||
if not write.ok:
|
||||
flash(f"Schreiben fehlgeschlagen: {write.message}", "danger")
|
||||
return redirect(url_for("hostsettings.network"))
|
||||
applied = sysops.apply_netplan()
|
||||
if not applied.ok:
|
||||
sysops.write_netplan(previous.stdout if previous.ok else "")
|
||||
sysops.apply_netplan()
|
||||
flash(
|
||||
f"Uebernahme fehlgeschlagen, alter Zustand wiederhergestellt: {applied.message}",
|
||||
"danger",
|
||||
)
|
||||
return redirect(url_for("hostsettings.network"))
|
||||
|
||||
extension.registry[PENDING_NETWORK_KEY] = {
|
||||
"interface": interface,
|
||||
"previous": previous.stdout if previous.ok else "",
|
||||
"deadline": time.time() + hostnet.REVERT_SECONDS,
|
||||
}
|
||||
_start_revert_timer(extension)
|
||||
_audit("settings.network_applied", interface, severity="warning")
|
||||
flash(
|
||||
f"Uebernommen. Bitte innerhalb von {hostnet.REVERT_SECONDS} Sekunden bestaetigen -- "
|
||||
"sonst wird automatisch zurueckgerollt.",
|
||||
"warning",
|
||||
)
|
||||
return redirect(url_for("hostsettings.network"))
|
||||
|
||||
|
||||
@bp.post("/netzwerk/bestaetigen")
|
||||
@permission_required("settings_network.edit")
|
||||
def network_confirm():
|
||||
if core().registry.pop(PENDING_NETWORK_KEY, None) is None:
|
||||
flash("Es liegt keine Aenderung zur Bestaetigung vor.", "info")
|
||||
else:
|
||||
_audit("settings.network_confirmed", severity="notice")
|
||||
flash("Netzwerkeinstellungen bestaetigt.", "success")
|
||||
return redirect(url_for("hostsettings.network"))
|
||||
|
||||
|
||||
def _start_revert_timer(extension: Any) -> None:
|
||||
def worker() -> None:
|
||||
time.sleep(hostnet.REVERT_SECONDS + 2)
|
||||
pending = extension.registry.pop(PENDING_NETWORK_KEY, None)
|
||||
if not pending:
|
||||
return
|
||||
sysops = extension.sysops
|
||||
if sysops is not None:
|
||||
sysops.write_netplan(pending.get("previous") or "")
|
||||
sysops.apply_netplan()
|
||||
with extension.database.session() as conn, extension.database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor="system",
|
||||
action="settings.network_auto_reverted",
|
||||
detail="Keine Bestaetigung innerhalb der Frist -- automatisch zurueckgerollt.",
|
||||
severity="warning",
|
||||
)
|
||||
|
||||
threading.Thread(target=worker, name="network-revert", daemon=True).start()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Webserver und TLS
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _web_config() -> webserver.WebConfig:
|
||||
extension = core()
|
||||
conn = db()
|
||||
values = extension.settings.get_many(
|
||||
conn,
|
||||
[
|
||||
"web_domain",
|
||||
"web_https_enabled",
|
||||
"web_hsts_enabled",
|
||||
"web_redirect_http",
|
||||
"web_cert_path",
|
||||
"web_key_path",
|
||||
"web_acme_webroot",
|
||||
"web_max_body_mb",
|
||||
"web_http_port",
|
||||
"web_https_port",
|
||||
],
|
||||
)
|
||||
return webserver.WebConfig(
|
||||
app_key=extension.config.site_key,
|
||||
upstream=str(extension.registry.get("upstream", "127.0.0.1:5000")),
|
||||
static_root=str(extension.registry.get("static_root", extension.config.base_dir / "static")),
|
||||
domain=str(values["web_domain"] or ""),
|
||||
https_enabled=bool(values["web_https_enabled"]),
|
||||
hsts_enabled=bool(values["web_hsts_enabled"]),
|
||||
redirect_http=bool(values["web_redirect_http"]),
|
||||
cert_path=str(values["web_cert_path"] or ""),
|
||||
key_path=str(values["web_key_path"] or ""),
|
||||
acme_webroot=str(values["web_acme_webroot"] or f"/var/www/{extension.config.site_key}-acme"),
|
||||
websocket_path=str(extension.registry.get("websocket_path", "")),
|
||||
max_body_mb=int(values["web_max_body_mb"] or 128),
|
||||
http_port=int(values["web_http_port"] or 80),
|
||||
https_port=int(values["web_https_port"] or 443),
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/webserver")
|
||||
@permission_required("settings_web.view")
|
||||
def web():
|
||||
extension = core()
|
||||
config = _web_config()
|
||||
return render_template(
|
||||
"tesm_core/settings_web.html",
|
||||
config=config,
|
||||
preview=webserver.render(config),
|
||||
certificate=webserver.certificate_info(config.cert_path),
|
||||
acme_email=str(extension.settings.get(db(), "web_acme_email") or ""),
|
||||
license_hint=_license_hint("nginx"),
|
||||
certbot_available=bool(shutil.which("certbot")),
|
||||
sysops_available=bool(extension.sysops and extension.sysops.available),
|
||||
local_names=webserver.local_names(),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/webserver")
|
||||
@permission_required("settings_web.edit")
|
||||
def web_save():
|
||||
problem = _license_hint("nginx")
|
||||
if problem:
|
||||
flash(problem, "danger")
|
||||
return redirect(url_for("hostsettings.web"))
|
||||
|
||||
extension = core()
|
||||
conn = db()
|
||||
try:
|
||||
domain = webserver.validate_domain(request.form.get("web_domain", ""))
|
||||
except ValueError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("hostsettings.web"))
|
||||
|
||||
https_enabled = request.form.get("web_https_enabled") == "1"
|
||||
cert_path = (request.form.get("web_cert_path") or "").strip()
|
||||
key_path = (request.form.get("web_key_path") or "").strip()
|
||||
if https_enabled and not (cert_path and key_path):
|
||||
flash("Fuer HTTPS werden Zertifikat und privater Schluessel gebraucht.", "danger")
|
||||
return redirect(url_for("hostsettings.web"))
|
||||
|
||||
values = {
|
||||
"web_domain": domain,
|
||||
"web_https_enabled": https_enabled,
|
||||
"web_hsts_enabled": request.form.get("web_hsts_enabled") == "1" and https_enabled,
|
||||
"web_redirect_http": request.form.get("web_redirect_http") == "1",
|
||||
"web_cert_path": cert_path,
|
||||
"web_key_path": key_path,
|
||||
"web_acme_email": (request.form.get("web_acme_email") or "").strip(),
|
||||
"web_acme_webroot": (request.form.get("web_acme_webroot") or "").strip()
|
||||
or f"/var/www/{extension.config.site_key}-acme",
|
||||
}
|
||||
with transaction():
|
||||
changed = extension.settings.set_many(conn, values, actor=current_user.username)
|
||||
_audit("settings.web_saved", domain, ", ".join(changed), severity="notice")
|
||||
flash("Gespeichert. Uebernehmen Sie die Konfiguration, damit sie wirksam wird.", "success")
|
||||
return redirect(url_for("hostsettings.web"))
|
||||
|
||||
|
||||
@bp.post("/webserver/uebernehmen")
|
||||
@permission_required("settings_web.execute")
|
||||
def web_apply():
|
||||
problem = _license_hint("nginx")
|
||||
if problem:
|
||||
flash(problem, "danger")
|
||||
return redirect(url_for("hostsettings.web"))
|
||||
sysops = core().sysops
|
||||
if sysops is None or not sysops.available:
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
||||
return redirect(url_for("hostsettings.web"))
|
||||
|
||||
result = webserver.apply_config(sysops, webserver.render(_web_config()))
|
||||
_audit(
|
||||
"settings.web_applied",
|
||||
detail=result.get("message", ""),
|
||||
severity="notice" if result["ok"] else "warning",
|
||||
)
|
||||
flash(
|
||||
result["message"] + (f" {result.get('detail', '')}" if not result["ok"] else ""),
|
||||
"success" if result["ok"] else "danger",
|
||||
)
|
||||
return redirect(url_for("hostsettings.web"))
|
||||
|
||||
|
||||
@bp.post("/webserver/zertifikat")
|
||||
@permission_required("settings_web.execute")
|
||||
def web_certificate():
|
||||
problem = _license_hint("nginx")
|
||||
if problem:
|
||||
flash(problem, "danger")
|
||||
return redirect(url_for("hostsettings.web"))
|
||||
extension = core()
|
||||
sysops = extension.sysops
|
||||
conn = db()
|
||||
if sysops is None or not sysops.available:
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
||||
return redirect(url_for("hostsettings.web"))
|
||||
|
||||
config = _web_config()
|
||||
result = webserver.request_certificate(
|
||||
sysops,
|
||||
domain=config.domain,
|
||||
email=str(extension.settings.get(conn, "web_acme_email") or ""),
|
||||
webroot=config.acme_webroot,
|
||||
)
|
||||
if result["ok"]:
|
||||
with transaction():
|
||||
extension.settings.set_many(
|
||||
conn,
|
||||
{
|
||||
"web_cert_path": result["cert_path"],
|
||||
"web_key_path": result["key_path"],
|
||||
"web_https_enabled": True,
|
||||
},
|
||||
actor=current_user.username,
|
||||
)
|
||||
_audit("settings.certificate_issued", config.domain, severity="notice")
|
||||
flash(result["message"], "success" if result["ok"] else "danger")
|
||||
return redirect(url_for("hostsettings.web"))
|
||||
|
||||
|
||||
@bp.post("/webserver/selbstsigniert")
|
||||
@permission_required("settings_web.execute")
|
||||
def web_self_signed():
|
||||
"""Erzeugt ein selbstsigniertes Zertifikat -- fuer Netze ohne Domain.
|
||||
|
||||
Danach wird HTTPS aktiviert, aber **nicht** automatisch uebernommen: der
|
||||
Administrator soll die erzeugte Konfiguration erst sehen. Ein Reload, der
|
||||
die eigene Sitzung ueber einen anderen Port wegwirft, kommt nie ungefragt.
|
||||
"""
|
||||
problem = _license_hint("nginx")
|
||||
if problem:
|
||||
flash(problem, "danger")
|
||||
return redirect(url_for("hostsettings.web"))
|
||||
extension = core()
|
||||
sysops = extension.sysops
|
||||
conn = db()
|
||||
if sysops is None or not sysops.available:
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
||||
return redirect(url_for("hostsettings.web"))
|
||||
|
||||
config = _web_config()
|
||||
suggested = webserver.local_names()
|
||||
common_name = config.domain or (suggested[0] if suggested else "")
|
||||
result = webserver.create_self_signed(
|
||||
sysops, common_name=common_name, alt_names=[*suggested, "localhost"]
|
||||
)
|
||||
if result["ok"]:
|
||||
with transaction():
|
||||
extension.settings.set_many(
|
||||
conn,
|
||||
{
|
||||
"web_cert_path": result["cert_path"],
|
||||
"web_key_path": result["key_path"],
|
||||
"web_https_enabled": True,
|
||||
},
|
||||
actor=current_user.username,
|
||||
)
|
||||
_audit("settings.self_signed_issued", common_name, severity="notice")
|
||||
flash(
|
||||
result["message"] + (f" {result.get('detail', '')}" if not result["ok"] else ""),
|
||||
"success" if result["ok"] else "danger",
|
||||
)
|
||||
return redirect(url_for("hostsettings.web"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Verzeichnisdienst
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/verzeichnisdienst")
|
||||
@permission_required("settings_directory.view")
|
||||
def directory_settings():
|
||||
extension = core()
|
||||
conn = db()
|
||||
bind_username, _ = directory.bind_credentials(conn)
|
||||
return render_template(
|
||||
"tesm_core/settings_directory.html",
|
||||
values=extension.settings.get_many(conn, [spec.key for spec in directory.SETTINGS]),
|
||||
specs={spec.key: spec for spec in directory.SETTINGS},
|
||||
bind_username=bind_username,
|
||||
mappings=Database.all(
|
||||
conn,
|
||||
"SELECT m.*, g.name AS group_name FROM ldap_group_mappings m "
|
||||
"LEFT JOIN groups g ON g.id = m.app_group_id ORDER BY m.ad_group_name",
|
||||
),
|
||||
groups=rbac.list_groups(conn),
|
||||
license_hint=_license_hint("directory"),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/verzeichnisdienst")
|
||||
@permission_required("settings_directory.edit")
|
||||
def directory_save():
|
||||
problem = _license_hint("directory")
|
||||
if problem:
|
||||
flash(problem, "danger")
|
||||
return redirect(url_for("hostsettings.directory_settings"))
|
||||
|
||||
extension = core()
|
||||
conn = db()
|
||||
values: dict[str, Any] = {}
|
||||
for spec in directory.SETTINGS:
|
||||
if spec.kind == "bool":
|
||||
values[spec.key] = request.form.get(spec.key) == "1"
|
||||
elif spec.key in request.form:
|
||||
values[spec.key] = request.form.get(spec.key)
|
||||
|
||||
if values.get("ldap_security") == "none" and not values.get("ldap_allow_insecure"):
|
||||
flash(
|
||||
"Eine unverschluesselte Verbindung muss ausdruecklich erlaubt werden -- das Passwort "
|
||||
"geht dabei im Klartext ueber das Netz.",
|
||||
"danger",
|
||||
)
|
||||
return redirect(url_for("hostsettings.directory_settings"))
|
||||
|
||||
with transaction():
|
||||
changed = extension.settings.set_many(conn, values, actor=current_user.username)
|
||||
if current_user.has("settings_directory.secrets"):
|
||||
directory.store_bind_credentials(
|
||||
conn,
|
||||
request.form.get("bind_username", ""),
|
||||
request.form.get("bind_password") or None,
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="settings.directory_saved",
|
||||
detail=", ".join(changed),
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Verzeichniseinstellungen gespeichert.", "success")
|
||||
return redirect(url_for("hostsettings.directory_settings"))
|
||||
|
||||
|
||||
@bp.post("/verzeichnisdienst/test")
|
||||
@permission_required("settings_directory.execute")
|
||||
def directory_test():
|
||||
result = directory.test_connection(db())
|
||||
flash(result["message"], "success" if result["ok"] else "danger")
|
||||
return redirect(url_for("hostsettings.directory_settings"))
|
||||
|
||||
|
||||
@bp.get("/verzeichnisdienst/gruppensuche")
|
||||
@permission_required("settings_directory.edit", "fileshare.edit", mode="any")
|
||||
def directory_group_search():
|
||||
"""Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder.
|
||||
|
||||
Auch mit ``fileshare.edit`` erreichbar: wer Freigaben zuordnen darf, muss
|
||||
die Gruppen finden koennen, ohne zugleich den Verzeichnisdienst
|
||||
konfigurieren zu duerfen. Gelesen wird nur, und nur mit dem hinterlegten
|
||||
Dienstkonto -- es entsteht kein Zugriff, den der Server nicht ohnehin hat.
|
||||
"""
|
||||
return jsonify(
|
||||
{"results": directory.search_directory_groups(db(), request.args.get("q") or "")}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/verzeichnisdienst/zuordnung")
|
||||
@permission_required("settings_directory.edit")
|
||||
def directory_mapping_add():
|
||||
conn = db()
|
||||
dn = (request.form.get("ad_group_dn") or "").strip()
|
||||
name = (request.form.get("ad_group_name") or "").strip()
|
||||
group_id = request.form.get("app_group_id")
|
||||
if not dn or not group_id:
|
||||
flash("Verzeichnisgruppe und Zielgruppe sind beide erforderlich.", "danger")
|
||||
return redirect(url_for("hostsettings.directory_settings"))
|
||||
with transaction():
|
||||
conn.execute(
|
||||
"INSERT INTO ldap_group_mappings (ad_group_dn, ad_group_name, app_group_id, created_at) "
|
||||
"VALUES (?,?,?,datetime('now')) ON CONFLICT(ad_group_dn) DO UPDATE SET "
|
||||
"ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id",
|
||||
(dn, name or dn, int(group_id)),
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="settings.directory_mapping_added",
|
||||
target=name or dn,
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Zuordnung gespeichert.", "success")
|
||||
return redirect(url_for("hostsettings.directory_settings"))
|
||||
|
||||
|
||||
@bp.post("/verzeichnisdienst/zuordnung/<int:mapping_id>/loeschen")
|
||||
@permission_required("settings_directory.edit")
|
||||
def directory_mapping_delete(mapping_id: int):
|
||||
with transaction() as conn:
|
||||
conn.execute("DELETE FROM ldap_group_mappings WHERE id=?", (mapping_id,))
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="settings.directory_mapping_deleted",
|
||||
target=str(mapping_id),
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Zuordnung entfernt.", "success")
|
||||
return redirect(url_for("hostsettings.directory_settings"))
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Protokollansichten, die beide Anwendungen teilen.
|
||||
|
||||
* **Laufend** -- die Textdatei ``live.log``.
|
||||
* **Verlauf** -- die von logrotate rotierten Kopien plus die aus der Datenbank
|
||||
ausgelagerten Audit-Tage.
|
||||
* **Aenderungen** -- die Tabelle ``audit_log`` mit pruefbarer Hash-Kette.
|
||||
|
||||
Was ``live.log`` fuellt, entscheidet die Anwendung: TESM schreibt dort
|
||||
Erreichbarkeit und PoE-Vorgaenge; der Lizenzserver spiegelt dorthin seinen
|
||||
Audit-Trail, weil es bei ihm keinen Telemetrie-Prozess gibt. Beides landet in
|
||||
derselben Ansicht -- der Vorgaenger hatte dafuer zwei getrennte Umsetzungen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, Response, abort, flash, redirect, render_template, request, url_for
|
||||
|
||||
from .. import audit
|
||||
from ..auth.models import current_user, permission_required
|
||||
from ..auth.service import client_ip
|
||||
from ..extension import core, db, transaction
|
||||
|
||||
bp = Blueprint("logs", __name__, url_prefix="/protokolle")
|
||||
|
||||
MAX_TAIL_LINES = 2000
|
||||
|
||||
|
||||
def _live_log():
|
||||
log = core().registry.get("live_log")
|
||||
if log is None: # pragma: no cover - jede App registriert eines
|
||||
abort(404)
|
||||
return log
|
||||
|
||||
|
||||
@bp.get("/laufend")
|
||||
@permission_required("logs_live.view")
|
||||
def live():
|
||||
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
|
||||
needle = (request.args.get("q") or "").strip()
|
||||
return render_template(
|
||||
"tesm_core/logs_live.html",
|
||||
lines=_live_log().tail(lines, needle=needle),
|
||||
path=str(_live_log().path),
|
||||
query=needle,
|
||||
line_count=lines,
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/laufend/inhalt")
|
||||
@permission_required("logs_live.view")
|
||||
def live_fragment():
|
||||
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
|
||||
needle = (request.args.get("q") or "").strip()
|
||||
return render_template(
|
||||
"tesm_core/_log_lines.html", lines=_live_log().tail(lines, needle=needle)
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/verlauf")
|
||||
@permission_required("logs_history.view", "logs_history.export", mode="any")
|
||||
def history():
|
||||
extension = core()
|
||||
rotated = []
|
||||
for path in sorted(extension.config.log_dir.glob("*.log.*"), reverse=True):
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
rotated.append({"name": path.name, "size": stat.st_size, "modified": stat.st_mtime})
|
||||
return render_template(
|
||||
"tesm_core/logs_history.html",
|
||||
rotated=rotated,
|
||||
archived=audit.archived_days(extension.audit_archive_dir),
|
||||
log_dir=str(extension.config.log_dir),
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/verlauf/datei/<path:name>")
|
||||
@permission_required("logs_history.view")
|
||||
def history_file(name: str):
|
||||
extension = core()
|
||||
target = (extension.config.log_dir / name).resolve()
|
||||
if extension.config.log_dir.resolve() not in target.parents or not target.is_file():
|
||||
abort(404)
|
||||
try:
|
||||
if target.suffix == ".gz":
|
||||
content = gzip.decompress(target.read_bytes()).decode("utf-8", errors="replace")
|
||||
else:
|
||||
content = target.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
abort(500, description=f"Datei nicht lesbar: {exc}")
|
||||
return render_template(
|
||||
"tesm_core/logs_file.html", name=name, lines=content.splitlines()[-MAX_TAIL_LINES:]
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/verlauf/archiv/<day>")
|
||||
@permission_required("logs_history.view")
|
||||
def history_archive(day: str):
|
||||
if len(day) != 10 or day.count("-") != 2:
|
||||
abort(404)
|
||||
rows = audit.read_archived_day(core().audit_archive_dir, day)
|
||||
if not rows:
|
||||
abort(404)
|
||||
return render_template("tesm_core/logs_archive.html", day=day, rows=rows)
|
||||
|
||||
|
||||
@bp.post("/verlauf/archiv-export")
|
||||
@permission_required("logs_history.export")
|
||||
def history_export():
|
||||
"""Packt die archivierten Audit-Tage in ein ZIP.
|
||||
|
||||
Anders als im Vorgaenger loescht der Export die Dateien **nicht**: ein
|
||||
Download, der Beweismittel vom Server entfernt, ist keine gute Idee. Das
|
||||
Loeschen ist ein eigener, ausdruecklicher Schritt.
|
||||
"""
|
||||
extension = core()
|
||||
entries = audit.archived_days(extension.audit_archive_dir)
|
||||
if not entries:
|
||||
flash("Es sind keine Archivdateien vorhanden.", "info")
|
||||
return redirect(url_for("logs.history"))
|
||||
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for entry in entries:
|
||||
archive.write(entry["path"], arcname=Path(entry["path"]).name)
|
||||
buffer.seek(0)
|
||||
|
||||
with transaction() as conn:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="logs.archive_exported",
|
||||
detail=f"{len(entries)} Tage",
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
return Response(
|
||||
buffer.getvalue(),
|
||||
mimetype="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{extension.config.app_key}-auditarchiv.zip"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/verlauf/archiv-loeschen")
|
||||
@permission_required("logs_history.delete")
|
||||
def history_purge():
|
||||
removed = 0
|
||||
for entry in audit.archived_days(core().audit_archive_dir):
|
||||
try:
|
||||
Path(entry["path"]).unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
continue
|
||||
with transaction() as conn:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="logs.archive_deleted",
|
||||
detail=f"{removed} Dateien",
|
||||
ip=client_ip(),
|
||||
severity="warning",
|
||||
)
|
||||
flash(f"{removed} Archivdatei(en) entfernt.", "success")
|
||||
return redirect(url_for("logs.history"))
|
||||
|
||||
|
||||
@bp.get("/aenderungen")
|
||||
@permission_required("logs_audit.view", "logs_audit.export", mode="any")
|
||||
def audit_view():
|
||||
conn = db()
|
||||
page = max(1, int(request.args.get("seite") or 1))
|
||||
per_page = 100
|
||||
return render_template(
|
||||
"tesm_core/logs_audit.html",
|
||||
rows=audit.search(
|
||||
conn,
|
||||
query=(request.args.get("q") or "").strip(),
|
||||
action=(request.args.get("aktion") or "").strip(),
|
||||
actor=(request.args.get("benutzer") or "").strip(),
|
||||
severity=(request.args.get("stufe") or "").strip(),
|
||||
limit=per_page,
|
||||
offset=(page - 1) * per_page,
|
||||
),
|
||||
total=audit.count(conn),
|
||||
actions=audit.distinct_actions(conn),
|
||||
chain=audit.verify_chain(conn, limit=2000),
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
filters={
|
||||
"q": request.args.get("q") or "",
|
||||
"aktion": request.args.get("aktion") or "",
|
||||
"benutzer": request.args.get("benutzer") or "",
|
||||
"stufe": request.args.get("stufe") or "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/aenderungen/export")
|
||||
@permission_required("logs_audit.export")
|
||||
def audit_export():
|
||||
conn = db()
|
||||
rows = audit.search(conn, limit=100000)
|
||||
lines = ["ts;actor;action;target;detail;ip;severity;hash"]
|
||||
for row in rows:
|
||||
lines.append(
|
||||
";".join(
|
||||
str(row.get(key) or "").replace(";", ",").replace("\n", " ")
|
||||
for key in ("ts", "actor", "action", "target", "detail", "ip", "severity", "hash")
|
||||
)
|
||||
)
|
||||
with transaction():
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="logs.audit_exported",
|
||||
detail=f"{len(rows)} Zeilen",
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
return Response(
|
||||
"\n".join(lines).encode("utf-8-sig"),
|
||||
mimetype="text/csv; charset=utf-8",
|
||||
headers={
|
||||
"Content-Disposition": (
|
||||
f'attachment; filename="{core().config.app_key}-aenderungsprotokoll.csv"'
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Papierkorb -- generisch ueber angemeldete Anbieter.
|
||||
|
||||
Der Vorgaenger hatte den Papierkorb nur in TESM und dort fuer fuenf fest
|
||||
verdrahtete Tabellen; der Lizenzserver-Fork hatte gar keinen, obwohl die
|
||||
``deleted_at``-Spalten noch existierten (ein Artefakt, das die eigene
|
||||
Spezifikation als "nicht nachbauen" markiert).
|
||||
|
||||
Hier ist der Papierkorb ein Kern-Feature mit einem Register: jede Anwendung
|
||||
meldet ihre loeschbaren Ressourcen an, die Oberflaeche ist fuer alle gleich.
|
||||
Benutzer und Gruppen registriert der Kern selbst -- damit haben **beide**
|
||||
Anwendungen einen funktionierenden Papierkorb, statt einer von beiden gar
|
||||
keinen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||
|
||||
from .. import audit
|
||||
from ..auth.models import current_user, permission_required
|
||||
from ..auth.service import client_ip
|
||||
from ..db import Database
|
||||
from ..extension import core, db, transaction
|
||||
from ..rbac import service as rbac
|
||||
|
||||
bp = Blueprint("trash", __name__, url_prefix="/papierkorb")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TrashProvider:
|
||||
"""Eine im Papierkorb verwaltbare Ressourcenart."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
icon: str
|
||||
list_fn: Callable[[sqlite3.Connection], list[dict[str, Any]]]
|
||||
restore_fn: Callable[[sqlite3.Connection, str], None]
|
||||
purge_fn: Callable[[sqlite3.Connection, str], None]
|
||||
hint: str = ""
|
||||
|
||||
|
||||
def register(provider: TrashProvider) -> None:
|
||||
"""Meldet einen Anbieter an. Wird beim App-Aufbau aufgerufen."""
|
||||
providers: dict[str, TrashProvider] = core().registry.setdefault("trash_providers", {})
|
||||
providers[provider.key] = provider
|
||||
|
||||
|
||||
def providers() -> dict[str, TrashProvider]:
|
||||
return core().registry.get("trash_providers", {})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Kern-Anbieter: Benutzer und Gruppen
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _users_in_trash(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": str(row["id"]),
|
||||
"label": str(row["username"]),
|
||||
"detail": f"{row['first_name']} {row['last_name']}".strip() or "-",
|
||||
"deleted_at": row["deleted_at"],
|
||||
}
|
||||
for row in Database.all(
|
||||
conn,
|
||||
"SELECT id, username, first_name, last_name, deleted_at FROM users "
|
||||
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _groups_in_trash(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": str(row["id"]),
|
||||
"label": str(row["name"]),
|
||||
"detail": str(row["description"] or "-"),
|
||||
"deleted_at": row["deleted_at"],
|
||||
}
|
||||
for row in Database.all(
|
||||
conn,
|
||||
"SELECT id, name, description, deleted_at FROM groups "
|
||||
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def register_core_providers() -> None:
|
||||
register(
|
||||
TrashProvider(
|
||||
key="users",
|
||||
label="Benutzerkonten",
|
||||
icon="users",
|
||||
list_fn=_users_in_trash,
|
||||
restore_fn=lambda conn, ident: rbac.restore_user(conn, int(ident)),
|
||||
purge_fn=lambda conn, ident: rbac.purge_user(conn, int(ident)),
|
||||
hint="Verzeichniskonten landen nie hier -- sie legen sich bei der naechsten "
|
||||
"Anmeldung von selbst wieder an.",
|
||||
)
|
||||
)
|
||||
register(
|
||||
TrashProvider(
|
||||
key="groups",
|
||||
label="Gruppen",
|
||||
icon="shield-users",
|
||||
list_fn=_groups_in_trash,
|
||||
restore_fn=lambda conn, ident: rbac.restore_group(conn, int(ident)),
|
||||
purge_fn=lambda conn, ident: rbac.purge_group(conn, int(ident)),
|
||||
hint="Beim endgueltigen Loeschen verschwinden auch Mitgliedschaften und Rechte.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Ansicht
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _retention_days() -> int:
|
||||
return int(core().settings.get(db(), "trash_retention_days") or 30)
|
||||
|
||||
|
||||
def _purge_expired(conn: sqlite3.Connection) -> int:
|
||||
"""Entfernt abgelaufene Eintraege beim Seitenaufruf.
|
||||
|
||||
Bewusst ohne eigenen Scheduler: ein zusaetzlicher Timer waere ein weiterer
|
||||
Prozess, der schiefgehen kann. Die Frist ist eine Obergrenze, kein
|
||||
sekundengenauer Vorgang.
|
||||
"""
|
||||
horizon = (datetime.now(timezone.utc) - timedelta(days=_retention_days())).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
removed = 0
|
||||
for provider in providers().values():
|
||||
for entry in provider.list_fn(conn):
|
||||
if str(entry.get("deleted_at") or "") and str(entry["deleted_at"]) < horizon:
|
||||
provider.purge_fn(conn, str(entry["id"]))
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("trash.view")
|
||||
def overview():
|
||||
conn = db()
|
||||
removed = 0
|
||||
if current_user.has("trash.delete"):
|
||||
with transaction():
|
||||
removed = _purge_expired(conn)
|
||||
if removed:
|
||||
audit.log(
|
||||
conn,
|
||||
actor="system",
|
||||
action="trash.auto_purged",
|
||||
detail=f"{removed} abgelaufene Eintraege",
|
||||
severity="notice",
|
||||
)
|
||||
if removed:
|
||||
flash(f"{removed} abgelaufene Eintraege wurden endgueltig entfernt.", "info")
|
||||
|
||||
sections = []
|
||||
for provider in providers().values():
|
||||
entries = provider.list_fn(conn)
|
||||
sections.append(
|
||||
{
|
||||
"key": provider.key,
|
||||
"label": provider.label,
|
||||
"icon": provider.icon,
|
||||
"hint": provider.hint,
|
||||
"entries": entries,
|
||||
}
|
||||
)
|
||||
return render_template(
|
||||
"tesm_core/trash.html",
|
||||
sections=sections,
|
||||
retention_days=_retention_days(),
|
||||
total=sum(len(section["entries"]) for section in sections),
|
||||
)
|
||||
|
||||
|
||||
def _provider_or_404(key: str) -> TrashProvider:
|
||||
provider = providers().get(key)
|
||||
if provider is None:
|
||||
abort(404)
|
||||
return provider
|
||||
|
||||
|
||||
@bp.post("/<kind>/<ident>/wiederherstellen")
|
||||
@permission_required("trash.edit")
|
||||
def restore(kind: str, ident: str):
|
||||
extension = core()
|
||||
if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
|
||||
flash(
|
||||
"Das Wiederherstellen aus dem Papierkorb erfordert eine gueltige, "
|
||||
"aktivierte Lizenz. Ansehen bleibt jederzeit moeglich.",
|
||||
"danger",
|
||||
)
|
||||
return redirect(url_for("trash.overview"))
|
||||
|
||||
provider = _provider_or_404(kind)
|
||||
try:
|
||||
with transaction() as conn:
|
||||
provider.restore_fn(conn, ident)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="trash.restored",
|
||||
target=f"{kind}:{ident}",
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Eintrag wiederhergestellt.", "success")
|
||||
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("trash.overview"))
|
||||
|
||||
|
||||
@bp.post("/<kind>/<ident>/endgueltig-loeschen")
|
||||
@permission_required("trash.delete")
|
||||
def purge(kind: str, ident: str):
|
||||
extension = core()
|
||||
if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
|
||||
flash(
|
||||
"Das endgueltige Loeschen erfordert eine gueltige, aktivierte Lizenz.",
|
||||
"danger",
|
||||
)
|
||||
return redirect(url_for("trash.overview"))
|
||||
|
||||
provider = _provider_or_404(kind)
|
||||
try:
|
||||
with transaction() as conn:
|
||||
provider.purge_fn(conn, ident)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="trash.purged",
|
||||
target=f"{kind}:{ident}",
|
||||
ip=client_ip(),
|
||||
severity="warning",
|
||||
)
|
||||
flash("Eintrag endgueltig entfernt.", "success")
|
||||
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("trash.overview"))
|
||||
|
||||
|
||||
@bp.post("/leeren")
|
||||
@permission_required("trash.delete")
|
||||
def purge_all():
|
||||
extension = core()
|
||||
if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
|
||||
flash("Das endgueltige Loeschen erfordert eine gueltige, aktivierte Lizenz.", "danger")
|
||||
return redirect(url_for("trash.overview"))
|
||||
|
||||
kind = request.form.get("kind") or ""
|
||||
removed = 0
|
||||
with transaction() as conn:
|
||||
targets = [providers()[kind]] if kind in providers() else list(providers().values())
|
||||
for provider in targets:
|
||||
for entry in provider.list_fn(conn):
|
||||
provider.purge_fn(conn, str(entry["id"]))
|
||||
removed += 1
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="trash.emptied",
|
||||
target=kind or "alle",
|
||||
detail=f"{removed} Eintraege",
|
||||
ip=client_ip(),
|
||||
severity="warning",
|
||||
)
|
||||
flash(f"{removed} Eintraege endgueltig entfernt.", "success")
|
||||
return redirect(url_for("trash.overview"))
|
||||
@@ -0,0 +1,13 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-licensing"
|
||||
version = "2.0.0"
|
||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["cryptography>=42"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@@ -0,0 +1,125 @@
|
||||
"""TESM-Lizenzprotokoll -- gemeinsames Paket von TESM und TESM-Lizenzserver.
|
||||
|
||||
Im Vorgaengerprojekt lag dieses Modul als handkopierte, byte-identisch zu
|
||||
haltende ``licensing.py`` in beiden Repos. Jede unabhaengige Aenderung an nur
|
||||
einer Kopie brach die gegenseitige Signaturpruefung, ohne dass das lokal
|
||||
auffiel. Hier ist es ein installierbares Paket mit eigener Version -- die
|
||||
Invariante ist damit strukturell erfuellt statt durch Disziplin.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .canonical import (
|
||||
SIG_CONTEXT_LICENSE,
|
||||
SIG_CONTEXT_REQUEST,
|
||||
SIG_CONTEXT_RESPONSE,
|
||||
canonical_bytes,
|
||||
decode_code,
|
||||
encode_code,
|
||||
generate_keypair,
|
||||
public_from_private,
|
||||
sign,
|
||||
verify,
|
||||
)
|
||||
from .errors import (
|
||||
InvalidLicenseFile,
|
||||
LicensingError,
|
||||
ProtocolViolation,
|
||||
ReplayDetected,
|
||||
SignatureInvalid,
|
||||
UntrustedIssuer,
|
||||
)
|
||||
from .fingerprint import machine_identity, system_fingerprint
|
||||
from .license import (
|
||||
ALL_MODULES,
|
||||
EXPIRY_WARNING_DAYS,
|
||||
FORMAT_VERSION,
|
||||
GRACE_PERIOD_DAYS,
|
||||
HEARTBEAT_WARNING_DAYS,
|
||||
LICENSE_ROLES,
|
||||
LICENSE_TYPE_LABELS,
|
||||
LICENSE_TYPES,
|
||||
LIFETIME_EXPIRES_AT,
|
||||
MODULE_LABELS,
|
||||
ROLE_LABELS,
|
||||
TRIAL_DEFAULT_DAYS,
|
||||
LicenseStatus,
|
||||
issue_license,
|
||||
iso,
|
||||
key_id,
|
||||
license_status,
|
||||
parse_iso,
|
||||
resign_license,
|
||||
split_bundle,
|
||||
utcnow,
|
||||
verify_license,
|
||||
)
|
||||
from .protocol import (
|
||||
ACTIONS,
|
||||
CLOCK_SKEW_SECONDS,
|
||||
HEARTBEAT_INTERVAL_SECONDS,
|
||||
MAX_REPORT_ROWS,
|
||||
REQUEST_MAX_AGE_SECONDS,
|
||||
RESPONSE_STATES,
|
||||
build_client_request,
|
||||
build_master_response,
|
||||
new_nonce,
|
||||
verify_client_request,
|
||||
verify_master_response,
|
||||
)
|
||||
|
||||
__version__ = "2.0.0"
|
||||
|
||||
__all__ = [
|
||||
"ACTIONS",
|
||||
"ALL_MODULES",
|
||||
"CLOCK_SKEW_SECONDS",
|
||||
"EXPIRY_WARNING_DAYS",
|
||||
"FORMAT_VERSION",
|
||||
"GRACE_PERIOD_DAYS",
|
||||
"HEARTBEAT_INTERVAL_SECONDS",
|
||||
"HEARTBEAT_WARNING_DAYS",
|
||||
"InvalidLicenseFile",
|
||||
"LICENSE_ROLES",
|
||||
"LICENSE_TYPES",
|
||||
"LICENSE_TYPE_LABELS",
|
||||
"LIFETIME_EXPIRES_AT",
|
||||
"LicenseStatus",
|
||||
"LicensingError",
|
||||
"MAX_REPORT_ROWS",
|
||||
"MODULE_LABELS",
|
||||
"ProtocolViolation",
|
||||
"REQUEST_MAX_AGE_SECONDS",
|
||||
"RESPONSE_STATES",
|
||||
"ROLE_LABELS",
|
||||
"ReplayDetected",
|
||||
"SIG_CONTEXT_LICENSE",
|
||||
"SIG_CONTEXT_REQUEST",
|
||||
"SIG_CONTEXT_RESPONSE",
|
||||
"SignatureInvalid",
|
||||
"TRIAL_DEFAULT_DAYS",
|
||||
"UntrustedIssuer",
|
||||
"build_client_request",
|
||||
"build_master_response",
|
||||
"canonical_bytes",
|
||||
"decode_code",
|
||||
"encode_code",
|
||||
"generate_keypair",
|
||||
"issue_license",
|
||||
"iso",
|
||||
"key_id",
|
||||
"license_status",
|
||||
"machine_identity",
|
||||
"new_nonce",
|
||||
"parse_iso",
|
||||
"public_from_private",
|
||||
"resign_license",
|
||||
"sign",
|
||||
"split_bundle",
|
||||
"system_fingerprint",
|
||||
"utcnow",
|
||||
"verify",
|
||||
"verify_client_request",
|
||||
"verify_license",
|
||||
"verify_master_response",
|
||||
]
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Kanonisierung und domaenengetrennte Ed25519-Signaturen.
|
||||
|
||||
Warum eine eigene Datei: Signieren und Verifizieren muessen auf Client- und
|
||||
Master-Seite bitidentisch arbeiten. Im Vorgaengerprojekt wurde das durch eine
|
||||
handkopierte ``licensing.py`` in beiden Repos geloest -- hier ist es *ein*
|
||||
installierbares Paket, das beide Anwendungen als Abhaengigkeit einbinden.
|
||||
|
||||
Sicherheitsrelevante Abweichung vom Vorgaenger: jede Signatur ist
|
||||
**domaenengetrennt**. Vor die kanonischen Bytes wird ein Kontext-Praefix
|
||||
gestellt (``SIG_CONTEXT_*``). Eine Signatur ueber eine Lizenz-Payload kann
|
||||
dadurch nie als Signatur ueber eine Aktivierungsantwort durchgehen, selbst wenn
|
||||
ein Angreifer Feldnamen so waehlen koennte, dass zwei Strukturen identische
|
||||
kanonische Bytes ergeben.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
from typing import Any, Mapping
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
# Domaenen-Praefixe. Aenderungen hieran sind ein Protokollbruch.
|
||||
SIG_CONTEXT_LICENSE = b"tesm/license-file/v2\x00"
|
||||
SIG_CONTEXT_REQUEST = b"tesm/client-request/v2\x00"
|
||||
SIG_CONTEXT_RESPONSE = b"tesm/master-response/v2\x00"
|
||||
|
||||
|
||||
def canonical_bytes(payload: Mapping[str, Any]) -> bytes:
|
||||
"""Deterministische JSON-Serialisierung: sortierte Schluessel, keine Whitespaces.
|
||||
|
||||
``ensure_ascii=True`` (Default) ist hier gewollt: die Bytes bleiben damit
|
||||
unabhaengig von der Unicode-Normalform der Eingabe reproduzierbar.
|
||||
"""
|
||||
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def _b64d(value: str, *, field: str) -> bytes:
|
||||
try:
|
||||
return base64.b64decode(value, validate=True)
|
||||
except (binascii.Error, ValueError, TypeError) as exc: # pragma: no cover - defensiv
|
||||
raise ValueError(f"{field} ist kein gueltiges Base64") from exc
|
||||
|
||||
|
||||
def generate_keypair() -> tuple[str, str]:
|
||||
"""Erzeugt ein Ed25519-Schluesselpaar. Rueckgabe: ``(private_b64, public_b64)``."""
|
||||
private = ed25519.Ed25519PrivateKey.generate()
|
||||
return (
|
||||
base64.b64encode(private.private_bytes_raw()).decode("ascii"),
|
||||
base64.b64encode(private.public_key().public_bytes_raw()).decode("ascii"),
|
||||
)
|
||||
|
||||
|
||||
def public_from_private(private_key_b64: str) -> str:
|
||||
"""Leitet den oeffentlichen Teil aus einem privaten Schluessel ab."""
|
||||
key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
_b64d(private_key_b64, field="private_key")
|
||||
)
|
||||
return base64.b64encode(key.public_key().public_bytes_raw()).decode("ascii")
|
||||
|
||||
|
||||
def sign(payload: Mapping[str, Any], private_key_b64: str, context: bytes) -> str:
|
||||
"""Signiert ``payload`` domaenengetrennt. Rueckgabe: Base64-Signatur."""
|
||||
key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
_b64d(private_key_b64, field="private_key")
|
||||
)
|
||||
return base64.b64encode(key.sign(context + canonical_bytes(payload))).decode("ascii")
|
||||
|
||||
|
||||
def verify(
|
||||
payload: Mapping[str, Any],
|
||||
signature_b64: str,
|
||||
public_key_b64: str,
|
||||
context: bytes,
|
||||
) -> bool:
|
||||
"""Verifiziert eine Signatur.
|
||||
|
||||
Gibt ``False`` zurueck statt zu werfen -- der Aufrufer kann "ungueltig" so
|
||||
nie mit "abgestuerzt" verwechseln. Das war im Vorgaengerprojekt bereits eine
|
||||
bewusste Entscheidung und bleibt es.
|
||||
"""
|
||||
if not signature_b64 or not public_key_b64:
|
||||
return False
|
||||
try:
|
||||
key = ed25519.Ed25519PublicKey.from_public_bytes(
|
||||
_b64d(public_key_b64, field="public_key")
|
||||
)
|
||||
key.verify(_b64d(signature_b64, field="signature"), context + canonical_bytes(payload))
|
||||
return True
|
||||
except (InvalidSignature, ValueError, TypeError, KeyError):
|
||||
return False
|
||||
|
||||
|
||||
def encode_code(data: Mapping[str, Any]) -> str:
|
||||
"""Kodiert ein Protokollobjekt als kopierbaren Offline-Code (Base64url, ohne Padding)."""
|
||||
raw = base64.urlsafe_b64encode(canonical_bytes(data)).decode("ascii")
|
||||
return raw.rstrip("=")
|
||||
|
||||
|
||||
def decode_code(code: str) -> dict[str, Any]:
|
||||
"""Dekodiert einen Offline-Code. Toleriert fehlendes Padding und Whitespace/Umbrueche."""
|
||||
cleaned = "".join(code.split())
|
||||
if not cleaned:
|
||||
raise ValueError("Leerer Code")
|
||||
cleaned += "=" * (-len(cleaned) % 4)
|
||||
try:
|
||||
data = json.loads(base64.urlsafe_b64decode(cleaned.encode("ascii")).decode("utf-8"))
|
||||
except (binascii.Error, ValueError, UnicodeDecodeError) as exc:
|
||||
raise ValueError("Code ist unlesbar oder beschaedigt") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Code enthaelt kein Objekt")
|
||||
return data
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Fehlerklassen des Lizenzprotokolls.
|
||||
|
||||
Alle Fehler sind bewusst so gebaut, dass sie *keine* Geheimnisse (Schluessel,
|
||||
Signaturen, Payload-Inhalte) in ihrer Textform tragen -- sie landen in Logs und
|
||||
teilweise in HTTP-Antworten.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class LicensingError(Exception):
|
||||
"""Basisklasse aller Protokollfehler."""
|
||||
|
||||
|
||||
class InvalidLicenseFile(LicensingError):
|
||||
"""Lizenzdatei ist strukturell kaputt, unvollstaendig oder falsch versioniert."""
|
||||
|
||||
|
||||
class UntrustedIssuer(LicensingError):
|
||||
"""Signatur ist in sich gueltig, der Aussteller aber nicht vertrauenswuerdig (Pinning)."""
|
||||
|
||||
|
||||
class SignatureInvalid(LicensingError):
|
||||
"""Signatur passt nicht zur Payload."""
|
||||
|
||||
|
||||
class ReplayDetected(LicensingError):
|
||||
"""Nonce wurde schon gesehen oder Zeitstempel liegt ausserhalb des Frischefensters."""
|
||||
|
||||
|
||||
class ProtocolViolation(LicensingError):
|
||||
"""Anfrage/Antwort verletzt das erwartete Format oder die erwartete Aktion."""
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Maschinen-Fingerprint der lizenzierten Instanz.
|
||||
|
||||
Jede Bindungs-/Autorisierungsentscheidung des Protokolls laeuft ueber diesen
|
||||
Wert -- nie ueber ``hostname`` oder ``license_id`` allein.
|
||||
|
||||
Aenderung gegenueber dem Vorgaenger (bewusst): der Hostname ist **nicht** mehr
|
||||
Teil des Hashes. Im Vorgaengerprojekt war ``sha256(machine_id + ":" + hostname)``
|
||||
im Einsatz, wodurch eine reine Umbenennung des Hosts den Fingerprint aenderte
|
||||
und die Lizenz "already_bound_elsewhere" lief -- ein Support-Fall ohne
|
||||
Sicherheitsgewinn. Jetzt zaehlt ausschliesslich die stabile Maschinenidentitaet
|
||||
(``/etc/machine-id``, ergaenzt um die DMI-Produkt-UUID, falls lesbar).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
_MACHINE_ID_PATHS = (Path("/etc/machine-id"), Path("/var/lib/dbus/machine-id"))
|
||||
_DMI_UUID_PATH = Path("/sys/class/dmi/id/product_uuid")
|
||||
|
||||
FINGERPRINT_CONTEXT = b"tesm/machine-fingerprint/v2\x00"
|
||||
|
||||
|
||||
def _read_first(paths: tuple[Path, ...]) -> str:
|
||||
for path in paths:
|
||||
try:
|
||||
value = path.read_text(encoding="utf-8", errors="strict").strip()
|
||||
except OSError:
|
||||
continue
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def machine_identity() -> str:
|
||||
"""Rohe, ungehashte Maschinenidentitaet -- nur fuer Diagnose/Tests."""
|
||||
machine_id = _read_first(_MACHINE_ID_PATHS)
|
||||
dmi_uuid = ""
|
||||
try:
|
||||
dmi_uuid = _DMI_UUID_PATH.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
pass
|
||||
if machine_id or dmi_uuid:
|
||||
return f"{machine_id}|{dmi_uuid}"
|
||||
# Letzter Ausweg (Windows/Container ohne machine-id): Hostname. Produktivziel
|
||||
# ist Linux, dort greift dieser Zweig nicht.
|
||||
return f"hostname:{socket.gethostname()}"
|
||||
|
||||
|
||||
def system_fingerprint() -> str:
|
||||
"""SHA-256-Hex-Digest der Maschinenidentitaet, domaenengetrennt gehasht."""
|
||||
return hashlib.sha256(FINGERPRINT_CONTEXT + machine_identity().encode("utf-8")).hexdigest()
|
||||
@@ -0,0 +1,417 @@
|
||||
"""Lizenzdatei-Format, Ausstellung, Verifikation und Statusberechnung.
|
||||
|
||||
Format-Version 2. Unterschiede zur Version 1 des Vorgaengerprojekts, jeweils mit
|
||||
Begruendung:
|
||||
|
||||
1. **Der private Client-Schluessel steckt nicht mehr in der Lizenz-Payload.**
|
||||
V1 lieferte ``license_privkey`` als Feld neben der signierten Payload in
|
||||
derselben Datei aus. Wer diese Datei zu Supportzwecken weitergab, gab damit
|
||||
das Aktivierungs-Credential mit. V2 liefert ein Bundle aus
|
||||
``{"license": <signiertes Dokument>, "client_key": <privater Schluessel>}``,
|
||||
das der Client beim Import aufteilt: Lizenzdokument nach ``license.json``,
|
||||
Schluessel nach ``license_key.json`` mit Modus 0600.
|
||||
2. **``lifetime`` ist ein signiertes Feld**, nicht mehr nur aus dem
|
||||
Sentinel-Datum abgeleitet. Der Sentinel bleibt zusaetzlich erhalten, damit
|
||||
jede Datumsvergleichslogik ohne Sonderfall auskommt.
|
||||
3. **``not_before``** existiert und wird geprueft -- eine Lizenz, die versehentlich
|
||||
mit Zukunftsdatum ausgestellt wurde, gilt nicht rueckwirkend.
|
||||
4. **``master_key_id``** (Fingerprint des Master-Public-Keys) erlaubt Pinning und
|
||||
Schluesselrotation auf Client-Seite.
|
||||
5. **``seq``** (monoton steigende Revision) erlaubt es dem Client, ein per
|
||||
Heartbeat nachgeliefertes ``license_update`` gegen Downgrade-Replay zu
|
||||
schuetzen.
|
||||
6. **Verifikation gegen einen gepinnten Aussteller-Schluessel** ist vorgesehen
|
||||
(``trusted_master_keys``). V1 pruefte die Signatur ausschliesslich gegen den in
|
||||
derselben Datei eingebetteten ``master_pubkey`` -- das beweist nur innere
|
||||
Selbstkonsistenz, nicht die Herkunft.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
from .canonical import SIG_CONTEXT_LICENSE, generate_keypair, sign, verify
|
||||
from .errors import InvalidLicenseFile, SignatureInvalid, UntrustedIssuer
|
||||
|
||||
FORMAT_VERSION = 2
|
||||
|
||||
LICENSE_TYPES: tuple[str, ...] = ("trial", "standard", "custom", "enterprise")
|
||||
LICENSE_ROLES: tuple[str, ...] = ("master", "license_server", "customer")
|
||||
ALL_MODULES: tuple[str, ...] = ("dhcp", "fileshare", "maintenance")
|
||||
|
||||
LICENSE_TYPE_LABELS: dict[str, str] = {
|
||||
"trial": "Testlizenz",
|
||||
"standard": "Standard",
|
||||
"custom": "Individuell",
|
||||
"enterprise": "Enterprise",
|
||||
}
|
||||
|
||||
MODULE_LABELS: dict[str, str] = {
|
||||
"dhcp": "DHCP-Server (Kea)",
|
||||
"fileshare": "Dateifreigaben",
|
||||
"maintenance": "Wartung (SSH-Bulk)",
|
||||
}
|
||||
|
||||
ROLE_LABELS: dict[str, str] = {
|
||||
"master": "Master-Lizenzserver",
|
||||
"license_server": "Lizenzserver",
|
||||
"customer": "Kundeninstanz",
|
||||
}
|
||||
|
||||
#: Kulanzzeitraum nach Ablauf, in dem Module weiterlaufen.
|
||||
GRACE_PERIOD_DAYS = 30
|
||||
#: Ab wann "laeuft bald ab" gemeldet wird.
|
||||
EXPIRY_WARNING_DAYS = 30
|
||||
#: Ab wann ein ausgebliebener Heartbeat gemeldet wird (schaltet nichts ab).
|
||||
HEARTBEAT_WARNING_DAYS = 14
|
||||
#: Standardlaufzeit einer Trial-Ausstellung.
|
||||
TRIAL_DEFAULT_DAYS = 30
|
||||
#: Sentinel-Ablaufdatum fuer Lifetime-Lizenzen.
|
||||
LIFETIME_EXPIRES_AT = "2099-12-31T00:00:00Z"
|
||||
|
||||
_PAYLOAD_FIELDS = (
|
||||
"fmt",
|
||||
"license_id",
|
||||
"seq",
|
||||
"customer",
|
||||
"type",
|
||||
"role",
|
||||
"modules",
|
||||
"lifetime",
|
||||
"issued_at",
|
||||
"not_before",
|
||||
"expires_at",
|
||||
"license_pubkey",
|
||||
"master_pubkey",
|
||||
"master_key_id",
|
||||
"master_endpoint",
|
||||
"vendor",
|
||||
)
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def iso(value: datetime) -> str:
|
||||
"""ISO-8601 in UTC mit ``Z``-Suffix, sekundengenau (keine Mikrosekunden)."""
|
||||
return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_iso(value: str) -> datetime:
|
||||
"""Parst ISO-8601. Ohne Zeitzone wird UTC angenommen."""
|
||||
text = (value or "").strip()
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
parsed = datetime.fromisoformat(text)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def key_id(public_key_b64: str) -> str:
|
||||
"""Kurzer, stabiler Fingerprint eines Public Keys -- fuer Pinning und Anzeige."""
|
||||
return hashlib.sha256((public_key_b64 or "").encode("ascii")).hexdigest()[:16]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LicenseStatus:
|
||||
"""Auswertung einer Lizenzdatei zu einem Zeitpunkt. Rein rechnerisch, ohne I/O."""
|
||||
|
||||
license_id: str
|
||||
seq: int
|
||||
type: str
|
||||
role: str
|
||||
customer: Mapping[str, Any]
|
||||
vendor: Mapping[str, Any]
|
||||
master_endpoint: str
|
||||
master_key_id: str
|
||||
modules: frozenset[str]
|
||||
lifetime: bool
|
||||
issued_at: datetime | None
|
||||
expires_at: datetime | None
|
||||
expired: bool
|
||||
not_yet_valid: bool
|
||||
days_left: int | None
|
||||
days_since_expiry: int | None
|
||||
expiring_soon: bool
|
||||
grace_active: bool
|
||||
modules_active: bool
|
||||
warnings: tuple[str, ...] = field(default=())
|
||||
|
||||
def has_module(self, name: str) -> bool:
|
||||
return self.modules_active and name in self.modules
|
||||
|
||||
|
||||
def _validate_issue_args(
|
||||
license_type: str, role: str, modules: Sequence[str], lifetime: bool
|
||||
) -> None:
|
||||
if license_type not in LICENSE_TYPES:
|
||||
raise ValueError(f"Unbekannter Lizenztyp: {license_type!r}")
|
||||
if role not in LICENSE_ROLES:
|
||||
raise ValueError(f"Unbekannte Rolle: {role!r}")
|
||||
unknown = sorted(set(modules) - set(ALL_MODULES))
|
||||
if unknown:
|
||||
raise ValueError(f"Unbekannte Module: {', '.join(unknown)}")
|
||||
if lifetime and license_type == "trial":
|
||||
# Dritte und letzte Absicherung derselben Regel (UI + Service pruefen
|
||||
# vorher schon): eine unbefristete Trial-Lizenz waere eine permanente
|
||||
# Lizenz ohne Module -- ein sinnloser Zustand.
|
||||
raise ValueError("Trial-Lizenzen koennen nicht unbefristet sein")
|
||||
if role != "customer" and modules:
|
||||
raise ValueError("Lizenzserver-Lizenzen tragen keine TESM-Module")
|
||||
|
||||
|
||||
def issue_license(
|
||||
*,
|
||||
customer: Mapping[str, Any],
|
||||
license_type: str,
|
||||
modules: Iterable[str],
|
||||
valid_days: int,
|
||||
master_private_key_b64: str,
|
||||
master_public_key_b64: str,
|
||||
master_endpoint: str,
|
||||
vendor: Mapping[str, Any],
|
||||
license_id: str | None = None,
|
||||
role: str = "customer",
|
||||
lifetime: bool = False,
|
||||
seq: int = 1,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
"""Stellt eine Lizenz aus.
|
||||
|
||||
Rueckgabe: ``(bundle, license_public_key)``. ``bundle`` ist die Datei, die
|
||||
an den Kunden geht; ``license_public_key`` wandert in die Master-Datenbank,
|
||||
damit der Master spaeter Client-Anfragen dieser Lizenz verifizieren kann.
|
||||
"""
|
||||
module_list = sorted({str(m) for m in modules})
|
||||
_validate_issue_args(license_type, role, module_list, lifetime)
|
||||
if not lifetime and int(valid_days) <= 0:
|
||||
raise ValueError("valid_days muss positiv sein")
|
||||
|
||||
moment = now or utcnow()
|
||||
client_private, client_public = generate_keypair()
|
||||
payload: dict[str, Any] = {
|
||||
"fmt": FORMAT_VERSION,
|
||||
"license_id": license_id or str(uuid.uuid4()),
|
||||
"seq": int(seq),
|
||||
"customer": dict(customer),
|
||||
"type": license_type,
|
||||
"role": role,
|
||||
"modules": module_list,
|
||||
"lifetime": bool(lifetime),
|
||||
"issued_at": iso(moment),
|
||||
"not_before": iso(moment),
|
||||
"expires_at": LIFETIME_EXPIRES_AT
|
||||
if lifetime
|
||||
else iso(moment + timedelta(days=int(valid_days))),
|
||||
"license_pubkey": client_public,
|
||||
"master_pubkey": master_public_key_b64,
|
||||
"master_key_id": key_id(master_public_key_b64),
|
||||
"master_endpoint": (master_endpoint or "").rstrip("/"),
|
||||
"vendor": dict(vendor),
|
||||
}
|
||||
signature = sign(payload, master_private_key_b64, SIG_CONTEXT_LICENSE)
|
||||
document = {**payload, "signature": signature}
|
||||
bundle = {
|
||||
"kind": "tesm-license-bundle",
|
||||
"fmt": FORMAT_VERSION,
|
||||
"license": document,
|
||||
"client_key": client_private,
|
||||
}
|
||||
return bundle, client_public
|
||||
|
||||
|
||||
def resign_license(
|
||||
document: Mapping[str, Any],
|
||||
*,
|
||||
master_private_key_b64: str,
|
||||
changes: Mapping[str, Any],
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Erzeugt eine neu signierte Revision eines bestehenden Lizenzdokuments.
|
||||
|
||||
Wird fuer ``license_update`` im Heartbeat gebraucht (z. B. nachtraeglich
|
||||
erweiterte Module oder verlaengerte Laufzeit). ``seq`` steigt dabei zwingend,
|
||||
damit der Client eine aeltere Revision nicht als Update akzeptiert.
|
||||
"""
|
||||
payload = {k: v for k, v in document.items() if k != "signature"}
|
||||
payload.update(changes)
|
||||
payload["seq"] = int(document.get("seq") or 1) + 1
|
||||
payload["issued_at"] = iso(now or utcnow())
|
||||
_validate_issue_args(
|
||||
str(payload.get("type")),
|
||||
str(payload.get("role")),
|
||||
[str(m) for m in payload.get("modules") or ()],
|
||||
bool(payload.get("lifetime")),
|
||||
)
|
||||
payload["signature"] = sign(payload, master_private_key_b64, SIG_CONTEXT_LICENSE)
|
||||
return payload
|
||||
|
||||
|
||||
def split_bundle(bundle: Mapping[str, Any]) -> tuple[dict[str, Any], str]:
|
||||
"""Zerlegt ein Auslieferungs-Bundle in ``(lizenzdokument, client_private_key)``.
|
||||
|
||||
Akzeptiert zusaetzlich ein bereits aufgeteiltes Dokument (dann ist der
|
||||
zweite Rueckgabewert leer) sowie -- fuer Migrationen -- eine V1-Datei mit
|
||||
eingebettetem ``license_privkey``.
|
||||
"""
|
||||
if not isinstance(bundle, Mapping):
|
||||
raise InvalidLicenseFile("Lizenzdatei enthaelt kein Objekt")
|
||||
|
||||
inner = bundle.get("license")
|
||||
if isinstance(inner, Mapping):
|
||||
return dict(inner), str(bundle.get("client_key") or "")
|
||||
|
||||
if "license_privkey" in bundle: # V1-Kompatibilitaet
|
||||
document = {k: v for k, v in bundle.items() if k != "license_privkey"}
|
||||
return document, str(bundle.get("license_privkey") or "")
|
||||
|
||||
if "signature" in bundle and "license_id" in bundle:
|
||||
return dict(bundle), ""
|
||||
|
||||
raise InvalidLicenseFile("Unbekanntes Lizenzdatei-Format")
|
||||
|
||||
|
||||
def verify_license(
|
||||
document: Mapping[str, Any],
|
||||
*,
|
||||
trusted_master_keys: Sequence[str] | None = None,
|
||||
) -> None:
|
||||
"""Verifiziert ein Lizenzdokument. Wirft bei Problemen, gibt sonst ``None``.
|
||||
|
||||
``trusted_master_keys`` sind Base64-Public-Keys, denen der Client vertraut
|
||||
(im Build mitgeliefert oder beim ersten Import per Trust-on-First-Use
|
||||
gepinnt). Ist die Liste leer/``None``, wird nur die innere Konsistenz der
|
||||
Datei geprueft -- dann traegt der Status eine entsprechende Warnung.
|
||||
"""
|
||||
if not isinstance(document, Mapping):
|
||||
raise InvalidLicenseFile("Lizenzdokument ist kein Objekt")
|
||||
|
||||
missing = [name for name in _PAYLOAD_FIELDS if name not in document]
|
||||
if missing:
|
||||
raise InvalidLicenseFile(f"Lizenzdokument unvollstaendig: {', '.join(missing)}")
|
||||
if int(document.get("fmt") or 0) != FORMAT_VERSION:
|
||||
raise InvalidLicenseFile(
|
||||
f"Nicht unterstuetzte Formatversion: {document.get('fmt')!r}"
|
||||
f" (erwartet {FORMAT_VERSION})"
|
||||
)
|
||||
if document.get("type") not in LICENSE_TYPES:
|
||||
raise InvalidLicenseFile("Unbekannter Lizenztyp")
|
||||
if document.get("role") not in LICENSE_ROLES:
|
||||
raise InvalidLicenseFile("Unbekannte Rolle")
|
||||
|
||||
master_pubkey = str(document.get("master_pubkey") or "")
|
||||
if key_id(master_pubkey) != str(document.get("master_key_id") or ""):
|
||||
raise InvalidLicenseFile("master_key_id passt nicht zum eingebetteten Schluessel")
|
||||
|
||||
payload = {k: v for k, v in document.items() if k != "signature"}
|
||||
if not verify(
|
||||
payload, str(document.get("signature") or ""), master_pubkey, SIG_CONTEXT_LICENSE
|
||||
):
|
||||
raise SignatureInvalid("Signatur der Lizenzdatei ist ungueltig")
|
||||
|
||||
if trusted_master_keys and master_pubkey not in set(trusted_master_keys):
|
||||
raise UntrustedIssuer(
|
||||
"Die Lizenz wurde nicht von einem vertrauenswuerdigen Lizenzserver ausgestellt"
|
||||
)
|
||||
|
||||
|
||||
def license_status(
|
||||
document: Mapping[str, Any],
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
issuer_pinned: bool = False,
|
||||
) -> LicenseStatus:
|
||||
"""Berechnet den Status. Verifiziert nicht selbst -- das macht ``verify_license``.
|
||||
|
||||
Bewusst getrennt gehalten, damit hier niemand versehentlich den Status einer
|
||||
unverifizierten Datei berechnet und weiterverwendet.
|
||||
"""
|
||||
moment = now or utcnow()
|
||||
lifetime = bool(document.get("lifetime"))
|
||||
warnings: list[str] = []
|
||||
if not issuer_pinned:
|
||||
warnings.append("Aussteller nicht gepinnt -- nur innere Konsistenz geprueft")
|
||||
|
||||
issued_at: datetime | None = None
|
||||
expires_at: datetime | None = None
|
||||
not_before: datetime | None = None
|
||||
for name in ("issued_at", "expires_at", "not_before"):
|
||||
raw = document.get(name)
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
parsed = parse_iso(str(raw))
|
||||
except ValueError:
|
||||
warnings.append(f"Feld {name} ist kein gueltiges Datum")
|
||||
continue
|
||||
if name == "issued_at":
|
||||
issued_at = parsed
|
||||
elif name == "expires_at":
|
||||
expires_at = parsed
|
||||
else:
|
||||
not_before = parsed
|
||||
|
||||
not_yet_valid = bool(not_before and moment < not_before)
|
||||
|
||||
days_left: int | None
|
||||
days_since_expiry: int | None
|
||||
if lifetime:
|
||||
days_left = None
|
||||
days_since_expiry = None
|
||||
expired = False
|
||||
elif expires_at is None:
|
||||
# Fail-safe: unlesbares Ablaufdatum gilt als abgelaufen, nicht als gueltig.
|
||||
days_left = None
|
||||
days_since_expiry = None
|
||||
expired = True
|
||||
warnings.append("Ablaufdatum fehlt oder ist unlesbar")
|
||||
else:
|
||||
delta = expires_at - moment
|
||||
expired = delta.total_seconds() <= 0
|
||||
if expired:
|
||||
days_left = 0
|
||||
days_since_expiry = int((moment - expires_at).total_seconds() // 86400)
|
||||
else:
|
||||
days_left = -(-int(delta.total_seconds()) // 86400)
|
||||
days_since_expiry = None
|
||||
|
||||
grace_active = bool(
|
||||
expired and days_since_expiry is not None and days_since_expiry < GRACE_PERIOD_DAYS
|
||||
)
|
||||
modules_active = not not_yet_valid and (lifetime or not expired or grace_active)
|
||||
expiring_soon = bool(
|
||||
not lifetime
|
||||
and not expired
|
||||
and days_left is not None
|
||||
and days_left <= EXPIRY_WARNING_DAYS
|
||||
)
|
||||
|
||||
return LicenseStatus(
|
||||
license_id=str(document.get("license_id") or ""),
|
||||
seq=int(document.get("seq") or 1),
|
||||
type=str(document.get("type") or ""),
|
||||
# Fehlt die Rolle, gilt "customer" -- niemals ein privilegierterer Wert.
|
||||
role=str(document.get("role") or "customer"),
|
||||
customer=dict(document.get("customer") or {}),
|
||||
vendor=dict(document.get("vendor") or {}),
|
||||
master_endpoint=str(document.get("master_endpoint") or ""),
|
||||
master_key_id=str(document.get("master_key_id") or ""),
|
||||
modules=frozenset(str(m) for m in (document.get("modules") or ())),
|
||||
lifetime=lifetime,
|
||||
issued_at=issued_at,
|
||||
expires_at=expires_at,
|
||||
expired=expired,
|
||||
not_yet_valid=not_yet_valid,
|
||||
days_left=days_left,
|
||||
days_since_expiry=days_since_expiry,
|
||||
expiring_soon=expiring_soon,
|
||||
grace_active=grace_active,
|
||||
modules_active=modules_active,
|
||||
warnings=tuple(warnings),
|
||||
)
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Aktivierungs-, Deaktivierungs- und Heartbeat-Protokoll.
|
||||
|
||||
Beide Transportwege -- HTTPS-JSON und kopierbarer Offline-Code -- tragen exakt
|
||||
dieselben signierten Objekte. Es gibt deshalb nur *einen* Satz
|
||||
Verifikationsfunktionen; der Transport ist reine Verpackung.
|
||||
|
||||
Haertungen gegenueber dem Vorgaengerprojekt:
|
||||
|
||||
* **Domaenengetrennte Signaturen** (siehe ``canonical``): eine Lizenz-Signatur
|
||||
kann nie als Antwort-Signatur durchgehen und umgekehrt.
|
||||
* **Frischefenster**: Anfragen und Antworten tragen einen Zeitstempel, der
|
||||
gegen ein Fenster geprueft wird (``REQUEST_MAX_AGE_SECONDS``, plus
|
||||
``CLOCK_SKEW_SECONDS`` Toleranz in die Zukunft). V1 pruefte den Zeitstempel
|
||||
gar nicht -- eine abgefangene Aktivierungsanfrage war unbegrenzt
|
||||
wiederverwendbar.
|
||||
* **Nonce-Replay-Schutz**: der Master kann ueber ``seen_nonce`` eine
|
||||
Persistenzschicht einhaengen; ein zweites Mal gesehene Nonce wird abgewiesen.
|
||||
* **Antwort-an-Anfrage-Bindung**: die Master-Antwort echot die Nonce der Anfrage
|
||||
in ihrer signierten Payload. Eine alte "ok"-Antwort kann dadurch nicht gegen
|
||||
eine neue Anfrage eingespielt werden (relevant vor allem im Offline-Code-Weg,
|
||||
wo Codes per Copy-Paste durch die Gegend wandern).
|
||||
* **Downgrade-Schutz fuer ``license_update``**: das nachgelieferte Dokument muss
|
||||
eine hoehere ``seq`` tragen als das aktuell installierte.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from .canonical import SIG_CONTEXT_REQUEST, SIG_CONTEXT_RESPONSE, sign, verify
|
||||
from .errors import ProtocolViolation, ReplayDetected, SignatureInvalid
|
||||
from .license import FORMAT_VERSION, iso, parse_iso, utcnow
|
||||
from .fingerprint import system_fingerprint
|
||||
|
||||
#: Erlaubte Aktionen des Protokolls.
|
||||
ACTIONS: tuple[str, ...] = ("activate", "deactivate", "heartbeat")
|
||||
|
||||
#: Antwortstatus, die der Master senden kann.
|
||||
RESPONSE_STATES: tuple[str, ...] = ("ok", "revoked", "expired", "rejected")
|
||||
|
||||
#: Wie alt eine Anfrage/Antwort maximal sein darf (Offline-Codes brauchen Luft).
|
||||
REQUEST_MAX_AGE_SECONDS = 7 * 24 * 3600
|
||||
#: Toleranz fuer Uhren, die leicht vorlaufen.
|
||||
CLOCK_SKEW_SECONDS = 300
|
||||
#: Regulaerer Heartbeat-Abstand des Clients.
|
||||
HEARTBEAT_INTERVAL_SECONDS = 6 * 3600
|
||||
#: Obergrenze fuer Zeilen in einem Sub-Lizenzserver-Bericht.
|
||||
MAX_REPORT_ROWS = 5000
|
||||
|
||||
_NONCE_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}$")
|
||||
_FINGERPRINT_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def new_nonce() -> str:
|
||||
return secrets.token_urlsafe(18)
|
||||
|
||||
|
||||
def build_client_request(
|
||||
action: str,
|
||||
document: Mapping[str, Any],
|
||||
client_private_key: str,
|
||||
*,
|
||||
hostname: str = "",
|
||||
nonce: str | None = None,
|
||||
report: Mapping[str, Any] | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Baut eine signierte Client-Anfrage.
|
||||
|
||||
``report`` wird ausschliesslich von Heartbeats einer Instanz mit
|
||||
``role="license_server"`` gesetzt (Sub-Lizenzserver-Reporting). Alle anderen
|
||||
Aufrufer lassen das Feld weg.
|
||||
"""
|
||||
if action not in ACTIONS:
|
||||
raise ProtocolViolation(f"Unbekannte Aktion: {action!r}")
|
||||
if not client_private_key:
|
||||
raise ProtocolViolation("Kein Client-Schluessel vorhanden -- Lizenz unvollstaendig importiert")
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"fmt": FORMAT_VERSION,
|
||||
"license_id": str(document.get("license_id") or ""),
|
||||
"seq": int(document.get("seq") or 1),
|
||||
"fingerprint": system_fingerprint(),
|
||||
"hostname": hostname,
|
||||
"action": action,
|
||||
"nonce": nonce or new_nonce(),
|
||||
"timestamp": iso(now or utcnow()),
|
||||
}
|
||||
if report is not None:
|
||||
payload["report"] = dict(report)
|
||||
payload["signature"] = sign(payload, client_private_key, SIG_CONTEXT_REQUEST)
|
||||
return payload
|
||||
|
||||
|
||||
def _check_freshness(timestamp: str, now: datetime, what: str) -> None:
|
||||
try:
|
||||
stamp = parse_iso(timestamp)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ProtocolViolation(f"{what}: Zeitstempel unlesbar") from exc
|
||||
if stamp > now + timedelta(seconds=CLOCK_SKEW_SECONDS):
|
||||
raise ReplayDetected(f"{what}: Zeitstempel liegt in der Zukunft")
|
||||
if stamp < now - timedelta(seconds=REQUEST_MAX_AGE_SECONDS):
|
||||
raise ReplayDetected(f"{what}: Zeitstempel ist zu alt")
|
||||
|
||||
|
||||
def verify_client_request(
|
||||
request: Mapping[str, Any],
|
||||
license_public_key: str,
|
||||
*,
|
||||
expected_action: str | None = None,
|
||||
now: datetime | None = None,
|
||||
seen_nonce: Callable[[str], bool] | None = None,
|
||||
) -> None:
|
||||
"""Verifiziert eine Client-Anfrage serverseitig. Wirft bei Problemen.
|
||||
|
||||
``seen_nonce`` ist ein Callback, der ``True`` zurueckgibt, wenn die Nonce
|
||||
bereits verbraucht ist. Der Aufrufer entscheidet ueber die Persistenz
|
||||
(in dieser Codebasis: Tabelle ``license_nonces``).
|
||||
"""
|
||||
if not isinstance(request, Mapping):
|
||||
raise ProtocolViolation("Anfrage ist kein Objekt")
|
||||
if int(request.get("fmt") or 0) != FORMAT_VERSION:
|
||||
raise ProtocolViolation("Nicht unterstuetzte Protokollversion")
|
||||
|
||||
action = str(request.get("action") or "")
|
||||
if action not in ACTIONS:
|
||||
raise ProtocolViolation("Unbekannte Aktion")
|
||||
if expected_action and action != expected_action:
|
||||
raise ProtocolViolation("Aktion passt nicht zum Endpunkt")
|
||||
|
||||
nonce = str(request.get("nonce") or "")
|
||||
if not _NONCE_RE.match(nonce):
|
||||
raise ProtocolViolation("Nonce hat ein unerwartetes Format")
|
||||
fingerprint = str(request.get("fingerprint") or "")
|
||||
if not _FINGERPRINT_RE.match(fingerprint):
|
||||
raise ProtocolViolation("Fingerprint hat ein unerwartetes Format")
|
||||
|
||||
payload = {k: v for k, v in request.items() if k != "signature"}
|
||||
if not verify(
|
||||
payload, str(request.get("signature") or ""), license_public_key, SIG_CONTEXT_REQUEST
|
||||
):
|
||||
raise SignatureInvalid("Signatur der Anfrage ist ungueltig")
|
||||
|
||||
# Frische und Replay erst *nach* der Signaturpruefung: unsignierte Muellanfragen
|
||||
# sollen keine Nonce-Eintraege in der Datenbank erzeugen koennen.
|
||||
moment = now or utcnow()
|
||||
_check_freshness(str(request.get("timestamp") or ""), moment, "Anfrage")
|
||||
if seen_nonce is not None and seen_nonce(nonce):
|
||||
raise ReplayDetected("Diese Anfrage wurde bereits verarbeitet")
|
||||
|
||||
report = request.get("report")
|
||||
if report is not None:
|
||||
if not isinstance(report, Mapping):
|
||||
raise ProtocolViolation("Bericht ist kein Objekt")
|
||||
total = sum(len(report.get(key) or ()) for key in ("customers", "tickets", "licenses"))
|
||||
if total > MAX_REPORT_ROWS:
|
||||
raise ProtocolViolation("Bericht ueberschreitet die Groessengrenze")
|
||||
|
||||
|
||||
def build_master_response(
|
||||
action: str,
|
||||
*,
|
||||
license_id: str,
|
||||
fingerprint: str,
|
||||
request_nonce: str,
|
||||
master_private_key: str,
|
||||
status: str = "ok",
|
||||
detail: str = "",
|
||||
license_update: Mapping[str, Any] | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Baut eine signierte Master-Antwort, gebunden an die Nonce der Anfrage."""
|
||||
if status not in RESPONSE_STATES:
|
||||
raise ProtocolViolation(f"Unbekannter Antwortstatus: {status!r}")
|
||||
payload: dict[str, Any] = {
|
||||
"fmt": FORMAT_VERSION,
|
||||
"license_id": license_id,
|
||||
"fingerprint": fingerprint,
|
||||
"action": action,
|
||||
"status": status,
|
||||
"detail": detail,
|
||||
"nonce": request_nonce,
|
||||
"timestamp": iso(now or utcnow()),
|
||||
}
|
||||
if license_update is not None:
|
||||
payload["license_update"] = dict(license_update)
|
||||
payload["signature"] = sign(payload, master_private_key, SIG_CONTEXT_RESPONSE)
|
||||
return payload
|
||||
|
||||
|
||||
def verify_master_response(
|
||||
response: Mapping[str, Any],
|
||||
master_public_key: str,
|
||||
*,
|
||||
expected_license_id: str,
|
||||
expected_fingerprint: str,
|
||||
expected_action: str,
|
||||
expected_nonce: str,
|
||||
current_seq: int = 0,
|
||||
now: datetime | None = None,
|
||||
) -> None:
|
||||
"""Verifiziert eine Master-Antwort clientseitig, komplett offline.
|
||||
|
||||
Prueft neben der Signatur, dass die Antwort tatsaechlich zu *dieser* Anfrage
|
||||
gehoert (Lizenz-ID, Fingerprint, Aktion, Nonce) und dass ein mitgeliefertes
|
||||
``license_update`` keine aeltere Revision ist.
|
||||
"""
|
||||
if not isinstance(response, Mapping):
|
||||
raise ProtocolViolation("Antwort ist kein Objekt")
|
||||
if int(response.get("fmt") or 0) != FORMAT_VERSION:
|
||||
raise ProtocolViolation("Nicht unterstuetzte Protokollversion")
|
||||
|
||||
payload = {k: v for k, v in response.items() if k != "signature"}
|
||||
if not verify(
|
||||
payload, str(response.get("signature") or ""), master_public_key, SIG_CONTEXT_RESPONSE
|
||||
):
|
||||
raise SignatureInvalid("Signatur der Antwort ist ungueltig")
|
||||
|
||||
if str(response.get("license_id") or "") != expected_license_id:
|
||||
raise ProtocolViolation("Antwort gehoert zu einer anderen Lizenz")
|
||||
if str(response.get("fingerprint") or "") != expected_fingerprint:
|
||||
raise ProtocolViolation("Antwort gehoert zu einer anderen Maschine")
|
||||
if str(response.get("action") or "") != expected_action:
|
||||
raise ProtocolViolation("Antwort gehoert zu einer anderen Aktion")
|
||||
if str(response.get("nonce") or "") != expected_nonce:
|
||||
raise ReplayDetected("Antwort gehoert nicht zu dieser Anfrage")
|
||||
if str(response.get("status") or "") not in RESPONSE_STATES:
|
||||
raise ProtocolViolation("Unbekannter Antwortstatus")
|
||||
|
||||
_check_freshness(str(response.get("timestamp") or ""), now or utcnow(), "Antwort")
|
||||
|
||||
update = response.get("license_update")
|
||||
if update is not None:
|
||||
if not isinstance(update, Mapping):
|
||||
raise ProtocolViolation("license_update ist kein Objekt")
|
||||
if int(update.get("seq") or 0) < int(current_seq or 0):
|
||||
raise ReplayDetected("license_update ist aelter als die installierte Lizenz")
|
||||
Reference in New Issue
Block a user