Kurs NestJS · Moduł 4: Uwierzytelnianie
Password Hashing - haszowanie kodów skarbca
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.1rc20s7WQMF0UJoC58nf5eHWniFUUrmFragment $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)');
59Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Dlaczego hasła należy hashować zamiast szyfrować?
Zadania praktyczne w grze
- Klikanie w kolejności
Uszereguj kroki hashowania hasła z bcrypt: