From 0e66bfdb2499e6fc7ed941b5cd9c460f2b613a29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Mon, 23 Mar 2026 19:39:39 +0100 Subject: [PATCH] Tenant-Feature Phase 6: Session-Cleanup, Docker-Env, Security-Fixes, Doku MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- DEVELOPMENT.md | 25 +++++++++ TODO.md | 4 +- compose/server-stack.yml | 3 + docs/TENANT-FEATURE-PLAN.md | 56 +++++++++---------- server/backend/internal/app/app.go | 38 +++++++++++-- .../backend/internal/httpapi/manage/auth.go | 12 +++- server/backend/internal/httpapi/middleware.go | 5 +- server/backend/internal/httpapi/router.go | 2 +- server/backend/internal/store/auth.go | 26 +++------ 9 files changed, 114 insertions(+), 57 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 46aa998..e05e96a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -118,6 +118,28 @@ Hinweis: - 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 ### Backend lokal starten @@ -137,6 +159,9 @@ Konfigurierbar ueber: - `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_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: diff --git a/TODO.md b/TODO.md index 96689b0..6067535 100644 --- a/TODO.md +++ b/TODO.md @@ -57,8 +57,8 @@ - [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] Firmen-/Monitor-Oberflaeche in Hauptbereiche aufteilen +- [x] 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 diff --git a/compose/server-stack.yml b/compose/server-stack.yml index 3a9527b..d8a6f41 100644 --- a/compose/server-stack.yml +++ b/compose/server-stack.yml @@ -33,6 +33,9 @@ services: MORZ_INFOBOARD_DATABASE_URL: "postgres://morz_infoboard:morz_infoboard@postgres:5432/morz_infoboard?sslmode=disable" MORZ_INFOBOARD_UPLOAD_DIR: "/uploads" 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: - uploads:/uploads depends_on: diff --git a/docs/TENANT-FEATURE-PLAN.md b/docs/TENANT-FEATURE-PLAN.md index 927166b..1b5dbcb 100644 --- a/docs/TENANT-FEATURE-PLAN.md +++ b/docs/TENANT-FEATURE-PLAN.md @@ -119,45 +119,45 @@ Logout implementieren, alle Routen eintragen. 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` +- [x] **RequireAuth implementieren** – in `server/backend/internal/httpapi/middleware.go` (neue Datei) Funktion `RequireAuth(authStore *store.AuthStore) func(http.Handler) http.Handler`; liest Cookie `morz_session`, ruft `authStore.GetSessionUser` auf, speichert `*store.User` im Context (eigener Key-Typ `contextKey`), redirectet bei Fehler zu `/login?next=`. -- [ ] **RequireAdmin implementieren** – in `middleware.go` Funktion +- [x] **RequireAdmin implementieren** – in `middleware.go` Funktion `RequireAdmin(next http.Handler) http.Handler`; liest User aus Context, prueft `user.Role == "admin"`, antwortet sonst mit 403. -- [ ] **RequireTenantAccess implementieren** – in `middleware.go` Funktion +- [x] **RequireTenantAccess implementieren** – in `middleware.go` Funktion `RequireTenantAccess(next http.Handler) http.Handler`; liest User und `{tenantSlug}` aus Request-Path, erlaubt Zugriff wenn `user.Role == "admin"` oder `user.TenantSlug == tenantSlug` (dazu Feld `TenantSlug string` auf `store.User` erganzen, per JOIN in `GetSessionUser` befullen), antwortet sonst mit 403. -- [ ] **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, `/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 +- [x] **Hardcoded "morz" entfernen (Stelle 1)** – in `server/backend/internal/httpapi/manage/ui.go` Zeile 93: `tenants.Get(r.Context(), "morz")` ersetzen durch Auslesen des authentifizierten Users aus Context; `tenant_id` aus `user.TenantID` verwenden. -- [ ] **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`. -- [ ] **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 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: `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. --- @@ -167,52 +167,52 @@ hinter den Middlewares liegen, hardcoded `"morz"` an allen vier Stellen entferne Ziel: Eigenes Package fuer Tenant-Handler, zweistufige Tab-Ansicht (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: `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), 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 +- [x] **Tab A – Screen-Karten implementieren** – in `tenantDashTmpl` Tab A mit Bulma-Cards pro Screen: Titel (Screen.Name), Orientierungsicon, Status-Badge (Online/Offline/Unbekannt) per JS-Fetch aus `/api/v1/screens/status`; JS-Funktion `loadScreenStatuses()` alle 30 Sekunden aufrufen und Badge-Farbe setzen (is-success / is-danger / is-warning). -- [ ] **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 (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 +- [x] **HandleTenantDashboard implementieren** – in `tenant/tenant.go` Funktion `HandleTenantDashboard(tenantStore *store.TenantStore, screenStore *store.ScreenStore, mediaStore *store.MediaStore, statusStore playerStatusStore) http.HandlerFunc`; liest `{tenantSlug}` aus URL, laedt Screens und Media-Assets, rendert `tenantDashTmpl`. -- [ ] **HandleTenantUpload implementieren** – in `tenant/tenant.go` Funktion +- [x] **HandleTenantUpload implementieren** – in `tenant/tenant.go` Funktion `HandleTenantUpload(tenantStore *store.TenantStore, mediaStore *store.MediaStore, uploadDir string) http.HandlerFunc`; identische Upload-Logik wie `manage.HandleUploadMediaUI`, aber ohne Screen-Kontext (Media gehoert direkt dem Tenant); nach Erfolg Redirect zu `/tenant/{tenantSlug}/dashboard?tab=media&flash=uploaded`. -- [ ] **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, 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 Screens nutzbar bleiben. -- [ ] **Routen eintragen** – in `router.go` innerhalb `registerManageRoutes` hinter +- [x] **Routen eintragen** – in `router.go` innerhalb `registerManageRoutes` hinter `RequireAuth` + `RequireTenantAccess`: `mux.HandleFunc("GET /tenant/{tenantSlug}/dashboard", tenant.HandleTenantDashboard(...))`, `mux.HandleFunc("POST /tenant/{tenantSlug}/upload", tenant.HandleTenantUpload(...))`. -- [ ] **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. --- @@ -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 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 `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 `BackLink = "/tenant/{tenantSlug}/dashboard"` und `BackLabel = "← Dashboard"`; 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. -- [ ] **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, 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: 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 +- [x] **Doku** – Kommentar in `manage/ui.go` bei `HandleManageUI` dokumentiert 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, 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` 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, 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), "Authentifizierungskonzept festlegen" (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` und `MORZ_INFOBOARD_DEV_MODE=true` setzen, um ohne HTTPS-Cookie arbeiten zu koennen. diff --git a/server/backend/internal/app/app.go b/server/backend/internal/app/app.go index f3f5e00..abf6bb9 100644 --- a/server/backend/internal/app/app.go +++ b/server/backend/internal/app/app.go @@ -8,6 +8,7 @@ import ( "log" "net/http" "os" + "time" "git.az-it.net/az/morz-infoboard/server/backend/internal/config" "git.az-it.net/az/morz-infoboard/server/backend/internal/db" @@ -17,9 +18,11 @@ import ( ) type App struct { - Config config.Config - server *http.Server - notifier *mqttnotifier.Notifier + Config config.Config + server *http.Server + notifier *mqttnotifier.Notifier + authStore *store.AuthStore + logger *log.Logger } func New() (*App, error) { @@ -89,14 +92,37 @@ func New() (*App, error) { }) return &App{ - Config: cfg, - server: &http.Server{Addr: cfg.HTTPAddress, Handler: handler}, - notifier: notifier, + Config: cfg, + server: &http.Server{Addr: cfg.HTTPAddress, Handler: handler}, + notifier: notifier, + authStore: authStore, + logger: logger, }, nil } func (a *App) Run() error { 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() if errors.Is(err, http.ErrServerClosed) { return nil diff --git a/server/backend/internal/httpapi/manage/auth.go b/server/backend/internal/httpapi/manage/auth.go index 4664a00..9c4d107 100644 --- a/server/backend/internal/httpapi/manage/auth.go +++ b/server/backend/internal/httpapi/manage/auth.go @@ -79,7 +79,12 @@ func HandleLoginPost(authStore *store.AuthStore, cfg config.Config) http.Handler user, err := authStore.GetUserByUsername(r.Context(), username) 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.") 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). -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) { if cookie, err := r.Cookie(sessionCookieName); err == nil { _ = authStore.DeleteSession(r.Context(), cookie.Value) } // 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{ Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, + Secure: !cfg.DevMode, SameSite: http.SameSiteLaxMode, }) diff --git a/server/backend/internal/httpapi/middleware.go b/server/backend/internal/httpapi/middleware.go index 1877202..249c903 100644 --- a/server/backend/internal/httpapi/middleware.go +++ b/server/backend/internal/httpapi/middleware.go @@ -72,7 +72,10 @@ func RequireTenantAccess(next http.Handler) http.Handler { return } 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) return } diff --git a/server/backend/internal/httpapi/router.go b/server/backend/internal/httpapi/router.go index 9f36a5b..a937411 100644 --- a/server/backend/internal/httpapi/router.go +++ b/server/backend/internal/httpapi/router.go @@ -95,7 +95,7 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) { // ── 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)) + mux.HandleFunc("POST /logout", manage.HandleLogoutPost(d.AuthStore, d.Config)) // Shorthand middleware combinators for this router. authOnly := func(h http.Handler) http.Handler { diff --git a/server/backend/internal/store/auth.go b/server/backend/internal/store/auth.go index c2a56ad..b9becf9 100644 --- a/server/backend/internal/store/auth.go +++ b/server/backend/internal/store/auth.go @@ -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. // bcrypt cost factor 12 is used (minimum recommended for production). 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 err := s.pool.QueryRow(ctx, - `select exists(select 1 from users where username = $1)`, - "admin", + `select exists( + 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) if err != nil { 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 // ------------------------------------------------------------------ -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. func scanUserWithSlug(row interface { Scan(dest ...any) error