1. Zwei Welten: Token und aktueller Benutzer
Wenn Sie zum ersten Mal die integrierte JWT-Unterstützung aktivieren, entsteht das Gefühl, dass Spring Security etwas Mystisches tut: Gestern hatten wir nur einen String im Header, und heute ist im Controller plötzlich JwtAuthenticationToken verfügbar, @AuthenticationPrincipal Jwt, hasAuthority(...) beginnt zu funktionieren … und das alles ohne Ihren Filter. Um dies nicht als Magie zu betrachten, ist es hilfreich, zwei Welten zu unterscheiden: die Welt der HTTP-Anfrage und die Welt von Spring Security innerhalb der Anwendung.
In der HTTP-Welt haben wir Header, Methode, Pfad, Body. Dort ist der Token nur ein String nach dem Wort Bearer. In der Welt von Spring Security wird der Token in ein konkretes Objekt vom Typ Authentication umgewandelt, das im SecurityContext genau für die Dauer der Anfrageverarbeitung „lebt“. Der integrierte JWT-Pfad ist eine saubere Pipeline, die genau die Übersetzung von einer Welt in die andere übernimmt: „String aus dem Header“ → „geprüfter Token“ → „authentifizierter Benutzer für die aktuelle Anfrage“.
Um diese Pipeline vollständig zu sehen, behalten Sie das folgende Diagramm im Kopf:
flowchart TD R[HTTP request] --> H["Authorization: Bearer ..."] H --> F[BearerTokenAuthenticationFilter] F --> AM[AuthenticationManager] AM --> P[JwtAuthenticationProvider] P --> D[JwtDecoder] D --> J[Jwt] J --> C[JwtAuthenticationConverter] C --> AT[JwtAuthenticationToken] AT --> SC[SecurityContext] SC --> MVC["Controller / Service / @PreAuthorize"]
Links – „rohe Anfrage“, rechts – „aktueller Benutzer innerhalb der Anwendung“. Unsere gesamte Vorlesung ist die Reise des Tokens entlang dieser Route.
2. Bearer-Token und MVC-Grenzen
Wenn Sie an eine „normale“ Anwendung gewöhnt sind, neigt Ihre Hand manchmal dazu, im Controller request.getHeader("Authorization") zu machen und dann den String mit regulären Ausdrücken zu parsen. In Übungsprojekten sieht das oft aus wie „es funktioniert ja“. Aber in der Welt von Spring Security ist das genau der Fall, in dem „funktioniert“ nicht „richtig“ bedeutet: Sie verlagern die Security-Logik auf die MVC-Ebene, zerstören den einheitlichen Fehlermechanismus 401/403 und verwandeln die Sicherheit in eine Sammlung zufälliger Entscheidungen über Controller.
Die richtige Idee ist: Controller und Services sollten nicht mit dem Header arbeiten, sondern mit dem Ergebnis der Authentifizierung. Der Header sind die Eingabedaten für die Security-Schicht. Und genau deshalb beginnt der integrierte Pfad zu arbeiten, bevor er überhaupt in MVC ankommt, innerhalb der Filter.
Um es ganz praktisch zu machen, hier sieht eine Anfrage „in der Praxis“ aus:
# -i: Antwort-Header anzeigen (praktisch zum Anzeigen von 401/403)
curl -i \
# Bearer-Token im Standard-Header Authorization übergeben
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
# Geschützter Endpunkt (wenn authenticated() erforderlich ist, muss der Token in jeder Anfrage enthalten sein)
http://localhost:8080/api/me
Beachten Sie ein wichtiges Detail: Wenn Sie ein stateless-System eingerichtet haben, wird „einmal anmelden und dann ohne Token gehen“ nicht mehr funktionieren. Die Browser-Magie von Cookies/Sessions hilft hier nicht. Jede Anfrage, die authenticated() erfordert, muss einen Bearer-Token mitbringen – sonst gibt es keinen aktuellen Benutzer.
3. BearerTokenAuthenticationFilter: Token-Extraktion
Der integrierte JWT-Pfad beginnt mit einem Filter mit einem sehr aussagekräftigen Namen: BearerTokenAuthenticationFilter. Er tut genau zwei Dinge, und genau das macht ihn großartig: er extrahiert den Token aus der Anfrage und startet die Authentifizierung, ohne zu versuchen, „alles in einem“ zu sein.
Auf logischer Ebene sieht das ungefähr so aus (vereinfachter Pseudocode, um die Idee zu sehen, nicht über API-Details zu diskutieren):
// 1) Token aus der Anfrage extrahieren (normalerweise aus Authorization: Bearer ...)
String token = bearerTokenResolver.resolve(request);
if (token != null) {
// 2) „Verpacken“ des rohen Tokens in eine Authentication-Anfrage (noch NICHT vertrauenswürdig)
Authentication authRequest = new BearerTokenAuthenticationToken(token);
// 3) Anfrage an AuthenticationManager übergeben: er findet einen geeigneten Provider und prüft den Token
Authentication authResult = authenticationManager.authenticate(authRequest);
// 4) Ergebnis (bereits authentifiziertes Authentication) in den SecurityContext der aktuellen Anfrage legen
SecurityContextHolder.getContext().setAuthentication(authResult);
}
// 5) Kette immer fortsetzen: später wird entschieden, ob authenticated() erforderlich ist oder der Endpunkt öffentlich ist
chain.doFilter(request, response);
Hier ist es wichtig, das „Verhalten ohne Token“ zu verstehen. Wenn die Anfrage keinen Authorization-Header enthält oder dort kein Bearer ... steht, muss der Filter nicht fehlschlagen. Er leitet die Anfrage einfach weiter. Und dann entscheidet authorizeHttpRequests was zu tun ist: Wenn der Endpunkt öffentlich ist – okay; wenn er authenticated() erfordert – erhalten Sie eine 401.
Ein weiteres wichtiges Detail ist, dass der Filter nicht „raten“ muss, woher der Token kommt. Dafür ist BearerTokenResolver zuständig (standardmäßig liest er den Authorization-Header). Und das ist genau die Stelle, an der Spring Security sagt: „Lassen wir nicht jeden denselben Code von Hand schreiben“.
4. AuthenticationManager und JwtAuthenticationProvider
An dieser Stelle ist es nützlich, sich an die alte Mantra des Kurses zu erinnern: Der Filter überprüft die Anmeldeinformationen nicht selbst. Er „stellt nur einen Antrag“ auf Authentifizierung und gibt ihn an AuthenticationManager weiter. Für Benutzername/Passwort hatten wir DaoAuthenticationProvider. Für das JWT-Modell gibt es einen eigenen Provider: JwtAuthenticationProvider.
Aus Sicht des mentalen Modells ist alles sehr ähnlich:
- Der Filter erstellt einen „Eingabe-Authentication“ (noch nicht vertrauenswürdig),
- AuthenticationManager wählt einen Provider aus, der diesen Typ von Authentication versteht,
- Der Provider gibt einen „Ausgabe-Authentication“ zurück (bereits authentifiziert).
In der JWT-Welt trägt das „Eingabe“-Objekt normalerweise den rohen Token-String, und das „Ausgabe“-Objekt wird zu JwtAuthenticationToken.
Es ist nützlich, dies in einer kleinen Tabelle festzuhalten, um Verwirrung zu vermeiden:
| Stadium | Um welches Objekt handelt es sich | Vertrauen wir ihm | Wofür wird es benötigt |
|---|---|---|---|
| Vor der Authentifizierung | BearerTokenAuthenticationToken | Nein | Token zu AuthenticationManager bringen |
| Nach dem Dekodieren | Jwt | Ja (wenn Signatur/Laufzeit gültig) | Claims in praktischer Form speichern |
| Ergebnis | JwtAuthenticationToken | Ja | Der Anwendung aktuellen Benutzer + Authorities bereitstellen |
Wichtig: Es ist JwtAuthenticationToken, das im SecurityContext liegen wird. Und genau das werden @PreAuthorize, hasAuthority(...), Authentication auth im Controller und alle anderen Security-Mechanismen sehen.
5. JwtDecoder: Vertrauen in den Token
Vor JwtDecoder ist der Token nur ein String. Sie können ihn lesen, weitergeben oder sogar die Base64-Teile parsen, aber dem Inhalt kann man nicht trauen. JwtDecoder tut zwei wesentliche Dinge: Er verwandelt den JWT in ein Jwt-Objekt mit Claims und prüft, ob diesem Token überhaupt vertraut werden kann – die Signatur stimmt, die Lebensdauer ist nicht abgelaufen, das Format ist nicht defekt.
Hier ist nur die Verantwortungsgrenze wichtig: Der Decoder ist für das Vertrauen in den Token verantwortlich, nicht für Zugriffsrechte. Wenn Signatur, Laufzeit oder Format die Prüfung nicht bestehen, erreicht die Anfrage den Controller nicht und wird nicht zum aktuellen Benutzer.
Ein alltäglicher Punkt: Wenn der Login-Endpunkt den Token mit einem Schlüssel signiert und JwtDecoder auf einen anderen konfiguriert ist, erhalten Sie eine ewige „ungültiger Token“-Meldung und 401. Das ist nicht „Spring Security ist bockig“, sondern es sagt ehrlich: „Die Signatur stimmt nicht überein – ich glaube nicht“.
6. JwtAuthenticationConverter: Benutzer und Rechte
Nach JwtDecoder haben wir ein Objekt Jwt – dekodiert und verifiziert. Aber die Anwendung benötigt normalerweise nicht nur eine Sammlung von Claims, sondern eine verständliche Sache: ein Authentication, das name und authorities hat. Dafür ist JwtAuthenticationConverter zuständig.
Er nimmt Jwt, wandelt Claims in eine Menge von GrantedAuthority um und baut das endgültige JwtAuthenticationToken zusammen. Hier ist es wichtig, die beiden Entitäten nicht zu vermischen: Jwt ist der Inhalt des Tokens, JwtAuthenticationToken ist die Authentifizierung, mit der dann @PreAuthorize, hasAuthority(...), Authentication auth im Controller und andere Security-Mechanismen arbeiten.
Vorerst reicht es, eine Grenze zu behalten: Der Decoder ist für das Vertrauen in den Token verantwortlich, der Converter dafür, wie aus Claims ein Benutzer mit einem Satz von Rechten wird. Daher bezieht sich die Situation „Token ist gültig, aber trotzdem 403“ meistens bereits auf das Mapping von Claims zu Authorities, nicht auf die Signaturprüfung.
7. SecurityContext in stateless
Ein sehr häufiger „Phantomschmerz“ in der JWT-Welt lautet: „Ich habe mich doch angemeldet! Warum erinnert sich die nächste Anfrage nicht an mich?“ Im Session-Modell war die Antwort: „Weil du eine JSESSIONID hast und der Server den Zustand speichert.“ Im stateless-Modell lautet die Antwort anders: „Weil SecurityContext nicht zwischen Anfragen gespeichert wird.“
Im integrierten JWT-Pfad wird SecurityContext für die Dauer der Verarbeitung einer bestimmten Anfrage erstellt/gefüllt, normalerweise über SecurityContextHolder im Thread-Local gespeichert und nach der Erstellung der Antwort gelöscht. Dies ist nicht nur theoretisch wichtig: Es erklärt direkt, warum der Token in jeder Anfrage übergeben werden muss und warum ein „irgendwo versehentlich gespeicherter SecurityContext“ ein Fehler und eine potenzielle Schwachstelle ist.
Diese Mechanik lässt sich gut so darstellen:
sequenceDiagram participant C as Client participant FS as "Security Filter Chain" participant SC as SecurityContextHolder participant MVC as "Controller/Service" C->>FS: "Request (+ Bearer token)" FS->>SC: setAuthentication(JwtAuthenticationToken) FS->>MVC: call controller/service MVC->>SC: read current user / authorities MVC-->>FS: return response FS->>SC: clearContext() FS-->>C: Response
Der Sinn ist, dass SecurityContext der „Kontext der aktuellen Verarbeitung“ ist, nicht eine „dauerhafte Erinnerung an den Benutzer“. Das Gedächtnis lebt im stateless-Modell im Token auf Client-Seite, nicht in der Serversession.
8. Zugriff auf aktuellen Benutzer im Code
Wenn die Security-Kette abgearbeitet ist, erhalten Controller und Services bereits ein fertiges Weltbild: „hier ist der aktuelle Benutzer, hier sind seine Rechte“. Und hier haben wir einen schönen Bonus des integrierten Pfads: Sie können nicht nur das abstrakte Authentication erhalten, sondern auch konkrete JWT-Objekte ohne manuelles Parsen der Header.
Der „transparenteste“ Weg ist, JwtAuthenticationToken als Methodenparameter zu akzeptieren:
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class MeNameController {
@GetMapping("/api/me/name")
String name(JwtAuthenticationToken auth) {
// auth ist bereits das „endgültige“ Authentication, das in den SecurityContext gelegt wurde
// getName() entspricht oft (aber nicht immer) dem subject (sub)
return auth.getName();
}
}
Wenn Sie die Claims des Tokens benötigen, ist es praktisch, Jwt als principal zu erhalten:
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class MeSubController {
@GetMapping("/api/me/sub")
String sub(@AuthenticationPrincipal Jwt jwt) {
// jwt ist ein dekodiertes und validiertes Token mit allen Claims
return jwt.getSubject(); // z.B. "alice"
}
}
Authorities leben innerhalb von JwtAuthenticationToken, und genau sie werden dann von hasAuthority(...), hasRole(...) und @PreAuthorize überprüft. Wenn eine Anfrage mit einem gültigen Token kommt, aber auf 403 stößt, schaut man zuerst nicht in den Controller oder den Header, sondern darauf, wie Claims innerhalb der Authentifizierungskette in Authorities umgewandelt wurden.
9. Typische Fehler bei der Arbeit mit integriertem JWT
Fehler Nr. 1: Den Authorization-Header im Controller lesen und den Token manuell parsen.
Das erscheint schnell als „einfache Lösung“, aber in Wirklichkeit werfen Sie Spring Security aus dem Prozess: Fehler werden nicht mehr einheitlich, @PreAuthorize sieht den Benutzer nicht, und ein Teil der Endpunkte lebt nach eigenen Regeln, ein anderer nach anderen. Der integrierte Pfad ist gerade deshalb gut, weil er das Parsen/Validieren in die Security-Schicht verlagert.
Fehler Nr. 2: Denken, dass JwtDecoder „Rechte zuweist“ und sich dann über 403 wundern.
JwtDecoder geht um Vertrauen in den Token (Signatur, Lebensdauer) und das Abrufen von Claims. Authorities erscheinen später, in der Phase JwtAuthenticationConverter. Daher bezieht sich die Situation „Token ist gültig, aber Rechte fehlen“ fast immer auf den Converter und das Mapping der Claims, nicht auf den Decoder.
Fehler Nr. 3: Jwt und JwtAuthenticationToken verwechseln.
Jwt sind die „Tokendaten“, und JwtAuthenticationToken ist das endgültige Authentication, mit dem Spring Security arbeitet. Wenn Sie an einer Stelle Jwt erwarten, an einer anderen Authentication verwenden, aber logisch denken, sie seien „dasselbe“, beginnen seltsame Casts und unvorhersehbare ClassCastException.
Fehler Nr. 4: Erwarten, dass nach einer erfolgreichen Anfrage die nächste ohne Token gemacht werden kann.
Im stateless-Modell lebt SecurityContext nur während der Verarbeitung einer Anfrage und wird nach der Antwort gelöscht. Wenn die nächste Anfrage keinen Bearer-Token mitbringt – aus Sicht des Servers sind Sie wieder anonymous. Das ist kein „Bug“ und nicht „Spring Security hat den Benutzer vergessen“, das ist der Sinn von stateless.
Fehler Nr. 5: Den gesamten Bearer-Token „zur Fehlersuche“ loggen.
Der Wunsch ist verständlich: „Ich schaue nur kurz nach“. Aber ein Bearer-Token ist im Grunde der Schlüssel zur Tür: Wer den String stiehlt, kommt hinein. In Logs, insbesondere in Team-/CI-Umgebungen, „entweichen“ solche Strings sehr gerne „versehentlich“. Für die Fehlersuche reicht es normalerweise, sub, jti (falls vorhanden) und die Tatsache des Fehlers zu loggen, nicht den gesamten Token.
GO TO FULL VERSION