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

Authentication - identyfikacja legionariuszy

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

Przejdź do CodeWorlds