Strona główna / Artykuły

Tworzenie gotowej do użycia w produkcji autoryzacji JWT w API Node.js

Jak haszować hasła, wydawać krótkotrwałe JWT-y, dodawać tokeny odświeżania i mechanizmy cofnięcia uprawnień, oddzielać autoryzację od uwierzytelniania, chronić przed atakami siłowymi oraz sprawdzać, czy autoryzacja zawodzi prawidłowo.

Wydawca: REACTAPP.TOP13 min czytania2888 słów

Źródła: JWT Authentication in Node.js: What I Learned Building Secure APIs

W dokumencie opisującym wymagania autoryzacja mieści się w jednej linijce: użytkownik loguje się za pomocą adresu e-mail i hasła, a API udziela dostępu. Gdy tylko ją wdrożysz, ta jedna linijka zamienia się w tuzin pytań. Gdzie przechowywane są hasła i jak są sprawdzane? Jak API rozpoznaje żądającego przy każdym zapytaniu? Co się dzieje, gdy token wygaśnie, jak funkcjonują tokeny odnowienia i co system powinien zrobić po serii nieudanych prób logowania? U podstaw wszystkich tych pytań leży to najważniejsze: jak zapobiec temu, by sam system logowania stał się najslabszym ogniwem aplikacji?

Ten przewodnik omawia te kwestie w kontekście API opartego na Node.js i TypeScript z bazą danych PostgreSQL, traktując autoryzację nie jako pojedynczy punkt końcowy, lecz jako warstwę bezpieczeństwa otaczającą całe aplikację. Pod koniec uzyskasz jasny obraz każdej warstwy, potencjalne zagrożenia w nich występujące oraz listę kontrolną pomagającą określić, kiedy system jest gotowy do użycia w produkcji.

Zachowuj hashe haseł, nigdy same hasła

Pierwszą zasadą jest to, że hasło nie jest zwykłymi danymi. Nigdy nie powinno być przechowywane w formie tekstowej. Rekord użytkownika taki jak poniżej jest niedopuszczalny, ponieważ jedynie wyciek z bazy danych mogłby ujawnić hasła wszystkich użytkowników, a ze względu na ponowne używanie haseł mogłoby to również ujawnić ich konta w innych miejscach.

email: sachin@example.com
password: MyPassword123

Zamiast tego należy przeprowadzić każde hasło przez celowo wolny algorytm haszowania przed jego zapisaniem. Za pomocą pakietu bcrypt importuje się odpowiednią bibliotekę:

import bcrypt from "bcrypt";

i haszuje się hasło podczas rejestracji. Drugi argument, 12, to współczynnik kosztu: każde zwiększenie tego wartości podwaja ilość wymaganej pracy, co sprawia, że próby odgadnięcia hasła bez połączenia z siecią są znacznie trudniejsze, przy jednoczesnym zachowaniu wystarczającej szybkości dla pojedynczego logowania.

const passwordHash = await bcrypt.hash(password, 12);

Podczas logowania nie odbywa się żadne dekodowanie. Serwer bierze hasło, które właśnie podał użytkownik, i pyta bcrypt, czy pasuje ono do zapisanego haszu. Funkcja bcrypt.compare odczytuje sól i współczynnik kosztu zawarte w haszu, powtarza obliczenia i zwraca wartość logiczną.

const isValid = await bcrypt.compare(
  password,
  user.passwordHash
);

Kluczowa różnica polega na tym, że hasła są hashowane, a nie szyfrowane w celu późniejszego odzyskania. Po zakończeniu rejestracji twoja aplikacja nie powinna nigdy potrzebować ani być w stanie odzyskać oryginalnego hasła. Dwie praktyczne uwagi: bcrypt bierze pod uwagę tylko pierwsze 72 bajty danych wejściowych, więc bardzo długie hasła są cicho skracane, a algorytmy takie jak Argon2 stanowią popularną alternatywę, jeśli chcesz opcję wymagającą dużych zasobów pamięci.

Traktuj logowanie jako proces pracy, a nie zapytanie

Typowy punkt końcowy do logowania obejmuje serię kroków, od otrzymania żądania przez weryfikację, wyszukiwanie, porównanie aż po tworzenie tokena:

