1. Wprowadzenie
Spring Security nie pracuje bezpośrednio z twoim domenowym użytkownikiem. Jeśli próbować „po prostu oddać mu UserAccount z bazy", wszystko szybko kończy się albo „tłustym" obiektem ciąganym wszędzie, albo sprawdzeniami security wprost w kontrolerach. Jedno i drugie sprawia, że kod jest ciężki do czytania, testowania i bezpiecznego rozwijania.
Spring Security rozwiązuje to twardo i pragmatycznie: nie potrzebuje całego twojego domenowego użytkownika. Potrzebuje minimalnej projekcji użytkownika, wystarczającej do (a) sprawdzenia danych uwierzytelniających i (b) dalszego podejmowania decyzji o dostępie. Ta projekcja jest opisana interfejsem UserDetails. A sposób uzyskania jej po „loginie" (stringu wybranym jako identyfikator logowania) jest określony przez interfejs UserDetailsService.
Jeśli przeprowadzić analogię, domenowy model użytkownika to „cały formularz osobowy człowieka", a UserDetails to „przepustka do fabryki": kim jesteś, czy przepustka jest ważna, czy nie jest zablokowana i jakie strefy są dla ciebie dostępne.
Idea jest tu bardzo praktyczna: komponenty security nie muszą wiedzieć, jaki jest twój ulubiony kolor, gdzie mieszkasz i jaki masz awatar. Ważne jest dla nich zrozumienie, kto dokładnie się zalogował, czy można zaufać temu kontu i co mu wolno. Reszta to już obszar domeny i logiki biznesowej, a nie autentykacji.
2. UserDetails i UserDetailsService w auth flow
Żeby temat nie zamienił się w zestaw oderwanych interfejsów, przywiążemy go do dokładnie tego miejsca, gdzie jest potrzebny. DaoAuthenticationProvider nie przechowuje użytkowników wewnątrz siebie i nie wie, skąd pochodzą. Potrzebuje mu zrozumiałego kontraktu: po username uzyskać security-reprezentację użytkownika, a dalej już sprawdzić hasło i stan konta.
Właśnie dlatego provider wywołuje UserDetailsService.loadUserByUsername(username) i dostaje UserDetails. Potem czyta password, authorities i flagi stanu konta, a następnie podejmuje decyzję w sprawie credentials. Tu ważny jest właśnie ten lokalny węzeł, bez pełnej trasy od żądania HTTP do SecurityContext.
flowchart TD
A[Authentication attempt] --> D[DaoAuthenticationProvider]
D --> F["UserDetailsService loadUserByUsername(username)"]
F --> G[UserDetails]
G --> D
D --> H[Authentication result]
UserDetails i UserDetailsService zamykają dokładnie jedną lukę w tym łańcuchu: dają providerowi niezawodne źródło security-danych użytkownika. Gdy ta część jest jasna, cały łańcuch autentykacji można już złożyć bez białych plam.
Jeśli powiedzieć bardzo krótko: UserDetailsService to „dostawa security-użytkownika po nazwie", a UserDetails to „co dokładnie dostarczono". I dopiero po tym Spring Security decyduje, czy przepuszczać użytkownika dalej.
3. Interfejs UserDetails
Gdy po raz pierwszy otwierasz UserDetails, wydaje się, że „za dużo tego wszystkiego". Ale w rzeczywistości to dość kompaktowy zestaw faktów, które są potrzebne bezpieczeństwu w prawie każdym systemie. Ważne jest postrzeganie UserDetails nie jako „użytkownika w ogóle", lecz jako security-paszportu, który Spring Security czyta podczas autentykacji i następnie używa jako źródła informacji o bieżącym użytkowniku.
Minimalnie ważne są nam cztery grupy danych: nazwa użytkownika, hasło (jako string, zazwyczaj nie w otwartej formie, ale na razie się nie zagłębiamy), zestaw praw (authorities) i flagi stanu konta (enabled/locked/expired). W interfejsie wyraża się to metodami, które często pojawiają się razem.
Wygodnie jest zobaczyć to w tabelce, żeby metody nie zlewały się w „biały szum":
| Metoda UserDetails | Co oznacza po ludzku | Po co potrzebne Spring Security |
|---|---|---|
| getUsername() | „Jaki login ma ten użytkownik?" | Żeby powiązać próbę logowania z znalezionym kontem |
| getPassword() | „Jaka wartość hasła jest przechowywana w systemie?" (zazwyczaj nie raw) | Żeby provider mógł porównać wprowadzone hasło z zapisanym |
| getAuthorities() | „Jakie prawa/role ma użytkownik?" | Żeby potem autoryzacja mogła powiedzieć hasRole / hasAuthority |
| isEnabled() | „Konto jest włączone?" | Żeby wyłączony użytkownik nie mógł się zalogować |
| isAccountNonLocked() | „Konto nie jest zablokowane?" | Żeby zablokowany użytkownik nie mógł się zalogować |
| isAccountNonExpired() | „Konto nie wygasło?" | Rzadkie w projektach edukacyjnych, ale mechanizm jest ogólny |
| isCredentialsNonExpired() | „Hasło/credentials nie wygasły?" | Też rzadkość w nauce, ale ważne jest rozumienie sensu |
Teraz zobaczmy żywy przykład. Spring Security ma gotową implementację UserDetails – klasę org.springframework.security.core.userdetails.User (tak, nazwa jest maksymalnie „podstępna", bo w twoim projekcie na pewno też będzie User. Witaj w świecie „nazw, które kończą się konfliktem"!).
Najprostszy sposób stworzenia UserDetails przez builder:
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
// Tworzymy "projekcję security" użytkownika, a nie domenową encję
UserDetails alice = User.withUsername("alice")
// {noop} – edukacyjna zaślepka: hasło przechowywane "jak jest" (na produkcji tak nie wolno)
.password("{noop}password")
// Authorities – to co potem będzie uczestniczyć w hasRole/hasAuthority
.authorities("profile:read")
.build();
Tu {noop} to znacznik formatu hasła do demonstracji. Traktuj ten string jako techniczną zaślepkę: potrzebna jest tylko po to, żeby pokazać formę UserDetails, a nie zalecany sposób pracy z hasłem.
Teraz „poczujmy" obiekt i upewnijmy się, że to naprawdę kontener z potrzebnymi danymi:
import org.springframework.security.core.GrantedAuthority;
// Username – stringowy identyfikator logowania, z którym pracuje auth flow
System.out.println(alice.getUsername()); // alice
// Authorities wewnątrz leżą jako obiekty GrantedAuthority, a nie jako "gołe stringi"
var auth = alice.getAuthorities().stream()
.map(GrantedAuthority::getAuthority) // wyciągamy stringową wartość prawa
.toList();
System.out.println(auth); // [profile:read]
Zwróć uwagę: getAuthorities() zwraca nie stringi, lecz obiekty GrantedAuthority. String prawa to po prostu pole wewnątrz GrantedAuthority. Dlatego bardzo często będziesz widzieć w kodzie new SimpleGrantedAuthority(...) i podobne rzeczy.
UserDetails jako własna klasa
W prawdziwym projekcie często wygodniej jest mieć własną implementację UserDetails, żeby starannie dodać tam np. userId (bo username może się zmienić, a id jest stabilniejsze). Ale ważne jest pamiętanie o granicy: to nadal projekcja security, a nie „wszystko o użytkowniku".
Oto jak może wyglądać nasz minimalny UserDetails-klasa w projekcie Secure Content Platform API (na razie bez podłączenia do bazy i bez „pól biznesowych"):
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 – identyfikator logowania (może być e-mail/login/cokolwiek wybierzesz jako "username")
private final String username;
// Przechowujemy nie surowe hasło, lecz jego reprezentację w magazynie (zazwyczaj hash/encoded)
private final String passwordHash;
// Prawa/role, które będą używane przy autoryzacji
private final Collection<? extends GrantedAuthority> authorities;
// ... tu będą implementacje metod UserDetails (getUsername/getPassword/getAuthorities i flagi)
}
I kawałek z metodami odpowiadającymi za „kto to" i „jakie prawa":
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
@Override
public String getUsername() {
// To, czym użytkownik "przedstawia się" w świecie security (nazwa principal)
return username;
}
@Override
public String getPassword() {
// To, z czym DaoAuthenticationProvider będzie porównywać wprowadzone hasło (przez PasswordEncoder)
return passwordHash;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// To, co potem trafi do sprawdzeń hasRole/hasAuthority
return authorities;
}
Sens jest prosty: Spring Security nie prosi cię o „biografię użytkownika". Prosi o dowód tożsamości i prawa dostępu. A szczegóły jak displayName, bio, avatarPath – to już profil aplikacyjny i jego miejsce jest w UserProfile, a nie w kontrakcie security.
4. Flagi stanu konta
Flagi stanu konta brzmią jak coś „z dorosłego enterprise", ale w praktyce to bardzo zdrowy mechanizm: pozwala systemowi powiedzieć „login i hasło niby prawidłowe, ale wejść nie można" – i zrobić to jednolicie na poziomie infrastruktury security, a nie przez if w każdym kontrolerze.
Najczęściej używane flagi to enabled i accountNonLocked. W projekcie edukacyjnym prawie zawsze ograniczamy się do nich, bo są naoczne: administrator wyłączył użytkownika – ten nie może się zalogować; administrator zablokował – też nie może. Mechanizm expired (dla konta lub credentials) pojawia się rzadziej, ale ważne jest rozumienie, że Spring Security z góry trzyma dla tego miejsce w kontrakcie. Pozwala to providerom i menedżerom użytkowników działać jednakowo, nawet jeśli twoja aplikacja na razie z tego nie korzysta.
Przykład, jak to wygląda w kodzie UserDetails:
@Override
public boolean isEnabled() {
// true: konto jest aktywne (false zazwyczaj oznacza "użytkownik wyłączony przez admina")
return true;
}
@Override
public boolean isAccountNonLocked() {
// true: użytkownik NIE jest zablokowany (false – jest zablokowany, autentykacja będzie zabroniona)
return true;
}
@Override
public boolean isAccountNonExpired() {
// true: konto "nie wygasło" (rzadko używane w projektach edukacyjnych)
return true;
}
@Override
public boolean isCredentialsNonExpired() {
// true: hasło/credentials "nie wygasły" (np. nie wymagana przymusowa zmiana)
return true;
}
Dlaczego prawie wszędzie true? Bo to „normalny" użytkownik. A jeśli chcesz zamodelować zablokowanego, zmieniasz chociaż jedną flagę.
I jeszcze ważny niuans do przemyślenia: te flagi to nie autoryzacja. Nie odpowiadają na pytanie „czy można publikować artykuły" ani „czy można wchodzić do strefy admin". Odpowiadają na inne pytanie: „czy to konto w ogóle może przejść autentykację".
5. UserDetailsService i UserDetailsManager
UserDetails sam w sobie nie rozwiązuje problemu „skąd brać użytkowników". Dlatego Spring Security wprowadza kolejny poziom: UserDetailsService. To bardzo mały interfejs, ale dosłownie cementuje cały model username/password. Ważne jest zapamiętanie jego odpowiedzialności: załadowanie użytkownika po nazwie (username) – i tylko tyle.
W „czystej postaci" interfejs wygląda tak:
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
public interface UserDetailsService {
// Kontrakt: po username zwróć UserDetails (a jeśli nie znaleziono – to osobny scenariusz odmowy)
UserDetails loadUserByUsername(String username);
}
W prawdziwym Spring Security ta metoda rzuca wyjątek UsernameNotFoundException, jeśli użytkownik nie został znaleziony. I słusznie: nieznaleziony użytkownik to nie „null i jakoś sobie poradzimy", lecz normalny scenariusz odmowy autentykacji.
Przykład najprostszej implementacji opartej na Map – to nie „produkcja", lecz edukacyjny mini-model, żeby zobaczyć, gdzie żyje logika:
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 {
// Edukacyjny in-memory "magazyn" użytkowników po username
private final Map<String, UserDetails> users;
MapUserDetailsService(Map<String, UserDetails> users) {
// W prawdziwym projekcie tu zazwyczaj będzie repozytorium/DAO, a nie Map
this.users = users;
}
@Override
public UserDetails loadUserByUsername(String username) {
// Tu NIE MA sprawdzania hasła: tylko ładujemy UserDetails
UserDetails user = users.get(username);
if (user == null) {
// "Nie znaleziono" – to osobny oczekiwany wynik w auth flow
throw new UsernameNotFoundException(username);
}
return user;
}
}
I mała weryfikacja „w próżni", żeby mózg połączył kropki (bez Springa, bez filtrów – tylko zobaczenie kontraktu):
// Wkładamy jednego użytkownika do in-memory store
var service = new MapUserDetailsService(Map.of("alice", alice));
// I ładujemy go po username (tak, jak zrobi DaoAuthenticationProvider)
UserDetails loaded = service.loadUserByUsername("alice");
System.out.println(loaded.getUsername()); // alice
Czego UserDetailsService nie powinien robić
Bardzo ważny moment, na którym wielu się łamie: UserDetailsService nie sprawdza hasła. Nie porównuje „password z żądania" z „password z bazy". On tylko zwraca UserDetails. Sprawdzanie hasła to strefa odpowiedzialności DaoAuthenticationProvider i PasswordEncoder (do tego przyjdziemy osobnym dniem). Jeśli zaczniesz sprawdzać hasło wewnątrz UserDetailsService, zmieszasz poziomy odpowiedzialności, a potem będziesz łapać dziwne bugi w stylu „dlaczego przy jednej autentykacji hasło sprawdziło się dwa razy, a przy innej – ani razu".
UserDetailsManager: zarządzanie użytkownikami
Dla samego auth flow krytyczne jest teraz zrozumienie tylko loadUserByUsername(...). UserDetailsManager to już przydatne rozszerzenie do przechowywania i administrowania użytkownikami, a nie kolejny obowiązkowy etap każdego żądania.
Niekiedy załadowanie użytkownika nie wystarczy: trzeba go stworzyć, zaktualizować, usunąć, zmienić hasło. Do tego w Spring Security jest interfejs UserDetailsManager, który rozszerza UserDetailsService i dodaje operacje „zarządzania". To nie nowy świat, a po prostu rozszerzenie tego samego kontraktu w stronę administrowania.
Ważne jest postrzeganie UserDetailsManager jako „user store z nawykami CRUD" na poziomie security-reprezentacji użytkownika. Nadal pracuje w terminach UserDetails, a nie twojego modelu domenowego. Jest to szczególnie wygodne w scenariuszach edukacyjnych i infrastrukturalnych: można zarządzać użytkownikami przez jeden zrozumiały interfejs, nie włączając całej biznesowej części systemu.
Na poziomie odczuć można zapamiętać tak: UserDetailsService – „czytać", UserDetailsManager – „czytać + zmieniać".
Mniej więcej tak wygląda „idea" użycia:
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.UserDetailsManager;
// W rzeczywistości manager stworzy i skonfiguruje Spring (np. InMemoryUserDetailsManager/JdbcUserDetailsManager)
UserDetailsManager manager = null; // wyobraź sobie, że dał ci go Spring
// Tworzymy użytkownika w formacie UserDetails (czyli w terminach security)
UserDetails bob = User.withUsername("bob")
.password("{noop}password")
.authorities("ROLE_USER")
.build();
// Zarządzamy użytkownikami przez kontrakt security (operacje CRUD)
manager.createUser(bob);
System.out.println(manager.userExists("bob")); // true
Teraz tego nie implementujemy i nie konfigurujemy konkretnej implementacji – tu ważny jest sam fakt, że Spring Security ma osobny kontrakt do zarządzania użytkownikami. Magazyn może być różny: pamięć, baza danych i inne warianty, ale rola interfejsu przez to się nie zmienia.
6. W projekcie Secure Content Platform API
Żeby dzisiejsza lektura nie pozostała „teorią o interfejsach", ostrożnie przywiążmy ją do naszego projektu przekrojowego. Mamy platformę contentu: publiczne artykuły, strefa osobista /api/me, szkice, endpointy editor/admin. Wszystko to wymaga, żeby w systemie istniał „bieżący użytkownik" i jego prawa. Teraz ważna jest forma tego połączenia: jakim minimalnym obiektem Spring Security będzie reprezentować użytkownika i skąd go weźmie.
W naszym projekcie rozsądnie jest wcześniej wydzielić pakiet, gdzie będzie żyć minimalne security-model użytkownika: com.example.securecontent.security.userdetails. Tam pojawi się później implementacja UserDetailsService (najpierw edukacyjna, potem bardziej realna) i ewentualnie nasz SecureContentUserDetails. Przy tym domenowa część projektu – profil użytkownika, awatar, bio – powinna żyć w swoich aplikacyjnych pakietach (profile, file) i nie „przenikać" do UserDetails.
Jeszcze jeden praktyczny moment: wiemy już, że będziemy mieć role USER, EDITOR, ADMIN, a także bardziej precyzyjne authorities (np. draft:publish, user:manage). Wszystko to na poziomie Spring Security ostatecznie zamienia się w GrantedAuthority. Czyli gdzieś na granicy „nasza domena → Spring Security" będziemy tworzyć listę SimpleGrantedAuthority.
Mini-przykład, jak może to wyglądać w bardzo prostej formie:
import java.util.List;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
var authorities = List.of(
new SimpleGrantedAuthority("ROLE_USER"),
new SimpleGrantedAuthority("profile:read")
);
Nie ma tu magii: ROLE_USER to po prostu string authority z konwencją nazewniczą, a profile:read to bardziej precyzyjne prawo. Pełnej macierzy dostępu teraz nie budujemy – to będzie później. Ale już tu ważne jest zobaczenie: UserDetails to miejsce, gdzie te authorities „żyją" dla konkretnego użytkownika.
7. Typowe błędy przy pracy z UserDetails
Błąd nr 1: uważać UserDetails za swój domenowy model użytkownika.
Początkujący często zaczynają pakować do UserDetails wszystko: displayName, e-mail, bio, ustawienia interfejsu, ścieżkę do awataru i „na wszelki wypadek numer ulubionego kubka". Szybko zamienia to kontrakt security w domenowy kombajn. Właściwa myśl: UserDetails to minimalny paszport security, reszta należy do domeny.
Błąd nr 2: sprawdzać hasło w UserDetailsService.
Niekiedy wydaje się logiczne: „skoro załadowałem użytkownika, mogę tu od razu porównać hasło". Ale wtedy łamiesz architektoniczny podział ról. UserDetailsService powinien załadować UserDetails, a porównanie credentials (i związane z tym błędy) – to zadanie DaoAuthenticationProvider i PasswordEncoder. Inaczej dostaniesz duplikację logiki i dziwne rozbieżności w zachowaniu.
Błąd nr 3: zwracać null zamiast UsernameNotFoundException.
null wydaje się „prostym rozwiązaniem", ale to zły nawyk: dalej po łańcuchu błędy stają się niejawne, niekiedy zamieniają się w NullPointerException, a niekiedy w „401 bez zrozumiałej przyczyny". Kontrakt loadUserByUsername(...) zakłada, że brak użytkownika to wyjątek, który jest częścią normalnego flow security.
Błąd nr 4: mylić role i authorities i zamieniać je w stringowy bałagan.
Gdy po raz pierwszy widzisz prefiks ROLE_ i hasRole, pojawia się pokusa pisania jak popadnie: to ADMIN, to ROLE_ADMIN, to admin, to authority_admin. W efekcie sam sobie urządzasz zagadkę. Lepiej od samego początku trzymać się jednej konwencji: role – jak ROLE_ADMIN, a uprawnienia aplikacyjne – jak zrozumiałe stringi np. draft:publish.
Błąd nr 5: robić UserDetails mutowalnym obiektem i po cichu zmieniać jego pola.
To subtelniejszy problem. UserDetails często trafia do Authentication i potem „żyje" jako część security-kontekstu. Jeśli zrobisz go mutowalnym i zaczniesz zmieniać authorities w runtime „wewnątrz obiektu", możesz uzyskać bardzo dziwne efekty, szczególnie przy równoległych żądaniach. Na pierwsze kroki prościej jest traktować UserDetails jako niemutowalną projekcję: załadowałeś – użyłeś – jeśli potrzeba – załadowałeś ponownie.
GO TO FULL VERSION