Kurs NestJS · Moduł 4: Uwierzytelnianie
Session Management i Cookie Auth - alternatywne drogi do fortu
W tej lekcji5
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.
Bezstanowe kontra stanowe
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ć.
Konfiguracja - cztery kroki
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.
Krok czwarty: gdzie mieszkają sesje
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ę.
Co wybrać
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: czy muszę móc unieważnić dostęp w sekundę?
Podsumowanie
Fort ma dwie bramy i wiesz, którą kiedy otworzyć:
- JWT jest bezstanowe: serwer nic nie pamięta, łatwo skaluje, ale nie ma czego unieważnić przed wygaśnięciem tokenu,
- sesja jest stanowa: serwer trzyma wpis, klient dostaje w ciasteczku sam identyfikator - numerek do szatni,
- konfiguracja to cztery kroki: instalacja pakietów, middleware w
main.ts,@Session()w kontrolerze, wybór magazynu, httpOnly: trueodcina ciasteczko od JavaScriptu,secure: truewymusza HTTPS - obie flagi są obowiązkowe,- ciasteczko wędruje automatycznie, więc sesje wymagają osobnej ochrony przed CSRF,
- domyślny magazyn w pamięci nie nadaje się na produkcję: restart wylogowuje wszystkich, a drugi serwer nie zna sesji pierwszego,
- Redis rozwiązuje oba problemy, bo wszystkie serwery pytają ten sam magazyn.
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.
Kod do tej lekcji: src/auth/session-auth.ts
1// Session Management i Cookie Auth w NestJS
2import { Controller, Post, Get, Body, Session, HttpCode } from '@nestjs/common';
3import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
4
5// ============================================
6// 1. Session Auth Controller
7// ============================================
8@Controller('auth')
9class SessionAuthController {
10 @Post('login')
11 @HttpCode(200)
12 async login(
13 @Body() body: { username: string; password: string },
14 @Session() session: Record<string, any>,
15 ) {
16 // TODO: Waliduj uzytkownika
17 // TODO: Zapisz userId, username, role w session
18 session.userId = 1;
19 session.username = body.username;
20 session.role = 'MILES';
21
22 return { message: 'Zalogowano do fortu!' };
23 }
24
25 @Get('profile')
26 async getProfile(@Session() session: Record<string, any>) {
27 // TODO: Sprawdz czy session.userId istnieje
28 // TODO: Jesli nie - rzuc UnauthorizedException
29 if (!session.userId) {
30 throw new UnauthorizedException('Brak aktywnej sesji!');
31 }
32
33 return {
34 userId: session.userId,
35 username: session.username,
36 role: session.role,
37 };
38 }
39
40 @Post('logout')
41 @HttpCode(200)
42 async logout(@Session() session: Record<string, any>) {
43 // TODO: Zniszcz sesje
44 const username = session.username;
45 session.destroy(() => {});
46 return { message: 'Wylogowano: ' + username };
47 }
48}
49
50// ============================================
51// 2. Session Guard
52// ============================================
53@Injectable()
54class SessionGuard implements CanActivate {
55 canActivate(context: ExecutionContext): boolean {
56 const request = context.switchToHttp().getRequest();
57 const session = request.session;
58
59 // TODO: Sprawdz czy sesja zawiera userId
60 if (!session || !session.userId) {
61 throw new UnauthorizedException('Sesja wygasla!');
62 }
63
64 request.user = {
65 id: session.userId,
66 username: session.username,
67 role: session.role,
68 };
69
70 return true;
71 }
72}
73
74// ============================================
75// 3. Porownanie JWT vs Session
76// ============================================
77console.log('=== Session vs JWT ===');
78console.log('Session: stan na serwerze, maly cookie z Session ID');
79console.log('JWT: stateless, token zawiera dane uzytkownika');
80console.log('');
81console.log('Session: latwe uniewaznanie (usun z bazy)');
82console.log('JWT: trudne uniewaznanie (token wazny do wygasniecia)');
83console.log('');
84console.log('Session: wymaga shared store (Redis) przy skalowaniu');
85console.log('JWT: latwe skalowanie - kazdy serwer weryfikuje token');
86Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Jaka jest główna różnica między session-based a JWT authentication?
Zadania praktyczne w grze
- Układanie w pionie
Uszereguj kroki konfiguracji session-based auth w NestJS: