CodeGym /Kurse /Spring Security /OncePerRequestFilter und JWT-Filter

OncePerRequestFilter und JWT-Filter

Spring Security
Level 24, Lektion 1
Verfügbar

1. Platz der JWT-Authentifizierung in der Filterkette

Betrachtet man Spring Security mit den Augen eines Anfängers, möchte man es am liebsten „wie in einer normalen Anwendung“ machen: das Token im Controller lesen, wenn vorhanden den Benutzer ermitteln, wenn nicht eine Ausnahme werfen. Das erscheint einfacher. Aber genau auf diesem Weg landet man meist bei Code, bei dem die Sicherheit über Controller und Services verteilt ist und anschließend nicht mit dem übereinstimmt, was authorizeHttpRequests tut.

Die Schlüsselidee von Spring Security in der servlet/MVC-Welt: Authentifizierung und Autorisierung sind die Verantwortung der Filter, also der Infrastrukturschicht, die vor dem DispatcherServlet und vor Ihren Controllern arbeitet.

Deshalb beschreibt Spring Security die Filterkette als Grundlage der Mechanik: Zuerst durchlaufen die Filter, die versuchen, die Anfrage zu authentifizieren, und erst dann kommt die Autorisierung (frei übersetzt: „darf dieser Benutzer auf diesen Endpoint“). Im offiziellen Schema der Filterreihenfolge betont Spring Security ausdrücklich, dass nach den Authentifizierungsfiltern der AuthorizationFilter aufgerufen wird, der dann die Entscheidung basierend auf den Zugriffsregeln trifft.

In unserem JWT-Zweig übernimmt der Filter die Rolle eines „Übersetzers“ zwischen HTTP und dem Spring-Security-Modell. Er nimmt die rohe HTTP-Anfrage, extrahiert das Bearer-Token, validiert es (darauf wird die nächste Vorlesung eingehen) und verwandelt dies schließlich in ein Standard-Authentication-Objekt.

Danach arbeitet das gesamte restliche Spring-Security-System wie gewohnt: AuthorizationFilter, @PreAuthorize, @AuthenticationPrincipal – es ist ihnen egal, woher der Benutzer kommt, Hauptsache er ist im Kontext vorhanden.

Wenn Sie diese Logik in den Controller verlagern, ergeben sich zwei unangenehme Effekte. Erstens arbeiten die Regeln von authorizeHttpRequests und Method Security „blind“, weil der Benutzer bis dahin nicht wiederhergestellt ist. Zweitens müssen Sie die Prüfungen auf jedem Endpoint wiederholen oder Ihr eigenes „Mini-Spring-Security“ über Spring Security legen. Das ist ungefähr so, als würde man einen zweiten Helm über den ersten setzen: unbequem, wenig sinnvoll und es sieht seltsam aus.

Eine kleine Skizze zur Verankerung im Kopf:

