Kurs NestJS · Moduł 4: Uwierzytelnianie
Two-Factor Authentication (2FA) - podwójna straż Imperium
W tej lekcji7
Legionista wpisał hasło na fałszywej stronie, która udawała bramę fortu. Od tej chwili zna je wróg i żadne hashowanie w bazie tu nie pomoże, bo przeciwnik po prostu się loguje. Architekt Vitruvius postanowił postawić przy bramie drugiego strażnika, który sprawdza coś zupełnie innego niż hasło. Tak działa Two-Factor Authentication (2FA).
Czym jest 2FA?
Two-Factor Authentication wymaga dwóch niezależnych czynników weryfikacji:
- Coś co wiesz - hasło (pierwszy faktor)
- Coś co masz - telefon z aplikacją generującą kody (drugi faktor)
Trzecią kategorią jest „coś, czym jesteś", czyli biometria. Nawet jeśli wróg przechwyci hasło legionariusza, bez drugiego faktora nie wejdzie do fortu!
TOTP - Time-based One-Time Password
TOTP (standard RFC 6238) wylicza kod z dwóch składników: wspólnego sekretu i bieżącego czasu. Telefon i serwer liczą ten sam kod niezależnie, więc aplikacja działa nawet bez internetu:
1// Jak działa TOTP:
2// 1. Serwer generuje losowy secret (klucz)
3// 2. Użytkownik skanuje QR kod z secretem w aplikacji (Google Authenticator)
4// 3. Aplikacja generuje 6-cyfrowy kod co 30 sekund
5// 4. Serwer weryfikuje kod używając tego samego secretu i czasuSekret wędruje do telefonu tylko raz, w kodzie QR. Uwaga na częsty mit: kod TOTP nie jest sam z siebie jednorazowy. W swoim oknie czasowym zadziała drugi raz, jeśli serwer nie zapamięta, że już go przyjął.
Instalacja bibliotek
Potrzebujesz dwóch pakietów: otplib do kodów i qrcode do obrazka dla aplikacji:
1# otplib (v13+) - biblioteka TOTP i HOTP
2npm install otplib
3# qrcode - generowanie kodów QR
4npm install qrcode
5npm install -D @types/qrcodeWersja 13 biblioteki otplib (styczeń 2026) przepisała API od nowa. Obiekt authenticator z wersji 12, który znajdziesz w starszych poradnikach, zniknął, a jego miejsce zajęły funkcje generateSecret, generateURI i asynchroniczne verify.
TwoFactor Service
Serwis zaczyna od importów i konfiguracji. Funkcję generateSecret importujemy pod nazwą generateTotpSecret, żeby nie myliła się z metodą serwisu:
1// auth/two-factor.service.ts
2import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
3import { generateSecret as generateTotpSecret, generateURI, verify } from 'otplib';
4import * as qrcode from 'qrcode';
5import { randomInt } from 'node:crypto';
6import { InjectRepository } from '@nestjs/typeorm';
7import { Repository } from 'typeorm';
8import { User } from '../entities/user.entity';
9
10@Injectable()
11export class TwoFactorService {
12 constructor(
13 @InjectRepository(User)
14 private usersRepository: Repository<User>,
15 ) {}
16
17 // Konfiguracja TOTP: 6 cyfr i nowy kod co 30 sekund to domyślne ustawienia
18 // otplib; akceptujemy kod z +/- 30 s, czyli jeden krok w każdą stronę
19 private readonly totpOptions = { epochTolerance: 30 };epochTolerance: 30 to odpowiednik dawnego window: 1: serwer przyjmie też kod z poprzedniego i następnego 30-sekundowego kroku, co wybacza drobne różnice zegarów.
Krok pierwszy to wygenerowanie sekretu i kodu QR:
1 // Krok 1: Generowanie secretu i QR kodu
2 async generateSecret(userId: number): Promise<{
3 secret: string;
4 qrCodeUrl: string;
5 backupCodes: string[];
6 }> {
7 const user = await this.usersRepository.findOne({ where: { id: userId } });
8
9 if (!user) {
10 throw new BadRequestException('Legionariusz nie znaleziony!');
11 }
12
13 if (user.twoFactorEnabled) {
14 throw new BadRequestException('2FA jest już aktywne!');
15 }
16
17 // Generuj losowy secret
18 const secret = generateTotpSecret();
19
20 // Generuj URI dla aplikacji authenticator
21 const otpauthUrl = generateURI({
22 issuer: 'Imperium Romanum', // Nazwa aplikacji
23 label: user.email,
24 secret,
25 });
26
27 // Generuj QR kod jako data URL
28 const qrCodeUrl = await qrcode.toDataURL(otpauthUrl);
29
30 // Generuj backup codes (kody zapasowe)
31 const backupCodes = this.generateBackupCodes(8);
32
33 // Zapisz secret tymczasowo (nie aktywuj jeszcze!)
34 await this.usersRepository.update(userId, {
35 twoFactorSecret: secret,
36 twoFactorBackupCodes: JSON.stringify(
37 backupCodes.map(code => ({ code, used: false }))
38 ),
39 });
40
41 return { secret, qrCodeUrl, backupCodes };
42 }generateURI() buduje adres otpauth://totp/..., a qrcode.toDataURL() zamienia go w obrazek. Sekret zapisujemy, ale 2FA jeszcze nie włączamy. W produkcji sekret szyfruj w bazie, a kody zapasowe hashuj jak hasła.
Krok drugi potwierdza, że skanowanie się udało:
1 // Krok 2: Weryfikacja i aktywacja 2FA
2 async enableTwoFactor(userId: number, code: string): Promise<boolean> {
3 const user = await this.usersRepository.findOne({ where: { id: userId } });
4
5 if (!user || !user.twoFactorSecret) {
6 throw new BadRequestException('Najpierw wygeneruj secret!');
7 }
8
9 // Zweryfikuj kod z aplikacji
10 const { valid } = await verify({
11 token: code,
12 secret: user.twoFactorSecret,
13 ...this.totpOptions,
14 });
15
16 if (!valid) {
17 throw new UnauthorizedException('Nieprawidłowy kod 2FA!');
18 }
19
20 // Aktywuj 2FA
21 await this.usersRepository.update(userId, {
22 twoFactorEnabled: true,
23 });
24
25 return true;
26 }Dopiero poprawny kod z aplikacji włącza 2FA. Gdyby skanowanie się nie udało, legionista nie zostanie odcięty od własnego konta.
Krok trzeci działa przy każdym logowaniu:
1 // Krok 3: Weryfikacja kodu przy logowaniu
2 async verifyCode(userId: number, code: string): Promise<boolean> {
3 const user = await this.usersRepository.findOne({ where: { id: userId } });
4
5 if (!user || !user.twoFactorSecret) {
6 throw new UnauthorizedException('2FA nie jest skonfigurowane!');
7 }
8
9 // Sprawdź czy to backup code
10 if (code.length === 8) {
11 return this.verifyBackupCode(user, code);
12 }
13
14 // Zweryfikuj TOTP kod
15 const { valid } = await verify({
16 token: code,
17 secret: user.twoFactorSecret,
18 ...this.totpOptions,
19 });
20 return valid;
21 }
22
23 // Weryfikacja backup code
24 private async verifyBackupCode(user: User, code: string): Promise<boolean> {
25 const backupCodes = JSON.parse(user.twoFactorBackupCodes || '[]');
26 const codeEntry = backupCodes.find(
27 (bc: any) => bc.code === code && !bc.used
28 );
29
30 if (!codeEntry) return false;
31
32 // Oznacz jako użyty
33 codeEntry.used = true;
34 await this.usersRepository.update(user.id, {
35 twoFactorBackupCodes: JSON.stringify(backupCodes),
36 });
37
38 return true;
39 }Kod ośmioznakowy to kod zapasowy, oznaczany po użyciu jako zużyty, a sześciocyfrowy trafia do verify(). Wynik ma też pole timeStep: zapisz je i przy kolejnej weryfikacji podaj jako afterTimeStep, a ten sam kod nie przejdzie drugi raz.
Na końcu kody zapasowe i wyłączanie 2FA:
1 // Generowanie backup codes
2 private generateBackupCodes(count: number): string[] {
3 const codes: string[] = [];
4 const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
5
6 for (let i = 0; i < count; i++) {
7 let code = '';
8 for (let j = 0; j < 8; j++) {
9 code += chars.charAt(randomInt(chars.length));
10 }
11 codes.push(code);
12 }
13
14 return codes;
15 }
16
17 // Dezaktywacja 2FA
18 async disableTwoFactor(userId: number, code: string): Promise<void> {
19 const isValid = await this.verifyCode(userId, code);
20
21 if (!isValid) {
22 throw new UnauthorizedException('Nieprawidłowy kod - nie można wyłączyć 2FA!');
23 }
24
25 await this.usersRepository.update(userId, {
26 twoFactorEnabled: false,
27 twoFactorSecret: null,
28 twoFactorBackupCodes: null,
29 });
30 }
31}randomInt() z modułu node:crypto korzysta z kryptograficznego generatora liczb losowych. Math.random() jest przewidywalny i do sekretów się nie nadaje. Wyłączenie 2FA wymaga poprawnego kodu, więc samo przejęte konto nie wystarczy, żeby zdjąć drugą straż.
2FA Controller
Kontroler wystawia trzy trasy, wszystkie za JwtAuthGuard ustawionym na klasie:
1// auth/two-factor.controller.ts
2import { Controller, Post, Get, Body, UseGuards, Req } from '@nestjs/common';
3import { TwoFactorService } from './two-factor.service';
4import { JwtAuthGuard } from './guards/jwt-auth.guard';
5
6@Controller('auth/2fa')
7@UseGuards(JwtAuthGuard)
8export class TwoFactorController {
9 constructor(private twoFactorService: TwoFactorService) {}
10
11 // Krok 1: Rozpocznij konfigurację 2FA
12 @Post('setup')
13 async setup(@Req() req) {
14 const result = await this.twoFactorService.generateSecret(req.user.userId);
15
16 return {
17 message: 'Zeskanuj QR kod w aplikacji authenticator',
18 qrCodeUrl: result.qrCodeUrl,
19 backupCodes: result.backupCodes,
20 warning: 'Zapisz backup codes w bezpiecznym miejscu!',
21 };
22 }
23
24 // Krok 2: Potwierdź konfigurację kodem z aplikacji
25 @Post('verify')
26 async verify(@Req() req, @Body('code') code: string) {
27 await this.twoFactorService.enableTwoFactor(req.user.userId, code);
28
29 return {
30 message: '2FA zostało aktywowane! Podwójna straż chroni twoje konto.',
31 enabled: true,
32 };
33 }
34
35 // Wyłącz 2FA
36 @Post('disable')
37 async disable(@Req() req, @Body('code') code: string) {
38 await this.twoFactorService.disableTwoFactor(req.user.userId, code);
39
40 return {
41 message: '2FA zostało dezaktywowane.',
42 enabled: false,
43 };
44 }
45}req.user.userId pochodzi z JwtStrategy. setup zwraca kod QR i kody zapasowe tylko raz, więc aplikacja kliencka powinna poprosić o ich zapisanie.
2FA Guard - strażnik drugiej bramy
Guard przepuszcza żądanie, gdy 2FA jest wyłączone albo sesja już je przeszła:
1// auth/guards/two-factor.guard.ts
2import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
3import { UnauthorizedException } from '@nestjs/common';
4import { TwoFactorService } from '../two-factor.service';
5
6@Injectable()
7export class TwoFactorGuard implements CanActivate {
8 constructor(private twoFactorService: TwoFactorService) {}
9
10 async canActivate(context: ExecutionContext): Promise<boolean> {
11 const request = context.switchToHttp().getRequest();
12 const user = request.user;
13
14 // Jeśli 2FA nie jest włączone, przepuść
15 if (!user.twoFactorEnabled) {
16 return true;
17 }
18
19 // Sprawdź czy użytkownik już przeszedł 2FA w tej sesji
20 if (user.twoFactorVerified) {
21 return true;
22 }
23
24 throw new UnauthorizedException(
25 'Wymagana weryfikacja 2FA! Podaj kod z aplikacji authenticator.'
26 );
27 }
28}Pułapka: JwtStrategy z lekcji o JWT zwraca tylko userId, username i role. Bez pól twoFactorEnabled i twoFactorVerified w req.user warunek !user.twoFactorEnabled jest zawsze prawdziwy i guard przepuszcza każdego. Dodaj oba pola do payloadu pełnego tokenu, wydawanego po udanym 2FA, i zwracaj je z validate().
Flow logowania z 2FA
Cały proces logowania z 2FA wygląda następująco:
- Legionariusz podaje username + password (pierwszy faktor)
- Serwer weryfikuje dane i sprawdza czy 2FA jest aktywne
- Jeśli tak - zwraca tymczasowy token i wymaga kodu 2FA
- Legionariusz otwiera aplikację authenticator i podaje 6-cyfrowy kod
- Serwer weryfikuje kod TOTP i wydaje pełny access token
- Legionariusz ma dostęp do fortu!
1// Przykład flow w AuthService
2async loginWith2FA(loginDto: LoginDto): Promise<any> {
3 // Krok 1: Weryfikuj hasło
4 const user = await this.validateUser(loginDto.username, loginDto.password);
5
6 if (!user) {
7 throw new UnauthorizedException('Nieprawidłowe dane!');
8 }
9
10 // Krok 2: Sprawdź czy 2FA aktywne
11 if (user.twoFactorEnabled) {
12 if (!loginDto.twoFactorCode) {
13 // Zwróć tymczasowy token wymagający 2FA, podpisany osobnym sekretem,
14 // żeby JwtAuthGuard nie uznał go za pełny access token
15 const tempToken = this.jwtService.sign(
16 { sub: user.id, requiresTwoFactor: true },
17 { secret: this.configService.get('JWT_2FA_SECRET'), expiresIn: '5m' },
18 );
19 return { requiresTwoFactor: true, tempToken };
20 }
21
22 // Krok 3: Zweryfikuj kod 2FA
23 const is2FAValid = await this.twoFactorService.verifyCode(
24 user.id,
25 loginDto.twoFactorCode,
26 );
27
28 if (!is2FAValid) {
29 throw new UnauthorizedException('Nieprawidłowy kod 2FA!');
30 }
31 }
32
33 // Krok 4: Wydaj pełny token
34 return this.generateTokenPair(user);
35}Tymczasowy token podpisujemy osobnym sekretem JWT_2FA_SECRET. Gdyby miał ten sam sekret co access token, JwtAuthGuard wpuściłby jego posiadacza do chronionych tras i druga straż nie miałaby nic do roboty. generateTokenPair() znasz z lekcji o refresh tokenach. Jako drugi faktor wybieraj aplikację TOTP zamiast SMS-ów, które można przechwycić przez podmianę karty SIM - i to polecam.
W kolejnym module przeniesiesz tokeny JWT do komunikacji WebSocket.
Pamiętaj: hasło może wykraść jeden podstęp, ale sekret TOTP zostaje w telefonie legionisty, więc wróg musi pokonać dwie niezależne straże naraz.
Kod do tej lekcji: src/auth/two-factor.service.ts
1// Two-Factor Authentication (2FA) w NestJS
2import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
3import { authenticator } from 'otplib';
4import * as qrcode from 'qrcode';
5
6// ============================================
7// 1. TwoFactorService - serce 2FA
8// ============================================
9@Injectable()
10class TwoFactorService {
11 constructor() {
12 // Konfiguracja TOTP
13 authenticator.options = {
14 digits: 6, // 6-cyfrowy kod
15 step: 30, // Nowy kod co 30 sekund
16 window: 1, // Tolerancja +/- 1 krok
17 };
18 }
19
20 // TODO: Zaimplementuj generowanie secretu
21 async generateSecret(userId: number, email: string) {
22 // TODO: Uzyj authenticator.generateSecret()
23 const secret = authenticator.generateSecret();
24
25 // TODO: Wygeneruj URI dla aplikacji authenticator
26 const otpauthUrl = authenticator.keyuri(
27 email,
28 'Imperium Romanum',
29 secret,
30 );
31
32 // TODO: Wygeneruj QR kod
33 const qrCodeUrl = await qrcode.toDataURL(otpauthUrl);
34
35 // TODO: Wygeneruj backup codes
36 const backupCodes = this.generateBackupCodes(8);
37
38 return { secret, qrCodeUrl, backupCodes };
39 }
40
41 // TODO: Zaimplementuj weryfikacje kodu TOTP
42 verifyCode(secret: string, code: string): boolean {
43 // TODO: Uzyj authenticator.verify()
44 return authenticator.verify({
45 token: code,
46 secret: secret,
47 });
48 }
49
50 // Generowanie backup codes
51 private generateBackupCodes(count: number): string[] {
52 const codes: string[] = [];
53 const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
54
55 for (let i = 0; i < count; i++) {
56 let code = '';
57 for (let j = 0; j < 8; j++) {
58 code += chars.charAt(Math.floor(Math.random() * chars.length));
59 }
60 codes.push(code);
61 }
62 return codes;
63 }
64}
65
66// ============================================
67// 2. Demonstracja flow 2FA
68// ============================================
69console.log('=== Two-Factor Authentication (2FA) ===');
70console.log('');
71console.log('Flow konfiguracji:');
72console.log('1. POST /auth/2fa/setup -> QR kod + backup codes');
73console.log('2. Uzytkownik skanuje QR w Google Authenticator');
74console.log('3. POST /auth/2fa/verify -> potwierdz kodem z aplikacji');
75console.log('');
76console.log('Flow logowania z 2FA:');
77console.log('1. POST /auth/login -> { requiresTwoFactor: true, tempToken }');
78console.log('2. POST /auth/2fa/authenticate -> pelny JWT token');
79console.log('');
80console.log('TOTP: Time-based One-Time Password');
81console.log('Kod zmienia sie co 30 sekund');
82console.log('Backup codes: jednorazowe kody zapasowe');
83Widzisz błąd w tej lekcji?