Wykrywanie i zapobieganie atakom brute force na logowanie w Spring Security

0
70
Rate this post

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

Z tej publikacji dowiesz się:

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

CechaA: in-memory w aplikacjiB: aplikacja + Redis/bazaC: edge/WAFD: hybryda
Skalowanie poziomeSłabeDobreBardzo dobreBardzo dobre
Kontrola per konto (login)DobraBardzo dobraOgraniczonaBardzo dobra
Ochrona zasobów aplikacjiŚredniaŚredniaBardzo dobraBardzo dobra
Ryzyko false positives (NAT/proxy)Niskie (dla per konto), zależne od IPNiskie/średnie (zależne od kluczy)Średnie/wysokieŚrednie (łatwiej skorygować w aplikacji)
Złożoność wdrożeniaNiskaŚredniaŚredniaWyższa
Obserwowalność i audytŚredniaDobraŚrednia/dobraBardzo 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