Maîtriser le Développement Full-stack Type-Safe : De l'API au Frontend avec TypeScript
Maîtriser le Développement Full-stack Type-Safe : De l'API au Frontend avec TypeScript

Mise en place de l'Authentification et de l'Autorisation Type-Safe

Bienvenue dans cette leçon consacrée à un pilier fondamental de la sécurité des applications web : l'authentification et l'autorisation. Dans le cadre de notre cours sur le développement Full-stack Type-Safe avec TypeScript, nous allons explorer comment implémenter ces mécanismes de manière robuste, prévisible et surtout, type-safe, de l'API au frontend.

Introduction : Sécuriser nos Applications Type-Safe

Lorsqu'on construit une application, il est rare que tous les utilisateurs aient accès à toutes les fonctionnalités. Il faut savoir qui est l'utilisateur et ce qu'il a le droit de faire. C'est précisément le rôle de l'authentification et de l'autorisation.

  • Authentification : Le processus de vérification de l'identité d'un utilisateur. Il s'agit de s'assurer que l'utilisateur est bien celui qu'il prétend être (par exemple, en vérifiant un mot de passe).
  • Autorisation : Le processus de détermination des actions qu'un utilisateur authentifié est autorisé à effectuer. Une fois que nous savons qui est l'utilisateur, nous devons décider à quelles ressources il peut accéder et quelles opérations il peut réaliser.

Dans un environnement TypeScript, la "type-safety" apporte une couche de prévisibilité et de robustesse cruciale. En typant nos données utilisateur, nos rôles et nos permissions, nous réduisons considérablement les erreurs d'exécution, améliorons l'expérience de développement (DX) grâce à l'autocomplétion et renforçons la sécurité en garantissant que les données manipulées sont conformes à nos attentes.

Notre objectif est de construire un système où les informations d'authentification et d'autorisation sont structurées et vérifiées par TypeScript à chaque étape, de la génération du token sur le backend à son utilisation sur le frontend.

Concepts Clés de l'Authentification

Pour notre approche type-safe, nous nous concentrerons sur l'authentification basée sur les tokens, en particulier les JSON Web Tokens (JWT), qui sont largement adoptés pour les APIs RESTful et les applications modernes sans état.

Le JSON Web Token (JWT)

Un JWT est une méthode standard et compacte pour transmettre des informations en toute sécurité entre les parties sous forme d'objet JSON. Ces informations peuvent être vérifiées et fiables car elles sont signées numériquement.

