Créer un système de login et register dans NestJS

Créer un système de login et register dans NestJS

L'authentification est une brique fondamentale de toute application web moderne. Dans ce tutoriel, nous allons construire un système complet de login et register avec NestJS, en s'appuyant sur bcrypt pour le hashage des mots de passe et JWT pour la gestion des sessions. Nous aborderons également la validation des données avec des DTOs, la gestion centralisée des erreurs et les bonnes pratiques de sécurité.

Système d'authentification login et register dans NestJS
Image générée par Google Gemini

Prérequis

Avant de commencer, assure-toi de disposer d'un projet NestJS opérationnel (version 10+) avec TypeORM configuré sur une base de données PostgreSQL ou MySQL. Tu dois également avoir une connaissance de base des concepts suivants :

  • Modules, contrôleurs et providers de NestJS
  • Décorateurs et injection de dépendances
  • Entités TypeORM et repositories

Installation des dépendances

Nous avons besoin de plusieurs packages : bcrypt pour hasher les mots de passe, @nestjs/jwt pour générer et vérifier les tokens, ainsi que class-validator et class-transformer pour la validation des DTOs.

npm install @nestjs/jwt @nestjs/typeorm typeorm bcrypt
npm install class-validator class-transformer
npm install -D @types/bcrypt

L'entité User

La première étape consiste à modéliser l'utilisateur en base de données. L'entité User contient les champs essentiels : email unique, mot de passe (jamais sélectionné par défaut), prénom, nom, téléphone optionnel, rôle et statut d'activation.

import { Entity, Column, OneToMany } from 'typeorm';
import { BaseEntity } from './base.entity';

export enum UserRole {
  ADMIN = 'admin',
  CLIENT = 'client',
}

@Entity('users')
export class User extends BaseEntity {
  @Column({ unique: true })
  email: string;

  @Column({ select: false })
  password: string;

  @Column({ name: 'first_name' })
  firstName: string;

  @Column({ name: 'last_name' })
  lastName: string;

  @Column({ nullable: true })
  phone: string;

  @Column({
    type: 'enum',
    enum: UserRole,
    default: UserRole.CLIENT,
  })
  role: UserRole;

  @Column({ name: 'is_active', default: true })
  isActive: boolean;

  @Column({ name: 'email_verified', default: false })
  emailVerified: boolean;

  @Column({ default: 0 })
  tokenVersion: number;
}

Deux points méritent attention. D'abord, l'option select: false sur le champ password empêche TypeORM de remonter le hash lors d'un find() classique : il faudra explicitement le demander via select. Ensuite, le champ tokenVersion permet d'invalider tous les JWT existants d'un utilisateur en incrémentant simplement ce compteur, ce qui est très utile pour gérer un logout global ou un changement de mot de passe.

Les DTOs : RegisterDto et LoginDto

Les DTOs (Data Transfer Objects) définissent la forme attendue des données entrantes et permettent leur validation automatique avec class-validator. Ils sont indispensables pour empêcher l'injection de données malformées.

