munin/internal/web/middleware.go

664 lines
23 KiB
Go

package web
import (
"crypto/subtle"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"munin/internal/auth"
"munin/internal/store"
"munin/internal/workspace"
)
// requireSession prüft den Session-Cookie und legt die Sitzung im Context ab.
//
// Reihenfolge der Prüfungen:
//
// 1. Signatur und Ablaufdatum des Cookies
// 2. Gibt es den zugehörigen Key noch, und ist er nicht zurückgezogen?
// 3. Bei schreibenden Requests: stimmt das CSRF-Token?
//
// Schritt 2 ist der Grund, warum ein zurückgezogener Key sofort wirkt: Die
// Sitzung trägt nur die Key-ID, die Gültigkeit wird bei jedem Request frisch in
// der Datenbank nachgesehen.
func (s *Server) requireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(auth.SessionCookieName)
if err != nil {
s.redirectToLogin(w, r)
return
}
subject, ok := s.signer.VerifySession(cookie.Value)
if !ok {
s.redirectToLogin(w, r)
return
}
sess, ok := s.sessionFromSubject(r, subject)
if !ok {
s.redirectToLogin(w, r)
return
}
if !s.checkCSRF(w, r, subject) {
return
}
ctx := withSession(r.Context(), sess)
// Ohne gewählten Workspace (Plattform-Admin vor der Workspace-Auswahl)
// gibt es nichts zu öffnen — solche Sitzungen dürfen ausschließlich die
// Workspace-Auswahl aufrufen, siehe requireWorkspacePicked.
if sess.WorkspaceID != "" {
ws, err := s.workspaces.Get(sess.WorkspaceID)
if err != nil {
s.serverError(w, "Workspace konnte nicht geöffnet werden", err)
return
}
ctx = withWorkspace(ctx, ws)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// sessionFromSubject baut aus dem Session-Subject die vollständige Sitzung.
//
// Ein Subject hat eine von vier Formen:
//
// - "platform/<fingerabdruck>" — Plattform-Admin, noch kein Workspace
// gewählt. Der Fingerabdruck muss zum aktuellen ADMIN_TOKEN passen
// (siehe auth.PlatformSubject).
// - "platform/<fingerabdruck>:<workspace-id>" — Plattform-Admin, mit
// gewähltem Workspace.
// - "wsadmin:<workspace-id>:<zugangs-id>" — Workspace-Admin, fest an seinen
// Workspace gebunden (AUSBAUSTUFEN.md 4.8).
// - "key:<workspace-id>:<key-id>" — Projekt-Zugang, immer an genau einen
// Workspace gebunden (den seines Keys).
//
// Die Workspace-ID steht direkt im (signierten, also fälschungssicheren)
// Subject — so lässt sich jede Anfrage ohne Umweg über die Kontroll-Datenbank
// der richtigen Workspace-Datei zuordnen.
//
// Sitzungen aus der Zeit vor dem Fingerabdruck ("platform" ohne Zusatz) passen
// zu keiner Form mehr und enden. Der Plattform-Admin meldet sich einmal neu an.
func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, bool) {
if fingerabdruck, workspaceID, ok := auth.ParsePlatformSubject(subject); ok {
// Mit einem anderen ADMIN_TOKEN entstanden: Das Token wurde seitdem
// ersetzt, die Sitzung gilt nicht mehr. Vergleich in konstanter Zeit,
// damit die Antwortzeit nichts über den richtigen Abdruck verrät.
if subtle.ConstantTimeCompare([]byte(fingerabdruck), []byte(s.adminFingerprint)) != 1 {
return Session{}, false
}
if workspaceID == "" {
return Session{
Subject: subject,
IsPlatformAdmin: true,
Role: auth.RoleAdmin,
}, true
}
ws, err := s.control.WorkspaceByID(r.Context(), workspaceID)
if err != nil {
return Session{}, false
}
return Session{
Subject: subject,
IsPlatformAdmin: true,
WorkspaceID: workspaceID,
WorkspaceName: ws.Name,
Role: auth.RoleAdmin,
}, true
}
if rest, ok := strings.CutPrefix(subject, auth.SubjectWorkspaceAdminPrefix); ok {
return s.workspaceAdminSession(r, subject, rest)
}
rest, ok := auth.KeyIDFromSubject(subject)
if !ok {
return Session{}, false
}
workspaceID, keyID, ok := strings.Cut(rest, ":")
if !ok {
return Session{}, false
}
ws, err := s.workspaces.Get(workspaceID)
if err != nil {
return Session{}, false
}
resolved, err := ws.Store.APIKeyByID(r.Context(), keyID)
if err != nil || resolved.Revoked() {
return Session{}, false
}
project, err := ws.Store.ProjectByID(r.Context(), resolved.ProjectID)
if err != nil {
return Session{}, false
}
return Session{
Subject: subject,
WorkspaceID: workspaceID,
Key: resolved,
Project: project,
Role: auth.Role(resolved.Role),
}, true
}
// workspaceAdminSession baut die Sitzung eines Workspace-Admins aus dem Rest
// seines Subjects ("<workspace-id>:<zugangs-id>").
//
// Wie bei den Keys wird der Zugang bei jedem Request frisch nachgesehen: Ein
// zurückgezogener Zugang beendet die Sitzung sofort, ebenso ein gelöschter
// Workspace (dessen Zugänge fallen per ON DELETE CASCADE mit weg).
//
// Der Vergleich der Workspace-IDs ist eine zweite Sicherung: Das Subject ist
// signiert und damit nicht fälschbar, aber die Bindung an den Workspace soll
// nicht allein davon abhängen, dass es beim Anmelden richtig gebildet wurde.
func (s *Server) workspaceAdminSession(r *http.Request, subject, rest string) (Session, bool) {
workspaceID, adminID, ok := strings.Cut(rest, ":")
if !ok {
return Session{}, false
}
admin, err := s.control.WorkspaceAdminByID(r.Context(), adminID)
if err != nil || admin.Revoked() || admin.WorkspaceID != workspaceID {
return Session{}, false
}
ws, err := s.control.WorkspaceByID(r.Context(), workspaceID)
if err != nil {
return Session{}, false
}
// Nebenbei die letzte Nutzung vermerken — eine Sitzung gilt 30 Tage, der
// Login allein sagt also wenig darüber, ob der Zugang noch gebraucht wird.
_ = s.control.TouchWorkspaceAdmin(r.Context(), admin.ID, admin.LastUsedAt)
return Session{
Subject: subject,
IsWorkspaceAdmin: true,
WorkspaceID: workspaceID,
WorkspaceName: ws.Name,
WorkspaceAdmin: admin,
}, true
}
// checkCSRF prüft bei schreibenden Requests das CSRF-Token.
//
// GET, HEAD und OPTIONS gelten als sichere Methoden — sie sollen nichts
// verändern und brauchen kein Token. Alles andere muss eines mitschicken.
func (s *Server) checkCSRF(w http.ResponseWriter, r *http.Request, subject string) bool {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
if !s.signer.ValidCSRFToken(auth.CSRFTokenFromRequest(r), subject) {
http.Error(w, "Ungültiges oder fehlendes CSRF-Token — bitte die Seite neu laden und erneut versuchen.",
http.StatusForbidden)
return false
}
return true
}
// redirectToLogin schickt den Browser zum Anmeldeformular.
//
// Bei einem schreibenden Request wäre eine Weiterleitung irreführend: Der Browser
// würde das POST als GET wiederholen und der Nutzer stünde ohne Erklärung vor dem
// Formular. Deshalb dort ein klarer Statuscode.
func (s *Server) redirectToLogin(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead:
http.Redirect(w, r, "/login", http.StatusSeeOther)
default:
http.Error(w, "Sitzung abgelaufen — bitte neu anmelden.", http.StatusUnauthorized)
}
}
// requireProject löst den Projekt-Bezeichner aus der URL auf und prüft den Zugriff.
//
// Alle Projektseiten liegen unter /p/{project}/… — dieser eine Ort entscheidet
// damit für sämtliche Projektrouten, ob jemand hineindarf.
func (s *Server) requireProject(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, ok := SessionFrom(r)
if !ok {
s.redirectToLogin(w, r)
return
}
ws, ok := WorkspaceFrom(r)
if !ok {
// Plattform-Admin ohne gewählten Workspace: Projektseiten gibt es
// erst, nachdem einer gewählt wurde.
http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther)
return
}
slug := r.PathValue("project")
project, err := ws.Store.ProjectBySlug(r.Context(), sess.WorkspaceID, slug)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.serverError(w, "Projekt konnte nicht geladen werden", err)
return
}
// Ein fremdes Projekt wird als "nicht vorhanden" beantwortet, nicht als
// "verboten". Sonst ließe sich über die Statuscodes herausfinden, welche
// Projekte es sonst noch gibt.
if !sess.CanAccessProject(project.ID) {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r.WithContext(withProject(r.Context(), project)))
})
}
// requireWorkspace lässt nur Sitzungen mit gewähltem Workspace durch.
//
// Betrifft nur den Plattform-Admin: Projekt- und Workspace-Admin-Sitzungen
// haben ihren Workspace immer schon im Subject (siehe sessionFromSubject). Der
// Plattform-Admin
// dagegen kann sich frisch anmelden, ohne dass klar ist, welche Abteilung er
// verwalten will — dafür ist /admin/workspaces da.
func (s *Server) requireWorkspace(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := WorkspaceFrom(r); !ok {
http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
// requirePlatformAdmin lässt nur den Plattform-Admin durch.
func (s *Server) requirePlatformAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, ok := SessionFrom(r)
if !ok || !sess.IsPlatformAdmin {
http.Error(w, "Nur für den Plattform-Admin verfügbar", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// requireWrite lässt nur Sitzungen durch, die Einträge verändern dürfen.
func (s *Server) requireWrite(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, ok := SessionFrom(r)
if !ok || !sess.CanWrite() {
http.Error(w, "Dieser Zugang darf nur lesen.", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// requireProjectManagement lässt nur Sitzungen durch, die Projekte verwalten
// dürfen: Plattform-Admin und Workspace-Admin.
func (s *Server) requireProjectManagement(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, ok := SessionFrom(r)
if !ok || !sess.CanManageProjects() {
http.Error(w, "Nur für Plattform- und Workspace-Admins verfügbar", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// requireImport lässt nur Sitzungen durch, die importieren dürfen (siehe
// Session.CanImport).
func (s *Server) requireImport(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, ok := SessionFrom(r)
if !ok || !sess.CanImport() {
http.Error(w, "Für den Import wird die Rolle \"admin\" benötigt.", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// requireKeyManagement lässt nur Sitzungen durch, die Keys verwalten dürfen.
func (s *Server) requireKeyManagement(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, ok := SessionFrom(r)
if !ok || !sess.CanManageKeys() {
http.Error(w, "Für die Key-Verwaltung wird die Rolle \"admin\" benötigt.", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// requireProjectDownload lässt nur Sitzungen durch, die ein Projekt als ZIP
// herunterladen dürfen (siehe Session.CanDownloadProject).
func (s *Server) requireProjectDownload(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, ok := SessionFrom(r)
if !ok || !sess.CanDownloadProject() {
http.Error(w, "Für den Download des ganzen Projekts wird die Rolle \"admin\" benötigt.", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// ── Allgemeine Schutzschichten ───────────────────────────────────────────────
// SecurityHeaders setzt bei jeder Antwort die Schutz-Header des Browsers.
//
// Diese Header sind Anweisungen an den Browser: Der Server kann damit Angriffe
// entschärfen, die er selbst gar nicht sieht, weil sie erst im Browser des
// Besuchers stattfinden.
func SecurityHeaders(next http.Handler, enableHSTS bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
// Die Content-Security-Policy ist der wichtigste dieser Header: Sie legt
// fest, woher der Browser überhaupt etwas laden und ausführen darf.
//
// Für uns ist sie die zweite Verteidigungslinie gegen eingeschleustes
// JavaScript. Die erste ist, dass goldmark kein Roh-HTML durchlässt
// (siehe internal/entry). Sollte das je gelockert werden, verhindert
// diese Regel trotzdem die Ausführung.
//
// default-src 'self' alles nur von der eigenen Adresse
// img-src … data: https: Bilder dürfen auch extern eingebunden werden
// object-src 'none' keine Plugin-Einbettungen
// base-uri 'none' verbietet <base href>, das sonst alle
// relativen Links der Seite umbiegen könnte
// form-action 'self' Formulare können nur an uns selbst senden
// frame-ancestors 'none' die Seite darf in keinem fremden Rahmen
// stecken (Schutz vor Clickjacking)
h.Set("Content-Security-Policy",
"default-src 'self'; img-src 'self' data: https:; style-src 'self'; "+
"script-src 'self'; object-src 'none'; base-uri 'none'; "+
"form-action 'self'; frame-ancestors 'none'")
// Verbietet dem Browser, den Inhaltstyp zu erraten. Ohne diesen Header
// könnte eine als Text ausgelieferte Datei, die zufällig wie HTML
// aussieht, als HTML ausgeführt werden.
h.Set("X-Content-Type-Options", "nosniff")
// Schutz vor Clickjacking für Browser, die frame-ancestors noch nicht
// kennen. Doppelt hält besser und kostet nichts.
h.Set("X-Frame-Options", "DENY")
// Beim Klick auf einen externen Link soll die vollständige interne URL
// nicht als Herkunft mitgehen — Projekt- und Eintragsnamen können
// verraten, woran gearbeitet wird.
h.Set("Referrer-Policy", "same-origin")
// Der Browser soll gar nicht erst nach Kamera, Mikrofon und Standort fragen.
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
// HSTS weist den Browser an, diese Adresse künftig nur noch über HTTPS
// aufzurufen. Der Header darf nur gesetzt werden, wenn HTTPS wirklich
// funktioniert: Ein Browser, der ihn einmal gesehen hat, weigert sich für
// die angegebene Dauer, die Seite über http zu laden — auf einer Adresse
// ohne Zertifikat sperrt man sich damit selbst aus.
if enableHSTS {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
// LimitBody begrenzt, wie viele Bytes ein Request-Body haben darf.
//
// Ohne Begrenzung kann ein einzelner Aufruf beliebig viel Speicher belegen. Das
// muss nicht einmal böswillig sein — ein Agent in einer Schleife oder eine
// versehentlich falsche Datei reichen aus.
//
// http.MaxBytesReader ist genau dafür gebaut: Er ersetzt den Body durch eine
// Variante, die nach maxBytes einen Fehler liefert statt weiterzulesen.
func LimitBody(next http.Handler, maxBytes int64) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
}
next.ServeHTTP(w, r)
})
}
// BodyLimits sind die Höchstgrößen eines Request-Bodys je Art von Route.
type BodyLimits struct {
Web int64 // Formulare der Weboberfläche
API int64 // MCP-Aufrufe unter /mcp
Import int64 // Hochladen eines ZIP-Archivs (siehe handlers_import.go)
}
// LimitBodies begrenzt den Body wie LimitBody, wählt die Grenze aber nach der
// Route.
//
// # Warum eine Stelle statt LimitBody je Route?
//
// Zwei ineinander geschachtelte MaxBytesReader begrenzen immer auf den
// KLEINEREN Wert. Ein großzügiges Limit an einer einzelnen Route unter einem
// strengen Limit für alles wirkt deshalb nie. Genau so war MCP früher
// verdrahtet: MAX_API_BODY_BYTES stand auf 4 MB, das äußere Limit von 1 MB
// schnitt trotzdem ab. Hier wird die Grenze einmal außen gewählt, bevor
// irgendetwas anderes den Body anfasst.
func LimitBodies(next http.Handler, limits BodyLimits) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
grenze := limits.Web
switch {
case r.URL.Path == "/mcp":
grenze = limits.API
case r.Method == http.MethodPost && istImportPfad(r.URL.Path):
grenze = limits.Import
}
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, grenze)
}
next.ServeHTTP(w, r)
})
}
// istImportPfad erkennt "/p/{projekt}/import". Der Mux hat zu diesem
// Zeitpunkt noch nicht entschieden, welche Route passt, deshalb wird der Pfad
// hier von Hand zerlegt: genau drei Teile, der erste "p", der letzte "import".
func istImportPfad(pfad string) bool {
teile := strings.Split(strings.Trim(pfad, "/"), "/")
return len(teile) == 3 && teile[0] == "p" && teile[2] == "import"
}
// ── API-Zugang (MCP) ─────────────────────────────────────────────────────────
// APIAuth prüft den Bearer-Key von MCP- und API-Aufrufen und legt die Sitzung im
// Context ab.
//
// Wichtig ist die Reihenfolge: erst den Key prüfen, dann die IP-Sperre.
// Andersherum wäre naheliegender, hätte aber einen unangenehmen Nebeneffekt —
// Deploy-Skript, MCP-Client und Browser eines Büros teilen sich nach außen oft
// eine Adresse, und ein einziger falsch konfigurierter Client würde alle anderen
// mitsperren. Gegen das Durchprobieren hilft die Reihenfolge trotzdem: Ein
// Angreifer hat per Definition keinen gültigen Key und landet im unteren Zweig.
func (s *Server) APIAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := auth.ClientIP(r)
key := ""
if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") {
key = strings.TrimPrefix(header, "Bearer ")
}
if auth.LooksLikeKey(key) {
hash := auth.HashKey(key)
// Erster Schritt: welcher Workspace? Erst danach lässt sich die
// richtige Datei überhaupt ansprechen (siehe ControlStore in
// internal/store/control.go und AUSBAUSTUFEN.md 4.4).
workspaceID, err := s.control.ResolveKeyWorkspace(r.Context(), hash)
if err == nil {
var ws *workspace.Workspace
ws, err = s.workspaces.Get(workspaceID)
if err == nil {
var resolved store.ResolvedKey
resolved, err = ws.Store.ResolveKeyHash(r.Context(), hash)
if err == nil {
s.limiter.Reset(ip)
// Nebenbei vermerken, dass der Key benutzt wurde — damit man
// in der Verwaltung sieht, welche Keys noch aktiv sind. Ein
// Fehler dabei ist unerheblich und darf den Request nicht
// scheitern lassen.
_ = ws.Store.TouchAPIKey(r.Context(), resolved.Key.ID, resolved.Key.LastUsedAt)
sess := Session{
Subject: auth.SubjectKeyPrefix + workspaceID + ":" + resolved.Key.ID,
WorkspaceID: workspaceID,
Key: resolved.Key,
Project: resolved.Project,
Role: auth.Role(resolved.Key.Role),
}
ctx := withSession(r.Context(), sess)
ctx = withProject(ctx, resolved.Project)
ctx = withWorkspace(ctx, ws)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
}
}
}
if allowed, retryAfter := s.limiter.Allow(ip); !allowed {
sekunden := int(retryAfter.Seconds()) + 1
w.Header().Set("Retry-After", strconv.Itoa(sekunden))
http.Error(w, fmt.Sprintf("Zu viele fehlgeschlagene Versuche. Bitte in %d Sekunden erneut versuchen.", sekunden),
http.StatusTooManyRequests)
return
}
s.limiter.RecordFailure(ip)
// Ein ungültiger Key lässt sich keinem Workspace zuordnen — es gibt
// also keine passende Datei für einen Audit-Eintrag. Das Protokoll
// verzeichnet nur Vorgänge innerhalb eines Workspace; ein anonymer
// Fehlversuch gehört ins allgemeine Server-Log.
log.Printf("Ungültiger API-Key von %s", ip)
http.Error(w, "Ungültiger oder fehlender API-Key", http.StatusUnauthorized)
})
}
// ── Zugriffsprotokoll ────────────────────────────────────────────────────────
// statusSchreiber merkt sich den Statuscode einer Antwort.
//
// http.ResponseWriter verrät ihn nicht — man kann ihn setzen, aber nicht lesen.
// Deshalb dieser dünne Mantel, der WriteHeader abfängt.
type statusSchreiber struct {
http.ResponseWriter
status int
bytes int
}
func (w *statusSchreiber) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusSchreiber) Write(b []byte) (int, error) {
// Wer direkt schreibt, ohne WriteHeader aufzurufen, erzeugt implizit ein 200.
if w.status == 0 {
w.status = http.StatusOK
}
n, err := w.ResponseWriter.Write(b)
w.bytes += n
return n, err
}
// Unwrap gibt den darunterliegenden ResponseWriter zurück.
//
// http.ResponseController nutzt diese Methode, um an Fähigkeiten des
// eingewickelten Schreibers heranzukommen. Der MCP-Transport geht diesen Weg für
// seine gestreamten Antworten — ohne Unwrap bliebe dort alles im Puffer hängen.
func (w *statusSchreiber) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
// Flush reicht das Leeren des Puffers durch.
//
// Unwrap deckt nur Aufrufer ab, die http.ResponseController verwenden. Die
// ältere und weiter verbreitete Schreibweise ist eine direkte Typzusicherung:
//
// if f, ok := w.(http.Flusher); ok { f.Flush() }
//
// Die schlägt bei einem eingewickelten Schreiber fehl, wenn er die Methode nicht
// selbst anbietet — und zwar lautlos: Der Aufrufer nimmt an, dass Leeren eben
// nicht unterstützt wird, und schreibt weiter in den Puffer. Solche Fehler
// bemerkt man erst im Betrieb, deshalb hier ausdrücklich.
func (w *statusSchreiber) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// LogRequests schreibt zu jeder Anfrage eine Zeile ins Serverlog.
//
// # Warum das nötig ist
//
// Das Audit-Log hält fest, wer etwas geändert hat — es sagt aber nichts über
// Lesezugriffe, Fehlerantworten oder Antwortzeiten. Ohne Zugriffsprotokoll sieht
// man weder, dass ein Agent in einer Schleife hängt, noch dass eine Seite
// langsam geworden ist, noch dass jemand Pfade abklappert.
//
// # Was bewusst nicht protokolliert wird
//
// - Die Query-Zeichenkette. Dort stehen Suchbegriffe, und die sind Inhalt,
// kein Betriebsdatum. Der Pfad allein reicht, um zu sehen, was aufgerufen wurde.
// - Statische Dateien und der Healthcheck. Beides erzeugt nur Rauschen; der
// Healthcheck läuft alle 30 Sekunden.
func LogRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/healthz" {
next.ServeHTTP(w, r)
return
}
start := time.Now()
schreiber := &statusSchreiber{ResponseWriter: w}
// Halter für den Namen des Zugangs mitgeben. Die Anmelde-Middleware
// weiter innen trägt ihn ein — siehe aktorHalter in context.go.
halter := &aktorHalter{}
r = r.WithContext(withAktorHalter(r.Context(), halter))
next.ServeHTTP(schreiber, r)
wer := halter.name
if wer == "" {
wer = "—" // nicht angemeldet oder abgewiesen
}
status := schreiber.status
if status == 0 {
status = http.StatusOK // Handler hat nichts geschrieben
}
log.Printf("%d %-6s %-44s %8s %s",
status, r.Method, r.URL.Path,
time.Since(start).Round(time.Microsecond), wer)
})
}