Sécuriser Votre API avec Express.js - Un Guide Pas à Pas pour Débutants

 


Table des Matières

Introduction

L'authentification est un élément crucial de toute application web. Elle permet de vérifier l'identité d'un utilisateur et de contrôler son accès aux ressources. Dans cet article, nous allons explorer comment mettre en place un système d'authentification robuste avec Express.js, en nous basant sur un exemple de code concret. Nous allons décortiquer chaque partie du code pour que vous puissiez comprendre et reproduire le processus, même si vous débutez

Prérequis

Avant de commencer, assurez-vous d'avoir les éléments suivants installés :

  • Node.js : La plateforme sur laquelle Express.js fonctionne.
  • npm (Node Package Manager) : Le gestionnaire de paquets pour Node.js, utilisé pour installer les bibliothèques nécessaires.
  • Un éditeur de code : Comme VS Code, Sublime Text, ou Atom.
  • MongoDB : (Si vous utilisez une base de données MongoDB, comme le code le suggère)

Étape 1 : Initialiser le projet et installer les dépendances

Créer un dossier pour votre projet :

mkdir mon-projet-auth
cd mon-projet-auth

Initialiser un projet Node.js :

npm init -y

Cette commande crée un fichier package.json à la racine de votre projet, qui contient les informations et les dépendances de votre projet.

Installer les dépendances nécessaires :

npm install express bcrypt jsonwebtoken cookie-parser axios mongoose dotenv

Voici ce que chaque dépendance fait :

  



 

  • express : Le framework web pour Node.js.
  • bcrypt : Pour hasher les mots de passe (les rendre illisibles).
  • jsonwebtoken : Pour créer et vérifier les tokens d'authentification (JWT).
  • cookie-parser : Pour manipuler les cookies.
  • axios : Pour faire des requêtes HTTP (dans le code front-end).
  • mongoose : (Si vous utilisez MongoDB) Pour interagir avec la base de données.
  • dotenv : Pour charger les variables d'environnement à partir d'un fichier .env.

Étape 2 : Configurer la base de données (MongoDB - Optionnel)

Si vous utilisez MongoDB, créez un fichier config/database.js (ou un nom similaire) pour configurer la connexion à votre base de données. N'oubliez pas d'installer mongoose.

// config/database.js
const mongoose = require('mongoose');

const connectDB = async () => {
  try {
    const conn = await mongoose.connect(process.env.MONGO_URI, {
      useNewUrlParser: true,
      useUnifiedTopology: true,
    });
    console.log(`MongoDB Connected: ${conn.connection.host}`);
  } catch (error) {
    console.error(error);
    process.exit(1);
  }
};

module.exports = connectDB;

Étape 3 : Définir les modèles de données

Créez un dossier models pour contenir vos modèles de données. Un modèle de données définit la structure de vos données dans la base de données.

Exemple de modèle utilisateur (models/user.model.js) :

// models/user.model.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');

const userSchema = new mongoose.Schema({
  email: {
    type: String,
    required: true,
    unique: true,
  },
  password: {
    type: String,
    required: true,
  },
  role: {
    type: String,
    enum: ['superAdmin', 'admin', 'student'], // Les rôles possibles
    default: 'student',
  },
  refreshToken: {
    // Pour stocker le refresh token
    type: String,
  },
}, { timestamps: true });

// Hashage du mot de passe avant de sauvegarder l'utilisateur
userSchema.pre('save', async function (next) {
  if (!this.isModified('password')) return next();
  try {
    const salt = await bcrypt.genSalt(10);
    this.password = await bcrypt.hash(this.password, salt);
    next();
  } catch (error) {
    return next(error);
  }
});

const UserModel = mongoose.model('User', userSchema);
module.exports = UserModel;

Exemple de modèle d'activité (models/activity.model.js) :

const mongoose = require('mongoose');

