8. Authentification et autorisation
Deux questions distinctes se posent pour chaque requête :
- qui est l'utilisateur ? C'est l'authentification : il s'est identifié par un login et un mot de passe, et chaque requête suivante doit prouver qu'elle vient de lui ;
- a-t-il le droit d'accéder à cette page ? C'est l'autorisation : dans l'étude de cas, seul un utilisateur de rôle ADMIN gère les médecins et les clients.
La preuve d'identité doit voyager avec chaque requête. [Django] possède une application d'authentification complète, [django.contrib.auth] : un modèle [User] (une table de la base), un middleware qui range l'utilisateur connecté dans [request.user], des vues de connexion et de déconnexion, des permissions, des groupes, et l'interface d'administration qui les gère. Elle range l'utilisateur connecté dans sa session (chapitre sur les portées), ce qui est la solution la plus simple pour une application web. L'étude de cas, dans toutes ses versions ([ASP.NET Core MVC], [Spring MVC], [NestJS], [Flask], [Laravel], [Symfony]), utilise plutôt un jeton JWT (JSON Web Token), que nous garderons ici : après la connexion, le serveur fabrique un jeton qui contient l'identité de l'utilisateur et le signe avec une clé secrète (c'est le principe du cookie signé de l'exemple 23). Le jeton est déposé dans un cookie ; le navigateur le renvoie à chaque requête ; le serveur vérifie la signature. L'utilisateur peut lire son jeton, mais pas le modifier : toute modification rend la signature invalide. Le format est normalisé (RFC 7519), compris par tous les langages, et le jeton porte sa date d'expiration. Surtout, le serveur n'a rien à mémoriser : n'importe quel serveur qui connaît la clé peut vérifier le jeton.
Les utilisateurs de l'étude de cas sont ceux de sa propre table [users], et non ceux de [django.contrib.auth] ; les jetons, eux, n'ont pas de place dans [django.contrib.auth]. Nous écrirons donc nous-mêmes l'authentification, avec les outils que nous connaissons déjà, ce qui a l'avantage de tout montrer :
- un middleware (exemple 03) établit, pour chaque requête, qui est l'utilisateur, d'après le jeton de son cookie, et le range dans la requête ([request.utilisateur], exemple 12). Le reste de l'application ne voit que ce résultat ;
- des décorateurs (exemple 08) protègent les vues : [@connexion_requise], [@role_requis("ADMIN")] ;
- les mots de passe sont vérifiés par les fonctions de hachage de [Django] ([django.contrib.auth.hashers], utilisables sans le reste de l'application [auth]) ;
- les refus d'accès sont des exceptions [PermissionDenied], transformées en page d'erreur par une vue [handler403] (exemple 24) ;
- les tentatives de connexion sont limitées à l'aide du cache de [Django] (exemple 27).
La fabrication et la vérification des jetons sont confiées à la bibliothèque [PyJWT], la plus utilisée en [Python], installée par le fichier [requirements.txt] des exemples (elle s'importe sous le nom [jwt]). (Il existe des bibliothèques de jetons JWT pour [Django], comme [djangorestframework-simplejwt], conçues pour les API ; pour une application web, quelques lignes suffisent, et elles montrent mieux le mécanisme.)
8.1. Exemple 25 : connexion, jeton, page protégée
8.1.1. La configuration : config/settings.py
Le jeton est signé avec une clé secrète, qui ne doit pas être écrite dans le code d'une application en production. Il est temps de séparer vraiment la configuration du code :
- lignes 21-26 : deux middlewares : la protection anti-CSRF (exemple 18), puis notre middleware d'authentification ;
- lignes 43-44 : deux réglages propres à l'application (exemple 06) : la clé qui signe les jetons, et leur durée de vie, en minutes. [os.environ.get("NOM", défaut)] rend la valeur de la variable d'environnement [NOM] si elle existe, la valeur par défaut sinon. En développement, la valeur écrite dans le fichier convient ; en production, on fournit la vraie clé par une variable d'environnement, qui l'emporte. Une variable d'environnement est toujours une chaîne : [int] convertit la durée en entier.
La norme exige, pour l'algorithme utilisé (HMAC-SHA256), une clé d'au moins 256 bits, soit 32 octets : [PyJWT] émet un avertissement pour une clé plus courte. Sous [Linux] ou [macOS], on lancerait l'application avec une autre clé ainsi :
et sous [Windows] ([PowerShell]) :
La clé secrète de [Django] lui-même, [SECRET_KEY], mériterait le même traitement ; l'étude de cas lit tous ses secrets dans un fichier [.env], ou dans l'environnement.
8.1.2. Les utilisateurs : securite/utilisateurs.py
- lignes 7-17 : un utilisateur : le login, le nom, le rôle, le hachage du mot de passe, et le contenu du jeton qui a authentifié l'utilisateur (vide pour un utilisateur lu dans la liste des utilisateurs). Les deux derniers attributs ont une valeur par défaut. Pour un dictionnaire, cette valeur ne peut pas être écrite [claims: dict = {}] : ce dictionnaire serait créé une seule fois, et partagé par tous les objets. [field(default_factory=dict)] demande à [@dataclass] d'appeler [dict()] pour chaque nouvel objet ;
- lignes 20-24 : un serveur ne conserve jamais un mot de passe, même chiffré : si la base de données était volée, les mots de passe le seraient aussi (et beaucoup de gens utilisent le même partout). Il conserve le résultat d'une fonction de hachage, une fonction à sens unique : on peut calculer le hachage d'un mot de passe, mais pas retrouver un mot de passe à partir de son hachage. Pour vérifier un mot de passe saisi, on calcule son hachage, et on le compare à celui qui est enregistré ;
- lignes 25-30 : deux utilisateurs de test, écrits dans le code (l'exemple 28 et l'étude de cas les mettront dans une base de données) : pour chaque login, le nom, le rôle, et le hachage du mot de passe. Le hachage a été calculé une fois pour toutes par la fonction [make_password] de [Django] (la commande est donnée en commentaire), avec son algorithme par défaut, PBKDF2-SHA256. Le hachage enregistré a quatre parties, séparées par des [$] : l'algorithme ([pbkdf2_sha256]), le nombre d'itérations, le sel, puis le résultat. Un bon algorithme de hachage de mots de passe a deux particularités. Il est volontairement lent, d'une lenteur réglable : PBKDF2 applique 1 500 000 fois la fonction SHA-256, ce qui prend quelques centaines de millisecondes, imperceptibles pour un utilisateur, mais rend très coûteux l'essai de milliards de mots de passe par un pirate ; [Django] augmente ce nombre à chaque version, pour suivre la puissance des ordinateurs (son application [auth] met alors à jour les hachages enregistrés, lors des connexions) ; les anciens hachages restent vérifiables, puisqu'ils portent leur nombre d'itérations. Il est salé : un sel, tiré au hasard, est mélangé au mot de passe avant hachage, et rangé dans le hachage lui-même ; deux utilisateurs qui ont le même mot de passe ont ainsi des hachages différents. [Django] sait aussi utiliser [Argon2] et [bcrypt] (réglage [PASSWORD_HASHERS]) ; l'étude de cas, dont la base contient des hachages [bcrypt], utilise directement la bibliothèque [bcrypt] ;
- lignes 33-38 : l'utilisateur d'un login, ou [None]. [not in] teste l'absence d'une clé ; l'affectation [nom, role, hachage = ...] déballe un tuple : chaque variable reçoit l'élément correspondant.
8.1.3. Le service d'authentification : securite/auth.py
- ligne 5 : la bibliothèque [PyJWT] ;
- ligne 7 : la fonction [check_password] de [Django] ;
- ligne 13 : le nom du cookie qui transporte le jeton ;
- lignes 19-25 : la vérification d'un login et d'un mot de passe. [check_password] lit, dans le hachage enregistré, l'algorithme, le nombre d'itérations et le sel, hache le mot de passe saisi avec eux, et compare les deux résultats (en un temps constant, exemple 23). Un login inconnu et un mot de passe faux donnent le même résultat, [None] ;
- lignes 29-39 : la fabrication du jeton. Son contenu est un dictionnaire d'affirmations (claims) sur l'utilisateur. Certaines ont un nom normalisé : [sub] (subject), l'identifiant de l'utilisateur ; [iat] (issued at), la date de création ; [exp] (expiration), la date d'expiration. Les dates s'écrivent en secondes écoulées depuis le 1er janvier 1970 ([time.time()] rend ce nombre, avec des décimales, que [int] retire). Les autres affirmations sont libres : ici le nom et le rôle. [jwt.encode] fabrique le jeton et le signe avec l'algorithme [HS256] (HMAC-SHA256, une signature par clé secrète partagée) ;
- lignes 44-50 : la vérification d'un jeton. [jwt.decode] vérifie la signature et la date d'expiration, puis rend les affirmations. Toute anomalie (jeton falsifié, expiré, mal formé) lève une exception, de la classe [jwt.InvalidTokenError] ou de l'une de ses classes filles, que le bloc [except] intercepte. Le paramètre [algorithms] fixe la liste des algorithmes acceptés : un jeton qui déclarerait un autre algorithme (voire aucun, [none]) serait refusé. Le jeton valide devient un [Utilisateur], sans hachage, avec ses affirmations (paramètre nommé [claims]).
8.1.4. Le middleware d'authentification : securite/middleware.py
Pour chaque requête, avant la vue :
- ligne 14 : par défaut, le visiteur est anonyme : [request.utilisateur] vaut [None] ;
- lignes 15-17 : si la requête porte le cookie du jeton, le jeton est vérifié ;
- lignes 18-23 : un jeton invalide (expiré ou falsifié) : le middleware répond lui-même, sans appeler la suite, par une redirection vers la page de connexion, et en profite pour effacer le cookie : [delete_cookie] ajoute à la réponse un cookie vide, déjà expiré ;
- ligne 24 : un jeton valide : l'utilisateur qu'il décrit est rangé dans la requête. Il n'est pas cherché dans la liste des utilisateurs : le jeton suffit, puisqu'il est signé.
Le middleware ne refuse rien : il établit qui est l'utilisateur, ou constate qu'il est anonyme. C'est l'authentification. Les refus sont l'affaire du contrôle d'accès.
8.1.5. Le contrôle d'accès : securite/decorateurs.py
Le décorateur [@connexion_requise] enveloppe une vue (exemple 08) : si l'utilisateur est anonyme, il répond par une redirection vers la page de connexion, sans appeler la vue ; sinon, il appelle la vue. [django.contrib.auth] fournit un décorateur de même rôle, [@login_required], qui s'appuie sur [request.user].
8.1.6. Les vues : comptes/views.py
- lignes 19-22 : le formulaire de connexion, écrit à la main dans le gabarit, est protégé contre les requêtes forgées par le middleware [CsrfViewMiddleware], comme tous les formulaires POST (exemple 18) ;
- lignes 23-28 : la connexion. En cas d'échec, le formulaire est réaffiché avec le login saisi et un message volontairement vague : dire « login inconnu » apprendrait à un pirate quels logins existent ;
- lignes 32-35 : en cas de succès, le jeton est déposé dans un cookie, avec une redirection vers la page protégée (schéma PRG). Le cookie est [httponly] : le [JavaScript] de la page ne peut pas le lire, ce qui protège le jeton contre une injection de script (XSS) ; il est [SameSite=Lax] : le navigateur ne l'envoie pas avec une requête POST venue d'un autre site, ce qui protège l'application contre les attaques CSRF ; sa durée de vie ([max_age], en secondes) est celle du jeton. En production, sur HTTPS, on ajouterait [secure=True] : le cookie ne serait jamais envoyé sur une connexion non chiffrée ;
- lignes 40-44 : la déconnexion efface le cookie ([delete_cookie]). C'est une vue POST, protégée par le jeton anti-CSRF : un simple lien vers [/logout], placé sur un autre site ou dans une image, ne peut pas déconnecter l'utilisateur à son insu. Remarquons une limite des jetons : le jeton lui-même reste valide jusqu'à son expiration. Quelqu'un qui en aurait fait une copie pourrait encore s'en servir. C'est pourquoi on limite leur durée de vie ;
- lignes 48-51 : la page protégée. Le décorateur [@connexion_requise] garantit que [request.utilisateur] n'est pas [None].
8.1.7. Les gabarits
- ligne 6 : le jeton anti-CSRF du formulaire ;
- ligne 9 : le login saisi est réaffiché après un échec (une variable absente s'affiche comme une chaîne vide).
- lignes 8-10 : le contenu du jeton, affiché pour l'exemple. [items] parcourt les couples (clé, valeur) du dictionnaire (exemple 18) ;
- lignes 13-16 : la déconnexion, par un formulaire réduit à son bouton.
8.1.8. Exécution
Demandons la page protégée [/profil] sans être connecté :
Un mot de passe erroné :

La connexion réussie. Avec [curl], il faut d'abord demander le formulaire, pour obtenir le jeton anti-CSRF et son cookie (exemple 18), puis envoyer le formulaire en conservant le cookie reçu :
Le jeton a trois parties, séparées par des points, chacune encodée en base 64 : l'entête, le contenu, la signature. Les deux premières se décodent sans aucune clé :
(Le [é] est écrit sous sa forme JSON échappée, [é].) L'expiration est une heure (3 600 secondes) après la création. Dans le navigateur, la page de profil affiche ce même contenu :

Un utilisateur malhonnête modifie son jeton : il remplace, dans le contenu, le rôle [USER] par [ADMIN], réencode cette partie en base 64, et garde la signature d'origine. Le serveur recalcule la signature du contenu modifié, qui ne correspond plus :
Le jeton falsifié est refusé, et effacé (la valeur vide et la date d'expiration passée demandent au navigateur de supprimer le cookie). Pour fabriquer une signature valide, il faudrait connaître la clé secrète : d'où l'importance de la garder secrète, et de la choisir longue et aléatoire.
8.2. Exemple 26 : les rôles
L'exemple 26 ajoute une page d'administration, réservée au rôle ADMIN. Seuls les fichiers nouveaux ou modifiés sont présentés.
8.2.1. Les rôles : securite/utilisateurs.py
- lignes 20-21 : la méthode [a_le_role] dit si l'utilisateur a un rôle, d'après la hiérarchie des rôles ;
- lignes 24-26 : une propriété (exemple 14), pour les gabarits : le DTL ne sait pas appeler une méthode avec un paramètre ([a_le_role("ADMIN")]), mais il sait lire une propriété ([utilisateur.est_admin]) ;
- lignes 30-33 : la hiérarchie des rôles : pour chaque rôle, l'ensemble ([set], écrit entre accolades) des rôles qu'il possède. Le rôle [ADMIN] comprend le rôle [USER] : un administrateur a ainsi tous les droits d'un utilisateur, sans qu'il faille lui donner les deux rôles. La méthode [a_le_role] peut utiliser [HIERARCHIE], bien qu'elle soit définie plus bas dans le fichier : le nom n'est cherché qu'au moment de l'appel, quand tout le module a été chargé.
8.2.2. Le contrôle d'accès : securite/decorateurs.py
- lignes 23-32 : un décorateur paramétré (exemple 08), [@role_requis("ADMIN")]. L'enveloppe est elle-même enveloppée par [@connexion_requise] : un visiteur anonyme est d'abord envoyé à la page de connexion ; un utilisateur authentifié qui n'a pas le rôle demandé lève l'exception [PermissionDenied] (exemple 24), qui produira une erreur 403 (Forbidden).
La différence entre les deux refus est importante : un visiteur anonyme qui demande une page protégée est envoyé à la page de connexion ; un utilisateur authentifié qui n'a pas le rôle demandé reçoit une erreur 403 : se connecter ne lui servirait à rien, puisqu'il l'est déjà.
8.2.3. Le refus d'accès : securite/erreurs.py et config/urls.py
La vue [acces_refuse] est la vue des erreurs 403 de l'application (variable [handler403], exemple 24) : [Django] l'appelle pour toute exception [PermissionDenied], en mode debug comme en production. La page [templates/erreurs/403.html] est une page d'erreur ordinaire, avec un lien de retour vers le profil. Sans cette vue, l'utilisateur verrait la page 403 sobre de [Django] (« 403 Forbidden »).
8.2.4. Les vues : comptes/views.py
- lignes 55-58 : la page d'administration, protégée par le décorateur [@role_requis("ADMIN")]. La vue ne fait aucune vérification elle-même.
Pour des règles plus fines qu'un rôle (« un utilisateur ne peut modifier que ses propres rendez-vous »), la vue vérifie elle-même, et lève [PermissionDenied] au besoin. L'étude de cas en donne des exemples.
8.2.5. La vue du profil
- lignes 7-11 : la propriété [est_admin] permet d'adapter la page au rôle. On évite ainsi, dans une vraie application, de proposer un lien qui mènerait à une erreur (ici, le lien est laissé à Marie, pour l'expérience) ; mais c'est un confort : la vraie protection est celle de la vue.
8.2.6. Exécution
Marie, de rôle USER, suit le lien vers la page d'administration :

L'administrateur, lui, y a accès :

Avec [curl] : un utilisateur de rôle USER (connecté comme à l'exemple 25, ses cookies étant dans le fichier [jeton.txt]), puis un visiteur anonyme :
Protéger les vues une par une a un défaut : un oubli ouvre une page à tous. L'étude de cas renversera la logique : toutes les URL y sont protégées par défaut, par un middleware de contrôle d'accès, et quelques URL seulement (la page de connexion, l'inscription) en sont exemptées.
8.3. Exemple 27 : captcha et limitation des tentatives
Un formulaire de connexion est une cible : un programme peut essayer des milliers de mots de passe par minute (attaque par force brute). Deux protections se complètent :
- un captcha (Completely Automated Public Turing test to tell Computers and Humans Apart) : une image contenant un code déformé, facile à lire pour un humain, difficile pour un programme ;
- une limitation des tentatives : après trois échecs, l'adresse IP du client est bloquée une minute.
L'exemple 27 reprend l'exemple 25, et ajoute ces deux protections :
8.3.1. Le dessin du captcha : securite/dessin_captcha.py
L'image est au format SVG (Scalable Vector Graphics) : un dessin vectoriel décrit en XML, c'est-à-dire du texte, que le serveur fabrique sans bibliothèque graphique, et que le navigateur affiche directement dans la page. Ce module est du [Python] pur, sans rien de propre à [Django] : c'est la traduction en [Python] de la classe des versions [Laravel] et [Symfony] de ce cours.
- lignes 13-46 : une « police » de caractères minimaliste : chaque caractère est une liste de traits, chaque trait une liste de coordonnées x, y dans une grille de 4 × 6. Les caractères faciles à confondre ([0] et [O], [1], [I] et [L]) sont exclus ;
- lignes 53-58 : un texte aléatoire, tiré parmi les caractères de la police ([list(POLICE)] : la liste des clés du dictionnaire). Le module [random] de [Python] produit des suites de nombres prévisibles (qui connaît quelques valeurs peut deviner les suivantes) : il convient aux simulations, pas à la sécurité. Le module [secrets] est un générateur cryptographique, imprévisible : [secrets.choice] tire un élément d'une liste, et [secrets.SystemRandom] offre les fonctions de [random] ([uniform] : un nombre à virgule entre deux bornes) avec ce générateur. [for _ in range(longueur)] répète [longueur] fois ; le nom [_] signale une variable dont on ne se sert pas ;
- lignes 70-102 : le dessin. Chaque caractère est agrandi, tourné et déplacé au hasard, et chacun de ses points est légèrement décalé ; chaque trait devient un élément [<path>] du SVG ([M] : déplacement, [L] : ligne). Trois courbes ([C] : courbe de Bézier) traversent l'image pour gêner les programmes de reconnaissance. Dans une f-chaîne, [{px:.1f}] écrit un nombre avec une décimale ; une expression conditionnelle peut y figurer ([{'M' if k == 0 else 'L'}]). Le module [math] fournit [cos] et [sin]. Les morceaux du SVG sont réunis par [join].
8.3.2. Le service des captchas : securite/captchas.py
- ligne 6 : le cache de [Django] : un magasin de couples clé → valeur, que tous les modules partagent. Où il range ses valeurs dépend du réglage [CACHES] (voir plus loin) ;
- lignes 21-29 : un nouveau captcha : un texte aléatoire, un identifiant aléatoire et impossible à deviner ([secrets.token_hex(12)] : 12 octets aléatoires, écrits en hexadécimal, soit 24 caractères), et le dessin. Le serveur garde, pour chaque identifiant, la réponse attendue, dans le cache, pour dix minutes : le troisième paramètre de [cache.set] est la durée de vie, en secondes. Passé ce délai, le cache l'oublie de lui-même. La page ne reçoit que l'identifiant et l'image : la réponse ne quitte jamais le serveur ;
- lignes 33-43 : la vérification. La réponse attendue est lue ([cache.get] rend [None] pour une clé absente), puis retirée du cache ([cache.delete]) : le captcha est consommé dans tous les cas, réussi ou non. Sans cela, un programme pourrait essayer toutes les réponses sur une même image. La comparaison ignore la casse ([casefold] met une chaîne en minuscules, en traitant correctement tous les alphabets) et les espaces autour de la saisie ([strip]).
8.3.3. La limitation des tentatives : securite/limiteur.py
Le limiteur compte les échecs d'un client dans une fenêtre de temps fixe : au plus trois échecs par fenêtre de 60 secondes, comptée à partir du premier échec. Chaque client est reconnu à son adresse IP.
- ligne 13 : la clé du cache, propre au client : [login.127.0.0.1] ;
- lignes 16-20 : les secondes à attendre si le client est bloqué (le nombre d'échecs a atteint le maximum), 0 sinon. L'état rangé dans le cache est un dictionnaire : le nombre d'échecs, et l'heure de la fin de la fenêtre ;
- lignes 23-30 : un échec de plus. S'il n'y a pas d'état dans le cache, une nouvelle fenêtre commence. L'état est réécrit, avec une durée de vie qui s'arrête à la fin de la fenêtre : le cache l'oubliera alors de lui-même, et le client repartira de zéro ;
- lignes 33-34 : une connexion réussie efface les échecs.
Ce limiteur, écrit pour l'exemple, a une faiblesse : deux échecs simultanés pourraient lire le même état, et n'en compter qu'un. Le cache de [Django] offre des opérations atomiques ([cache.add], qui n'écrit que si la clé n'existe pas, et [cache.incr], qui augmente un nombre), dont se sert la version de l'étude de cas. Des bibliothèques toutes prêtes existent aussi ([django-ratelimit], [django-axes]).
8.3.4. La configuration : config/settings.py
- lignes 49-53 : le cache de l'application. [LocMemCache] range les valeurs dans la mémoire du processus : c'est le cache par défaut de [Django], que nous écrivons pour qu'il soit visible. Il a les limites de la portée application (chapitre sur les portées) : il est propre à un processus, et perdu à l'arrêt du serveur. L'étude de cas utilise un cache rangé dans des fichiers ([FileBasedCache]), qui survit aux redémarrages ; en production, on préférerait [Redis] ([django.core.cache.backends.redis.RedisCache]), partagé par tous les processus ;
- lignes 56-57 : les paramètres du limiteur.
8.3.5. La vue : comptes/views.py
- ligne 25 : le limiteur de ce client, d'après son adresse IP ([REMOTE_ADDR], exemple 05) ;
- lignes 27-29 : une adresse bloquée n'est même pas écoutée ;
- lignes 31-37 : le captcha est vérifié avant le mot de passe : sa vérification ne coûte presque rien, alors que le hachage PBKDF2 est volontairement lent. Un programme qui se tromperait de captcha ne ferait ainsi pas travailler le serveur. Chaque échec, de captcha ou de mot de passe, est compté. Au troisième échec, l'adresse est bloquée jusqu'à la fin de la fenêtre de 60 secondes, comptées depuis le premier échec ;
- ligne 38 : une connexion réussie efface les échecs ;
- lignes 46-48 : chaque affichage du formulaire fabrique un nouveau captcha, y compris après un échec.
8.3.6. Le gabarit : templates/auth/login.html
- ligne 18 : l'image SVG est insérée telle quelle dans la page, sans échappement (filtre [safe], exemple 14). C'est légitime : ce HTML a été fabriqué par le serveur, il ne contient aucune saisie de l'utilisateur ;
- ligne 20 : l'identifiant du captcha, dans un champ caché ;
- ligne 21 : le navigateur ne propose pas de saisies précédentes pour ce champ ([autocomplete="off"]).
8.3.7. Exécution

Un code mal recopié :

Après trois échecs, l'adresse est bloquée :

Avec [curl], quatre tentatives avec un identifiant de captcha inventé (après avoir obtenu, comme à l'exemple 25, le cookie et le jeton anti-CSRF) :
Même avec le bon mot de passe, la quatrième tentative est refusée sans examen. L'apostrophe du message a été échappée par le DTL ([']).