flowchart TD
  A["HTTP request
Authorization: Bearer ..."] --> B["SecurityFilterChain"] B --> C["JWT-Filter (unser)
Authentifizierung wiederherstellen"] C --> D["AuthorizationFilter
Zugriffsregeln prüfen"] D --> E["DispatcherServlet / Controller"]

2. OncePerRequestFilter: Filtergerüst

Filter in der Servlet-API sind eine alte und harte Sache: wie Java vor Lambdas (es funktioniert irgendwie, aber lächeln fällt schwer). Daher schreibt man in der Spring-Welt fast nie einen „nackten“ jakarta.servlet.Filter, sondern nimmt eine bequemere Verpackung. Für unser Szenario ist die verständlichste und beliebteste Basisklasse der OncePerRequestFilter.

Der OncePerRequestFilter löst zwei praktische Aufgaben auf einmal. Erstens bietet er eine bequemere Methode doFilterInternal(HttpServletRequest, HttpServletResponse, FilterChain) anstelle der Arbeit mit ServletRequest/ServletResponse. Zweitens hilft er, „einen ordnungsgemäßen Aufruf pro Anfrage“ zu garantieren und die Wahrscheinlichkeit versehentlicher Doppelauslösungen zu verringern (er heilt nicht alles, macht aber im Durchschnitt das Leben ruhiger).

In der Dokumentation von Spring Security ist zu sehen, dass viele eingebaute Filter ebenfalls auf diesem Gerüst aufbauen: zum Beispiel hat der CsrfFilter eine doFilterInternal(...)-Methode und kann shouldNotFilter(...) überschreiben, wobei beide Methoden genau aus dem OncePerRequestFilter stammen.

Ein weiterer nützlicher psychologischer Punkt: Wenn Sie von OncePerRequestFilter erben, beginnen Sie, wie Spring Security zu denken. Das ist nicht „mein separater Krückenfilter“, sondern „ein weiterer normaler Security-Filter“, der in der Kette neben anderen Filtern lebt und seine Rolle erfüllt.

Wichtige Konsequenz: In unserem benutzerdefinierten JWT-Filter darf keine Geschäftslogik enthalten sein. Das ist nicht der Ort, um zu entscheiden, „ob der USER berechtigt ist, den Entwurf eines anderen zu bearbeiten“. Der Filter beschäftigt sich nur damit, die Authentication aus dem Bearer-Token wiederherzustellen. Alle anderen Entscheidungen sollen dort bleiben, wo sie hingehören – in den Request-Level-Regeln und Method Security.

3. Gerüst des JwtAuthFilter: doFilterInternal und zwei Ausgänge

Bevor wir auch nur eine Zeile über „Überprüfung der Token-Signatur“ schreiben, müssen wir lernen, einen Filter so zu schreiben, dass er die Anwendung nicht kaputt macht. Ein Filter hat im Wesentlichen zwei korrekte Beendigungsszenarien: entweder leitet er die Anfrage weiter (filterChain.doFilter(...)) oder er beendet die Verarbeitung der Anfrage bewusst selbst (z. B. durch Rückgabe von 401) und macht ein return. Eine dritte Möglichkeit gibt es nicht: Wenn Sie die Chain „vergessen“ haben, hängt die Anfrage irgendwo im Nirgendwo fest.

Das Skelett eines Filters auf Basis von OncePerRequestFilter sieht so aus:

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.web.filter.OncePerRequestFilter;

public class JwtAuthFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        // Wichtig: Wenn der Filter keine eigene Antwort gesendet hat, muss er die Anfrage an die Kette weiterleiten
        chain.doFilter(req, res); // vorerst tun wir nichts, stören nur nicht
    }
}

Ja, das ist eine „Leerhülle“. Und das ist großartig. Das ist der Moment, in dem Sie die grundlegende Mechanik überprüfen: Der Filter wird kompiliert, er ist eine Spring Bean, er ist in die Chain eingebunden und wird aufgerufen. Erst dann fügen Sie das Extrahieren des Tokens, die Validierung und den Aufbau der Authentication hinzu.

Nun zu den beiden Ausgangsszenarien etwas bodenständiger. In den meisten Fällen wird Ihr Filter frühe Ausgänge machen: Wenn die Anfrage beispielsweise gar nicht aus dem geschützten API-Bereich kommt, soll der Filter keine Zeit und Aufmerksamkeit verschwenden. Oder wenn kein Token vorhanden ist – für einen öffentlichen Endpoint ist das in Ordnung, für einen geschützten Endpoint kümmern sich später die Zugriffsregeln darum (normalerweise endet das mit einem 401 über die Standardinfrastruktur). Daher taucht innerhalb von doFilterInternal häufig die Struktur auf: „Wenn nicht meine Anfrage, dann leite ich weiter; wenn meine, versuche ich den Benutzer wiederherzustellen; wenn wiederhergestellt, leite ich ebenfalls weiter.“

Und noch ein praktischer Hinweis. In Spring Security gibt es den Standard-AnonymousAuthenticationFilter, der eine anonyme Authentifizierung einträgt, falls keine andere Authentifizierung funktioniert hat. Er ist nützlich, aber im JWT-Zweig wollen wir die echte Authentifizierung vorher setzen, bevor die Anfrage in den Begriffen des SecurityContext zu einem „anonymen Benutzer“ wird. Daher hat die Position des Filters in der Kette eine Bedeutung, und das ist genau das Thema des nächsten Abschnitts.

