Skip to content

8. Authentification et autorisation

Deux questions distinctes se posent pour chaque requête :

  • qui est l'utilisateur ? C'est l'authentification : il s'est identifié par un login et un mot de passe, et chaque requête suivante doit prouver qu'elle vient de lui ;
  • a-t-il le droit d'accéder à cette page ? C'est l'autorisation : dans l'étude de cas, seul un utilisateur de rôle ADMIN gère les médecins et les clients.

Le serveur étant sans état (chapitre 6), la preuve d'identité doit voyager avec chaque requête. On utilise un jeton JWT ([JSON Web Token]) : après la connexion, le serveur fabrique un jeton qui contient l'identité de l'utilisateur et le signe avec une clé secrète. Le jeton est déposé dans un cookie ; le navigateur le renvoie à chaque requête ; le serveur vérifie la signature. L'utilisateur peut lire son jeton, mais pas le modifier : toute modification rend la signature invalide.

[NestJS] s'appuie pour cela sur deux bibliothèques : [passport] (et sa stratégie [passport-jwt]), qui vérifie les jetons, et [@nestjs/jwt], qui les fabrique.

8.1. Exemple 25 : connexion, jeton, page protégée

25-auth-jwt/
├── src/
│   ├── auth/
│   │   ├── auth.controller.ts
│   │   ├── auth.service.ts
│   │   ├── constantes.ts
│   │   ├── jwt-auth.guard.ts
│   │   ├── jwt.strategy.ts
│   │   └── utilisateurs.service.ts
│   ├── app.module.ts
│   ├── erreurs.filter.ts
│   ├── main.ts
│   └── profil.controller.ts
├── views/
│   ├── accueil.hbs
│   ├── erreur.hbs
│   ├── layout.hbs
│   ├── login.hbs
│   └── profil.hbs
├── nest-cli.json
└── tsconfig.json

Le fichier [src/auth/constantes.ts] :

1
2
3
4
5
6
// le nom du cookie qui transporte le jeton
export const COOKIE_JWT = 'jeton';
// la clé secrète qui signe les jetons (en production : une variable d'environnement)
export const JWT_SECRET = process.env.JWT_SECRET ?? 'secret-de-developpement';
// durée de vie d'un jeton, en secondes
export const JWT_DUREE = 3600;

ligne 4 : la clé qui signe les jetons. Qui la connaît peut fabriquer des jetons valides : en production, elle vient d'une variable d'environnement, jamais du code source.

Le fichier [src/auth/utilisateurs.service.ts] :

import { Injectable } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';

export interface Utilisateur {
  id: number;
  login: string;
  nom: string;
  role: 'ADMIN' | 'USER';
  motDePasseHache: string; // JAMAIS le mot de passe lui-même
}

// les utilisateurs, en mémoire (le chapitre suivant les mettra en base).
// Le mot de passe de chacun est son login ; seul son hachage bcrypt est conservé
@Injectable()
export class UtilisateursService {
  private readonly utilisateurs: Utilisateur[] = [
    { id: 1, login: 'admin', nom: 'Administrateur', role: 'ADMIN', motDePasseHache: bcrypt.hashSync('admin', 10) },
    { id: 2, login: 'marie', nom: 'Marie Pélissier', role: 'USER', motDePasseHache: bcrypt.hashSync('marie', 10) },
  ];

  parLogin(login: string): Utilisateur | undefined {
    return this.utilisateurs.find((u) => u.login === login);
  }
}
  • ligne 9 : on ne conserve jamais un mot de passe, seulement son hachage : une empreinte calculée par une fonction à sens unique. On peut vérifier qu'un mot de passe correspond à une empreinte, mais pas retrouver le mot de passe à partir de l'empreinte ;
  • lignes 16-19 : [bcrypt.hashSync(motDePasse, 10)] calcule le hachage avec l'algorithme [bcrypt]. Le 10 est le « coût » : bcrypt est volontairement lent (2¹⁰ itérations), pour décourager qui voudrait essayer des millions de mots de passe. Deux hachages du même mot de passe sont différents (un « sel » aléatoire est ajouté) : c'est [bcrypt.compare] qui sait les vérifier.