const activitySchema = new mongoose.Schema({
  userId: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User', // Reference to the User model
    required: true,
  },
  activity: {
    type: Date,
    default: Date.now,
  },
  isDeleted: {
    type: Boolean,
    default: false,
  },
});

const ActivityModel = mongoose.model('Activity', activitySchema);
module.exports = ActivityModel;

Étape 4 : Créer les contrôleurs

Les contrôleurs contiennent la logique de votre application. Créez un dossier controllers pour les organiser.

Exemple de contrôleur utilisateur (controllers/user.controller.js) :

// controllers/user.controller.js
const bcrypt = require("bcrypt");
const jwt = require("jsonwebtoken");
const UserModel = require("../models/user.model");
const Activity = require("../models/activity.model");

module.exports = {
  CreateAdmins: async (req, res) => {
    try {
      const newUser = await UserModel.create(req.body);
      res.json(newUser);
    } catch (err) {
      res.json(err);
    }
  },

  login: async (req, res) => {
    try {
      const user = await UserModel.findOne({ email: req.body.email });
      if (!user) {
        return res.status(400).json({ message: "User does not exist" });
      }

      const correctPassword = await bcrypt.compare(
        req.body.password,
        user.password
      );
      if (!correctPassword) {
        return res.status(400).json({ message: "Incorrect password" });
      }

      const userInfo = { _id: user._id };

      // Generate the access token and refreshToken
      const accessToken = jwt.sign(userInfo, process.env.JWT_SECRET, {
        expiresIn: "30m",
      });
      const refreshToken = jwt.sign(userInfo, process.env.JWT_REFRESH_SECRET, {
        expiresIn: "2h",
      });

      // Cookie options
      const cookieOptions = {
        httpOnly: true,
      };

      // create lastActivity
      await Activity.create({ userId: user._id });

      // Store the refreshToken in the database
      await UserModel.findOneAndUpdate(
        { _id: user._id },
        { refreshToken: refreshToken }
      );

      res
        .cookie("accessToken", accessToken, {
          ...cookieOptions,
          expires: new Date(Date.now() + 30 * 60 * 1000),
        })
        .cookie("refreshToken", refreshToken, {
          ...cookieOptions,
          expires: new Date(Date.now() + 2 * 60 * 60 * 1000),
        })
        .json({
          message: "Login successful",
          user: { email: user.email, role: user.role },
        });
    } catch (err) {
      console.error(err);
      res.status(400).json({ error: "Something went wrong" });
    }
  },

  refreshToken: async (req, res) => {
    const refreshToken = req.cookies.refreshToken;

    if (!refreshToken) {
      return res.status(401).json({ message: "No refresh token provided" });
    }

    try {
      // Check the refreshToken
      const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
      const user = await UserModel.findById(decoded._id);

      if (!user || user.refreshToken !== refreshToken) {
        return res.status(401).json({ message: "Invalid refresh token" });
      }

      // invalidate the refresToken for inactivity
      const lastActivity = await Activity.findOne({
        userId: user._id,
        isDeleted: false,
      }).sort({ activity: -1 });
      const now = new Date();

      if (lastActivity && now - lastActivity.activity > 60 * 60 * 1000) {
        // the user has been inactive for 1 hour
        return res
          .status(401)
          .json({ message: "Refresh token expired due to inactivity" });
      }
      // create lastActivity
      await Activity.create({ userId: user._id });

      // Generate a new accessToken
      const newAccessToken = jwt.sign({ _id: user._id }, process.env.JWT_SECRET, {
        expiresIn: "30m",
      });

      // Reply with the new token
      res
        .cookie("accessToken", newAccessToken, {
          httpOnly: true,
          expires: new Date(Date.now() + 30 * 60 * 1000), // 30m
        })
        .json({ message: "Access token refreshed" });
    } catch (err) {
      console.error(err);
      res.status(401).json({ message: "Invalid or expired refresh token" });
    }
  },

  logout: (req, res) => {
    try {
      res.clearCookie("accessToken");
      res.status(200).json({
        message: "You have successfully logged out of our system",
      });
    } catch (error) {
      res.status(400).json(err);
    }
  },
};

