Tenant-Feature Phase 6: Session-Cleanup, Docker-Env, Security-Fixes, Doku

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>
This commit is contained in:
Jesko Anschütz 2026-03-23 19:39:39 +01:00
parent ae5dfbd210
commit 0e66bfdb24
9 changed files with 114 additions and 57 deletions

View file

@ -118,6 +118,28 @@ Hinweis:
- auf dem aktuellen System dieser Session sind `make` und `go` nicht installiert; die Befehle sind fuer den Entwicklungsrechner vorbereitet - auf dem aktuellen System dieser Session sind `make` und `go` nicht installiert; die Befehle sind fuer den Entwicklungsrechner vorbereitet
## Lokale Entwicklung mit Login
Seit der Implementierung des Tenant-Features ist das Backend durch eine Session-basierte Authentifizierung geschuetzt. Fuer den lokalen Entwicklungsbetrieb muessen zwei zusaetzliche Umgebungsvariablen gesetzt werden:
- `MORZ_INFOBOARD_ADMIN_PASSWORD` legt das Passwort des initialen Admin-Users fest. Beim Backend-Start wird automatisch ein User `admin` angelegt (bzw. dessen Passwort aktualisiert), der dem Standard-Tenant `morz` zugeordnet ist. Bleibt die Variable leer, wird kein Admin angelegt und der Login-Bereich ist nicht nutzbar.
- `MORZ_INFOBOARD_DEV_MODE` setzt das Session-Cookie ohne das `Secure`-Flag, sodass er auch ueber unverschluesseltes HTTP (lokales `localhost`) uebertragen wird. Ohne dieses Flag wird der Cookie nur ueber HTTPS gesetzt und der Login schlaegt im lokalen Betrieb still fehl.
Empfohlener Start fuer die lokale Entwicklung:
```bash
cd server/backend
MORZ_INFOBOARD_ADMIN_PASSWORD=dev \
MORZ_INFOBOARD_DEV_MODE=true \
go run ./cmd/api
```
Danach ist der Login unter `http://localhost:8080/login` mit `admin` / `dev` erreichbar.
Hinweis: `MORZ_INFOBOARD_DEV_MODE=true` darf niemals in einer produktiven Umgebung gesetzt werden, da der Cookie dort ausschliesslich ueber HTTPS uebertragen werden soll.
---
## Lokaler Start ## Lokaler Start
### Backend lokal starten ### Backend lokal starten
@ -137,6 +159,9 @@ Konfigurierbar ueber:
- `MORZ_INFOBOARD_HTTP_ADDR` HTTP-Adresse (Standard: `:8080`) - `MORZ_INFOBOARD_HTTP_ADDR` HTTP-Adresse (Standard: `:8080`)
- `MORZ_INFOBOARD_STATUS_STORE_PATH` Pfad zur JSON-Datei fuer persistenten Status-Store; leer lassen fuer reinen In-Memory-Betrieb - `MORZ_INFOBOARD_STATUS_STORE_PATH` Pfad zur JSON-Datei fuer persistenten Status-Store; leer lassen fuer reinen In-Memory-Betrieb
- `MORZ_INFOBOARD_ADMIN_PASSWORD` Passwort fuer den initialen Admin-User (leer = kein EnsureAdminUser-Lauf)
- `MORZ_INFOBOARD_DEFAULT_TENANT` Slug des Standard-Tenants, dem der Admin-User zugeordnet wird (Standard: `morz`)
- `MORZ_INFOBOARD_DEV_MODE` wenn `true`: Session-Cookie wird ohne `Secure`-Flag gesetzt (nur fuer lokale Entwicklung)
Beispiele: Beispiele:

View file