Le fichier [src/auth/auth.service.ts] :

import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { Utilisateur, UtilisateursService } from './utilisateurs.service';

// ce que contient le jeton : de quoi identifier l'utilisateur sans relire la base
export interface ContenuJeton {
  sub: number; // "subject" : l'identifiant de l'utilisateur (convention JWT)
  login: string;
  nom: string;
  role: string;
}

@Injectable()
export class AuthService {
  constructor(
    private readonly utilisateurs: UtilisateursService,
    private readonly jwt: JwtService,
  ) {}

  // l'utilisateur si le couple login / mot de passe est correct, null sinon
  async verifier(login: string, motDePasse: string): Promise<Utilisateur | null> {
    const u = this.utilisateurs.parLogin(login);
    // bcrypt.compare hache le mot de passe saisi et compare les hachages
    return u && (await bcrypt.compare(motDePasse, u.motDePasseHache)) ? u : null;
  }

  // un jeton SIGNÉ : le navigateur peut le lire, mais pas le modifier
  creerJeton(u: Utilisateur): string {
    const contenu: ContenuJeton = { sub: u.id, login: u.login, nom: u.nom, role: u.role };
    return this.jwt.sign(contenu);
  }
}
  • lignes 7-12 : le contenu du jeton. [sub] ([subject]) est le nom conventionnel de l'identifiant de l'utilisateur. On y met ce dont les pages ont besoin (nom, rôle), pour ne pas relire la base à chaque requête ;
  • lignes 22-26 : vérification du login et du mot de passe ;
  • lignes 29-32 : [JwtService.sign] fabrique le jeton signé. [JwtService] est configuré (clé, durée de vie) dans le module de l'application.

Le fichier [src/auth/jwt.strategy.ts] :

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ContenuJeton } from './auth.service';
import { COOKIE_JWT, JWT_SECRET } from './constantes';

// l'utilisateur connecté, tel que les contrôleurs le voient (req.user)
export interface UtilisateurConnecte {
  id: number;
  login: string;
  nom: string;
  role: string;
}

// la stratégie "jwt" de Passport : où trouver le jeton, comment le vérifier
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor() {
    super({
      // le jeton est lu dans le cookie (et non dans l'entête Authorization)
      jwtFromRequest: ExtractJwt.fromExtractors([(req: Request) => req?.cookies?.[COOKIE_JWT] ?? null]),
      ignoreExpiration: false, // un jeton expiré est refusé
      secretOrKey: JWT_SECRET, // pour vérifier la signature
    });
  }

  // appelée si le jeton est valide ; ce qu'elle renvoie devient req.user
  validate(contenu: ContenuJeton): UtilisateurConnecte {
    return { id: contenu.sub, login: contenu.login, nom: contenu.nom, role: contenu.role };
  }
}
  • ligne 18 : une stratégie [Passport], nommée [jwt], décrit comment authentifier une requête ;
  • ligne 22 : où trouver le jeton : dans le cookie. (Un client [JavaScript] l'enverrait plutôt dans l'entête [Authorization: Bearer ...], cf. [ExtractJwt.fromAuthHeaderAsBearerToken()]) ;
  • lignes 23-24 : un jeton expiré ou mal signé est refusé ;
  • lignes 29-31 : appelée quand le jeton est valide. Ce qu'elle rend devient [req.user] : l'utilisateur connecté, accessible aux actions.

Le fichier [src/auth/jwt-auth.guard.ts] :

1
2
3
4
5
6
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

// la garde qui exécute la stratégie 'jwt' : pas de jeton valide -> 401
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

[AuthGuard('jwt')] fabrique une garde qui exécute la stratégie [jwt]. Sans jeton valide, elle lève une [UnauthorizedException] (401).

Le fichier [src/auth/auth.controller.ts] :

