syncova-backup/apps/api/internal/httpapi/auth_middleware.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

335 lines
12 KiB
Go

package httpapi
import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"github.com/syncova/syncova/packages/audit"
"github.com/syncova/syncova/packages/auth"
"github.com/syncova/syncova/packages/platform/logging"
)
// authenticatedUserContextKeyType ist der private Typ des Context-Schlüssels
// für den angemeldeten Benutzer.
type authenticatedUserContextKeyType struct{}
// sessionContextKeyType ist der private Typ des Context-Schlüssels für die Sitzung.
type sessionContextKeyType struct{}
var (
// authenticatedUserContextKey speichert den angemeldeten Benutzer.
authenticatedUserContextKey = authenticatedUserContextKeyType{}
// sessionContextKey speichert die Kennung der aktiven Sitzung.
sessionContextKey = sessionContextKeyType{}
)
// AuthenticatedUserFromContext liest den angemeldeten Benutzer aus dem Context.
//
// Der zweite Rückgabewert ist false, wenn der Request nicht authentifiziert ist.
func AuthenticatedUserFromContext(currentContext context.Context) (auth.User, bool) {
authenticatedUser, isPresent := currentContext.Value(authenticatedUserContextKey).(auth.User)
return authenticatedUser, isPresent
}
// SessionIDFromContext liest die Kennung der aktiven Sitzung aus dem Context.
func SessionIDFromContext(currentContext context.Context) (uuid.UUID, bool) {
sessionID, isPresent := currentContext.Value(sessionContextKey).(uuid.UUID)
return sessionID, isPresent
}
// RequestContextFrom baut den Herkunftskontext für das Auditprotokoll.
func RequestContextFrom(request *http.Request) auth.RequestContext {
correlationID, _ := logging.CorrelationIDFromContext(request.Context())
return auth.RequestContext{
IPAddress: clientIPAddress(request),
UserAgent: request.UserAgent(),
CorrelationID: correlationID,
}
}
// clientIPAddress ermittelt die Absenderadresse eines Requests.
//
// Weitergeleitete Adressen aus Headern werden bewusst NICHT ausgewertet: sie
// sind frei fälschbar und würden ein Auditprotokoll wertlos machen, solange
// nicht bekannt ist, welchem Proxy zu trauen ist.
func clientIPAddress(request *http.Request) string {
remoteHost, _, splitError := net.SplitHostPort(request.RemoteAddr)
if splitError != nil {
return request.RemoteAddr
}
return remoteHost
}
// AuthenticationMiddleware prüft das Zugriffstoken jedes Requests.
//
// Ohne gültiges Token endet der Request mit 401; der nachgelagerte Handler wird
// dann gar nicht erst erreicht (PROMPT.md §45).
func AuthenticationMiddleware(authService *auth.Service, baseLogger *slog.Logger) Middleware {
return func(nextHandler http.Handler) http.Handler {
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), baseLogger)
accessToken, extractError := extractBearerToken(request)
if extractError != nil {
WriteError(responseWriter, request, requestLogger, newUnauthenticatedError(
"Für diesen Zugriff ist eine Anmeldung erforderlich."))
return
}
authenticatedUser, sessionID, authenticateError := authService.Authenticate(request.Context(), accessToken)
if authenticateError != nil {
// Die Ursache wird nicht offengelegt: ob ein Token unbekannt,
// abgelaufen oder widerrufen ist, geht den Aufrufer nichts an.
if errors.Is(authenticateError, auth.ErrAccountDisabled) {
WriteError(responseWriter, request, requestLogger, newUnauthenticatedError(
"Das Konto ist nicht mehr aktiv. Bitte an die Administration wenden."))
return
}
// Eine nicht erreichbare Datenbank ist kein Anmeldeproblem.
//
// Die Tokenpruefung braucht die Datenbank; faellt sie aus,
// scheitert jede Pruefung. Als „Sitzung abgelaufen" gemeldet
// schickt das den Betreiber auf die falsche Spur: Er meldet
// sich neu an, was ebenfalls scheitert, und sucht den Fehler
// bei der Anmeldung statt bei der Datenbank. In Phase 21 real
// beobachtet.
if errors.Is(authenticateError, context.DeadlineExceeded) ||
isDatabaseUnavailable(authenticateError) {
requestLogger.Error("die anmeldung liess sich nicht pruefen",
slog.String("grund", authenticateError.Error()))
WriteError(responseWriter, request, requestLogger, NewServiceUnavailableError(
"Die Anmeldung lässt sich derzeit nicht prüfen: Die Datenbank ist "+
"nicht erreichbar. Das ist kein Problem Ihrer Sitzung."))
return
}
WriteError(responseWriter, request, requestLogger, newUnauthenticatedError(
"Die Sitzung ist abgelaufen. Bitte erneut anmelden."))
return
}
enrichedContext := context.WithValue(request.Context(), authenticatedUserContextKey, authenticatedUser)
enrichedContext = context.WithValue(enrichedContext, sessionContextKey, sessionID)
nextHandler.ServeHTTP(responseWriter, request.WithContext(enrichedContext))
})
}
}
// RequirePermission erzwingt eine Berechtigung für einen Handler.
//
// Die Prüfung erfolgt ausschließlich serverseitig; Angaben des Frontends werden
// niemals als Berechtigungsnachweis akzeptiert (PROMPT.md §42).
func RequirePermission(requiredPermission string, authService *auth.Service, auditRecorder audit.Recorder, baseLogger *slog.Logger, protectedHandler http.HandlerFunc) http.HandlerFunc {
return func(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), baseLogger)
authenticatedUser, isAuthenticated := AuthenticatedUserFromContext(request.Context())
if !isAuthenticated {
// Dieser Fall bedeutet einen Fehler im Routenaufbau: der Handler
// wurde ohne vorgeschaltete Authentifizierung eingebunden.
requestLogger.Error("berechtigungsprüfung ohne authentifizierung aufgerufen",
slog.String("permission", requiredPermission),
slog.String("path", request.URL.Path))
WriteError(responseWriter, request, requestLogger, newUnauthenticatedError(
"Für diesen Zugriff ist eine Anmeldung erforderlich."))
return
}
if !authenticatedUser.HasPermission(requiredPermission) {
// Ein abgewiesener Zugriff ist ein Sicherheitsereignis (PROMPT.md §125).
correlationID, _ := logging.CorrelationIDFromContext(request.Context())
if recordError := auditRecorder.Record(request.Context(), audit.Event{
UserID: &authenticatedUser.ID,
ActorUsername: authenticatedUser.Username,
Action: audit.ActionPermissionDenied,
Result: audit.ResultDenied,
IPAddress: clientIPAddress(request),
UserAgent: request.UserAgent(),
CorrelationID: correlationID,
Details: map[string]any{
"benötigte_berechtigung": requiredPermission,
"pfad": request.URL.Path,
},
}); recordError != nil {
requestLogger.Error("abgewiesener zugriff konnte nicht protokolliert werden",
slog.String("error", recordError.Error()))
}
WriteError(responseWriter, request, requestLogger, &APIError{
StatusCode: http.StatusForbidden,
Code: ErrorCodePermissionDenied,
Message: "Für diese Aktion fehlt die erforderliche Berechtigung.",
Details: map[string]any{"required_permission": requiredPermission},
})
return
}
protectedHandler(responseWriter, request)
}
}
// extractBearerToken liest das Zugriffstoken aus dem Authorization-Header.
func extractBearerToken(request *http.Request) (string, error) {
authorizationHeader := request.Header.Get("Authorization")
if authorizationHeader == "" {
return "", errors.New("kein authorization-header vorhanden")
}
// Das Schema wird ohne Beachtung der Groß-/Kleinschreibung verglichen.
const bearerPrefix = "bearer "
if len(authorizationHeader) <= len(bearerPrefix) || !strings.EqualFold(authorizationHeader[:len(bearerPrefix)], bearerPrefix) {
return "", errors.New("das authorization-schema ist nicht Bearer")
}
accessToken := strings.TrimSpace(authorizationHeader[len(bearerPrefix):])
if accessToken == "" {
return "", errors.New("das token ist leer")
}
return accessToken, nil
}
// newUnauthenticatedError baut eine Antwort für einen fehlenden Nachweis.
func newUnauthenticatedError(errorMessage string) *APIError {
return &APIError{
StatusCode: http.StatusUnauthorized,
Code: ErrorCodeUnauthenticated,
Message: errorMessage,
}
}
// ---------------------------------------------------------------------------
// Rate Limiting
// ---------------------------------------------------------------------------
// RateLimiter begrenzt die Anzahl Versuche je Absender.
//
// Der Zähler liegt im Arbeitsspeicher: für die Control Plane mit einem Knoten
// genügt das. Bei mehreren Knoten müsste er in eine gemeinsame Ablage wandern.
type RateLimiter struct {
// mutex schützt die Zählerkarte gegen gleichzeitige Zugriffe.
mutex sync.Mutex
// attemptsByKey hält die Zeitpunkte der Versuche je Absender.
attemptsByKey map[string][]time.Time
// maxAttempts ist die erlaubte Anzahl Versuche im Zeitfenster.
maxAttempts int
// window ist die Länge des betrachteten Zeitfensters.
window time.Duration
}
// NewRateLimiter erzeugt einen Begrenzer.
func NewRateLimiter(maxAttempts int, window time.Duration) *RateLimiter {
return &RateLimiter{
attemptsByKey: make(map[string][]time.Time),
maxAttempts: maxAttempts,
window: window,
}
}
// Allow meldet, ob ein weiterer Versuch zulässig ist, und vermerkt ihn.
func (limiter *RateLimiter) Allow(limiterKey string) bool {
limiter.mutex.Lock()
defer limiter.mutex.Unlock()
currentTime := time.Now()
windowStart := currentTime.Add(-limiter.window)
// Versuche ausserhalb des Zeitfensters werden verworfen.
recentAttempts := make([]time.Time, 0, len(limiter.attemptsByKey[limiterKey]))
for _, attemptTime := range limiter.attemptsByKey[limiterKey] {
if attemptTime.After(windowStart) {
recentAttempts = append(recentAttempts, attemptTime)
}
}
if len(recentAttempts) >= limiter.maxAttempts {
limiter.attemptsByKey[limiterKey] = recentAttempts
return false
}
limiter.attemptsByKey[limiterKey] = append(recentAttempts, currentTime)
return true
}
// Cleanup entfernt Einträge, deren Versuche vollständig abgelaufen sind.
//
// Ohne diese Bereinigung wüchse die Karte mit jeder neuen Absenderadresse.
func (limiter *RateLimiter) Cleanup() {
limiter.mutex.Lock()
defer limiter.mutex.Unlock()
windowStart := time.Now().Add(-limiter.window)
for limiterKey, attemptTimes := range limiter.attemptsByKey {
hasRecentAttempt := false
for _, attemptTime := range attemptTimes {
if attemptTime.After(windowStart) {
hasRecentAttempt = true
break
}
}
if !hasRecentAttempt {
delete(limiter.attemptsByKey, limiterKey)
}
}
}
// RateLimitMiddleware begrenzt Anfragen je Absenderadresse.
//
// Sie schützt die Anmeldung vor dem Durchprobieren von Passwörtern (PROMPT.md §45).
func RateLimitMiddleware(limiter *RateLimiter, baseLogger *slog.Logger) Middleware {
return func(nextHandler http.Handler) http.Handler {
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
if !limiter.Allow(clientIPAddress(request)) {
requestLogger := logging.WithContext(request.Context(), baseLogger)
requestLogger.Warn("anfrage wegen zu vieler versuche abgewiesen",
slog.String("path", request.URL.Path))
WriteError(responseWriter, request, requestLogger, &APIError{
StatusCode: http.StatusTooManyRequests,
Code: ErrorCodeRateLimited,
Message: "Zu viele Versuche. Bitte einige Minuten warten und erneut versuchen.",
})
return
}
nextHandler.ServeHTTP(responseWriter, request)
})
}
}
// isDatabaseUnavailable erkennt einen Ausfall der Datenbank.
//
// Geprueft wird der Verbindungsfehler, nicht der Meldungstext: Ein
// Textvergleich braeche bei der ersten geaenderten Fehlermeldung des Treibers,
// und zwar unbemerkt — der Ausfall erschiene dann wieder als Anmeldeproblem.
func isDatabaseUnavailable(occurredError error) bool {
// Ein Netzfehler auf dem Weg zur Datenbank: kein Rechner, keine Route,
// keine Verbindung.
var networkError net.Error
if errors.As(occurredError, &networkError) {
return true
}
var connectError *pgconn.ConnectError
return errors.As(occurredError, &connectError)
}