Kurs NestJS · Moduł 4: Uwierzytelnianie

JWT Tokens - rzymskie przepustki

5 min czytania
W tej lekcji6

Legionista podał hasło przy bramie i został wpuszczony. Za godzinę wraca po zaopatrzenie - i co, znowu ma podawać hasło? A przy trzeciej bramie? Sprawdzanie hasła przy każdym kroku oznacza, że serwer musi przy każdym żądaniu sięgać do bazy po użytkownika. Setki razy na minutę, po to samo.

Rzymianie wydawali w takich razach przepustkę: dokument z wypisanymi uprawnieniami i pieczęcią, którą każdy strażnik potrafi rozpoznać. Wartownik nie biegnie do archiwum - patrzy na pieczęć i wie, że dokument jest prawdziwy. W sieci ta przepustka nazywa się JWT, czyli JSON Web Token.

Trzy części przepustki

Token JWT wygląda jak ciąg znaków przedzielony dwiema kropkami. Te kropki nie są przypadkowe - dzielą go na trzy części, zawsze w tej samej kolejności:

1eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjQyLCJyb2xlIjoiY2VudHVyaW9uIn0.dBjftJeZ4CVP
2   <-- Header -->      <----- Payload ----->                  <- Signature ->

Header mówi, jakim algorytmem podpisano dokument. Payload to treść przepustki - dane o legioniście, zwane claims: jego identyfikator, rola, czas wygaśnięcia. Signature to pieczęć: wynik podpisania dwóch pierwszych części tajnym kluczem serwera.

Teraz najważniejsze i najczęściej mylone: payload jest zakodowany, ale nie zaszyfrowany. Każdy, kto przechwyci token, odczyta jego treść - to zwykły Base64, nie tajemnica. Pieczęć nie ukrywa danych, tylko dowodzi, że nikt ich nie podmienił, bo bez tajnego klucza nie da się jej podrobić. Wniosek praktyczny jest jeden: do payloadu nigdy nie wkładaj hasła ani danych wrażliwych.

Konfiguracja - wydawanie przepustek

Zdolność wydawania przepustek dodajemy przez moduł:

1JwtModule.registerAsync({
2  imports: [ConfigModule],
3  inject: [ConfigService],
4  useFactory: (configService: ConfigService) => ({
5    secret: configService.get('JWT_SECRET'),
6    signOptions: {
7      expiresIn: '24h',
8      issuer: 'LegionaryFleet',
9    },
10  }),
11});

secret to tajny klucz, którym serwer pieczętuje tokeny i którym je potem weryfikuje. Czytamy go przez ConfigService ze zmiennej środowiskowej, w pliku .env zapisanej jako JWT_SECRET=super-secret-key - nazwa, znak równości, wartość, bez cudzysłowów i spacji. Klucz wpisany w kod trafiłby do repozytorium, a stamtąd w cudze ręce; kto ma klucz, może wystawić sobie przepustkę centuriona.

Używamy registerAsync zamiast zwykłego register, bo wartość klucza nie jest znana w chwili pisania kodu - trzeba ją najpierw odczytać z konfiguracji. Stąd useFactory: funkcja, którą NestJS wywoła, gdy ConfigService będzie już dostępny.

W signOptions opisujemy samą przepustkę. expiresIn: '24h' to jej czas życia - po dobie dokument traci ważność i legionista musi zalogować się ponownie. To celowe ograniczenie: gdyby token wyciekł, złodziej ma tylko tyle czasu. issuer to wystawca, zapisywany w payloadzie.

Kształt przepustki

Warto opisać typem, co dokładnie wkładamy do payloadu:

1export interface JwtPayload {
2  sub: number;
3  username: string;
4  email: string;
5  role: string;
6  iat: number;
7  exp: number;
8}

Trzy z tych nazw są skrótami ustalonymi w standardzie JWT i dlatego wyglądają dziwnie. sub to subject - identyfikator tego, kogo dotyczy przepustka, czyli po prostu id użytkownika. iat to issued at, moment wystawienia. exp to expiration, moment wygaśnięcia - to jego pilnuje weryfikacja czasu. Pola iat i exp biblioteka dopisuje sama; username, email i role to już Twoje własne claims.

Wystawienie przepustki przy logowaniu