Un JWT se compose de trois parties, séparées par des points (.):

  1. Header (En-tête) : Contient le type de token (JWT) et l'algorithme de signature utilisé (par exemple, HS256 ou RS256).
    {
      "alg": "HS256",
      "typ": "JWT"
    }
    
  2. Payload (Charge utile) : Contient les "claims" (revendications). Les claims sont des déclarations sur une entité (généralement l'utilisateur) et des données supplémentaires. C'est ici que nous inclurons nos informations type-safe sur l'utilisateur.
    • Claims enregistrées (Registered claims) : Recommandées mais non obligatoires (ex: iss - émetteur, exp - expiration, sub - sujet).
    • Claims publiques (Public claims) : Définies par les utilisateurs, par exemple pour transporter des informations spécifiques.
    • Claims privées (Private claims) : Créées pour partager des informations entre parties spécifiques. C'est ici que nous mettrons l'ID de l'utilisateur et son rôle.
    {
      "userId": "123e4567-e89b-12d3-a456-426614174000",
      "userRole": "admin",
      "iat": 1678886400, // Issued At (timestamp)
      "exp": 1678890000  // Expiration Time (timestamp)
    }
    
  3. Signature : Créée en encodant l'en-tête et la charge utile en Base64Url, puis en appliquant l'algorithme spécifié dans l'en-tête, avec une clé secrète. Cette signature garantit que le token n'a pas été altéré en cours de route.
    HMACSHA256(
      base64UrlEncode(header) + "." +
      base64UrlEncode(payload),
      secret
    )
    

Le JWT complet ressemble à ceci : aaaaa.bbbbb.ccccc

Avantages et Inconvénients des JWT pour la Type-Safety

  • Avantages :
    • Stateless (sans état) : Le serveur n'a pas besoin de stocker l'état de la session, ce qui facilite la scalabilité.
    • Portabilité : Les tokens peuvent être utilisés entre différents services (microservices).
    • Type-Safe Payload : En TypeScript, nous pouvons définir une interface pour la charge utile (JwtPayload), garantissant que toutes les données extraites sont fortement typées.
  • Inconvénients :
    • Taille : Les tokens peuvent devenir grands si beaucoup de données sont stockées dans le payload.
    • Révocation : Les JWT sont difficiles à révoquer avant leur expiration car le serveur ne les "suit" pas. Des mécanismes supplémentaires (listes noires) sont nécessaires.
    • Sécurité : Si le token est compromis, il reste valide jusqu'à son expiration. Nécessite une gestion rigoureuse des expirations courtes et des refresh tokens.

Concepts Clés de l'Autorisation

Une fois que l'utilisateur est authentifié et que son identité est établie, nous passons à l'autorisation. Nous nous concentrerons sur le Contrôle d'Accès Basé sur les Rôles (RBAC), une approche courante et bien adaptée à la type-safety.

Contrôle d'Accès Basé sur les Rôles (RBAC)

Le RBAC est un modèle où les permissions sont associées à des rôles, et les utilisateurs se voient attribuer un ou plusieurs rôles. Cela simplifie la gestion des permissions, car au lieu d'attribuer des permissions individuelles à chaque utilisateur, nous attribuons des rôles qui encapsulent des ensembles de permissions.

  • Utilisateur : Une personne ou une entité qui interagit avec l'application.
  • Rôle : Une fonction ou un titre au sein de l'organisation (ex: Administrateur, Éditeur, Lecteur, Utilisateur Standard).
  • Permission : L'autorisation d'effectuer une action spécifique sur une ressource (ex: créer un article, éditer un article, supprimer un utilisateur).

Avec le RBAC, pour vérifier l'autorisation, nous devons simplement vérifier si l'utilisateur possède un rôle spécifique qui a les permissions nécessaires pour l'action demandée.

Type-Safety avec les Rôles

En TypeScript, nous pouvons définir nos rôles comme un enum ou un union type, ce qui nous permet de les manipuler de manière sûre à travers notre application, du backend au frontend.

// src/shared/types.ts (ou un fichier de types partagé)

export enum UserRole {
  ADMIN = 'admin',
  EDITOR = 'editor',
  VIEWER = 'viewer',
  USER = 'user',
}

Ce type UserRole sera inclus dans le JwtPayload et utilisé pour les vérifications d'autorisation.

Implémentation de l'Authentification Type-Safe (Backend)

Nous allons maintenant voir comment implémenter l'authentification et l'autorisation dans un backend Node.js avec Express et TypeScript.

1. Définir les Types Partagés

Pour une approche type-safe, il est essentiel de définir des interfaces qui seront utilisées à la fois par le backend et le frontend.

// src/shared/types.ts (ou un dossier de types partagés dans un monorepo)

export enum UserRole {
  ADMIN = 'admin',
  EDITOR = 'editor',
  VIEWER = 'viewer',
  USER = 'user',
}

// Interface pour le payload de notre JWT
export interface JwtPayload {
  userId: string;
  userRole: UserRole;
  // Ajoutons quelques champs standard de JWT pour la démo
  iat?: number; // Issued At
  exp?: number; // Expiration Time
}

// Interface pour l'utilisateur tel que stocké dans notre DB (simplifié)
export interface User {
  id: string;
  email: string;
  passwordHash: string; // En production, le mot de passe doit être haché
  role: UserRole;
}

2. Le Serveur Express et la Route de Connexion

Voici un exemple simplifié d'une route de connexion qui génère un JWT.

// src/server.ts (exemple avec Express)
import express, { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { JwtPayload, UserRole, User } from './shared/types'; // Nos types partagés

// Clé secrète pour signer les JWT (À utiliser via les variables d'environnement en prod !)
const JWT_SECRET = process.env.JWT_SECRET || 'super-secret-key';

const app = express();
app.use(express.json()); // Permet à Express de parser le corps des requêtes en JSON

// --- Base de données simulée (en réalité, vous interagiriez avec une DB) ---
const mockUsers: User[] = [
  { id: 'user-1', email: 'admin@example.com', passwordHash: 'password123', role: UserRole.ADMIN },
  { id: 'user-2', email: 'editor@example.com', passwordHash: 'password123', role: UserRole.EDITOR },
  { id: 'user-3', email: 'viewer@example.com', passwordHash: 'password123', role: UserRole.VIEWER },
];

// --- Route de connexion ---
app.post('/api/auth/login', (req: Request, res: Response) => {
  const { email, password } = req.body;

  // 1. Vérifier les identifiants de l'utilisateur (simplifié pour la démo)
  const user = mockUsers.find(u => u.email === email && u.passwordHash === password);

  if (!user) {
    return res.status(401).json({ message: 'Identifiants invalides' });
  }

  // 2. Créer le payload du JWT avec nos types
  const payload: JwtPayload = {
    userId: user.id,
    userRole: user.role,
  };

  // 3. Générer le token JWT
  const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' }); // Token valide 1 heure

  res.json({ token, user: { id: user.id, email: user.email, role: user.role } });
});

Explication du code :

  • Nous importons nos types JwtPayload, UserRole, et User qui assurent la cohérence.
  • La route /api/auth/login prend un email et un mot de passe.
  • Après une vérification (simplifiée ici), nous construisons un objet payload de type JwtPayload. C'est crucial pour la type-safety. TypeScript s'assurera que payload contient userId et userRole.
  • Le jwt.sign() crée le token, incluant le payload typé et le signe avec notre clé secrète. Le token est envoyé au client.

3. Middleware d'Authentification Type-Safe

Pour protéger nos routes, nous avons besoin d'un middleware qui va vérifier le JWT envoyé par le client et extraire les informations de l'utilisateur.

Pour rendre Express type-safe, nous allons étendre l'interface Request d'Express pour inclure notre propriété user typée.

// src/server.ts (suite)
// Déclarer un module pour étendre l'interface Request d'Express
declare module 'express-serve-static-core' {
  interface Request {
    user?: JwtPayload; // Ajout de la propriété 'user' de type JwtPayload
  }
}

// --- Middleware d'authentification ---
export const authenticateToken = (req: Request, res: Response, next: NextFunction) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // Format: "Bearer TOKEN"

  if (token == null) {
    return res.status(401).json({ message: 'Accès refusé : Aucun token fourni' });
  }

  jwt.verify(token, JWT_SECRET, (err, decodedPayload) => {
    if (err) {
      return res.status(403).json({ message: 'Accès refusé : Token invalide ou expiré' });
    }

    // Le payload décodé est de type JwtPayload grâce à notre logique
    // Il est important de s'assurer que decodedPayload correspond à JwtPayload
    req.user = decodedPayload as JwtPayload; // Cast du payload décodé vers notre interface JwtPayload
    next();
  });
};

Explication du code :

  • Le declare module 'express-serve-static-core' est une technique TypeScript pour étendre les définitions de type d'une bibliothèque tierce. Ici, nous ajoutons une propriété user optionnelle de type JwtPayload à l'interface Request. Cela signifie que dans toutes les routes ou middlewares après authenticateToken, req.user sera reconnu par TypeScript comme un objet de type JwtPayload.
  • Le middleware authenticateToken extrait le token de l'en-tête Authorization.
  • jwt.verify() vérifie la signature et l'expiration du token.
  • Si le token est valide, le decodedPayload est casté en JwtPayload et attaché à req.user. TypeScript garantira alors la structure correcte de req.user dans les middlewares et routes suivants.

Implémentation de l'Autorisation Type-Safe (Backend)

Maintenant que l'utilisateur est authentifié et que ses informations (y compris son rôle) sont disponibles via req.user, nous pouvons implémenter un middleware d'autorisation.

Middleware d'Autorisation Basé sur les Rôles

// src/server.ts (suite)

// --- Middleware d'autorisation ---
export const authorizeRoles = (requiredRoles: UserRole[]) => {
  return (req: Request, res: Response, next: NextFunction) => {
    // S'assurer que l'utilisateur est bien authentifié avant de vérifier les rôles
    if (!req.user) {
      return res.status(401).json({ message: 'Non authentifié.' });
    }

    // Vérifier si le rôle de l'utilisateur est parmi les rôles requis
    if (!requiredRoles.includes(req.user.userRole)) {
      return res.status(403).json({ message: `Accès refusé : Rôle insuffisant. Rôle requis : ${requiredRoles.join(', ')}` });
    }

    next(); // L'utilisateur est autorisé
  };
};

// --- Exemple de routes protégées ---
app.get('/api/admin/dashboard', authenticateToken, authorizeRoles([UserRole.ADMIN]), (req: Request, res: Response) => {
  // TypeScript sait que req.user est de type JwtPayload ici !
  console.log(`Accès au dashboard par l'administrateur : ${req.user!.userId}`);
  res.json({ message: 'Bienvenue sur le tableau de bord administrateur!', user: req.user });
});

app.get('/api/editor/posts', authenticateToken, authorizeRoles([UserRole.ADMIN, UserRole.EDITOR]), (req: Request, res: Response) => {
  console.log(`Accès aux posts par l'utilisateur : ${req.user!.userId} avec le rôle : ${req.user!.userRole}`);
  res.json({ message: 'Liste des articles pour éditeurs et administrateurs.', user: req.user });
});

app.get('/api/viewer/data', authenticateToken, authorizeRoles([UserRole.ADMIN, UserRole.EDITOR, UserRole.VIEWER]), (req: Request, res: Response) => {
  console.log(`Accès aux données par l'utilisateur : ${req.user!.userId} avec le rôle : ${req.user!.userRole}`);
  res.json({ message: 'Données accessibles aux viewers, éditeurs et administrateurs.', user: req.user });
});

app.get('/api/public/data', (req: Request, res: Response) => {
    res.json({ message: 'Ceci est une donnée publique, pas besoin d\'authentification.' });
});

// Démarrer le serveur
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Serveur démarré sur le port ${PORT}`);
});

Explication du code :

  • Le middleware authorizeRoles est une factory function : elle prend un tableau de UserRole (requiredRoles) et retourne un middleware Express.
  • À l'intérieur du middleware retourné, nous vérifions si req.user existe (signifiant que l'utilisateur est authentifié).
  • Ensuite, nous utilisons requiredRoles.includes(req.user.userRole) pour vérifier si le rôle de l'utilisateur (typé UserRole) est présent dans la liste des rôles autorisés.
  • Si le rôle ne correspond pas, un statut 403 (Forbidden) est retourné. Sinon, l'exécution passe au handler de la route.
  • Remarquez comment nous enchaînons les middlewares : authenticateToken puis authorizeRoles. L'ordre est crucial. authenticateToken doit s'exécuter en premier pour que req.user soit disponible pour authorizeRoles.
  • TypeScript fournit une excellente autocomplétion et vérification pour req.user.userRole grâce à l'extension de l'interface Request et à notre JwtPayload typé.

Intégration Côté Frontend

Le frontend joue un rôle crucial dans la consommation et la gestion des tokens.

1. Stockage du Token

Après une connexion réussie, le frontend reçoit le JWT. Il doit le stocker quelque part pour l'utiliser dans les requêtes futures.

  • localStorage : Facile à utiliser, persiste entre les sessions du navigateur. Vulnérable aux attaques XSS.
  • sessionStorage : Similaire à localStorage mais ne persiste que pour la durée de la session de la fenêtre/onglet.
  • Cookies HttpOnly : Plus sécurisé contre les attaques XSS car le JavaScript ne peut pas y accéder. Nécessite une configuration serveur supplémentaire pour envoyer le token via des cookies.

Pour la simplicité de cette leçon et pour démontrer l'envoi de l'en-tête Authorization, nous supposerons l'utilisation de localStorage.

2. Envoi du Token dans les Requêtes

Pour chaque requête authentifiée vers le backend, le token doit être inclus dans l'en-tête Authorization, généralement avec le schéma Bearer.

// src/frontend/api.ts (Exemple de fonction d'appel API côté frontend)
import { JwtPayload, UserRole } from '../shared/types'; // Importez vos types partagés

const API_BASE_URL = 'http://localhost:3000/api';

interface LoginResponse {
  token: string;
  user: {
    id: string;
    email: string;
    role: UserRole;
  };
}

export async function login(email: string, password: string): Promise<LoginResponse> {
  const response = await fetch(`${API_BASE_URL}/auth/login`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ email, password }),
  });

  if (!response.ok) {
    const errorData = await response.json();
    throw new Error(errorData.message || 'Échec de la connexion');
  }

  const data: LoginResponse = await response.json();
  localStorage.setItem('jwt_token', data.token); // Stocker le token
  return data;
}

export async function fetchProtectedData<T>(endpoint: string): Promise<T> {
  const token = localStorage.getItem('jwt_token');

  if (!token) {
    throw new Error('Aucun token trouvé. Veuillez vous connecter.');
  }

  const response = await fetch(`${API_BASE_URL}/${endpoint}`, {
    method: 'GET',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`, // Inclure le token dans l'en-tête
    },
  });

  if (!response.ok) {
    if (response.status === 401 || response.status === 403) {
      localStorage.removeItem('jwt_token'); // Nettoyer le token invalide
      throw new Error('Accès non autorisé ou refusé. Vous avez été déconnecté.');
    }
    const errorData = await response.json();
    throw new Error(errorData.message || 'Erreur lors de la récupération des données.');
  }

  return response.json();
}

