Cours NestJS · Module 4: Authentification

Passport.js - le système de contrôle d'accès

7 min de lecture
Dans cette leçon6

Tu sais déjà délivrer un laissez-passer JWT et vérifier un mot de passe. Mais dans un véritable Empire, toutes sortes de visiteurs se présentent aux portes : l'un a un mot de passe, l'autre un laissez-passer scellé, le troisième une lettre de recommandation d'un allié de Gaule, le quatrième une clé de la porte marchande. Traiter chacun séparément, c'est répéter quatre fois la même logique - et chaque voie d'entrée est une nouvelle occasion de se tromper.

Les Romains ont installé à la porte un corps de garde avec un seul règlement et plusieurs sentinelles : chaque sentinelle connaît un type de document, mais toutes font leur rapport de la même façon. Dans NestJS, ce corps de garde s'appelle Passport.js - une bibliothèque où chaque méthode de connexion est une stratégie distincte, et l'écosystème en propose plus de 500 toutes prêtes.

Les trois couches du corps de garde

Avant d'écrire une ligne de code, établissons de quoi ce corps de garde se compose - c'est le squelette de toute la leçon et il revient à chaque stratégie :

  1. Passport Module - l'enregistrement : indique à NestJS quelles sentinelles nous employons.
  2. Strategy - la logique de vérification : une sentinelle contrôlant un type de document.
  3. Guard - l'activation : désigne quelle sentinelle nous appelons à cette porte précise.
  4. Le décorateur @UseGuards() - poster la sentinelle à l'entrée d'un endpoint.

Retiens cet ordre de bas en haut : la stratégie sait comment vérifier, le guard sait qui appeler, le décorateur sait où le poster.

La stratégie locale - la sentinelle des mots de passe

Commençons par la sentinelle la plus simple : elle vérifie un identifiant et un mot de passe.

1@Injectable()
2export class LocalStrategy extends PassportStrategy(Strategy) {
3  constructor(private authService: AuthService) {
4    super({
5      usernameField: 'username',
6      passwordField: 'password',
7    });
8  }
9
10  async validate(username: string, password: string): Promise<any> {
11    const user = await this.authService.validateUser(username, password);
12
13    if (!user) {
14      throw new UnauthorizedException();
15    }
16
17    return user;
18  }
19}

Parcourons cette classe, car chacun de ses éléments se répétera dans les stratégies suivantes. PassportStrategy(Strategy) est une classe de base construite à partir du Strategy importé - ici du paquet passport-local. Change le paquet et tu changes le type de sentinelle, le reste du squelette demeure.

L'appel super() dans le constructeur configure la stratégie. usernameField et passwordField indiquent de quels champs de la requête tirer les données - et c'est un point d'accrochage fréquent, car si ton formulaire envoie email au lieu de username, c'est précisément ici que tu le déclares.

Le cœur est validate(). Passport l'appelle lui-même en lui passant les champs extraits, et ton rôle est de répondre : qui est-ce. Note le contrat de cette méthode - c'est la phrase la plus importante de cette leçon. L'objet retourné atterrit dans request.user et devient accessible dans le contrôleur. Quand le document est faux, tu ne renvoies ni null ni false - tu lèves UnauthorizedException, et NestJS la transforme en réponse 401.

Le guard - appeler la sentinelle par son nom

Une stratégie n'agira pas d'elle-même. Il faut l'activer, et c'est le rôle du guard :

1@Injectable()
2export class LocalAuthGuard extends AuthGuard('local') {}

Oui, c'est tout le corps de la classe - vide. AuthGuard('local') construit un guard tout prêt qui retrouve la stratégie enregistrée par son nom et exécute son validate(). Le nom 'local' n'est pas arbitraire : c'est l'identifiant par défaut de la stratégie du paquet passport-local, tout comme 'jwt' appartient à passport-jwt et 'google' à la stratégie Google.

Le guard des laissez-passer JWT rencontrés dans la leçon précédente est identique :

1@Injectable()
2export class JwtAuthGuard extends AuthGuard('jwt') {}

Pourquoi écrire une classe, alors qu'on pourrait mettre @UseGuards(AuthGuard('local')) directement ? Pour deux raisons : le nom LocalAuthGuard se lit mieux dans un contrôleur, et quand tu voudras ajouter ton propre comportement, tu auras un endroit où l'inscrire. C'est exactement à cela que sert la méthode handleRequest, que tu peux redéfinir :

1@Injectable()
2export class JwtAuthGuard extends AuthGuard('jwt') {
3  handleRequest(err: any, user: any) {
4    if (err) {
5      throw err;
6    }
7
8    if (!user) {
9      throw new UnauthorizedException('Laissez-passer invalide ou expiré');
10    }
11
12    return user;
13  }
14}

L'ordre est ici logique et mérite d'être retenu : d'abord tu vérifies l'erreur, puis la présence d'un utilisateur, et seulement à la fin tu renvoies l'objet - et ce que tu renvoies atterrit dans request.user. Nous redéfinissons cette méthode surtout pour donner un message lisible au lieu d'un 401 nu.

Une stratégie externe - la lettre de Gaule

