JWT - JSON Web Token - okładka

JWT – JSON Web Token – mega piguła wiedzy

Opublikowano Kategorie SecurityCzas czytania 18min

JSON Web Token (w skrócie JWT) to obecnie często spotykane rozwiązanie. JWT wykorzystuje się do transferu danych między podmiotami, szczególnie w celach uwierzytelnienia i autoryzacji. W tym artykule rozłożę go na części pierwsze. Dowiesz się o najważniejszych aspektach tego standardu, pułapkach, jakie w nim czyhają na nieostrożnych programistów oraz dostaniesz garść przydatnych wskazówek jak lepiej korzystać z JWT.

Wymowa i nazewnictwo

Schody z JWT zaczynają się już przy samej wymowie i nazewnictwie. Gdy po raz pierwszy to usłyszałem, to byłem co najmniej zaskoczony, ale poprawna wymowa JWT brzmi „dżot” (ang. jot). Informację o tym można znaleźć nawet w RFC 7519 opisującym standard JWT.

The suggested pronunciation of JWT is the same as the English word „jot”.

Kiedyś usłyszałem, że niepoprawna wymowa świadczy o braku profesjonalizmu i uważam to za bzdurę. W komunikacji chodzi o to, by się rozumieć. Nie twierdzę jednak, że nie warto się starać. Zdecydowanie warto spróbować wyplenić ze swojej mowy takie kwiatki jak dżej-wi-ti. W wymowie JWT powszechnie można usłyszeć również dżej-dablju-ti i myślę, że zdecydowana większość rozmówców zrozumie, o czym mowa, szczególnie że według RFC, „jot” to jedynie sugestia.

Pułapką, zarówno w mowie, jak i piśmie, jest fraza „JWT token”. Słowo „token” jest już zawarte w JWT, więc nie ma konieczności duplikowania go.

Struktura JWT

JWT składa się z trzech elementów:

  • nagłówek w formacie JSON zawierający algorytm oraz typ tokena;
  • ładunek w formacie JSON, czyli zawartość tokena (payload) zawierający dane do przesłania;
  • sygnatura podpisująca token i pozwalająca na weryfikację jego autentyczności.

JWT jest wysyłany jako ciąg znaków zakodowany w standardzie kodowania base64url. Standard base64url od klasycznego base64 różni to, że base64 wykorzystuje do kodowania niektóre znaki zarezerwowane. Znak "+" zastąpiony jest znakiem "-", znak "/" znakiem "_", znaki "=" (często występujące na końcu wartości zakodowanych w base64) mogą zostać usunięte oraz nie pozwala się na wykorzystanie separatorów nowej linii. Zastosowanie takiego standardu kodowania umożliwia przesyłanie tokenów z wykorzystaniem protokołu HTTP, np. jako wartość nagłówka czy query param. Każda część JWT jest odseparowana kropką. Przykładowy JWT w formie zakodowanej wygląda następująco.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Po rozkodowaniu części tokenu zawierającej nagłówek i ładunek można odczytać dane tokena.


{
  "alg": "HS256",
  "typ": "JWT"
}
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

Próba zdekodowania sygnatury nie pozwoli na uzyskanie interesujących rezultatów. Do generowania, enkodowania i dekodowania JSON Web Tokenów możesz wykorzystać serwis jwt.io, który dostarcza m.in. wygodny w użyciu debugger.

JWT debugger

Klucze obiektów z nagłówkiem i ładunkiem nazwane zostały w RFC mianem JWT Claims. W JWT jedynym obowiązkowym claimem jest "alg" definiujący algorytm kryptograficzny wykorzystany do podpisania tokena. Wszystkie pozostałe claimy są nieobowiązkowe.

Claimy zdefiniowane w RFC 7519 noszą miano Registered Claim Names. Wykorzystanie ich w sposób przewidziany w RFC pozwala zachować zgodność ze standardem. Zwiększa to komfort wykorzystania tokenów i ich interoperacyjność. Również biblioteki implementujące JWT wykorzystują Registered Claim Names np. do weryfikacji czy dany token wygasł. Jeśli aplikacja przetwarzająca tokeny wymaga przekazania określonych claimów, to ich walidacja powinna być zaimplementowana na poziomie tej aplikacji, np. z wykorzystaniem wspomnianych już bibliotek.

