Kurs NestJS · Moduł 4: Uwierzytelnianie

Password Hashing - haszowanie kodów skarbca

4 min czytania
W tej lekcji4

Wyobraź sobie, że ktoś wynosi z fortu kopię rejestru legionistów. Jeśli hasła leżą tam zwykłym tekstem, wróg zna je wszystkie od ręki - a ponieważ ludzie używają tych samych haseł w wielu miejscach, otwiera też ich skrzynki i konta w innych serwisach. Architekt Vitruvius ma na to prostą zasadę: skarbiec nie przechowuje kluczy, tylko ich odciski. Tym właśnie jest password hashing.

Czym jest Password Hashing?

Hashowanie przekształca hasło w ciąg znaków (hash) za pomocą funkcji jednokierunkowej. To jak odcisk palca: to samo hasło daje zawsze pasujący odcisk, ale z odcisku nie da się odtworzyć palca. Przy logowaniu nie odzyskujesz więc hasła z bazy, tylko liczysz hash z tego, co wpisał użytkownik, i porównujesz wyniki.

Dlaczego nie zwykłe szyfrowanie?

Szyfrowanie jest odwracalne - kto ma klucz, odczyta wszystko. Porównaj oba podejścia (funkcje encrypt i decrypt są tu umowne, służą tylko do zilustrowania różnicy):

1// ZŁE PODEJŚCIE - zwykłe szyfrowanie
2const encryptedPassword = encrypt('my-secret-password', 'encryption-key');
3// Można odszyfrować: decrypt(encryptedPassword, 'encryption-key')
4
5// DOBRE PODEJŚCIE - hashowanie
6const hashedPassword = await bcrypt.hash('my-secret-password', 12);
7// Nie można odwrócić procesu!

Wyciek klucza szyfrującego oznacza wyciek wszystkich haseł naraz. Hash nie ma klucza, który można ukraść. Dlaczego zatem nie zwykłe SHA-256? Bo jest za szybkie: karta graficzna sprawdza miliardy kandydatów na sekundę. bcrypt jest powolny celowo i dodaje do każdego hasła sól.

Sól i koszt

Sól to losowa wartość generowana osobno dla każdego hasła. Dzięki niej dwóch legionistów z hasłem „Roma123" dostaje zupełnie różne hashe, a gotowe tablice odgadniętych haseł stają się bezużyteczne. Koszt (saltRounds) mówi, ile pracy trzeba wykonać: bcrypt wykonuje 2^koszt iteracji, więc każdy kolejny stopień podwaja czas. Tak wygląda gotowy hash o długości 60 znaków:

1$2b$12$FSazelkmW3GJ/HK7ig5K9.1rc20s7WQMF0UJoC58nf5eHWniFUUrm

Fragment $2b$ to wersja algorytmu, 12 to koszt, następne 22 znaki to sól, a ostatnie 31 to właściwy hash. Sól jest zapisana w samym wyniku, więc nie potrzebujesz na nią osobnej kolumny w bazie.

Implementacja z bcrypt

Instalujesz pakiet bcrypt oraz typy @types/bcrypt i tworzysz serwis, który będzie jedynym miejscem pracy z hasłami:

