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:
parent
ae5dfbd210
commit
0e66bfdb24
9 changed files with 114 additions and 57 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
4
TODO.md
4
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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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=<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,
|
||||
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 (`<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
|
||||
- [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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -20,6 +21,8 @@ type App struct {
|
|||
Config config.Config
|
||||
server *http.Server
|
||||
notifier *mqttnotifier.Notifier
|
||||
authStore *store.AuthStore
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func New() (*App, error) {
|
||||
|
|
@ -92,11 +95,34 @@ func New() (*App, error) {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue