CodeGym /Kurse /Spring Security /Erfolgreicher Login, Fehler und Logout

Erfolgreicher Login, Fehler und Logout

Spring Security
Level 8, Lektion 2
Verfügbar

1. formLogin: fehlende Teile

Wenn Sie zum ersten Mal formLogin aktivieren, entsteht ein angenehmes Gefühl: „Oh, es gibt eine Login-Seite, die Sicherheit ist fertig.“ Das ist etwa so, als ob man eine Eingangstür einbaut und sofort die Wohnung zur uneinnehmbaren Festung erklärt. Die Tür ist natürlich wichtig, aber wenn sie kein normales Schloss, keine Klinke und kein Verständnis dafür hat, was im Fall „Schlüssel passt nicht“ zu tun ist, dann wird das Leben schnell zu einer Serie: Jede Folge ist eine neue seltsame Situation vor der Tür.

Die Login-Seite und POST /login sind nur der Eingangsteil. Nicht weniger wichtig ist zu sehen, wie das System Erfolg, Fehler und den offiziellen Ausgang unterscheidet.

In einer realen Anwendung ist der Login-Ablauf immer eine Menge von Ergebnissen, nicht nur ein „glückliches“ Szenario. Der Benutzer kann ein korrektes Passwort eingeben, einen Fehler machen, auf „Zurück“ klicken, versuchen, in einen Bereich zu gelangen, der ihm nicht zusteht. Und separat: Er muss in der Lage sein, sich abzumelden, sodass der Server ihn nicht mehr als authentifiziert betrachtet. Daher fixieren wir heute drei grundlegende Ereignisse: Login Success, Login Failure und Logout.

2. Zustandsmodell von Login/Logout

Wenn man formLogin als „Seite“ betrachtet, übersieht man leicht das Wesentliche: Spring Security arbeitet nicht mit einem HTML-Formular, sondern mit dem Security-Zustand der Anfrage. Vor dem Login läuft die Anfrage als anonymous. Nach erfolgreichem Login laufen Anfragen als authenticated (mit einem bestimmten Benutzer). Ein Login-Fehler ist keine „Ausnahme im Controller“, sondern einfach ein Pfad, in dem sich der Zustand nicht ändert: Der Benutzer bleibt anonymous. Logout ist der umgekehrte Übergang, bei dem wir in den anonymous-Zustand zurückkehren.

Es ist hilfreich, sich dies als kleinen Zustandsautomaten vorzustellen (keine Angst, es ist keine „Automatentheorie“, sondern nur ein Bild „wohin gehen wir“):

stateDiagram-v2
    [*] --> Anonymous
    Anonymous --> Authenticated: login success
    Anonymous --> Anonymous: login failure
    Authenticated --> Anonymous: logout

Fügen wir nun eine „menschliche“ Tabelle hinzu, um nicht zu verwechseln, was wir konfigurieren und was wir beobachten:

Ereignis Was der Benutzer eingegeben hat Was ist mit dem Status passiert Was der Browser normalerweise sieht Womit steuern wir in der Konfig
Erfolgreicher Login korrekte username/password anonymous → authenticated Weiterleitung zur Anwendung defaultSuccessUrl(...)
Login-Fehler falsche Credentials bleibt anonymous Weiterleitung zur Login-Seite mit Fehleranzeige failureUrl(...)
Logout hat Logout gedrückt authenticated → anonymous Weiterleitung zur Seite „Sie haben sich abgemeldet“ logoutUrl(...), logoutSuccessUrl(...)

Der entscheidende Gedanke: all diese Übergänge finden in der Security-Schicht statt, nicht in Ihren Controllern. Das heißt, Sie schreiben kein @PostMapping("/login") und führen keinen if(password.equals(...)) aus. Für die Überprüfung ist das bereits bekannte Gespann AuthenticationManager → AuthenticationProvider → UserDetailsService + PasswordEncoder zuständig. Und hier steuern wir, wie dem Benutzer das Ergebnis erklärt wird (wohin er bei Erfolg oder Misserfolg geschickt wird) und wie der „offizielle“ Logout durchgeführt wird.

3. Erfolgreicher Login und defaultSuccessUrl(...)

Wenn der Login erfolgreich ist, hat der Anfänger oft die Erwartung: „Na gut, ich bin drin, jetzt bin ich in der Anwendung.“ Aber der Browser stellt eine wichtige UX-Frage: an welcher Stelle der Anwendung soll der Benutzer nach dem Login landen? Die einfachste Variante ist, immer zu einem Punkt zu führen, z.B. zum „persönlichen Bereich“. Genau dafür gibt es defaultSuccessUrl.