Exemple de contrôleur étudiant (controllers/student.controller.js) :

const { StudentModel } = require("../models/student.model"); // Assurez-vous que le chemin est correct

module.exports = {
  findAllStudents: async (req, res) => {
    try {
      const students = await StudentModel.find(); // Récupère tous les étudiants
      res.json(students);
    } catch (err) {
      res.status(500).json({ error: "Could not retrieve students" });
    }
  },

  findDetailsSingleStudent: async (req, res) => {
    const studentId = req.params.id;
    try {
      const student = await StudentModel.findById(studentId);
      if (!student) {
        return res.status(404).json({ message: "Student not found" });
      }
      res.json(student);
    } catch (err) {
      res.status(500).json({ error: "Could not retrieve student details" });
    }
  },

  createNewStudent: async (req, res) => {
    try {
      const newStudent = await StudentModel.create(req.body);
      res.status(201).json(newStudent); // 201 Created
    } catch (err) {
      res.status(400).json({ error: "Could not create student" });
    }
  },

  updateExistingStudent: async (req, res) => {
    const studentId = req.params.id;
    try {
      const updatedStudent = await StudentModel.findByIdAndUpdate(
        studentId,
        req.body,
        { new: true, runValidators: true } // Retourne le document modifié et valide les données
      );
      if (!updatedStudent) {
        return res.status(404).json({ message: "Student not found" });
      }
      res.json(updatedStudent);
    } catch (err) {
      res.status(400).json({ error: "Could not update student" });
    }
  },

  deleteAnExistingStudent: async (req, res) => {
    const studentId = req.params.id;
    try {
      const deletedStudent = await StudentModel.findByIdAndDelete(studentId);
      if (!deletedStudent) {
        return res.status(404).json({ message: "Student not found" });
      }
      res.status(204).send(); // 204 No Content (succès, pas de contenu à renvoyer)
    } catch (err) {
      res.status(500).json({ error: "Could not delete student" });
    }
  },

  notifsStudent: async (req, res) => {
    try {
      // TODO: Implémenter la logique pour les notifications des étudiants
      res.status(200).json({ message: "NotifsStudent route works" });
    } catch (err) {
      res.status(500).json({ error: "Could not process notifications" });
    }
  },
};

Étape 5 : Configurer l'authentification et les autorisations

Créez un fichier config/jwt.config.js pour gérer l'authentification et les autorisations (qui a le droit de faire quoi).

// config/jwt.config.js
const jwt = require("jsonwebtoken");
const UserModel = require("../models/user.model");
const ActivityModel = require("../models/activity.model");

module.exports = {
  authenticate: async (req, res, next) => {
    try {
      const token = req.cookies.accessToken;
      if (!token) {
        return res.status(401).json({
          message: "Unauthorized access: No token provided.",
        });
      }

      const decodedToken = jwt.verify(token, process.env.JWT_SECRET);
      const user = await UserModel.findOne({ _id: decodedToken._id });

      if (!user) {
        return res.status(401).json({
          message: "Unauthorized access: User not found.",
        });
      }

      console.log("You are authenticated!");
      req.role = user.role; // Attach user role to the request.
      req.user = user;
      next();
    } catch (err) {
      console.error(err);
      res.status(401).json({ message: "Unauthorized access." });
    }
  },

  checkPermissions: (...roles) => {
    return (req, res, next) => {
      if (!roles.includes(req.role)) {
        return res.status(403).json({
          message: "You do not have permission to perform this action.",
        });
      }
      next();
    };
  },

  logActivityMiddleware: async (req, res, next) => {
    try {
      if (req.user) {
        // if the user is authenticated
        await ActivityModel.create({ userId: req.user._id });
      }
      next();
    } catch (error) {
      console.error("Error logging user activity:", error.message);
      next(); // Move to the next middleware even if an error occurs
    }
  },
};

