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

JWT Tokens - rzymskie przepustki

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.

Przejdź do CodeWorlds