JWT – a miało być bezstanowo

koddlo.pl 11 miesięcy temu

Bezstanowość to podstawowa cecha dobrze przemyślanego API. Nie bez przyczyny jest jedną z sześciu zasad REST. Ma pozytywny wpływ na takie czynniki jak skalowalność, niezawodność, prostotę, buforowanie i kilka innych. Tworząc API, pierwszy bój z bezstanowością prawdopodobnie przyjdzie Wam stoczyć podczas implementacji uwierzytelniania. Klasyczna sesja odpada. Standardem stało się już wykorzystywanie do tego celu JWT (JSON Web Token).

Tokeny JWT faktycznie są bezstanowe. Generowane są po stronie serwera, ale przechowywane po stronie klienta. Token jest poprawny tak długo, aż jego sygnatura jest zgodna, a jego żywotność nie ulenie przedawnieniu. Brzmi świetnie. Tylko jak w takim razie unieważnić już istniejący token? Zaraz postaram się Wam pokazać, dlaczego to tak istotne. Będę stanowczy. Osobiście nie znam rozwiązania, które pozwoli unieważnić JWT bez wprowadzenia pewnej stanowości.

JWT, a wylogowanie użytkownika

Pierwszym i chyba najprostszym przypadkiem jest prośba użytkownika o wylogowanie go z aplikacji. Czy wystarczy porzucić jego token do momentu, aż sam wygaśnie? Rzadko kiedy jest to pożądane rozwiązanie. W tym scenariuszu, o ile użytkownik świadomie chce się wylogować z aplikacji, wystarczyłoby zapomnieć token po jego stronie. Ewentualnie unieważnić token służący do odświeżania (refresh token), o ile istnieje tego rodzaju mechanizm. Co prawda, użytkownik osiągnie swój cel, ale prawdopodobnie nie zakłada, iż jego token jest wciąż ważny. Gdyby ktoś inny wszedł w jego posiadanie to przez cały czas mógłby korzystać z aplikacji.

Faktycznie, w niektórych aplikacjach porzucenie tokena może okazać się wystarczające. To z kolei pozwoli zachować wspomnianą bezstanowość. o ile szukacie kompromisu to nic nie stoi na przeszkodzie, by tylko niektóre akcje (te wymagające specjalnego bezpieczeństwa) były pilnowane w nadzwyczajny sposób w oparciu o stan.

Dla mnie bezpieczeństwo ma zawsze bardzo wysoki priorytet, dlatego nie idę na tego typu kompromisy.

Jednym ze sposobów na unieważnienie tokenów jest stworzenie listy zablokowanych (blacklist). Ale zaraz… czy w tym momencie nie wracamy do punktu wyjścia. Trzeba będzie przy każdym żądaniu odpytać bazę. Zgadza się. Sugeruję sięgnąć po coś szybszego. Bardzo często będzie to Redis, który idealnie pasuje do takich zadań. Wystarczy dodać zbanowany token do wspomnianej listy i określić jego czas życia (ttl) zgodnie z długością życia tokena. Dla uproszczenia może to zawsze być maksymalna jego długość. Taka implementacja wprowadza stanowość, ale wspomniałem już iż nie potrafię inaczej rozwiązać tego problemu. W praktyce ogranicza się to do trzymania tylko niedozwolonych tokenów, a nie wszystkich. Blacklist zamiast Whitelist. To z kolei ogranicza się do znacznie mniejszej liczby przetrzymywanych tokenów. Mała uwaga: nazywam to tokenami dla uproszczenia, ale prawdopodobnie do Redisa czy innego źródła danych lepiej nie wrzucać całych tokenów JWT, a ich unikalny identyfikator.

Odświeżanie JWT

Sam mechanizm odświeżania tokena często będzie wiązał się z wprowadzeniem stanowości. Koncept regeneracji tokena, w dużym skrócie, polega na tym iż podczas uwierzytelniania zwracane są dwa tokeny. Ten drugi posłuży do przegenerowania pierwszego, wygasłego tokena. W innym wypadku, użytkownik musiałby ponownie się uwierzytelnić. To z kolei nie mogłoby odbyć się automatycznie. Wyobraźcie sobie, iż właśnie poświęciliście kilkanaście minut na uzupełnienie formularza, a po jego zatwierdzeniu zostaliście przekierowani do strony logowania. Dokładnie tak wyglądało moje zakładanie firmy przez internet. Tyle tylko, iż mój formularz został wysłany anonimowo, więc musiałem jechać do urzędu go podpisać.

Jeżeli w tym momencie pomyśleliście o ustawieniu żywotności tokena na pierdyliard minut to powinniście się wstydzić. To słabe rozwiązanie. Prędzej, czy później trzeba więc pomyśleć o odświeżaniu tokenów. Wspomniałem, iż samego JWT nie trzeba trzymać po stronie serwera. Analogicznie dla refresh tokena można ponownie wykorzystać JWT. Tym razem z dłuższym czasem życia.

Unieważnienie tokenów JWT

Niestety, pojawia się kolejny problem. Jest kilka operacji, które powinny unieważnić oba tokeny. Zmiana hasła może być dobrym przykładem. Skoro tokeny JWT mogą być unieważniane dzięki adekwatnej listy to jeszcze raz można wykorzystać tę implementację. Podczas próby odświeżenia tokena, poza jego poprawnością należy sprawdzić, czy nie został on zbanowany. Jak wspomniałem, taka implementacja wprowadza stanowość. W tym wypadku tylko dla jednego żądania – odświeżanie tokena.