// Exemple d'utilisation dans un composant React/Vue/Angular
async function handleLogin() {
  try {
    const { token, user } = await login('admin@example.com', 'password123');
    console.log('Connecté !', user);
    // Mettre à jour l'état de l'application, rediriger, etc.
  } catch (error: any) {
    console.error(error.message);
  }
}

async function fetchAdminDashboard() {
  try {
    // Le type de retour sera inféré ou peut être spécifié si connu
    const data = await fetchProtectedData<{ message: string; user: JwtPayload }>('admin/dashboard');
    console.log('Données du tableau de bord admin:', data.message, data.user.userRole);
  } catch (error: any) {
    console.error(error.message);
  }
}

// Simulation d'un appel après connexion
// handleLogin();
// setTimeout(() => fetchAdminDashboard(), 1000); // Appeler après un court délai pour la démo

Explication du code :

  • Les types JwtPayload et UserRole sont partagés et importés, assurant que le frontend comprend la structure des données utilisateur.
  • La fonction login effectue la requête de connexion et stocke le token reçu dans localStorage.
  • La fonction fetchProtectedData récupère le token du localStorage et l'ajoute à l'en-tête Authorization de chaque requête sortante.
  • La gestion des erreurs 401/403 est un bon endroit pour déconnecter l'utilisateur automatiquement.
  • Grâce aux types partagés, le frontend sait exactement à quoi s'attendre du backend concernant les données de l'utilisateur dans les réponses protégées, améliorant ainsi la robustesse et la maintenance.

