Przepustki JWT mają jedną wadę, o której dotąd milczeliśmy. Legionista okazał się zdrajcą i chcesz go natychmiast wyrzucić z obozu - ale jego przepustka jest ważna jeszcze przez dwadzieścia godzin. Serwer nie ma jak jej unieważnić, bo nigdzie jej nie zapisał. Wszystko, co wie o dokumencie, jest w samym dokumencie.
Istnieje starsza droga do fortu: lista gości przy bramie. Wartownik nie czyta przepustek - zagląda do księgi i sprawdza, czy przybysz jest na liście. Skreślenie z niej działa natychmiast. Tę drogę nazywamy uwierzytelnieniem sesyjnym.
Różnica między tymi dwiema drogami sprowadza się do jednego pytania: czy serwer coś pamięta?
JWT jest bezstanowe (stateless). Cała wiedza o legioniście podróżuje w tokenie, serwer nie przechowuje niczego. Zaletą jest skalowanie - postawisz dziesięć serwerów i każdy odczyta ten sam token, bo do weryfikacji wystarczy mu tajny klucz. Wadą jest właśnie ten zdrajca: nie ma czego skreślić.
Sesja jest stanowa (stateful). Po zalogowaniu serwer zapisuje u siebie wpis - kim jest ten człowiek - i odsyła klientowi jedynie identyfikator sesji w ciasteczku. Ciasteczko samo w sobie nic nie znaczy, to numerek do szatni. Przy każdym żądaniu serwer po tym numerku odnajduje wpis. Wylogowanie to skasowanie wpisu - i przybysz przestaje istnieć natychmiast.
Trzymaj tę parę pojęć razem z ich konsekwencją, bo o to właśnie pytają: stateless = nic do unieważnienia, ale łatwe skalowanie; stateful = natychmiastowe unieważnienie, ale serwer musi pamiętać.
Sesje wprowadzamy w ustalonej kolejności. Zaczynamy od pakietów:
1npm install express-session
2npm install -D @types/express-sessionDrugi pakiet zawiera same typy dla TypeScriptu - stąd flaga
-D, bo do działania aplikacji na produkcji nie jest potrzebny.Krok drugi to podpięcie middleware. Robimy to w
main.ts, bo sesje muszą być gotowe, zanim jakiekolwiek żądanie dotrze do kontrolerów:1app.use(
2 session({
3 secret: process.env.SESSION_SECRET,
4 resave: false,
5 saveUninitialized: false,
6 cookie: {
7 httpOnly: true,
8 secure: true,
9 maxAge: 3600000,
10 },
11 }),
12);Przejdźmy przez te opcje, bo dwie z nich decydują o bezpieczeństwie całego rozwiązania.
secret podpisuje ciasteczko, żeby nikt nie podmienił numerka na cudzy - czytamy go ze zmiennej środowiskowej, tak samo jak klucz JWT.httpOnly: true to najważniejsza linia w tym bloku. Sprawia, że ciasteczka nie da się odczytać z JavaScriptu w przeglądarce. Gdyby ktoś wstrzyknął na Twoją stronę obcy skrypt, bez tej flagi wykradłby identyfikator sesji jednym document.cookie. secure: true dokłada drugi warunek: ciasteczko podróżuje wyłącznie po HTTPS, więc nie da się go podsłuchać po drodze.maxAge to czas życia w milisekundach - tutaj godzina. resave: false i saveUninitialized: false ograniczają zbędne zapisy: nie odświeżaj wpisu, gdy nic się nie zmieniło, i nie twórz sesji dla gościa, który jeszcze niczego nie zrobił.Krok trzeci to sięgnięcie po sesję w kontrolerze:
1@Post('login')
2login(@Body() dto: LoginDto, @Session() session: Record<string, any>) {
3 const user = this.authService.validate(dto);
4 session.userId = user.id;
5 return { message: 'Zalogowano' };
6}
7
8@Get('profile')
9getProfile(@Session() session: Record<string, any>) {
10 if (!session.userId) {
11 throw new UnauthorizedException();
12 }
13 return this.usersService.findOne(session.userId);
14}Dekorator
@Session() wstrzykuje obiekt sesji - zwykły obiekt, do którego wpisujesz, co chcesz zapamiętać. Zauważ, czego tu nie ma: nie zwracamy żadnego tokenu. Klient dostaje ciasteczko automatycznie, w nagłówku odpowiedzi, i równie automatycznie odsyła je przy każdym kolejnym żądaniu. Stąd wrażenie, że "po prostu działa" - i stąd też pułapka, bo działa też wtedy, gdy żądanie wysyła cudza strona. To zagrożenie nazywa się CSRF i wymaga osobnego zabezpieczenia, którego JWT w nagłówku nie potrzebuje.Domyślnie
express-session trzyma wpisy w pamięci procesu Node. To wystarcza na Twoim laptopie i zawodzi na produkcji z dwóch powodów: restart aplikacji wylogowuje wszystkich, a przy dwóch serwerach użytkownik zalogowany na pierwszym jest nieznany drugiemu.Dlatego na produkcji wskazujemy zewnętrzny magazyn:
1app.use(
2 session({
3 store: new RedisStore({ client: redisClient }),
4 secret: process.env.SESSION_SECRET,
5 resave: false,
6 saveUninitialized: false,
7 }),
8);Redis nadaje się tu najlepiej, bo trzyma dane w pamięci i sam usuwa wpisy po wygaśnięciu. Wszystkie serwery pytają ten sam magazyn, więc numerek z szatni działa niezależnie od tego, który wartownik go obejrzy. I to jest ta cena stanowości, o której mówiliśmy: pamiętanie kosztuje infrastrukturę.
Reguła praktyczna jest krótka. JWT przy API dla aplikacji mobilnych, mikroserwisów i wszędzie tam, gdzie klientem nie jest przeglądarka - podróżuje w nagłówku, nie wymaga wspólnego magazynu. Sesje przy klasycznych aplikacjach webowych renderowanych po stronie serwera, zwłaszcza gdy potrzebujesz natychmiast kogoś wylogować albo widzieć listę aktywnych sesji.
Nie ma tu lepszego i gorszego rozwiązania - jest wybór między łatwym skalowaniem a natychmiastową kontrolą. I to polecam jako pytanie, od którego zaczynasz, @name: czy muszę móc unieważnić dostęp w sekundę?
Fort ma dwie bramy i wiesz, którą kiedy otworzyć:
main.ts, @Session() w kontrolerze, wybór magazynu,httpOnly: true odcina ciasteczko od JavaScriptu, secure: true wymusza HTTPS - obie flagi są obowiązkowe,W następnej lekcji zmierzysz się z projektem: zbudujesz kompletny system uwierzytelniania dla legionu. A na razie zapamiętaj: JWT to przepustka, którą nosi się przy sobie, a sesja to numerek do szatni - dokument mówi sam za siebie, numerek działa tylko tak długo, jak długo wartownik trzyma wpis w księdze.