Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Session Management i Cookie Auth - alternatywne drogi do fortu

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, @name: 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.

Przejdź do CodeWorlds