Kurs NestJS · Moduł 4: Uwierzytelnianie

Session Management i Cookie Auth - alternatywne drogi do fortu

5 min czytania
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-session

Drugi 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: true odcina ciasteczko od JavaScriptu, secure: true wymusza 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');
86

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 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:

Przydatne artykuły