TESM-Lizenzserver 2.0.0 -- Neubau
Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm-license (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Die verwaltete Anwendung liegt in ihrem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger: * Keine doppelte licensing.py -- ein Paket, das beide Anwendungen installieren, statt zweier Dateien, die byte-identisch bleiben sollen. * 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. * Keine Lizenz ohne master_endpoint: eine Ausstellung ohne Endpunkt wird abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann. * Offline-Aktivierung in beide Richtungen; die Lizenz bleibt als "Aktivierung offen" markiert, bis sie zurueckkommt. * Getrennte Signaturkontexte je Nachrichtenart, Nonce gegen Wiedereinspielung, seq gegen das Zurueckrollen auf eine aeltere Lizenz. * Kein Hostname im Maschinen-Fingerabdruck. * Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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"))
|
||||
Reference in New Issue
Block a user