1// npm install bcrypt
2// npm install -D @types/bcrypt
3
4import { Injectable } from '@nestjs/common';
5import * as bcrypt from 'bcrypt';
6
7@Injectable()
8export class PasswordService {
9  private readonly saltRounds = 12; // Koszt hashowania: 2^12 iteracji
10
11  async hashPassword(plainPassword: string): Promise<string> {
12    console.log('Tworzenie bezpiecznego hasha hasła...');
13
14    // bcrypt sam generuje sól i zapisuje ją w wyniku
15    const hashedPassword = await bcrypt.hash(plainPassword, this.saltRounds);
16
17    console.log(`Hasło zahashowane: ${hashedPassword.substring(0, 20)}...`);
18
19    return hashedPassword;
20  }

Jedno wywołanie bcrypt.hash() wykonuje cały proces: podajesz koszt, biblioteka generuje losową sól, łączy ją z hasłem i przepuszcza przez 2^12 rund, a Ty zapisujesz w bazie gotowy ciąg. Samo hasło nigdzie nie zostaje. Wersja asynchroniczna liczy w wątkach puli libuv, więc nie blokuje pętli zdarzeń serwera.

Weryfikacja przy logowaniu wygląda tak:

1  async verifyPassword(
2    plainPassword: string,
3    hashedPassword: string
4  ): Promise<boolean> {
5    console.log('Sprawdzanie hasła...');
6
7    const isValid = await bcrypt.compare(plainPassword, hashedPassword);
8
9    console.log(`Wynik weryfikacji: ${isValid ? 'hasło poprawne' : 'hasło błędne'}`);
10
11    return isValid;
12  }

bcrypt.compare() odczytuje z zapisanego hasha sól i koszt, liczy hash z wpisanego hasła i porównuje oba wyniki. Nic tu nie jest odszyfrowywane - tego nie da się zrobić.

Gdy za kilka lat podniesiesz koszt do 13, stare hashe nadal będą miały 12. Pomaga w tym metoda sprawdzająca:

1  // Sprawdzenie czy hasło wymaga re-hashowania (zmiana saltRounds)
2  async needsRehash(hashedPassword: string): Promise<boolean> {
3    try {
4      // Koszt (saltRounds) jest zapisany w samym hashu
5      const currentRounds = this.extractSaltRounds(hashedPassword);
6      return currentRounds < this.saltRounds;
7    } catch (error) {
8      return true; // Jeśli nie można określić, lepiej re-hashować
9    }
10  }
11
12  private extractSaltRounds(hash: string): number {
13    // Format bcrypt: $2b$12$...
14    const parts = hash.split('$');
15    return parseInt(parts[2], 10);
16  }
17}

Ręczne cięcie napisu po znaku $ działa, ale biblioteka ma gotowe bcrypt.getRounds(hash) - i to polecam. Nowy hash wygenerujesz przy najbliższym udanym logowaniu, bo tylko wtedy masz w ręku prawdziwe hasło.

Granice bcrypt

bcrypt czyta tylko pierwsze 72 bajty wejścia - bajty UTF-8, nie znaki - a resztę po cichu pomija. Dla haseł to rzadko problem, ale zapamiętaj ten limit: przy refresh tokenach okaże się kluczowy. Koszt 12 to rozsądny punkt wyjścia; dokumentacja NestJS pokazuje także alternatywę argon2.

W następnej lekcji poznasz tokeny JWT, czyli przepustki, które legionista dostaje po udanym logowaniu.

Pamiętaj: hasła się nie szyfruje, tylko hashuje - skarbiec przechowuje odcisk klucza, nigdy sam klucz.

Kod do tej lekcji: src/auth/password.service.ts
1// Password Hashing - Szyfrowanie kodow skarbca
2import { Injectable } from '@nestjs/common';
3import * as bcrypt from 'bcrypt';
4
5@Injectable()
6export class PasswordService {
7  private readonly saltRounds = 12;
8
9  // TODO: Zaimplementuj hashowanie hasla
10  // Uzyj bcrypt.hash() z this.saltRounds
11  async hashPassword(plainPassword: string): Promise<string> {
12    // TODO: Wygeneruj hash hasla
13    return '';
14  }
15
16  // TODO: Zaimplementuj weryfikacje hasla
17  // Uzyj bcrypt.compare() do porownania
18  async verifyPassword(
19    plainPassword: string,
20    hashedPassword: string,
21  ): Promise<boolean> {
22    // TODO: Porownaj haslo z hashem
23    return false;
24  }
25
26  // Sprawdz czy haslo wymaga re-hashowania
27  async needsRehash(hashedPassword: string): Promise<boolean> {
28    try {
29      const parts = hashedPassword.split('$');
30      const currentRounds = parseInt(parts[2], 10);
31      return currentRounds < this.saltRounds;
32    } catch {
33      return true;
34    }
35  }
36}
37
38// Demonstracja
39async function demo() {
40  const service = new PasswordService();
41
42  const password = 'GloriaRomae2024';
43  console.log('Haslo:', password);
44
45  const hashed = await service.hashPassword(password);
46  console.log('Hash:', hashed);
47
48  const isValid = await service.verifyPassword(password, hashed);
49  console.log('Poprawne:', isValid);
50
51  const isWrong = await service.verifyPassword('ZleHaslo', hashed);
52  console.log('Bledne:', isWrong);
53}
54
55demo();
56
57console.log('bcrypt - jednokierunkowe hashowanie hasel');
58console.log('Salt rounds = sila hashowania (wiecej = bezpieczniej ale wolniej)');
59

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. Dlaczego hasła należy hashować zamiast szyfrować?

Zadania praktyczne w grze

  • Klikanie w kolejności

    Uszereguj kroki hashowania hasła z bcrypt:

Przydatne artykuły