Cours NestJS · Module 4: Authentification

Authentification à deux facteurs (2FA) - la double garde de l'Empire

10 min de lecture
Dans cette leçon7

Un légionnaire a saisi son mot de passe sur une fausse page qui imitait la porte du fort. Dès cet instant, l'ennemi le connaît, et aucun hachage dans la base n'y change rien, car l'attaquant se connecte tout simplement. L'architecte Vitruvius a décidé de poster à la porte un second garde, qui vérifie tout autre chose que le mot de passe. C'est ainsi que fonctionne l'authentification à deux facteurs (2FA).

Qu'est-ce que le 2FA ?

L'authentification à deux facteurs exige deux facteurs de vérification indépendants :

  • Ce que tu sais - le mot de passe (premier facteur)
  • Ce que tu possèdes - un téléphone avec une application qui génère des codes (second facteur)

La troisième catégorie est « ce que tu es », c'est-à-dire la biométrie. Même si l'ennemi intercepte le mot de passe d'un légionnaire, il n'entrera pas dans le fort sans le second facteur !

TOTP - Time-based One-Time Password

Le TOTP (norme RFC 6238) calcule un code à partir de deux ingrédients : un secret partagé et l'heure actuelle. Le téléphone et le serveur calculent le même code chacun de leur côté, l'application fonctionne donc même hors ligne :

1// Comment fonctionne le TOTP :
2// 1. Le serveur génère un secret aléatoire (clé)
3// 2. L'utilisateur scanne le QR code contenant le secret dans l'application d'authentification (Google Authenticator)
4// 3. L'application génère un code à 6 chiffres toutes les 30 secondes
5// 4. Le serveur vérifie le code en utilisant le même secret et le même temps

Le secret ne voyage vers le téléphone qu'une seule fois, dans le QR code. Attention à un mythe courant : un code TOTP n'est pas à usage unique par lui-même. Dans sa fenêtre de temps, il fonctionne une deuxième fois si le serveur ne retient pas qu'il l'a déjà accepté.

Installation des bibliothèques

Il te faut deux paquets : otplib pour les codes et qrcode pour l'image destinée à l'application :

1# otplib (v13+) - bibliothèque TOTP et HOTP
2npm install otplib
3# qrcode - génération de QR codes
4npm install qrcode
5npm install -D @types/qrcode

La version 13 d'otplib (janvier 2026) a réécrit l'API de zéro. L'objet authenticator de la version 12, que tu trouveras dans les anciens tutoriels, a disparu, remplacé par les fonctions generateSecret, generateURI et une fonction verify asynchrone.

Le service TwoFactor

Le service commence par les imports et la configuration. Nous importons generateSecret sous le nom generateTotpSecret pour qu'il ne se confonde pas avec la méthode du service :

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  // Configuration du TOTP : 6 chiffres et un nouveau code toutes les 30 secondes sont
18  // les valeurs par défaut d'otplib ; on accepte un code à +/- 30 s, soit un pas de chaque côté
19  private readonly totpOptions = { epochTolerance: 30 };

epochTolerance: 30 est l'équivalent de l'ancien window: 1 : le serveur accepte aussi le code du pas de 30 secondes précédent et du suivant, ce qui pardonne les petits décalages d'horloge.

La première étape génère le secret et le QR code :

1  // Étape 1 : Générer le secret et le QR code
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('Légionnaire introuvable !');
11    }
12
13    if (user.twoFactorEnabled) {
14      throw new BadRequestException('Le 2FA est déjà actif !');
15    }
16
17    // Générer un secret aléatoire
18    const secret = generateTotpSecret();
19
20    // Générer l'URI pour l'application d'authentification
21    const otpauthUrl = generateURI({
22      issuer: 'Imperium Romanum', // Nom de l'application
23      label: user.email,
24      secret,
25    });
26
27    // Générer le QR code sous forme de data URL
28    const qrCodeUrl = await qrcode.toDataURL(otpauthUrl);
29
30    // Générer les codes de secours
31    const backupCodes = this.generateBackupCodes(8);
32
33    // Enregistrer le secret temporairement (ne pas encore activer !)
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() construit une adresse otpauth://totp/..., et qrcode.toDataURL() la transforme en image. Nous enregistrons le secret, mais nous n'activons pas encore le 2FA. En production, chiffre le secret dans la base et hache les codes de secours comme des mots de passe.

La deuxième étape confirme que le scan a réussi :