4. Einbindung in SecurityFilterChain: addFilterBefore und Anker

Wenn Sie einen benutzerdefinierten Filter in Spring Security hinzufügen, ist das Gefährlichste zu denken: „Hauptsache hinzugefügt, die Reihenfolge ergibt sich irgendwie.“ Die Reihenfolge ergibt sich leider nicht „irgendwie“. Die Reihenfolge ist das Sicherheitsverhalten. Und hier hilft Spring: Die HttpSecurity-Klasse bietet Methoden, mit denen Sie Ihren Filter vor oder nach bereits bekannten Filtern einfügen können, sodass Sie die Reihenfolge nicht durch Sterndeutung ermitteln oder nachts die Quelltexte lesen müssen. In der API von HttpSecurity sind die Methoden addFilterBefore und addFilterAfter direkt beschrieben, um Ihren Filter relativ zu einem anderen Filter zu registrieren.

In praktischen Begriffen sieht das so aus: Sie wählen einen „Anker“-Filter (eine beliebige Standard-Filterklasse) und sagen zu Spring Security: „Setze meinen Filter vor ihn“ oder „nach ihn“.

Zur Orientierung merken Sie sich diese kleine Tabelle (und heben Sie sie im Skript auf):

Methode von HttpSecurity Was tut sie Wann praktisch
addFilterBefore(my, SomeFilter.class) fügt vor einem bekannten Filter ein wenn Sie früher dran sein wollen (unser Fall)
addFilterAfter(my, SomeFilter.class) fügt nach einem bekannten Filter ein wenn Sie das Ergebnis eines anderen Filters verarbeiten möchten
addFilterAt(my, SomeFilter.class) ersetzt den Filter an der Position wenn Sie ganz sicher sind, dass „ich statt ihm“ (für Anfänger fast immer gefährlich)

Warum wählen wir im Kurs addFilterBefore(jwtAuthFilter, AnonymousAuthenticationFilter.class)? Weil der AnonymousAuthenticationFilter genau für das Szenario zuständig ist: „Wenn niemand authentifiziert hat, soll anonym verwendet werden.“ Im JWT-Zweig möchten wir, dass das Bearer-Token die Anfrage früher in eine authentifizierte verwandelt, bevor der anonyme Filter greift. Sonst erhalten Sie ein seltsames Verhalten: Das Token ist zwar vorhanden, aber im Kontext befindet sich bereits ein anonymer Benutzer, und die Autorisierung schaut auf ihn und sagt: „Hallo Anonym, ich lasse dich nicht rein.“ Sie werden dann nach einem Fehler suchen, „warum das Token nicht funktioniert“, obwohl der Fehler im Ordering liegt.

Beispiel für das minimale Einbinden des Filters (Konzentration auf die Stelle in der Chain, nicht auf den vollständigen Regelsatz):

import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;

SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter) throws Exception {
    // Setzen den JWT-Filter früher, bevor Spring die anonyme Authentifizierung setzt
    http.addFilterBefore(jwtAuthFilter, AnonymousAuthenticationFilter.class);
    return http.build();
}

Und jetzt binden wir das an unseren stateless-Modus. In einem stateless-Modell konfiguriert man Spring Security normalerweise so, dass die Anwendung keine HttpSession zur Speicherung der Authentifizierung erstellt, über SessionCreationPolicy.STATELESS. In der Dokumentation wird dies direkt als Weg beschrieben, ohne Sessions zu arbeiten.

Das heißt, wenn wir eine Chain für die API schreiben, legen wir normalerweise zwei Entscheidungen fest: „Wir erstellen keine Sessions“ und „JWT-Filter soll früh arbeiten“.

5. Filtergrenzen: shouldNotFilter und Early-Return

Selbst wenn Sie den Filter perfekt in die SecurityFilterChain eingebunden haben, gibt es noch eine kleine Möglichkeit, sich selbst wehzutun: den Filter auf absolut allem arbeiten zu lassen. Auf GET /api/public/articles, auf POST /api/auth/login, auf zukünftigen Auth-Endpoints, auf Doku-Seiten, auf irgendwelche zufälligen Nicht-API-URLs … Das ist nicht immer tödlich, macht den Code aber komplizierter und provoziert Fehler wie „Warum verlangt der Login plötzlich ein Token?“