Client
   ↓
POST /api/auth/login
   ↓
Validate input
   ↓
Find user
   ↓
Compare password
   ↓
Create authentication tokens
   ↓
Return authentication response

Ważne jest to, że każdy krok może się nie powieść, a na każdą taką sytuację konieczna jest świadoma reakcja. E-mail może być nieprawidłowo sformatowany – żaden użytkownik może się z nim nie pokrywać. Hasło może być błędne, konto może być zablokowane, a baza danych może być niedostępna. To proces wymagający środków bezpieczeństwa, dlatego każda z jego gałęzi powinna być odpowiednio zaprojektowana, rejestrowana i testowana.

Czym jest JWT i czym nie jest

Tokeny JSON Web to powszechny sposób reprezentowania autoryzowanego identyfikatora w API. Po udanym logowaniu serwer wydaje podpisany token opisujący użytkownika. Minimalna zawartość takiego tokena może obejmować jedynie identyfikator użytkownika i jego rolę; sub to standardowy element służący do reprezentacji identyfikatora użytkownika.

{
  "sub": "user-id",
  "role": "user"
}

Ładunek jest kodowany, a nie szyfrowany, więc każdy, kto posiada token, może go odczytać. Powinien zawierać jedynie identyfikatory i ogólne informacje, nigdy tajemnice ani dane osobowe. W kolejnych żądaniach klient dołącza token do nagłówka Authorization przy użyciu schematu Bearer:

Authorization: Bearer <access-token>

Zanim serwer udostępni jakikolwiek zasób chroniony, sprawdza podpis i termin ważności tokena. Ogólna sekwencja wygląda w ten sposób:

Login
  ↓
Credentials verified
  ↓
Access token issued
  ↓
Client sends token
  ↓
Server verifies token
  ↓
Protected API is allowed

Kluczowym wnioskiem jest to, że sam JWT nie czyni aplikacji bezpieczną. Bezpieczeństwo zależy w równym stopniu od sposobu tworzenia tokena, miejsca przechowywania go przez klienta, stopnia jego weryfikacji (włączając określenie akceptowanego algorytmu podpisywania), terminu jego ważności, możliwości odwołania go oraz sposobu przesyłania przez sieć (zawsze przez HTTPS).

Zachowuj krótki czas życia tokenów dostępu

Tokeny dostępu nie powinny być ważne w nieskończoność. Jeśli token ważny na kilka miesięcy wycieknie z pliku logów lub ze skompromitowanego urządzenia, atakujący może go używać przez cały ten czas.

Poniższy przykład wykorzystuje jsonwebtoken do podpisywania tokena zawierającego ID i rolę użytkownika za pomocą sekretu odczytanego z środowiska, a także ustawia expiresIn na piętnaście minut. Znak ! po process.env.JWT_SECRET to operacja stwierdzania, że zmienna nie jest null w TypeScript; w kodzie produkcyjnym bezpieczniej jest sprawdzić na starcie istnienie tej zmiennej, zamiast robić to przy każdym wywołaniu.

const accessToken = jwt.sign(
  {
    sub: user.id,
    role: user.role
  },
  process.env.JWT_SECRET!,
  {
    expiresIn: "15m"
  }
);

Odpowiednia długość ważności zależy od wymagań bezpieczeństwa, ale zasada jest wszędzie ta sama: ogranicz czas, przez jaki jakiekolwiek uprawnienie umożliwiające dostęp do zasobów chronionych pozostaje użyteczne. Krótki okres oznacza, że skradziony token szybko traci wartość.

Tokeny odnowienia rozwiązują problem użyteczności

Jeśli tokeny dostępu wygasały co piętnaście minut, użytkownicy musieliby ciągle ponawiać logowanie, co nikt nie toleruje. Tokeny odświeżania rozwiązują ten problem. Podczas logowania klient otrzymuje dwa identyfikatory: krótkotrwały token dostępu używany w wywołaniach API oraz dłużej trwający token odświeżania, którego jedynym celem jest uzyskanie nowego tokena dostępu po wygaśnięciu starego.

┌─────────────────┐
             │      Login      │
             └────────┬────────┘
                      ↓
             ┌─────────────────┐
             │ Access Token    │
             │ Short Lifetime  │
             └────────┬────────┘
                      │
                 API Requests
                      │
                      ↓
              Token expires
                      │
                      ↓
             ┌─────────────────┐
             │ Refresh Token   │
             │ Longer Lifetime │
             └────────┬────────┘
                      ↓
             New Access Token

Taki podział pozwala, aby tokeny dostępu pozostały krótkie, a sesje wydawały się ciągłe. Stwarza to jednak nowe obowiązki, ponieważ token odświeżania jest teraz cenniejszym elementem uwierzytelniającym. Musi być przechowywany ostrożnie (zwykle w pliku cookie o atrybutach httpOnly i Secure w przeglądarce, a nie w pamięci dostępnej z JavaScript), wysyłany wyłącznie do endpointu odświeżania oraz ściśle weryfikowany przez serwer. Aby zapoznać się z bardziej szczegółową dyskusją na temat projektowania, sprawdź nasz przewodnik dotyczący strategii tokenów odświeżania w systemach uwierzytelniania Node.js.

Plan odwołania

Jedną z cech samodzielnych tokenów, która dziwi wiele zespołów, jest to, że pozostają one ważne nawet po zmianach na serwerze. Weźmy pod uwagę użytkownika, który się wylogowuje, administratora, który dezaktywuje konto, token odświeżania, o którym wiadomo, że został skompromitowany, lub incydent bezpieczeństwa wymagający zakończenia wszystkich sesji. Jeśli projekt nie zawiera mechanizmu odwołania, usunięcie lub aktualizacja wiersza w bazie danych nie ma żadnego wpływu na już wydany token; będzie on funkcjonować aż do upływu terminu ważności.

Z tego powodu wiele systemów autoryzacji przechowuje na stronie serwera rekordy tokenów odświeżania lub sesji, zazwyczaj po jednym na urządzenie, powiązane z każdym użytkownikiem:

User
 ├── Refresh Token A
 ├── Refresh Token B
 └── Refresh Token C

Gdy ta informacja jest już dostępna, serwer może oznaczyć poszczególne tokeny jako anulowane, zakończyć wszystkie sesje danego użytkownika oraz odmówić wydania nowych tokenów dostępu na podstawie anulowanego tokena odnowienia. To pokazuje konieczność kompromisu: JWTs zmniejszają ilość informacji o stanie sesji, które musi przechowywać serwer, ale bezpieczna autoryzacja zazwyczaj nadal wymaga pewnych danych na stronie serwera. Krótki czas trwania tokenów dostępu w połączeniu z tokenami odnowienia, które można anulować, to powszechny sposób na zrównoważenie tych dwóch aspektów.

Wymuszanie autoryzacji w middleware

Powtarzanie sprawdzania tokenów w każdym kontrolerze jest podatne na błędy; jedno zapomniane sprawdzenie może pozostawić punkt końcowy nieszyfrowany. Middleware centralizuje tę logikę. W Express ochroniona trasa po prostu umieszcza funkcję authenticate przed swoim kontrolerem:

app.get(
  "/api/profile",
  authenticate,
  profileController
);

