Kurs NestJS · Moduł 4: Uwierzytelnianie

Authentication - identyfikacja legionariuszy

5 min czytania
W tej lekcji5

Do bramy obozu podchodzi człowiek w rzymskiej zbroi. Zbroja niczego nie dowodzi - można ją kupić albo zdjąć z poległego. Zanim wartownik go wpuści, musi odpowiedzieć na pytanie: kim ty właściwie jesteś?

To pytanie nazywamy uwierzytelnieniem (ang. authentication). Nie myl go z pytaniem, które padnie później, przy drzwiach do skarbca: co ci wolno? - to autoryzacja (authorization). Kolejność jest nieodwracalna: najpierw ustalamy tożsamość, dopiero potem uprawnienia. Nie da się sprawdzić rangi kogoś, kogo jeszcze nie rozpoznaliśmy.

W tym module zbudujemy całą bramę. Ta lekcja to jej fundament: rejestr legionu i bezpieczne przyjmowanie rekrutów.

Pakiety - wyposażenie strażnicy

Zacznijmy od narzędzi, które będą nam potrzebne w całym module:

1npm install @nestjs/passport passport passport-jwt @nestjs/jwt

passport to sama biblioteka uwierzytelniania, @nestjs/passport spina ją z NestJS. @nestjs/jwt wystawia przepustki, a passport-jwt uczy strażnika je czytać. Na razie tylko je instalujemy - poznasz je po kolei w kolejnych lekcjach.

Rejestr legionu - encja User

Każdy legionista musi mieć wpis w rejestrze:

1@Entity('users')
2export class User {
3  @PrimaryGeneratedColumn()
4  id: number;
5
6  @Column({ unique: true })
7  username: string;
8
9  @Column({ unique: true })
10  email: string;
11
12  @Column()
13  @Exclude()
14  password: string;
15}

Encję czytasz już swobodnie - to wiedza z modułu o TypeORM. unique: true przy username i email gwarantuje, że dwóch legionistów nie zamelduje się pod tym samym imieniem.

Zatrzymajmy się przy @Exclude(), bo to jedyny nowy element i zarazem najczęstsza dziura w bezpieczeństwie początkujących aplikacji. Ten dekorator nie usuwa pola z bazy ani niczego nie szyfruje - hasło nadal siedzi w kolumnie. On działa dopiero przy wyjściu: gdy NestJS zamienia encję na odpowiedź JSON, pole oznaczone @Exclude() zostaje pominięte.

Bez niego zwykłe GET /users/1 odesłałoby klientowi hash hasła razem z resztą danych. Nikt tego nie zauważy w testach, bo aplikacja działa poprawnie - a hasła wyciekają przy każdym żądaniu. Zapamiętaj tę zasadę: pole, które nigdy nie ma opuścić serwera, oznaczasz @Exclude() w chwili, gdy je tworzysz, a nie kiedyś później.

Sprawdzenie dokumentów przy wejściu - DTO

Zanim dane trafią do rejestru, trzeba je obejrzeć. Służy do tego DTO z regułami walidacji:

1export class RegisterDto {
2  @IsNotEmpty()
3  username: string;
4
5  @IsNotEmpty()
6  @IsEmail()
7  email: string;
8
9  @IsNotEmpty()
10  @MinLength(8)
11  password: string;
12}

Zwróć uwagę na porządek sprawdzeń - idzie od najbardziej podstawowego do najbardziej szczegółowego. Najpierw @IsNotEmpty() pyta, czy pole w ogóle wypełniono. Potem @IsEmail() bada, czy to, co wpisano, ma kształt adresu. Na końcu @MinLength(8) stawia wymóg co do treści.

Ta kolejność ma sens praktyczny: nie ma po co badać formatu pustego pola. I jeszcze jedno rozróżnienie, bo bywa mylące. Wszystkie te dekoratory sprawdzają samo żądanie - patrzą tylko na to, co przyszło, i nie wiedzą nic o bazie. Pytanie "czy ten username jest już zajęty?" wymaga zajrzenia do rejestru, więc nie da się go zadać dekoratorem. To sprawdzenie należy do serwisu i zaraz je zobaczysz.

Przyjęcie rekruta

Rejestracja to cztery kroki w ustalonej kolejności:

1async register(dto: RegisterDto): Promise<User> {
2  const existing = await this.userRepository.findOne({
3    where: { username: dto.username },
4  });
5
6  if (existing) {
7    throw new ConflictException('Legionista o tym imieniu już służy');
8  }
9
10  const hashedPassword = await bcrypt.hash(dto.password, 10);
11
12  return this.userRepository.save({
13    ...dto,
14    password: hashedPassword,
15  });
16}

Prześledźmy je. Walidacja wykonała się wcześniej, automatycznie - dane z DTO doszły tu już sprawdzone. Sprawdzenie unikalności to to zapytanie do bazy, którego dekorator nie mógł wykonać; przy kolizji rzucamy ConflictException, czyli odpowiedź 409. Zahaszowanie hasła - i dopiero teraz zapis.

Kolejność dwóch ostatnich kroków jest nienegocjowalna: hasło zamieniamy na hash przed zapisem, nigdy po. Do bazy nie trafia nic, co dałoby się odczytać.