Der OncePerRequestFilter bietet ein Werkzeug, das dieses Problem sehr menschlich löst: die Methode shouldNotFilter(HttpServletRequest request). Sie existiert als Erweiterungspunkt genau bei OncePerRequestFilter und wird sogar von eingebauten Filtern überschrieben – zum Beispiel sieht man in der API von CsrfFilter, dass shouldNotFilter(...) über dem Basis-OncePerRequestFilter überschrieben wird.

Die Idee ist einfach: Sie geben true für Anfragen zurück, die Sie nicht filtern möchten. Dann wird doFilterInternal gar nicht erst aufgerufen, und Sie müssen keinen „frühen Return“ an jeder Stelle schreiben.

Beispiel für unser Projekt (Bearer-Authentifizierung wird nur auf geschützten APIs benötigt, öffentliche und Auth-Zonen filtert der Filter nicht):

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.filter.OncePerRequestFilter;

public class JwtAuthFilter extends OncePerRequestFilter {
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        // Den Verantwortungsbereich des Filters eingrenzen:
        // Bearer-Authentifizierung wird außerhalb der API nicht benötigt, und innerhalb der API nicht für public und auth Zonen
        String uri = request.getRequestURI();
        return !uri.startsWith("/api/")
                || uri.startsWith("/api/public/")
                || uri.startsWith("/api/auth/");
    }
}

Die zweite Variante – ein Early-Return innerhalb von doFilterInternal. Sie ist ebenfalls funktionsfähig und manchmal sogar einfacher für Anfänger zu erklären, weil man die gesamte Logik in einer Methode sieht. Aber sie hat einen Nachteil: doFilterInternal wird schnell zu einem „Butterbrot aus ifs“, und die Tokenvalidierung (die wir später durchführen werden) beginnt in der Infrastrukturverpackung zu versinken.

