Rano w logach widać nagły wysyp nieudanych logowań, a po południu support dostaje kilka zgłoszeń „konto nie działa”. Zwykle to moment, w którym okazuje się, że endpoint logowania jest najłatwiejszym celem: tani do automatyzacji, trudny do odróżnienia od zwykłych pomyłek i często słabo obserwowalny. Jeśli korzystasz ze Spring Security, masz kilka sensownych dróg obrony — od prostych haków w aplikacji po ochronę na brzegu infrastruktury — ale wybór „pierwszego kroku” decyduje o UX i ryzyku fałszywych blokad.
Realne pytania, które zwykle padają przy tej decyzji:
- Jak rozpoznać brute force i password spraying w logach oraz metrykach, zanim użytkownicy zaczną cierpieć?
- Czy lepiej limitować per IP, per konto, czy mieszać oba podejścia?
- Co da się zrobić „czysto” w Spring Security (handlery, eventy, filtry), a co lepiej zostawić reverse proxy/WAF?
- Jak nie zrobić sobie nowej dziury: user enumeration i DoS przez „blokowanie” konta ofiary?
- Gdzie trzymać licznik prób (pamięć/Redis/baza), żeby działało przy wielu instancjach?
- Jak to przetestować bez zgadywania „magicznych progów”?
Frazy pomocnicze: Spring Security brute force, password spraying wykrywanie, rate limiting logowanie, AuthenticationFailureHandler Spring, ApplicationListener authentication events, blokada konta vs blokada IP, Redis licznik prób logowania, user enumeration komunikaty błędów, X-Forwarded-For Spring Boot, opóźnienie po nieudanym logowaniu, WAF reverse proxy ochrona logowania, metryki nieudane logowania Micrometer
Sygnały brute force i password spraying – co widać w logach, a co łatwo przeoczyć
Dwa wzorce ataku, które wyglądają podobnie, ale wymagają innej reakcji
Brute force najczęściej „bije” w jedno konto: jeden login, dużo prób, często z jednego IP lub z kilku rotujących adresów. W logach zobaczysz długą serię nieudanych prób dla tej samej nazwy użytkownika, czasem przeplataną krótkimi przerwami (boty robią pauzy, żeby nie wyglądać jak prosty skrypt).
Password spraying jest podstępniejszy: mało prób na jedno konto, za to wiele kont w krótkim czasie. Jeśli masz politykę blokady „po N błędach” per konto, spraying potrafi ją ominąć, bo atakujący próbuje 1–2 hasła na tysiąc loginów, zamiast 100 haseł na jeden login.
Mini-wniosek: jeśli liczysz tylko „błędy na użytkownika”, złapiesz klasyczny brute force, ale możesz przegapić spraying; jeśli liczysz tylko „błędy na IP”, przegrasz z atakiem rozproszonym i narazisz na fałszywe blokady w NAT.
Minimalny zestaw danych, bez którego detekcja to zgadywanie
Do wykrywania ataków brute force na logowanie potrzebujesz spójnych, powtarzalnych sygnałów. Nie chodzi o rozbudowany SIEM od pierwszego dnia, tylko o konsekwentne logowanie i metryki, które pozwalają policzyć trendy.
- Login string (to, co wpisano) – nawet jeśli konto nie istnieje; przy spraying to kluczowa oś analizy.
- Adres IP z poprawnym uwzględnieniem reverse proxy (o tym niżej).
- User-Agent (wielu botów ma charakterystyczne lub puste UA; nie jest to dowód, ale sygnał).
- Ścieżka i metoda (np. /login POST, /api/auth/login POST) – przy kilku mechanizmach logowania musisz je rozróżniać.
- Wynik: fail/success/blocked/rate-limited.
- Correlation ID (np. z filtra na wejściu) – przy dochodzeniu i łączeniu logów z proxy to oszczędza czas.
Jeśli masz Spring Boot, łatwo dołożyć correlation ID w filtrze i przepinać go do MDC, aby pojawiał się w logach aplikacji. Bez tego typowe „co się stało?” zamienia się w ręczne przeszukiwanie tysiąca linii.
Czerwone flagi w ruchu, które nie wymagają „AI”
Najczęstsze czerwone flagi, które da się zauważyć prostym dashboardem lub nawet grepem:
- Nagły skok 401/403 na endpointach logowania przy stałym wolumenie ruchu na innych endpointach.
- „Rozlanie” prób po wielu loginach w krótkim czasie (spraying).
- Powtarzalne sekwencje loginów (np. alfabetycznie, lub wg domen e-mail).
- Nietypowe UA: puste, bardzo krótkie, albo identyczne na tysiącach prób.
Mini-wniosek: mechanizmy ochrony (limity, opóźnienia, blokady) ustawiaj dopiero, gdy masz choć minimalną możliwość obserwacji skutków ubocznych: fałszywych blokad i „przepuszczonych” ataków.
Kryteria wyboru obrony: UX, skalowanie, ryzyko DoS i user enumeration
Co chronić: konto, źródło ruchu, czy oba naraz
Ochrona per konto jest intuicyjna: „ktoś zgaduje hasło do jan.kowalski, to spowolnij albo zablokuj próby dla jan.kowalski”. Skutek uboczny jest jednak poważny: atakujący może celowo generować błędne logowania na cudze konto i wywołać DoS, jeśli zastosujesz twardą blokadę.
Ochrona per IP bywa najprostsza: „z tego IP jest za dużo prób, tnij”. Ale IP często nie odpowiada pojedynczemu użytkownikowi: korporacyjny NAT, sieć mobilna, wspólny VPN albo proxy w firmie. Zablokowanie IP może uderzyć w wielu prawidłowych użytkowników naraz.
W praktyce najlepiej działa warstwowanie: miękka kontrola per konto (opóźnienia/backoff), plus twardsze limity per IP na „głośne” źródła, plus dodatkowe reguły na spraying (wiele loginów z jednego IP).
Opóźnienia i limity kontra twarda blokada
Rate limiting ogranicza tempo prób. Jest przewidywalny, a przy dobrze ustawionych kluczach (IP, ścieżka, czas) potrafi odciąć banalne boty bez mieszania w logice kont. Problem: rozproszone ataki z wielu IP obchodzą prosty limiter.
Progresywne opóźnienie (np. backoff rosnący wraz z liczbą porażek dla danego loginu) jest dobrą alternatywą dla „blokady konta”. Zamiast odcinać użytkownika, sprawiasz, że zgadywanie haseł staje się kosztowne czasowo. Efekt uboczny: źle zaimplementowane opóźnienia potrafią wiązać wątki i obniżać wydajność przy dużej liczbie prób.
Twarda blokada (czasowa) ma sens, gdy masz dojrzałą ścieżkę odblokowania, dobrą obserwowalność i pewność, że nie karzesz ofiary. Jeśli dopiero zaczynasz, częściej bezpieczniej jest iść w opóźnienia + limity, a blokadę zostawić jako element „wysokiego ryzyka” (np. po wykryciu wzorca ataku i dodatkowym sygnale).
Jak nie zdradzić zbyt dużo: user enumeration i „timing oracle”
Wdrażając blokady i liczniki, łatwo przypadkiem pomóc atakującemu. Typowe błędy:
- Inny komunikat dla „użytkownik nie istnieje” i „złe hasło”.
- Inny status/ciało odpowiedzi dla „konto zablokowane” niż dla „błędne dane”.
- Wyraźnie inny czas odpowiedzi dla istniejącego konta (bo np. robisz więcej pracy).
Bezpieczniejsza praktyka dla logowania: jeden komunikat (np. „Nieprawidłowy login lub hasło”), a w API spójny status (najczęściej 401). Informację o blokadzie pokazuj ostrożnie: jeśli już, to po dodatkowym uwierzytelnieniu (np. po kliknięciu w link z e-maila) albo w kanałach wewnętrznych (alerty, logi, panel administracyjny).
Mini-wniosek: najlepsza ochrona brute force to taka, która zwiększa koszt ataku, nie psując UX i nie zdradzając, które loginy są prawdziwe.
Warianty ochrony logowania (3–4 podejścia) – porównanie, plusy/minusy, kiedy które ma sens
Wariant A — tylko aplikacja (in-memory)
To podejście polega na trzymaniu liczników prób w pamięci procesu (np. mapy z TTL), a decyzje (opóźnij/odrzuć) podejmujesz w Spring Security. Zyskujesz szybkość wdrożenia i pełną kontrolę nad logiką per konto.
Plusy: szybkie, bez dodatkowej infrastruktury, wygodne do prototypu lub aplikacji w jednej instancji.
Minusy: po restarcie wszystko znika, a przy skalowaniu poziomym każda instancja widzi tylko swój fragment prób. Atak rozproszony lub round-robin LB potrafi rozmyć liczniki.
Wariant B — aplikacja + współdzielony magazyn (Redis / baza)
Liczniki i blokady trzymasz we współdzielonym storage (najczęściej Redis z TTL). Każda instancja aplikacji ma ten sam obraz sytuacji, a limity działają spójnie.
Plusy: działa w klastrze, łatwo wdrożyć TTL, lepsza odporność na „skakanie” ruchu między instancjami, dobra baza pod alerty i metryki.
Minusy: dochodzi zależność (Redis), trzeba przemyśleć awarie (co robisz, gdy Redis nie odpowiada?), i uważać na koszty operacji przy dużym wolumenie (np. atomowość inkrementacji, redukcja liczby kluczy, sensowne TTL).
Wariant C — edge/infrastruktura (reverse proxy / WAF / API gateway)
Limity nakładasz zanim ruch dotrze do aplikacji: na reverse proxy, bramie API albo WAF. Najczęściej jest to rate limiting per IP i per ścieżka, czasem z prostymi regułami na nagłówki i UA.
Plusy: odcinasz szum „przed” aplikacją, chronisz zasoby (wątki, CPU), a wdrożenie może być centralne dla wielu usług.
Minusy: logika per konto jest trudniejsza (edge zwykle nie zna kontekstu użytkownika, a jeśli zna, to rośnie złożoność i ryzyko wycieku informacji). Przy NAT łatwo o false positives. Edge też nie rozwiązuje problemu user enumeration w samej aplikacji.
Wariant D — hybryda (edge + logika ryzyka w aplikacji)
Najczęściej spotykany „sensowny kompromis”: edge robi proste odcinanie masowych prób (per IP/per ścieżka), a aplikacja liczy próby per login, robi progresywne opóźnienia, reset po sukcesie, korelację zdarzeń i alerty. To podejście jest zwykle najlepsze, gdy chcesz zacząć bez rewolucji, ale docelowo mieć pełną kontrolę.
Plusy: najlepsza skuteczność praktyczna, mniejsze obciążenie aplikacji, a jednocześnie możliwość polityk per konto i redukcji DoS na konto.
Minusy: większa liczba miejsc konfiguracji i konieczność spójnej obserwowalności (edge i aplikacja muszą logować i przekazywać kontekst).
Tabela porównawcza: co wybrać, gdy liczy się szybka decyzja
| Cecha | A: in-memory w aplikacji | B: aplikacja + Redis/baza | C: edge/WAF | D: hybryda |
|---|---|---|---|---|
| Skalowanie poziome | Słabe | Dobre | Bardzo dobre | Bardzo dobre |
| Kontrola per konto (login) | Dobra | Bardzo dobra | Ograniczona | Bardzo dobra |
| Ochrona zasobów aplikacji | Średnia | Średnia | Bardzo dobra | Bardzo dobra |
| Ryzyko false positives (NAT/proxy) | Niskie (dla per konto), zależne od IP | Niskie/średnie (zależne od kluczy) | Średnie/wysokie | Średnie (łatwiej skorygować w aplikacji) |
| Złożoność wdrożenia | Niska | Średnia | Średnia | Wyższa |
| Obserwowalność i audyt | Średnia | Dobra | Średnia/dobra | Bardzo dobra |
Mini-wniosek: jeśli aplikacja działa na jednej instancji i potrzebujesz „tu i teraz” — A ma sens. Jeśli masz więcej niż jedną instancję albo planujesz autoscaling — B lub D. Jeśli aplikacja już cierpi od ruchu botów i chcesz natychmiast odciążyć zasoby — C jako pierwsza tama, a potem D.
Integracja w Spring Security: gdzie się podpiąć, jak liczyć próby i jak nie zdradzić zbyt dużo
Punkty zaczepienia: failure handler, zdarzenia, filtr w łańcuchu
W Spring Security masz trzy naturalne miejsca, w których można wdrożyć wykrywanie i zapobieganie brute force na logowaniu:
Klasyk z produkcji: nagle rośnie liczba nieudanych logowań, a jednocześnie support dostaje zgłoszenia „nie mogę się zalogować” od kilku prawdziwych osób z tej samej firmy. Ktoś odpalił password spraying zza jednego NAT-a, a prosty limit per IP uciął również legalny ruch. W Spring Security da się to rozwiązać, ale ważne jest, gdzie wpiąć logikę i jakim sygnałem podejmować decyzję.
AuthenticationFailureHandler to najprostsze miejsce na inkrementację licznika i ewentualne opóźnienie/odrzucenie kolejnych prób. Działa blisko formularza logowania, łatwo dodać kontekst (login z requestu, IP, fingerprint urządzenia, ścieżka), a jednocześnie masz pełną kontrolę nad tym, co wraca do klienta (spójny komunikat, ten sam status). Minusy wychodzą przy niuansach: handler odpala się dopiero po tym, jak provider spróbował uwierzytelnić, więc jeśli chcesz odcinać „przed” kosztownymi operacjami (np. hash), lepiej dołożyć wcześniejszy checkpoint.
Zdarzenia (ApplicationEventPublisher) i listener na AuthenticationFailureBadCredentialsEvent/AuthenticationSuccessEvent sprawdzają się, gdy chcesz rozdzielić odpowiedzialności: warstwa security publikuje fakty, a osobny komponent liczy próby, robi korelację i wystawia metryki/alerty. To podejście jest wygodne przy hybrydzie (aplikacja + Redis), bo liczniki i TTL żyją poza samą konfiguracją loginu. Uwaga praktyczna: eventy w różnych konfiguracjach potrafią się różnić (np. inne typy wyjątków dla nieaktywnego konta), więc lepiej normalizować je do jednego „login failed” i dopiero wtedy liczyć ryzyko, zamiast rozróżniać zbyt szczegółowo i przypadkiem otworzyć user enumeration.
Filtr w łańcuchu (np. przed UsernamePasswordAuthenticationFilter) pozwala wprowadzić „bramkę” zanim dojdzie do realnego uwierzytelniania: sprawdzasz klucze (login/IP), odczytujesz aktualny stan (np. z Redis), i podejmujesz decyzję: przepuść, dołóż opóźnienie po stronie klienta (np. Retry-After) albo natychmiast zakończ request. To miejsce jest najbezpieczniejsze dla wydajności, bo redukuje pracę poniżej. Trzeba tylko uważać, żeby nie zdradzać powodu odrzucenia i nie robić „innych” czasów odpowiedzi dla istniejących loginów; jeśli filtr podejmuje decyzję per login, to i tak odpowiedź powinna wyglądać tak samo jak zwykłe „bad credentials”.
Domknięcie decyzyjne: jeśli problemem są pojedyncze, szybkie próby z jednego źródła — zacznij od edge/rate limitu i miej spokój. Gdy widzisz spraying i ryzyko blokowania realnych osób (NAT, VPN, korporacje) — oprzyj detekcję na loginie i dodaj progresywne opóźnienie w aplikacji, najlepiej na współdzielonym magazynie. A jeśli zależy Ci na ochronie zasobów oraz spójnej polityce ryzyka — filtr jako bramka + liczniki per konto w Redisie zwykle daje najlepszy kompromis bez wchodzenia w „magiczne progi”.
Jak zbudować klucze licznika, żeby nie zrobić DoS na użytkownika
Najczęstsza awaria „po wdrożeniu ochrony” wygląda tak: atakujący przez chwilę wali w jedno konto, a prawdziwy użytkownik dostaje blokadę w środku dnia pracy. Mechanizm działa, tylko chroni nie to, co trzeba — zamiast utrudniać życie atakującemu, daje mu tani przycisk „zablokuj dowolną osobę”.
Dlatego w praktyce rzadko opłaca się oprzeć decyzję o blokadzie wyłącznie o login albo wyłącznie o IP. Lepszy jest model z kilkoma licznikami o różnych konsekwencjach:
- Per login (konto): używaj raczej do progresywnego spowalniania i do sygnału „podnieś ryzyko”, a nie do twardej blokady na długo.
- Per IP: dobre do odcinania „maszynki” (wysoki wolumen w krótkim czasie), ale w środowiskach z NAT/VPN blokada per IP powinna być krótka i ostrożna.
- Per (login + IP): złoty środek na zgadywanie haseł dla konkretnego konta z jednego źródła. Taki licznik może szybciej eskalować (np. większe opóźnienia), bo ryzyko DoS na konto jest niższe.
Mini-wniosek: jeśli musisz wybrać jeden klucz „na szybko”, to (login + IP) daje zwykle mniej skutków ubocznych niż samo IP i mniej wektora DoS niż samo konto.
Normalizacja odpowiedzi: jak nie wpaść w user enumeration
W logach wewnętrznych chcesz wiedzieć, czy konto istnieje, czy jest zablokowane, czy wymaga MFA — ale klient zewnętrzny nie powinien dostać tej wiedzy „za darmo”. Zdarza się, że po dodaniu blokad nagle pojawiają się dwa komunikaty: „złe hasło” vs „konto zablokowane” i atakujący ma prosty kanał do enumeracji.
Praktyczne zasady, które ratują skórę:
- Jedna klasa odpowiedzi na niepowodzenie logowania: ten sam status (często 401), ten sam komunikat, brak zdradzania, czy login istnieje.
- Podobny czas odpowiedzi: jeśli dla nieistniejącego loginu zwracasz odpowiedź natychmiast, a dla istniejącego po dodatkowych sprawdzeniach — masz timing side-channel. Przy progresywnych opóźnieniach stosuj je tak, aby dotyczyły również „nieistniejących” loginów (na podstawie klucza wejściowego), a nie tylko realnych kont.
- Retry-After ostrożnie: nagłówek jest fajny dla klienta API, ale niech nie zdradza, że konto jest „na czarnej liście”; lepiej wiązać go z ogólnym limitem dla kombinacji (login+IP) albo IP.
Mini-wniosek: im więcej rozróżniasz w odpowiedzi HTTP, tym łatwiej zbudować listę prawdziwych loginów. Rozróżnianie zostaw do logów i metryk.
Krótkie przykłady: licznik, progresywne opóźnienie, decyzja per IP vs per konto
Kontrakt na licznik prób: interfejs niezależny od storage
Warto oddzielić „gdzie trzymasz stan” od „jaką politykę stosujesz”. Dzięki temu możesz zacząć od in-memory, a potem przejść na Redis bez przepisywania Security config.
public interface LoginAttemptService {
/**
* Sprawdza, czy próbę logowania należy przepuścić, spowolnić lub odrzucić.
* Klucz wejściowy powinien być wyliczalny bez dostępu do bazy użytkowników.
*/
Decision evaluate(AttemptKey key);
/** Rejestruje nieudaną próbę (bad credentials lub inny „nieudany login”). */
void onFailure(AttemptKey key);
/** Rejestruje sukces i czyści/obniża ryzyko. */
void onSuccess(AttemptKey key);
record AttemptKey(String username, String ip, String userAgent) {}
sealed interface Decision permits Decision.Allow, Decision.Delay, Decision.Block {
record Allow() implements Decision {}
record Delay(java.time.Duration duration) implements Decision {}
record Block(java.time.Duration retryAfter) implements Decision {}
}
}Takie API wymusza dobrą dyscyplinę: decyzję da się podjąć tylko na podstawie danych z requestu (username/IP/UA), bez „sprawdzania czy konto istnieje”. To zmniejsza ryzyko user enumeration.
Filtr jako bramka: decyzja zanim Spring policzy hasło
Jeżeli celem jest odciążenie aplikacji (hashing potrafi być kosztowny), filtr przed uwierzytelnianiem jest najprostszy. Dla form login musisz tylko umieć wyciągnąć username z requestu (dla JSON login analogicznie, tylko parser inny).
public class BruteForceGateFilter extends jakarta.servlet.Filter {
private final LoginAttemptService attempts;
public BruteForceGateFilter(LoginAttemptService attempts) {
this.attempts = attempts;
}
@Override
public void doFilter(jakarta.servlet.ServletRequest req,
jakarta.servlet.ServletResponse res,
jakarta.servlet.FilterChain chain)
throws java.io.IOException, jakarta.servlet.ServletException {
var request = (jakarta.servlet.http.HttpServletRequest) req;
var response = (jakarta.servlet.http.HttpServletResponse) res;
// Pilnuj, żeby filtrować tylko endpoint logowania.
if (!"/login".equals(request.getServletPath())
|| !"POST".equalsIgnoreCase(request.getMethod())) {
chain.doFilter(req, res);
return;
}
String username = request.getParameter("username"); // dla form login
String ip = extractClientIp(request);
String ua = request.getHeader("User-Agent");
var key = new LoginAttemptService.AttemptKey(
username == null ? "" : username.trim(),
ip == null ? "" : ip,
ua == null ? "" : ua
);
var decision = attempts.evaluate(key);
if (decision instanceof LoginAttemptService.Decision.Allow) {
chain.doFilter(req, res);
return;
}
// Odpowiedź celowo „jak zwykły błąd logowania”, bez zdradzania przyczyny.
if (decision instanceof LoginAttemptService.Decision.Delay d) {
try { Thread.sleep(d.duration().toMillis()); } catch (InterruptedException ignored) {}
chain.doFilter(req, res);
return;
}
if (decision instanceof LoginAttemptService.Decision.Block b) {
response.setStatus(401);
// Retry-After opcjonalnie, ale niech nie komunikuje „konto istnieje”.
response.setHeader("Retry-After", String.valueOf(b.retryAfter().toSeconds()));
response.setContentType("application/json");
response.getWriter().write("{"error":"bad_credentials"}");
}
}
private String extractClientIp(jakarta.servlet.http.HttpServletRequest request) {
// Jeśli masz proxy/LB, użyj ForwardedHeaderFilter i ufaj tylko zaufanym proxy.
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isBlank()) return xff.split(",")[0].trim();
return request.getRemoteAddr();
}
}Uwaga praktyczna: Thread.sleep blokuje wątek. Przy małym ruchu to działa, ale przy większym lepsze jest krótkie opóźnienie i szybkie wyjście (Block) albo przeniesienie „delay” do warstwy edge (np. tarpit), jeśli to wspiera. W aplikacji progresywne opóźnienie ma sens raczej jako niewielkie tarcie, nie jako długa kara.
Wpięcie filtra w SecurityFilterChain
@Bean
SecurityFilterChain security(HttpSecurity http,
BruteForceGateFilter gateFilter) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.addFilterBefore(gateFilter, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class)
.formLogin(form -> form
.loginProcessingUrl("/login")
// handlers możesz nadal używać do onFailure/onSuccess
)
.build();
}Failure/success jako źródło prawdy: inkrementacja i reset
Filtr podejmuje decyzję „na wejściu”, ale nie wie, czy logowanie się udało. Do aktualizacji liczników najwygodniej użyć handlerów albo eventów. Handler daje pełną kontrolę nad odpowiedzią HTTP, a eventy są lepsze, gdy chcesz spójnie logować i metrykować niezależnie od typu logowania.
@Component
public class CountingAuthHandlers {
private final LoginAttemptService attempts;
public CountingAuthHandlers(LoginAttemptService attempts) {
this.attempts = attempts;
}
public org.springframework.security.web.authentication.AuthenticationFailureHandler failureHandler() {
return (request, response, exception) -> {
var key = new LoginAttemptService.AttemptKey(
request.getParameter("username"),
request.getRemoteAddr(),
request.getHeader("User-Agent")
);
attempts.onFailure(key);
response.setStatus(401);
response.setContentType("application/json");
response.getWriter().write("{"error":"bad_credentials"}");
};
}
public org.springframework.security.web.authentication.AuthenticationSuccessHandler successHandler() {
return (request, response, authentication) -> {
var key = new LoginAttemptService.AttemptKey(
request.getParameter("username"),
request.getRemoteAddr(),
request.getHeader("User-Agent")
);
attempts.onSuccess(key);
response.setStatus(200);
response.setContentType("application/json");
response.getWriter().write("{"status":"ok"}");
};
}
}Mini-wniosek: filtr jest dobry do „stopowania” i opóźnień, a handler/event do tego, by licznik nie rozjechał się z rzeczywistością.
Progresywne opóźnienie bez „magicznych progów”
Da się uniknąć arbitralnych progów typu „po 5 próbach blokuj”. W praktyce lepiej działa łagodna eskalacja: każda kolejna porażka podbija opóźnienie w ramach krótkiego okna (TTL), a po sukcesie ryzyko spada. To utrudnia brute force, a normalnym użytkownikom daje zwykle tylko chwilowy dyskomfort.
Przykładowa polityka (schemat, nie „norma”):
- licz porażki w oknie czasowym (TTL),
- delay rośnie z liczbą porażek, ale ma limit,
- twardy block stosuj głównie dla IP albo (login+IP), gdy wzorzec jest oczywisty,
- po sukcesie wyzeruj licznik dla (login+IP) i obniż licznik per login.
Ważna rzecz: „okno z TTL” samo w sobie jest mechanizmem powrotu do normalności. Bez niego blokady potrafią żyć za długo i UX cierpi.
Per IP vs per konto: kiedy który sygnał ma większy sens
Jeśli logujesz ludzi B2B, korporacyjny NAT/VPN jest normą. Wtedy twarde odcinanie po IP zwykle kończy się zgłoszeniami od realnych użytkowników. Z kolei w aplikacjach konsumenckich, gdzie IP są bardziej „indywidualne”, IP bywa niezłym heurystycznym kluczem na start.
Dwie proste reguły decyzyjne, które często porządkują temat:
- Gdy widzisz dużo różnych loginów z jednego IP (spraying) — reaguj przede wszystkim per IP (krótki block) i jednocześnie delikatnie podbijaj ryzyko per login (małe opóźnienia). To minimalizuje efekt uboczny „zablokuj jedno konto na życzenie”.
- Gdy widzisz jeden login i wiele prób z różnych IP — nie blokuj konta twardo „na długo”; lepiej progresywnie opóźniaj per login i rozważ dodatkowy krok (MFA / challenge) po stronie aplikacji. Rozproszone źródła i tak ominą proste IP rate limiting.
Gdzie trzymać stan i co to zmienia w klastrze
W pojedynczej instancji pamięć procesu jest OK, ale przy kilku instancjach licznik musi być współdzielony, inaczej atak „rozpłynie się” po nodach. Redis jest tu praktyczny, bo wspiera TTL i atomowe inkrementacje, ale trzeba świadomie zdecydować, co robi aplikacja przy awarii magazynu:
- Fail-open (przepuść logowanie, jeśli Redis nie działa): lepszy UX, gorsza ochrona w momencie awarii.
- Fail-closed (blokuj logowanie, jeśli Redis nie działa): lepsze bezpieczeństwo, ryzyko samodzielnego DoS w razie problemów z infrastrukturą.
W systemach publicznych częściej spotyka się podejście mieszane: fail-open dla „miękkich” mechanizmów (delay), fail-closed tylko dla bardzo oczywistych przypadków (np. IP na liście deny) i i tak z alarmem operacyjnym.
Minimalna telemetria, żeby odróżnić atak od pomyłek
Bez podstawowych sygnałów łatwo „strzelać w ciemno” i dobrać politykę, która będzie karać prawdziwych użytkowników. Minimum, które realnie pomaga w diagnozie i korelacji:
- log zdarzenia login failure z polami: znormalizowany typ (np.
LOGIN_FAILED), endpoint, klient (IP), user-agent (albo jego hash), login (najlepiej zhaszowany/zanonimizowany), correlation id; - log zdarzenia login success (do resetu ryzyka i do wykrycia „credential stuffing”);
- metryka liczby nieudanych prób oraz metryka aktywowanych blokad/opóźnień — osobno dla kluczy IP i login;
- sygnał o nietypowym rozkładzie: dużo różnych loginów z jednego IP albo dużo IP na jeden login (to już prosta analiza po stronie logów/SIEM).
Mini-wniosek: nawet jeśli zaczynasz od prostego mechanizmu, logowanie „decyzji ochrony” (allow/delay/block) daje natychmiastowy wgląd, czy mechanizm nie blokuje normalnego ruchu.
Decyzja wdrożeniowa: co ma sens w zależności od Twojego kontekstu
W praktyce wybór nie sprowadza się do „czy blokować”, tylko gdzie i jakim kluczem. Kilka typowych scenariuszy:
- Jedna instancja, mały ruch, chcesz szybko podnieść poprzeczkę: wariant A z licznikiem (login+IP) i krótkim delayem + porządne logi zdarzeń. Gdy tylko pojawi się skalowanie, przenieś stan do Redis.
- Kilka instancji / autoscaling, logowanie jest krytyczne: wariant B albo D — współdzielony magazyn + filtr jako bramka. Najpierw „miękkie” opóźnienia, potem dopiero twarde odcięcia IP.
- Konto uprzywilejowane (admin, finanse), wysoki koszt przejęcia: progresywne opóźnienie per login jako stała baza + szybkie przejście w „challenge” (MFA, link magiczny, WebAuthn) przy podejrzanym wzorcu. Twarda blokada konta rzadko jest dobrym ruchem — zbyt łatwo zamienia się w DoS na konkretną osobę. Lepiej ograniczać tempo i wymuszać dodatkowy dowód, że to człowiek, a nie skrypt.
- Dużo fałszywych alarmów przez NAT/VPN: odpuść ostre cięcie per IP i przenieś ciężar na klucz (login+IP) oraz sygnały „spraying” (wiele loginów z jednego IP). W praktyce sprawdza się krótki block tylko wtedy, gdy IP generuje wyłącznie porażki i rotuje loginami jak w słowniku; przy ruchu mieszanym (część sukcesów) lepiej zostać przy delay.
- API/SPA bez klasycznego form login: filtry nadal działają, ale większą przewagę dają eventy i spójne odpowiedzi (ten sam kod błędu niezależnie od tego, czy użytkownik istnieje). Jeśli masz kilka kanałów logowania (hasło, SSO, link), potraktuj licznik jako „ryzyko” i aktualizuj go z jednego miejsca, zamiast powielać logikę w każdym kontrolerze.
Typowa scena z produkcji: support dostaje zgłoszenia „nie mogę się zalogować”, a w logach widać, że adres IP jest wspólny dla pół firmy. Problemem nie było hasło, tylko zbyt agresywny rate limit per IP, który zadziałał jak bramka na recepcji — zablokował wszystkich. Mini-wniosek: jeśli w Twoim świecie współdzielone IP to norma, IP nie może być jedynym kluczem do twardych blokad.
Druga częsta wpadka jest bardziej podstępna: licznik per konto bez żadnego „kontekstu” (IP/UA) pozwala zablokować dowolnego użytkownika serią błędnych prób. To działa świetnie jako narzędzie dla napastnika, a nie obrońcy. Lepsza linia obrony to miękkie spowalnianie per login (krótki TTL, reset po sukcesie) i dopiero wyżej: twarde decyzje dla (login+IP) albo dla IP, gdy widać spraying.
Jeżeli masz wybierać jedną rzecz do wdrożenia „na już”, to praktycznie wygrywa filtr-bramka + progresywne opóźnienie w oknie TTL, a do tego poprawne podpięcie inkrementacji/resetu w handlerach lub eventach. Gdy potrzebujesz bardziej stanowczej reakcji, dołóż krótkie blokady dla IP przy oczywistym spraying, a dla kont o wysokiej wartości — wymuś dodatkowy krok zamiast długiego lockout.
Dobry mechanizm anty–brute force nie udaje nieomylności: spowalnia tam, gdzie ryzyko rośnie, blokuje tylko wtedy, gdy wzorzec jest czytelny, i zostawia po sobie ślad w telemetrii. Dzięki temu da się go regulować jak zawór — bez gaszenia pożarów zgłoszeniami od użytkowników i bez „magicznych progów”, które prędzej czy później przestają pasować do ruchu.
Najczęściej zadawane pytania (FAQ)
Jak wykryć atak brute force na logowanie w Spring Security po logach?
Najczęściej zaczyna się niewinnie: w logach pojawia się „trochę więcej” nieudanych logowań, a potem nagle support dostaje zgłoszenia, że „konto nie działa”. Jeśli widzisz długą serię porażek dla jednego loginu (czasem z krótkimi pauzami), to klasyczny sygnał brute force.
Żeby to miało ręce i nogi, loguj/metrykuj spójny zestaw pól: wpisany login (nawet jeśli konto nie istnieje), IP (z uwzględnieniem proxy), User-Agent, ścieżkę/metodę, wynik (fail/success/blocked/rate-limited) i correlation ID. Mini-wniosek: bez tych danych każda „detekcja” szybko zamienia się w zgadywanie.
Czym się różni brute force od password spraying i jak to rozpoznać?
Brute force zwykle uderza w jedno konto: jeden login, dużo prób. Password spraying robi odwrotnie: mało prób per konto, za to wiele kont w krótkim czasie — dlatego prosta blokada „po N błędach na użytkownika” potrafi go kompletnie przepuścić.
W praktyce widać to tak: przy spraying rośnie liczba unikalnych loginów z błędami w krótkim oknie czasu, często z tego samego źródła ruchu (albo z kilku). Mini-wniosek: licznik tylko „per user” łapie brute force, ale może przegapić spraying; licznik tylko „per IP” bywa zbyt tępy na ataki rozproszone i NAT.
Lepiej robić limitowanie prób logowania per IP czy per konto?
Per konto jest intuicyjne, ale ma haczyk: atakujący może celowo „zablokować” ofiarę (DoS na konto), jeśli stosujesz twardą blokadę po N porażkach. Per IP jest proste operacyjnie, tylko że IP często nie oznacza jednego człowieka (NAT w firmie, sieć mobilna, wspólny VPN) i łatwo o fałszywe blokady.
Najczęściej wygrywa warstwowanie: miękko per konto (opóźnienia/backoff), twardziej per IP na głośne źródła i osobne reguły pod spraying (wiele różnych loginów w krótkim czasie). Mini-wniosek: mieszane podejście daje lepszy kompromis między UX a bezpieczeństwem.
Jak zrobić ochronę przed brute force „czysto” w Spring Security (bez WAF)?
Najprościej wpiąć logikę w punkty, które Spring Security już daje: AuthenticationFailureHandler (nieudane logowanie), AuthenticationSuccessHandler (udane) oraz eventy (np. przez ApplicationListener dla zdarzeń uwierzytelnienia). Tam aktualizujesz licznik i decydujesz, czy zwalniasz (opóźnienie), czy odrzucasz próbę (rate-limit/blocked).
To podejście jest szybkie do wdrożenia i pozwala kontrolować UX, ale wymaga dyscypliny w spójnych odpowiedziach (żeby nie zdradzać informacji). Mini-wniosek: aplikacja jest dobrym „pierwszym krokiem”, jeśli masz obserwowalność i wiesz, jak uniknąć user enumeration.
Gdzie trzymać licznik prób logowania: pamięć, Redis czy baza?
Gdy masz jedną instancję albo prototyp, in-memory z TTL bywa wystarczające: wdrożenie jest szybkie, a opóźnienia działają natychmiast. Problem pojawia się przy skalowaniu poziomym i restarcie — licznik znika albo rozmywa się między instancjami za load balancerem.
Przy wielu instancjach najczęściej wybiera się Redis z TTL, bo daje współdzielony stan i kontrolę nad oknami czasowymi. Baza też zadziała, ale łatwiej o koszty/locki przy dużej liczbie prób. Mini-wniosek: jeśli logowanie jest krytyczne i masz więcej niż jedną instancję, współdzielony magazyn (zwykle Redis) szybko przestaje być „opcją”, a staje się podstawą.
Jak uniknąć user enumeration przy blokadach i limitach logowania?
To typowy błąd: wdrażasz ochronę, a przy okazji tworzysz orakla dla atakującego. Jeśli zwracasz inny komunikat dla „użytkownik nie istnieje” i „złe hasło”, albo osobny status/treść dla „konto zablokowane”, to da się z tego wyciągać listy prawdziwych loginów.
Bezpieczniej trzymać jeden komunikat (np. „Nieprawidłowy login lub hasło”) i spójny status (zwykle 401), a informację o blokadzie kierować do kanałów wewnętrznych (logi/alerty/panel) albo ujawniać dopiero po dodatkowym kroku. Mini-wniosek: ochrona ma podnosić koszt ataku, nie poprawiać jakości rekonesansu.
Jak poprawnie brać IP, jeśli aplikacja stoi za reverse proxy (X-Forwarded-For) i nie zablokować złych osób?
Scenka z życia: w logach wszystkie próby wyglądają, jakby pochodziły z jednego IP — adresu reverse proxy. Wtedy limiter per IP staje się bezużyteczny albo wręcz szkodliwy, bo „karze” wszystkich naraz.
Rozwiązaniem jest konsekwentne uwzględnianie nagłówków typu X-Forwarded-For (albo odpowiednika z Twojej infrastruktury) i zaufanie do nich tylko wtedy, gdy pochodzą z kontrolowanego proxy/load balancera. Mini-wniosek decyzyjny: jeśli ruch idzie przez proxy/WAF, najpierw uporządkuj źródło IP — dopiero potem ustawiaj progi i blokady, inaczej wyniki będą losowe.
Co warto zapamiętać
- Atak na logowanie zwykle wychodzi dopiero „po fakcie”: rano rośnie liczba nieudanych prób, a po południu pojawiają się zgłoszenia o niedziałających kontach — dlatego pierwszy krok to obserwowalność, nie blokady w ciemno.
- Brute force i password spraying wyglądają podobnie, ale liczy się inna oś detekcji: brute force to dużo prób na jeden login, spraying to mało prób na konto, za to wiele kont w krótkim czasie; liczenie tylko „per użytkownik” albo tylko „per IP” zawsze zostawia dziurę.
- Minimum, bez którego analiza jest zgadywaniem, to spójne logi/metryki: wpisany login (nawet gdy konto nie istnieje), poprawny IP (z uwzględnieniem proxy), User-Agent, endpoint/metoda, wynik (fail/success/blocked/rate-limited) oraz correlation ID do szybkiego łączenia zdarzeń między aplikacją i proxy.
- Czerwone flagi da się złapać prostymi wykresami: nagły skok 401/403 na endpointzie logowania przy normalnym ruchu gdzie indziej, „rozlanie” prób po wielu loginach (spraying), powtarzalne sekwencje loginów oraz masowo identyczny/pusty User-Agent.
- Dobór mechanizmu obrony to trade-off UX vs bezpieczeństwo: blokada per konto jest intuicyjna, ale ułatwia DoS (ktoś może „zablokować” ofiarę błędnymi próbami), a blokada per IP jest prosta, lecz ryzykowna przy NAT/VPN — potrafi odciąć całe biuro.
- Najczęściej wygrywa warstwowanie: miękkie spowalnianie per konto (progresywne opóźnienia/backoff), twardsze limity per IP dla „głośnych” źródeł i osobne reguły pod spraying (wiele różnych loginów z jednego IP).
Bibliografia
- Spring Security Reference Documentation. VMware – Mechanizmy uwierzytelniania, filtry, handlery i zdarzenia w Spring Security
- Spring Boot Reference Documentation. Broadcom – Konfiguracja aplikacji, logowanie, filtry servletowe i integracje produkcyjne
- OWASP Cheat Sheet Series: Authentication Cheat Sheet. OWASP Foundation – Zalecenia dot. logowania, blokad, opóźnień, rate limiting i anty-enumeration
- NIST Special Publication 800-63B: Digital Identity Guidelines – Authentication and Lifecycle Management. National Institute of Standards and Technology (2017) – Wytyczne dot. uwierzytelniania, błędów logowania i odporności na ataki online
- RFC 7239: Forwarded HTTP Extension. Internet Engineering Task Force (2014) – Standard nagłówka Forwarded; poprawne ustalanie IP klienta za proxy





