CodeGym /Kursy /Spring Security /UserDetails i UserDetailsService

UserDetails i UserDetailsService

Spring Security
Poziom 4, Lekcja 3
Dostępny

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.

1
Zadanie
Spring Security, poziom 4, lekcja 3
Niedostępne
Własna klasa UserDetails
Własna klasa UserDetails
1
Zadanie
Spring Security, poziom 4, lekcja 3
Niedostępne
UserDetailsService oparty na Map
UserDetailsService oparty na Map
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION