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.
Le serveur étant sans état (chapitre sur les portées), la preuve d'identité doit voyager avec chaque requête. On utilise un jeton JWT (*JSON Web Token*) : après la connexion, le serveur fabrique un jeton qui contient l'identité de l'utilisateur et le signe avec une clé secrète. 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. C'est le principe de la session de [Flask] (exemple 20), mais avec un format normalisé (RFC 7519), compris par tous les langages, et une date d'expiration intégrée au jeton.
[Flask] n'a pas de système de sécurité intégré, contrairement à [Spring] ([Spring Security]) ou [ASP.NET Core]. Des extensions existent ([Flask-Login], [Flask-JWT-Extended]...), mais le mécanisme est assez simple pour être écrit à la main, avec les outils des chapitres précédents, ce qui a l'avantage de le rendre visible :
- l'authentification est l'affaire d'une fonction [before_request] (chapitre précédent), qui cherche le jeton dans le cookie, le vérifie, et range l'utilisateur authentifié dans [g], l'objet de la requête ;
- l'autorisation est l'affaire de décorateurs posés sur les actions ([@connexion_requise], [@role_requis("ADMIN")]), construits comme le décorateur [@avec_langue] de l'exemple 12.
Deux bibliothèques sont utilisées, installées par le fichier [requirements.txt] des exemples : [PyJWT], qui fabrique et vérifie les jetons, et [bcrypt], qui hache les mots de passe.
8.1. Exemple 25 : connexion, jeton, page protégée
8.1.1. La configuration : config.py et app.py
Le jeton est signé avec une clé secrète, qui ne doit pas être écrite dans le code. Il est temps de séparer la configuration du code :
Un fichier de configuration de [Flask] est un simple module [Python] : ses variables en majuscules deviennent des clés de [app.config]. [JWT_SECRET] est la clé de signature des jetons ; la norme exige, pour l'algorithme utilisé (HMAC-SHA256), une clé d'au moins 256 bits, soit 32 octets. [JWT_DUREE_MINUTES] est la durée de vie d'un jeton.
- ligne 21 : la lecture du fichier de configuration, désigné par un chemin relatif au dossier de l'application ;
- ligne 22 : la lecture des variables d'environnement dont le nom commence par [FLASK_] : [FLASK_JWT_SECRET] remplace la clé [JWT_SECRET] du fichier. La valeur d'une variable est interprétée comme du JSON quand c'est possible ([FLASK_JWT_DUREE_MINUTES=30] donne l'entier 30). En production, la clé secrète est fournie par l'environnement, et le fichier ne contient qu'une valeur de développement ;
- lignes 24-26 : les deux services de l'authentification, dont le second reçoit la clé et la durée de vie des jetons ;
- ligne 32 : l'enregistrement de la fonction [before_request] qui lit le jeton.
Sous [Linux] ou [macOS], on lancerait l'application avec une autre clé ainsi :
et sous [Windows] (PowerShell) :
8.1.2. Les utilisateurs : auth/utilisateurs.py
- lignes 9-13 : un utilisateur : son login, son nom, son rôle, et le hachage de son mot de passe. 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 18-19 : le hachage par l'algorithme [bcrypt], conçu pour les mots de passe. Il a deux particularités. Il est volontairement lent, d'une lenteur réglable (le paramètre [rounds], ou coût : 2^10 tours de calcul) : quelques dizaines de millisecondes, imperceptibles pour un utilisateur, mais qui rendent très coûteux l'essai de milliards de mots de passe par un pirate. Il est salé : [gensalt] tire au hasard un sel, 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. [bcrypt] travaille sur des octets : [encode()] convertit la chaîne en octets ([UTF-8]). Il ne prend en compte que les 72 premiers octets d'un mot de passe, et la version 5 de la bibliothèque refuse un mot de passe plus long (l'étude de cas en tient compte) ;
- lignes 26-28 : deux utilisateurs de test, en mémoire, rangés dans un dictionnaire construit en compréhension ([{clé: valeur for ...}]). L'exemple 28 et l'étude de cas les mettront dans une base de données.
8.1.3. Le service d'authentification : auth/service.py
- lignes 24-31 : la vérification d'un login et d'un mot de passe. [bcrypt.checkpw] hache le mot de passe saisi avec le sel du hachage enregistré, et compare les deux hachages. Un login inconnu et un mot de passe faux donnent le même résultat, [None] ;
- lignes 34-44 : 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 autres 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). Les dates doivent être des dates avec fuseau horaire : [datetime.now(timezone.utc)] ;
- lignes 48-30 : 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 famille [InvalidTokenError]. Le paramètre [algorithms] fixe les algorithmes acceptés : sans lui, un pirate pourrait fabriquer un jeton qui déclare un autre algorithme (voire aucun, [none]).
8.1.4. La sécurité : auth/securite.py
- lignes 13-15 : avant chaque action, le jeton du cookie est vérifié ; [g.utilisateur] contient les affirmations d'un jeton valide, ou [None]. Cette fonction ne refuse rien : elle établit qui est l'utilisateur. C'est l'authentification ;
- lignes 20-31 : le décorateur qui exige un utilisateur authentifié. Pour un visiteur anonyme, il ne rend pas l'action, mais une redirection vers la page de connexion. Si le navigateur a envoyé un cookie, c'est que son jeton est invalide (expiré ou falsifié) : on en profite pour l'effacer ([delete_cookie] envoie un cookie vide, déjà expiré).
8.1.5. Les contrôleurs
- lignes 20-26 : 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 27-32 : 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 (exemple 18) ; sa durée de vie ([max_age], qui accepte un [timedelta]) 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 38-41 : la déconnexion efface le cookie. C'est une action POST : 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.
- ligne 10 : la page est protégée ;
- ligne 13 : l'action utilise [g.utilisateur], certaine qu'il n'est pas [None].
8.1.6. Les vues
- ligne 8 : le login saisi est réaffiché après un échec ; l'expression [login or ''] vaut une chaîne vide si [login] est [None] (ou absent).
- lignes 9-11 : le contenu du jeton, affiché pour l'exemple ;
- lignes 14-16 : la déconnexion, par un formulaire réduit à son bouton.
8.1.7. Exécution
Demandons la page protégée [/profil] sans être connecté :
Un mot de passe erroné :