Wie wählt man? Hier ist die Regel einfach: Wenn der Filter einen klaren Verantwortungsbereich hat (z. B. protected API innerhalb von /api/**), dann macht shouldNotFilter den Code sauberer und weniger umfangreich. Innerhalb von doFilterInternal lassen Sie dann nur die „echte Arbeit“ des Filters: Token extrahieren → (später) prüfen → (später) Authentifizierung zusammenbauen → weiterleiten.

Überprüfung des Filters in der Kette: DEBUG-Logs

Eine normale Ingenieursgewohnheit: sich nicht auf das Wort zu verlassen, dass „der Filter eingebunden ist“. Überprüfen. Spring Security ist in dieser Hinsicht ziemlich ehrlich: Es kann die Liste der Filter ausgeben, die angewendet werden, und das bietet eine hervorragende Möglichkeit, das Ordering und die Tatsache der Einbindung zu diagnostizieren. In der Dokumentation von Spring Security wird angegeben, dass die Liste der Filter auf der Stufe DEBUG beim Start der Anwendung ausgegeben wird, und es wird sogar ein Beispiel einer Zeile gezeigt, in der die Filter in der DefaultSecurityFilterChain aufgeführt sind.

Um dies lokal zu sehen, reicht es, vorübergehend das DEBUG-Logging für das Security-Paket zu aktivieren:

logging:
  level:
    # Vorübergehend aktivieren, um die Zusammensetzung und Reihenfolge der Filter in der Kette zu sehen
    org.springframework.security: DEBUG

Danach können Sie beim Start der Anwendung zwei Dinge mit eigenen Augen überprüfen. Erstens, dass Ihr JwtAuthFilter tatsächlich in der Liste vorhanden ist. Zweitens, dass er vor dem AnonymousAuthenticationFilter steht (wenn Sie addFilterBefore genau auf diesen beziehen). Das ist die „Kleinigkeit“, die Ihnen später einen halben Tag Debugging und 40 Minuten philosophischer Gespräche mit dem Monitor erspart.

Wenn der Filter nicht in der Liste ist, liegt das Problem fast immer entweder daran, dass der Filter keine Spring Bean ist (es gibt nichts zu injecten), oder dass Sie ihn nicht in die richtige SecurityFilterChain eingefügt haben (z. B. passt die Filter-Chain nicht auf die gewünschten Anfragen), oder dass Ihre Konfiguration überhaupt nicht Ihre Chain verwendet (z. B. eine andere Konfiguration hochgeladen wurde).

6. Typische Fehler beim JWT-Filter

An dieser Stelle tauchen normalerweise Fehler auf, die wie „das ist doch eine Kleinigkeit“ aussehen, aber tatsächlich den gesamten JWT-Zweig zerstören. Und das Ärgerlichste: Sie zerstören still, ohne eine schöne rote Fehlermeldung. Daher ist es besser, sie im Voraus zu kennen – zumindest um später nicht der Katze erklären zu müssen, warum Sie wieder auf den SecurityFilterChain wütend sind.

Fehler Nr. 1: Filter geschrieben, aber filterChain.doFilter(...) vergessen.
In der Folge „hängt“ die Anfrage oder kehrt seltsam zurück, weil Sie die Verarbeitung abgefangen und nirgendwo hin weitergegeben haben. Im Filter muss man immer bewusst wählen: entweder eine Antwort senden und return aufrufen, oder die Anfrage an die Kette weiterleiten.

Fehler Nr. 2: Filter angeschlossen, aber zu spät platziert.
Wenn man den JWT-Filter nach dem Punkt setzt, an dem bereits eine Entscheidung über den Zugriff getroffen wurde, hat die Wiederherstellung des Benutzers keinen Sinn. Die Autorisierung ist bereits erfolgt (oder verweigert), und Ihr Filter „holt den Zug ein, der abgefahren ist“. Deshalb wählen wir addFilterBefore(...) und binden uns an einen klaren „Anker“ wie den AnonymousAuthenticationFilter.

Fehler Nr. 3: Filter versucht, /api/auth/login zu verarbeiten.
Der Login-Endpoint dient dazu, ein Token auszustellen, nicht ein Token zu verlangen. Wenn der Filter auf Login ein Bearer-Token verlangt, geraten Sie in eine logische Schleife: „Um ein Token zu erhalten, braucht man ein Token“. Das ist, als käme man, um einen Ausweis fürs Büro zu holen, und die Sicherheit sagt: „Zeigen Sie Ihren Ausweis“. Begrenzen Sie den Verantwortungsbereich über shouldNotFilter(...).

Fehler Nr. 4: Verwendung von javax.servlet.* anstelle von jakarta.servlet.*.
In Spring Boot 4 und Spring Framework 7 gibt es an dieser Stelle kein „altes“ javax.* mehr. Wenn Sie versehentlich das falsche Paket importiert haben (oft schlägt die IDE das alte vor), können seltsame Kompilierungsfehler oder Klassenkonflikte auftreten. Für den Filter und die HTTP-Anfrage benötigen wir in unserer Baseline jakarta.servlet.FilterChain und jakarta.servlet.http.HttpServletRequest/HttpServletResponse.

Fehler Nr. 5: Filter ist kein Spring-Bean, aber Sie versuchen, ihn zu inject’en.
Sie schreiben die Konfiguration SecurityFilterChain(HttpSecurity http, JwtAuthFilter filter), und Spring schaut Sie an wie jemanden, der eine Katze beim Namen ruft, den die Katze noch nicht bekommen hat. Wenn der Filter nicht im Kontext registriert ist, kann er nicht an die Kette übergeben werden. Machen Sie ihn zu @Component oder deklarieren Sie ihn als @Bean.

1
Aufgabe
Spring Security, Level 24, Lektion 1
Gesperrt
Marker-JWT-Filter basierend auf OncePerRequestFilter
Marker-JWT-Filter basierend auf OncePerRequestFilter
1
Aufgabe
Spring Security, Level 24, Lektion 1
Gesperrt
Früher `401` bei falschem `Authorization`-Schema
Früher `401` bei falschem `Authorization`-Schema
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION