diff --git a/AGENTS.md b/AGENTS.md index ebe3de1..c461002 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,12 +86,14 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens. - Recovery-Code-Einsatz erzwingt erneute MFA-Einrichtung - Deaktivierung via `/mfa/disable` mit Passwort-Bestätigung - MFA-Status in der Userliste (`/admin/users`) als grünes "Aktiv"-Badge oder "–" +- **Trusted Device**: Nach erfolgreichem TOTP-Code kann das Gerät für 1–30 Tage gespeichert werden. Bei erneuter Anmeldung wird der MFA-Code dann übersprungen (Cookie `mfa_trust` mit Token, gespeichert in `users.json[email].trusted_devices`). Beim Logout oder Deaktivieren von MFA wird der Cookie gelöscht. ### MFA-Login-Flow 1. POST `/login` mit Passwort → bei aktivem MFA: Session `mfa_pending` setzen → redirect `/mfa/verify` 2. GET/POST `/mfa/verify` → TOTP-Code prüfen → `login_user()` aufrufen → redirect `/admin/` 3. Recovery-Code: POST `/mfa/verify` mit `recovery=true` → Code prüfen → `mfa_enabled=False` → Login erlauben, aber User muss MFA neu einrichten +4. **Trusted Device**: POST `/mfa/verify` mit `remember` (Tage) → Token generieren → in `users.json[email].trusted_devices` speichern → Cookie `mfa_trust` setzen. Nächstes POST `/login`: Cookie erkannt → MFA überspringen → direkt einloggen ### Zugriffs-Dekoratoren diff --git a/README.md b/README.md index 11b5bf6..b8b5fd0 100755 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens. - **Client-Info**: Tooltip beim Hovern über den Online-Button zeigt Browser, Bildschirmauflösung, IP-Adresse und letzten Heartbeat-Zeitpunkt; Info-Tab pro Screen zeigt detaillierte Client-Info-Tabelle - **User-Verwaltung**: Mehrere User mit Rollen (Admin / Superuser / User), E-Mail als Login, Passwort-Hashing (scrypt), Berechtigungen pro Standort - **Passwort-Workflow**: First-Login-Änderung, Admin-Reset mit temporärem Passwort, Notfall-Login bei fehlendem Hash -- **MFA (Multi-Faktor)**: Optionale TOTP-Authentifizierung (Google Authenticator, Authy) per User aktivierbar; QR-Code-Scan + Code-Verifikation, 8 Recovery-Codes, Deaktivierung mit Passwort-Bestätigung +- **MFA (Multi-Faktor)**: Optionale TOTP-Authentifizierung (Google Authenticator, Authy) per User aktivierbar; QR-Code-Scan + Code-Verifikation, 8 Recovery-Codes, Deaktivierung mit Passwort-Bestätigung, **Trusted Device** (Gerät für 1–30 Tage speichern, überspringt MFA beim nächsten Login) - **Admin Dashboard**: `/admin/dashboard` – Statistiken (Sites, Screens, User, Admins, Superuser) mit Sparkline-Charts + Trendanzeige, Aktivitätsverlauf - **Aktivitätsverlauf**: Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden in `history.json` protokolliert und im Dashboard angezeigt - Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien, Info (Tabler Tabs) diff --git a/app.py b/app.py index 136e250..f2b7d31 100755 --- a/app.py +++ b/app.py @@ -22,7 +22,7 @@ import pyotp import qrcode from functools import wraps -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from werkzeug.middleware.proxy_fix import ProxyFix @@ -343,7 +343,7 @@ class User(UserMixin): def __init__(self, email, password_hash="", role="user", sites=None, must_change_password=False, first_name="", last_name="", department="", notes="", mfa_enabled=False, mfa_secret="", - mfa_recovery_codes=None): + mfa_recovery_codes=None, trusted_devices=None): self.email = email self.id = email self.password_hash = password_hash @@ -357,6 +357,7 @@ class User(UserMixin): self.mfa_enabled = mfa_enabled self.mfa_secret = mfa_secret self.mfa_recovery_codes = mfa_recovery_codes or [] + self.trusted_devices = trusted_devices or [] @property def is_admin(self): @@ -513,6 +514,34 @@ def login(): return redirect("/mfa/setup?recovered=1") error = "Ungültiger Sicherheitscode" else: + # Trusted Device prüfen (Cookie überspringt MFA) + trust_token = request.cookies.get("mfa_trust") + if trust_token: + users_data = load_users() + user_data = users_data.get(email, {}) + trusted = user_data.get("trusted_devices", []) + now = datetime.now() + # Abgelaufene Einträge bereinigen + active = [d for d in trusted + if d.get("token") != trust_token or + datetime.fromisoformat(d["expires"]) > now] + changed = len(active) != len(trusted) + for d in trusted: + if d.get("token") == trust_token: + try: + expires = datetime.fromisoformat(d["expires"]) + if expires > now: + # Trusted Device gültig → MFA überspringen + login_user(user) + add_history_entry("user_login", f"User '{email}' angemeldet (Trusted Device)", email) + if user.must_change_password: + return redirect("/change-password") + return redirect("/admin") + except (ValueError, KeyError): + pass + if changed: + users_data[email]["trusted_devices"] = active + save_users(users_data) session["mfa_user"] = email session["mfa_password_ok"] = True return redirect("/mfa/verify") @@ -537,7 +566,9 @@ def logout(): email = current_user.email if current_user.is_authenticated else "unknown" logout_user() add_history_entry("user_logout", f"User '{email}' abgemeldet", email) - return redirect("/login") + resp = redirect("/login") + resp.delete_cookie("mfa_trust") + return resp # ------------------------------------------------- @@ -566,6 +597,33 @@ def mfa_verify(): totp = pyotp.TOTP(user.mfa_secret) if code and totp.verify(code): + # Trusted Device speichern wenn gewünscht + remember_days = request.form.get("remember") + if remember_days: + try: + days = int(remember_days) + if 1 <= days <= 365: + token = secrets.token_urlsafe(32) + expires = datetime.now().isoformat() + exp_date = datetime.now() + exp_date = exp_date + timedelta(days=days) + expires = exp_date.isoformat() + users_data = load_users() + trusted = users_data.setdefault(email, {}).setdefault("trusted_devices", []) + trusted.append({"token": token, "expires": expires}) + save_users(users_data) + resp = redirect("/admin") + resp.set_cookie("mfa_trust", token, max_age=days * 86400, + httponly=True, samesite="Lax", + secure=False) + login_user(user) + session.pop("mfa_user", None) + add_history_entry("user_login", f"User '{email}' angemeldet (mit MFA, Gerät für {days} Tage gespeichert)", email) + if user.must_change_password: + resp = redirect("/change-password") + return resp + except (ValueError, TypeError): + pass login_user(user) session.pop("mfa_user", None) add_history_entry("user_login", f"User '{email}' angemeldet (mit MFA)", email) @@ -663,9 +721,12 @@ def mfa_disable(): users_data[email]["mfa_enabled"] = False users_data[email]["mfa_secret"] = "" users_data[email]["mfa_recovery_codes"] = [] + users_data[email]["trusted_devices"] = [] save_users(users_data) add_history_entry("mfa_disabled", f"User '{email}' hat MFA deaktiviert", email) - return redirect("/mfa/setup?disabled=1") + resp = redirect("/mfa/setup?disabled=1") + resp.delete_cookie("mfa_trust") + return resp # ------------------------------------------------- diff --git a/templates/mfa_verify.html b/templates/mfa_verify.html index a53451a..33b632b 100644 --- a/templates/mfa_verify.html +++ b/templates/mfa_verify.html @@ -42,6 +42,32 @@ maxlength="6" pattern="[0-9]{6}" required autofocus> +
+ + +
+