Lista zdefiniowanych claimów jest jednak znacznie dłuższa i można ją znaleźć na stronie Internet Assigned Numbers Authority. W tym artykule skupię się tylko na tych wymienionych w RFC. Jeśli po lekturze artykułu poczujesz niedosyt, to odsyłam Cię do podlinkowanej strony, gdzie znajdziesz pełną listę.

Nagłówek

Nagłówek powinien zawierać metadane związane z identyfikacją typu danych czy zastosowanym algorytmem kryptograficznym. Nagłówki związane z zabezpieczeniem i opisaniem tokena noszą miano JOSE Headers (JavaScript Object Signing and Encryption). RFC 7519 definiuje następujące JOSE Headers dla JWT:

  • "typ" – definiuje typ przekazanej wartości. W przypadku JWT wartością będzie "JWT". Przekazanie typu jest opcjonalne, ale jeśli jest zaimplementowane, rekomendowane jest przekazanie wartości "JWT" pisanej wielkimi literami ze względu na kompatybilność. Zasadniczo, zarówno klucze, jak i wartości w JWT są case-sensitive.
  • "cty" – typ danych wykorzystywany w przypadku tokenów wykorzystujących zagnieżdżone podpisywanie lub szyfrowanie. W przypadku pozostałych tokenów, wykorzystanie tego claima nie jest rekomendowane. Dla wskazanych przypadków wymagane jest przekazanie tego nagłówka z wartością "JWT";

Istnieje także dość obszerna lista claimów zdefiniowanych w RFC 7515. Dokument ten opisuje JSON Web Signature (JWS), czyli JSON Web Token podpisany z wykorzystaniem zdefiniowanego algorytmu. Oznacza to, że JWT w najpopularniejszym tego pojęcia rozumieniu jest tak naprawdę JWS. Do najciekawszych JOSE Headers związanych z JSON Web Signature należą:

  • "kid" – identyfikator klucza wykorzystanego do podpisania tokena. Parametr przydaje się, gdy w systemie istnieje wiele kluczy służących do podpisywania tokenów;
  • "alg" – definiuje algorytm wykorzystany do podpisywania tokenów;
  • "jwk" – przechowuje klucz publiczny wykorzystany do podpisania JWS. Strukturę klucza opisuje RFC 7517; Do tematu JWK jeszcze wrócimy.
  • "jku" – URI, które kieruje do klucza publicznego służącego do weryfikacji tokena.

Algorytm

Claim "alg" definiujący algorytm jest na tyle istotny, że warto poświęcić mu dłuższą chwilę. W celu wygenerowania poprawnego JWT wymagane jest przekazanie jednego ze wspieranych algorytmów lub wartości "none". Listę sugerowanych algorytmów również można znaleźć na stronie IANA. Do najpopularniejszych algorytmów należą:

  • HMAC z SHA-256, SHA-384 lub SHA-512 (HS256, HS384, HS512). Wykorzystywane jest tutaj szyfrowanie symetryczne i wymagany jest pojedynczy secret służący zarówno do podpisywania, jak i do weryfikacji autentyczności tokena;
  • RSASSA-PKCS1-v1_5 z SHA-256, SHA-384 lub SHA-512 (RS256, RS384, RS512). RSASSA-PKCS1-v1_5 wykorzystuje kryptografię asymetryczną, przez co konieczne jest posiadanie pary kluczy — publicznego i prywatnego. Klucz publiczny pozwala na zweryfikowanie autentyczności tokena, a prywatny pozwala na jego podpisanie;
  • ECDSA z SHA-256, SHA-384 lub SHA-512 (ES256, ES384, ES512). Sytuacja wygląda analogicznie do algorytmu RSASSA-PKCS1-v1_5.

Odpowiednie żonglowanie algorytmem podpisującym w przeszłości bywało wektorem ataku pozwalającym na obejście ominięcie weryfikacji tokena w wybranych bibliotekach implementujących JWT. Więcej o tych problemach możesz dowiedzieć się z CVE-2015-9235 oraz CVE-2016-10555. Algorytmom wykorzystywanym przez JWS, JWE i JWK poświęcono osobny dokument RFC 7518.