To middleware ma prosty, dobrze zdefiniowany zakres obowiązków:

  1. Czyta nagłówek Authorization.
  • Wyodrębnij token z wartości Bearer.
  • Zwaliduj podpis oraz datę ważności.
  • Określ, który użytkownik reprezentuje token.
  • Połącz tę tożsamość z obiektem żądania dla późniejszych procesorów.
  • Odrzuć żądanie z błędem, jeśli którykolwiek krok się nie powiedzie.
  • Wynikowy przepływ sterowania jest łatwy do zrozumienia: nieważny lub brakujący token powoduje błąd 401, a tylko ważny token trafia do kontrolera.

    Request
       ↓
    Authentication Middleware
       ↓
    Token valid?
      ↙       ↘
     No        Yes
     ↓          ↓
    401       Controller
                 ↓
              Response
    

    Autoryzacja i uwierzytelnianie to różne procedury weryfikacji

    Te dwa terminy są często używane zamiennie, ale odpowiadają na różne pytania. Autoryzacja to sprawdzenie, kim jest osoba próbująca uzyskać dostęp. Upoważnienie to określenie tego, co ta osoba ma prawo robić. Nasz artykuł na temat miejsca, w którym autoryzacja i upoważnienie powinny znajdować się w kodzie omawia ten temat bardziej szczegółowo.

    Załóżmy aplikację z trzema rolami:

    Admin
    Staff
    User
    

    Członkowie wszystkich trzech ról mogą być w pełni autoryzowani, ale ich uprawnienia znacznie się różnią. Zwykły użytkownik może mieć prawo do czytania swojego profilu, przeglądania problemów oraz tworzenia nowych:

    GET /api/profile
    GET /api/issues
    POST /api/issues
    

    Administrator może dodatkowo mieć uprawnienie do modyfikacji kont użytkowników oraz usuwania problemów:

    PATCH /api/users/:id
    DELETE /api/issues/:id
    

    Zachowanie ważnego JWT potwierdza jedynie tożsamość; nie daje uprawnień do wykonywania każdej operacji. Bezpieczny backend wymaga obu warstw – zazwyczaj middleware do autoryzacji, po którym następują sprawdzenia ról lub uprawnień dla poszczególnych tras, a idealnie także sprawdzenia prawa własności – aby użytkownik mógł edytować swoje własne problemy, ale nie te należące do kogoś innego.

    Ochrona przed powtarzającymi się nieudanymi loginami

    Projekt autoryzacji musi uwzględniać atakujących, a nie tylko prawowitych użytkowników. Skrypt automatyczny może bez końca wysyłać próby hasł do momentu, aż coś go zatrzyma. Najprostszą formą obrony jest licznik, który po kilku kolejnych nieudanych próbach uruchamia tymczasowe zablokowanie:

    Attempt 1 → Failed
    Attempt 2 → Failed
    Attempt 3 → Failed
    Attempt 4 → Failed
    Attempt 5 → Failed
                  ↓
              Temporary lock
    

    W praktyce należy połączyć kilka środków w zależności od wymagań:

    • Ograniczenie liczby żądań do punktu logowania.
    • Tymczasowe zamykanie kont.
  • Stopniowo rosnące opóźnienia po każdej nieudanej próbie.
  • Nadzór w celu wykrycia podejrzanych wzorców.
  • Ochrony oparte na adresie IP.
  • Alerty dotyczące zdarzeń istotnych dla bezpieczeństwa.
  • Należy dokonać starannego wyboru, ponieważ środek obrony może stać się narzędziem do ataków typu denial-of-service. Jeśli ktoś może zablokować konto po prostu poprzez pięciokrotne błędne wpisanie adresu e-mail, atakujący mogą celowo wykluczyć prawdziwych użytkowników. Celem nie jest jedynie blokowanie po nieudanych próbach, ale także sprawienie, by automatyczne ataki były kosztowne, przy jednoczesnym umożliwieniu prawdziwym użytkownikom dostępu – na przykład poprzez połączenie opóźnień dla poszczególnych kont z ograniczeniami szybkości dla poszczególnych adresów IP, zamiast polegać wyłącznie na całkowitym blokowaniu.

    Nigdy nie ufać identyfikatorowi dostarczonemu przez klienta

    Niebezpiecznym podejściem jest wierzenie w dane tylko dlatego, że zostały one przesłane przez interfejs użytkownika. Załóżmy, że ciało żądania podaje identyfikator użytkownika i jego rolę:

    {
      "userId": "123",
      "role": "admin"
    }
    

    Nic nie powstrzymuje złośliwego klienta przed edytowaniem tych pól. Serwer nie powinien nigdy zakładać, że są one prawdziwe. Tożsamość musi pochodzić od danych uwierzytelniających, które sam serwer zweryfikował, co oznacza, że identyfikator użytkownika i jego rola są odczytywane z zweryfikowanego tokena (lub z bazy danych przy użyciu informacji zawartych w tokenie), a sprawdzanie uprawnień opiera się na tych danych:

    Request
       ↓
    Verify JWT
       ↓
    Read authenticated user ID
       ↓
    Check permissions
       ↓
    Access resource
    

    Serwer jest ostateczną instancją decyzyjną. Ta zasada ma zastosowanie nie tylko w zakresie uwierzytelniania: klient może poprawić doświadczenie użytkownika, ukrywając przyciski, których ten nie może używać, ale nigdy nie można na nim polegać w egzekwowaniu zasad bezpieczeństwa.

    Bезpieczeństwo dotyczy również takich szczegółów jak sformułowanie komunikatów błędów. Porównaj odpowiedź, która informuje o braku istnienia adresu e-mail:

    Email does not exist.
    

    z taką, która celowo pozostaje niejasna:

    Invalid email or password.
    

    Pierwszy z nich informuje atakującego, które adresy e-mail są zarejestrowane, co umożliwia wyliczanie kont i ułatwia celowane próby odgadnięcia haseł. Drugi daje prawowitemu użytkownikowi możliwość ponownej próby bez konieczności potwierdzania czegokolwiek. Należy pamiętać, że wyliczanie kont może również odbywać się poprzez czas odpowiedzi lub za pomocą punktów końcowych do rejestracji i resetowania haseł, więc te same zasady obowiązują również w tych przypadkach.

    Ogólniejsza zasada obejmuje również błędy w bazie danych oraz nieobsłużone wyjątki. Klienci potrzebują błędu, na który mogą zareagować; nie potrzebują ścieżki stosu, komunikatu SQL ani wewnętrznych identyfikatorów.

    Zachowuj tajemnice z dala od kodu źródłowego

    Autoryzacja zależy od tajemnic, takich jak klucz podpisywania, ciąg połączenia do bazy danych oraz klucze API dostawców zewnętrznych:

    JWT_SECRET
    DATABASE_URL
    API_KEY
    

    Nigdy nie należy ich komitować do repozytorium. Takie hardkodowane hasło stanowi zagrożenie w momencie, gdy ktokolwiek inny może przeczytać kod:

    const secret = "my-super-secret-key";
    

    Czytanie go z środowiska to minimalna poprawa:

    const secret = process.env.JWT_SECRET;
    

    W środowisku produkcyjnym należy posunąć się dalej i dostarczać tajne dane za pośrednictwem odpowiedniego systemu konfiguracji lub zarządzania sekretami. Jest to szczególnie ważne w przypadku publicznych repozytoriów, gdzie przypadkowe komitowanie hasła może niemal natychmiast doprowadzić do poważnego incydentu; w takiej sytuacji należy zastąpić tajne dane, a nie tylko usunąć komit.

    Testowanie poprawnego zawodzenia autoryzacji

    Testy ręczne nie nadają się do sprawdzania autoryzacji, ponieważ istotne są przypadki, w których autoryzacja nie powinna się udać. Podstawowy zestaw testów automatycznych powinien obejmować zarówno ścieżki udane, jak i odrzucone we wszystkich etapach procesu.

    Rejestracja

    Prawidłowe rejestracje są przyjmowane, natomiast błędnie sformatowane adresy e-mail, nieważne dane wejściowe oraz duplikatowe konta są odrzucane:

    Valid registration → Success
    Invalid email → Rejected
    Weak/invalid input → Rejected
    Duplicate account → Rejected
    

    Logowanie

    Prawidłowe dane logowania są akceptowane; błędne hasła, nieznane konta oraz konta zablokowane są odrzucane:

    Correct password → Success
    Wrong password → Rejected
    Unknown account → Rejected
    Locked account → Rejected
    

    Ochronione trasy

    Brakujące, nieważne lub wygasłe tokeny powodują błąd 401, ważny token umożliwia dostęp, natomiast ważny token bez wymaganej roli powoduje błąd 403. Należy zwrócić uwagę na różnicę: 401 oznacza „nie wiemy, kim jesteś”, natomiast 403 oznacza „wiemy, kim jesteś, ale odpowiedź brzmi nie”.

    No token → 401
    Invalid token → 401
    Expired token → 401
    Valid token → Allowed
    Valid token + insufficient role → 403
    

    Takie testy robią więcej niż tylko potwierdzają, że autoryzacja działa. Pokazują one, w jaki właściwy sposób może ona zawieść, a właśnie tam kryją się większość rzeczywistych wrażliwości.

    Kierunkowy model autoryzacji

    Pomaga wyobrazić sobie autoryzację jako szereg oddzielnych warstw, z których każda ma jedną funkcję – od uwierzytelniania poprzez weryfikację haseł, wydawanie tokenów, walidację i autoryzację tokenów aż po sam zasób chroniony:

    ┌─────────────────────────────┐
    │       User Credentials      │
    └──────────────┬──────────────┘
                   ↓
    ┌─────────────────────────────┐
    │     Password Verification   │
    └──────────────┬──────────────┘
                   ↓
    ┌─────────────────────────────┐
    │       Token Issuance        │
    └──────────────┬──────────────┘
                   ↓
    ┌─────────────────────────────┐
    │      Token Validation       │
    └──────────────┬──────────────┘
                   ↓
    ┌─────────────────────────────┐
    │       Authorization         │
    └──────────────┬──────────────┘
                   ↓
    ┌─────────────────────────────┐
    │       Protected Resource    │
    └─────────────────────────────┘
    

    Wokół całego tego układu znajduje się warstwa wspólna dla wszystkich elementów: monitorowanie, logowanie, ograniczanie szybkości żądań, bezpieczna konfiguracja oraz testowanie.

    Lista kontrolna gotowości do produkcji

    Zanim uznamy system autoryzacji za kompletny, upewnij się, że:

    • Haseła są haszowane za pomocą silnego, wolnego algorytmu.
    • Tokeny dostępu mają odpowiednio krótki okres ważności.
    • Tokeny odnowienia są przechowywane, przekazywane i walidowane w sposób bezpieczny.
    • Każda chroniona ścieżka sprawdza autoryzację.
    • Autoryzacja jest realizowana jako odrębny krok od procesu uwierzytelniania.
  • Próby nieudanego logowania są ograniczane, bez konieczności stosowania nadmiernych mechanizmów blokady.
  • Haseła nie znajdują się w kodzie i są przechowywane w bezpieczny sposób.
  • Logi nie zawierają haseł, tokenów ani innych wrażliwych danych.
  • Odpowiedzi na błędy nie ujawniają zbędnych informacji wewnętrznych.
  • Procesy autoryzacji są sprawdzane za pomocą testów automatycznych.
  • Nieważność tokenów i sesji jest zaprojektowana od razu, a nie odkładana.
  • Konfiguracja w środowisku produkcyjnym została przeanalizowana.
  • Ta lista stanowi znacznie lepszy wskaźnik niż prosty sprawdzenie, czy logowanie działa.

    Główne wnioski

    Autoryzacja to nie coś, co można dodać na końcu projektu. Ona wpływa na projekt bazy danych, strukturę API, middleware, zachowanie interfejsu użytkownika, obsługę błędów, logowanie, testy i wdrażanie, a przede wszystkim na bezpieczeństwo. Koniec punktu logowania zajmuje zaledwie kilka linijek kodu; natomiast system, który zachowuje się bezpiecznie w obliczu rzeczywistych użytkowników, atakujących, wygasłych tokenów, błędnie sformatowanych żądań oraz awarii w produkcji, stanowi znacznie większy problem inżynieryjny.

    Następnym naturalnym obszarem do zgłębienia jest bezpieczeństwo tokenów odświeżania: gdzie powinny być przechowywane, czy i w jaki sposób należy je rotować, jak wykryć ponowne użycie starego tokena, jak w praktyce wygląda jego cofnięcie oraz jak powinno działać logowanie wylogowania na różnych urządzeniach. Każda z tych decyzji opiera się na tym samym zasadzie co wszystko powyżej: bezpieczeństwo jest zapewnione, gdy stanowi element każdego wyboru projektowego, a nie warstwę dodaną później.

    Literatura pokrewna

  • Kiedy autoryzacja JWT staje się zależna od stanu: Przykład wykorzystania sesji po stronie serwera w Node — Dowiedz się, jak listy blokujące i magazyny odnowień sprawiają, że autoryzacja JWT jest krucha, jak sesje Express z bazą Postgres je upraszczają oraz gdzie nadal pasują JWT.
  • Zabezpieczanie API Express: Autoryzacja, walidacja, limity prędkości i monitorowanie — Praktyczny przewodnik po zabezpieczeniach API w Express: autoryzacja Passport i JWT, modele upoważniania, szyfrowanie AES-GCM, walidacja, limity prędkości i logowanie.