import { Body, Controller, Get, Post, Render, Res } from '@nestjs/common';
import { Response } from 'express';
import { AuthService } from './auth.service';
import { COOKIE_JWT, JWT_DUREE } from './constantes';

@Controller()
export class AuthController {
  constructor(private readonly auth: AuthService) {}

  @Get('login')
  @Render('login')
  formulaire(): object {
    return {};
  }

  @Post('login')
  async connecter(@Body() corps: { login?: string; motDePasse?: string }, @Res() res: Response): Promise<void> {
    const utilisateur = await this.auth.verifier(corps.login ?? '', corps.motDePasse ?? '');
    if (!utilisateur) {
      // message volontairement vague : on ne dit pas lequel des deux est faux
      res.render('login', { login: corps.login, erreur: 'Login ou mot de passe incorrect.' });
      return;
    }
    // le jeton est déposé dans un cookie httpOnly : le JavaScript de la page
    // ne peut pas le lire, le navigateur le renvoie à chaque requête
    res.cookie(COOKIE_JWT, this.auth.creerJeton(utilisateur), {
      httpOnly: true,
      sameSite: 'lax',
      maxAge: JWT_DUREE * 1000,
    });
    res.redirect('/profil');
  }

  // la déconnexion MODIFIE l'état : c'est un POST, pas un lien
  @Post('logout')
  deconnecter(@Res() res: Response): void {
    res.clearCookie(COOKIE_JWT);
    res.redirect('/login');
  }
}
  • lignes 17-32 : la connexion. En cas d'échec, le formulaire est réaffiché avec un message volontairement vague : dire « ce login n'existe pas » aiderait un attaquant à trouver des logins valides ;
  • lignes 26-30 : en cas de succès, le jeton est déposé dans un cookie [httpOnly] : le [JavaScript] de la page ne peut pas le lire, ce qui protège le jeton si un script malveillant parvenait à s'exécuter dans la page. [sameSite: 'lax'] : le cookie n'accompagne pas les requêtes POST émises depuis un autre site, ce qui protège contre la falsification de requêtes ([CSRF]) ;
  • lignes 35-39 : la déconnexion efface le cookie. C'est un POST : elle modifie l'état de l'utilisateur (cf. exemple 21).

Le fichier [src/profil.controller.ts] :

import { Controller, Get, Render, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { UtilisateurConnecte } from './auth/jwt.strategy';

@Controller()
export class ProfilController {
  // page publique
  @Get()
  @Render('accueil')
  accueil(): object {
    return {};
  }

  // page protégée : la garde exige un jeton valide
  @Get('profil')
  @UseGuards(JwtAuthGuard)
  @Render('profil')
  profil(@Req() req: Request): object {
    return { utilisateur: req.user as UtilisateurConnecte };
  }
}

ligne 17 : la page [/profil] est protégée par la garde. L'action lit l'utilisateur dans [req.user].

Le fichier [src/erreurs.filter.ts] :

import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
import { Response } from 'express';

@Catch(HttpException)
export class ErreursFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();
    const status = exception.getStatus();
    // 401 (pas de jeton, ou jeton invalide) : direction la page de connexion
    if (status === HttpStatus.UNAUTHORIZED) {
      res.redirect('/login');
      return;
    }
    res.status(status).render('erreur', { status, message: exception.message });
  }
}

lignes 10-13 : une 401 n'affiche pas de page d'erreur : l'utilisateur est redirigé vers la page de connexion.

Le fichier [src/app.module.ts] :

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth/auth.controller';
import { AuthService } from './auth/auth.service';
import { JWT_DUREE, JWT_SECRET } from './auth/constantes';
import { JwtStrategy } from './auth/jwt.strategy';
import { UtilisateursService } from './auth/utilisateurs.service';
import { ProfilController } from './profil.controller';

@Module({
  imports: [
    PassportModule,
    // JwtService, configuré une fois pour toutes : clé et durée de vie
    JwtModule.register({ secret: JWT_SECRET, signOptions: { expiresIn: JWT_DUREE } }),
  ],
  controllers: [AuthController, ProfilController],
  providers: [UtilisateursService, AuthService, JwtStrategy],
})
export class AppModule {}

