package web import ( "context" "net/http" "time" "github.com/netcell-it/deklarix/internal/store" ) const sessionCookieName = "deklarix_session" type contextKey int const ( userContextKey contextKey = iota impersonatorContextKey ) // authenticate liest das Session-Cookie, prüft die Sitzung (existiert, // nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, "", false), // wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes // Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit // requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben. Der // zweite Rückgabewert ist die E-Mail des Betreibers, falls diese Sitzung // durch einen Support-Login entstanden ist (siehe CreateImpersonatedSession), // sonst leer. func (s *Server) authenticate(r *http.Request) (store.User, string, bool) { cookie, err := r.Cookie(sessionCookieName) if err != nil || cookie.Value == "" { return store.User{}, "", false } sess, err := s.store.GetSession(r.Context(), cookie.Value) if err != nil { return store.User{}, "", false } if time.Now().After(sess.ExpiresAt) { return store.User{}, "", false } user, err := s.store.GetUser(r.Context(), sess.UserID) if err != nil || !user.Active { return store.User{}, "", false } impersonator := "" if sess.ImpersonatedByUserID != nil { if betreiber, err := s.store.GetUser(r.Context(), *sess.ImpersonatedByUserID); err == nil { impersonator = betreiber.Email } } return user, impersonator, true } // withAuthContext legt Nutzer und ggf. Impersonator-Hinweis in den // Request-Kontext — gemeinsame Hilfsfunktion für alle require*-Middlewares. func withAuthContext(r *http.Request, user store.User, impersonator string) *http.Request { ctx := context.WithValue(r.Context(), userContextKey, user) if impersonator != "" { ctx = context.WithValue(ctx, impersonatorContextKey, impersonator) } return r.WithContext(ctx) } // requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht // es zurück zu /login (eine echte Navigation, kein htmx-Fragment). func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, impersonator, ok := s.authenticate(r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } next(w, withAuthContext(r, user, impersonator)) } } // requireAPI schützt htmx-Endpunkte (Formular-Posts, Downloads) — diese // werden nur aus einer bereits authentifizierten Seite heraus // aufgerufen, ein Fehlschlag hier ist der Ausnahmefall (z. B. Sitzung // mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect. func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, impersonator, ok := s.authenticate(r) if !ok { http.Error(w, "nicht angemeldet", http.StatusUnauthorized) return } next(w, withAuthContext(r, user, impersonator)) } } // requireBetreiber schützt den Plattform-Bereich (Ebene 5 — "nur // Betreiber", technisch getrennt vom Mandantenbereich, siehe CLAUDE.md). // Ohne Sitzung geht es wie bei requirePage zu /login; mit einer // Sitzung, aber ohne Betreiber-Rolle, gibt es 404 statt 403 — sonst // würde eine 403 einem angemeldeten, aber unprivilegierten Nutzer // verraten, dass unter dieser URL überhaupt etwas existiert. func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, impersonator, ok := s.authenticate(r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } if user.Role != "betreiber" { http.Error(w, "nicht gefunden", http.StatusNotFound) return } next(w, withAuthContext(r, user, impersonator)) } } // requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und // Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe // CLAUDE.md). "admin" ist hier bewusst mit zugelassen: die Spezifikation // will, dass der erste Nutzer einer neuen Firma gleichzeitig admin UND // verantwortlicher ist ("Admin und KI-Verantwortlicher" — siehe CLAUDE.md, // Offene Punkte), app_user.role kennt aber nur einen Wert. Statt eines // Datenmodell-Umbaus (roles-Array oder zwei app_user-Zeilen pro Person) // bekommt admin hier pragmatisch dieselben Fachebene-Rechte wie // verantwortlicher (inkl. Entscheidungsrecht, siehe handleFallEntscheiden) // — ohne das könnte eine frisch registrierte Firma mit nur einem // admin-Login keinen einzigen eingereichten Antrag sehen oder // bearbeiten. pruefer bleibt unverändert nur lesend. Wie bei // requireBetreiber: 404 statt 403 bei falscher Rolle, damit ein // angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter") // nicht erfährt, dass es die Seite überhaupt gibt. func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, impersonator, ok := s.authenticate(r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } if user.Role != "verantwortlicher" && user.Role != "pruefer" && user.Role != "admin" { http.Error(w, "nicht gefunden", http.StatusNotFound) return } next(w, withAuthContext(r, user, impersonator)) } } // requireAdmin schützt die Mandanten-Verwaltung (Ebene 4 — Nutzer/ // Rollen für GENAU EINEN Mandanten, siehe CLAUDE.md). Wie bei // requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle. func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, impersonator, ok := s.authenticate(r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } if user.Role != "admin" { http.Error(w, "nicht gefunden", http.StatusNotFound) return } next(w, withAuthContext(r, user, impersonator)) } } // navData steuert die gemeinsame Navigation (layout.html, "nav"-Block). // Eigenes, kleines Struct statt jeder Seite Zugriff auf den vollen // currentUser zu geben — die Navigation braucht nur, ob ein // Plattform-Link gezeigt werden soll. type navData struct { IsBetreiber bool IsFachebene bool IsAdmin bool Impersonator string // E-Mail des Betreibers bei einem Support-Login, sonst leer } // navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage. // Nur für Seiten hinter requirePage/requireBetreiber aufrufbar (braucht // currentUser). func navFor(r *http.Request) navData { role := currentUser(r).Role return navData{ IsBetreiber: role == "betreiber", // admin sieht die Fachebene mit, siehe requireFachebene. IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin", IsAdmin: role == "admin", Impersonator: currentImpersonator(r), } } // currentUser liest den Nutzer, den requirePage/requireAPI in den // Kontext gelegt haben. Panics, wenn es aufgerufen wird, ohne dass eine // dieser Middlewares vorgeschaltet war — das ist ein Programmierfehler, // kein Laufzeitfall, den man abfangen sollte. func currentUser(r *http.Request) store.User { user, ok := r.Context().Value(userContextKey).(store.User) if !ok { panic("web: currentUser aufgerufen ohne requirePage/requireAPI") } return user } // currentImpersonator liefert die E-Mail des Betreibers, falls die // aktuelle Sitzung ein Support-Login ist, sonst einen leeren String. func currentImpersonator(r *http.Request) string { v, _ := r.Context().Value(impersonatorContextKey).(string) return v }