1  // Étape 2 : Vérifier et activer le 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("Génère d'abord le secret !");
7    }
8
9    // Vérifier le code provenant de l'application
10    const { valid } = await verify({
11      token: code,
12      secret: user.twoFactorSecret,
13      ...this.totpOptions,
14    });
15
16    if (!valid) {
17      throw new UnauthorizedException('Code 2FA invalide !');
18    }
19
20    // Activer le 2FA
21    await this.usersRepository.update(userId, {
22      twoFactorEnabled: true,
23    });
24
25    return true;
26  }

Seul un code correct provenant de l'application active le 2FA. Si le scan avait échoué, le légionnaire n'est pas coupé de son propre compte.

La troisième étape s'exécute à chaque connexion :

1  // Étape 3 : Vérifier le code lors de la connexion
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("Le 2FA n'est pas configuré !");
7    }
8
9    // Vérifier s'il s'agit d'un code de secours
10    if (code.length === 8) {
11      return this.verifyBackupCode(user, code);
12    }
13
14    // Vérifier le code TOTP
15    const { valid } = await verify({
16      token: code,
17      secret: user.twoFactorSecret,
18      ...this.totpOptions,
19    });
20    return valid;
21  }
22
23  // Vérification du code de secours
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    // Marquer comme utilisé
33    codeEntry.used = true;
34    await this.usersRepository.update(user.id, {
35      twoFactorBackupCodes: JSON.stringify(backupCodes),
36    });
37
38    return true;
39  }

Un code de huit caractères est un code de secours, marqué comme utilisé une fois consommé, et un code à six chiffres part vers verify(). Le résultat contient aussi un champ timeStep : enregistre-le et transmets-le comme afterTimeStep lors de la vérification suivante, et le même code ne passera pas deux fois.

Pour finir, les codes de secours et la désactivation du 2FA :

1  // Générer les codes de secours
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  // Désactiver le 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('Code invalide - impossible de désactiver le 2FA !');
23    }
24
25    await this.usersRepository.update(userId, {
26      twoFactorEnabled: false,
27      twoFactorSecret: null,
28      twoFactorBackupCodes: null,
29    });
30  }
31}

randomInt(), du module node:crypto, utilise un générateur de nombres aléatoires cryptographiquement sûr. Math.random() est prévisible et ne convient pas aux secrets. Désactiver le 2FA exige un code valide : une session détournée ne suffit donc pas à retirer la seconde garde.

Le contrôleur 2FA

Le contrôleur expose trois routes, toutes derrière un JwtAuthGuard placé sur la classe :

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  // Étape 1 : Démarrer la configuration du 2FA
12  @Post('setup')
13  async setup(@Req() req) {
14    const result = await this.twoFactorService.generateSecret(req.user.userId);
15
16    return {
17      message: "Scanne le QR code dans l'application d'authentification",
18      qrCodeUrl: result.qrCodeUrl,
19      backupCodes: result.backupCodes,
20      warning: 'Enregistre les codes de secours dans un endroit sûr !',
21    };
22  }
23
24  // Étape 2 : Confirmer la configuration avec un code de l'application
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: 'Le 2FA a été activé ! Une double garde protège ton compte.',
31      enabled: true,
32    };
33  }
34
35  // Désactiver le 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: 'Le 2FA a été désactivé.',
42      enabled: false,
43    };
44  }
45}

req.user.userId vient de JwtStrategy. setup ne renvoie le QR code et les codes de secours qu'une seule fois, l'application cliente doit donc demander à l'utilisateur de les conserver.

Le guard 2FA - le gardien de la seconde porte

Le guard laisse passer la requête quand le 2FA est désactivé ou que la session l'a déjà franchi :

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    // Si le 2FA n'est pas activé, laisser passer
15    if (!user.twoFactorEnabled) {
16      return true;
17    }
18
19    // Vérifier si l'utilisateur a déjà passé le 2FA dans cette session
20    if (user.twoFactorVerified) {
21      return true;
22    }
23
24    throw new UnauthorizedException(
25      "Vérification 2FA requise ! Saisis le code de ton application d'authentification."
26    );
27  }
28}

Le piège : JwtStrategy, vue dans la leçon sur les JWT, ne renvoie que userId, username et role. Sans les champs twoFactorEnabled et twoFactorVerified dans req.user, la condition !user.twoFactorEnabled est toujours vraie et le guard laisse entrer tout le monde. Ajoute ces deux champs au payload du token complet délivré après un 2FA réussi, et renvoie-les depuis validate().

Le flux de connexion avec 2FA

Le processus complet de connexion avec 2FA se déroule ainsi :

  1. Le légionnaire fournit username + password (premier facteur)
  2. Le serveur vérifie les identifiants et regarde si le 2FA est actif
  3. Si oui - il renvoie un token temporaire et demande le code 2FA
  4. Le légionnaire ouvre l'application d'authentification et saisit le code à 6 chiffres
  5. Le serveur vérifie le code TOTP et délivre un access token complet
  6. Le légionnaire a accès au fort !
