Session-Cleanup: - app.go: stündlicher Ticker für CleanExpiredSessions mit Context-Shutdown Docker/Infra: - compose/.env.example: Vorlage für ADMIN_PASSWORD, DEV_MODE, DEFAULT_TENANT - server-stack.yml: Backend-Service referenziert neue Env-Variablen Security-Review (Larry): - EnsureAdminUser: Admin-Check tenant-scoped statt global - scanUser() (toter Code, falsche Spaltenanzahl) entfernt - RequireTenantAccess: leerer tenantSlug nicht mehr als Bypass nutzbar - Login: Dummy-bcrypt bei unbekanntem User gegen Timing-Leak - Logout-Cookie: Secure-Flag konsistent mit Login gesetzt Doku (Doris): - DEVELOPMENT.md: Abschnitt "Lokale Entwicklung mit Login" - TENANT-FEATURE-PLAN.md: Phase 3-5 Checkboxen abgehakt - TODO.md: erledigte Punkte abgehakt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
307 lines
16 KiB
Markdown
307 lines
16 KiB
Markdown
# Tenant-/Firmen-Dashboard – Implementierungs-Checkliste
|
||
|
||
Stand: 2026-03-23
|
||
Verantwortliche: Backy (Auth-Backend), Fred (Tenant-UI), Larry (Code-Review)
|
||
|
||
Diese Datei ist die einzige verbindliche Quelle fuer die Umsetzung des Tenant-Dashboards.
|
||
Alle Schritte sind als Checkboxen formuliert und enthalten Dateipfade und Funktionsnamen,
|
||
sodass kein weiterer Kontext benoetigt wird.
|
||
|
||
---
|
||
|
||
## Phase 1 – Auth-Fundament
|
||
|
||
Ziel: Datenbank-Tabellen fuer User/Sessions anlegen, AuthStore mit allen CRUD-Methoden
|
||
implementieren, Config erweitern und Admin-User beim Start anlegen.
|
||
|
||
- [x] **DB-Migration anlegen** – neue Datei
|
||
`server/backend/internal/db/migrations/002_auth.sql` erstellen mit zwei Tabellen:
|
||
`users` (id UUID PK, tenant_id UUID FK tenants.id, username TEXT UNIQUE NOT NULL,
|
||
password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'tenant', created_at TIMESTAMPTZ)
|
||
und `sessions` (id UUID PK DEFAULT gen_random_uuid(), user_id UUID FK users.id ON DELETE CASCADE,
|
||
created_at TIMESTAMPTZ DEFAULT now(), expires_at TIMESTAMPTZ NOT NULL); Index auf
|
||
`sessions(user_id)` und `sessions(expires_at)` anlegen.
|
||
|
||
- [x] **AuthStore-Datei anlegen** – neue Datei
|
||
`server/backend/internal/store/auth.go` mit Typ `AuthStore struct{ pool *pgxpool.Pool }`
|
||
und Konstruktor `NewAuthStore(pool) *AuthStore`; Paket `store`, gleiche Import-Konventionen
|
||
wie `store.go`.
|
||
|
||
- [x] **AuthStore.GetUserByUsername implementieren** – Methode
|
||
`(s *AuthStore) GetUserByUsername(ctx, username string) (*User, error)`;
|
||
neuer Domain-Typ `User` (id, tenant_id, username, password_hash, role, created_at) in
|
||
`store/auth.go` definieren; Query: `SELECT ... FROM users WHERE username=$1`.
|
||
|
||
- [x] **AuthStore Session-Methoden implementieren** –
|
||
`CreateSession(ctx, userID string, ttl time.Duration) (*Session, error)`,
|
||
`GetSessionUser(ctx, sessionID string) (*User, error)` (JOIN users ON users.id = sessions.user_id
|
||
WHERE sessions.id=$1 AND expires_at > now()),
|
||
`DeleteSession(ctx, sessionID string) error`,
|
||
`CleanExpiredSessions(ctx) error` (DELETE FROM sessions WHERE expires_at <= now()).
|
||
|
||
- [x] **AuthStore.EnsureAdminUser implementieren** – Methode
|
||
`(s *AuthStore) EnsureAdminUser(ctx, tenantSlug, password string) error`;
|
||
prueft ob `username='admin'` existiert, legt ihn mit `bcrypt.GenerateFromPassword` an falls nicht;
|
||
haengt den User an den Tenant mit `tenantSlug` (via Subquery auf `tenants.slug`).
|
||
|
||
- [x] **Config erweitern** – in `server/backend/internal/config/config.go` drei neue Felder
|
||
ergaenzen: `AdminPassword string` (Env: `MORZ_INFOBOARD_ADMIN_PASSWORD`, kein Fallback –
|
||
leer bedeutet: kein EnsureAdminUser-Lauf),
|
||
`DefaultTenantSlug string` (Env: `MORZ_INFOBOARD_DEFAULT_TENANT`, Fallback `"morz"`),
|
||
`DevMode bool` (Env: `MORZ_INFOBOARD_DEV_MODE`, Fallback `false`; wenn true: Session-Cookie
|
||
wird auch ohne HTTPS gesetzt).
|
||
|
||
- [x] **main.go / app.go: AuthStore verdrahten** – in `server/backend/internal/app/app.go`
|
||
`store.NewAuthStore(pool.Pool)` instanziieren und an `httpapi.RouterDeps` uebergeben
|
||
(neues Feld `AuthStore *store.AuthStore`).
|
||
|
||
- [x] **EnsureAdminUser beim Start aufrufen** – in `app.New()` nach der Store-Initialisierung:
|
||
wenn `cfg.AdminPassword != ""`, dann `authStore.EnsureAdminUser(ctx, cfg.DefaultTenantSlug,
|
||
cfg.AdminPassword)` aufrufen; Fehler loggen, aber nicht fatal (Server soll starten).
|
||
|
||
- [x] **Migrations-Runner pruefen** – sicherstellen, dass `db.Connect` in
|
||
`server/backend/internal/db/db.go` alle Dateien aus `migrations/` in alphabetischer
|
||
Reihenfolge ausfuehrt und `002_auth.sql` damit automatisch eingespielt wird; ggf. Logzeile
|
||
hinzufuegen.
|
||
|
||
- [x] **Doku** – Abschnitt "Auth-Datenbankschema" in `docs/SCHEMA.md` ergaenzen:
|
||
Tabellen `users` und `sessions` mit allen Spalten und Constraints beschreiben.
|
||
|
||
---
|
||
|
||
## Phase 2 – Login-Flow
|
||
|
||
Ziel: Login-Seite rendern, POST-Handler mit bcrypt-Pruefung, Session-Cookie setzen,
|
||
Logout implementieren, alle Routen eintragen.
|
||
|
||
- [x] **Login-Template definieren** – in `server/backend/internal/httpapi/manage/templates.go`
|
||
neue Template-Konstante `loginTmpl` anlegen; Bulma-Card zentriert auf der Seite
|
||
(`columns is-centered`, `column is-narrow`), Formular mit Feldern `username` und `password`
|
||
(type=password), Submit-Button `Anmelden`, Flash-Message-Bereich fuer Fehlermeldungen
|
||
(gleiche Klasse wie bestehende Flash-Messages in `manageTmpl`).
|
||
|
||
- [x] **HandleLoginUI implementieren** – in `server/backend/internal/httpapi/manage/auth.go`
|
||
(neue Datei) Funktion `HandleLoginUI() http.HandlerFunc`; GET-Handler, rendert `loginTmpl`;
|
||
wenn Session-Cookie bereits gueltig ist, redirect zu `/tenant/{slug}/dashboard`.
|
||
|
||
- [x] **HandleLoginPost implementieren** – in `auth.go` Funktion
|
||
`HandleLoginPost(authStore *store.AuthStore, cfg config.Config) http.HandlerFunc`;
|
||
liest `username`/`password` aus Formular, ruft `authStore.GetUserByUsername` auf,
|
||
prueft Passwort mit `bcrypt.CompareHashAndPassword`, erstellt Session mit
|
||
`authStore.CreateSession(..., 24*time.Hour)`, setzt `HttpOnly`-Cookie `morz_session`
|
||
(Secure=true ausser bei DevMode), redirectet je nach Rolle:
|
||
`admin` zu `/admin`, `tenant` zu `/tenant/{tenantSlug}/dashboard`.
|
||
|
||
- [x] **HandleLogoutPost implementieren** – in `auth.go` Funktion
|
||
`HandleLogoutPost(authStore *store.AuthStore) http.HandlerFunc`;
|
||
liest Cookie `morz_session`, ruft `authStore.DeleteSession` auf,
|
||
setzt Cookie mit MaxAge=-1 (loeschen), redirectet zu `/login`.
|
||
|
||
- [x] **Routen eintragen** – in `server/backend/internal/httpapi/router.go` in
|
||
`registerManageRoutes` hinzufuegen:
|
||
`mux.HandleFunc("GET /login", auth.HandleLoginUI())`,
|
||
`mux.HandleFunc("POST /login", auth.HandleLoginPost(d.AuthStore, cfg))`,
|
||
`mux.HandleFunc("POST /logout", auth.HandleLogoutPost(d.AuthStore))`;
|
||
Import `httpapi/manage/auth` ergaenzen (oder Package zusammenfassen – Entscheidung vor Umsetzung
|
||
treffen).
|
||
|
||
- [x] **Manuell testen** – Server starten, `GET /login` aufrufen, Login mit
|
||
Admin-Credentials pruefen, Cookie in Devtools sichtbar, Logout funktioniert,
|
||
Fehler bei falschem Passwort zeigt Flash-Message.
|
||
|
||
- [x] **Doku** – `docs/SERVER-KONZEPT.md` Abschnitt "Authentifizierung" um Login-Flow
|
||
und Cookie-Lebensdauer erganzen.
|
||
|
||
---
|
||
|
||
## Phase 3 – Middleware + Tenant-Isolation
|
||
|
||
Ziel: Drei Middleware-Funktionen implementieren, Router umbauen sodass geschuetzte Routen
|
||
hinter den Middlewares liegen, hardcoded `"morz"` an allen vier Stellen entfernen.
|
||
|
||
- [x] **RequireAuth implementieren** – in `server/backend/internal/httpapi/middleware.go`
|
||
(neue Datei) Funktion `RequireAuth(authStore *store.AuthStore) func(http.Handler) http.Handler`;
|
||
liest Cookie `morz_session`, ruft `authStore.GetSessionUser` auf,
|
||
speichert `*store.User` im Context (eigener Key-Typ `contextKey`),
|
||
redirectet bei Fehler zu `/login?next=<aktueller-Pfad>`.
|
||
|
||
- [x] **RequireAdmin implementieren** – in `middleware.go` Funktion
|
||
`RequireAdmin(next http.Handler) http.Handler`; liest User aus Context,
|
||
prueft `user.Role == "admin"`, antwortet sonst mit 403.
|
||
|
||
- [x] **RequireTenantAccess implementieren** – in `middleware.go` Funktion
|
||
`RequireTenantAccess(next http.Handler) http.Handler`; liest User und `{tenantSlug}` aus
|
||
Request-Path, erlaubt Zugriff wenn `user.Role == "admin"` oder `user.TenantSlug == tenantSlug`
|
||
(dazu Feld `TenantSlug string` auf `store.User` erganzen, per JOIN in `GetSessionUser` befullen),
|
||
antwortet sonst mit 403.
|
||
|
||
- [x] **Router umbauen** – in `router.go` die bisherige flache Route-Liste in Gruppen
|
||
umstrukturieren: `/admin`-Routen hinter `RequireAuth` + `RequireAdmin` legen,
|
||
`/manage/{screenSlug}`-Routen und kuenftige `/tenant/{tenantSlug}/...`-Routen hinter
|
||
`RequireAuth` + `RequireTenantAccess` legen; Hilfsfunktion `chain(...Middleware)` nutzen
|
||
oder inline wrappen.
|
||
|
||
- [x] **Hardcoded "morz" entfernen (Stelle 1)** – in
|
||
`server/backend/internal/httpapi/manage/ui.go` Zeile 93:
|
||
`tenants.Get(r.Context(), "morz")` ersetzen durch Auslesen des authentifizierten Users aus
|
||
Context; `tenant_id` aus `user.TenantID` verwenden.
|
||
|
||
- [x] **Hardcoded "morz" entfernen (Stelle 2)** – in `ui.go` Zeile 154:
|
||
gleiche Ersetzung fuer `HandleManageUI`.
|
||
|
||
- [x] **Hardcoded "morz" entfernen (Stelle 3)** – in `ui.go` Zeile 197:
|
||
gleiche Ersetzung fuer `HandleProvisionUI`; SSH-User `"morz"` (Zeile 191) aus Config
|
||
lesen oder als optionalen Query-Parameter ermoeglichen.
|
||
|
||
- [x] **Hardcoded "morz" entfernen (Stelle 4)** – in
|
||
`server/backend/internal/httpapi/manage/register.go` Zeile 43:
|
||
`tenants.Get(r.Context(), "morz")` durch `cfg.DefaultTenantSlug` ersetzen.
|
||
|
||
- [x] **Doku** – `docs/SERVER-KONZEPT.md` um Abschnitt "Middleware-Kette" erganzen:
|
||
Schaubild der Route-Gruppen mit den jeweiligen Middlewares.
|
||
|
||
---
|
||
|
||
## Phase 4 – Tenant-Dashboard UI
|
||
|
||
Ziel: Eigenes Package fuer Tenant-Handler, zweistufige Tab-Ansicht
|
||
(Screens mit Live-Status, Mediathek mit Upload), Navbar, Routing.
|
||
|
||
- [x] **Package-Verzeichnis anlegen** – neues Verzeichnis
|
||
`server/backend/internal/httpapi/tenant/`; Dateien:
|
||
`tenant.go` (Handler), `templates.go` (Template-Strings); gleiche Struktur wie Package `manage`.
|
||
|
||
- [x] **tenantDashTmpl definieren** – in `tenant/templates.go` Bulma-Layout mit:
|
||
Navbar (Logo links, "Abmelden"-Button rechts als POST /logout),
|
||
zwei Tabs (`<div class="tabs">`) mit IDs `tab-screens` und `tab-media`,
|
||
Tab A "Meine Monitore", Tab B "Mediathek"; JS-Snippet fuer Tab-Switching inline am Ende
|
||
des Templates (analog zu bestehenden inline-Scripts in `manage/templates.go`).
|
||
|
||
- [x] **Tab A – Screen-Karten implementieren** – in `tenantDashTmpl` Tab A mit Bulma-Cards
|
||
pro Screen: Titel (Screen.Name), Orientierungsicon, Status-Badge
|
||
(Online/Offline/Unbekannt) per JS-Fetch aus `/api/v1/screens/status`;
|
||
JS-Funktion `loadScreenStatuses()` alle 30 Sekunden aufrufen und Badge-Farbe setzen
|
||
(is-success / is-danger / is-warning).
|
||
|
||
- [x] **Tab B – Mediathek mit Upload implementieren** – in `tenantDashTmpl` Tab B:
|
||
Upload-Formular (multipart, POST `/tenant/{tenantSlug}/upload`), Dateiliste als Bulma-Table
|
||
(Titel, Typ, Groesse, Datum, Loeschen-Button mit Modal-Confirmation analog zu `manage/templates.go`);
|
||
Upload-Fortschrittsbalken (bestehende JS-Logik aus `manageTmpl` wiederverwenden oder extrahieren).
|
||
|
||
- [x] **HandleTenantDashboard implementieren** – in `tenant/tenant.go` Funktion
|
||
`HandleTenantDashboard(tenantStore *store.TenantStore, screenStore *store.ScreenStore,
|
||
mediaStore *store.MediaStore, statusStore playerStatusStore) http.HandlerFunc`;
|
||
liest `{tenantSlug}` aus URL, laedt Screens und Media-Assets, rendert `tenantDashTmpl`.
|
||
|
||
- [x] **HandleTenantUpload implementieren** – in `tenant/tenant.go` Funktion
|
||
`HandleTenantUpload(tenantStore *store.TenantStore, mediaStore *store.MediaStore,
|
||
uploadDir string) http.HandlerFunc`; identische Upload-Logik wie `manage.HandleUploadMediaUI`,
|
||
aber ohne Screen-Kontext (Media gehoert direkt dem Tenant);
|
||
nach Erfolg Redirect zu `/tenant/{tenantSlug}/dashboard?tab=media&flash=uploaded`.
|
||
|
||
- [x] **Navbar in Admin-UI erganzen** – in `manage/templates.go` in `adminTmpl` und
|
||
`manageTmpl` eine minimale Bulma-Navbar mit "Admin" (aktiv) und "Abmelden"-Button erganzen,
|
||
sodass beide UIs optisch konsistent sind.
|
||
|
||
- [x] **Responsive pruefen** – `tenantDashTmpl` auf `is-mobile`-Breakpoint testen:
|
||
Screen-Karten sollen in `columns is-multiline` wrappen; Upload-Bereich soll auf schmalen
|
||
Screens nutzbar bleiben.
|
||
|
||
- [x] **Routen eintragen** – in `router.go` innerhalb `registerManageRoutes` hinter
|
||
`RequireAuth` + `RequireTenantAccess`:
|
||
`mux.HandleFunc("GET /tenant/{tenantSlug}/dashboard", tenant.HandleTenantDashboard(...))`,
|
||
`mux.HandleFunc("POST /tenant/{tenantSlug}/upload", tenant.HandleTenantUpload(...))`.
|
||
|
||
- [x] **Doku** – `docs/SERVER-KONZEPT.md` neuen Abschnitt "Tenant-Dashboard" mit
|
||
URL-Schema, Tab-Beschreibung und Status-Polling-Intervall erganzen.
|
||
|
||
---
|
||
|
||
## Phase 5 – Manage-UI Anpassung
|
||
|
||
Ziel: Der "Zurueck"-Link in der Manage-UI soll kontextsensitiv sein –
|
||
aus dem Admin-Bereich kommend zeigt er zur Admin-Uebersicht,
|
||
aus dem Tenant-Dashboard kommend zurueck zum Dashboard.
|
||
|
||
- [x] **TemplateData um BackLink/BackLabel erweitern** – in `manage/ui.go`
|
||
Struct `manageData` (oder gleichwertiges anonymes Struct) um Felder
|
||
`BackLink string` und `BackLabel string` erganzen.
|
||
|
||
- [x] **HandleManageUI: BackLink aus Query-Parameter lesen** – in `HandleManageUI`:
|
||
wenn `r.URL.Query().Get("from") == "tenant"`, dann
|
||
`BackLink = "/tenant/{tenantSlug}/dashboard"` und `BackLabel = "← Dashboard"`;
|
||
sonst `BackLink = "/admin"` und `BackLabel = "← Admin"`.
|
||
|
||
- [x] **manageTmpl: statisches "← Admin" ersetzen** – in `manage/templates.go`
|
||
den hardcoded Link `← Admin` durch `{{.BackLabel}}` mit `href="{{.BackLink}}"` ersetzen.
|
||
|
||
- [x] **Tenant-Dashboard: Links zu Manage-UI mit ?from=tenant** – in `tenant/templates.go`
|
||
jeden "Playlist bearbeiten"-Link als `/manage/{screenSlug}?from=tenant` formulieren,
|
||
damit der Ruecklink korrekt gesetzt wird.
|
||
|
||
- [x] **Breadcrumb-Navigation** – optional, aber empfohlen: in `manageTmpl` oberhalb des
|
||
Hauptinhalts eine Bulma-Breadcrumb-Leiste einfuegen:
|
||
Admin-Pfad: `Admin > {ScreenName}`, Tenant-Pfad: `Dashboard > {ScreenName}`;
|
||
Daten aus `BackLabel`/`BackLink` und `Screen.Name` zusammensetzen.
|
||
|
||
- [x] **Doku** – Kommentar in `manage/ui.go` bei `HandleManageUI` dokumentiert
|
||
den `?from=tenant`-Parameter und das BackLink-Verhalten.
|
||
|
||
---
|
||
|
||
## Phase 6 – Abschluss + Deployment
|
||
|
||
Ziel: Session-Cleanup als Hintergrundprozess, Secrets in Docker/Ansible,
|
||
Code-Review durch Larry, End-to-End-Test, Deployment, Nachziehen der Kerndokumentation.
|
||
|
||
- [x] **Session-Cleanup-Ticker implementieren** – in `app.go` nach Server-Start einen
|
||
`time.NewTicker(1 * time.Hour)` starten (als Goroutine), der `authStore.CleanExpiredSessions`
|
||
aufruft; Ticker beim Shutdown stoppen (Context-Abbruch oder `defer ticker.Stop()`).
|
||
|
||
- [ ] **Docker-Secret fuer AdminPassword** – in `compose/` (docker-compose.yml oder
|
||
ein neues Override-File) das Env `MORZ_INFOBOARD_ADMIN_PASSWORD` aus einem Docker-Secret
|
||
oder `.env`-Datei beziehen; `.env.example` mit leerem Wert als Dokumentation erganzen.
|
||
|
||
- [ ] **Ansible-Variable erganzen** – in `ansible/` (Rollen `signage_server` oder
|
||
`signage_base`) Variable `morz_admin_password` als Vault-Variable definieren;
|
||
in das entsprechende Docker-Compose-Template eintragen.
|
||
|
||
- [ ] **Code-Review durch Larry** – Pull-Request / Branch auf Gitea erstellen,
|
||
Larry als Reviewer eintragen; Review-Fokus: SQL-Injection, Session-Fixation,
|
||
bcrypt-Cost-Faktor (mind. 12), Middleware-Reihenfolge, fehlende Fehlerbehandlung.
|
||
|
||
- [ ] **End-to-End-Test** – manuelle Checkliste durchlaufen:
|
||
(1) Admin-Login, Admin-UI sichtbar, Logout funktioniert;
|
||
(2) Tenant-Login, nur eigene Screens und Medien sichtbar;
|
||
(3) Tenant kann nicht auf `/admin` zugreifen (403);
|
||
(4) Upload im Tenant-Dashboard, Datei erscheint in Liste;
|
||
(5) Playlist-Bearbeitung aus Tenant-Dashboard, Ruecklink zeigt "← Dashboard".
|
||
|
||
- [ ] **Deployment** – neues Image bauen (`make build` oder CI-Pipeline),
|
||
`docker compose pull && docker compose up -d` auf dem Server ausfuehren,
|
||
Migration 002_auth.sql wird automatisch eingespielt, Logs auf Fehler pruefen.
|
||
|
||
- [x] **TODO.md nachziehen** – abgearbeitete Punkte in `TODO.md` abhaken:
|
||
"Firmen-/Monitor-Oberflaeche in Hauptbereiche aufteilen" (Phase 4),
|
||
"Authentifizierungskonzept festlegen" (falls noch offen),
|
||
"Mandantentrennung in den APIs absichern" (falls noch offen).
|
||
|
||
- [x] **README / DEVELOPMENT nachziehen** – `DEVELOPMENT.md` um Abschnitt
|
||
"Lokale Entwicklung mit Login" erganzen: Env-Variable `MORZ_INFOBOARD_ADMIN_PASSWORD=dev`
|
||
und `MORZ_INFOBOARD_DEV_MODE=true` setzen, um ohne HTTPS-Cookie arbeiten zu koennen.
|
||
|
||
---
|
||
|
||
## Offene Entscheidungen
|
||
|
||
Diese Punkte sind bewusst noch nicht spezifiziert. Sie muessen vor oder waehrend der
|
||
Umsetzung entschieden und dann in dieser Datei oder `docs/OFFENE-ARCHITEKTURFRAGEN.md`
|
||
festgeschrieben werden.
|
||
|
||
| Thema | Frage | Wer entscheidet |
|
||
|---|---|---|
|
||
| Passwort-Reset | Soll es eine "Passwort vergessen"-Funktion geben? Falls ja: E-Mail-Versand oder Admin-seitig? | Jesko |
|
||
| CSRF-Schutz | Brauchen die POST-Formulare CSRF-Tokens? Aktuell kein Schutz implementiert. Empfehlung: `gorilla/csrf` oder eigenes Double-Submit-Cookie. | Backy + Larry |
|
||
| Rate-Limiting | Soll `/login` gegen Brute-Force abgesichert werden (z.B. 10 Versuche / 15 Min per IP)? | Backy |
|
||
| Session-TTL | 24 Stunden ist ein erster Wert. Soll es eine "Eingeloggt bleiben"-Option geben? | Jesko |
|
||
| Tenant-User-Verwaltung | Wer legt Tenant-User an? Aktuell nur der Admin via DB oder EnsureAdminUser. Braucht es eine UI fuer neue Firmen-User? | Jesko |
|