Przejdź do treści

Token JWT — rozważania o bezpieczeństwie na przykładzie AWS Cognito

Maciej Filipiak 6 min czytania

  • JWT
  • AWS Cognito
  • Bezpieczeństwo
Trójwymiarowy napis JWT otoczony elementami połączeń sieciowych

Token JWT (JSON Web Token) jest obecnie jednym z najpopularniejszych sposobów autoryzacji połączeń pomiędzy aplikacjami. Najczęściej kojarzony jest z uwierzytelnianiem i autoryzacją użytkownika w komunikacji między frontendem a backendem. Mnogość dostępnych bibliotek ułatwia tworzenie endpointów API, które odrzucają połączenie w przypadku problemów z tokenem JWT.

Czy jednak samo wykorzystanie tokena JWT gwarantuje bezpieczeństwo? Niestety, istnieje wiele aspektów, które należy zrozumieć, aby w pełni zabezpieczyć swoją aplikację. W tym artykule przyjrzymy się potencjalnym zagrożeniom wynikającym z niewłaściwego użycia tokenów JWT oraz praktykom, które należy stosować, aby maksymalnie zwiększyć bezpieczeństwo aplikacji korzystających z tego rozwiązania.

Ufaj, ale sprawdzaj

Weryfikacja tokenów, które trafiają do API, wydaje się truizmem, ale zdziwisz się, jak często ten aspekt bywa ignorowany. Skąd to wiem? Chociażby z niektórych internetowych tutoriali, w których do wygenerowania tokena wykorzystywany jest „losowy ciąg znaków” (o zgrozo, przechowywany w zmiennej w pamięci aplikacji). O ile taki ciąg znaków możemy pobrać z repozytorium, do którego mają dostęp tylko zaufane strony komunikacji — albo samo API, gdy weryfikuje powracający token, który wcześniej wysłało do aplikacji przeglądarkowej. Sprawa komplikuje się, gdy nasz klient to inny mikroserwis, który nie ma dostępu do naszego repozytorium sekretów. Biblioteki do obsługi JWT oferują zazwyczaj dwie metody do sprawdzenia tokena — decode(), która pozwoli nam sprawdzić, jakie role są przypisane do użytkownika, oraz verify(), która weryfikuje podpis i sprawdzi, czy token nie wygasł. Nie zapominajmy, że w naszym Guardzie trzeba użyć obu tych metod.

Przykład w klasycznym podejściu, gdy współdzielimy sekrety lub korzystamy z nich tylko w jednym miejscu:

const jwt = require('jsonwebtoken');

const secret = 'your-256-bit-secret'; // <-- get secret from storage

// token generation
const payload = { sub: '1234567890', name: 'John Doe', admin: true };
const token = jwt.sign(payload, secret, { algorithm: 'HS256', expiresIn: '1h' });

Lepszym i zalecanym rozwiązaniem jest bazowanie na kluczu prywatnym i publicznym. Generując token JWT, podpisz go swoim kluczem prywatnym, a do jego weryfikacji udostępnij każdemu, kto odczuje taką potrzebę, klucz publiczny.

const privateKey = fs.readFileSync('path/to/private.key', 'utf8');

const payload = {
  sub: '1234567890',
  name: 'John Doe',
  admin: true
};

// Generowanie tokenu
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256', expiresIn: '1h' });

Weryfikacja tokena:

const publicKey = .... // read from public, shared repository

function verifyToken(token) {
  try {
    const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
    console.log('Token is valid:', decoded);
    return decoded;
  } catch (err) {
    console.error('Token verification failed:', err);
    return null;
  }
}

Taki klucz usługa AWS Cognito udostępnia pod adresem URL dedykowanym dla naszej puli użytkowników, o czym powiemy za chwilę.

Kodowanie to nie szyfrowanie!

