MFA: Trusted Device Feature — Gerät für X Tage merken, überspringt MFA

- User kann nach TOTP-Eingabe das Gerät für 1–30 Tage speichern
- Cookie mfa_trust mit zufälligem Token, gespeichert in trusted_devices[]
- Nächstes POST /login: Cookie prüfen → gültiges Token → MFA überspringen
- Abgelaufene Einträge werden beim Login automatisch bereinigt
- Logout + MFA-Deaktivierung löschen Cookie + trusted_devices
- mfa_verify.html: checkbox + days-select (1/3/7/14/30 Tage)
This commit is contained in:
Erik Thiele
2026-07-10 15:27:17 +02:00
parent 317f2a6eb2
commit 23a33955aa
4 changed files with 94 additions and 5 deletions

View File

@@ -86,12 +86,14 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- Recovery-Code-Einsatz erzwingt erneute MFA-Einrichtung - Recovery-Code-Einsatz erzwingt erneute MFA-Einrichtung
- Deaktivierung via `/mfa/disable` mit Passwort-Bestätigung - Deaktivierung via `/mfa/disable` mit Passwort-Bestätigung
- MFA-Status in der Userliste (`/admin/users`) als grünes "Aktiv"-Badge oder "" - 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 130 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 ### MFA-Login-Flow
1. POST `/login` mit Passwort → bei aktivem MFA: Session `mfa_pending` setzen → redirect `/mfa/verify` 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/<site>` 2. GET/POST `/mfa/verify` → TOTP-Code prüfen → `login_user()` aufrufen → redirect `/admin/<site>`
3. Recovery-Code: POST `/mfa/verify` mit `recovery=true` → Code prüfen → `mfa_enabled=False` → Login erlauben, aber User muss MFA neu einrichten 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 ### Zugriffs-Dekoratoren

View File

@@ -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 - **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 - **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 - **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 130 Tage speichern, überspringt MFA beim nächsten Login)
- **Admin Dashboard**: `/admin/dashboard` Statistiken (Sites, Screens, User, Admins, Superuser) mit Sparkline-Charts + Trendanzeige, Aktivitätsverlauf - **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 - **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) - Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien, Info (Tabler Tabs)

69
app.py
View File

@@ -22,7 +22,7 @@ import pyotp
import qrcode import qrcode
from functools import wraps from functools import wraps
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
@@ -343,7 +343,7 @@ class User(UserMixin):
def __init__(self, email, password_hash="", role="user", sites=None, def __init__(self, email, password_hash="", role="user", sites=None,
must_change_password=False, first_name="", last_name="", must_change_password=False, first_name="", last_name="",
department="", notes="", mfa_enabled=False, mfa_secret="", department="", notes="", mfa_enabled=False, mfa_secret="",
mfa_recovery_codes=None): mfa_recovery_codes=None, trusted_devices=None):
self.email = email self.email = email
self.id = email self.id = email
self.password_hash = password_hash self.password_hash = password_hash
@@ -357,6 +357,7 @@ class User(UserMixin):
self.mfa_enabled = mfa_enabled self.mfa_enabled = mfa_enabled
self.mfa_secret = mfa_secret self.mfa_secret = mfa_secret
self.mfa_recovery_codes = mfa_recovery_codes or [] self.mfa_recovery_codes = mfa_recovery_codes or []
self.trusted_devices = trusted_devices or []
@property @property
def is_admin(self): def is_admin(self):
@@ -513,6 +514,34 @@ def login():
return redirect("/mfa/setup?recovered=1") return redirect("/mfa/setup?recovered=1")
error = "Ungültiger Sicherheitscode" error = "Ungültiger Sicherheitscode"
else: 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_user"] = email
session["mfa_password_ok"] = True session["mfa_password_ok"] = True
return redirect("/mfa/verify") return redirect("/mfa/verify")
@@ -537,7 +566,9 @@ def logout():
email = current_user.email if current_user.is_authenticated else "unknown" email = current_user.email if current_user.is_authenticated else "unknown"
logout_user() logout_user()
add_history_entry("user_logout", f"User '{email}' abgemeldet", email) 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) totp = pyotp.TOTP(user.mfa_secret)
if code and totp.verify(code): 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) login_user(user)
session.pop("mfa_user", None) session.pop("mfa_user", None)
add_history_entry("user_login", f"User '{email}' angemeldet (mit MFA)", email) 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_enabled"] = False
users_data[email]["mfa_secret"] = "" users_data[email]["mfa_secret"] = ""
users_data[email]["mfa_recovery_codes"] = [] users_data[email]["mfa_recovery_codes"] = []
users_data[email]["trusted_devices"] = []
save_users(users_data) save_users(users_data)
add_history_entry("mfa_disabled", f"User '{email}' hat MFA deaktiviert", email) 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
# ------------------------------------------------- # -------------------------------------------------

View File

@@ -42,6 +42,32 @@
maxlength="6" pattern="[0-9]{6}" required autofocus> maxlength="6" pattern="[0-9]{6}" required autofocus>
</div> </div>
<button class="btn btn-primary w-100 mb-2" type="submit">Bestätigen</button> <button class="btn btn-primary w-100 mb-2" type="submit">Bestätigen</button>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="remember" name="remember" value="7">
<label class="form-check-label" for="remember">
Gerät für
<select class="form-select form-select-sm d-inline-block w-auto" id="remember_days" onchange="document.getElementById('remember').value=this.value">
<option value="1">1 Tag</option>
<option value="3">3 Tage</option>
<option value="7" selected>7 Tage</option>
<option value="14">14 Tage</option>
<option value="30">30 Tage</option>
</select>
merken
</label>
</div>
<script>
(function() {
var cb = document.getElementById('remember');
var sel = document.getElementById('remember_days');
cb.addEventListener('change', function() {
if (this.checked) this.value = sel.value;
});
sel.addEventListener('change', function() {
if (cb.checked) cb.value = this.value;
});
})();
</script>
</form> </form>
<p class="text-center text-muted mt-3 small"> <p class="text-center text-muted mt-3 small">