ligne 15 : la configuration de [JwtService] : clé et durée de vie des jetons (une heure).

Le fichier [views/login.hbs] :

<h1 class="h3">Connexion</h1>
<form method="post" action="/login">
  <div class="mb-2">
    <label class="form-label" for="login">Login</label>
    <input class="form-control" id="login" name="login" value="{{login}}" autocomplete="username">
  </div>
  <div class="mb-3">
    <label class="form-label" for="motDePasse">Mot de passe</label>
    <input class="form-control" id="motDePasse" name="motDePasse" type="password" autocomplete="current-password">
  </div>
  <button class="btn btn-primary" type="submit">Se connecter</button>
</form>
{{#if erreur}}<div class="alert alert-danger mt-3">{{erreur}}</div>{{/if}}
<p class="text-muted mt-3">Comptes : admin / admin, marie / marie</p>

8.1.1. Exécution

Sans cookie, la page protégée redirige vers la page de connexion :

curl -i http://localhost:3000/profil
1
2
3
4
5
6
HTTP/1.1 302 Found
Location: /login
Content-Type: text/plain; charset=utf-8
Content-Length: 28

Found. Redirecting to /login

Image

Un mot de passe erroné :

Image

La connexion réussie, vue par [curl] :

curl -i -c cookies.txt -d "login=marie&motDePasse=marie" http://localhost:3000/login
1
2
3
4
5
6
7
HTTP/1.1 302 Found
Set-Cookie: jeton=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjIsImxvZ2luIjoibWFyaWUiLCJub20iOiJNYXJpZSBQw6lsaXNzaWVyIiwicm9sZSI6IlVTRVIiLCJpYXQiOjE3OTAxNzMwODcsImV4cCI6MTc5MDE3NjY4N30.ThiPopT9JH3D3CwHTSn3w5Z3CrJP9PYNvLl6gYH4DCI; Max-Age=3600; Path=/; Expires=Wed, 23 Sep 2026 15:18:07 GMT; HttpOnly; SameSite=Lax
Location: /profil
Content-Type: text/plain; charset=utf-8
Content-Length: 29

Found. Redirecting to /profil

Le jeton est formé de trois parties séparées par des points : un entête, le contenu, la signature. Les deux premières sont simplement encodées en [base64url] ; décodons le contenu :

1
2
3
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjIsImxvZ2luIjoibWFyaWUiLCJub20iOiJNYXJpZSBQw6lsaXNzaWVyIiwicm9sZSI6IlVTRVIiLCJpYXQiOjE3OTAxNzMwODcsImV4cCI6MTc5MDE3NjY4N30.ThiPopT9JH3D3CwHTSn3w5Z3CrJP9PYNvLl6gYH4DCI

{"sub": 2, "login": "marie", "nom": "Marie Pélissier", "role": "USER", "iat": 1790173087, "exp": 1790176687}

On retrouve le contenu fabriqué par [creerJeton], plus deux champs ajoutés par [JwtService] : [iat] ([issued at], la date de création) et [exp] (la date d'expiration), en secondes depuis le 1er janvier 1970. Le jeton n'est pas chiffré : on n'y met rien de secret. Dans le navigateur :

Image

Tentons maintenant de modifier le jeton pour nous attribuer le rôle ADMIN : on décode le contenu, on remplace [USER] par [ADMIN], on le réencode... mais on ne peut pas recalculer la signature sans la clé secrète :

curl -i -b "jeton=eyJhbGciOi...(contenu modifié)...ThiPopT9..." http://localhost:3000/profil
1
2
3
4
5
6
HTTP/1.1 302 Found
Location: /login
Content-Type: text/plain; charset=utf-8
Content-Length: 28

Found. Redirecting to /login

La signature ne correspond plus au contenu : le jeton est refusé (erreur 401), et le filtre d'exceptions renvoie vers la page de connexion. La déconnexion :

curl -i -b cookies.txt -X POST http://localhost:3000/logout
1
2
3
4
5
6
7
HTTP/1.1 302 Found
Set-Cookie: jeton=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT
Location: /login
Content-Type: text/plain; charset=utf-8
Content-Length: 28

Found. Redirecting to /login

8.2. Exemple 26 : les rôles

L'exemple 26 reprend l'exemple 25 et y ajoute une page réservée au rôle ADMIN. Trois fichiers sont nouveaux.

Le fichier [src/auth/roles.decorator.ts] :

1
2
3
4
5
6
7
import { SetMetadata } from '@nestjs/common';

export const CLE_ROLES = 'roles';

// @Roles('ADMIN') : pose une MÉTADONNÉE sur l'action (ou le contrôleur) ;
// le décorateur ne vérifie rien lui-même, c'est RolesGuard qui la lira
export const Roles = (...roles: string[]) => SetMetadata(CLE_ROLES, roles);

ligne 7 : [SetMetadata] attache une métadonnée (une information quelconque) à la classe ou à la méthode décorée. [@Roles('ADMIN')] ne vérifie rien : il déclare le rôle exigé.

Le fichier [src/auth/roles.guard.ts] :

import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { UtilisateurConnecte } from './jwt.strategy';
import { CLE_ROLES } from './roles.decorator';

@Injectable()
export class RolesGuard implements CanActivate {
  // Reflector : l'outil de NestJS pour relire les métadonnées
  constructor(private readonly reflector: Reflector) {}

  canActivate(contexte: ExecutionContext): boolean {
    // @Roles de l'action, à défaut celui du contrôleur
    const requis = this.reflector.getAllAndOverride<string[]>(CLE_ROLES, [contexte.getHandler(), contexte.getClass()]);
    if (!requis || requis.length === 0) {
      return true; // pas de @Roles : pas de restriction de rôle
    }
    // req.user a été déposé par JwtAuthGuard, qui s'exécute AVANT cette garde
    const utilisateur = contexte.switchToHttp().getRequest<Request>().user as UtilisateurConnecte;
    if (!requis.includes(utilisateur.role)) {
      throw new ForbiddenException(`le rôle ${utilisateur.role} ne donne pas accès à cette page`);
    }
    return true;
  }
}
  • ligne 10 : [Reflector] est le service de [NestJS] qui lit les métadonnées ;
  • ligne 14 : les rôles exigés par l'action, à défaut par son contrôleur. C'est ici que sert le contexte d'exécution de la garde (cf. chapitre 7) ;
  • lignes 15-17 : sans [@Roles], aucune restriction ;
  • ligne 19 : [req.user] a été déposé par la garde [JwtAuthGuard], qui s'exécute avant celle-ci ;
  • lignes 20-22 : un rôle insuffisant provoque une erreur 403 (Forbidden).

Le fichier [src/admin.controller.ts] :

import { Controller, Get, Render, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { Roles } from './auth/roles.decorator';
import { RolesGuard } from './auth/roles.guard';

// l'ordre compte : d'abord QUI est-ce (JwtAuthGuard), puis A-T-IL LE DROIT (RolesGuard)
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
export class AdminController {
  @Get()
  @Render('admin')
  admin(): object {
    return {};
  }
}

lignes 8-9 : les gardes s'exécutent dans l'ordre où elles sont listées : d'abord l'identification, puis le contrôle du rôle. Tout le contrôleur est réservé au rôle ADMIN. L'utilisatrice [marie] (rôle USER) tente d'ouvrir la page d'administration :

Image

Même tentative par [admin] :

Image

L'étude de cas a trois rôles (ADMIN, DOCTOR, USER) et utilise exactement ces deux gardes. Le filtre d'exceptions y transforme la 403 en page d'erreur traduite.

8.3. Exemple 27 : captcha et limitation des tentatives

Une page de connexion attire les robots : des programmes qui essaient des milliers de couples login / mot de passe. Deux protections complémentaires :

  • un captcha : une image de caractères déformés, qu'un humain lit sans peine mais qu'un robot simple ne sait pas déchiffrer ;
  • une limitation des tentatives : après quelques échecs, l'adresse IP est bloquée un moment.

L'exemple 27 ajoute ces deux protections à l'exemple 25.

Le fichier [src/auth/captcha.service.ts] :

import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import * as svgCaptcha from 'svg-captcha';

export interface Captcha {
  id: string; // placé dans un champ caché du formulaire
  svg: string; // l'image, insérée telle quelle dans la page
}

@Injectable()
export class CaptchaService {
  // id -> réponse attendue. La réponse ne quitte JAMAIS le serveur
  private readonly reponses = new Map<string, string>();

  generer(): Captcha {
    // svg-captcha : un texte au hasard, dessiné en caractères déformés
    const { text, data } = svgCaptcha.create({ size: 5, noise: 3, color: true, ignoreChars: '0oO1iIlL' });
    const id = randomUUID();
    this.reponses.set(id, text.toLowerCase());
    return { id, svg: data };
  }

  // la réponse est-elle la bonne ? Dans tous les cas, le captcha est
  // consommé : une image ne sert qu'une fois
  verifier(id: unknown, saisie: unknown): boolean {
    if (typeof id !== 'string' || typeof saisie !== 'string') {
      return false;
    }
    const attendue = this.reponses.get(id);
    this.reponses.delete(id);
    return attendue !== undefined && attendue === saisie.trim().toLowerCase();
  }
}
  • ligne 13 : les réponses attendues sont gardées par le serveur, sous un identifiant aléatoire. La page ne contient que l'image et l'identifiant : la réponse ne quitte jamais le serveur ;
  • lignes 15-21 : la bibliothèque [svg-captcha] tire un texte au hasard et le dessine dans une image SVG, brouillée par des traits. Les caractères faciles à confondre (0 et O, 1 et l...) sont exclus ;
  • lignes 25-32 : la vérification consomme le captcha : une image ne sert qu'une fois, un robot ne peut pas rejouer une réponse trouvée une fois.

Le fichier [src/auth/limiteur.service.ts] :

import { Injectable } from '@nestjs/common';

const MAX_ECHECS = 3; // échecs tolérés...
const BLOCAGE_MS = 60 * 1000; // ... avant un blocage d'une minute

// limite le nombre de tentatives de connexion par adresse IP
@Injectable()
export class LimiteurService {
  private readonly compteurs = new Map<string, { echecs: number; bloqueJusqua: number }>();

  // 0 si l'IP peut tenter sa chance, sinon le nombre de secondes à attendre
  secondesDeBlocage(ip: string): number {
    const c = this.compteurs.get(ip);
    const reste = c ? c.bloqueJusqua - Date.now() : 0;
    return reste > 0 ? Math.ceil(reste / 1000) : 0;
  }

  echec(ip: string): void {
    const c = this.compteurs.get(ip) ?? { echecs: 0, bloqueJusqua: 0 };
    c.echecs++;
    if (c.echecs >= MAX_ECHECS) {
      c.bloqueJusqua = Date.now() + BLOCAGE_MS;
      c.echecs = 0;
    }
    this.compteurs.set(ip, c);
  }

  reussite(ip: string): void {
    this.compteurs.delete(ip);
  }
}
  • lignes 3-4 : trois échecs bloquent l'adresse IP une minute ;
  • lignes 12-16 : le temps de blocage restant ;
  • lignes 18-26 et 28-30 : un échec est compté, une réussite remet le compteur à zéro.

Le fichier [src/auth/auth.controller.ts] :

import { Body, Controller, Get, Ip, Post, Res } from '@nestjs/common';
import { Response } from 'express';
import { AuthService } from './auth.service';
import { CaptchaService } from './captcha.service';
import { COOKIE_JWT, JWT_DUREE } from './constantes';
import { LimiteurService } from './limiteur.service';

interface CorpsLogin {
  login?: string;
  motDePasse?: string;
  captchaId?: string;
  captcha?: string;
}

@Controller()
export class AuthController {
  constructor(
    private readonly auth: AuthService,
    private readonly captchas: CaptchaService,
    private readonly limiteur: LimiteurService,
  ) {}

  @Get('login')
  formulaire(@Res() res: Response): void {
    this.afficher(res, {});
  }

  @Post('login')
  async connecter(@Body() corps: CorpsLogin, @Ip() ip: string, @Res() res: Response): Promise<void> {
    // 1. une IP bloquée n'est même pas écoutée
    const secondes = this.limiteur.secondesDeBlocage(ip);
    if (secondes > 0) {
      this.afficher(res, { login: corps.login, erreur: `Trop de tentatives : réessayez dans ${secondes} s.` });
      return;
    }
    // 2. le captcha, puis 3. le login et le mot de passe
    const captchaOk = this.captchas.verifier(corps.captchaId, corps.captcha);
    const utilisateur = captchaOk ? await this.auth.verifier(corps.login ?? '', corps.motDePasse ?? '') : null;
    if (!utilisateur) {
      this.limiteur.echec(ip);
      const erreur = captchaOk ? 'Login ou mot de passe incorrect.' : 'Les caractères recopiés sont incorrects.';
      this.afficher(res, { login: corps.login, erreur });
      return;
    }
    this.limiteur.reussite(ip);
    res.cookie(COOKIE_JWT, this.auth.creerJeton(utilisateur), {
      httpOnly: true,
      sameSite: 'lax',
      maxAge: JWT_DUREE * 1000,
    });
    res.redirect('/profil');
  }

  @Post('logout')
  deconnecter(@Res() res: Response): void {
    res.clearCookie(COOKIE_JWT);
    res.redirect('/login');
  }

  // chaque affichage du formulaire a un NOUVEAU captcha
  private afficher(res: Response, modele: object): void {
    res.render('login', { ...modele, captcha: this.captchas.generer() });
  }
}
  • ligne 29 : [@Ip()] injecte l'adresse IP du client ;
  • lignes 32-35 : une IP bloquée n'est même pas écoutée ;
  • lignes 37-38 : le captcha est vérifié d'abord ; le mot de passe (calcul bcrypt coûteux) seulement si le captcha est bon ;
  • lignes 39-44 : un mauvais captcha compte comme un échec ;
  • lignes 61-63 : chaque affichage du formulaire fabrique un nouveau captcha.

Le fichier [views/login.hbs] :

<h1 class="h3">Connexion</h1>
<form method="post" action="/login">
  <div class="mb-2">
    <label class="form-label" for="login">Login</label>
    <input class="form-control" id="login" name="login" value="{{login}}" autocomplete="username">
  </div>
  <div class="mb-3">
    <label class="form-label" for="motDePasse">Mot de passe</label>
    <input class="form-control" id="motDePasse" name="motDePasse" type="password" autocomplete="current-password">
  </div>
  <div class="mb-3">
    <label class="form-label" for="captcha">Recopiez les caractères de l'image</label>
    {{!-- l'image SVG est insérée telle quelle : triple accolades --}}
    <div class="mb-1">{{{captcha.svg}}}</div>
    {{!-- l'identifiant du captcha voyage avec le formulaire ; pas la réponse --}}
    <input type="hidden" name="captchaId" value="{{captcha.id}}">
    <input class="form-control" id="captcha" name="captcha" autocomplete="off">
  </div>
  <button class="btn btn-primary" type="submit">Se connecter</button>
</form>
{{#if erreur}}<div class="alert alert-danger mt-3">{{erreur}}</div>{{/if}}
<p class="text-muted mt-3">Comptes : admin / admin, marie / marie</p>

lignes 14-17 : l'image SVG est insérée telle quelle dans la page (triples accolades : c'est du HTML fabriqué par le serveur) ; l'identifiant du captcha voyage dans un champ caché.

Image

Un captcha mal recopié :

Image

Au troisième échec :

Image

L'étude de cas applique ces deux protections à sa page de connexion et à la création de compte, avec des seuils réglables dans le fichier [.env].