Pamiętajmy, żeby w tokenie JWT nie przechowywać poufnych informacji. Klucz (czy tam „ciąg znaków”) nie szyfruje informacji, a jedynie je podpisuje. Osoba przechwytująca token może nie tylko przejąć naszą tożsamość, ale też bez problemu odczyta informacje w nim zawarte — zwykle są to informacje o rolach przypisanych do użytkownika, ale często też dane quasi-osobowe, np. adres e-mail. Poza standardowymi informacjami, jakie muszą znaleźć się w tokenie JWT (chociażby data jego wygaśnięcia czy informacja o przyjętym algorytmie kodowania), możemy w nim umieścić dowolne dane dodatkowe, aby poinformować aplikacje w przeglądarce, jak ma się zachować wobec użytkownika (tzw. claims).

Podobną strukturę danych może wygenerować złośliwa osoba, wstrzykując ją do nagłówka HTTP i uzyskać w ten sposób dostęp do API. Jedynym sposobem, aby zapobiec takiemu atakowi, jest weryfikacja podpisu w tokenie.

AWS Cognito — zaczyna się zabawa

Cognito to usługa chmury AWS, która dostarcza rozwiązania do zarządzania użytkownikami. Dla naszej aplikacji możemy zdefiniować dedykowaną pulę użytkowników, po czym wdrożyć system logowania i zarządzania hasłami. Dodatkowo Cognito oferuje weryfikację urządzeń, z których loguje się użytkownik, logowanie 2FA oraz uwierzytelnienie za pośrednictwem portali społecznościowych z wykorzystaniem protokołu OAuth.

Po uwierzytelnieniu użytkownika w AWS Cognito dostajemy token JWT (a raczej trzy tokeny — o czym za chwilę). Możemy wykorzystać je na własne potrzeby lub do połączenia z innymi usługami chmury AWS (jak np. dostęp do S3, API Gateway itp.).

Jest wiele sposobów integracji aplikacji frontendowej z usługami Cognito — szczegóły konfiguracji połączeń nie są tematem niniejszego artykułu, jednak rozpatrzymy dwa przypadki integracji, po czym zastanowimy się nad tym, jaki ogarnąć fakt, że tokeny są aż trzy.

Bezpośrednie połączenie frontendu z AWS Cognito

Jeżeli się na to zdecydujemy — usługa AWS Cognito dostarcza gotowy interfejs do obsługi logowania. Mamy tu wszystko, co powinno się znaleźć w nowoczesnym serwisie internetowym. Użytkownik może odzyskać hasło, zarejestrować urządzenie, wylogować się ze wszystkich urządzeń. Nie musimy martwić się o weryfikacje SMS-em, MFA, TOTP, OAuth… W tym modelu integracji użytkownik zostanie przeniesiony na chwilę na stronę AWS, a usługa chmurowa zadba o wyświetlenie właściwych formularzy, po czym użytkownik zostanie przekierowany z powrotem na naszą stronę.

Rozwiązanie jest bardzo wygodne — jednak niepokój budzi tutaj fakt, że wszystkie trzy tokeny JWT trafiają do aplikacji frontendowej.

To jest ten moment, żeby wspomnieć, dlaczego tokeny JWT są aż trzy. Zwykle słyszymy o jednym albo dwóch. Po udanym uwierzytelnieniu serwer AWS Cognito zwraca nam obiekt z trzema tokenami:

  • Access Token służy do autoryzacji dostępu do zasobów, zwłaszcza niektórych usług AWS (API Gateway, S3, Lambda, ELS, Kubernetes, AppSync itp.), jeżeli tylko pozwoli na to zdefiniowana wcześniej polityka IAM.
  • ID Token służy do przechowywania danych użytkownika, takich jak login, id użytkownika w puli użytkowników (User Pool), opcjonalne dane zdefiniowane dla puli (numer telefonu, data urodzenia, płeć, imię, nazwisko, URL do zdjęcia itd.) oraz niestandardowe claims specyficzne dla aplikacji (custom claims).
  • Refresh Token to token z dłuższym czasem życia, służy do odświeżenia Access Tokenu.

