„Moja apka to tylko mały sklep internetowy, kto by chciał ją atakować” – słyszałeś kiedyś podobną sentencję? Brzmi, jak słynne ostatnie słowa. 😀 W dzisiejszym świecie zdominowanym przez wymianę informacji, żaden biznes online nie może być pewny, że nie stanie się celem ataku. Dlatego w tym artykule przyjrzymy się mechanizmowi device cookie, który pozwala znacznie spowolnić ataki brute force, słownikowe i inne, wymagające ciągłego odpytywania endpointu logowania.
Kod zawarty w tym artykule znajdziecie tutaj.
Zapobieganie atakom brute force 101
Zapobieganie atakom brute force można podzielić na dwa główne problemy:
- Jak rozpoznać atak brute force?
- Jak mu zapobiegać?
Oba problemy są mocno splecione i są adresowane przez różne rozwiązania, różniące się kosztem, bezpieczeństwem i wpływem na zwykłego użytkownika. Prawdopodobnie najczęściej stosuje się po prostu „wycinanie” adresów IP, z których przychodzi zbyt dużo requestów. Nie jest to idealna metoda, ponieważ bardzo często zdarza się, że wiele osób dzieli ten sam adres IP, na przykład przez konfigurację sieci domowej, VPNa lub ISP. To zabezpieczenie jest również dość łatwe do obejścia, przez używanie różnych serwerów proxy lub sieci zainfekowanych komputerów.
Inną często stosowaną metodą jest blokada konta użytkownika, na które przeprowadzany jest atak. Ma to jednak oczywistą wadę, w postaci odcięcia dostępu do konta użytkownikowi, który jest atakowany.
Jest jeszcze captcha, jednak nie zapewnia już zbyt dobrej ochrony – istnieją już nawet firmy, które udostępniają usługi tanich pracowników w krajach trzeciego świata do masowego rozwiązywania captcha. Ponadto wymaga dodatkowej czynności użytkownika, wpływając negatywnie na user experience.
Bardziej złożone systemy wykrywania incydentów opierają się o AI i potrafią wycinać ruch oraz alertować administratorów. Są one jednak zazwyczaj drogie, a ich wprowadzenie czasochłonne i trudne.
Device cookie na ratunek
Device cookie to alternatywne rozwiązanie tego problemu, które jest tanie, łatwe do wprowadzenia i transparentne z perspektywy użytkownika. W tym mechanizmie używamy „pre-autentykacji”, żeby już klienta który próbuje się zalogować, móc wstępnie zidentyfikować jako:
- klient niezaufany, z którego użytkownik jeszcze się nie logował (nie posiada device cookie),
- klient zaufany, z którego użytkownik już się logował (posiada zbieżne z loginem, ważne device cookie).
Dzięki temu podziałowi otrzymujemy dwa główne profity:
- gdy blokujemy wszystkie klienty niezaufane, klienty zaufane mogą się logować,
- gdy blokujemy klienta zaufanego, reszta klientów zaufanych może się logować.
W przeciwieństwie do captcha czy blokowania dostępu do konta, algorytm ten jest transparentny z punktu widzenia użytkownika i zapewnia środowisko, w którym użytkownik nie jest odcinany od atakowanego konta.
Jak działa device cookie
Spróbuję wyjaśnić krótko, na czym polega algorytm. Moje umiejętności narracyjne są kontrowersyjnej jakości, więc postarałem się wzbogacić post schematami. 🙂 Poza tym, pełna implementacja algorytmu w Spring Security dostępna jest w repo wspomnianym na wstępie.
Przed uwierzytelnieniem

Na początku całego procesu, gdy system otrzymuje request uwierzytelniający, sprawdzamy obecność device cookie.
Jeśli go nie ma sprawdzamy, czy niezaufany klient z danym loginem nie jest jeszcze zablokowany. W przeciwnym razie przepuszczamy proces dalej.
Jeśli device cookie znajdziemy w requeście, login z device cookie porównujemy z loginem z request body. Jeśli są różne, traktujemy klienta jako niezaufanego. W przeciwnym razie z ciasteczka pobieramy wartość nonce, która jednoznacznie identyfikuje zaufanego klienta. Na tej podstawie sprawdzamy, czy zaufany klient jest zablokowany. W przeciwnym razie przepuszczamy proces dalej.
Jeśli okaże się, że klient jest zablokowany, powinniśmy przesłać odpowiedź zawierającą jak najmniej informacji (najlepiej tylko status 401) i identyczną z typową odpowiedzią na bad credentials.
Udane logowanie
Jeśli zastanawiacie się, skąd w ogóle miałoby się wziąć device cookie w przeglądarce użytkownika, to teraz właśnie to się dzieje. Po udanym uwierzytelnieniu, przesyłamy klientowi w odpowiedzi nowe device cookie z unikalną, wygenerowaną losowo wartością nonce.