Gdy hasło się zgadza, serwis podpisuje token i oddaje go klientowi:

1async login(user: User) {
2  const payload = {
3    sub: user.id,
4    username: user.username,
5    role: user.role,
6  };
7
8  return {
9    access_token: this.jwtService.sign(payload),
10  };
11}

jwtService.sign() bierze Twój obiekt, dokłada iat i exp zgodnie z signOptions, a całość pieczętuje tajnym kluczem. Zwrócony ciąg znaków to gotowa przepustka. Zauważ, czego tu nie ma: serwer niczego nie zapamiętuje. Nie powstaje żaden wpis w bazie sesji - cała wiedza o legioniście podróżuje w samym tokenie. Na tym polega różnica między przepustką a listą gości przy bramie.

Cały obieg wygląda więc tak: klient wysyła login i hasło, serwer weryfikuje je w bazie, serwer generuje token, klient dołącza go do każdego kolejnego żądania w nagłówku Authorization, a serwer odczytuje token i autoryzuje żądanie - bez sięgania do bazy po hasło.

Wartownik od przepustek

Kto właściwie sprawdza pieczęć przy każdym żądaniu? Robi to strategia - klasa, w której opisujesz, skąd wziąć token i jak go zweryfikować. Pełny system strategii poznasz w kolejnej lekcji; na razie wystarczy ta jedna, od przepustek:

1@Injectable()
2export class JwtStrategy extends PassportStrategy(Strategy) {
3  constructor(private configService: ConfigService) {
4    super({
5      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
6      ignoreExpiration: false,
7      secretOrKey: configService.get('JWT_SECRET'),
8    });
9  }
10
11  async validate(payload: JwtPayload) {
12    return { userId: payload.sub, username: payload.username, role: payload.role };
13  }
14}

Trzy opcje w super() opisują, jak wartownik czyta dokument. jwtFromRequest mówi, skąd wziąć token - ExtractJwt.fromAuthHeaderAsBearerToken() wyciąga go z nagłówka Authorization: Bearer <token>, czyli najpopularniejszego miejsca. secretOrKey to ten sam klucz, którym token podpisano; bez niego nie da się sprawdzić pieczęci.

ignoreExpiration: false bywa mylące przez tę podwójną przeczącą formę, więc przeczytajmy ją wprost: nie ignoruj wygaśnięcia, czyli sprawdzaj exp i odrzucaj przeterminowane tokeny. To jest ustawienie, którego chcesz. Ustawienie true oznaczałoby, że przepustka sprzed roku wciąż otwiera bramy.

Metoda validate() dostaje już zweryfikowany payload - pieczęć sprawdzono, data ważności też, zanim Twój kod się uruchomił. Zwrócony obiekt trafia do request.user i staje się dostępny w kontrolerze. Zauważ, że nie ma tu zapytania do bazy: wszystkie potrzebne dane przyszły w tokenie, i to jest właśnie oszczędność, dla której wprowadziliśmy przepustki.

Podsumowanie

Legionista nosi przepustkę i nie podaje hasła przy każdej bramie:

  • JWT składa się z trzech części w stałej kolejności: Header, Payload, Signature,
  • payload jest kodowany, nie szyfrowany - każdy go odczyta, więc nie wkładaj tam haseł,
  • podpis nie ukrywa danych, tylko dowodzi, że nikt ich nie podmienił,
  • JwtModule.registerAsync z useFactory pozwala pobrać secret z ConfigService; klucz mieszka w .env, nigdy w kodzie,
  • signOptions.expiresIn ustala czas życia tokenu, issuer - wystawcę,
  • standardowe claims: sub (id użytkownika), iat (wystawiono), exp (wygasa),
  • jwtService.sign(payload) wystawia przepustkę, a serwer niczego nie zapamiętuje,
  • w JwtStrategy: ExtractJwt.fromAuthHeaderAsBearerToken() czyta token z nagłówka Bearer, ignoreExpiration: false odrzuca przeterminowane, a validate() dostaje payload już po weryfikacji.

W następnej lekcji poznasz Passport.js od strony całego systemu strategii - zobaczysz, że wartownik od przepustek to tylko jeden z wielu. A na razie zapamiętaj: JWT to przepustka z pieczęcią - dane w niej każdy przeczyta, ale podrobić ich nie zdoła nikt bez tajnego klucza.