Étape 6 : Définir les routes

Créez un dossier routes pour organiser vos routes. Les routes définissent comment l'application répond aux requêtes des clients.

Exemple de route utilisateur (routes/user.routes.js) :

// routes/user.routes.js
const {
  CreateAdmins,
  login,
  logout,
  refreshToken,
} = require("../controllers/user.controller");
const { authenticate, checkPermissions } = require("../config/jwt.config");

module.exports = (app) => {
  app.post(
    "/api/register",
    authenticate,
    checkPermissions("superAdmin"),
    CreateAdmins
  );
  app.get(
    "/api/admins",
    authenticate,
    checkPermissions("superAdmin"),
    findAllAdminsByNodeleted
  );
  app.patch(
    "/api/admins/delete/:id",
    authenticate,
    checkPermissions("superAdmin"),
    deleteAnExistingUserNew
  );
  app.post("/api/login", login);
  app.get("/api/refresh-token", refreshToken);
  app.post("/api/logout", logout);
};

Exemple de route étudiant (routes/student.routes.js) :

const {
  findAllStudents,
  findDetailsSingleStudent,
  createNewStudent,
  updateExistingStudent,
  notifsStudent,
  deleteAnExistingStudent,
} = require("../controllers/student.controller");

const { authenticate, checkPermissions, logActivityMiddleware } = require("../config/jwt.config");

module.exports = (app) => {
  app.get("/api/students/:id", findDetailsSingleStudent);
  app.get(
    "/api/students",
    authenticate,
    checkPermissions("superAdmin", "admin"),
    logActivityMiddleware,
    findAllStudents
  );
  app.post(
    "/api/notifs",
    authenticate,
    checkPermissions("superAdmin", "admin"),
    logActivityMiddleware,
    notifsStudent
  );
  app.post(
    "/api/students",
    authenticate,
    checkPermissions("superAdmin", "admin"),
    logActivityMiddleware,
    createNewStudent
  );
  app.patch(
    "/api/students/:id",
    authenticate,
    checkPermissions("superAdmin", "admin"),
    logActivityMiddleware,
    updateExistingStudent
  );
  app.delete(
    "/api/students/:id",
    authenticate,
    checkPermissions("superAdmin", "admin"),
    logActivityMiddleware,
    deleteAnExistingStudent
  );
};

Étape 7 : Configurer l'application Express

Créez un fichier server.js (ou app.js) pour configurer votre application Express.

// server.js
const express = require("express");
const cookieParser = require("cookie-parser");
const dotenv = require("dotenv");
const connectDB = require("./config/database"); // Si vous utilisez MongoDB
const cors = require('cors');

dotenv.config(); // Load environment variables from .env file

const app = express();
const PORT = process.env.PORT || 5000;

// Connect to MongoDB
if (process.env.MONGO_URI) { // Only connect if MONGO_URI is defined
    connectDB();
}

// Middleware
app.use(cors({
    origin: 'http://localhost:3000',  // or the origin of your front-end
    credentials: true,             // enable sending cookies
}));
app.use(express.json()); // Parse JSON request bodies
app.use(cookieParser()); // Parse cookies


// Routes
require("./routes/user.routes")(app);
require("./routes/student.routes")(app);

app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});

Étape 8 : Créer le client Axios (front-end)

Ce code configure une instance d'Axios pour effectuer des requêtes HTTP vers votre API Express. Il gère également le renouvellement automatique des tokens d'accès et les erreurs d'autorisation.

// axiosInstance.js
import axios from "axios";
import baseURL_API from "@/constants/baseURL_API"; // Assurez-vous que le chemin est correct
import baseUrl from "@/constants/baseUrl";       // Assurez-vous que le chemin est correct

const baseURL: string = baseURL_API;

// Create an Axios instance
const axiosInstance = axios.create({
  baseURL,
  withCredentials: true, // Include cookies with every query
});