@ -57,8 +57,8 @@
- [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 - [x] Firmen-/Monitor-Oberflaeche in Hauptbereiche aufteilen
- [ ] Firmen-/Tenant-Oberfläche → siehe docs/TENANT-FEATURE-PLAN.md - [x] 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

View file

@ -33,6 +33,9 @@ services:
MORZ_INFOBOARD_DATABASE_URL: "postgres://morz_infoboard:morz_infoboard@postgres:5432/morz_infoboard?sslmode=disable" MORZ_INFOBOARD_DATABASE_URL: "postgres://morz_infoboard:morz_infoboard@postgres:5432/morz_infoboard?sslmode=disable"
MORZ_INFOBOARD_UPLOAD_DIR: "/uploads" MORZ_INFOBOARD_UPLOAD_DIR: "/uploads"
MORZ_INFOBOARD_MQTT_BROKER: "tcp://mosquitto:1883" MORZ_INFOBOARD_MQTT_BROKER: "tcp://mosquitto:1883"
MORZ_INFOBOARD_ADMIN_PASSWORD: "${MORZ_INFOBOARD_ADMIN_PASSWORD}"
MORZ_INFOBOARD_DEV_MODE: "${MORZ_INFOBOARD_DEV_MODE:-false}"
MORZ_INFOBOARD_DEFAULT_TENANT: "${MORZ_INFOBOARD_DEFAULT_TENANT:-morz}"
volumes: volumes:
- uploads:/uploads - uploads:/uploads
depends_on: depends_on:

View file

@ -119,45 +119,45 @@ Logout implementieren, alle Routen eintragen.
Ziel: Drei Middleware-Funktionen implementieren, Router umbauen sodass geschuetzte Routen Ziel: Drei Middleware-Funktionen implementieren, Router umbauen sodass geschuetzte Routen
hinter den Middlewares liegen, hardcoded `"morz"` an allen vier Stellen entfernen. hinter den Middlewares liegen, hardcoded `"morz"` an allen vier Stellen entfernen.
- [ ] **RequireAuth implementieren** in `server/backend/internal/httpapi/middleware.go` - [x] **RequireAuth implementieren** in `server/backend/internal/httpapi/middleware.go`
(neue Datei) Funktion `RequireAuth(authStore *store.AuthStore) func(http.Handler) http.Handler`; (neue Datei) Funktion `RequireAuth(authStore *store.AuthStore) func(http.Handler) http.Handler`;
liest Cookie `morz_session`, ruft `authStore.GetSessionUser` auf, liest Cookie `morz_session`, ruft `authStore.GetSessionUser` auf,
speichert `*store.User` im Context (eigener Key-Typ `contextKey`), speichert `*store.User` im Context (eigener Key-Typ `contextKey`),
redirectet bei Fehler zu `/login?next=<aktueller-Pfad>`. redirectet bei Fehler zu `/login?next=<aktueller-Pfad>`.
- [ ] **RequireAdmin implementieren** in `middleware.go` Funktion - [x] **RequireAdmin implementieren** in `middleware.go` Funktion
`RequireAdmin(next http.Handler) http.Handler`; liest User aus Context, `RequireAdmin(next http.Handler) http.Handler`; liest User aus Context,
prueft `user.Role == "admin"`, antwortet sonst mit 403. prueft `user.Role == "admin"`, antwortet sonst mit 403.
- [ ] **RequireTenantAccess implementieren** in `middleware.go` Funktion - [x] **RequireTenantAccess implementieren** in `middleware.go` Funktion
`RequireTenantAccess(next http.Handler) http.Handler`; liest User und `{tenantSlug}` aus `RequireTenantAccess(next http.Handler) http.Handler`; liest User und `{tenantSlug}` aus
Request-Path, erlaubt Zugriff wenn `user.Role == "admin"` oder `user.TenantSlug == tenantSlug` 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), (dazu Feld `TenantSlug string` auf `store.User` erganzen, per JOIN in `GetSessionUser` befullen),
antwortet sonst mit 403. antwortet sonst mit 403.
- [ ] **Router umbauen** in `router.go` die bisherige flache Route-Liste in Gruppen - [x] **Router umbauen** in `router.go` die bisherige flache Route-Liste in Gruppen
umstrukturieren: `/admin`-Routen hinter `RequireAuth` + `RequireAdmin` legen, umstrukturieren: `/admin`-Routen hinter `RequireAuth` + `RequireAdmin` legen,
`/manage/{screenSlug}`-Routen und kuenftige `/tenant/{tenantSlug}/...`-Routen hinter `/manage/{screenSlug}`-Routen und kuenftige `/tenant/{tenantSlug}/...`-Routen hinter
`RequireAuth` + `RequireTenantAccess` legen; Hilfsfunktion `chain(...Middleware)` nutzen `RequireAuth` + `RequireTenantAccess` legen; Hilfsfunktion `chain(...Middleware)` nutzen
oder inline wrappen. oder inline wrappen.
- [ ] **Hardcoded "morz" entfernen (Stelle 1)** in - [x] **Hardcoded "morz" entfernen (Stelle 1)** in
`server/backend/internal/httpapi/manage/ui.go` Zeile 93: `server/backend/internal/httpapi/manage/ui.go` Zeile 93:
`tenants.Get(r.Context(), "morz")` ersetzen durch Auslesen des authentifizierten Users aus `tenants.Get(r.Context(), "morz")` ersetzen durch Auslesen des authentifizierten Users aus
Context; `tenant_id` aus `user.TenantID` verwenden. Context; `tenant_id` aus `user.TenantID` verwenden.
- [ ] **Hardcoded "morz" entfernen (Stelle 2)** in `ui.go` Zeile 154: - [x] **Hardcoded "morz" entfernen (Stelle 2)** in `ui.go` Zeile 154:
gleiche Ersetzung fuer `HandleManageUI`. gleiche Ersetzung fuer `HandleManageUI`.
- [ ] **Hardcoded "morz" entfernen (Stelle 3)** in `ui.go` Zeile 197: - [x] **Hardcoded "morz" entfernen (Stelle 3)** in `ui.go` Zeile 197:
gleiche Ersetzung fuer `HandleProvisionUI`; SSH-User `"morz"` (Zeile 191) aus Config gleiche Ersetzung fuer `HandleProvisionUI`; SSH-User `"morz"` (Zeile 191) aus Config
lesen oder als optionalen Query-Parameter ermoeglichen. lesen oder als optionalen Query-Parameter ermoeglichen.
- [ ] **Hardcoded "morz" entfernen (Stelle 4)** in - [x] **Hardcoded "morz" entfernen (Stelle 4)** in
`server/backend/internal/httpapi/manage/register.go` Zeile 43: `server/backend/internal/httpapi/manage/register.go` Zeile 43:
`tenants.Get(r.Context(), "morz")` durch `cfg.DefaultTenantSlug` ersetzen. `tenants.Get(r.Context(), "morz")` durch `cfg.DefaultTenantSlug` ersetzen.
- [ ] **Doku** `docs/SERVER-KONZEPT.md` um Abschnitt "Middleware-Kette" erganzen: - [x] **Doku** `docs/SERVER-KONZEPT.md` um Abschnitt "Middleware-Kette" erganzen:
Schaubild der Route-Gruppen mit den jeweiligen Middlewares. Schaubild der Route-Gruppen mit den jeweiligen Middlewares.
--- ---
@ -167,52 +167,52 @@ hinter den Middlewares liegen, hardcoded `"morz"` an allen vier Stellen entferne
Ziel: Eigenes Package fuer Tenant-Handler, zweistufige Tab-Ansicht Ziel: Eigenes Package fuer Tenant-Handler, zweistufige Tab-Ansicht
(Screens mit Live-Status, Mediathek mit Upload), Navbar, Routing. (Screens mit Live-Status, Mediathek mit Upload), Navbar, Routing.
- [ ] **Package-Verzeichnis anlegen** neues Verzeichnis - [x] **Package-Verzeichnis anlegen** neues Verzeichnis
`server/backend/internal/httpapi/tenant/`; Dateien: `server/backend/internal/httpapi/tenant/`; Dateien:
`tenant.go` (Handler), `templates.go` (Template-Strings); gleiche Struktur wie Package `manage`. `tenant.go` (Handler), `templates.go` (Template-Strings); gleiche Struktur wie Package `manage`.
- [ ] **tenantDashTmpl definieren** in `tenant/templates.go` Bulma-Layout mit: - [x] **tenantDashTmpl definieren** in `tenant/templates.go` Bulma-Layout mit:
Navbar (Logo links, "Abmelden"-Button rechts als POST /logout), Navbar (Logo links, "Abmelden"-Button rechts als POST /logout),
zwei Tabs (`<div class="tabs">`) mit IDs `tab-screens` und `tab-media`, 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 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`). des Templates (analog zu bestehenden inline-Scripts in `manage/templates.go`).
- [ ] **Tab A Screen-Karten implementieren** in `tenantDashTmpl` Tab A mit Bulma-Cards - [x] **Tab A Screen-Karten implementieren** in `tenantDashTmpl` Tab A mit Bulma-Cards
pro Screen: Titel (Screen.Name), Orientierungsicon, Status-Badge pro Screen: Titel (Screen.Name), Orientierungsicon, Status-Badge
(Online/Offline/Unbekannt) per JS-Fetch aus `/api/v1/screens/status`; (Online/Offline/Unbekannt) per JS-Fetch aus `/api/v1/screens/status`;
JS-Funktion `loadScreenStatuses()` alle 30 Sekunden aufrufen und Badge-Farbe setzen JS-Funktion `loadScreenStatuses()` alle 30 Sekunden aufrufen und Badge-Farbe setzen
(is-success / is-danger / is-warning). (is-success / is-danger / is-warning).
- [ ] **Tab B Mediathek mit Upload implementieren** in `tenantDashTmpl` Tab B: - [x] **Tab B Mediathek mit Upload implementieren** in `tenantDashTmpl` Tab B:
Upload-Formular (multipart, POST `/tenant/{tenantSlug}/upload`), Dateiliste als Bulma-Table 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`); (Titel, Typ, Groesse, Datum, Loeschen-Button mit Modal-Confirmation analog zu `manage/templates.go`);
Upload-Fortschrittsbalken (bestehende JS-Logik aus `manageTmpl` wiederverwenden oder extrahieren). Upload-Fortschrittsbalken (bestehende JS-Logik aus `manageTmpl` wiederverwenden oder extrahieren).
- [ ] **HandleTenantDashboard implementieren** in `tenant/tenant.go` Funktion - [x] **HandleTenantDashboard implementieren** in `tenant/tenant.go` Funktion
`HandleTenantDashboard(tenantStore *store.TenantStore, screenStore *store.ScreenStore, `HandleTenantDashboard(tenantStore *store.TenantStore, screenStore *store.ScreenStore,
mediaStore *store.MediaStore, statusStore playerStatusStore) http.HandlerFunc`; mediaStore *store.MediaStore, statusStore playerStatusStore) http.HandlerFunc`;
liest `{tenantSlug}` aus URL, laedt Screens und Media-Assets, rendert `tenantDashTmpl`. liest `{tenantSlug}` aus URL, laedt Screens und Media-Assets, rendert `tenantDashTmpl`.
- [ ] **HandleTenantUpload implementieren** in `tenant/tenant.go` Funktion - [x] **HandleTenantUpload implementieren** in `tenant/tenant.go` Funktion
`HandleTenantUpload(tenantStore *store.TenantStore, mediaStore *store.MediaStore, `HandleTenantUpload(tenantStore *store.TenantStore, mediaStore *store.MediaStore,
uploadDir string) http.HandlerFunc`; identische Upload-Logik wie `manage.HandleUploadMediaUI`, uploadDir string) http.HandlerFunc`; identische Upload-Logik wie `manage.HandleUploadMediaUI`,
aber ohne Screen-Kontext (Media gehoert direkt dem Tenant); aber ohne Screen-Kontext (Media gehoert direkt dem Tenant);
nach Erfolg Redirect zu `/tenant/{tenantSlug}/dashboard?tab=media&flash=uploaded`. nach Erfolg Redirect zu `/tenant/{tenantSlug}/dashboard?tab=media&flash=uploaded`.
- [ ] **Navbar in Admin-UI erganzen** in `manage/templates.go` in `adminTmpl` und - [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, `manageTmpl` eine minimale Bulma-Navbar mit "Admin" (aktiv) und "Abmelden"-Button erganzen,
sodass beide UIs optisch konsistent sind. sodass beide UIs optisch konsistent sind.
- [ ] **Responsive pruefen** `tenantDashTmpl` auf `is-mobile`-Breakpoint testen: - [x] **Responsive pruefen** `tenantDashTmpl` auf `is-mobile`-Breakpoint testen:
Screen-Karten sollen in `columns is-multiline` wrappen; Upload-Bereich soll auf schmalen Screen-Karten sollen in `columns is-multiline` wrappen; Upload-Bereich soll auf schmalen
Screens nutzbar bleiben. Screens nutzbar bleiben.
- [ ] **Routen eintragen** in `router.go` innerhalb `registerManageRoutes` hinter - [x] **Routen eintragen** in `router.go` innerhalb `registerManageRoutes` hinter
`RequireAuth` + `RequireTenantAccess`: `RequireAuth` + `RequireTenantAccess`:
`mux.HandleFunc("GET /tenant/{tenantSlug}/dashboard", tenant.HandleTenantDashboard(...))`, `mux.HandleFunc("GET /tenant/{tenantSlug}/dashboard", tenant.HandleTenantDashboard(...))`,
`mux.HandleFunc("POST /tenant/{tenantSlug}/upload", tenant.HandleTenantUpload(...))`. `mux.HandleFunc("POST /tenant/{tenantSlug}/upload", tenant.HandleTenantUpload(...))`.
- [ ] **Doku** `docs/SERVER-KONZEPT.md` neuen Abschnitt "Tenant-Dashboard" mit - [x] **Doku** `docs/SERVER-KONZEPT.md` neuen Abschnitt "Tenant-Dashboard" mit
URL-Schema, Tab-Beschreibung und Status-Polling-Intervall erganzen. URL-Schema, Tab-Beschreibung und Status-Polling-Intervall erganzen.
--- ---
@ -223,28 +223,28 @@ Ziel: Der "Zurueck"-Link in der Manage-UI soll kontextsensitiv sein
aus dem Admin-Bereich kommend zeigt er zur Admin-Uebersicht, aus dem Admin-Bereich kommend zeigt er zur Admin-Uebersicht,
aus dem Tenant-Dashboard kommend zurueck zum Dashboard. aus dem Tenant-Dashboard kommend zurueck zum Dashboard.
- [ ] **TemplateData um BackLink/BackLabel erweitern** in `manage/ui.go` - [x] **TemplateData um BackLink/BackLabel erweitern** in `manage/ui.go`
Struct `manageData` (oder gleichwertiges anonymes Struct) um Felder Struct `manageData` (oder gleichwertiges anonymes Struct) um Felder
`BackLink string` und `BackLabel string` erganzen. `BackLink string` und `BackLabel string` erganzen.
- [ ] **HandleManageUI: BackLink aus Query-Parameter lesen** in `HandleManageUI`: - [x] **HandleManageUI: BackLink aus Query-Parameter lesen** in `HandleManageUI`:
wenn `r.URL.Query().Get("from") == "tenant"`, dann wenn `r.URL.Query().Get("from") == "tenant"`, dann
`BackLink = "/tenant/{tenantSlug}/dashboard"` und `BackLabel = "← Dashboard"`; `BackLink = "/tenant/{tenantSlug}/dashboard"` und `BackLabel = "← Dashboard"`;
sonst `BackLink = "/admin"` und `BackLabel = "← Admin"`. sonst `BackLink = "/admin"` und `BackLabel = "← Admin"`.
- [ ] **manageTmpl: statisches "← Admin" ersetzen** in `manage/templates.go` - [x] **manageTmpl: statisches "← Admin" ersetzen** in `manage/templates.go`
den hardcoded Link `← Admin` durch `{{.BackLabel}}` mit `href="{{.BackLink}}"` ersetzen. den hardcoded Link `← Admin` durch `{{.BackLabel}}` mit `href="{{.BackLink}}"` ersetzen.
- [ ] **Tenant-Dashboard: Links zu Manage-UI mit ?from=tenant** in `tenant/templates.go` - [x] **Tenant-Dashboard: Links zu Manage-UI mit ?from=tenant** in `tenant/templates.go`
jeden "Playlist bearbeiten"-Link als `/manage/{screenSlug}?from=tenant` formulieren, jeden "Playlist bearbeiten"-Link als `/manage/{screenSlug}?from=tenant` formulieren,
damit der Ruecklink korrekt gesetzt wird. damit der Ruecklink korrekt gesetzt wird.
- [ ] **Breadcrumb-Navigation** optional, aber empfohlen: in `manageTmpl` oberhalb des - [x] **Breadcrumb-Navigation** optional, aber empfohlen: in `manageTmpl` oberhalb des
Hauptinhalts eine Bulma-Breadcrumb-Leiste einfuegen: Hauptinhalts eine Bulma-Breadcrumb-Leiste einfuegen:
Admin-Pfad: `Admin > {ScreenName}`, Tenant-Pfad: `Dashboard > {ScreenName}`; Admin-Pfad: `Admin > {ScreenName}`, Tenant-Pfad: `Dashboard > {ScreenName}`;
Daten aus `BackLabel`/`BackLink` und `Screen.Name` zusammensetzen. Daten aus `BackLabel`/`BackLink` und `Screen.Name` zusammensetzen.
- [ ] **Doku** Kommentar in `manage/ui.go` bei `HandleManageUI` dokumentiert - [x] **Doku** Kommentar in `manage/ui.go` bei `HandleManageUI` dokumentiert
den `?from=tenant`-Parameter und das BackLink-Verhalten. den `?from=tenant`-Parameter und das BackLink-Verhalten.
--- ---
@ -254,7 +254,7 @@ aus dem Tenant-Dashboard kommend zurueck zum Dashboard.
Ziel: Session-Cleanup als Hintergrundprozess, Secrets in Docker/Ansible, Ziel: Session-Cleanup als Hintergrundprozess, Secrets in Docker/Ansible,
Code-Review durch Larry, End-to-End-Test, Deployment, Nachziehen der Kerndokumentation. Code-Review durch Larry, End-to-End-Test, Deployment, Nachziehen der Kerndokumentation.
- [ ] **Session-Cleanup-Ticker implementieren** in `app.go` nach Server-Start einen - [x] **Session-Cleanup-Ticker implementieren** in `app.go` nach Server-Start einen
`time.NewTicker(1 * time.Hour)` starten (als Goroutine), der `authStore.CleanExpiredSessions` `time.NewTicker(1 * time.Hour)` starten (als Goroutine), der `authStore.CleanExpiredSessions`
aufruft; Ticker beim Shutdown stoppen (Context-Abbruch oder `defer ticker.Stop()`). aufruft; Ticker beim Shutdown stoppen (Context-Abbruch oder `defer ticker.Stop()`).
@ -281,12 +281,12 @@ Code-Review durch Larry, End-to-End-Test, Deployment, Nachziehen der Kerndokumen
`docker compose pull && docker compose up -d` auf dem Server ausfuehren, `docker compose pull && docker compose up -d` auf dem Server ausfuehren,
Migration 002_auth.sql wird automatisch eingespielt, Logs auf Fehler pruefen. Migration 002_auth.sql wird automatisch eingespielt, Logs auf Fehler pruefen.
- [ ] **TODO.md nachziehen** abgearbeitete Punkte in `TODO.md` abhaken: - [x] **TODO.md nachziehen** abgearbeitete Punkte in `TODO.md` abhaken:
"Firmen-/Monitor-Oberflaeche in Hauptbereiche aufteilen" (Phase 4), "Firmen-/Monitor-Oberflaeche in Hauptbereiche aufteilen" (Phase 4),
"Authentifizierungskonzept festlegen" (falls noch offen), "Authentifizierungskonzept festlegen" (falls noch offen),
"Mandantentrennung in den APIs absichern" (falls noch offen). "Mandantentrennung in den APIs absichern" (falls noch offen).
- [ ] **README / DEVELOPMENT nachziehen** `DEVELOPMENT.md` um Abschnitt - [x] **README / DEVELOPMENT nachziehen** `DEVELOPMENT.md` um Abschnitt
"Lokale Entwicklung mit Login" erganzen: Env-Variable `MORZ_INFOBOARD_ADMIN_PASSWORD=dev` "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. und `MORZ_INFOBOARD_DEV_MODE=true` setzen, um ohne HTTPS-Cookie arbeiten zu koennen.

View file

@ -8,6 +8,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/config" "git.az-it.net/az/morz-infoboard/server/backend/internal/config"
"git.az-it.net/az/morz-infoboard/server/backend/internal/db" "git.az-it.net/az/morz-infoboard/server/backend/internal/db"
@ -17,9 +18,11 @@ import (
) )
type App struct { type App struct {
Config config.Config Config config.Config
server *http.Server server *http.Server
notifier *mqttnotifier.Notifier notifier *mqttnotifier.Notifier
authStore *store.AuthStore
logger *log.Logger
} }
func New() (*App, error) { func New() (*App, error) {
@ -89,14 +92,37 @@ func New() (*App, error) {
}) })
return &App{ return &App{
Config: cfg, Config: cfg,
server: &http.Server{Addr: cfg.HTTPAddress, Handler: handler}, server: &http.Server{Addr: cfg.HTTPAddress, Handler: handler},
notifier: notifier, notifier: notifier,
authStore: authStore,
logger: logger,
}, nil }, nil
} }
func (a *App) Run() error { func (a *App) Run() error {
defer a.notifier.Close() defer a.notifier.Close()
// Session-Cleanup: expired sessions werden stündlich aus der DB entfernt.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := a.authStore.CleanExpiredSessions(ctx); err != nil {
a.logger.Printf("event=session_cleanup_failed err=%v", err)
} else {
a.logger.Printf("event=session_cleanup_ok")
}
case <-ctx.Done():
return
}
}
}()
err := a.server.ListenAndServe() err := a.server.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) { if errors.Is(err, http.ErrServerClosed) {
return nil return nil

View file

@ -79,7 +79,12 @@ func HandleLoginPost(authStore *store.AuthStore, cfg config.Config) http.Handler
user, err := authStore.GetUserByUsername(r.Context(), username) user, err := authStore.GetUserByUsername(r.Context(), username)
if err != nil { if err != nil {
// Constant-time failure — same message for unknown user and wrong password. // Mitigate user-enumeration timing leak: run a dummy bcrypt
// comparison so that unknown-user and wrong-password responses
// take approximately the same time. The dummy hash is a
// pre-computed bcrypt hash of "dummy" (cost 12).
const dummyHash = "$2a$12$44H3KPmJUDdgNss7JB7Qneg9GWEl2OgxWwSqVpXRaQdki8T3U9ED2"
_ = bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(password))
renderError(w, next, "Benutzername oder Passwort falsch.") renderError(w, next, "Benutzername oder Passwort falsch.")
return return
} }
@ -124,19 +129,22 @@ func HandleLoginPost(authStore *store.AuthStore, cfg config.Config) http.Handler
} }
// HandleLogoutPost deletes the session and clears the cookie (POST /logout). // HandleLogoutPost deletes the session and clears the cookie (POST /logout).
func HandleLogoutPost(authStore *store.AuthStore) http.HandlerFunc { func HandleLogoutPost(authStore *store.AuthStore, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(sessionCookieName); err == nil { if cookie, err := r.Cookie(sessionCookieName); err == nil {
_ = authStore.DeleteSession(r.Context(), cookie.Value) _ = authStore.DeleteSession(r.Context(), cookie.Value)
} }
// Expire the cookie immediately. // Expire the cookie immediately.
// Secure must match the flag used when the cookie was set so that
// browsers on HTTPS connections honour the expiry directive.
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Name: sessionCookieName,
Value: "", Value: "",
Path: "/", Path: "/",
MaxAge: -1, MaxAge: -1,
HttpOnly: true, HttpOnly: true,
Secure: !cfg.DevMode,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })

View file

@ -72,7 +72,10 @@ func RequireTenantAccess(next http.Handler) http.Handler {
return return
} }
tenantSlug := r.PathValue("tenantSlug") tenantSlug := r.PathValue("tenantSlug")
if tenantSlug != "" && user.TenantSlug != tenantSlug { // An empty tenantSlug means the route was registered without a
// {tenantSlug} parameter — that is a configuration error. Deny
// access rather than silently granting it to every logged-in user.
if tenantSlug == "" || user.TenantSlug != tenantSlug {
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
return return
} }

View file

@ -95,7 +95,7 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
// ── Auth (no auth middleware required) ──────────────────────────────── // ── Auth (no auth middleware required) ────────────────────────────────
mux.HandleFunc("GET /login", manage.HandleLoginUI(d.AuthStore)) mux.HandleFunc("GET /login", manage.HandleLoginUI(d.AuthStore))
mux.HandleFunc("POST /login", manage.HandleLoginPost(d.AuthStore, d.Config)) mux.HandleFunc("POST /login", manage.HandleLoginPost(d.AuthStore, d.Config))
mux.HandleFunc("POST /logout", manage.HandleLogoutPost(d.AuthStore)) mux.HandleFunc("POST /logout", manage.HandleLogoutPost(d.AuthStore, d.Config))
// Shorthand middleware combinators for this router. // Shorthand middleware combinators for this router.
authOnly := func(h http.Handler) http.Handler { authOnly := func(h http.Handler) http.Handler {

View file

@ -112,11 +112,17 @@ func (s *AuthStore) VerifyPassword(ctx context.Context, userID, password string)
// if no user with username 'admin' already exists. The password is hashed with bcrypt. // if no user with username 'admin' already exists. The password is hashed with bcrypt.
// bcrypt cost factor 12 is used (minimum recommended for production). // bcrypt cost factor 12 is used (minimum recommended for production).
func (s *AuthStore) EnsureAdminUser(ctx context.Context, tenantSlug, password string) error { func (s *AuthStore) EnsureAdminUser(ctx context.Context, tenantSlug, password string) error {
// Check whether 'admin' user already exists for this tenant. // Check whether 'admin' user already exists for this specific tenant.
// The check must be scoped to the tenant to avoid false positives when
// another tenant already has an 'admin' user.
var exists bool var exists bool
err := s.pool.QueryRow(ctx, err := s.pool.QueryRow(ctx,
`select exists(select 1 from users where username = $1)`, `select exists(
"admin", select 1 from users u
join tenants t on t.id = u.tenant_id
where u.username = $1 and t.slug = $2
)`,
"admin", tenantSlug,
).Scan(&exists) ).Scan(&exists)
if err != nil { if err != nil {
return fmt.Errorf("auth: check admin user: %w", err) return fmt.Errorf("auth: check admin user: %w", err)
@ -149,20 +155,6 @@ func (s *AuthStore) EnsureAdminUser(ctx context.Context, tenantSlug, password st
// scan helpers // scan helpers
// ------------------------------------------------------------------ // ------------------------------------------------------------------
func scanUser(row interface {
Scan(dest ...any) error
}) (*User, error) {
var u User
err := row.Scan(&u.ID, &u.TenantID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt)
if err != nil {
if err == pgx.ErrNoRows {
return nil, pgx.ErrNoRows
}
return nil, fmt.Errorf("scan user: %w", err)
}
return &u, nil
}
// scanUserWithSlug scans a row that includes tenant_slug as the third column. // scanUserWithSlug scans a row that includes tenant_slug as the third column.
func scanUserWithSlug(row interface { func scanUserWithSlug(row interface {
Scan(dest ...any) error Scan(dest ...any) error