Puisque le squelette ne change pas, ajouter la connexion via Google se résume à remplacer le paquet et la configuration. La sentinelle est nouvelle, le règlement reste le même.

1@Injectable()
2export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
3  constructor(private configService: ConfigService) {
4    super({
5      clientID: configService.get('GOOGLE_CLIENT_ID'),
6      clientSecret: configService.get('GOOGLE_CLIENT_SECRET'),
7      callbackURL: '/auth/google/callback',
8      scope: ['email', 'profile'],
9    });
10  }
11}

Ici, Strategy provient du paquet passport-google-oauth20. clientID et clientSecret sont les identifiants de ton application délivrés par Google - c'est pourquoi nous les lisons via ConfigService depuis les variables d'environnement plutôt que de les écrire dans le code. Un secret dans un dépôt est le secret de quelqu'un d'autre ; je te recommande d'en faire une règle sans exception.

callbackURL est l'adresse vers laquelle Google renverra l'utilisateur après la connexion - ton application doit y avoir un endpoint. scope déclare quelles données tu demandes.

Note le second argument de PassportStrategy(Strategy, 'google') : c'est un nom donné explicitement, par lequel le guard AuthGuard('google') retrouvera cette stratégie. Pour la stratégie locale, nous pouvions l'omettre, car le nom par défaut suffisait.

Poster la sentinelle à la porte

La dernière étape consiste à désigner les endpoints que la sentinelle surveille :

1@Controller('auth')
2export class AuthController {
3  @UseGuards(LocalAuthGuard)
4  @Post('login')
5  async login(@Request() req) {
6    return this.authService.generateToken(req.user);
7  }
8
9  @UseGuards(JwtAuthGuard)
10  @Get('profile')
11  getProfile(@Request() req) {
12    return req.user;
13  }
14}

Ici toute la chaîne se referme. Le guard a lancé la stratégie, la stratégie a exécuté validate(), l'objet retourné a atterri dans request.user - et c'est seulement maintenant que la méthode du contrôleur peut le récupérer. Si la vérification a échoué, la méthode ne s'exécute pas du tout : le guard arrête la requête avant qu'elle n'atteigne le contrôleur.

Récapitulatif

Le corps de garde est en place, et tu sais y employer n'importe quelle sentinelle :

  • Passport.js est un framework d'authentification où chaque méthode de connexion est une stratégie distincte - l'écosystème en fournit plus de 500,
  • les couches s'empilent depuis le bas : la stratégie (comment vérifier), le guard (qui appeler), @UseGuards() (où le poster),
  • une stratégie hérite de PassportStrategy(Strategy), se configure par super({...}) et implémente validate(),
  • validate() renvoie l'utilisateur, qui atterrit dans request.user, et en cas de rejet elle lève UnauthorizedException,
  • un guard est généralement une classe vide : extends AuthGuard('local'), AuthGuard('jwt'), AuthGuard('google'),
  • tu redéfinis handleRequest pour un traitement personnalisé : vérifie l'erreur, vérifie l'utilisateur, renvoie l'objet,
  • les stratégies externes ne diffèrent que par le paquet et la configuration ; lis clientID et clientSecret depuis ConfigService, jamais depuis le code,
  • le second argument de PassportStrategy(Strategy, 'nom') donne à la stratégie le nom sous lequel son guard la retrouvera.

Dans la prochaine leçon, nous descendrons d'un niveau - vers les rôles et les permissions, c'est-à-dire la question de ce qu'un visiteur déjà admis a le droit de faire. En attendant, retiens ceci : la stratégie sait comment vérifier un document, le guard sait quelle sentinelle appeler, et request.user est le rapport laissé par un contrôle réussi.

Code de cette leçon : src/auth/passport-strategies.ts
1// Passport.js - le système universel de contrôle d'accès
2// Stratégies Passport pour diverses méthodes de connexion
3import { Injectable, UnauthorizedException } from '@nestjs/common';
4import { PassportStrategy } from '@nestjs/passport';
5import { Strategy as LocalStrategy } from 'passport-local';
6import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
7
8// ===========================================
9// 1. Local Strategy - connexion par mot de passe
10// ===========================================
11
12@Injectable()
13export class RomanLocalStrategy extends PassportStrategy(LocalStrategy) {
14  constructor() {
15    super({
16      usernameField: 'username', // Champ du nom d'utilisateur
17      passwordField: 'password', // Champ du mot de passe
18    });
19  }
20
21  // Méthode appelée lors de la connexion
22  async validate(username: string, password: string) {
23    // Appelle ici AuthService.validateUser()
24    console.log('Local Strategy : je vérifie', username);
25
26    // Simulation de la validation
27    if (username === 'caesar' && password === 'spqr') {
28      return { id: '1', username: 'caesar', rank: 'consul' };
29    }
30
31    throw new UnauthorizedException('Données de connexion incorrectes !');
32  }
33}
34
35// ===========================================
36// 2. JWT Strategy - vérification du token
37// ===========================================
38
39@Injectable()
40export class RomanJwtStrategy extends PassportStrategy(JwtStrategy) {
41  constructor() {
42    super({
43      // D'où récupérer le token
44      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
45      // Faut-il ignorer l'expiration
46      ignoreExpiration: false,
47      // Secret pour vérifier la signature
48      secretOrKey: 'spqr-secret-key',
49    });
50  }
51
52  // Méthode appelée après le décodage du token
53  async validate(payload: any) {
54    console.log('JWT Strategy : token décodé', payload);
55
56    // Le payload du token est placé dans req.user
57    return {
58      id: payload.sub,
59      username: payload.username,
60      rank: payload.rank,
61    };
62  }
63}
64
65// ===========================================
66// 3. Utilisation des stratégies dans un contrôleur
67// ===========================================
68
69import { Controller, Post, Get, UseGuards, Request } from '@nestjs/common';
70import { AuthGuard } from '@nestjs/passport';
71
72@Controller('auth')
73export class AuthController {
74  // Connexion - utilise LocalStrategy
75  @Post('login')
76  @UseGuards(AuthGuard('local'))
77  async login(@Request() req) {
78    console.log('Connecté :', req.user);
79    return { message: "Bienvenue dans l'Imperium !", user: req.user };
80  }
81
82  // Endpoint protégé - utilise JwtStrategy
83  @Get('profile')
84  @UseGuards(AuthGuard('jwt'))
85  getProfile(@Request() req) {
86    console.log('Profil du légionnaire :', req.user);
87    return { user: req.user };
88  }
89}
90
91console.log('=== Stratégies Passport ===');
92console.log('LocalStrategy : username + password -> validate()');
93console.log('JwtStrategy : Bearer token -> validate(payload)');
94console.log('AuthGuard("local") -> utilise LocalStrategy');
95console.log('AuthGuard("jwt") -> utilise JwtStrategy');
96

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

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Qu'est-ce que Passport.js dans le contexte de NestJS ?

  2. 2. Que fait la méthode validate() dans LocalStrategy ?

Voici 2 des 12 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Complétez la classe LocalStrategy qui étend PassportStrategy(Strategy) de passport-local et implémente validate(username, password) en levant UnauthorizedException lorsque l'utilisateur n'existe pas

  • Tri vertical

    Placez les options de configuration de super() dans LocalStrategy de gauche à droite

  • Clique dans l'ordre

    Placez les éléments de la définition de LocalAuthGuard dans le bon ordre

  • Éditeur de code

    Créez une classe JwtAuthGuard avec le décorateur @Injectable() qui étend AuthGuard('jwt')

  • Tri vertical

    Ordonnez les couches d'authentification dans NestJS, de la plus basse à la plus haute

  • Éditeur de code

    Complétez la GoogleStrategy avec les options clientID et clientSecret récupérées depuis ConfigService et callbackURL défini à '/auth/google/callback'

  • Clique dans l'ordre

    Placez la logique de la méthode handleRequest dans AuthGuard, de la première à la dernière étape

  • Tri vertical

    Ordonnez les étapes du processus OAuth 2.0 (par ex. la connexion via Google)

  • Éditeur de code

    Complétez le AuthController avec un endpoint @Post('login') protégé par @UseGuards(LocalAuthGuard) qui renvoie un token JWT

  • Tri horizontal

    Placez la syntaxe permettant d'utiliser plusieurs guards sur un endpoint

  • Éditeur de code

    Complétez la méthode generateRefreshToken dans AuthService qui crée un JWT avec expiresIn: '7d' et contient un payload avec userId et tokenType: 'refresh'

  • Clique dans l'ordre

    Placez les étapes du processus de renouvellement d'un token JWT

  • Éditeur de code

    Complétez le contrôleur avec un endpoint @Get('profile') protégé par @UseGuards(JwtAuthGuard) qui renvoie les données de l'utilisateur connecté depuis @Req() req

  • Tri vertical

    Placez la syntaxe permettant de récupérer l'utilisateur depuis la requête dans un endpoint protégé

  • Clique dans l'ordre

    Placez les éléments de l'appel jwtService.sign() dans le bon ordre

  • Éditeur de code

    Complétez la méthode login(user) dans AuthService qui crée un payload avec sub: user.id et username: user.username, puis renvoie { access_token: this.jwtService.sign(payload) }

  • Tri vertical

    Ordonnez les éléments de configuration d'AuthModule, de imports à exports

  • Éditeur de code

    Complétez la méthode validate dans AuthService qui vérifie l'email et le mot de passe, et lève throw new UnauthorizedException('Invalid login credentials') lorsque l'utilisateur n'est pas trouvé

  • Tri horizontal

    Placez la syntaxe d'import d'AuthGuard depuis @nestjs/passport

  • Clique dans l'ordre

    Placez l'ordre d'exécution des couches dans une requête HTTP avec authentification

  • Éditeur de code

    Complétez l'AuthModule en important PassportModule, JwtModule.registerAsync(jwtConfig), avec providers: [AuthService, JwtStrategy, LocalStrategy] et exports: [AuthService]

  • Tri horizontal

    Placez la syntaxe de la méthode validate dans JwtStrategy, de async au renvoi de l'objet

  • Tri vertical

    Ordonnez le cycle de vie d'un token JWT, de sa création à son expiration