Szczególną uwagę należy zwrócić na wartość "none". Wartość ta oznacza, że token jest niepodpisany, przez co nie można zweryfikować jego autentyczności. Próba przekazywania tokenów z wartością "none" stanowi wektor ataku na aplikacje niepoprawnie obsługujące tokeny z tą wartością. Jeśli aplikacja traktuje takie tokeny jako poprawne, to możliwe staje się wygenerowanie dowolnego, poprawnego tokena. Jednoznacznie wskazuje na istnienie podatności w infiltrowanej aplikacji. Jeśli token służy np. do określania roli użytkownika, to stanowi to otwartą furtkę do eskalacji uprawnień.

JSON Web Key (JWK)

JSON Web Key to struktura danych reprezentująca klucz kryptograficzny, który jest wykorzystywany do podpisywania tokenów.

Możliwe jest skorzystanie zarówno z jednego klucza, jak również tablicy kluczy. Dla definicji JWK w RFC opisane zostały następujące claimy:

  • "kty" – definiuje rodzinę algorytmów użytą do celów kryptograficznych. Zdefiniowanie claima "kty" jest obowiązkowe;
  • "use" – definiuje przeznaczenie klucza. RFC definiuje dwie sugerowane wartości – "signature" dla JWS i "encryption" dla JWE (o którym później);
  • "key_opts" – definiuje operacje, dla których klucz ma zostać użyty;

Przeznaczenie claimów "alg" czy "kid" jest analogiczne, do tego, co zostało opisane wcześniej. Jedynym wyjątkiem jest to, że dla JWK "alg" jest opcjonalnym claimem.

W zależności od wybranej rodziny algorytmów pozostałe claimy będą wyglądać różnie. Myślę, że nie ma sensu w szczegółach opisywać ich wszystkich, a po szczegóły odsyłam Cię do RFC 7517. Pozostawię tu dwa przykłady, jak mogą wyglądać przykładowe JWK. Przykłady pochodzą ze wspomnianego RFC.


{
  "kty":"EC",
  "crv":"P-256",
  "x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
  "y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
  "kid":"kid"
}

Dla wielu kluczy należy przekazać je w pod kluczem "keys" a przykładową strukturę przedstawia poniższy przykład.


{
  "keys": [
    {
      "kty":"EC",
      "crv":"P-256",
      "x":"MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4",
      "y":"4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM",
      "use":"enc",
      "kid":"1"
    },
    {
      "kty":"RSA",
      "n": "kEgU8awapJzKnqDKgw",
      "e":"AQAB",
      "alg":"RS256",
      "kid":"2011-04-29"
    }
  ]
}

Ładunek

W tej części token może przechowywać dane wymagane przez używającą go aplikację. RFC 7519 definiuje następujące Registered Claim Names:

  • "iss" – pod tą wartością przechowywany jest emitent (issuer) tokena;
  • "sub" – podmiot wykorzystujący token. Może to być np. użytkownik, do którego należy token;
  • "aud" – odbiorca tokena;
  • "exp" – data wygaśnięcia ważności tokena. Standard zakłada, że token z wartością "exp" wskazującą na punkt w czasie w przeszłości powinien zostać odrzucony przez aplikację. Standard dopuszcza zastosowanie progu tolerancji dla tej wartości nieprzekraczającego kilka minut. Wartym zwrócenia uwagi jest fakt, że oczekiwane jest przekazanie daty w formacie NumericDate, czyli liczby sekund (nie milisekund!) od 1970-01-01T00:00:00Z UTC. Przekazanie wartości w milisekundach jest dość prostym do popełnienia błędem, którego skutkiem będzie generowanie tokenów ważnych… kilkadziesiąt tysięcy lat;
  • "nbf" – pozwala na zdefiniowanie, od kiedy ważny ma być token. Tokeny, które jeszcze nie są ważne, powinny zostać odrzucone przez aplikację. Pozostałe wytyczne pozostają takie same jak w przypadku "exp";
  • "iat" – data utworzenia tokena w formacie NumericDate;
  • "jti" – unikalny identyfikator JWT. Przykładowym przypadkiem użycia jest zapobiegnięcie przed użyciem danego tokena więcej niż raz.