Czym właściwie jest to bcrypt.hash(dto.password, 10)? Hashowanie to przemiana jednokierunkowa - z hasła powstaje ciąg znaków, z którego nie da się wrócić do oryginału. To nie jest szyfrowanie, bo szyfrowanie z definicji można odwrócić kluczem. Przy logowaniu nie odszyfrowujemy więc niczego; hashujemy podane hasło jeszcze raz i porównujemy wyniki metodą bcrypt.compare(). Nawet Ty, mając pełen dostęp do bazy, nie odczytasz hasła swojego użytkownika - i o to właśnie chodzi.

Liczba 10 to salt rounds, sterujące kosztem obliczenia. Wrócimy do niej i do reszty mechaniki bcrypta w osobnej lekcji o hashowaniu.

Podsumowanie

Brama stoi, rejestr działa, rekruci zapisują się bezpiecznie:

  • uwierzytelnienie odpowiada, kim jest przybysz; autoryzacja - co mu wolno; ta pierwsza zawsze poprzedza drugą,
  • moduł opiera się na czterech pakietach: passport, @nestjs/passport, @nestjs/jwt, passport-jwt,
  • @Exclude() nie rusza bazy - zapobiega tylko wysłaniu pola w odpowiedzi API; bez niego hasła wyciekają przy każdym GET,
  • walidacja DTO idzie od ogółu do szczegółu: @IsNotEmpty, potem @IsEmail, potem @MinLength,
  • dekoratory widzą wyłącznie treść żądania - sprawdzenie unikalności wymaga zapytania do bazy i należy do serwisu,
  • rejestracja ma cztery kroki: walidacja, sprawdzenie istnienia, hashowanie, zapis,
  • hash jest jednokierunkowy: hasła nie odszyfrowujemy, tylko hashujemy ponownie i porównujemy przez bcrypt.compare().

W następnej lekcji rozbierzemy hashowanie na czynniki pierwsze - dowiesz się, czym jest salt i dlaczego wolne hashowanie bywa zaletą. A na razie zapamiętaj: uwierzytelnienie pyta "kim jesteś", a hasło w rejestrze legionu nie istnieje - jest tylko jego jednokierunkowy odcisk.

Kod do tej lekcji: src/auth/auth-basics.ts
1// Authentication - Identyfikacja Legionariuszy
2// Imperium Rzymskie - system weryfikacji tozsamosci
3import { Injectable, UnauthorizedException } from '@nestjs/common';
4import { JwtService } from '@nestjs/jwt';
5import * as bcrypt from 'bcrypt';
6
7// Interfejs uzytkownika w systemie Imperium
8interface Legionary {
9  id: string;
10  username: string;
11  passwordHash: string;
12  rank: 'miles' | 'centurion' | 'legatus' | 'consul';
13}
14
15@Injectable()
16export class AuthService {
17  // Symulacja bazy legionariuszy
18  private legionaries: Legionary[] = [];
19
20  constructor(private jwtService: JwtService) {}
21
22  // Rejestracja nowego legionariusza
23  async register(username: string, password: string) {
24    // Hashowanie hasla - jak pieczec woskowa na dokumencie
25    const saltRounds = 10;
26    const passwordHash = await bcrypt.hash(password, saltRounds);
27
28    const newLegionary: Legionary = {
29      id: Date.now().toString(),
30      username,
31      passwordHash,
32      rank: 'miles', // Nowy rekrut
33    };
34
35    this.legionaries.push(newLegionary);
36    console.log('Nowy legionariusz zarejestrowany:', username);
37
38    return { message: 'Rejestracja zakonczona pomyslnie' };
39  }
40
41  // Walidacja legionariusza - sprawdzenie tozsamosci
42  async validateUser(username: string, password: string) {
43    const legionary = this.legionaries.find(l => l.username === username);
44
45    if (!legionary) {
46      throw new UnauthorizedException('Nieznany legionariusz!');
47    }
48
49    // Porownanie hasla z hashem
50    const isPasswordValid = await bcrypt.compare(
51      password,
52      legionary.passwordHash
53    );
54
55    if (!isPasswordValid) {
56      throw new UnauthorizedException('Bledne haslo!');
57    }
58
59    return legionary;
60  }
61
62  // Logowanie - wydanie przepustki (tokenu)
63  async login(username: string, password: string) {
64    const legionary = await this.validateUser(username, password);
65
66    // Payload tokenu JWT
67    const payload = {
68      sub: legionary.id,
69      username: legionary.username,
70      rank: legionary.rank,
71    };
72
73    return {
74      access_token: this.jwtService.sign(payload),
75      legionary: {
76        id: legionary.id,
77        username: legionary.username,
78        rank: legionary.rank,
79      },
80    };
81  }
82}
83
84console.log('Authentication: register -> validate -> login');
85console.log('Hasla sa hashowane bcryptem - nigdy nie przechowuj tekstem!');
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. Czym jest Authentication w kontekście aplikacji webowej?

  2. 2. Jaka jest różnica między Authentication a Authorization?

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

Zadania praktyczne w grze

  • Edytor kodu

    Uzupełnij kod instalacji @nestjs/passport, passport, passport-jwt i @nestjs/jwt do projektu NestJS

  • Klikanie w kolejności

    Ułóż elementy definicji encji User w poprawnej kolejności

  • Układanie w pionie

    Uporządkuj kroki walidacji danych rejestracji od najbardziej podstawowego do szczegółowego

  • Edytor kodu

    Uzupełnij serwis AuthService o metodę hashPassword, która przyjmuje hasło i zwraca zahashowaną wersję za pomocą bcrypt

  • Układanie w pionie

    Uporządkuj etapy procesu rejestracji użytkownika w NestJS

Przydatne artykuły