diff --git a/.gitignore b/.gitignore index ee2fae9..4075b97 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ ansible/.vault_pass ansible/roles/signage_player/files/morz-agent player/agent/agent-linux-arm64 docs/SESSION-MEMORY-*.md +player/agent/morz-agent diff --git a/TODO.md b/TODO.md index 8fd6938..96689b0 100644 --- a/TODO.md +++ b/TODO.md @@ -58,6 +58,7 @@ - [x] API-Backend fachlich schneiden - [x] Admin-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] Authentifizierungskonzept festlegen - [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] 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 - [ ] Datensicherung fuer Datenbank und Medien einplanen diff --git a/docs/TENANT-FEATURE-PLAN.md b/docs/TENANT-FEATURE-PLAN.md new file mode 100644 index 0000000..927166b --- /dev/null +++ b/docs/TENANT-FEATURE-PLAN.md @@ -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=`. + +- [ ] **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 (`
`) 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 | diff --git a/player/agent/internal/playerserver/server.go b/player/agent/internal/playerserver/server.go index ac4028c..b508015 100644 --- a/player/agent/internal/playerserver/server.go +++ b/player/agent/internal/playerserver/server.go @@ -295,10 +295,10 @@ const playerHTML = ` all.push({ label: 'Playlist', value: dynPlaylistLength + ' Eintr\u00e4ge' }); } if (dynConnectivity) { - var connLabel = dynConnectivity === 'online' ? 'Online' + var connLabel = dynConnectivity === 'online' ? 'Erreichbar' : dynConnectivity === 'degraded' ? 'Eingeschränkt' - : 'Offline'; - all.push({ label: 'Netzwerk', value: connLabel }); + : 'Nicht erreichbar'; + all.push({ label: 'Server', value: connLabel }); } overlay.innerHTML = ''; diff --git a/server/backend/go.mod b/server/backend/go.mod index dc2d74e..0919d34 100644 --- a/server/backend/go.mod +++ b/server/backend/go.mod @@ -9,7 +9,8 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.9.1 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/net v0.44.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.35.0 // indirect ) diff --git a/server/backend/go.sum b/server/backend/go.sum index e7db59d..8cf7273 100644 --- a/server/backend/go.sum +++ b/server/backend/go.sum @@ -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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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/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/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/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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/backend/internal/app/app.go b/server/backend/internal/app/app.go index 40b1dc3..f3f5e00 100644 --- a/server/backend/internal/app/app.go +++ b/server/backend/internal/app/app.go @@ -2,6 +2,8 @@ package app import ( "context" + "crypto/rand" + "encoding/hex" "errors" "log" "net/http" @@ -47,6 +49,23 @@ func New() (*App, error) { screens := store.NewScreenStore(pool.Pool) media := store.NewMediaStore(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). notifier := mqttnotifier.New(cfg.MQTTBroker, cfg.MQTTUsername, cfg.MQTTPassword) @@ -62,7 +81,9 @@ func New() (*App, error) { ScreenStore: screens, MediaStore: media, PlaylistStore: playlists, + AuthStore: authStore, Notifier: notifier, + Config: cfg, UploadDir: cfg.UploadDir, Logger: logger, }) diff --git a/server/backend/internal/config/config.go b/server/backend/internal/config/config.go index 35ac6de..5610fa9 100644 --- a/server/backend/internal/config/config.go +++ b/server/backend/internal/config/config.go @@ -11,17 +11,24 @@ type Config struct { MQTTBroker string MQTTUsername 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 { return Config{ - HTTPAddress: getenv("MORZ_INFOBOARD_HTTP_ADDR", ":8080"), - 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"), - UploadDir: getenv("MORZ_INFOBOARD_UPLOAD_DIR", "/tmp/morz-uploads"), - MQTTBroker: os.Getenv("MORZ_INFOBOARD_MQTT_BROKER"), - MQTTUsername: os.Getenv("MORZ_INFOBOARD_MQTT_USERNAME"), - MQTTPassword: os.Getenv("MORZ_INFOBOARD_MQTT_PASSWORD"), + HTTPAddress: getenv("MORZ_INFOBOARD_HTTP_ADDR", ":8080"), + 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"), + UploadDir: getenv("MORZ_INFOBOARD_UPLOAD_DIR", "/tmp/morz-uploads"), + MQTTBroker: os.Getenv("MORZ_INFOBOARD_MQTT_BROKER"), + MQTTUsername: os.Getenv("MORZ_INFOBOARD_MQTT_USERNAME"), + 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", } } diff --git a/server/backend/internal/httpapi/manage/auth.go b/server/backend/internal/httpapi/manage/auth.go new file mode 100644 index 0000000..e36ce3d --- /dev/null +++ b/server/backend/internal/httpapi/manage/auth.go @@ -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 +} diff --git a/server/backend/internal/httpapi/manage/templates.go b/server/backend/internal/httpapi/manage/templates.go index 82bc488..14bd094 100644 --- a/server/backend/internal/httpapi/manage/templates.go +++ b/server/backend/internal/httpapi/manage/templates.go @@ -1,5 +1,87 @@ package manage +const loginTmpl = ` + + + + + Anmelden – morz infoboard + + + + + + + + +` + const provisionTmpl = ` diff --git a/server/backend/internal/httpapi/router.go b/server/backend/internal/httpapi/router.go index ffe3438..31097fd 100644 --- a/server/backend/internal/httpapi/router.go +++ b/server/backend/internal/httpapi/router.go @@ -4,6 +4,7 @@ import ( "log" "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/mqttnotifier" "git.az-it.net/az/morz-infoboard/server/backend/internal/store" @@ -16,7 +17,9 @@ type RouterDeps struct { ScreenStore *store.ScreenStore MediaStore *store.MediaStore PlaylistStore *store.PlaylistStore + AuthStore *store.AuthStore Notifier *mqttnotifier.Notifier + Config config.Config UploadDir string 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/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 ────────────────────────────────────────────────────────── mux.HandleFunc("GET /admin", manage.HandleAdminUI(d.TenantStore, d.ScreenStore)) mux.HandleFunc("POST /admin/screens/provision", manage.HandleProvisionUI(d.TenantStore, d.ScreenStore))