From bdf83bd275741907ada458321678c9641def1d50 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 15 Apr 2026 11:05:21 +0200 Subject: [PATCH] modified: web/app.py modified: web/blueprints/auth.py modified: web/blueprints/site_admin.py modified: web/config.py modified: web/panel_db.py modified: web/templates/admin/audit_log.html modified: web/templates/admin/dashboard.html new file: web/templates/auth/consent.html --- web/app.py | 10 +++ web/blueprints/auth.py | 73 ++++++++++++++++++- web/blueprints/site_admin.py | 20 +++++- web/config.py | 11 +++ web/panel_db.py | 25 +++++++ web/templates/admin/audit_log.html | 4 ++ web/templates/admin/dashboard.html | 108 +++++++++++++++++++++++++++++ web/templates/auth/consent.html | 84 ++++++++++++++++++++++ 8 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 web/templates/auth/consent.html diff --git a/web/app.py b/web/app.py index 06337fb..b8d6fab 100644 --- a/web/app.py +++ b/web/app.py @@ -6,6 +6,7 @@ Coolify-kompatibel: alle Einstellungen via ENV. import secrets from datetime import datetime from flask import Flask, abort, render_template, request, session, url_for +from werkzeug.middleware.proxy_fix import ProxyFix from config import Config from panel_db import init_databases, get_user_groups from roles import can_manage_group @@ -28,6 +29,15 @@ def create_app() -> Flask: Config.validate_security() + # Reverse-Proxy: echte Client-IP aus X-Forwarded-For lesen + if Config.PROXY_COUNT > 0: + app.wsgi_app = ProxyFix( + app.wsgi_app, + x_for=Config.PROXY_COUNT, + x_proto=Config.PROXY_COUNT, + x_host=Config.PROXY_COUNT, + ) + # Blueprints registrieren app.register_blueprint(auth) app.register_blueprint(site_admin) diff --git a/web/blueprints/auth.py b/web/blueprints/auth.py index 0e7cf46..9de171c 100644 --- a/web/blueprints/auth.py +++ b/web/blueprints/auth.py @@ -5,12 +5,75 @@ Getrennte Login-Seiten für Site-Admins und normale Nutzer/Gruppen-Admins. import json from datetime import datetime from flask import Blueprint, render_template, request, redirect, url_for, session, flash -from panel_db import accept_group_invite, check_login, get_invite_by_token, get_user_groups, log_audit_event +from panel_db import ( + accept_group_invite, check_login, get_invite_by_token, get_user_groups, + log_audit_event, get_user_consent_version, set_user_consent, +) +from config import Config from limiter import limiter auth = Blueprint("auth", __name__) +# ── DSGVO-Einwilligungs-Check ───────────────────────────────── +# Routen, die ohne Zustimmung erreichbar sein müssen: +_CONSENT_EXEMPT = frozenset({ + "auth.consent", "auth.logout", "auth.login", "auth.admin_login", + "auth.accept_invite", "privacy_policy", "static", +}) + + +@auth.before_app_request +def require_consent(): + """Leitet angemeldete Nutzer auf die Zustimmungsseite, solange sie der + aktuellen Datenschutzerklärung noch nicht zugestimmt haben.""" + if request.endpoint in _CONSENT_EXEMPT: + return + user_id = session.get("user_id") + if not user_id: + return + # Site-Admins sind ebenfalls einwilligungspflichtig + if session.get("needs_consent"): + return redirect(url_for("auth.consent")) + + +@auth.route("/consent", methods=["GET", "POST"]) +def consent(): + user_id = session.get("user_id") + if not user_id: + return redirect(url_for("auth.login")) + + if request.method == "POST": + action = request.form.get("action") + if action == "accept": + set_user_consent(user_id, Config.PRIVACY_POLICY_VERSION) + log_audit_event( + user_id, session.get("username"), "consent.given", + details={"policy_version": Config.PRIVACY_POLICY_VERSION}, + ip_address=request.remote_addr, + ) + session.pop("needs_consent", None) + # Nach Zustimmung weiterleiten + if session.get("is_site_admin"): + return redirect(url_for("site_admin.dashboard")) + return redirect(url_for("panel.dashboard")) + else: + # Ablehnen → ausloggen + log_audit_event( + user_id, session.get("username"), "consent.declined", + details={"policy_version": Config.PRIVACY_POLICY_VERSION}, + ip_address=request.remote_addr, + ) + session.clear() + flash("You must accept the Privacy Policy to use this service.", "warning") + return redirect(url_for("auth.login")) + + return render_template( + "auth/consent.html", + policy_version=Config.PRIVACY_POLICY_VERSION, + ) + + @auth.route("/login", methods=["GET", "POST"]) @limiter.limit("15 per minute", methods=["POST"]) def login(): @@ -34,6 +97,10 @@ def login(): entity_type="user", entity_id=user["id"], ip_address=request.remote_addr, ) + # DSGVO: Zustimmung prüfen + if get_user_consent_version(user["id"]) != Config.PRIVACY_POLICY_VERSION: + session["needs_consent"] = True + return redirect(url_for("auth.consent")) return redirect(url_for("panel.dashboard")) else: log_audit_event( @@ -65,6 +132,10 @@ def admin_login(): entity_type="user", entity_id=user["id"], ip_address=request.remote_addr, ) + # DSGVO: Zustimmung prüfen + if get_user_consent_version(user["id"]) != Config.PRIVACY_POLICY_VERSION: + session["needs_consent"] = True + return redirect(url_for("auth.consent")) return redirect(url_for("site_admin.dashboard")) elif user: log_audit_event( diff --git a/web/blueprints/site_admin.py b/web/blueprints/site_admin.py index 25aed60..cf8c9d7 100644 --- a/web/blueprints/site_admin.py +++ b/web/blueprints/site_admin.py @@ -48,7 +48,14 @@ def dashboard(): "admin_count": sum(1 for u in users if u.get("is_site_admin")), "mail_configured": int(has_mail), } - return render_template("admin/dashboard.html", groups=groups, users=users, stats=stats) + # Letzte 10 Audit-Einträge für das Dashboard-Widget + try: + recent_audit, _ = db.get_audit_log(page=1, per_page=10) + except Exception: + recent_audit = [] + return render_template("admin/dashboard.html", groups=groups, users=users, + stats=stats, recent_audit=recent_audit, + retention_days=Config.AUDIT_LOG_RETENTION_DAYS) @site_admin.route("/mail", methods=["GET", "POST"]) @@ -594,6 +601,13 @@ def user_edit(user_id): if not user: flash("User not found.", "danger") return redirect(url_for("site_admin.users")) + if request.method == "GET": + db.log_audit_event( + session["user_id"], session["username"], "admin.view_user", + entity_type="user", entity_id=user_id, + details={"target": user["username"]}, + ip_address=request.remote_addr, + ) if request.method == "POST": username = request.form.get("username", "").strip() email = request.form.get("email", "").strip() @@ -702,6 +716,10 @@ def stop_view(): @site_admin.route("/audit") @admin_required def audit_log(): + db.log_audit_event( + session["user_id"], session["username"], "admin.view_audit_log", + ip_address=request.remote_addr, + ) page = request.args.get("page", 1, type=int) action_f = request.args.get("action", "").strip() or None group_f = request.args.get("group_id", None, type=int) diff --git a/web/config.py b/web/config.py index 0cfb236..3137f1d 100644 --- a/web/config.py +++ b/web/config.py @@ -53,6 +53,17 @@ class Config: MAIL_USE_TLS = _as_bool(os.getenv("MAIL_USE_TLS"), default=True) MAIL_TIMEOUT = int(os.getenv("MAIL_TIMEOUT") or "15") + # ── Reverse-Proxy ───────────────────────────────────────── + # Anzahl der vorgelagerten Proxy-Ebenen (z.B. Nginx + Coolify-Traefik). + # ProxyFix liest X-Forwarded-For entsprechend aus und liefert die echte Client-IP. + # Auf 0 setzen, wenn Flask direkt erreichbar ist (kein Proxy). + PROXY_COUNT = int(os.getenv("PROXY_COUNT") or "1") + + # ── Datenschutz / DSGVO ─────────────────────────────────── + # Version der Datenschutzerklärung (ISO-Datum). Wenn sich diese ändert, + # werden alle Nutzer beim nächsten Login zur erneuten Zustimmung aufgefordert. + PRIVACY_POLICY_VERSION = os.getenv("PRIVACY_POLICY_VERSION") or "2026-04-15" + # ── Standard-Berechtigungen neuer Gruppenmitglieder ─────── INVITE_EXPIRY_HOURS = int(os.getenv("INVITE_EXPIRY_HOURS") or "72") INVITE_MAX_ACTIVE_PER_GROUP = int(os.getenv("INVITE_MAX_ACTIVE_PER_GROUP") or "200") diff --git a/web/panel_db.py b/web/panel_db.py index 29dca34..3cd1f8d 100644 --- a/web/panel_db.py +++ b/web/panel_db.py @@ -169,6 +169,12 @@ PANEL_MIGRATIONS = [ (6, "ALTER TABLE group_invites ADD COLUMN IF NOT EXISTS is_site_admin TINYINT(1) NOT NULL DEFAULT 0", "Add group_invites.is_site_admin"), + (7, + "ALTER TABLE users ADD COLUMN IF NOT EXISTS consent_version VARCHAR(20) NULL", + "Add users.consent_version for GDPR consent tracking"), + (8, + "ALTER TABLE users ADD COLUMN IF NOT EXISTS consented_at DATETIME NULL", + "Add users.consented_at for GDPR consent timestamp"), ] CREDS_SCHEMA = [ @@ -728,6 +734,25 @@ def has_site_mail_settings() -> bool: return row is not None +# ───────────────────────────────────────────────────────────── +# DSGVO-Einwilligung +# ───────────────────────────────────────────────────────────── + +def get_user_consent_version(user_id: int) -> str | None: + row = _panel_query( + "SELECT consent_version FROM users WHERE id = %s", (user_id,), fetchone=True + ) + return row["consent_version"] if row else None + + +def set_user_consent(user_id: int, policy_version: str) -> None: + _panel_query( + "UPDATE users SET consent_version = %s, consented_at = UTC_TIMESTAMP() WHERE id = %s", + (policy_version, user_id), + write=True, + ) + + # ───────────────────────────────────────────────────────────── # Audit-Log # ───────────────────────────────────────────────────────────── diff --git a/web/templates/admin/audit_log.html b/web/templates/admin/audit_log.html index 1f7c68c..e9b14d9 100644 --- a/web/templates/admin/audit_log.html +++ b/web/templates/admin/audit_log.html @@ -97,8 +97,10 @@ 'admin.login': 'badge bg-warning text-dark', 'admin.login_failed': 'badge bg-danger', 'admin.view_users': 'badge bg-dark border border-secondary', + 'admin.view_user': 'badge bg-dark border border-secondary', 'admin.view_group': 'badge bg-dark border border-secondary', 'admin.view_group_members': 'badge bg-dark border border-secondary', + 'admin.view_audit_log': 'badge bg-dark border border-secondary', 'invite.created': 'badge bg-primary', 'invite.accepted': 'badge bg-success', 'invite.revoked': 'badge bg-secondary', @@ -116,6 +118,8 @@ 'user.deleted': 'badge bg-danger', 'mail.settings_saved': 'badge bg-info text-dark', 'mail.settings_deleted': 'badge bg-danger', + 'consent.given': 'badge bg-success', + 'consent.declined': 'badge bg-warning text-dark', 'audit.purged': 'badge bg-danger', } %} diff --git a/web/templates/admin/dashboard.html b/web/templates/admin/dashboard.html index c4018bc..d0a0502 100644 --- a/web/templates/admin/dashboard.html +++ b/web/templates/admin/dashboard.html @@ -129,4 +129,112 @@ + + +
+
+
+
+ Recent Audit Activity +
+ {% if retention_days > 0 %} + + Retention: {{ retention_days }}d + + {% endif %} + + Full log + +
+
+
+ + + + + + + + + + + + + {% for row in recent_audit %} + {% set action_class = { + 'user.login': 'badge bg-success', + 'user.login_failed': 'badge bg-danger', + 'user.password_changed': 'badge bg-warning text-dark', + 'session.logout': 'badge bg-secondary', + 'admin.login': 'badge bg-warning text-dark', + 'admin.login_failed': 'badge bg-danger', + 'admin.view_users': 'badge bg-dark border border-secondary', + 'admin.view_user': 'badge bg-dark border border-secondary', + 'admin.view_group': 'badge bg-dark border border-secondary', + 'admin.view_group_members': 'badge bg-dark border border-secondary', + 'admin.view_audit_log': 'badge bg-dark border border-secondary', + 'invite.created': 'badge bg-primary', + 'invite.accepted': 'badge bg-success', + 'invite.revoked': 'badge bg-secondary', + 'invite.resent': 'badge bg-info text-dark', + 'member.added': 'badge bg-primary', + 'member.removed': 'badge bg-danger', + 'member.role_changed': 'badge bg-warning text-dark', + 'group.created': 'badge bg-success', + 'group.updated': 'badge bg-secondary', + 'group.deleted': 'badge bg-danger', + 'db.credentials_changed': 'badge bg-warning text-dark', + 'db.credentials_deleted': 'badge bg-danger', + 'user.updated': 'badge bg-secondary', + 'user.deleted': 'badge bg-danger', + 'mail.settings_saved': 'badge bg-info text-dark', + 'mail.settings_deleted': 'badge bg-danger', + 'consent.given': 'badge bg-success', + 'consent.declined': 'badge bg-warning text-dark', + 'audit.purged': 'badge bg-danger', + } %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
Timestamp (UTC)ActorActionGroupDetailsIP Address
{{ row.created_at | fmt_dt }} + {% if row.actor_username %} + {{ row.actor_username }} + {% else %} + + {% endif %} + + + {{ row.action }} + + + {% if row.group_name %} + {{ row.group_name }} + {% else %} + + {% endif %} + + {% if row.details %} + {% set d = row.details if row.details is mapping else {} %} + {% for k, v in d.items() %} + {{ k }}: {{ v }} + {% endfor %} + {% else %}—{% endif %} + {{ row.ip_address or '—' }}
+ No audit events yet. +
+
+
+
+
{% endblock %} diff --git a/web/templates/auth/consent.html b/web/templates/auth/consent.html new file mode 100644 index 0000000..2dc492f --- /dev/null +++ b/web/templates/auth/consent.html @@ -0,0 +1,84 @@ + + + + + + MCLogger – Privacy Consent + + + + + + + + +