Tenant-Feature Phase 1+2: Auth-Fundament + Login-Flow + UX-Textverbesserung

- DB-Migration 002_auth.sql (users + sessions Tabellen)
- AuthStore mit Session-Management, bcrypt, EnsureAdminUser
- Login/Logout Handler mit Cookie-Session (HttpOnly, SameSite=Lax)
- Login-Template (Bulma-Card, deutsche Labels)
- Config: AdminPassword, DefaultTenantSlug, DevMode
- Fallback-Texte: "Netzwerk offline" → "Server nicht erreichbar"
- TENANT-FEATURE-PLAN.md mit 46 Checkboxen als Steuerungsdatei

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jesko Anschütz 2026-03-23 15:46:14 +01:00
parent cea393c1a0
commit 7e7a692521
11 changed files with 614 additions and 13 deletions

1
.gitignore vendored
View file

@ -21,3 +21,4 @@ ansible/.vault_pass
ansible/roles/signage_player/files/morz-agent ansible/roles/signage_player/files/morz-agent
player/agent/agent-linux-arm64 player/agent/agent-linux-arm64
docs/SESSION-MEMORY-*.md docs/SESSION-MEMORY-*.md
player/agent/morz-agent

View file

@ -58,6 +58,7 @@
- [x] API-Backend fachlich schneiden - [x] API-Backend fachlich schneiden
- [x] Admin-Oberflaeche in Hauptbereiche aufteilen - [x] Admin-Oberflaeche in Hauptbereiche aufteilen
- [ ] Firmen-/Monitor-Oberflaeche in Hauptbereiche aufteilen - [ ] Firmen-/Monitor-Oberflaeche in Hauptbereiche aufteilen
- [ ] Firmen-/Tenant-Oberfläche → siehe docs/TENANT-FEATURE-PLAN.md
- [x] Storage-Konzept fuer Uploads, Cache-Dateien und Screenshots festlegen - [x] Storage-Konzept fuer Uploads, Cache-Dateien und Screenshots festlegen
- [x] Authentifizierungskonzept festlegen - [x] Authentifizierungskonzept festlegen
- [x] Mandantentrennung im Datenmodell und in den APIs absichern - [x] Mandantentrennung im Datenmodell und in den APIs absichern
@ -163,6 +164,13 @@
- [x] vars.yml Download-Button in Provision-UI statt Copy-Paste - [x] vars.yml Download-Button in Provision-UI statt Copy-Paste
- [x] Toggle-Switch statt Ja/Nein-Select fuer Enabled-Feld - [x] Toggle-Switch statt Ja/Nein-Select fuer Enabled-Feld
## UX-Bug-Fixes
- [x] Fix: Protokoll-relative URLs (//cdn...) werden nicht mehr durch URL-Normalisierung kaputtgeschrieben
- [x] Fix: PDF-Fragment-Parameter werden mit bestehenden Fragments gemerged statt blind angehängt
- [x] Fix: /api/startup-token setzt Cache-Control: no-store Header (Server + Client)
- [x] Fix: TestAssetsServed Nil-Dereferenz durch tote Goroutine behoben
## Querschnittsthemen ## Querschnittsthemen
- [ ] Datensicherung fuer Datenbank und Medien einplanen - [ ] Datensicherung fuer Datenbank und Medien einplanen

307
docs/TENANT-FEATURE-PLAN.md Normal file
View file

@ -0,0 +1,307 @@
# 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.
- [ ] **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>`.
- [ ] **RequireAdmin implementieren** in `middleware.go` Funktion
`RequireAdmin(next http.Handler) http.Handler`; liest User aus Context,
prueft `user.Role == "admin"`, antwortet sonst mit 403.
- [ ] **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.
- [ ] **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.
- [ ] **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.
- [ ] **Hardcoded "morz" entfernen (Stelle 2)** in `ui.go` Zeile 154:
gleiche Ersetzung fuer `HandleManageUI`.
- [ ] **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.
- [ ] **Hardcoded "morz" entfernen (Stelle 4)** in
`server/backend/internal/httpapi/manage/register.go` Zeile 43:
`tenants.Get(r.Context(), "morz")` durch `cfg.DefaultTenantSlug` ersetzen.
- [ ] **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.
- [ ] **Package-Verzeichnis anlegen** neues Verzeichnis
`server/backend/internal/httpapi/tenant/`; Dateien:
`tenant.go` (Handler), `templates.go` (Template-Strings); gleiche Struktur wie Package `manage`.
- [ ] **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`).
- [ ] **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).
- [ ] **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).
- [ ] **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`.
- [ ] **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`.
- [ ] **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.
- [ ] **Responsive pruefen** `tenantDashTmpl` auf `is-mobile`-Breakpoint testen:
Screen-Karten sollen in `columns is-multiline` wrappen; Upload-Bereich soll auf schmalen
Screens nutzbar bleiben.
- [ ] **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(...))`.
- [ ] **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.
- [ ] **TemplateData um BackLink/BackLabel erweitern** in `manage/ui.go`
Struct `manageData` (oder gleichwertiges anonymes Struct) um Felder
`BackLink string` und `BackLabel string` erganzen.
- [ ] **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"`.
- [ ] **manageTmpl: statisches "← Admin" ersetzen** in `manage/templates.go`
den hardcoded Link `← Admin` durch `{{.BackLabel}}` mit `href="{{.BackLink}}"` ersetzen.
- [ ] **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.
- [ ] **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.
- [ ] **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.
- [ ] **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.
- [ ] **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).
- [ ] **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 |

