1. Problem „Ein Login – viele Anfragen“
Nach formLogin entsteht bei Anfängern oft das Gefühl: „Na gut, ich habe mich eingeloggt – warum „erinnert“ sich Spring jetzt an mich und wie genau macht es das?“ Das ist ein gutes Gefühl, weil es die richtige Frage zeigt: HTTP ist von Natur aus nicht verpflichtet, sich an irgendetwas zu erinnern. Und wenn wir möchten, dass ein erfolgreicher Login die Tür zum persönlichen Bereich (/api/me, /api/drafts usw.) nicht nur für eine Anfrage, sondern für eine ganze Reihe von Anfragen öffnet, benötigen wir einen Mechanismus zur Zustandshaltung.
Im HTTP-Protokoll selbst steckt eine einfache Idee: Jede Anfrage ist in sich abgeschlossen, und der Server ist nicht verpflichtet, sich an frühere Vorgänge zu erinnern. Du sendest GET /api/me – der Server verarbeitet sie. Dann sendest du eine zweite GET /api/me – das ist eine neue Anfrage, und wenn es keinen speziellen Mechanismus gäbe, müsste der Server theoretisch überhaupt nicht wissen, dass es „derselbe Benutzer wie vor 2 Sekunden“ ist.
Aber wir wollen den Benutzer nicht zwingen, bei jeder Anfrage sein Passwort einzugeben. Und wir wollen nicht, dass der Browser das Passwort wie eine offene Milchtüte hin- und herträgt. Deshalb kommt das zustandsbehaftete Modell: „Ein Login → wir erhalten eine Sitzungskennung → dann legt der Browser einfach diese Kennung vor“. In Vorlesung 1 haben wir bereits gesehen, dass JSESSIONID nur eine technische Kennung ist. Jetzt müssen wir noch verstehen: welchen genauen Sicherheitszustand verbindet der Server mit dieser Sitzung und wie wird er bei jeder Anfrage wieder in die Arbeit zurückgebracht?
2. SecurityContext zur Laufzeit
In den vorherigen Vorlesungen haben wir bereits SecurityContext erwähnt, aber jetzt muss er zu einer „lebendigen Figur“ werden, nicht nur zu einem Begriff aus dem Glossar. Wichtig ist, das mentale Modell zu erfassen: SecurityContext ist weder Ihre Domain-Entität UserAccount, noch ein Profil oder ein Datenbankeintrag. Es ist ein Container, in dem Spring Security das Wesentliche für die aktuelle Anfrage speichert: Authentication. Und Authentication ist das, was Spring Security als „Ergebnis der Authentifizierung“ betrachtet: Wer der Benutzer ist, welche Rechte er hat und ob er als authentifiziert gilt.
Es ist sehr hilfreich, sich eine einfache Verknüpfung einzuprägen:
| Entität | Was ist das? | Wo lebt sie während der Anfrage? | Was ist das Wichtigste daran? |
|---|---|---|---|
| SecurityContext | Container | in |
enthält |
| Authentication | „Reisepass“ des Benutzers für die Sicherheitsebene | ebenfalls über |
getName(), |
| Principal | „Wer bist du“ im allgemeinsten Sinne | oft verfügbar als |
Name/Kennung des Benutzers |
| GrantedAuthority | Einzelnes Recht/eine Rolle | innerhalb von |
Zeichenketten wie usw. |
Ein weiterer wichtiger (und etwas heikler) Punkt: Es ist am einfachsten, sich SecurityContext als eine „Dokumentenmappe“ vorzustellen, die Spring Security Ihrem Code für die Dauer der Bearbeitung einer bestimmten Anfrage unterlegt. Sobald die Anfrage beendet ist, muss die Mappe vom Tisch geräumt werden. Und Spring Security tut das auch: Am Ende der Anfrage löscht es den Kontext (damit die Daten eines Benutzers nicht versehentlich an der nächsten Anfrage im selben Thread hängen bleiben – das wäre ein „Sicherheit: Das Überlebensspiel“).
Um SecurityContext praktisch zu erfühlen, erstellen wir einen einfachen „Thermometer des aktuellen Benutzers“ – einen Endpunkt, der den Namen des aktuellen Benutzers zurückgibt, wie Spring Security ihn sieht.
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class WhoAmIController {
@GetMapping("/debug/whoami")
String whoAmI(Authentication authentication) {
// Spring MVC fügt automatisch Authentication aus dem SecurityContext der aktuellen Anfrage ein
return authentication.getName(); // "anonymousUser" oder echter Username
}
}
Warum funktioniert das? Weil Spring MVC in der Lage ist, Authentication als Methodenparameter einzufügen, und er wird aus dem SecurityContext genommen. Solange Sie nicht angemeldet sind, sehen Sie höchstwahrscheinlich "anonymousUser". Nach dem Login – den echten Benutzernamen (z.B. "alice"). Und das ist schon der erste „Zauber“ ohne Magie: Der aktuelle Benutzer wird nicht im Controller gespeichert, sondern kommt aus dem Sicherheitskontext.
Und hier erkennt man leicht einen scheinbaren Widerspruch zu Beispielen, bei denen im Methodenparameter Principal ankommt und für anonyme Anfragen null ist. Es gibt keinen Widerspruch. Authentication lesen wir direkt aus dem SecurityContext, und Spring Security kann dort AnonymousAuthenticationToken mit dem Namen anonymousUser ablegen. Principal, das über die Servlet/MVC-API kommt, fehlt bei anonymen Anfragen häufig einfach.
3. Speichern von SecurityContext in HttpSession
Jetzt verbinden wir zwei Bilder: die Sitzung (aus Vorlesung 1) und den SecurityContext (aus dieser Vorlesung). Die Hauptidee ist einfach: Nach erfolgreichem Login muss Spring Security irgendwo die „Dokumentenmappe“ speichern, damit sie bei der nächsten Anfrage wieder abgerufen werden kann. Und im klassischen zustandsbehafteten Modell ist dieses „irgendwo“ – HttpSession.
Technisch gesehen macht Spring Security das nicht „manuell im Controller“, sondern über einen infrastrukturellen Vertrag: SecurityContextRepository. Seine Aufgabe ist das Laden und Speichern von SecurityContext. In der üblichen session-basierten Standardkonfiguration wird eine Implementierung verwendet, die über HttpSession arbeitet (in der Praxis begegnen Ihnen öfter der Name HttpSessionSecurityContextRepository). Sie hat einen sehr pragmatischen Ansatz: Sie legt das Kontextobjekt einfach als Attribut in der Sitzung ab.
Ja, innerhalb von HttpSession sieht das ungefähr wie ein „Schlüssel-Wert“-Paar aus. Und der Schlüssel (historisch) heißt oft SPRING_SECURITY_CONTEXT. Nicht wie ein Passwort, nicht wie eine Rolle, sondern wörtlich „hier liegt der SecurityContext“.
Überprüfen wir das auf eine sichere und „langweilige“ Weise: Wir erstellen einen Debug-Endpunkt, der anzeigt, ob in der Sitzung ein gespeicherter Sicherheitskontext vorhanden ist.
import jakarta.servlet.http.HttpSession;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class SessionSecurityContextController {
@GetMapping("/debug/session/security-context")
String securityContext(HttpSession session) {
// Liest den Inhalt der Server-Sitzung (nicht Cookie!), um zu sehen, ob der Kontext gespeichert wurde
Object ctx = session.getAttribute("SPRING_SECURITY_CONTEXT");
// Vor dem Login ist das normalerweise null, nach dem Login ein Objekt vom Typ SecurityContextImpl
return ctx == null ? "<empty>" : ctx.getClass().getSimpleName();
}
}
Wie beobachtet man das manuell (im Browser):
- Sie öffnen /debug/session/security-context vor dem Login und erhalten <empty> (oder Sie können sehen, dass die Sitzung noch nicht erstellt wurde – das hängt davon ab, wie Ihre Anwendung die Sitzung initiiert).
- Sie melden sich über formLogin an.
- Sie öffnen denselben Endpunkt erneut – und jetzt steht dort normalerweise etwas wie SecurityContextImpl.
Hier ist der wichtige Gedanke: Im Cookie befindet sich nichts davon. Das Cookie JSESSIONID ist nur die „Schließfachnummer“. Der Inhalt des Schließfachs liegt auf dem Server. Und in diesem Schließfach speichert Spring Security den SecurityContext. Es ist wie eine Garderobe: Sie tragen den Mantel nicht den ganzen Abend mit sich herum, sondern tragen die Nummer. (Hauptsache, Sie verlieren die Nummer nicht, sonst müssen Sie dem Wachmann erklären: „Ich war definitiv hier, die Nummer war nur hübsch.“)
4. Wiederherstellung des Kontexts in Filtern
Nachdem wir verstanden haben, dass SecurityContext in der HttpSession liegt, ist die logische nächste Frage: „Okay, er liegt dort. Aber wer holt ihn da raus, und wann genau?“ Antwort: Die Filter von Spring Security. Deshalb haben wir in Modul 1 so sehr betont, dass Spring Security vor den Controllern arbeitet. Der Kontext muss wiederhergestellt werden, bevor Ihr @RestController etwas tut – sonst erfährt er einfach nicht, wer der Benutzer ist.
Im modernen Spring Security (in unserem Baseline ist das Security 7.x) ist für das Setzen des Kontexts in den SecurityContextHolder die Infrastruktur in der Filterkette zuständig. Die Namen der Filter können sich von Version zu Version ändern (der klassische Name, den Sie in Artikeln sehen könnten, ist SecurityContextPersistenceFilter, in neueren Versionen taucht oft SecurityContextHolderFilter auf), aber die Bedeutung bleibt dieselbe: Beim Eingehen einer Anfrage laden wir den Kontext aus dem Repository (normalerweise aus der Sitzung), beim Verlassen löschen wir den Kontext und speichern bei Bedarf den aktualisierten Zustand.
Hier ist ein Schema, das Sie buchstäblich „in den Kopf einkleben“ sollten:
sequenceDiagram
participant B as Browser
participant C as Servlet container
participant F as Spring Security filters
participant M as "Spring MVC (controllers)"
B->>C: Request + Cookie: JSESSIONID=...
C->>F: Anfrage tritt in die Filterkette ein
F->>F: HttpSession anhand von JSESSIONID finden
F->>F: "SecurityContext aus Sitzung laden (Repository)"
F->>F: Authentication in SecurityContextHolder ablegen
F->>M: Anfrage an Controller weitergeben
M-->>F: Response zurückgeben
F->>F: SecurityContextHolder leeren
F-->>B: Response
Beachten Sie mehrere Dinge. Erstens: Der Controller ist hier überhaupt nicht daran beteiligt, den Benutzer „wiederherzustellen“ – er erhält einfach einen bereits fertigen Authentication. Zweitens: SecurityContextHolder ist keine „globale Variable der Anwendung“, sondern ein Kontextspeicher, der an den aktuellen Verarbeitungsthread der Anfrage gebunden ist. Drittens: Die Bereinigung des Kontexts am Ende der Anfrage ist keine Pedanterie des Frameworks, sondern eine direkte Sicherheitsanforderung: Sonst könnte ein Thread den Benutzer in die nächste Anfrage „schleppen“.
Damit solche Beobachtungen konsistent funktionieren, ist es praktisch, einen offenen Debug-Bereich /debug/** zu haben. Dann können Sie vor und nach dem Login auf /debug/whoami, /debug/session/security-context und andere Diagnose-Endpunkte schauen, ohne die Zugriffsregeln für jedes Experiment ändern zu müssen. In einem Übungsprojekt ist das in Ordnung; in der echten Produktion wird ein solcher Bereich entweder geschlossen oder entfernt.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
// Übungsbaseline: Debug-Endpunkte sind offen, um das Verhalten "vor/nach" zu beobachten
.requestMatchers("/api/public/**", "/debug/**").permitAll()
// Alles andere nur für authentifizierte Benutzer
.anyRequest().authenticated()
)
// Standard-Login-Formular aktivieren (zustandsbehaftetes Modell über Session standardmäßig)
.formLogin(Customizer.withDefaults());
return http.build();
}
}
Dieser Baseline reicht für den weiteren Verlauf: /debug/** ist zur Beobachtung offen, und die Anwendungsendpunkte leben im normalen session-basierten Modell mit authenticated().
Jetzt können Sie /debug/whoami vor dem Login öffnen und anonymousUser sehen, sich dann anmelden und den Benutzernamen sehen. Nicht weil „der Controller sich etwas gemerkt hat“, sondern weil die Filter vor dem Controller jedes Mal den Sicherheitskontext aus der Sitzung abrufen.
5. Zugriff auf Authentication und Principal im Code
Wenn SecurityContext zu Beginn der Anfrage wiederhergestellt ist, wird Ihr Code einfacher. In Controllern können Sie ehrlich sagen: „Wenn ein Endpunkt authenticated() erfordert, dann ist hier nur eine Anfrage angekommen, bei der im SecurityContext eine gültige Authentication liegt.“ Und dann arbeiten Sie mit dem aktuellen Benutzer als Gegebenheit, nicht als Parameter jeder Anfrage.
In unserem Projekt Secure Content Platform API wird dies besonders im persönlichen Bereich deutlich. Das offensichtlichste Beispiel ist der Endpunkt /api/me. Er sollte username nicht als Parameter akzeptieren (denn das wäre „bitte tun Sie das nicht“), sondern ihn aus dem Sicherheitskontext holen.
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class MeController {
record MeResponse(String username) {}
@GetMapping("/api/me")
MeResponse me(Authentication authentication) {
// Username holen wir aus der Authentication der aktuellen Anfrage, nicht aus Anfrageparametern
return new MeResponse(authentication.getName());
}
}
Aus Verhaltenssicht sieht das so aus:
- Vor dem Login versuchen Sie, /api/me zu öffnen, aber die Regeln authenticated() lassen Sie nicht hinein (und Sie sehen höchstwahrscheinlich eine Weiterleitung zur Login-Seite – das ist normale browserorientierte Semantik von formLogin).
- Nach dem Login öffnen Sie /api/me und erhalten ein JSON mit Ihrem Username.
Ein weiteres gutes „Röntgenbild“ ist das Anzeigen der Authorities, denn auch sie leben in Authentication. Dienste und Controller benötigen sie oft, um zu verstehen, welche Bereiche dem Benutzer zur Verfügung stehen (insbesondere bei uns – USER / EDITOR / ADMIN).
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class MyAuthoritiesController {
@GetMapping("/debug/authorities")
Object authorities(Authentication authentication) {
// Authorities sind das, was Spring Security später mit hasRole/hasAuthority vergleicht
return authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority) // Wandelt GrantedAuthority in einen String wie ROLE_ADMIN um
.toList();
}
}
Es ist nützlich, dies „vor/nach“ dem Login und für verschiedene Benutzer zu beobachten. Sie werden sehen, dass Sie nach dem Login eine Menge von Zeichenkettenrechten erhalten, und genau diese verwendet Spring Security, wenn Sie hasRole("ADMIN") oder hasAuthority("...") schreiben.
Und noch ein Punkt, der Anfängern sehr wichtig zu vermitteln ist: In diesem Modell reist das Passwort nicht bei jeder Anfrage mit. In einem normalen Szenario speichert Authentication nach erfolgreicher Authentifizierung den Principal (wer der Benutzer ist) und die Authorities (was ihm erlaubt ist), während die Credentials (Passwort) entweder gelöscht werden oder nicht mehr benötigt werden. Das ist einer der Gründe, warum das session-basierte Modell angenehm ist: Sie zwingen den Browser nicht, das Passwort ständig mit sich zu führen.
Wann der Kontext nicht wiederhergestellt wird
Jetzt, wo alles schön „wiederhergestellt“ wird, möchte man leicht glauben, dass dies eine Magie „für immer“ ist. Aber jede Magie hat banale Betriebsbedingungen. Das Sitzungsmodell funktioniert nur unter einer zwingenden Voraussetzung: Die nächste Anfrage muss mit derselben Sitzungskennung kommen (normalerweise über das Cookie JSESSIONID), und der Server muss in der Lage sein, die entsprechende HttpSession zu finden.
Wenn diese Bedingung nicht erfüllt ist, gibt es einfach keine Quelle, um SecurityContext wiederherzustellen. Dann wird die Anfrage als anonym behandelt. In der Praxis zeigt sich das auf alltäglichste Weise. Sie haben die Anwendung in einem normalen Browserfenster geöffnet, sich angemeldet, alles gut. Dann öffnen Sie dieselbe Anwendung im Inkognito-Modus – und plötzlich sind Sie wieder anonym. Das ist kein Bug, das ist ein anderer Cookie-Satz, also eine andere (oder fehlende) Sitzung.
Ein weiteres häufiges Szenario: Der Client sendet kein Cookie. Der Browser macht das automatisch (wenn die Cookie-Richtlinie es erlaubt), aber ein „manueller Client“ kann das nur tun, wenn Sie explizit das Speichern von Cookies aktiviert haben. Wenn kein Cookie vorhanden ist, ist das aus Sicht des Servers ein neuer Client, eine neue „Identität“ – und er hat keine Sitzung, in der SecurityContext liegt.
Und schließlich das Einfachste: Der Server hat die Sitzung „vergessen“ (z.B. weil die Anwendung neu gestartet wurde und die Sitzungen nur im Speicher waren). Wir gehen heute nicht auf verteilte Sitzungsspeicher und Multi-Node-Cluster ein, aber die grundlegende Logik ist wichtig zu verstehen: Wenn die Serversitzung verschwunden ist, dann ist auch die Bindung an SecurityContext verschwunden. Der Client kann noch tausendmal die alte JSESSIONID senden – der Server sagt: „Ich kenne diese Sitzung nicht.“
Die gute Nachricht: Wenn Sie diese Mechanik verstehen, hören viele „seltsame“ Probleme auf, seltsam zu sein. Sie denken nicht „Spring Security ist kaputt“, sondern „Mein SecurityContext wurde wiederhergestellt/nicht wiederhergestellt, weil eine entsprechende Sitzung vorhanden/nicht vorhanden ist“.
6. Typische Fehler bei der Arbeit mit SecurityContext
An dieser Stelle tritt meist eines von zwei Extremen auf: Entweder beginnt der Student zu glauben, dass die Sitzung „eine Art schwarze Magie“ ist, oder er beginnt, die Sitzung als Lager für alles mögliche zu nutzen (und bald befindet sich die Hälfte Ihrer Anwendung darin und sie passt nicht mehr in den Speicher). Lassen Sie uns die häufigsten Fehler, die tatsächlich schon in Übungsprojekten vorkommen, sorgfältig auflisten.
Fehler Nr. 1: Zu denken, dass im Cookie der „Benutzer“ liegt.
Eine sehr verbreitete Verwirrung: Weil der Browser ein Cookie sendet, liegt „im Cookie der Benutzer“. Nein. Im Cookie liegt die Sitzungskennung (JSESSIONID) – sozusagen die Schließfachnummer. Der Benutzer und der SecurityContext leben auf dem Server, in der HttpSession. Wenn Sie diesen Gedanken einmal verinnerlicht haben, ist die Hälfte des Sitzungsmodells für Sie nicht mehr mysteriös.
Fehler Nr. 2: „Es gibt eine Sitzung“ und „Der Benutzer ist authentifiziert“ verwechseln.
Eine Sitzung kann auch ohne Authentifizierung existieren. Zum Beispiel könnte der Server eine Sitzung für technische Zwecke erstellt haben, und SPRING_SECURITY_CONTEXT ist darin leer. Daher ist „Ich habe eine JSESSIONID“ nicht gleich „Ich bin angemeldet“. Die richtige Prüfung ist, ob im Kontext eine authentifizierte Authentication vorhanden ist (und das ist bereits die Aufgabe der Spring-Security-Filter).
Fehler Nr. 3: Versuchen, den „Benutzer“ in einem statischen Feld oder in einem Singleton-Bean zu speichern.
Manchmal macht ein Anfänger so etwas wie static String currentUser; oder legt Authentication in ein Feld eines Dienstes „zur Bequemlichkeit“. Das zerstört das Thread-Modell der Anfragen: Sie haben einen Dienst für die gesamte Anwendung, viele Anfragen, viele Benutzer – und plötzlich sind alle der „letzte, der hereingekommen ist“. Spring Security speichert den SecurityContext bewusst so, dass er an eine bestimmte Anfrage gebunden ist, nicht an die gesamte Anwendung.
Fehler Nr. 4: SecurityContextHolder „irgendwo“ abrufen und erwarten, dass dort immer ein Benutzer ist.
SecurityContextHolder wird während der Bearbeitung einer Anfrage gefüllt. Wenn Sie dort zu einem Zeitpunkt hineinschauen, an dem keine Anfrage läuft (z.B. in einem @PostConstruct, in einer Hintergrundaufgabe oder einfach „bevor die Filter ausgeführt werden“), erhalten Sie leicht einen leeren Kontext oder anonymous. Das ist nicht Spring, der „launisch“ ist, sondern Sie schauen zum falschen Zeitpunkt.
Fehler Nr. 5: Debug-Endpunkte erstellen und vergessen, sie zu schließen.
Heute haben wir bewusst /debug/** als permitAll() hinzugefügt, weil es für das Lernen praktisch ist. Aber ein solcher Bereich ist eine ideale Falle: Nach einer Woche gewöhnen Sie sich daran, dass „alles funktioniert“ und lassen ihn versehentlich im Hauptcode. In einem Übungsrepository ist das in Ordnung, aber in einem echten Projekt wird der Debug-Bereich entweder entfernt, geschützt oder in ein separates Profil ausgelagert.
GO TO FULL VERSION