// Interceptor to handle expired token and errors
axiosInstance.interceptors.response.use(
  (response) => {
    // If the response is OK, we return it directly
    return response;
  },
  async (error) => {
    const originalRequest = error.config;

    if (error.response) {
      const status = error.response.status;

      // Handling 401 errors
      if (status === 401 && !originalRequest._retry) {
        //the request is executed once, avoids infinite retry loops.
        originalRequest._retry = true;

        try {
          // Try to refresh the token
          await axios.get(baseUrl + "refresh-token", {
            withCredentials: true,
          });
          return axiosInstance(originalRequest); // Retry the original query
        } catch (err: any) {
          console.error("Failed to refresh token", err);
          window.location.href = "/home/signin";
          localStorage.removeItem("USER_OBJ"); // for the "protected routes"
        }
      }

      // Handling 401 errors
      if (status === 401 && originalRequest._retry) {
        window.location.href = "/home/signin";
        localStorage.removeItem("USER_OBJ");
      }

      // Handling 403 errors
      if (status === 403) {
        console.error("Access prohibited: status 403");
        //window.location.href = "/not-authorized";
        window.location.href = "/home/signin";
        localStorage.removeItem("USER_OBJ");
      }
    }

    return Promise.reject(error);
  }
);

export default axiosInstance;

Explication Détaillée du Code

axiosInstance.js (Front-end):

  • Crée une instance d'Axios avec une configuration de base, incluant l'envoi des cookies avec chaque requête (withCredentials: true). C'est essentiel pour que le serveur puisse vérifier l'authentification via les cookies.
  • Définit un interceptor pour la réponse (axiosInstance.interceptors.response.use). Les interceptors permettent d'intercepter et de modifier les requêtes ou les réponses.
  • Dans l'interceptor, si une requête échoue avec un code d'erreur 401 (Unauthorized), cela signifie que le token d'accès a peut-être expiré. L'interceptor essaie de récupérer un nouveau token d'accès en envoyant une requête à la route /refresh-token de votre serveur.
  • Si le renouvellement du token réussit, la requête originale est relancée avec le nouveau token.
  • Si le renouvellement du token échoue, l'utilisateur est redirigé vers la page de connexion (/home/signin), et les informations de l'utilisateur sont supprimées du localStorage.
  • Gère également les erreurs 403 (Forbidden) enredirigeant l'utilisateur vers la page de connexion.

server.js (Back-end):

  • Initialise une application Express.
  • Charge les variables d'environnement à partir d'un fichier .env (important pour stocker les secrets comme JWT_SECRET).
  • Se connecte à la base de données MongoDB si MONGO_URI est fourni.
  • Utilise le middleware cors pour gérer les problèmes de CORS (Cross-Origin Resource Sharing). C'est crucial pour permettre à votre front-end (qui tourne peut-être sur un port différent) de communiquer avec votre API. credentials: true est nécessaire pour que les cookies soient inclus dans les requêtes CORS.
  • Utilise express.json() pour parser automatiquement le corps des requêtes HTTP au format JSON.
  • Utilise cookieParser() pour parser les cookies envoyés par le client.
  • Inclut les fichiers de routes pour organiser les routes de l'application.
  • Démarre le serveur Express sur le port spécifié.

config/jwt.config.js (Back-end):

  • Contient les fonctions pour l'authentification et l'autorisation.
  • authenticate :
    • Middleware qui vérifie si un token d'accès est présent dans les cookies de la requête.
    • Si le token est présent, il le vérifie avec la clé secrète (JWT_SECRET).
    • Si le token est valide, il décode les informations du token et récupère l'utilisateur correspondant de la base de données.
    • Ajoute les informations de l'utilisateur (req.user) et son rôle (req.role) à l'objet req pour que les middlewares suivants puissent y accéder.
    • Appelle next() pour passer au middleware suivant.
  • checkPermissions :
    • Fonction qui renvoie un middleware. Ce middleware vérifie si le rôle de l'utilisateur (récupéré lors de l'authentification) est autorisé à accéder à la route.
    • Prend un nombre variable de rôles autorisés en arguments (...roles).
    • Si le rôle de l'utilisateur n'est pas inclus dans les rôles autorisés, il renvoie une erreur 403 (Forbidden).
    • Appelle next() pour passer au middleware suivant si l'utilisateur est autorisé.
  • logActivityMiddleware:
    • Middleware qui enregistre l'activité de l'utilisateur (date et heure) dans la base de données.
    • Crée une entrée dans le modèle ActivityModel.

