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:
10
web/app.py
10
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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
84
web/templates/auth/consent.html
Normal file
84
web/templates/auth/consent.html
Normal 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 & 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 & 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. 15–21):</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 & 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>
|
||||
Reference in New Issue
Block a user