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) }