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 // 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. func (s *Server) authenticate(r *http.Request) (store.User, 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 { return store.User{}, false } return user, true } // 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, ok := s.authenticate(r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) } } // 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, ok := s.authenticate(r) if !ok { http.Error(w, "nicht angemeldet", http.StatusUnauthorized) return } next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) } } // 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, 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, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) } } // requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und // Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe // CLAUDE.md). Beide Rollen sehen dieselbe Sicht; ob innerhalb der // Seite tatsächlich entschieden werden darf, prüft der einzelne // Handler (nur "verantwortlicher" hat Entscheidungsrecht). 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, ok := s.authenticate(r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } if user.Role != "verantwortlicher" && user.Role != "pruefer" { http.Error(w, "nicht gefunden", http.StatusNotFound) return } next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) } } // 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, 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, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) } } // 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 } // 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", IsFachebene: role == "verantwortlicher" || role == "pruefer", IsAdmin: role == "admin", } } // 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 }