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.
Ź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:
- Czyta nagłówek
Authorization.
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.
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.
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
- Wdrożenie tokenów dostępu i odnowienia razem w Node.js — Dowiedz się, jak łączyć krótkotrwałe tokeny dostępu z rotującymi tokenami odnowienia w Node.js, aby zapewnić równowagę pomiędzy bezpieczeństwem a płynnymi sesjami użytkowników.
- Sesje vs. JWT: Wybór odpowiedniego modelu autoryzacji w Node.js — Dowiedz się, w czym dokładnie różnią się sesje i JWT w mechanizmach autoryzacji w Node.js, gdzie każdy z nich ma swoje ograniczenia, oraz jak wybrać między nimi bez późniejszych żalów.