Podobny efekt można uzyskać zapisując token do odświeżania w bazie danych. W takim wypadku bardzo łatwo można go unieważnić. Konsekwencje są podobne, a tylko żądania odświeżenia tokena są bardziej kosztowne. Takie rozwiązanie może okazać się pomocne, gdy nie macie w projekcie Redisa, czy analogicznego źródła danych. Ponownie wspomnę, iż czarna lista zawsze zajmie mniej miejsca, niż biała. Przełoży się to oczywiście na wydajność samej walidacji tokena. Przy dużej skali może mieć to znaczenie, bo adekwatny JWT jest sprawdzany podczas każdego żądania, a refresh token podczas odświeżania.

Przy okazji, długość życia JWT powinna być rozsądnie krótka, co w połączeniu z mechanizmem jego odświeżania nie powinno stanowić problemu. W zależności od systemu, najczęściej zakładam wartości z przedziału 5-15 minut. Refresh token także może dostać się w niepożądane ręce, dlatego nie należy trzymać go ważnego w nieskończoność. zwykle kilka godzin będzie w punkt.

Mam nadzieję, iż czytacie ze zrozumieniem i wyłapaliście małą nieścisłość. W trakcie zmiany hasła przez użytkownika wysyła on żądanie udekorowane JWT. Skąd więc wziąć token do odświeżania, który należy dodać do listy zbanowanych? No właśnie. Często istnieje też potrzeba dorzucenia kilku innych mechanizmów. Być może okazało się, iż zostaliście zhakowani. Funkcja „zmień hasło” lub „wyloguj ze wszystkich urządzeń” nie może po prostu powodować porzucenia tokenów albo ich unieważnienia. Powinna zagwarantować, iż wszystkie istniejące tokeny dla użytkownika przestaną być odpowiednie. Przypominam, iż samo żądanie posiada tylko jeden konkretny JWT, a nie wszystkie istniejące.

Jak można rozwiązać powyższy problem? Naturalnie trzeba mieć powiązanie między użytkownikiem, a jego tokenami. W przypadku, gdy zapiszecie token do odświeżania w bazie danych okaże się to prostsze, ale mniej „pożądane rozwiązanie”. W każdym razie, jeżeli użytkownik zażądał wylogowania ze wszystkich urządzeń, wystarczy usunąć wszystkie jego refresh tokeny. Niestety, przy każdym żądaniu trzeba się upewnić, iż istnieje odpowiedni refresh token. A co jeżeli użytkownik chce się wylogować z konkretnego urządzenia? Do tego celu trzeba byłoby zapisać powiązanie tokena z refresh tokenem. Nie różni się to przecież aż tak bardzo od klasycznej sesji. Nie brzmi to jak idealne rozwiązanie, ale ma ono jeden plus. Działa.

Wspomniałem jednak, iż powiązanie między użytkownikiem, a tokenami musi istnieć. Nie ma innej drogi. Skoro pogodziliście się już ze stanowością to czas zastanowić się nad możliwie najlepszym rozwiązaniem. Popularnym podejściem do tego problemu jest zapisanie użytkownikowi znacznika czasu (timestamp) określającego od którego momentu token jest obowiązujący albo jakiegoś sekretu. Niezależnie od sposobu, parametr ten trzeba będzie zapisać po stronie klienta i serwera. Przy każdym generowaniu nowego tokena timestamp lub sekret zapisywane są w ładunku JWT. Przy każdym żądaniu najpierw sprawdzany jest sam token. o ile jest poprawny (zgodnie z tym co pisałem wcześniej) to dodatkowo trzeba się upewnić, czy timestamp/sekret się zgadza. W momencie akcji, która powinna unieważnić wszystkie tokeny (na przykład zmiana hasła) wystarczy przegenerować timestamp/sekret. Spowoduje to, iż wszystkie dotychczasowe tokeny przestaną działać.

Ostatnim tematem jest inwalidacja wszystkich istniejących tokenów. To rzadkie wymaganie, ale gdyby była taka potrzeba to najprościej będzie przegenerować klucz szyfrujący podpis. Opcjonalnie można wprowadzić wersjonowanie tokenów, a podbicie wersji powoduje, iż istniejące przestają działać.

JWT jednak nie jest bezstanowy

Jak widzicie, choćby o ile sam JWT jest bezstanowy to próba utrzymania tej cechy powoduje obniżenie bezpieczeństwa. Jak zwykle, wszystko zależy od czynników sterujących decyzjami w projekcie. Personalnie, zapomnienie tokena tylko po stronie klienta mnie nie zadowala. Trzeba jednak pamiętać, dlaczego bezstanowość przy tworzeniu API jest tak istotna. Okazuje się, iż dobrze zaimplementowane rozwiązania wspomniane w tym materiale wcale nie muszą generować problemów typowych dla stanowości. Może nie warto próbować osiągać bezstanowości za wszelką cenę?

Wpis JWT – a miało być bezstanowo pojawił się pierwszy raz pod Koddlo.

Idź do oryginalnego materiału