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
This commit is contained in:
simon
2026-04-15 11:05:21 +02:00
parent 179a0e1042
commit bdf83bd275
8 changed files with 333 additions and 2 deletions

View File

@@ -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)

View File

@@ -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(

View File

@@ -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)

View File

@@ -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")

View File

@@ -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
# ─────────────────────────────────────────────────────────────

View File

@@ -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',
} %}
<span class="{{ action_class.get(row.action, 'badge bg-secondary') }} font-monospace" style="font-size:.75em">

View File

@@ -129,4 +129,112 @@
</div>
</div>
</div>
<!-- ── Recent Audit Activity ──────────────────────────────── -->
<div class="row g-3 mt-1">
<div class="col-12">
<div class="card border-secondary">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-journal-text me-2"></i>Recent Audit Activity</span>
<div class="d-flex gap-2 align-items-center">
{% if retention_days > 0 %}
<span class="badge bg-secondary small">
<i class="bi bi-clock-history me-1"></i>Retention: {{ retention_days }}d
</span>
{% endif %}
<a href="{{ url_for('site_admin.audit_log') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-right-circle me-1"></i>Full log
</a>
</div>
</div>
<div class="card-body p-0">
<table class="table table-dark table-hover table-sm align-middle mb-0">
<thead class="table-secondary text-dark">
<tr>
<th style="width:155px">Timestamp (UTC)</th>
<th style="width:130px">Actor</th>
<th style="width:180px">Action</th>
<th style="width:120px">Group</th>
<th>Details</th>
<th style="width:110px">IP Address</th>
</tr>
</thead>
<tbody>
{% 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',
} %}
<tr>
<td class="text-muted small">{{ row.created_at | fmt_dt }}</td>
<td>
{% if row.actor_username %}
<span class="text-info">{{ row.actor_username }}</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
<span class="{{ action_class.get(row.action, 'badge bg-secondary') }} font-monospace" style="font-size:.75em">
{{ row.action }}
</span>
</td>
<td class="small">
{% if row.group_name %}
<span class="badge bg-dark border border-secondary">{{ row.group_name }}</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="small text-muted font-monospace">
{% if row.details %}
{% set d = row.details if row.details is mapping else {} %}
{% for k, v in d.items() %}
<span class="me-2"><strong>{{ k }}:</strong> {{ v }}</span>
{% endfor %}
{% else %}—{% endif %}
</td>
<td class="text-muted small font-monospace">{{ row.ip_address or '—' }}</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center text-muted py-3">
<i class="bi bi-journal-x me-2"></i>No audit events yet.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCLogger Privacy Consent</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
body { background: #0d1117; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.consent-card { width: 100%; max-width: 600px; }
</style>
</head>
<body>
<div class="consent-card p-4">
<div class="text-center mb-4">
<i class="bi bi-shield-check fs-1 text-warning"></i>
<h3 class="fw-bold mt-2">Privacy Policy Consent</h3>
<p class="text-muted small">Version {{ policy_version }}</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }}">{{ msg }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="card border-warning mb-4">
<div class="card-header bg-transparent border-warning text-warning fw-semibold">
<i class="bi bi-file-earmark-text me-2"></i>What data we process
</div>
<div class="card-body small text-secondary">
<p>To operate MCLogger we process the following personal data:</p>
<ul class="mb-2">
<li><strong>Account data</strong> — username, e-mail address, hashed password (no plain-text storage)</li>
<li><strong>Session &amp; security data</strong> — login timestamps, IP addresses (stored for up to 90 days in the audit log)</li>
<li><strong>Minecraft server data</strong> — player names, UUIDs, chat messages, commands &amp; block interactions logged by the Minecraft plugin</li>
<li><strong>Audit events</strong> — records of actions you perform in the panel (logins, member changes, configuration edits)</li>
</ul>
<p class="mb-0">
<strong>Legal basis:</strong> Art. 6 (1)(b) GDPR — performance of a contract / provision of the service.<br>
<strong>Retention:</strong> Audit log entries containing IP addresses are automatically deleted after 90 days.
Account data is retained for as long as your account exists.
</p>
</div>
</div>
<div class="card border-secondary mb-4">
<div class="card-body small text-secondary">
<p class="mb-1">
<strong>Your rights (GDPR Art. 1521):</strong> You may request access to, rectification or deletion of your
personal data, as well as data portability, at any time by contacting
<a href="mailto:simon@devanturas.net" class="text-warning">simon@devanturas.net</a>.
</p>
<p class="mb-0">
Read the full <a href="{{ url_for('privacy_policy') }}" target="_blank" class="text-warning">Privacy Policy</a>.
</p>
</div>
</div>
<form method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="d-flex gap-3">
<button type="submit" name="action" value="accept" class="btn btn-warning w-100 fw-semibold">
<i class="bi bi-check-circle-fill me-1"></i>I accept the Privacy Policy
</button>
<button type="submit" name="action" value="decline"
class="btn btn-outline-secondary w-100"
onclick="return confirm('Declining will log you out. Are you sure?')">
<i class="bi bi-x-circle me-1"></i>Decline &amp; Logout
</button>
</div>
<p class="text-muted text-center mt-3 small">
By accepting you confirm that you have read and understood the Privacy Policy
(version {{ policy_version }}).
</p>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>