6. Portées des données
Une application web sert de nombreux utilisateurs à la fois, et chacun d'eux enchaîne de nombreuses requêtes. Une donnée peut donc être partagée à plusieurs niveaux, qu'on appelle sa portée :
- la portée application : la donnée est partagée par tous les utilisateurs, pendant toute la vie de l'application (la liste des médecins, par exemple) ;
- la portée requête : la donnée n'existe que le temps de traiter une requête (le modèle d'un gabarit, l'utilisateur connecté tel qu'il a été reconnu pour cette requête) ;
- la portée session : la donnée est propre à un utilisateur, et doit survivre d'une requête à la suivante (la langue choisie, un panier) ;
- la portée client : une donnée confiée au navigateur lui-même, dans un cookie.
En [Python], ces portées prennent un tour différent de celui que connaît le lecteur du cours [Symfony], parce que l'application reste en mémoire d'une requête à l'autre (exemple 01). Une donnée rangée dans une variable de module (exemples 06, 20 et 21) a naturellement la portée application : c'est commode, et très rapide. Mais cette portée a deux limites. D'abord, elle ne dure que le temps du processus : un redémarrage du serveur l'efface. Ensuite, elle est propre à un processus : en production, on lance d'ordinaire plusieurs processus de l'application (plusieurs « workers » de [gunicorn], par exemple), voire plusieurs serveurs derrière un répartiteur de charge, et chacun aurait sa propre copie de la donnée. Les données qui doivent être vraiment partagées, et durer, vont donc dans une base de données, ou dans un cache partagé ([Redis], [Memcached], que [Django] sait utiliser par son réglage [CACHES]). En contrepartie de la vitesse, le partage demande de la rigueur : un processus traite plusieurs requêtes à la fois, dans plusieurs fils d'exécution, et une donnée partagée modifiée par l'un est vue par tous : il faut la protéger par un verrou (exemple 20), et ne jamais y ranger ce qui appartient à un utilisateur.
En [Django], chaque portée a son support :
Portée | Support | Durée de vie |
application | une variable de module (un seul processus), le cache de [Django] (réglage [CACHES]), ou la base de données | celle du processus ; la durée choisie pour le cache ; sans limite pour la base |
requête | l'objet [HttpRequest], auquel un middleware peut ajouter des attributs (exemple 12) ; les variables locales de la vue | le traitement d'une requête |
session | la session ([request.session], application [django.contrib.sessions]), gardée par le serveur, retrouvée grâce à un cookie | deux semaines par défaut (réglage [SESSION_COOKIE_AGE]) |
client | un cookie ([set_cookie], exemple 22), en clair, ou signé ([set_signed_cookie]) | la durée donnée au cookie |
La session de [Django] est, par défaut, une session tenue par le serveur, comme celles de [Java] ([JSESSIONID]) ou de [PHP] ([PHPSESSID]) : ses données sont rangées sur le serveur, et le navigateur n'en détient que l'identifiant, un nombre aléatoire impossible à deviner, dans le cookie [sessionid]. Le réglage [SESSION_ENGINE] choisit où les données sont rangées : dans la base de données (le choix par défaut, dans une table [django_session]), dans le cache, dans des fichiers (le choix de notre exemple), ou même, comme le fait [Flask], tout entières dans un cookie signé, chez le client.
6.1. Exemple 23 : les portées
6.1.1. La configuration : config/settings.py
- ligne 17 : l'application des sessions ;
- lignes 21-27 : deux middlewares. [SessionMiddleware] retrouve, en début de requête, la session du navigateur, grâce au cookie [sessionid], et la range dans [request.session] ; en fin de requête, il l'enregistre si elle a été modifiée, et envoie le cookie. Il ne lit réellement les données qu'au premier accès à [request.session] : une vue qui ne s'en sert pas ne coûte rien. Le second middleware est le nôtre (voir plus loin) ;
- lignes 31-33 : les données des sessions sont rangées dans des fichiers, un par session, dans le dossier [var/sessions] de l'exemple, que nous pourrons examiner. La ligne 33 crée ce dossier s'il n'existe pas ([parents=True] : avec les dossiers parents ; [exist_ok=True] : sans erreur s'il existe déjà) : le fichier des réglages est un fichier [Python], qui peut exécuter du code. Le choix par défaut, la base de données, demanderait une base (chapitre suivant) ;
- ligne 42 : le processeur de contexte [request] : les gabarits verront [request.session] et [request.infos].
6.1.2. Les objets : portees/compteurs.py et portees/middleware.py
- ligne 5 : la classe [Counter] du module [collections] : un dictionnaire dont les valeurs sont des nombres, et dont les clés absentes valent 0 ;
- lignes 9-10 : les compteurs, et leur verrou, créés au chargement du module : ils vivent aussi longtemps que le processus du serveur. C'est la portée application ;
- lignes 15-18 : un numéro de plus sous une clé. L'opération [+=] n'est pas atomique : elle lit la valeur, l'augmente, puis l'écrit, et deux requêtes simultanées pourraient lire la même valeur : une visite serait perdue. Le verrou (exemple 20) l'empêche.
- lignes 10-13 : les informations de portée requête : un numéro, et l'heure de la requête ;
- lignes 18-28 : le middleware crée un objet [RequeteInfos] par requête, et le range dans la requête (exemple 12). Le numéro est tiré d'un compteur commun à toutes les requêtes (le module précédent, sous une autre clé) : chaque objet a donc un numéro différent. L'heure est écrite avec ses millisecondes : [%f] donne les microsecondes (six chiffres), dont [[:-3]] retire les trois derniers (une tranche de chaîne : tous les caractères, sauf les trois derniers). L'objet disparaît avec la requête.
[Django] n'a pas de conteneur de services, et donc pas de « service de portée requête » : c'est la requête elle-même qui transporte les objets de cette portée. Le lecteur du cours [Symfony] se souvient que c'était l'inverse : en [PHP], tout service était de portée requête, et il fallait un cache pour obtenir la portée application.
6.1.3. Des cookies signés
Un cookie est une donnée confiée au client, qui peut la lire et la modifier. Pour qu'un cookie soit digne de confiance, il suffit qu'il soit signé : sa valeur voyage en clair, mais elle est suivie d'une signature, que seul le serveur sait calculer, parce qu'elle dépend d'une clé secrète. Une valeur modifiée par le client n'a plus la bonne signature, et le serveur la refuse. [Django] le fait pour nous : [reponse.set_signed_cookie(nom, valeur)] dépose un cookie signé, et [request.get_signed_cookie(nom, default=...)] rend sa valeur si la signature est la bonne, la valeur par défaut sinon. La signature est un HMAC (Hash-based Message Authentication Code) : une empreinte SHA-256 de la valeur, calculée avec la clé secrète de l'application ([SECRET_KEY], exemple 01), et un « sel » propre au nom du cookie. [Django] y ajoute la date de la signature, ce qui permet de refuser un cookie trop ancien ([get_signed_cookie(..., max_age=3600)]). La vérification compare les signatures en un temps qui ne dépend pas du nombre de caractères identiques : une comparaison ordinaire s'arrêterait au premier caractère différent, et un attaquant pourrait, en mesurant le temps de réponse, deviner la signature caractère par caractère. Le module [django.core.signing] offre les mêmes fonctions pour signer n'importe quelle donnée ([signing.dumps], [signing.loads]) : c'est lui qui signe les messages flash de l'exemple 20.
C'est le principe du jeton JWT, que le chapitre suivant utilisera pour l'authentification.
6.1.4. La vue : portees/views.py
- ligne 12 : la portée application ;
- ligne 16 : la portée session. [request.session] s'utilise comme un dictionnaire : [get(clé, défaut)] lit une valeur, l'affectation l'écrit. [Django] enregistre la session à la fin de la requête. Les valeurs doivent pouvoir être converties en JSON (des chaînes, des nombres, des listes, des dictionnaires) : c'est ainsi qu'elles sont rangées ;
- lignes 19-21 : la portée client, avec deux cookies. [request.COOKIES] est le dictionnaire des cookies envoyés par le navigateur. Leurs valeurs sont des chaînes, fournies par le navigateur, et donc peut-être absentes ou invalides : [int] les convertit (un cookie [mesVisites=abc] lèverait ici une exception [ValueError], et produirait une erreur 500 ; une vraie application vérifierait la valeur, cf. exemple 07). La valeur du cookie signé n'est acceptée que si sa signature est la bonne ;
- lignes 22-28 : le contexte du gabarit ;
- lignes 29-31 : pour déposer un cookie, il faut l'objet réponse : [render] le construit (exemple 13), puis [set_cookie] et [set_signed_cookie] lui ajoutent les cookies. Sans durée de vie ([max_age]), un cookie dure jusqu'à la fermeture du navigateur (c'est un cookie « de session », au sens des navigateurs).
6.1.5. Le gabarit : templates/portees/portees.html
- ligne 16 : [request.infos], l'objet rangé dans la requête par le middleware : c'est le même objet [RequeteInfos] que celui de la vue ; le gabarit affiche son numéro, qui doit être celui reçu de la vue ;
- ligne 19 : la session est aussi accessible dans les gabarits, par [request.session].
6.1.6. Exécution
Après trois visites, dans un navigateur :

Le numéro de l'objet de la requête est plus grand que le nombre de visites : le navigateur a aussi demandé, à la première visite, l'icône du site ([/favicon.ico]), qui n'existe pas. Cette requête, qui a abouti à une erreur 404, a traversé le middleware, et a donc eu son propre objet [RequeteInfos].
Suivons les requêtes avec [curl], qui joue le rôle d'un premier navigateur en conservant ses cookies dans le fichier [a.txt] (le serveur vient d'être démarré) :
La réponse dépose trois cookies. Le premier, [mesVisites], contient la valeur en clair. Le second, [mesVisitesSigne], contient la même valeur, suivie, après des deux-points, de la date de la signature (en base 62) et de la signature elle-même. Le troisième, [sessionid], est l'identifiant de la session, valable deux semaines ([Max-Age=1209600] secondes), [HttpOnly] et [SameSite=Lax] (exemple 20). Les données de la session sont dans un fichier du serveur, [var/sessions/sessionidup3xpr7qnr8gj95hw4w0v1a1v4wnow79], dont le nom reprend l'identifiant :
On y reconnaît le format des données signées de [Django] : les données, la date, la signature. Les données sont du JSON, codé en base 64 : [eyJ2aXNpdGVzIjoxfQ] se décode en [{"visites":1}]. Le serveur signe même ses propres fichiers de session : un fichier modifié par un tiers serait refusé. Une seconde visite, avec les mêmes cookies :
La session est la même (même identifiant) ; son cookie est renvoyé parce que la session a été modifiée, ce qui prolonge sa durée de vie. Un second « navigateur », avec son propre fichier de cookies, vide au départ :
Le compteur de l'application a continué ; la session et les cookies du second navigateur repartent de 1. Chaque requête a eu son propre objet [RequeteInfos], et, dans chacune, la vue et le gabarit ont vu le même. Enfin, un utilisateur malhonnête fabrique ses cookies : 1000 visites dans [mesVisites], 999 dans [mesVisitesSigne] (avec une signature inventée), et un identifiant de session de son invention :
Le cookie en clair a été accepté tel quel : 1001 visites. Le cookie [mesVisitesSigne], lui, n'avait pas la bonne signature : il a été ignoré, comme s'il était absent. Recopier la signature d'une autre valeur (celle de [1], par exemple, à côté de la valeur [999]) ne marche pas mieux : la signature dépend de la valeur. Quant à l'identifiant de session inventé, il ne désigne aucune session existante : [Django] l'ignore, et crée une session neuve, avec un nouvel identifiant, qu'il choisit lui-même. L'utilisateur ne peut donc pas choisir la session d'un autre.
Retenons-en la règle : une donnée venue du client n'est digne de confiance que si elle est protégée par le serveur (signée, comme ici ou comme le jeton du chapitre sur l'authentification, ou chiffrée) ; les données qui n'ont rien à cacher, et dont le serveur vérifie la valeur (la langue de l'exemple 22), peuvent rester en clair. Une signature garantit qu'une valeur n'a pas été modifiée, mais ne la cache pas : un secret ne doit jamais être confié au client, même signé.