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 (.):
- 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" } - 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) } - Claims enregistrées (Registered claims) : Recommandées mais non obligatoires (ex:
- 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, etUserqui assurent la cohérence. - La route
/api/auth/loginprend un email et un mot de passe. - Après une vérification (simplifiée ici), nous construisons un objet
payloadde typeJwtPayload. C'est crucial pour la type-safety. TypeScript s'assurera quepayloadcontientuserIdetuserRole. - 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éuseroptionnelle de typeJwtPayloadà l'interfaceRequest. Cela signifie que dans toutes les routes ou middlewares aprèsauthenticateToken,req.usersera reconnu par TypeScript comme un objet de typeJwtPayload. - Le middleware
authenticateTokenextrait le token de l'en-têteAuthorization. jwt.verify()vérifie la signature et l'expiration du token.- Si le token est valide, le
decodedPayloadest casté enJwtPayloadet attaché àreq.user. TypeScript garantira alors la structure correcte dereq.userdans 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
authorizeRolesest une factory function : elle prend un tableau deUserRole(requiredRoles) et retourne un middleware Express. - À l'intérieur du middleware retourné, nous vérifions si
req.userexiste (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 :
authenticateTokenpuisauthorizeRoles. L'ordre est crucial.authenticateTokendoit s'exécuter en premier pour quereq.usersoit disponible pourauthorizeRoles. - TypeScript fournit une excellente autocomplétion et vérification pour
req.user.userRolegrâce à l'extension de l'interfaceRequestet à notreJwtPayloadtypé.
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 àlocalStoragemais 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
JwtPayloadetUserRolesont partagés et importés, assurant que le frontend comprend la structure des données utilisateur. - La fonction
logineffectue la requête de connexion et stocke letokenreçu danslocalStorage. - La fonction
fetchProtectedDatarécupère le token dulocalStorageet l'ajoute à l'en-têteAuthorizationde 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
bcryptouargon2. - 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.