W tej części również spodziewane jest przekazywanie wszelkich dodatkowych danych, jakie potrzebuje aplikacja używająca tokena. Mogą to być np. dane identyfikujące użytkownika, jego rola w systemie czy jakiekolwiek inne dane. Przykładowy use-case możesz znaleźć w dokumentacji dla konfiguracji funkcji Real-Time Collaboration w edytorze CKEditor 5. Na podstawie danych przekazanych w tokenie definiowany jest użytkownik łączący się do dokumentu oraz jego rola i uprawnienia.


{
    "aud": "environmentId",
    "iat": 1701000780,
    "sub": "userId1",
    "user": {
        "email": "[email protected]",
        "name": "John Doe",
        "avatar": "https://i.pravatar.cc/300"
    },
    "auth": {
        "collaboration": {
            "*": {
                "role": "writer"
            }
        }
    }
}

W przedstawionym przykładzie każdy environment pozwala na wygenerowanie kluczy służących do podpisywania tokenów. Dzięki temu każdy klient jest w stanie podpisywać tokeny indywidualnym kluczem. Następnie przy uwierzytelnianiu i autoryzacji token przekazany przy połączeniu do sesji kolaboracyjnej jest walidowany pod kątem sygnatury, bazując na liście kluczy dla danego środowiska dostępnych w systemie.

Sygnatura

Tokeny w postaci przedstawionej postaci zabezpieczone są przez mechanizm cyfrowego podpisu. Tak jak już wspomniałem, próby zdekodowania części tokena przechowującej sygnaturę w sposób analogiczny do pozostałych części JWT nie doprowadzi do ciekawych rezultatów. W przypadku przekazania wartości "none" dla claima "alg" nic nie jest podpisane, więc w tokenie w znika część zawierająca sygnaturę.

JSON Web Encryption (JWE)

Ponieważ w JWT dane są jedynie kodowane i podpoisywane problematyczne staje się przesyłanie w nim danych poufnych. Każdy, kto jest w stanie przechwycić token, jest w stanie rozkodować go i odczytać jego zawartość. Alternatywą dla JSON Web Signature pozwalającą na szyfrowanie ładunku jest JSON Web Encryption. JWE zostało opisane w RFC 7516.

W przeciwieństwie do JWS próby rozkodowania ładunku będą skutkowały niepowodzeniem. Dane zawarte w tokenie są zaszyfrowane, a następnie zakodowane w base64url. Jedynie nagłówek będzie możliwy do podglądu.

Do claimów możliwych do zdefiniowania w nagłówku dochodzi kilka nowych:

  • "enc" – definiuje algorytm używany do szyfrowania ładunku. Sugerowana lista dostępnych algorytmów definiowana jest przez IANA „JSON Web Signature and Encryption Algorithms”. Zdefiniowanie claima "enc" jest obowiązkowe;
  • "zip" – definiuje czy zawartość ma zostać skompresowana przed zaszyfrowaniem. Wartość sugerowana przez RFC to "DEF" odwołująca się do algorytmu DEFLATE.

Zastosowanie pozostałych JOSE Headers pokrywa się z ich zastosowanie w JWS.

Dość istotną różnicą między JWS a JWE jest różnica w strukturze tokena. JWE składa się nie z trzech a z pięciu częsci:

  • JWE Protected Header – nagłówek zawierający JOSE headers. Umożliwia on także przechowywanie Additional Authenticated Data (AAD), czyli danych, które chcesz chronić bez ich szyfrowania. Wykorzystanie AAD pozwala zweryfikować integralność danych i upewnić się, że nie zostały one zmodyfikowane podczas transmisji.
  • JWE Content Encryption Key (CEK) – klucz używany do zaszyfrowania ładunku JWE. Klucz jest unikalny dla każdego wygenerowanego tokenu;
  • JWE Initialization Vector (IV) – jest to wektor inicjujący wykorzystywany w niektórych trybach szyfrowania blokowego. Jeśli wybrany algorytm nie wymaga IV, to ten blok może być pusty;
  • JWE Payload – przechowuje zaszyfrowany ładunek;
  • JWE Authentication Tag – wykorzystywany do sprawdzenia integralności danych w AAD oraz ładunku podczas deszyfrowania tokena.