In Spring Security wird dies in formLogin(...) konfiguriert. Beispiel – nach erfolgreichem Login den Bereich des aktuellen Benutzers /api/me anzeigen:

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    // Konfigurieren des Spring Security Filter Chain
    http.formLogin(form -> form
        // Wohin den Benutzer nach erfolgreichem Login umleiten
        .defaultSuccessUrl("/api/me")
    );

    return http.build();
}

Hier ist eine Feinheit wichtig, die bei vielen zu „seltsamem Verhalten“ führt: Spring Security versucht oft, schlauer zu sein als wir. Wenn der Benutzer zuerst versucht hat, eine geschützte URL aufzurufen (z.B. direkt /api/me geöffnet hat), dann merkt sich die Security-Schicht normalerweise, „wohin er eigentlich wollte“, sendet ihn zum Login und leitet ihn nach erfolgreichem Login dorthin zurück, wo er hinwollte. Das ist logisch: Sie möchten nicht nach dem Login an einem zufälligen Ort landen, sondern an dem, den Sie aufzurufen versucht haben.

Und genau hier kommt die zweite Variante von defaultSuccessUrl mit dem Flag alwaysUse ins Spiel:

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http.formLogin(form -> form
        // alwaysUse = true: nach dem Login IMMER zu einem Punkt führen,
        // auch wenn der Benutzer zuvor eine andere URL aufrufen wollte
        .defaultSuccessUrl("/api/me", true)
    );

    return http.build();
}

Das Flag true bedeutet: „nach erfolgreichem Login immer zu /api/me führen, selbst wenn der Benutzer etwas anderes aufzurufen versucht hat.“

Warum ist das in einem Schulungsprojekt nützlich? Weil es einfacher ist, das Ergebnis zu beobachten: eingeloggt – und stabil an derselben Stelle gelandet, wo man überprüfen kann, dass der Login tatsächlich stattgefunden hat. Das reduziert „Rauschen“ und die Anzahl der Rätsel.

Aber es gibt auch eine Kehrseite, die man jetzt schon sehen sollte. Stellen Sie sich vor, der Benutzer versucht, /api/admin/users zu öffnen, wird zum Login geschickt, loggt sich als normaler USER ein, und Sie haben alwaysUse = true nicht erzwungen. Dann gelangt er nach dem Login zurück zu /api/admin/users und … erhält eine Zugriffsverweigerung. Das ist ein großartiger Demonstrationsmoment: Erfolgreiche Authentifizierung bedeutet nicht, dass der Zugriff erlaubt ist. Der Login beantwortet „Wer bist du?“, und die Zugriffsregel beantwortet „Was darfst du?“

4. Login-Fehler und failureUrl

Ein fehlgeschlagener Login ist kein „unangenehmer Edge Case“, sondern eine völlig normale Situation. Menschen machen Fehler. Menschen vergessen Passwörter. Menschen tippen mit aktiviertem Caps Lock (und schwören dann, dass sie „es richtig eingegeben haben“). Und im Security-Modell ist es wichtig, dass die Anwendung vorhersagbar reagiert: nicht mit 500 abstürzt, keinen seltsamen HTML-Müll anzeigt, keine überflüssigen Details preisgibt und vor allem – nicht „versehentlich hereinlässt“.

Standardmäßig sendet Spring Security den Benutzer bei fehlgeschlagenem Login zurück zur Login-Seite mit dem Parameter ?error. Wir können dieses Verhalten explizit und verständlich über failureUrl(...) gestalten:

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http.formLogin(form -> form
        // Wohin den Benutzer umleiten, wenn die Authentifizierung fehlschlägt
        .failureUrl("/login?error")
    );

    return http.build();
}

Wenn Sie die integrierte Login-Seite von Spring Security verwenden, ist der Parameter error für sie ein verständliches Signal: „Zeige dem Benutzer eine Meldung, dass der Login fehlgeschlagen ist.“ Ihr Ablauf wird also beobachtbar: falsches Passwort eingegeben – zurück zu /login?error, und Sie sehen, dass es sich genau um einen „Login-Fehler“ handelt, nicht um „der Controller ist kaputt“.

Warum lege ich so viel Wert auf Beobachtbarkeit? Weil Anfänger sonst beginnen, Probleme mit der Methode „Schamanentrommel + StackOverflow“ zu diagnostizieren. Wir brauchen aber einen technischen Ansatz: sehen, wo wir sind (welche URL), sehen, was passiert ist (Erfolg/Fehler), verstehen, welche Regel das steuert.

