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:
@@ -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 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
|
### 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
|
||||||
|
|
||||||
|
|||||||
@@ -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 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
|
- **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
69
app.py
@@ -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
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user