Schéma de Fonctionnement de l'Authentification

Connexion (Login):

  • L'utilisateur envoie ses identifiants (email et mot de passe) au serveur via une requête POST sur la route /api/login.
  • Le serveur vérifie les identifiants, crée un accessToken et un refreshToken, les envoie au client dans des cookies, et stocke le refreshToken dans la base de données.

Requêtes Protégées:

  • Lorsque l'utilisateur accède à une route protégée, le navigateur envoie automatiquement les cookies (y compris l'accessToken) au serveur.
  • Le middleware authenticate vérifie la validité de l'accessToken. Si le token est invalide ou expiré, l'utilisateur n'est pas autorisé.
  • Si le token est valide, le serveur traite la requête.

Renouvelement du Token (Access Token):

  • Si l'accessToken expire, le client (front-end) intercepte l'erreur 401.
  • Le client envoie une requête à la route /api/refresh-token.
  • Le serveur vérifie la validité du refreshToken (et vérifie l'inactivité).
  • Si le refreshToken est valide, le serveur crée un nouvel accessToken et l'envoie au client dans un cookie.
  • Le client relance la requête originale avec le nouvel accessToken.

Déconnexion (Logout):

  • L'utilisateur clique sur un bouton de déconnexion, ce qui déclenche une requête POST sur la route /api/logout.
  • Le serveur efface le cookie accessToken. Le refreshToken reste dans la base de données jusqu'à son expiration ou invalidation par inactivité.

Sécurité

  • Tokens HTTP-only : Les tokens sont envoyés dans des cookies avec l'option httpOnly: true. Cela empêche le JavaScript du navigateur d'accéder aux tokens, réduisant ainsi le risque de XSS (Cross-Site Scripting).
  • Hashage des mots de passe : Les mots de passe ne sont jamais stockés en clair dans la base de données. Ils sont hashés avec bcrypt, ce qui les rend illisibles.
  • Renouvellement du token et Inactivité : Utilisation d'un refreshToken pour obtenir un nouvel accessToken sans demander à l'utilisateur de se reconnecter fréquemment. La vérification de l'inactivité (lastActivity) invalide le refreshToken après une période d'inactivité, ce qui améliore la sécurité en cas de vol du token.
  • Validation des données : Assurez-vous de toujours valider les données envoyées par le client (par exemple, l'email et le mot de passe lors de la connexion) pour prévenir les vulnérabilités comme les injections SQL. Vous pouvez utiliser une bibliothèque comme express-validator pour cela.
  • Protection CSRF : Si vous utilisez des sessions ou des cookies pour l'authentification, protégez-vous contre les attaques CSRF (Cross-Site Request Forgery). Vous pouvez utiliser une bibliothèque comme csurf pour cela.
  • Variables d'environnement : Ne jamais stocker de secrets (comme JWT_SECRET) directement dans votre code. Utilisez des variables d'environnement (avec un fichier .env) et assurez-vous que ce fichier n'est pas accessible publiquement.

Conclusion

Ce guide vous a montré comment mettre en place un système d'authentification sécurisé avec Express.js, en utilisant des tokens JWT, des cookies HTTP-only, et le hashage des mots de passe. N'oubliez pas d'adapter ce code à vos besoins spécifiques et de toujours suivre les meilleures pratiques de sécurité. L'authentification est un domaine complexe, mais avec une bonne compréhension des concepts et des outils, vous pouvez protéger efficacement votre application.

Commentaires