Nieudane logowanie
W przypadku nieudanego uwierzytelnienia, dodajemy jego timestamp do listy nieudanych prób danego klienta. Tutaj ma już znaczenie, czy klient był zaufany, czy nie, bo niezaufanych klientów identyfikują loginy użytkowników, a zaufanych unikalne nonce zawarte w device cookie. Następnie przycinamy listę do okienka czasowego, które analizujemy i jeśli w tym czasie było więcej niż X requestów, dodajemy klienta do listy zablokowanych. Na przykład, jeśli zdefiniowaliśmy limit prób logowania do 5 w ciągu 30 minut, to szósty request w ciągu tych 30 minut będzie skutkował zablokowaniem klienta na założony okres czasu.
Jak zaimplementować device cookie w Spring Security
Zobaczmy teraz sposób wpięcia algorytmu we flow uwierzytelnienia Spring Security
Pre-authorization filter
Aby zweryfikować request musimy założyć filtr, który będzie działał jeszcze przed uwierzytelnieniem. W tym celu zdefiniujmy PreAuthFilter
implementujący interfejs GenericFilterBean
, a w metodzie doFilter()
wprowadźmy pierwszą część algorytmu device cookie. Najpierw upewniamy się, że w requeście jest username i czy idzie na path /login
. Robimy to, ponieważ przez filtr idą wszystkie requesty a chcemy go aktywować tylko do uwierzytelnienia:
final HttpServletRequest httpRequest = (HttpServletRequest) request; final String username = httpRequest.getParameter("username"); if (username == null || username.isBlank() || !httpRequest.getRequestURI().contains("/login")) { // if it is not a login request, skip this filter filterChain.doFilter(request, response); return; }
Jeśli wiemy już, że to request uwierzytelnienia, wyciągamy device cookie:
final Optional<String> deviceCookie = Arrays.stream(ArrayUtils.nullToEmpty(httpRequest.getCookies(), Cookie[].class)) .filter(cookie -> cookie.getName().equals(DEVICE_COOKIE_NAME)) .map(Cookie::getValue) .findFirst();
Teraz weryfikujemy, czy request jest od niezablokowanego klienta zaufanego. Jeśli tak, w metodzie proceedAsTrustedClient()
oznaczamy request jako pochodzący od zaufanego klienta i wywołujemy resztę filtrów:
// if request has a valid device cookie if (deviceCookie.isPresent() && deviceCookieService.isDeviceCookieValidFor(username, deviceCookie.get())) { final String nonce = deviceCookieService.extractNonce(deviceCookie.get()); // if trusted client identified by cookie nonce is not locked if (!deviceCookieService.isTrustedClientLocked(nonce)) { // proceed with authentication as trusted client proceedAsTrustedClient(request, response, filterChain, nonce); return; } } // else treat as untrusted client
private void proceedAsTrustedClient(ServletRequest request, ServletResponse response, FilterChain filterChain, String nonce) throws IOException, ServletException { request.setAttribute(AUTH_ALLOWED_ATTR_NAME, true); request.setAttribute(CLIENT_TRUSTED_ATTR_NAME, true); request.setAttribute(NONCE_ATTR_NAME, nonce); filterChain.doFilter(request, response); }
W przeciwnym razie sprawdzamy, czy klienty niezaufane nie zostały zablokowane. Jeśli nie, w metodzie proceedAsUntrustedClient()
oznaczamy request od niezaufanego klienta i wywołujemy resztę filtrów:
// if untrusted clients are not locked from that account if (!deviceCookieService.areUntrustedClientsLocked(username)) { // proceed with authentication as untrusted client proceedAsUntrustedClient(request, response, filterChain); return; }
private void proceedAsUntrustedClient(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException { request.setAttribute(AUTH_ALLOWED_ATTR_NAME, true); request.setAttribute(CLIENT_TRUSTED_ATTR_NAME, false); filterChain.doFilter(request, response); }
Jeśli doszliśmy do tego momentu to znaczy, że request pochodzi od zablokowanego klienta, więc rzucamy wyjątek, blokując uwierzytelnienie:
// else throw exception, blocking authentication request.setAttribute(AUTH_ALLOWED_ATTR_NAME, false); throw new AuthenticationAttemptsLockedException( String.format("Authentication attempts for login: %s are locked", username));
Login success handler
Po udanym zalogowaniu musimy wygenerować nowe device cookie i zwrócić je klientowi, musimy więc zaimplementować AuthenticationSuccessHandler
:
@RequiredArgsConstructor class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler { private final DeviceCookieService deviceCookieService; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { response.addCookie(new Cookie("device", deviceCookieService.generateDeviceCookieFor(authentication.getName()))); response.setStatus(OK.value()); } }
Login failure handler
Po nieudanym uwierzytelnieniu musimy odnotować tę próbę, więc potrzebujemy zaimplementować AuthenticationFailureHandler
:
@RequiredArgsConstructor class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler { private final DeviceCookieService deviceCookieService; @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) { if ((Boolean) request.getAttribute(AUTH_ALLOWED_ATTR_NAME)) { if ((Boolean) request.getAttribute(CLIENT_TRUSTED_ATTR_NAME)) { deviceCookieService.reportTrustedClientLoginFailure((String) request.getAttribute(NONCE_ATTR_NAME)); } else { deviceCookieService.reportUntrustedClientLoginFailure(request.getParameter("username")); } } response.setStatus(UNAUTHORIZED.value()); } }
Wpięcie w Spring Security
Filtr i dwa handlery musimy jeszcze wpiąć we flow uwierzytelnienia. Możemy to zrobić w konfiguracji web security:
@Configuration @EnableWebSecurity @RequiredArgsConstructor public class WebSecurityConfig extends WebSecurityConfigurerAdapter { private final DeviceCookieService deviceCookieService; // … @Override protected void configure(final HttpSecurity http) throws Exception { http .addFilterBefore(new PreAuthFilter(deviceCookieService), UsernamePasswordAuthenticationFilter.class) .failureHandler(new CustomAuthenticationFailureHandler(deviceCookieService)) .successHandler(new CustomAuthenticationSuccessHandler(deviceCookieService)) // … } }
Device cookie service
Serwis obsługujący device cookie jest już dość prosty, orkiestruje tylko encjami klientów i dwoma providerami, NonceProvider
i CookieProvider
:
@Service @RequiredArgsConstructor class DeviceCookieServiceImpl implements DeviceCookieService { private final DeviceCookieProperties properties; private final TrustedClientRepository trustedClientRepository; private final UntrustedClientRepository untrustedClientRepository; private final CookieProvider cookieProvider; private final NonceProvider nonceProvider; @Override public boolean isDeviceCookieValidFor(String login, String deviceCookie) { if (!cookieProvider.isCookieValid(deviceCookie)) { return false; } DeviceCookie cookie = cookieProvider.decodeCookie(deviceCookie); return cookie.getLogin().equals(login); } @Override public String generateDeviceCookieFor(String login) { String nonce = nonceProvider.generate(properties.getNonceLength()); return cookieProvider.encodeCookie(new DeviceCookie(login, nonce)); } @Override public String extractNonce(String deviceCookie) { return cookieProvider.decodeCookie(deviceCookie).getNonce(); } @Override public boolean isTrustedClientLocked(String nonce) { Optional<TrustedClient> trustedClient = trustedClientRepository.findById(nonce); return trustedClient.isPresent() && trustedClient.get().isLocked(); } @Override public boolean areUntrustedClientsLocked(String login) { Optional<UntrustedClient> untrustedClient = untrustedClientRepository.findById(login); return untrustedClient.isPresent() && untrustedClient.get().isLocked(); } @Override public void reportTrustedClientLoginFailure(String nonce) { TrustedClient trustedClient = trustedClientRepository.findById(nonce) .orElse(TrustedClient.newInstance(nonce)); trustedClient.registerFailedLoginAttempt(properties); trustedClientRepository.save(trustedClient); } @Override public void reportUntrustedClientLoginFailure(String login) { UntrustedClient untrustedClient = untrustedClientRepository.findById(login) .orElse(UntrustedClient.newInstance(login)); untrustedClient.registerFailedLoginAttempt(properties); untrustedClientRepository.save(untrustedClient); } }
Za długo byłoby omawiać tutaj szczegóły implementacyjne providerów czy encji klientów, ich implementację znajdziecie w repo podlinkowanym we wstępie.
Podsumowanie
Mechanizm device cookie jest na pewno ciekawą i przydatną rzeczą, zwłaszcza, jeśli zdarzy się Wam tworzyć lub rozwijać własny system uwierzytelnienia. Więcej znajdziecie w originalnym poście o device cookie na stronie OWASP.