Wykorzystanie w praktyce — JavaScript

W środowisku JavaScript do pracy z JWT do najpopularniejszych bibliotek należą josejsonwebtoken. Listę rekomendowanych bibliotek dla innych języków znajdziesz na jwt.io. Mimo że jsonwebtoken w statystykach wykorzystania wypada lepiej i jest rozwiązaniem dostarczonym przez Auth0, to jose wyróżnia się na plus względem konkurenta liczbą zależności, która wynosi okrągłe zero. Do przygotowania prostej aplikacji służącej tworzeniu i weryfikacji tokenów wykorzystam jsonwebtoken. Po instalacji i zaimportowaniu zależności przydadzą się metody signverify. Przy podpisywaniu tokenów wymagane jest podanie wartości sekretu dla algorytmów symetrycznych lub klucza prywatnego dla algorytmów asymetrycznych. Przy przekazywaniu sekretów pamiętaj, by nie przechowywać ich jawnie w kodzie. Sekrety powinny być przekazywane dynamicznie np. z wykorzystaniem zmiennych środowiskowych.

Uważaj też na kopiowanie fragmentów kodów z blogów czy Stack Overflow zawierających zaszyte wartości sekretów. Błędem zdarzającym się wśród początkujących programistów jest bezmyślne kopiowanie takich kodów i używaniu ich w aplikacji bez uprzedniej podmiany wartości sekretu. W takim przypadku powstaje w niej podatność, gdyż wykorzystany sekret jest publiczny i łatwo może zostać odnaleziony w Internecie. Takie publicznie dostępne klucze mogą się również znajdować w słownikach wykorzystywanych przez testerów bezpieczeństwa i cyberprzestępców. Pamiętaj również, by wykorzystany sekret był odpowiednio trudny do znalezienia z wykorzystaniem ataków brutalnych czy słownikowych.


import jwt from 'jsonwebtoken';

const token = jwt.sign(
    { data: 'foobar' },
    process.env.JWT_SECRET
);

console.log( token ); // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjoiZm9vYmFyIiwiaWF0IjoxNzAxMDExNDgyfQ.SC_owZNN2u6sXcjAXNgizflFdn4ugktJSbaN3a53kC4

Domyślnie token podpisany jest algorytmem HS256. Jeśli chcemy to zmienić, to należy wykorzystać options jako trzeci parametr metody sign. W options można skonfigurować znacznie więcej, w tym wartości dla Registered Claim Names. Po pełną listę opcji odsyłam Cię do dokumentacji.

Weryfikacja tokenów

Weryfikacja tokenów odbywa się równie łatwo. Co ważne, weryfikacja tokenów nie powiedzie się dla niepodpisanych tokenów, nawet jeśli jawnie zostanie dodany "none" do listy algorytmów przy weryfikacji.


import jwt from 'jsonwebtoken';

const token = jwt.sign(
    { data: 'foobar' },
    process.env.JWT_SECRET
);

const tokenNone = jwt.sign(
    { data: 'foobar' },
    process.env.JWT_SECRET,
    { algorithm:'none' }
);

console.log( jwt.verify( token, process.env.JWT_SECRET ) );
console.log(
    jwt.verify( tokenNone, process.env.JWT_SECRET )
); // JsonWebTokenError: jwt signature is required
console.log(
    jwt.verify( tokenNone, process.env.JWT_SECRET ),
    { algorithms: [ 'none' ] }
); // still error

Weryfikacja JWT polega nie tylko na sprawdzeniu poprawności sygnatury, ale też weryfikacji wartości w Registered Claim Names. Jeśli token np. wygasł lub został przekazany przed datą zdefiniowaną w "nbf" to rzucony zostanie odpowiedni błąd. Metoda oprócz weryfikacji JWT zwraca jego zawartość. Jeśli potrzeba jedynie odczytać zawartość tokena, to można w tym celu wykorzystać metodę decode.


import jwt from 'jsonwebtoken';

const token = jwt.sign(
    { data: 'foobar' },
    process.env.JWT_SECRET
);

console.log( jwt.decode( token ) );