View file

@ -295,10 +295,10 @@ const playerHTML = `<!DOCTYPE html>
all.push({ label: 'Playlist', value: dynPlaylistLength + ' Eintr\u00e4ge' }); all.push({ label: 'Playlist', value: dynPlaylistLength + ' Eintr\u00e4ge' });
} }
if (dynConnectivity) { if (dynConnectivity) {
var connLabel = dynConnectivity === 'online' ? 'Online' var connLabel = dynConnectivity === 'online' ? 'Erreichbar'
: dynConnectivity === 'degraded' ? 'Eingeschränkt' : dynConnectivity === 'degraded' ? 'Eingeschränkt'
: 'Offline'; : 'Nicht erreichbar';
all.push({ label: 'Netzwerk', value: connLabel }); all.push({ label: 'Server', value: connLabel });
} }
overlay.innerHTML = ''; overlay.innerHTML = '';

View file

@ -9,7 +9,8 @@ require (
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/net v0.44.0 // indirect golang.org/x/crypto v0.49.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/net v0.51.0 // indirect
golang.org/x/text v0.29.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
) )

View file

@ -15,11 +15,19 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -2,6 +2,8 @@ package app
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"errors" "errors"
"log" "log"
"net/http" "net/http"
@ -47,6 +49,23 @@ func New() (*App, error) {
screens := store.NewScreenStore(pool.Pool) screens := store.NewScreenStore(pool.Pool)
media := store.NewMediaStore(pool.Pool) media := store.NewMediaStore(pool.Pool)
playlists := store.NewPlaylistStore(pool.Pool) playlists := store.NewPlaylistStore(pool.Pool)
authStore := store.NewAuthStore(pool.Pool)
// Ensure admin user exists — generate a random password if none is configured.
adminPassword := cfg.AdminPassword
if adminPassword == "" {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
pool.Close()
return nil, err
}
adminPassword = hex.EncodeToString(buf)
logger.Printf("event=admin_password_generated password=%s", adminPassword)
}
if err := authStore.EnsureAdminUser(context.Background(), cfg.DefaultTenantSlug, adminPassword); err != nil {
logger.Printf("event=ensure_admin_user_failed err=%v", err)
// Non-fatal: server starts even if admin setup fails.
}
// MQTT notifier (no-op when broker not configured). // MQTT notifier (no-op when broker not configured).
notifier := mqttnotifier.New(cfg.MQTTBroker, cfg.MQTTUsername, cfg.MQTTPassword) notifier := mqttnotifier.New(cfg.MQTTBroker, cfg.MQTTUsername, cfg.MQTTPassword)
@ -62,7 +81,9 @@ func New() (*App, error) {
ScreenStore: screens, ScreenStore: screens,
MediaStore: media, MediaStore: media,
PlaylistStore: playlists, PlaylistStore: playlists,
AuthStore: authStore,
Notifier: notifier, Notifier: notifier,
Config: cfg,
UploadDir: cfg.UploadDir, UploadDir: cfg.UploadDir,
Logger: logger, Logger: logger,
}) })

View file

@ -11,17 +11,24 @@ type Config struct {
MQTTBroker string MQTTBroker string
MQTTUsername string MQTTUsername string
MQTTPassword string MQTTPassword string
// Auth — optional. When AdminPassword is empty, EnsureAdminUser is skipped.
AdminPassword string // MORZ_INFOBOARD_ADMIN_PASSWORD
DefaultTenantSlug string // MORZ_INFOBOARD_DEFAULT_TENANT (default: "morz")
DevMode bool // MORZ_INFOBOARD_DEV_MODE — when true, session cookie works without HTTPS
} }
func Load() Config { func Load() Config {
return Config{ return Config{
HTTPAddress: getenv("MORZ_INFOBOARD_HTTP_ADDR", ":8080"), HTTPAddress: getenv("MORZ_INFOBOARD_HTTP_ADDR", ":8080"),
StatusStorePath: os.Getenv("MORZ_INFOBOARD_STATUS_STORE_PATH"), StatusStorePath: os.Getenv("MORZ_INFOBOARD_STATUS_STORE_PATH"),
DatabaseURL: getenv("MORZ_INFOBOARD_DATABASE_URL", "postgres://morz_infoboard:morz_infoboard@localhost:5432/morz_infoboard?sslmode=disable"), DatabaseURL: getenv("MORZ_INFOBOARD_DATABASE_URL", "postgres://morz_infoboard:morz_infoboard@localhost:5432/morz_infoboard?sslmode=disable"),
UploadDir: getenv("MORZ_INFOBOARD_UPLOAD_DIR", "/tmp/morz-uploads"), UploadDir: getenv("MORZ_INFOBOARD_UPLOAD_DIR", "/tmp/morz-uploads"),
MQTTBroker: os.Getenv("MORZ_INFOBOARD_MQTT_BROKER"), MQTTBroker: os.Getenv("MORZ_INFOBOARD_MQTT_BROKER"),
MQTTUsername: os.Getenv("MORZ_INFOBOARD_MQTT_USERNAME"), MQTTUsername: os.Getenv("MORZ_INFOBOARD_MQTT_USERNAME"),
MQTTPassword: os.Getenv("MORZ_INFOBOARD_MQTT_PASSWORD"), MQTTPassword: os.Getenv("MORZ_INFOBOARD_MQTT_PASSWORD"),
AdminPassword: os.Getenv("MORZ_INFOBOARD_ADMIN_PASSWORD"),
DefaultTenantSlug: getenv("MORZ_INFOBOARD_DEFAULT_TENANT", "morz"),
DevMode: os.Getenv("MORZ_INFOBOARD_DEV_MODE") == "true",
} }
} }

View file

@ -0,0 +1,158 @@
package manage
import (
"html/template"
"net/http"
"net/url"
"strings"
"time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookieName = "morz_session"
sessionTTL = 8 * time.Hour
)
// loginData is the template data for the login page.
type loginData struct {
Error string
Next string
}
// HandleLoginUI renders the login form (GET /login).
// If a valid session cookie is already present, the user is redirected to /admin
// (or the tenant dashboard once tenants are wired up in Phase 3).
func HandleLoginUI(authStore *store.AuthStore) http.HandlerFunc {
tmpl := template.Must(template.New("login").Parse(loginTmpl))
return func(w http.ResponseWriter, r *http.Request) {
// Redirect if already logged in.
if cookie, err := r.Cookie(sessionCookieName); err == nil {
if _, err := authStore.GetSessionUser(r.Context(), cookie.Value); err == nil {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
}
next := r.URL.Query().Get("next")
data := loginData{Next: sanitizeNext(next)}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = tmpl.Execute(w, data)
}
}
// HandleLoginPost handles form submission (POST /login).
// It validates credentials, creates a session, sets the session cookie and
// redirects the user based on their role or the ?next= parameter.
func HandleLoginPost(authStore *store.AuthStore, cfg config.Config) http.HandlerFunc {
tmpl := template.Must(template.New("login").Parse(loginTmpl))
renderError := func(w http.ResponseWriter, next, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
_ = tmpl.Execute(w, loginData{Error: msg, Next: next})
}
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
renderError(w, "", "Ungültige Anfrage.")
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
next := sanitizeNext(r.FormValue("next"))
if username == "" || password == "" {
renderError(w, next, "Bitte Benutzername und Passwort eingeben.")
return
}
user, err := authStore.GetUserByUsername(r.Context(), username)
if err != nil {
// Constant-time failure — same message for unknown user and wrong password.
renderError(w, next, "Benutzername oder Passwort falsch.")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
renderError(w, next, "Benutzername oder Passwort falsch.")
return
}
session, err := authStore.CreateSession(r.Context(), user.ID, sessionTTL)
if err != nil {
renderError(w, next, "Interner Fehler beim Erstellen der Sitzung.")
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: session.ID,
Path: "/",
MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true,
Secure: !cfg.DevMode,
SameSite: http.SameSiteLaxMode,
})
// Redirect: honour ?next= for relative paths, otherwise role-based default.
if next != "" {
http.Redirect(w, r, next, http.StatusSeeOther)
return
}
switch user.Role {
case "admin":
http.Redirect(w, r, "/admin", http.StatusSeeOther)
default:
// Tenant users Phase 3 will provide the full tenant slug; for now fall back to /admin.
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
}
}
// HandleLogoutPost deletes the session and clears the cookie (POST /logout).
func HandleLogoutPost(authStore *store.AuthStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(sessionCookieName); err == nil {
_ = authStore.DeleteSession(r.Context(), cookie.Value)
}
// Expire the cookie immediately.
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
}
// sanitizeNext ensures the redirect target is a safe relative path.
// Only paths starting with "/" and not containing "//" or a scheme are allowed.
func sanitizeNext(next string) string {
if next == "" {
return ""
}
// Reject absolute URLs (contain scheme or authority).
if strings.Contains(next, "://") || strings.Contains(next, "//") {
return ""
}
// Must start with a slash.
if !strings.HasPrefix(next, "/") {
return ""
}
// Validate via url.Parse — rejects anything with a host component.
u, err := url.ParseRequestURI(next)
if err != nil || u.Host != "" || u.Scheme != "" {
return ""
}
return next
}

View file

@ -1,5 +1,87 @@
package manage package manage
const loginTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anmelden morz infoboard</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
body { background: #f5f5f5; min-height: 100vh; display: flex; flex-direction: column; }
.login-wrapper { flex: 1; display: flex; align-items: center; justify-content: center; padding: 2rem; }
</style>
</head>
<body>
<nav class="navbar is-dark">
<div class="navbar-brand">
<span class="navbar-item"><strong>morz infoboard</strong></span>
</div>
</nav>
<div class="login-wrapper">
<div class="columns is-centered" style="width:100%">
<div class="column is-narrow" style="min-width:340px;max-width:420px">
<div class="box">
<h1 class="title is-4 has-text-centered mb-5">Anmelden</h1>
{{if .Error}}
<div class="notification is-danger is-light">
<button class="delete" onclick="this.parentElement.remove()"></button>
{{.Error}}
</div>
{{end}}
<form method="POST" action="/login">
{{if .Next}}
<input type="hidden" name="next" value="{{.Next}}">
{{end}}
<div class="field">
<label class="label" for="username">Benutzername</label>
<div class="control has-icons-left">
<input class="input" type="text" id="username" name="username"
autocomplete="username" autofocus required>
<span class="icon is-small is-left">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</span>
</div>
</div>
<div class="field">
<label class="label" for="password">Passwort</label>
<div class="control has-icons-left">
<input class="input" type="password" id="password" name="password"
autocomplete="current-password" required>
<span class="icon is-small is-left">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</span>
</div>
</div>
<div class="field mt-5">
<div class="control">
<button class="button is-dark is-fullwidth" type="submit">Anmelden</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</body>
</html>`
const provisionTmpl = `<!DOCTYPE html> const provisionTmpl = `<!DOCTYPE html>
<html lang="de"> <html lang="de">
<head> <head>