// dto/login.dto.ts
import { IsEmail, IsNotEmpty, IsString, MinLength, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class LoginDto {
  @ApiProperty({ example: 'admin@example.com' })
  @IsEmail()
  @IsNotEmpty()
  @MaxLength(254)
  email: string;

  @ApiProperty({ example: 'password123' })
  @IsString()
  @IsNotEmpty()
  @MinLength(6)
  @MaxLength(128)
  password: string;
}
// dto/register.dto.ts
import { IsEmail, IsNotEmpty, IsString, MinLength, MaxLength, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class RegisterDto {
  @ApiProperty({ example: 'admin@example.com' })
  @IsEmail()
  @IsNotEmpty()
  @MaxLength(254)
  email: string;

  @ApiProperty({ example: 'password123' })
  @IsString()
  @IsNotEmpty()
  @MinLength(6)
  @MaxLength(128)
  password: string;

  @ApiProperty({ example: 'John' })
  @IsString()
  @IsNotEmpty()
  @MaxLength(50)
  firstName: string;

  @ApiProperty({ example: 'Doe' })
  @IsString()
  @IsNotEmpty()
  @MaxLength(50)
  lastName: string;

  @ApiProperty({ example: '+33612345678', required: false })
  @IsString()
  @IsOptional()
  @MaxLength(20)
  phone?: string;
}

Les contraintes MaxLength sont essentielles : elles évitent qu'un attaquant n'envoie une chaîne de plusieurs mégaoctets pour saturer le serveur (attaque DoS). N'oublie pas d'activer le ValidationPipe globalement dans main.ts avec whitelist: true pour ignorer les champs non déclarés.

Configuration du module Auth

Le module AuthModule centralise les dépendances nécessaires : le repository User via TypeORM et le JwtModule configuré de manière asynchrone pour récupérer le secret depuis les variables d'environnement.

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { User } from '../../database/entities/user.entity';

@Module({
  imports: [
    TypeOrmModule.forFeature([User]),
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => {
        const secret = configService.get<string>('jwt.secret');
        if (!secret) {
          throw new Error('JWT_SECRET est obligatoire. L\'application ne peut pas démarrer sans.');
        }
        const expiresIn = configService.get<string>('jwt.expiresIn') || '30m';
        return {
          secret,
          signOptions: {
            expiresIn: expiresIn as any,
            algorithm: 'HS256' as const,
          },
        };
      },
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService],
  exports: [AuthService, JwtModule],
})
export class AuthModule {}

Vérifier la présence du secret JWT au démarrage est une bonne pratique : on échoue tôt plutôt que d'avoir un comportement imprévisible en production.

Le service d'authentification

L'AuthService est le cœur du système. Il expose deux méthodes publiques principales : register() et login(), ainsi qu'un helper validateUser() qui factorise la vérification des credentials.

Méthode register()

Lors de l'inscription, on vérifie d'abord que l'email n'est pas déjà utilisé, puis on hash le mot de passe avec bcrypt avant de persister l'utilisateur.

async register(registerDto: RegisterDto) {
  const existingUser = await this.userRepository.findOne({
    where: { email: registerDto.email },
  });

  if (existingUser) {
    throw new BadRequestException('User with this email already exists');
  }

  const hashedPassword = await bcrypt.hash(registerDto.password, 10);

  const user = this.userRepository.create({
    email: registerDto.email,
    password: hashedPassword,
    firstName: registerDto.firstName,
    lastName: registerDto.lastName,
    phone: registerDto.phone,
    role: UserRole.CLIENT,
    isActive: true,
    emailVerified: false,
  });

  const savedUser = await this.userRepository.save(user);
  const { password: _, ...result } = savedUser;

  return result;
}

Le facteur de coût 10 pour bcrypt est un bon compromis entre sécurité et performance en 2024. Plus il est élevé, plus le hashage est lent (et donc résistant au brute-force), mais plus il consomme de CPU. Le destructuring const { password: _, ...result } permet d'exclure proprement le hash de la réponse renvoyée au client.

Méthode validateUser() et login()

Pour le login, on récupère l'utilisateur en demandant explicitement le champ password (puisqu'il est en select: false), on compare le hash avec bcrypt.compare(), puis on génère le JWT.

async validateUser(email: string, password: string): Promise<any> {
  const user = await this.userRepository.findOne({
    where: { email },
    select: ['id', 'email', 'password', 'firstName', 'lastName', 'role', 'isActive', 'tokenVersion'],
  });

  if (!user) {
    throw new UnauthorizedException('Invalid credentials');
  }

  if (!user.isActive) {
    throw new UnauthorizedException('Invalid credentials');
  }

  const isPasswordValid = await bcrypt.compare(password, user.password);
  if (!isPasswordValid) {
    throw new UnauthorizedException('Invalid credentials');
  }

  const { password: _, ...result } = user;
  return result;
}

async login(loginDto: LoginDto) {
  const user = await this.validateUser(loginDto.email, loginDto.password);

  const payload = {
    sub: user.id,
    email: user.email,
    role: user.role,
    tokenVersion: user.tokenVersion,
  };

  return {
    access_token: this.jwtService.sign(payload),
    user: {
      id: user.id,
      email: user.email,
      firstName: user.firstName,
      lastName: user.lastName,
      role: user.role,
    },
  };
}

Remarque importante sur la sécurité : on retourne toujours le même message Invalid credentials, que l'email n'existe pas ou que le mot de passe soit faux. Cela empêche un attaquant d'énumérer les comptes existants. Le payload du JWT inclut sub (subject = id utilisateur), role pour la gestion des autorisations, et tokenVersion pour pouvoir invalider les tokens.

Le contrôleur AuthController

Le contrôleur expose les endpoints HTTP. On y applique deux décorateurs clés : @Public() pour exempter ces routes du JwtAuthGuard global, et @Throttle() pour limiter le nombre de tentatives et freiner les attaques par force brute.

import { Controller, Post, Body } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { Public } from '../../common/decorators/public.decorator';
import { ApiResponseDto } from '../../common/dto/api-response.dto';

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

  @Post('login')
  @Public()
  @Throttle({ default: { limit: 5, ttl: 60000 } })
  @ApiOperation({ summary: 'Login user' })
  @ApiResponse({ status: 200, description: 'Login successful' })
  @ApiResponse({ status: 401, description: 'Invalid credentials' })
  async login(@Body() loginDto: LoginDto): Promise<ApiResponseDto<any>> {
    const result = await this.authService.login(loginDto);
    return ApiResponseDto.success(result, 'Login successful');
  }

  @Post('register')
  @Public()
  @Throttle({ default: { limit: 5, ttl: 3600000 } })
  @ApiOperation({ summary: 'Register new admin user' })
  @ApiResponse({ status: 201, description: 'User registered successfully' })
  @ApiResponse({ status: 400, description: 'User already exists' })
  async register(@Body() registerDto: RegisterDto): Promise<ApiResponseDto<any>> {
    const user = await this.authService.register(registerDto);
    return ApiResponseDto.success(user, 'User registered successfully');
  }
}

Les limites du throttler sont volontairement strictes : 5 tentatives de login par minute et 5 inscriptions par heure depuis la même IP. Cela suffit pour un usage légitime tout en bloquant les bots. La réponse est enveloppée dans un ApiResponseDto qui standardise le format de retour avec un statut, un message et les données.

Format de réponse

Voici à quoi ressemble une réponse de login réussie :

{
  "success": true,
  "message": "Login successful",
  "data": {
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "user": {
      "id": "a3f1b2c4-...",
      "email": "john@example.com",
      "firstName": "John",
      "lastName": "Doe",
      "role": "client"
    }
  }
}

Le client stocke ensuite le access_token (idéalement en mémoire ou dans un cookie httpOnly, jamais dans localStorage pour éviter le XSS) et l'envoie dans le header Authorization: Bearer ... sur les requêtes suivantes.

Gestion des erreurs

NestJS convertit automatiquement les exceptions HTTP en réponses JSON correctement formatées :

  • BadRequestException (400) : email déjà utilisé lors du register
  • UnauthorizedException (401) : credentials invalides ou compte inactif
  • BadRequestException automatique (400) : DTO invalide (email mal formé, password trop court...)

Conclusion

Tu disposes maintenant d'un système d'authentification complet, sécurisé et testable dans NestJS. Nous avons couvert le hashage avec bcrypt, la validation des DTOs, la génération de JWT, la protection contre la force brute et les bonnes pratiques pour ne pas divulguer d'informations sensibles.

Pour aller plus loin, tu peux explorer plusieurs pistes : implémenter une JwtStrategy avec Passport.js pour valider automatiquement les tokens sur les routes protégées, ajouter un système de refresh token stocké en base, mettre en place la vérification d'email (le champ emailVerified est déjà prévu), ou intégrer une authentification OAuth via Google ou GitHub. Pense également à écrire des tests unitaires sur l'AuthService en mockant le repository et le JwtService : c'est la meilleure assurance contre les régressions sur ce composant critique.

Commentaires

Soyez le premier à laisser un commentaire !

Laisser un commentaire

Les champs obligatoires sont indiqués avec *