Chcąc akceptować niepodpisane tokeny, pozostaje wykorzystanie metody decode. Zmniejsza to ryzyko wprowadzenia błędów bezpieczeństwa związanych z brakiem weryfikacji podpisu.

Praktyczne porady

Na sam koniec, bazując na tym, co zostało przedstawione w artykule, zostawiam garść porad, które pomogą Ci bezpieczniej pracować z JWT:

  • JWT domyślnie nie definiuje czasu życia tokena. Jeśli w Twojej aplikacji czas życia tokena jest istotny, to pamiętaj by go zdefiniować;
  • JWT nie dostarcza mechanizmów do unieważniania wygenerowanych tokenów. Jeśli potrzebujesz wycofać utworzony token, to konieczne będzie obsłużenie tego z poziomu aplikacji. W tym celu możesz wykorzystać mechanizm blacklist zawierający listę unieważnionych tokenów lub mechanizm whitelist zawierający listę dozwolonych tokenów. W pewnych przypadkach alternatywą może być generowanie tokenów z krótkim czasem życia;
  • w kontekście powyższych informacji, zastanów się, czy JWT jest odpowiednim rozwiązaniem dla Ciebie. Na przykład w przypadku mechanizmów uwierzytelniania być może w Twoim przypadku lepszy będzie klasyczny mechanizm sesji użytkownika. Nie zawsze warto iść w to, co jest „fajne i modne”. Nie bez powodu KISS jest jedną z podstawowych dobrych praktyk programistycznych;
  • przy uwierzytelnianiu i autoryzacji JWT zwykle przesyłany jest w formie nagłówka HTTP Authorization z wartością Bearer: <twój_token>;
  • intencjonalnie nie skupiłem się na opisaniu szczegółów implementacyjnych związanych z implementacją JWT. Jestem zwolennikiem korzystania z gotowych rozwiązań, nawet jeśli czasami zdarzają się w nich podatności. Uważam, że podobnie jak z algorytmami szyfrującymi i hashującymi, tworzenie własnych rozwiązań do obsługi JWT to prawie zawsze będzie zły pomysł. Słowo „prawie” napisałem z ostrożności;
  • nie pozwalaj na wykorzystanie "none" jako wartość claima "alg". Tokeny z taką wartością powinny być odrzucane;
  • warto pamiętać, że ważne i poprawne tokeny, które wyciekły mogą posłużyć do podszycia się pod innego użytkownika w aplikacji i wykonywania akcji w jego imieniu;
  • tokeny podpisuj kluczem prywatnym lub odpowiednio silnym sekretem. Sekret nie powinien być łatwy do odgadnięcia z wykorzystaniem słowników i ataku brutalnego;
  • jeśli token zawiera dane poufne, to rozważ wykorzystanie z JSON Web Encryption.

Podsumowanie

Wiem, że ten artykuł to potężna piguła wiedzy i nowych informacji. Dlatego też nie przejmuj się, jeśli nie wszystko udało Ci się zapamiętać. Daj znać w komentarzu czy udało Ci się czegoś ciekawego dowiedzieć. Zachęcam też do podania dalej tego artykułu, by dotarł do jak największej liczby osób. Koniecznie zajrzyj też do źródeł, w szczególności do RFC.

Dołożyłem wszelkich starań, by w artykule pojawiły się wyłącznie informacje prawdziwe i potwierdzone. Jeśli jednak widzisz jakiś błąd lub nieścisłość, to koniecznie daj mi o tym znać. Twórzmy razem bezpieczniejszy software! 🙂

Źródła i materiały dodatkowe

Dominik Szczepaniak

Zawodowo Senior Software Engineer w CKSource. Prywatnie bloger, fan włoskiej kuchni, miłośnik jazdy na rowerze i treningu siłowego.

Inne wpisy, które mogą Cię zainteresować

Zapisz się na mailing i odbierz e-booka

Zapisując się na mój mailing, otrzymasz darmowy egzemplarz e-booka 106 Pytań Rekrutacyjnych Junior JavaScript Developer! Będziesz też otrzymywać wartościowe treści i powiadomienia o nowych wpisach na skrzynkę e-mail.

Subscribe
Powiadom o
guest

0 komentarzy
Inline Feedbacks
View all comments