View file

@ -4,6 +4,7 @@ import (
"log" "log"
"net/http" "net/http"
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi/manage" "git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi/manage"
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier" "git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store" "git.az-it.net/az/morz-infoboard/server/backend/internal/store"
@ -16,7 +17,9 @@ type RouterDeps struct {
ScreenStore *store.ScreenStore ScreenStore *store.ScreenStore
MediaStore *store.MediaStore MediaStore *store.MediaStore
PlaylistStore *store.PlaylistStore PlaylistStore *store.PlaylistStore
AuthStore *store.AuthStore
Notifier *mqttnotifier.Notifier Notifier *mqttnotifier.Notifier
Config config.Config
UploadDir string UploadDir string
Logger *log.Logger Logger *log.Logger
} }
@ -88,6 +91,11 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
mux.HandleFunc("GET /static/bulma.min.css", manage.HandleStaticBulmaCSS()) mux.HandleFunc("GET /static/bulma.min.css", manage.HandleStaticBulmaCSS())
mux.HandleFunc("GET /static/Sortable.min.js", manage.HandleStaticSortableJS()) mux.HandleFunc("GET /static/Sortable.min.js", manage.HandleStaticSortableJS())
// ── Auth (no auth middleware required) ────────────────────────────────
mux.HandleFunc("GET /login", manage.HandleLoginUI(d.AuthStore))
mux.HandleFunc("POST /login", manage.HandleLoginPost(d.AuthStore, d.Config))
mux.HandleFunc("POST /logout", manage.HandleLogoutPost(d.AuthStore))
// ── Admin UI ────────────────────────────────────────────────────────── // ── Admin UI ──────────────────────────────────────────────────────────
mux.HandleFunc("GET /admin", manage.HandleAdminUI(d.TenantStore, d.ScreenStore)) mux.HandleFunc("GET /admin", manage.HandleAdminUI(d.TenantStore, d.ScreenStore))
mux.HandleFunc("POST /admin/screens/provision", manage.HandleProvisionUI(d.TenantStore, d.ScreenStore)) mux.HandleFunc("POST /admin/screens/provision", manage.HandleProvisionUI(d.TenantStore, d.ScreenStore))