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

69
app.py
View File

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