Bonnes Pratiques et Considérations de Sécurité

La sécurité est un domaine complexe. Voici quelques bonnes pratiques essentielles :

  • Gestion de la Clé Secrète JWT : Ne jamais hardcoder la clé secrète en production. Utilisez des variables d'environnement (process.env.JWT_SECRET) et assurez-vous qu'elle est forte et générée aléatoirement.
  • Expiration des Tokens et Refresh Tokens :
    • Les tokens d'accès (comme ceux que nous avons créés) devraient avoir une courte durée de vie (ex: 15min - 1h) pour limiter les dégâts en cas de vol.
    • Implémentez un mécanisme de refresh token : un token à longue durée de vie, stocké de manière plus sécurisée (par exemple, dans un cookie HttpOnly), utilisé uniquement pour obtenir de nouveaux tokens d'accès.
  • HTTPS : Toujours servir votre application via HTTPS pour chiffrer les communications et prévenir l'interception des tokens.
  • CORS (Cross-Origin Resource Sharing) : Configurez correctement les en-têtes CORS sur votre serveur pour autoriser uniquement les domaines frontend légitimes.
  • Hachage des Mots de Passe : Ne stockez jamais les mots de passe en texte clair. Utilisez des fonctions de hachage robustes comme bcrypt ou argon2.
  • Validation et Nettoyage des Entrées : Validez et nettoyez toutes les entrées utilisateur pour prévenir les injections SQL, XSS, etc.
  • Rate Limiting : Implémentez des limites de taux sur les routes sensibles (connexion, inscription) pour se protéger contre les attaques par force brute.
  • Journalisation (Logging) : Enregistrez les tentatives de connexion échouées et d'autres événements de sécurité pour la détection d'incidents.