Es gibt noch einen weiteren Punkt, den man im Hinterkopf behalten sollte: Ein Login-Fehler sollte keine überflüssigen Details preisgeben. Selbst in einem Schulungsprojekt ist es besser, sich an eine neutrale Formulierung wie „Ungültiger Benutzername oder Passwort“ zu gewöhnen, anstatt „Benutzer nicht gefunden“ oder „Passwort falsch“. Das geht nicht nur um die Textschönheit – es geht darum, einem Angreifer keine bequeme Möglichkeit zu geben, Konten zu erraten.

Und zum Schluss: failureUrl betrifft „wohin schicken“. Es ändert nicht die Mechanik der Passwortprüfung selbst. Egal wie schön Ihre failureUrl ist: Wenn das Passwort über PasswordEncoder und UserDetailsService geprüft wird, bleibt der fehlerhafte Login fehlerhaft. Wir „verbessern“ die Sicherheit nicht durch einen Link, wir machen das Verhalten der Anwendung nur für den Menschen verständlich.

5. Logout: logoutUrl und logoutSuccessUrl

Logout wird oft als Kosmetik betrachtet: „naja, damit es einen Abmelde-Button gibt“. Dabei ist Logout der offizielle Punkt, an dem das System sagt: „Ich betrachte diesen Benutzer nicht mehr als eingeloggt.“ Das ist genauso ein wichtiges Element des Modells wie der Login. Wenn der Login das „Anlegen eines Ausweises“ ist, dann ist Logout das „Abnehmen und Wegwerfen des Ausweises“. Ohne den zweiten Punkt wird die Sicherheitskraft weiterhin glauben, dass Sie dazugehören.

In Spring Security wird der Logout in einem separaten Block logout(...) konfiguriert. Die minimale Konfiguration, die es explizit zu machen gilt, ist: wohin soll der Benutzer nach dem Logout geführt werden:

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http.logout(logout -> logout
        // Wohin den Benutzer nach dem Logout umleiten
        .logoutSuccessUrl("/login?logout")
    );

    return http.build();
}

Hier ist es wichtig, Logout nicht als „einen weiteren Link im Menü“ zu betrachten. Es ist eine zustandsändernde Aktion: Der Server ändert den Security-Zustand des Benutzers, nicht nur die Auslieferung einer neuen Seite. Im Browser/Session-Zweig ist dies der Moment, in dem die Anwendung den aktuellen Browser nicht mehr als den eingeloggten Benutzer betrachtet.

Warum ist /login?logout praktisch? Weil es dasselbe Prinzip der „Beobachtbarkeit“ wie bei ?error ist. Wenn Sie die integrierte Login-Seite verwenden, versteht sie den Parameter logout und zeigt eine Meldung an, dass Sie abgemeldet sind. Der Benutzer bleibt nicht in einem seltsamen „Was ist passiert?“, sondern sieht ein verständliches Ende der Aktion.

Wenn Sie die Logout-URL aussagekräftiger gestalten möchten, können Sie logoutUrl(...) steuern:

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http.logout(logout -> logout
        // URL, an die die Logout-Anfrage gesendet wird
        .logoutUrl("/logout")
    );

    return http.build();
}

Technisch gesehen ist das bereits der Standard, aber manchmal ist es hilfreich, es explizit anzugeben, damit die Konfiguration eine vollständige „Karte“ von Login und Logout zeigt.

Und eine wichtige praktische Verknüpfung (ohne in die Interna abzutauchen): Nach dem Logout werden Sie wieder anonymous. Das heißt, wenn Sie die Seite /api/me aktualisieren, verhält sich die Anwendung wieder so, als wären Sie nicht eingeloggt. An dieser Stelle sind Studenten oft überrascht: „Ich war doch gerade eingeloggt, warum werde ich jetzt wieder aufgefordert, mich anzumelden?“ – und genau diese Überraschung bedeutet, dass der Logout funktioniert hat.

Damit das Bild vollständig ist, hier ein Beispiel einer „Mini-Verklebung“ (im Rahmen unserer Bereiche) – Zugriff + formLogin + Logout. Ja, die Konfiguration ist etwas länger, aber sie liest sich wie eine Geschichte: Öffentliches ist offen, Privates erfordert Login, Login/Logout existieren explizit.

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth
            // Öffentlicher Bereich ist für alle zugänglich (auch für anonymous)
            .requestMatchers("/api/public/**").permitAll()
            // Persönlicher Bereich ist nur für authentifizierte Benutzer zugänglich
            .requestMatchers("/api/me/**").authenticated()
            // Alles andere wird hart abgelehnt, damit es kein "versehentlich geöffnetes" gibt
            .anyRequest().denyAll()
        )
        .formLogin(form -> form
            // Nach erfolgreichem Login immer zum persönlichen Bereich führen
            .defaultSuccessUrl("/api/me", true)
            // Nach erfolglosem Login zur Login-Seite mit Fehleranzeige zurückleiten
            .failureUrl("/login?error")
            // Wichtig: Die Login-Seite muss für anonymous zugänglich sein, sonst gibt es eine Redirect-Schleife
            .permitAll()
        )
        .logout(logout -> logout
            // Nach dem Logout zur Login-Seite mit dem Hinweis "abgemeldet" weiterleiten
            .logoutSuccessUrl("/login?logout")
            // Wichtig: Der Logout-Endpunkt muss auch zugänglich sein, sonst kann man sich nicht abmelden
            .permitAll()
        )
        .build();
}