La connexion réussie. Avec [curl], on conserve le cookie reçu dans un fichier :
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é :
Les dates sont exprimées en secondes écoulées depuis le 1er janvier 1970 ; l'expiration est une heure 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é. 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. Un décorateur à paramètre : auth/securite.py
- lignes 37-47 : [@role_requis("ADMIN")] a un paramètre. L'expression qui suit le [@] est d'abord évaluée : [role_requis("ADMIN")] rend une fonction, [decorateur], qui est le décorateur proprement dit, appliqué à l'action. On a donc trois fonctions imbriquées : [role_requis] reçoit le rôle, [decorateur] reçoit l'action, [action_protegee] reçoit les paramètres de la requête et fait le travail. Chacune a accès aux variables des fonctions qui la contiennent ([role], [action]) ;
- ligne 42 : un utilisateur authentifié, mais qui n'a pas le rôle demandé, reçoit une erreur 403 (*Forbidden*). La différence avec un utilisateur anonyme, redirigé vers la page de connexion, est importante : se connecter ne servirait à rien, puisqu'il l'est déjà.
8.2.2. Le contrôleur : controllers/admin.py
- lignes 12-14 : les décorateurs s'appliquent de bas en haut : [role_requis] enveloppe l'action, puis [connexion_requise] enveloppe le tout. À l'exécution, l'ordre est inverse : c'est l'enveloppe extérieure, [connexion_requise], qui s'exécute en premier. Quand [role_requis] s'exécute, l'utilisateur est donc forcément authentifié, et [g.utilisateur] n'est pas [None].
8.2.3. La page d'erreur : app.py
- lignes 37-39 : un gestionnaire d'erreur enregistré pour un code : l'erreur 403 affiche notre page.
8.2.4. 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, puis un visiteur anonyme :
Protéger les actions une par une a un défaut : un oubli ouvre une page à tous. L'étude de cas renversera la logique : toutes les actions y sont protégées par défaut, et un décorateur [@publique] désigne les rares exceptions (la page de connexion, l'inscription).
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 (sans la page d'administration), et ajoute ces deux protections.
8.3.1. Le dessin du captcha : auth/dessin_captcha.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 | |
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.
- ligne 11 : 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] fournit un générateur cryptographique, imprévisible ;
- lignes 16-48 : 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 ;
- ligne 51 : ["".join(POLICE)] réunit les clés du dictionnaire : les caractères disponibles ;
- lignes 57-58 : un texte aléatoire. [_] est le nom conventionnel d'une variable inutilisée ;
- lignes 69-103 : 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. Les formats [:.1f] écrivent les nombres avec une décimale.
8.3.2. Le service des captchas : auth/captcha.py
- lignes 28-33 : un nouveau captcha : un texte aléatoire, un identifiant aléatoire et impossible à deviner ([secrets.token_urlsafe]), et le dessin. Le serveur garde, pour chaque identifiant, la réponse attendue et une date d'expiration. La page ne reçoit que l'identifiant et l'image : la réponse ne quitte jamais le serveur ;
- lignes 36-42 : la vérification. [pop] retire l'entrée du dictionnaire et la rend : 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 et les espaces autour de la saisie.
Ce service garde des données en mémoire, sur le serveur : c'est une entorse au principe du serveur sans état (chapitre sur les portées), acceptable pour des données de courte durée. Avec plusieurs serveurs, il faudrait les ranger dans un stockage partagé (une base de données, [Redis]...). Remarquons aussi que les captchas jamais utilisés ne sont pas supprimés : l'étude de cas nettoie régulièrement les captchas expirés.
8.3.3. La limitation des tentatives : auth/limiteur.py
- lignes 12-15 : pour chaque adresse IP, un nombre d'échecs et une date de fin de blocage ([datetime.min], la plus petite date possible, signifie « pas bloquée ») ;
- lignes 26-32 : le nombre de secondes de blocage restantes, arrondi à l'entier supérieur ([math.ceil]) ;
- lignes 34-40 : un échec de plus. [setdefault] rend le compteur de l'adresse, en le créant s'il n'existe pas. Au troisième échec, l'adresse est bloquée pour une minute, et le compteur repart de zéro ;
- lignes 42-44 : une connexion réussie efface le compteur.
8.3.4. Le contrôleur : controllers/auth.py
- ligne 25 : l'adresse IP du client ;
- lignes 28-30 : une adresse bloquée n'est même pas écoutée ;
- lignes 32-34 : le captcha est vérifié avant le mot de passe : sa vérification ne coûte presque rien, alors que [bcrypt] est volontairement lent. Un programme qui se tromperait de captcha ne ferait ainsi pas travailler [bcrypt]. Chaque échec, de captcha ou de mot de passe, est compté ;
- ligne 39 : une connexion réussie efface les échecs ;
- lignes 54-56 : chaque affichage du formulaire fabrique un nouveau captcha, y compris après un échec.
8.3.5. La vue : templates/auth/login.html
- ligne 17 : l'image SVG est insérée telle quelle dans la page, grâce au 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 19 : l'identifiant du captcha, dans un champ caché ;
- ligne 20 : le navigateur ne propose pas de saisies précédentes pour ce champ.
8.3.6. Exécution

Un code mal recopié :

Au troisième échec, l'adresse est bloquée :

Avec [curl], quatre tentatives avec un identifiant de captcha inventé :
Même avec le bon mot de passe, la quatrième tentative est refusée sans examen.