1. Einführung
Spring Security arbeitet nicht direkt mit deinem Domain-Benutzer. Wenn du versuchst, ihm „einfach den UserAccount aus der Datenbank zu geben", endet das schnell entweder mit einem „fetten" Objekt, das überall mitgeschleppt wird, oder mit Security-Prüfungen direkt in den Controllern. Beides macht den Code schwer lesbar, schwer testbar und schwer sicher weiterzuentwickeln.
Spring Security löst das kompromisslos und pragmatisch: Es braucht nicht deinen gesamten Domain-Benutzer. Es braucht eine minimale Projektion des Benutzers, die ausreicht, um (a) die Credentials zu prüfen und (b) danach Zugriffsentscheidungen zu treffen. Diese Projektion wird durch das Interface UserDetails beschrieben. Und der Weg, sie anhand eines „Logins" (der Zeichenkette, die du als Login-Bezeichner gewählt hast) zu laden, wird durch das Interface UserDetailsService festgelegt.
Als Analogie: Das Domain-Modell des Benutzers ist „der komplette Personalbogen", während UserDetails der „Werksausweis" ist – wer du bist, ob der Ausweis gültig ist, ob er gesperrt ist und welche Zonen dir zugänglich sind.
Die Idee dahinter ist sehr praktisch: Security-Komponenten müssen nicht wissen, welche Lieblingsfarbe ein Benutzer hat, wo er wohnt oder wie sein Avatar aussieht. Wichtig ist zu verstehen, wer sich eingeloggt hat, ob diesem Konto vertraut werden kann und was ihm erlaubt ist. Alles Weitere ist Domäne und Geschäftslogik – nicht Authentifizierung.
2. UserDetails und UserDetailsService im Auth Flow
Damit das Thema nicht zu einer Sammlung loser Interfaces wird, verankern wir es genau dort, wo es gebraucht wird. DaoAuthenticationProvider speichert keine Benutzer intern und weiß nicht, woher sie kommen. Er braucht einen klaren Vertrag: Anhand von username die Security-Darstellung des Benutzers laden – und danach das Passwort und den Kontostatus prüfen.
Genau dafür ruft der Provider UserDetailsService.loadUserByUsername(username) auf und erhält UserDetails zurück. Danach liest er Passwort, Authorities und die Kontostatus-Flags und trifft dann die Credentials-Entscheidung. Hier interessiert uns genau dieser lokale Knoten – ohne die vollständige Route vom HTTP-Request bis zum SecurityContext.
flowchart TD
A[Authentifizierungsversuch] --> D[DaoAuthenticationProvider]
D --> F["UserDetailsService loadUserByUsername(username)"]
F --> G[UserDetails]
G --> D
D --> H[Authentifizierungsergebnis]
UserDetails und UserDetailsService schließen genau eine Lücke in dieser Kette: Sie geben dem Provider eine zuverlässige Quelle für Security-Daten des Benutzers. Wenn dieser Teil verstanden ist, lässt sich die gesamte Authentifizierungskette ohne blinde Flecken zusammensetzen.
Ganz kurz gesagt: UserDetailsService ist die „Lieferung des Security-Benutzers nach Name" und UserDetails ist „was genau geliefert wurde". Erst danach entscheidet Spring Security, ob der Benutzer durchgelassen wird.
3. Das Interface UserDetails
Wenn du UserDetails zum ersten Mal öffnest, wirkt es wie „zu viel auf einmal". Aber eigentlich ist es ein recht kompaktes Set an Fakten, die in fast jedem System für die Sicherheit benötigt werden. Wichtig ist, UserDetails nicht als „den Benutzer überhaupt" zu verstehen, sondern als Security-Reisepass, den Spring Security während der Authentifizierung liest und dann als Informationsquelle über den aktuellen Benutzer verwendet.
Minimal wichtig sind vier Datengruppen: Benutzername, Passwort (als Zeichenkette, üblicherweise nicht im Klartext, aber dazu später mehr), ein Set an Berechtigungen (authorities) und Kontostatus-Flags (enabled/locked/expired). Im Interface werden diese durch Methoden ausgedrückt, die häufig zusammen auftauchen.
Eine Tabelle hilft, damit die Methoden nicht zu „weißem Rauschen" verschwimmen:
| Methode von UserDetails | Was das auf Deutsch bedeutet | Wozu Spring Security es braucht |
|---|---|---|
| getUsername() | „Wie lautet der Login dieses Benutzers?" | Um den Login-Versuch mit dem gefundenen Konto zu verknüpfen |
| getPassword() | „Welcher Passwort-Wert ist im System gespeichert?" (üblicherweise nicht im Klartext) | Damit der Provider das eingegebene Passwort mit dem gespeicherten vergleichen kann |
| getAuthorities() | „Welche Berechtigungen/Rollen hat der Benutzer?" | Damit die Autorisierung später hasRole / hasAuthority auswerten kann |
| isEnabled() | „Ist das Konto aktiviert?" | Damit ein deaktivierter Benutzer sich nicht einloggen kann |
| isAccountNonLocked() | „Ist das Konto nicht gesperrt?" | Damit ein gesperrter Benutzer sich nicht einloggen kann |
| isAccountNonExpired() | „Ist das Konto nicht abgelaufen?" | In Lernprojekten selten, aber der Mechanismus ist allgemeingültig |
| isCredentialsNonExpired() | „Sind die Credentials nicht abgelaufen?" | Ebenfalls selten im Lernkontext, aber der Sinn ist wichtig zu verstehen |
Schauen wir uns jetzt ein lebendiges Beispiel an. Spring Security hat eine fertige Implementierung von UserDetails – die Klasse org.springframework.security.core.userdetails.User (ja, der Name ist maximale „Gemeinheit", weil in deinem Projekt wahrscheinlich auch eine Klasse namens User existieren wird. Willkommen in der Welt der „Namen, die mit Konflikten enden"!).
Der einfachste Weg, UserDetails per Builder zu erstellen:
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
// Wir erstellen eine "Security-Projektion" des Benutzers, keine Domain-Entität
UserDetails alice = User.withUsername("alice")
// {noop} — Lernstub: Passwort wird "wie es ist" gespeichert (im Prod so nicht zulässig)
.password("{noop}password")
// Authorities — das, was später in hasRole/hasAuthority verwendet wird
.authorities("profile:read")
.build();
Hier ist {noop} ein Passwortformat-Marker für Demo-Zwecke. Betrachte diese Zeichenkette als technischen Stub: Er wird nur verwendet, um die Form von UserDetails zu zeigen, nicht als empfohlene Methode für den Umgang mit Passwörtern.
Lass uns das Objekt jetzt „anfassen" und sicherstellen, dass es wirklich ein Container mit den benötigten Daten ist:
import org.springframework.security.core.GrantedAuthority;
// Username — der String-Bezeichner des Logins, mit dem der Auth Flow arbeitet
System.out.println(alice.getUsername()); // alice
// Authorities liegen intern als GrantedAuthority-Objekte vor, nicht als "nackte Strings"
var auth = alice.getAuthorities().stream()
.map(GrantedAuthority::getAuthority) // den String-Wert der Berechtigung extrahieren
.toList();
System.out.println(auth); // [profile:read]
Beachte: getAuthorities() gibt keine Strings zurück, sondern GrantedAuthority-Objekte. Der Berechtigungs-String ist nur ein Feld innerhalb von GrantedAuthority. Deshalb wirst du im Code sehr häufig new SimpleGrantedAuthority(...) und ähnliches sehen.
UserDetails als eigene Klasse
In einem echten Projekt ist es oft praktischer, eine eigene Implementierung von UserDetails zu haben, um dort z.B. sauber eine userId hinzuzufügen (weil sich der Username ändern kann, eine ID aber stabiler ist). Wichtig ist dabei, die Grenze zu beachten: Es ist nach wie vor eine Security-Projektion, kein „Alles über den Benutzer".
So könnte unsere minimale UserDetails-Klasse im Projekt Secure Content Platform API aussehen (noch ohne Datenbankanbindung und ohne „Business-Felder"):
package com.example.securecontent.security.userdetails;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
public class SecureContentUserDetails implements UserDetails {
// Username — der Login-Bezeichner (kann E-Mail/Login/was auch immer du als "username" wählst sein)
private final String username;
// Wir speichern nicht das Roh-Passwort, sondern seine Darstellung im Speicher (üblicherweise Hash/encoded)
private final String passwordHash;
// Berechtigungen/Rollen, die bei der Autorisierung verwendet werden
private final Collection<? extends GrantedAuthority> authorities;
// ... hier folgen die Implementierungen der UserDetails-Methoden (getUsername/getPassword/getAuthorities und Flags)
}
Und ein Ausschnitt mit den Methoden, die für „wer ist das" und „welche Rechte hat er" zuständig sind:
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
@Override
public String getUsername() {
// Das, womit sich der Benutzer in der Security-Welt "vorstellt" (Principal Name)
return username;
}
@Override
public String getPassword() {
// Das, womit DaoAuthenticationProvider das eingegebene Passwort vergleicht (über PasswordEncoder)
return passwordHash;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// Das, was danach in die hasRole/hasAuthority-Prüfungen einfließt
return authorities;
}
Der Sinn hier ist simpel: Spring Security bittet dich nicht um eine „Benutzerbiografie". Es bittet um einen Ausweis und eine Zugangsliste. Details wie displayName, bio, avatarPath – das ist das Anwendungsprofil und gehört in UserProfile, nicht in den Security-Vertrag.
4. Kontostatus-Flags
Kontostatus-Flags klingen nach etwas „aus dem großen Enterprise", aber in der Praxis ist das ein sehr vernünftiger Mechanismus: Er erlaubt dem System zu sagen „Login und Passwort sind zwar korrekt, aber Einloggen ist nicht erlaubt" – und das einheitlich auf Ebene der Security-Infrastruktur, nicht per if in jedem Controller.
Die am häufigsten verwendeten Flags sind enabled und accountNonLocked. Im Lernprojekt beschränken wir uns fast immer auf diese beiden, weil sie anschaulich sind: Der Admin hat den Benutzer deaktiviert – der kann sich nicht einloggen; der Admin hat gesperrt – ebenfalls nicht. Der expired-Mechanismus (für Konto oder Credentials) kommt seltener vor, aber es ist wichtig zu verstehen, dass Spring Security dafür bereits einen Platz im Vertrag reserviert. Das erlaubt Providern und User-Managern einheitlich zu agieren, auch wenn deine Anwendung das heute noch nicht nutzt.
Beispiel, wie das im Code von UserDetails aussieht:
@Override
public boolean isEnabled() {
// true: Konto ist aktiv (false bedeutet üblicherweise "Benutzer vom Admin deaktiviert")
return true;
}
@Override
public boolean isAccountNonLocked() {
// true: Benutzer ist NICHT gesperrt (false — gesperrt, Authentifizierung wird verweigert)
return true;
}
@Override
public boolean isAccountNonExpired() {
// true: Konto ist "nicht abgelaufen" (selten in Lernprojekten verwendet)
return true;
}
@Override
public boolean isCredentialsNonExpired() {
// true: Passwort/Credentials sind "nicht abgelaufen" (z.B. kein erzwungener Passwortwechsel nötig)
return true;
}
Warum fast überall true? Weil das ein „normaler" Benutzer ist. Wenn du aber einen gesperrten Benutzer simulieren möchtest, änderst du mindestens ein Flag.
Noch ein wichtiger Denkaspekt: Diese Flags sind keine Autorisierung. Sie beantworten nicht die Frage „Darf man Artikel veröffentlichen?" oder „Darf man in den Admin-Bereich?". Sie beantworten eine andere Frage: „Kann dieses Konto überhaupt die Authentifizierung durchlaufen?"
5. UserDetailsService und UserDetailsManager
UserDetails allein löst das Problem „Woher kommen die Benutzer?" nicht. Deshalb führt Spring Security die nächste Ebene ein: UserDetailsService. Das ist ein sehr kleines Interface, das aber buchstäblich das gesamte Username/Password-Modell zementiert. Die Verantwortung ist wichtig zu behalten: Laden eines Benutzers anhand seines Namens (username) – und nur das.
In seiner „reinen Form" sieht das Interface so aus:
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
public interface UserDetailsService {
// Vertrag: per username UserDetails zurückgeben (wenn nicht gefunden — das ist ein separates Fehlerszenario)
UserDetails loadUserByUsername(String username);
}
Im echten Spring Security wirft diese Methode eine UsernameNotFoundException, wenn der Benutzer nicht gefunden wurde. Und das ist richtig: Ein nicht vorhandener Benutzer ist kein „null und irgendwie weiter", sondern ein normales Authentifizierungs-Fehlerszenario.
Hier ein Beispiel der einfachsten Implementierung auf Basis einer Map – das ist kein „Prod", sondern ein Lern-Mini-Modell, um zu sehen, wo die Logik lebt:
import java.util.Map;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
class MapUserDetailsService implements UserDetailsService {
// Lern-In-Memory-„Speicher" der Benutzer nach Username
private final Map<String, UserDetails> users;
MapUserDetailsService(Map<String, UserDetails> users) {
// Im echten Projekt kommt hier üblicherweise ein Repository/DAO statt einer Map
this.users = users;
}
@Override
public UserDetails loadUserByUsername(String username) {
// Hier wird KEIN Passwort geprüft: Wir laden nur UserDetails
UserDetails user = users.get(username);
if (user == null) {
// "Nicht gefunden" — das ist ein separates erwartetes Ergebnis im Auth Flow
throw new UsernameNotFoundException(username);
}
return user;
}
}
Und eine kleine Prüfung „im Vakuum", damit das Gehirn die Punkte verbindet (ohne Spring, ohne Filter – einfach nur den Vertrag sehen):
// Wir legen einen Benutzer in den In-Memory-Store
var service = new MapUserDetailsService(Map.of("alice", alice));
// Und laden ihn per Username (so wie es DaoAuthenticationProvider tun würde)
UserDetails loaded = service.loadUserByUsername("alice");
System.out.println(loaded.getUsername()); // alice
Was UserDetailsService nicht tun sollte
Ein sehr wichtiger Punkt, an dem viele scheitern: UserDetailsService prüft kein Passwort. Er vergleicht nicht „Passwort aus der Anfrage" mit „Passwort aus der Datenbank". Er gibt nur UserDetails zurück. Die Passwortprüfung ist Verantwortung von DaoAuthenticationProvider und PasswordEncoder (dazu kommen wir an einem eigenen Tag). Wenn du anfängst, das Passwort innerhalb von UserDetailsService zu prüfen, vermischst du Verantwortungsebenen – und jagst dann seltsame Bugs wie „Warum wurde das Passwort bei dieser Authentifizierung zweimal geprüft, bei der anderen gar nicht?"
UserDetailsManager: Benutzerverwaltung
Für den eigentlichen Auth Flow ist es jetzt entscheidend, nur loadUserByUsername(...) zu verstehen. UserDetailsManager ist bereits eine nützliche Erweiterung für das Speichern und Verwalten von Benutzern – keine weitere obligatorische Stufe bei jedem Request.
Manchmal reicht das Laden eines Benutzers nicht aus: Man muss Benutzer anlegen, aktualisieren, löschen, Passwörter ändern. Dafür gibt es in Spring Security das Interface UserDetailsManager, das UserDetailsService erweitert und Verwaltungsoperationen hinzufügt. Das ist keine neue Welt, sondern einfach eine Erweiterung desselben Vertrags in Richtung Administration.
UserDetailsManager sollte man als „User-Store mit CRUD-Fähigkeiten" auf Ebene der Security-Darstellung des Benutzers verstehen. Er arbeitet weiterhin in Begriffen von UserDetails, nicht von deinem Domain-Modell. Das ist besonders praktisch in Lern- und Infrastruktur-Szenarien: Benutzer lassen sich über ein einziges überschaubares Interface verwalten, ohne den gesamten Geschäftsteil des Systems einzubinden.
Auf der Gefühlsebene lässt sich das so merken: UserDetailsService – „lesen", UserDetailsManager – „lesen + ändern".
Ungefähr so sieht die „Idee" der Nutzung aus:
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.UserDetailsManager;
// In der Realität erstellt und konfiguriert Spring den Manager (z.B. InMemoryUserDetailsManager/JdbcUserDetailsManager)
UserDetailsManager manager = null; // stell dir vor, Spring hat ihn dir bereitgestellt
// Wir erstellen einen Benutzer im UserDetails-Format (also in Security-Begriffen)
UserDetails bob = User.withUsername("bob")
.password("{noop}password")
.authorities("ROLE_USER")
.build();
// Benutzerverwaltung über den Security-Vertrag (CRUD-Operationen)
manager.createUser(bob);
System.out.println(manager.userExists("bob")); // true
Wir implementieren das jetzt noch nicht und konfigurieren auch keine konkrete Implementierung – hier ist der Fakt selbst wichtig: Spring Security hat einen separaten Vertrag für die Benutzerverwaltung. Der Speicher kann unterschiedlich sein: Arbeitsspeicher, Datenbank und andere Varianten, aber die Rolle des Interface ändert sich dadurch nicht.
6. Im Projekt Secure Content Platform API
Damit diese Lektion nicht als „Theorie über Interfaces" endet, binden wir sie sauber an unser Querschnittsprojekt an. Wir haben eine Content-Plattform: öffentliche Artikel, den persönlichen Bereich /api/me, Entwürfe, Editor/Admin-Endpoints. Das alles erfordert, dass im System ein „aktueller Benutzer" und seine Berechtigungen existieren. Jetzt ist die Form dieser Verbindung wichtig: Mit welchem minimalen Objekt stellt Spring Security den Benutzer dar und woher holt es ihn.
In unserem Projekt ist es sinnvoll, vorab ein Paket für das minimale Security-Modell des Benutzers festzulegen: com.example.securecontent.security.userdetails. Dort wird später eine Implementierung von UserDetailsService (zunächst eine Lern-Variante, dann eine realistischere) und möglicherweise unser SecureContentUserDetails entstehen. Der Domain-Teil des Projekts – Benutzerprofil, Avatar, Bio – soll in seinen eigenen Anwendungspaketen leben (profile, file) und nicht in UserDetails „durchsickern".
Noch ein praktischer Punkt: Wir wissen bereits, dass wir die Rollen USER, EDITOR, ADMIN haben werden, sowie genauere Authorities (z.B. draft:publish, user:manage). Auf Spring-Security-Ebene wird das alles letztlich zu GrantedAuthority. Das heißt, irgendwo an der Grenze „unsere Domäne → Spring Security" werden wir eine Liste von SimpleGrantedAuthority erstellen.
Mini-Beispiel, wie das in einfachster Form aussehen kann:
import java.util.List;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
var authorities = List.of(
new SimpleGrantedAuthority("ROLE_USER"),
new SimpleGrantedAuthority("profile:read")
);
Hier ist keine Magie: ROLE_USER ist einfach ein Authority-String mit Namenskonvention, und profile:read ist eine genauere Berechtigung. Die vollständige Zugriffsmatrix bauen wir jetzt noch nicht – das kommt später. Aber hier ist es bereits wichtig zu sehen: UserDetails ist der Ort, an dem diese Authorities für einen konkreten Benutzer „leben".
7. Typische Fehler bei der Arbeit mit UserDetails
Fehler Nr. 1: UserDetails für das Domain-Modell des Benutzers halten.
Einsteiger fangen oft an, alles Mögliche in UserDetails zu stopfen: displayName, E-Mail, Bio, Interface-Einstellungen, Pfad zum Avatar und „sicherheitshalber noch die Lieblingstasse". Das verwandelt den Security-Vertrag schnell in einen Domain-Mähdrescher. Der richtige Gedanke: UserDetails ist ein minimaler Security-Pass – alles andere gehört in die Domäne.
Fehler Nr. 2: Das Passwort im UserDetailsService prüfen.
Manchmal erscheint es logisch: „Da ich den Benutzer schon geladen habe, kann ich das Passwort gleich hier vergleichen." Aber dann brichst du die architektonische Rollentrennung. UserDetailsService soll UserDetails laden; den Credentials-Vergleich (und die damit verbundenen Fehler) übernehmen DaoAuthenticationProvider und PasswordEncoder. Sonst bekommst du doppelte Logik und seltsame Verhaltensabweichungen.
Fehler Nr. 3: null statt UsernameNotFoundException zurückgeben.
null wirkt wie eine „einfache Lösung", ist aber eine schlechte Gewohnheit: Weiter in der Kette werden Fehler implizit, manchmal werden sie zu NullPointerException, manchmal zu „401 ohne erkennbaren Grund". Der Vertrag von loadUserByUsername(...) sieht vor, dass ein fehlender Benutzer eine Exception ist, die Teil des normalen Security-Flows ist.
Fehler Nr. 4: Rollen und Authorities verwechseln und in einen String-Brei verwandeln.
Wenn du zum ersten Mal das ROLE_-Präfix und hasRole siehst, entsteht der Wunsch, beliebig zu schreiben: mal ADMIN, mal ROLE_ADMIN, mal admin, mal authority_admin. Am Ende verursachst du dir selbst ein Rätsel. Besser von Anfang an eine Konvention einhalten: Rollen als ROLE_ADMIN, und Anwendungsberechtigungen als verständliche Strings wie draft:publish.
Fehler Nr. 5: UserDetails als veränderbares Objekt machen und Felder still ändern.
Das ist ein subtileres Problem. UserDetails landet oft in Authentication und „lebt" dann als Teil des Security-Kontexts. Wenn du es veränderbar machst und anfängst, Authorities zur Laufzeit „im Objekt" zu ändern, können sehr seltsame Effekte auftreten, besonders bei parallelen Requests. Für die ersten Schritte ist es einfacher, UserDetails als unveränderliche Projektion zu betrachten: einmal laden – verwenden – bei Bedarf erneut laden.
GO TO FULL VERSION