Löst einen Blocker im Kernablauf: eine frisch registrierte Firma hat nur einen admin-Login und konnte damit bislang keinen einzigen eingereichten Antrag sehen oder entscheiden, weil requireFachebene nur verantwortlicher/pruefer durchließ. Die Spezifikation will admin+ KI-Verantwortlicher ohnehin auf derselben Person (siehe CLAUDE.md, Offene Punkte) — statt eines Datenmodell-Umbaus (roles-Array oder zwei app_user-Zeilen pro Person) bekommt admin jetzt pragmatisch dieselben Fachebene-Rechte wie verantwortlicher (hatEntscheidungsrecht in fachebene_handlers.go). pruefer bleibt unverändert nur lesend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
174 lines
6.3 KiB
Go
174 lines
6.3 KiB
Go
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 || !user.Active {
|
|
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). "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, 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, 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",
|
|
// admin sieht die Fachebene mit, siehe requireFachebene.
|
|
IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin",
|
|
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
|
|
}
|