Kod do tej lekcji: src/auth/jwt-config.ts
1// JWT Tokens - Rzymskie Przepustki
2// Kazda przepustka sklada sie z 3 czesci: Header, Payload, Signature
3import { Module } from '@nestjs/common';
4import { JwtModule, JwtService } from '@nestjs/jwt';
5import { ConfigModule, ConfigService } from '@nestjs/config';
6import { Injectable } from '@nestjs/common';
7
8// ===========================================
9// 1. Konfiguracja JWT Module
10// ===========================================
11
12@Module({
13  imports: [
14    JwtModule.registerAsync({
15      imports: [ConfigModule],
16      useFactory: async (configService: ConfigService) => ({
17        // Sekret do podpisywania tokenow
18        secret: configService.get<string>('JWT_SECRET') || 'spqr-secret-key',
19        signOptions: {
20          expiresIn: '24h',      // Token wazny 24 godziny
21          issuer: 'imperium-romanum', // Wydawca tokenu
22          algorithm: 'HS256',    // Algorytm podpisu
23        },
24      }),
25      inject: [ConfigService],
26    }),
27  ],
28  exports: [JwtModule],
29})
30export class JwtConfigModule {}
31
32// ===========================================
33// 2. Serwis obslugujacy tokeny
34// ===========================================
35
36@Injectable()
37export class TokenService {
38  constructor(private jwtService: JwtService) {}
39
40  // Tworzenie tokenu dostepu
41  createAccessToken(legionary: { id: string; username: string; rank: string }) {
42    const payload = {
43      sub: legionary.id,
44      username: legionary.username,
45      rank: legionary.rank,
46      type: 'access',
47    };
48    return this.jwtService.sign(payload, { expiresIn: '1h' });
49  }
50
51  // Tworzenie tokenu odswiezania
52  createRefreshToken(legionary: { id: string }) {
53    const payload = {
54      sub: legionary.id,
55      type: 'refresh',
56    };
57    return this.jwtService.sign(payload, { expiresIn: '7d' });
58  }
59
60  // Weryfikacja tokenu
61  verifyToken(token: string) {
62    try {
63      const decoded = this.jwtService.verify(token);
64      console.log('Token zdekodowany:', decoded);
65      return decoded;
66    } catch (error) {
67      console.log('Token niewazny lub wygasl!');
68      throw error;
69    }
70  }
71
72  // Dekodowanie bez weryfikacji (np. do odczytu payloadu)
73  decodeToken(token: string) {
74    return this.jwtService.decode(token);
75  }
76}
77
78// ===========================================
79// 3. Struktura tokenu JWT
80// ===========================================
81
82console.log('=== Struktura JWT ===');
83console.log('Header:  { alg: "HS256", typ: "JWT" }');
84console.log('Payload: { sub: "1", username: "Caesar", rank: "consul" }');
85console.log('Signature: HMACSHA256(header + payload, secret)');
86console.log('');
87console.log('Token = base64(header).base64(payload).signature');
88console.log('Access Token: krotki czas zycia (1h)');
89console.log('Refresh Token: dlugi czas zycia (7d)');
90

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. Z ilu części składa się token JWT?

  2. 2. Co zawiera sekcja Payload tokenu JWT?

To 2 z 5 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Edytor kodu

    Uzupełnij konfigurację JwtModule, ustawiając secret z ConfigService, czas wygaśnięcia na '24h' i issuer na 'LegionaryFleet'

  • Układanie w poziomie

    Ułóż części tokenu JWT w kolejności od lewej do prawej (tak jak występują w stringu)

  • Edytor kodu

    Uzupełnij klasę JwtStrategy, która dziedziczy po PassportStrategy(Strategy) i implementuje metodę validate sprawdzającą czy użytkownik istnieje

  • Klikanie w kolejności

    Ułóż elementy deklaracji klasy JwtStrategy w poprawnej kolejności

  • Układanie w pionie

    Uporządkuj kroki procesu logowania z użyciem JWT

  • Układanie w poziomie

    Ułóż składnię definiowania zmiennej JWT_SECRET w pliku .env

  • Edytor kodu

    Stwórz interfejs JwtPayload w TypeScript zawierający pola: sub (number), username (string), email (string), role (string), iat (number) i exp (number)

Przydatne artykuły