Beachten Sie eine Sache: Wir „öffnen nicht alles“. Wir öffnen nur das, was für Login/Logout notwendig ist (permitAll() bei formLogin und logout), und lassen den Rest unter unseren Regeln. Das ist eine sehr gesunde Angewohnheit: den Login erreichbar machen, aber die Zugriffsgrenzen nicht verwässern.

6. Typische Fehler bei Login/Logout

Fehler Nr. 1: „Eingeloggt“ und „Zugriff erhalten“ verwechseln.
Manchmal freut sich ein Student: „Login erfolgreich!“ und wundert sich dann, dass /api/admin/** immer noch nicht geöffnet wird. Das ist kein Bug, sondern das richtige Modell. Ein erfolgreicher Login bedeutet, dass das System Ihre Identität anerkannt hat. Der Zugriff auf den Redakteurs- oder Adminbereich wird durch Regeln wie hasRole("EDITOR") und hasRole("ADMIN") bestimmt. Wenn Sie die Trennung von Authentifizierung und Autorisierung im Kopf behalten, hören solche Situationen auf, wie Mystik zu wirken.

Fehler Nr. 2: defaultSuccessUrl(...) auf einen Endpunkt setzen, der nicht für alle Rollen geeignet ist.
Eine häufige Falle: defaultSuccessUrl("/api/admin/users", true) angeben und sich dann als normaler Benutzer anmelden. Der Login ist erfolgreich, aber sofort danach landen Sie in einem Bereich, auf den Sie keinen Zugriff haben, und erhalten eine Ablehnung. In einem Schulungsprojekt ist es besser, standardmäßig zu einem neutralen „persönlichen“ Punkt wie /api/me zu führen, der für jeden authentifizierten Benutzer zugänglich ist.

Fehler Nr. 3: defaultSuccessUrl(..., true) aktivieren und nicht verstehen, warum es „nicht dorthin zurückleitet, wo ich hinwollte“.
Das Flag alwaysUse = true deaktiviert das „intelligente“ Verhalten der Rückkehr zur ursprünglich angeforderten URL. Im Lernkontext ist das manchmal praktisch, aber wenn Sie erwarten, dass Sie nach dem Login zu der Seite zurückkehren, die Sie öffnen wollten, haben Sie genau dieses Verhalten selbst ausgeschaltet. Es ist wichtig, die Bedeutung des Flags zu verstehen und es nicht als „magisches Häkchen“ zu betrachten.

Fehler Nr. 4: permitAll() neben Login oder Logout vergessen und eine Redirect-Schleife erhalten.
Wenn die Login-Seite oder der Logout-Endpunkt plötzlich eine Authentifizierung erfordert, entsteht eine traurige Komödie: Um sich anzumelden, muss man sich anmelden. Der Browser beginnt, im Kreis zwischen dem geschützten Bereich und dem Login zu laufen. Wenn Sie eine Redirect-Schleife sehen, sollten Sie fast immer zuerst überprüfen, dass die Login/Logout-Punkte tatsächlich für anonymous erreichbar sind.

Fehler Nr. 5: Versuchen, den „Login-Fehler“ im Controller zu behandeln.
Manchmal möchte man einen POST /login-Controller schreiben und ein hübsches JSON oder eine Seite zurückgeben. Aber im Zweig formLogin ist der Login Teil der Filterkette. Ein Login-Fehler ist keine Business-Ausnahme aus einem Service, sondern ein Ergebnis der Entscheidung des AuthenticationManager. Wenn Sie versuchen, dies auf Controller-Ebene „abzufangen“, landen Sie entweder gar nicht dort oder erhalten seltsame Effekte und beginnen, gegen Spring Security zu kämpfen, anstatt es zu nutzen.

1
Aufgabe
Spring Security, Level 8, Lektion 2
Gesperrt
Skript des fehlgeschlagenen Logins
Skript des fehlgeschlagenen Logins
1
Aufgabe
Spring Security, Level 8, Lektion 2
Gesperrt
Erfolgreicher Login, custom logout URL und erneuter Zugriff
Erfolgreicher Login, custom logout URL und erneuter Zugriff
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION