Node.js

Authentification

Mettre en place un système d'authentification avec JWT dans Node.js

L'authentification permet de vérifier l'identité d'un utilisateur. Dans ce cours, nous allons mettre en place un système de login qui génère un JWT (JSON Web Token), puis nous allons protéger certaines routes de notre API pour qu'elles ne soient accessibles qu'aux utilisateurs authentifiés.

Concepts de base

JWT

Les tokens d'authentification sont des jetons qui sont obtenus lors de la connexion d'un utilisateur. Ce dernier reçoit une clé cryptée qui contient des informations permettant de l'identifier. Ces jetons sont souvents stockés dans le LocalStorage ou les Cookies sur les navigateurs.

À chaque fois que l'utilisateur tentera de se rendre sur des ressources sécurisées le serveur vérifiera la validé du token. Si le token est correct et correspond bien à l'utilisateur alors il autorisera l'accès à la ressource sinon il le rejettera (erreur 401 Unauthorized).

Les tokens sont donc très pratique car ils évitent à un utilisateur de devoir se reconnecter systmétiquement à chaque requête au serveur. D'autant qu'on peut leur donner une durée de vie ce qui permettra d'éviter la reconnexion pendant ce laps de temps.

Pour utiliser les tokens avec nodeJS nous utilisons ce que l'on appelle les jsonwebtoken ou JWT. Il s'agit d'un format standard défini ici. Sa structure se décompose en 3 parties:

HEADER.PAYLOAD.SIGNATURE
  • HEADER: en-tête qui définit le type de token ainsi que son algorithme d'encryptage de signature. C'est un objet JSON.
  • PAYLOAD: qui possède les data que l'on souhaite stocker dans le JWT, comme l'id utilisateur, son rôle, (...). C'est un objet JSON.
  • SIGNATURE : Une signature numérique qui permet le chiffrement et le déchiffrement de notre JWT. On l'obtient en chiffrant le HEADER et le PAYLOAD avec l'encodage base64url. Ensuite, on les concatène en les séparant par un point. On obtient la signature de ce résultat avec l'algorithme choisi. Cette signature est ajoutée au résultat de la même manière (encodée et séparée par un point). Généralement on rajoute à cela une clé de chiffrement définie par nos soins. Le chiffrement est capital car il permet de vérifier l'intégrité du token.

RÈGLE D'OR : Ne JAMAIS stocker les mots de passe en clair ! On utilise bcrypt pour hasher (chiffrer de manière irréversible) les mots de passe avant de les stocker en base de données.

Mise en place

Installer les dépendances

npm install bcrypt jsonwebtoken dotenv
npm install -D @types/bcrypt @types/jsonwebtoken

Configuration de dotenv : Le package dotenv permet de charger les variables d'environnement depuis un fichier .env. C'est essentiel pour stocker de manière sécurisée des informations sensibles comme la clé secrète JWT ou l'URL de la base de données. Pour l'activer, ajoutez simplement cette ligne au tout début de votre fichier principal ( index.ts ou app.ts) :

import 'dotenv/config'

Cette import doit être la première ligne avant toute autre import pour garantir que les variables d'environnement sont disponibles partout dans votre application.

Schéma Prisma

Ajoutons un champ password à notre modèle User :

model User {
  id       Int    @id @default(autoincrement())
  name     String 
  email    String @unique
  password String 
}

Créez la migration :

npx prisma migrate dev --name add_password

Seed

Modifions notre fichier prisma/seed.ts pour ajouter des mots de passe hashés :

import {PrismaBetterSqlite3} from '@prisma/adapter-better-sqlite3'
import bcrypt from 'bcrypt'
import {PrismaClient} from "@/generated/prisma/client";

const adapter = new PrismaBetterSqlite3({
    url: process.env.DATABASE_URL || 'file:./dev.db',
})
const prisma = new PrismaClient({adapter})

async function main() {
    await prisma.user.deleteMany()
    await prisma.$executeRaw`DELETE
                             FROM sqlite_sequence
                             WHERE name = 'User'`

    // Tous les utilisateurs auront le mot de passe "password123"
    const hashedPassword = await bcrypt.hash('password123', 10)

    await prisma.user.createMany({
        data: [
            {
                name: 'Alice',
                email: 'alice@example.com',
                password: hashedPassword,
            },
            {
                name: 'Bob',
                email: 'bob@example.com',
                password: hashedPassword,
            },
            {
                name: 'John Doe',
                email: 'john@example.com',
                password: hashedPassword,
            },
        ],
    })

    console.log('Base de données peuplée avec succès !')
}

main()
    .catch((e) => {
        throw e
    })
    .finally(async () => {
        await prisma.$disconnect()
    })

Exécutez le seed :

npx prisma db seed

Variables d'environnement

Créez un fichier .env à la racine du projet :

DATABASE_URL="file:./dev.db"
JWT_SECRET=votre_cle_secrete_tres_longue_et_complexe_ici

Important : Ne commitez JAMAIS le fichier .env ! Ajoutez-le au .gitignore.

Mettre à jour l'API

Endpoint Login

Fichier auth.route.ts :

import {Request, Response, Router} from 'express'
import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken'
import prisma from "@/client";

export const authRouter = Router()