Aby nasza aplikacja mogła korzystać z usług AWS, w nagłówku HTTP powinien być załączony Access Token — to on umożliwi nam dostęp do zasobów chmury. Nie ma potrzeby informować serwerów po stronie AWS, jakie uprawnienia ma użytkownik, gdyż wskazuje na nie polityka IAM przypisana do tokenu użytkownika.

Wiedzy o uprawnieniach użytkownika nie ma natomiast frontend — pewne widoki i działania aplikacji przeglądarkowej mogą być zależne od roli użytkownika lub innych informacji zawartych w ID Token. ID Token w tym przypadku przydaje się na frontendzie.

Czy jesteśmy bezpieczni?

Jak się domyślasz — nie do końca. Potencjalny „włamywacz”, jeżeli przejmie Access Token — praktycznie przejmuje dostęp do wszystkich zasobów użytkownika. Jeżeli zostaliśmy „podsłuchani”, możemy liczyć na to, że ochroni nas czas życia tokena. Jeżeli jednak włamywacz dotrze do zasobów przeglądarki (np. http-only cookies, local storage itp.), w których aplikacja frontendowa zdecydowała się przechowywać Refresh Token, wtedy nawet przysłowiowe wyłączenie wtyczki nas nie uratuje.

Połączenie frontend → API → AWS Cognito

Zdecydowanie bezpieczniejszym (choć nie idealnym) rozwiązaniem jest przejęcie ciężaru autoryzacji i uwierzytelnienia przez serwer API, który możemy wstawić pomiędzy frontend a usługi AWS. Możemy tu skorzystać z API, jakie dostarcza nam AWS Cognito, ale rezygnujemy z gotowych formularzy logowania i odzyskiwania hasła. Musimy zadbać sami zarówno o zbudowanie serii kontrolek po stronie frontendu, jak i o obsługę komunikacji z API Cognito za pomocą AWS SDK.

Wszystkie trzy tokeny przechowujemy wówczas w lokalnej bazie danych, dbając o to, żeby niepotrzebne tokeny usuwać. W ten sposób tworzymy zarządzanie sesją użytkownika po stronie backendu. Jest to trochę smutne, ponieważ tokeny JWT miały właśnie pomóc nam zastąpić wzorce projektowe z epoki stron pisanych w PHP. Bądźmy rozsądni, poza tym drobnym wyjątkiem API nadal pozostaje bezstanowe :)

Zwróćmy uwagę na fakt, że nie musimy już przesyłać Access Tokenu w nagłówku HTTP. W zupełności wystarczy, że prześlemy ID Token, który potencjalnemu włamywaczowi na wiele się nie przyda. Nie udostępniamy też na zewnątrz Refresh Tokena, co mocno poprawia bezpieczeństwo naszej aplikacji. Nasze API musi w takiej sytuacji stanowić proxy nie tylko dla AWS Cognito, ale i dla pozostałych zasobów AWS.

Świadomość zagrożeń to połowa sukcesu

Mam nadzieję, że ten artykuł przybliżył Wam podstawowe zasady działania i zabezpieczania tokenów JWT, zwłaszcza w kontekście AWS Cognito. Warto jednak pamiętać, że temat bezpieczeństwa JWT jest znacznie bardziej rozbudowany i istnieje wiele innych aspektów, które należy wziąć pod uwagę. Zachęcam do dalszego zgłębiania tematu oraz regularnego audytu bezpieczeństwa Waszych aplikacji.

Dziękuję za przeczytanie artykułu i życzę powodzenia w implementacji bezpiecznych rozwiązań z wykorzystaniem JWT!

Masz podobne wyzwanie?

Porozmawiajmy o Twoim systemie. Konsultacje są bezpłatne.

Umów bezpłatną konsultację