Conclusion

Dans cette leçon, nous avons parcouru les étapes clés pour mettre en place un système d'authentification et d'autorisation type-safe pour vos applications full-stack TypeScript.

Nous avons couvert :

  • Les définitions de l'authentification et de l'autorisation.
  • Le fonctionnement des JSON Web Tokens (JWT) comme mécanisme d'authentification sans état.
  • Le Contrôle d'Accès Basé sur les Rôles (RBAC) pour la gestion des permissions.
  • Comment utiliser les types TypeScript partagés (JwtPayload, UserRole) pour garantir la cohérence et la robustesse entre le backend et le frontend.
  • L'implémentation pratique de middlewares Express type-safe pour l'authentification et l'autorisation, en étendant l'objet Request.
  • Les principes d'intégration côté frontend pour l'envoi et la gestion des tokens.
  • Des bonnes pratiques de sécurité pour protéger votre application.

L'adoption d'une approche type-safe pour l'authentification et l'autorisation non seulement réduit les bugs et améliore la maintenabilité, mais renforce également la confiance dans la logique de sécurité de votre application. Continuez à explorer les mécanismes avancés comme les refresh tokens, les politiques de sécurité plus granulaires et l'intégration avec des fournisseurs d'identité tiers (OAuth, OpenID Connect) pour construire des systèmes encore plus robustes.