// POST /auth/login
// Accessible via POST /auth/login
authRouter.post('/login', async (req: Request, res: Response) => {
    const {email, password} = req.body

    try {
        // 1. Vérifier que l'utilisateur existe
        const user = await prisma.user.findUnique({
            where: {email},
        })

        if (!user) {
            return res.status(401).json({error: 'Email ou mot de passe incorrect'})
        }

        // 2. Vérifier le mot de passe
        const isPasswordValid = await bcrypt.compare(password, user.password)

        if (!isPasswordValid) {
            return res.status(401).json({error: 'Email ou mot de passe incorrect'})
        }

        // 3. Générer le JWT
        const token = jwt.sign(
            {
                userId: user.id,
                email: user.email,
            },
            process.env.JWT_SECRET as string,
            {expiresIn: '1h'}, // Le token expire dans 1 heure
        )

        // 4. Retourner le token
        return res.status(200).json({
            message: 'Connexion réussie',
            token,
            user: {
                id: user.id,
                name: user.name,
                email: user.email,
            },
        })
    } catch (error) {
        console.error('Erreur lors de la connexion:', error)
        return res.status(500).json({error: 'Erreur serveur'})
    }
})

Remarque : N'oubliez pas d'importer et d'utiliser ce router dans votre fichier principal avec app.use('/auth', authRouter).

Middleware d'authentification

Le middleware vérifie que le JWT est valide avant d'autoriser l'accès à une route.

import {NextFunction, Request, Response} from 'express'
import jwt from 'jsonwebtoken'

// Étendre le type Request pour ajouter userId
declare global {
    namespace Express {
        interface Request {
            userId?: number
        }
    }
}

export const authenticateToken = (
    req: Request,
    res: Response,
    next: NextFunction,
) => {
    // 1. Récupérer le token depuis l'en-tête Authorization
    const authHeader = req.headers.authorization
    const token = authHeader && authHeader.split(' ')[1] // Format: "Bearer TOKEN"

    if (!token) {
        return res.status(401).json({error: 'Token manquant'})
    }

    try {
        // 2. Vérifier et décoder le token
        const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as {
            userId: number
            email: string
        }

        // 3. Ajouter userId à la requête pour l'utiliser dans les routes
        req.userId = decoded.userId

        // 4. Passer au prochain middleware ou à la route
        next()
    } catch (error) {
        return res.status(403).json({error: 'Token invalide ou expiré'})
    }
}

Protéger les routes

Maintenant, nous pouvons protéger certaines routes pour qu'elles ne soient accessibles qu'aux utilisateurs authentifiés.

Fichier user.route.ts :

import {Request, Response, Router} from 'express'
import bcrypt from 'bcrypt'
import prisma from "@/client";
import {authenticateToken} from "@/auth/auth.middleware";

export const userRouter = Router()

// Route protégée : seuls les utilisateurs authentifiés peuvent créer un utilisateur
// Accessible via POST /users
userRouter.post('/', authenticateToken, async (req: Request, res: Response) => {
    const {name, email, password} = req.body

    try {
        const hashedPassword = await bcrypt.hash(password, 10)

        const user = await prisma.user.create({
            data: {name, email, password: hashedPassword},
            select: {
                id: true,
                name: true,
                email: true,
            },
        })

        res.status(201).json({
            message: 'Utilisateur créé',
            user,
        })
    } catch (error: any) {
        res.status(400).json({error: error.message})
    }
})

Important : Le middleware authenticateToken est ajouté en tant que deuxième paramètre pour protéger les routes. Il sera exécuté avant le handler de la route.

Tester avec Bruno

Structure des fichiers

Créez une collection Bruno pour tester votre API d'authentification :

bruno/
├── environments/
│   └── local.bru           # Variables d'environnement
├── auth/
│   └── login.bru           # Endpoint de login
└── users/
    ├── create-user.bru     # Endpoint protégé
    └── get-me.bru          # Endpoint protégé

Configuration de l'environnement

Créez un fichier environments/local.bru pour stocker vos variables :

vars {
  baseUrl: http://localhost:3000
  token:
}

La variable token est vide au départ. Elle sera remplie automatiquement par le script post-request du login.

Endpoint de login avec script

Créez auth/login.bru :

meta {
  name: Login
  type: http
  seq: 1
}

post {
  url: {{baseUrl}}/auth/login
  body: json
  auth: none
}

body:json {
  {
    "email": "alice@example.com",
    "password": "password123"
  }
}

script:post-response {
  // Extraire le token de la réponse et le stocker dans l'environnement
  if (res.status === 200) {
    const data = res.body;
    if (data.token) {
      bru.setEnvVar("token", data.token);
      console.log("Token sauvegardé dans l'environnement");
    }
  }
}

Explication du script :

  • script:post-response s'exécute après avoir reçu la réponse
  • On vérifie que la requête a réussi (status === 200)
  • On extrait le token de res.body.token
  • On le stocke dans la variable d'environnement avec bru.setEnvVar("token", data.token)
  • Ce token sera maintenant disponible pour toutes les autres requêtes via {{token}}

Endpoints protégés avec Bearer Token

Créer un utilisateur (protégé)

Créez users/create-user.bru :

meta {
  name: Create User
  type: http
  seq: 1
}

post {
  url: {{baseUrl}}/users
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "name": "Charlie",
    "email": "charlie@example.com",
    "password": "password123"
  }
}