Info Tab und MFA

This commit is contained in:
Erik Thiele
2026-06-28 15:14:06 +02:00
parent 1243910d4b
commit 297b52e50c
15 changed files with 5203 additions and 6363 deletions

View File

@@ -65,10 +65,33 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
| Route | Beschreibung |
|-------|-------------|
| `GET /admin/users` | User-Liste (Admin, mit MFA-Spalte) |
| `GET /admin/users/create` | User anlegen (Formular) |
| `POST /admin/users/create` | User anlegen |
| `GET /admin/users/edit/<email>` | User bearbeiten (Formular) |
| `POST /admin/users/edit/<email>` | User bearbeiten speichern |
| `POST /admin/users/delete/<email>` | User löschen (letzter Admin geschützt) |
| `POST /admin/users/reset-password/<email>` | Passwort-Reset (temporäres Passwort) |
| `POST /admin/users/edit/<email>` | User bearbeiten speichern |
| `GET /mfa/setup` | MFA einrichten (QR-Code + Verifikation) |
| `POST /mfa/setup` | TOTP-Code verifizieren → MFA aktivieren |
| `GET /mfa/verify` | TOTP-Code nach Login eingeben |
| `POST /mfa/verify` | Code prüfen oder Recovery-Code verwenden |
| `POST /mfa/disable` | MFA deaktivieren (mit Passwort) |
### MFA (Multi-Faktor-Authentifizierung)
- TOTP via `pyotp`, QR-Code via `qrcode[pil]` (nur in `.venv` installiert)
- Der TOTP-Secret wird sofort beim ersten Aufruf von `/mfa/setup` (GET) in `users.json` persistiert, sodass die nachfolgende POST-Verifikation denselben Secret verwendet
- Nach erfolgreicher Aktivierung: 8 Recovery-Codes (hex, 7 Stellen), einmalig im UI angezeigt, jeder nur einmal verwendbar
- 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 ""
### 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/<site>`
3. Recovery-Code: POST `/mfa/verify` mit `recovery=true` → Code prüfen → `mfa_enabled=False` → Login erlauben, aber User muss MFA neu einrichten
### Zugriffs-Dekoratoren
@@ -106,13 +129,14 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- Neue Standorte können über den `+`-Button im Header oder über `GET /add-site?name=<name>` angelegt werden (nur Admins).
- `delete_site` entfernt den Standort aus Config und löscht das Medienverzeichnis rekursiv (nur Admins).
- `config.json["server_url"]` (z.B. `http://signage.ccmake.de`) wird in der Admin-Ansicht für die Player-URLs verwendet.
- Screen-Card-Body hat Tabler-Tabs: **Playlist** (1, aktiv), **Einstellungen** (2), **Aktionen** (3), **Digital Voice Agent** (4), **Medien** (5); Priority-Seite ebenfalls Tabs **Playlist** und **Medien**.
- Screen-Card-Body hat Tabler-Tabs: **Playlist** (1, aktiv), **Einstellungen** (2), **Aktionen** (3), **Digital Voice Agent** (4), **Medien** (5), **Info** (6); Priority-Seite ebenfalls Tabs **Playlist** und **Medien**.
- `stay_on_first`: Wenn aktiviert bleibt der Player auf dem ersten Playlist-Element stehen (kein Durchlauf).
- Tab-Reihenfolge in Screen-Cards: Playlist → Einstellungen → Aktionen → Digital Voice Agent → Medien.
- Tab-Reihenfolge in Screen-Cards: Playlist → Einstellungen → Aktionen → Digital Voice Agent → Medien → Info.
- Player-URL im Screen-Header ist ein klickbarer Link in grauer Farbe.
- `add_customer` und `/customer` erfordern jetzt Login (`@login_required`) mit Site-Zugriffsprüfung.
- `/admin` redirectet zum ersten für den User zugänglichen Standort (nicht mehr global ersten).
- User-Dropdown im Header: zeigt E-Mail + Role-Badge, Menü mit Passwort ändern, Userverwaltung (Admin), Abmelden.
- User-Dropdown im Header: zeigt E-Mail + Role-Badge, Menü mit Passwort ändern, MFA einrichten (mit "Aktiv"-Badge wenn MFA eingeschaltet), Userverwaltung (Admin), Abmelden.
- Info-Tab in Screen-Cards: zeigt Client-Info-Tabelle (IP, Browser, Auflösung, zuletzt gesehen) aus dem Heartbeat-JSON für den jeweiligen Screen
## Repo Quirks
@@ -120,7 +144,7 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- `users.json` is NOT gitignored (tracked for initial admin setup, contains no secrets by default).
- The app has no configured tests, lint, typecheck, formatter, or CI.
- Hardcoded secrets exist in tracked files; do not commit new secrets or reshuffle them casually.
- Wichtige Helper-Funktionen in `app.py`: `load_config()`, `save_config()`, `get_site_list()`, `get_screen_config()`, `is_url()`, `normalize_url()`, `playlist_item_name()`, `playlist_item_enabled()`, `load_priority_files()`, `prio_redirect()`, `get_background_url()`, `load_users()`, `save_users()`, `get_user()`, `init_user_db()`, `get_accessible_sites()`, `record_heartbeat()`, `screen_is_active()`.
- Wichtige Helper-Funktionen in `app.py`: `load_config()`, `save_config()`, `get_site_list()`, `get_screen_config()`, `is_url()`, `normalize_url()`, `playlist_item_name()`, `playlist_item_enabled()`, `load_priority_files()`, `prio_redirect()`, `get_background_url()`, `load_users()`, `save_users()`, `get_user()`, `init_user_db()`, `get_accessible_sites()`, `record_heartbeat()`, `screen_is_active()`, `get_client_info()`, `add_history_entry()`.
- Zugriffs-Dekoratoren in `app.py`: `admin_required`, `site_access_required`.
- Hintergrundbild der Willkommensseite wird pro Standort unter `media/<site>/background.*` gespeichert; Fallback auf `static/wallpaper.png` wenn keine Datei existiert.
- `get_background_url(site)` prüft auf benutzerdefiniertes Hintergrundbild für einen Standort.
@@ -142,6 +166,7 @@ Der Player-Status (Online/Offline) wird mittels dateibasiertem Heartbeat ermitte
| **Server zeichnet Heartbeat auf** | `record_heartbeat(site, screen)` erstellt/updated `/tmp/signage-heartbeat/<site>/<screen>` via `path.touch()` |
| **Admin-UI fragt Status ab** | `screen_is_active(site, screen)` prüft ob `mtime < 60s` alt |
| **Anzeige** | Grüner "Online"-Button (filled success) oder roter "Offline"-Button (filled danger) im Card-Header neben "Löschen" |
| **Client-Info** | Beim Hovern über den Online-Button: Tooltip mit Browser, Auflösung, IP, letztem Seen-Zeitpunkt |
### Helper-Funktionen in `app.py`
@@ -149,23 +174,32 @@ Der Player-Status (Online/Offline) wird mittels dateibasiertem Heartbeat ermitte
HEARTBEAT_DIR = Path("/tmp/signage-heartbeat")
HEARTBEAT_TIMEOUT = 60 # Sekunden
def record_heartbeat(site, screen):
path = HEARTBEAT_DIR / site / screen
def record_heartbeat(site, screen, ip="", ua="", resolution=""):
"""Schreibt JSON mit Client-Info (ip, ua, resolution, last_seen)."""
path = HEARTBEAT_DIR / site / f"{screen}.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
data = {"ip": ip, "ua": ua[:200], "resolution": resolution,
"last_seen": datetime.now().strftime("%d.%m.%Y %H:%M:%S")}
path.write_text(json.dumps(data))
def screen_is_active(site, screen):
path = HEARTBEAT_DIR / site / screen
"""Prüft, ob Heartbeat < 60s alt ist (anhand .json-mtime)."""
path = HEARTBEAT_DIR / site / f"{screen}.json"
if not path.exists():
return False
return (time.time() - path.stat().st_mtime) < HEARTBEAT_TIMEOUT
return (time.time() - path.stat().st_mtime) < 60
def get_client_info(site, screen):
"""Gibt das gespeicherte Client-Info-JSON zurück."""
...
```
### Integration
- `playlist_hash()`-Route ruft `record_heartbeat()` direkt am Anfang auf
- `admin()`-Route setzt `screen_status["online"/"offline"]` via `screen_is_active()` für jeden Screen
- Admin-Template (`admin.html`) zeigt beide Buttons als `<button class="btn btn-sm btn-success/btn-danger" onclick="return false;">`
- `playlist_hash()`-Route liest Query-Parameter `w` (Breite) und `h` (Höhe) + `remote_addr` + `User-Agent` und übergibt sie an `record_heartbeat()`
- `admin()`-Route sammelt `client_info[screen] = get_client_info(...)` für jeden Screen
- Admin-Template (`admin.html`) zeigt den Online-Button mit `title`-Tooltip (Browser, Auflösung, IP, letztes Update) und den Info-Tab mit Client-Info-Tabelle
- Player (`player.html`) sendet `screen.width` und `screen.height` als `?w=...&h=...` im `checkForUpdates`-Fetch
- Die Lösch-Buttons (`btn-outline-danger btn-sm`) haben gleiche Größe durch gleiche btn-sm-Klasse, Form-Wrapper hat `m-0 p-0`
### Docker-Hinweis