1// Exemple de flux dans AuthService
2async loginWith2FA(loginDto: LoginDto): Promise<any> {
3  // Étape 1 : Vérifier le mot de passe
4  const user = await this.validateUser(loginDto.username, loginDto.password);
5
6  if (!user) {
7    throw new UnauthorizedException('Identifiants invalides !');
8  }
9
10  // Étape 2 : Vérifier si le 2FA est actif
11  if (user.twoFactorEnabled) {
12    if (!loginDto.twoFactorCode) {
13      // Renvoyer un token temporaire exigeant le 2FA, signé avec un secret distinct
14      // pour que JwtAuthGuard ne l'accepte jamais comme un access token complet
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    // Étape 3 : Vérifier le code 2FA
23    const is2FAValid = await this.twoFactorService.verifyCode(
24      user.id,
25      loginDto.twoFactorCode,
26    );
27
28    if (!is2FAValid) {
29      throw new UnauthorizedException('Code 2FA invalide !');
30    }
31  }
32
33  // Étape 4 : Délivrer le token complet
34  return this.generateTokenPair(user);
35}

Nous signons le token temporaire avec un secret distinct, JWT_2FA_SECRET. S'il partageait le secret de l'access token, JwtAuthGuard laisserait son porteur accéder aux routes protégées et la seconde garde n'aurait plus rien à faire. Tu connais generateTokenPair() depuis la leçon sur les refresh tokens. Comme second facteur, choisis une application TOTP plutôt que des SMS, qu'on peut intercepter par un échange de carte SIM - c'est ce que je te recommande.

Dans le prochain module, tu emporteras les tokens JWT dans la communication WebSocket.

Souviens-toi : une seule ruse peut voler un mot de passe, mais le secret TOTP reste dans le téléphone du légionnaire, donc l'ennemi doit vaincre deux gardes indépendantes à la fois.

Code de cette leçon : src/auth/two-factor.service.ts
1// Authentification à deux facteurs (2FA) dans NestJS
2import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
3import { authenticator } from 'otplib';
4import * as qrcode from 'qrcode';
5
6// ============================================
7// 1. TwoFactorService - le cœur du 2FA
8// ============================================
9@Injectable()
10class TwoFactorService {
11  constructor() {
12    // Configuration du TOTP
13    authenticator.options = {
14      digits: 6,    // Code à 6 chiffres
15      step: 30,     // Nouveau code toutes les 30 secondes
16      window: 1,    // Tolérance +/- 1 pas
17    };
18  }
19
20  // TODO: Implémente la génération du secret
21  async generateSecret(userId: number, email: string) {
22    // TODO: Utilise authenticator.generateSecret()
23    const secret = authenticator.generateSecret();
24
25    // TODO: Génère l'URI pour l'application d'authentification
26    const otpauthUrl = authenticator.keyuri(
27      email,
28      'Imperium Romanum',
29      secret,
30    );
31
32    // TODO: Génère le QR code
33    const qrCodeUrl = await qrcode.toDataURL(otpauthUrl);
34
35    // TODO: Génère les codes de secours
36    const backupCodes = this.generateBackupCodes(8);
37
38    return { secret, qrCodeUrl, backupCodes };
39  }
40
41  // TODO: Implémente la vérification du code TOTP
42  verifyCode(secret: string, code: string): boolean {
43    // TODO: Utilise authenticator.verify()
44    return authenticator.verify({
45      token: code,
46      secret: secret,
47    });
48  }
49
50  // Génération des codes de secours
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. Démonstration du flux 2FA
68// ============================================
69console.log('=== Authentification à deux facteurs (2FA) ===');
70console.log('');
71console.log('Flux de configuration :');
72console.log('1. POST /auth/2fa/setup -> QR code + codes de secours');
73console.log("2. L'utilisateur scanne le QR dans Google Authenticator");
74console.log("3. POST /auth/2fa/verify -> confirme avec le code de l'application");
75console.log('');
76console.log('Flux de connexion avec 2FA :');
77console.log('1. POST /auth/login -> { requiresTwoFactor: true, tempToken }');
78console.log('2. POST /auth/2fa/authenticate -> token JWT complet');
79console.log('');
80console.log('TOTP : mot de passe à usage unique basé sur le temps');
81console.log('Le code change toutes les 30 secondes');
82console.log('Codes de secours : des codes de secours à usage unique');
83

Tu as repéré une erreur dans cette leçon ?