Skip to content

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

23-portees/
├── config/
│   ├── settings.py
│   └── ...
├── portees/
│   ├── __init__.py
│   ├── compteurs.py
│   ├── middleware.py
│   └── views.py
├── templates/
│   └── portees/
│       └── portees.html
├── var/
│   └── sessions/
└── manage.py

6.1.1. La configuration : config/settings.py

# config/settings.py : la configuration de l'exemple 23

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-1zxb1%#mksmtg+m&rsp_e6&i7@6sbtm_i=lqjq_+rg#-=i5r-p"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "fr"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

INSTALLED_APPS = [
    "django.contrib.staticfiles",
    # les SESSIONS
    "django.contrib.sessions",
    "portees",
]

MIDDLEWARE = [
    # le middleware des sessions : il retrouve la session du navigateur (grâce au
    # cookie sessionid) en début de requête, et l'enregistre en fin de requête
    "django.contrib.sessions.middleware.SessionMiddleware",
    # la portée REQUÊTE : un objet par requête (cf. portees/middleware.py)
    "portees.middleware.InfosRequete",
]

# les données des sessions sont rangées sur le serveur, dans des FICHIERS (un par
# session), dans le dossier var/sessions de l'exemple, créé s'il n'existe pas
SESSION_ENGINE = "django.contrib.sessions.backends.file"
SESSION_FILE_PATH = BASE_DIR / "var" / "sessions"
SESSION_FILE_PATH.mkdir(parents=True, exist_ok=True)

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "OPTIONS": {
            "context_processors": [
                # la variable request, et donc request.session et request.infos
                "django.template.context_processors.request",
            ],
        },
    },
]

STATIC_URL = "static/"
STATICFILES_DIRS = [
    BASE_DIR.parent / "commun" / "static",
]
  • 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

# portees/compteurs.py : portée APPLICATION, des compteurs partagés par toutes les
# requêtes de tous les utilisateurs

import threading
from collections import Counter

# un Counter : un dictionnaire {clé: nombre}, dont les clés absentes valent 0. Il est
# créé au chargement du module, et vit aussi longtemps que le processus du serveur
_compteurs = Counter()
_verrou = threading.Lock()


# un numéro de plus sous la clé [cle] ; += n'est pas une opération atomique : deux
# requêtes simultanées pourraient lire la même valeur. Le verrou l'empêche
def suivant(cle: str) -> int:
    with _verrou:
        _compteurs[cle] += 1
        return _compteurs[cle]
  • 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.
# portees/middleware.py : portée REQUÊTE, les informations d'une requête

from dataclasses import dataclass

from django.utils import timezone

from . import compteurs


@dataclass(frozen=True)
class RequeteInfos:
    numero: int
    debut: str


# le middleware crée UN objet RequeteInfos par requête, et le range dans la requête :
# la vue et le gabarit le retrouvent par request.infos. Il disparaît avec elle
class InfosRequete:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        request.infos = RequeteInfos(
            # un numéro par OBJET créé, tiré d'un compteur commun à toutes les requêtes
            numero=compteurs.suivant("instances"),
            # l'heure, avec ses millisecondes
            debut=timezone.localtime().strftime("%H:%M:%S.%f")[:-3])
        return self.get_response(request)
  • 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

# portees/views.py : les quatre portées des données

from django.shortcuts import render
from django.views.decorators.http import require_GET

from . import compteurs


@require_GET
def portees(request):
    # APPLICATION : le compteur partagé
    total = compteurs.suivant("visites")
    # SESSION : des données propres à CE navigateur, gardées par le SERVEUR (un
    # fichier de var/sessions), retrouvées grâce au cookie de session ; la session
    # s'utilise comme un dictionnaire
    request.session["visites"] = request.session.get("visites", 0) + 1
    # CLIENT : des données conservées par le NAVIGATEUR dans des cookies, renvoyées au
    # serveur à chaque requête ; l'un en clair, l'autre signé
    mes_visites = int(request.COOKIES.get("mesVisites", 0)) + 1
    # get_signed_cookie : la valeur si la signature est la bonne ; default sinon
    mes_visites_signe = int(request.get_signed_cookie("mesVisitesSigne", default=0)) + 1
    reponse = render(request, "portees/portees.html", {
        "total": total,
        "instance": request.infos.numero,
        "visites_session": request.session["visites"],
        "mes_visites": mes_visites,
        "mes_visites_signe": mes_visites_signe,
    })
    reponse.set_cookie("mesVisites", str(mes_visites))
    # set_signed_cookie : la valeur, suivie d'une signature calculée avec SECRET_KEY
    reponse.set_signed_cookie("mesVisitesSigne", str(mes_visites_signe))
    return reponse
  • 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

<!doctype html>
{% load static %}
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Portées</title>
  <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
</head>
<body>
  <div class="container py-3">
    <h1 class="h3">Les portées des données</h1>
    {# request.infos : l'objet rangé dans la requête par le middleware ; c'est le même que celui de la vue #}
    <ul>
      <li>application : {{ total }} visite(s) du site, tous navigateurs confondus</li>
      <li>requête : objet n° {{ instance }} (n° {{ request.infos.numero }} dans le gabarit),
        requête reçue à {{ request.infos.debut }}</li>
      {# request.session : la session, accessible aussi dans les gabarits #}
      <li>session : {{ visites_session }} visite(s) de ce navigateur ({{ request.session.visites }} dans le gabarit)</li>
      <li>client : {{ mes_visites }} visite(s) de ce navigateur (cookie en clair),
        {{ mes_visites_signe }} (cookie signé)</li>
    </ul>
  </div>
</body>
</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 :

Image

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é) :

curl -i -c a.txt -b a.txt http://localhost:8000/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Vary: Cookie
Set-Cookie: mesVisites=1; Path=/
Set-Cookie: mesVisitesSigne=1:1xAspS:4Nvkcx3peL8C64QPSFZNbB4KciM5Vv3OCuAR9bEMZwc; Path=/
Set-Cookie: sessionid=up3xpr7qnr8gj95hw4w0v1a1v4wnow79; expires=Sun, 11 Oct 2026 17:38:22 GMT; HttpOnly; Max-Age=1209600; Path=/; SameSite=Lax
...
      <li>application : 1 visite(s) du site, tous navigateurs confondus</li>
      <li>requête : objet n° 1 (n° 1 dans le gabarit),
        requête reçue à 19:38:22.950</li>
      <li>session : 1 visite(s) de ce navigateur (1 dans le gabarit)</li>
      <li>client : 1 visite(s) de ce navigateur (cookie en clair),
        1 (cookie signé)</li>
...

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 :

eyJ2aXNpdGVzIjoxfQ:1xAspS:kBLb-izAURiIYXcjkHm2aDLFw8Vdgbm_Jxpq0f0ErA4

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 :

curl -i -c a.txt -b a.txt http://localhost:8000/
...
Set-Cookie: mesVisites=2; Path=/
Set-Cookie: mesVisitesSigne=2:1xAspS:pxWJZHoXRsALnFoTXbCOEfmTMgpOG0VtI-cosaBWPWo; Path=/
Set-Cookie: sessionid=up3xpr7qnr8gj95hw4w0v1a1v4wnow79; expires=Sun, 11 Oct 2026 17:38:22 GMT; HttpOnly; Max-Age=1209600; Path=/; SameSite=Lax
...
      <li>application : 2 visite(s) du site, tous navigateurs confondus</li>
      <li>requête : objet n° 2 (n° 2 dans le gabarit),
        requête reçue à 19:38:22.972</li>
      <li>session : 2 visite(s) de ce navigateur (2 dans le gabarit)</li>
      <li>client : 2 visite(s) de ce navigateur (cookie en clair),
        2 (cookie signé)</li>
...

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 :

curl -c b.txt -b b.txt http://localhost:8000/
1
2
3
4
5
6
      <li>application : 3 visite(s) du site, tous navigateurs confondus</li>
      <li>requête : objet n° 3 (n° 3 dans le gabarit),
        requête reçue à 19:38:22.987</li>
      <li>session : 1 visite(s) de ce navigateur (1 dans le gabarit)</li>
      <li>client : 1 visite(s) de ce navigateur (cookie en clair),
        1 (cookie signé)</li>

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 :

curl -i -b "mesVisites=1000;mesVisitesSigne=999:abc;sessionid=abc" http://localhost:8000/
1
2
3
4
5
6
7
8
9
...
Set-Cookie: mesVisites=1001; Path=/
Set-Cookie: mesVisitesSigne=1:1xAspT:L8gY1VsFmTmnExyPBzfi-PIKW2S4uEJ_N5BlNbKA7M0; Path=/
Set-Cookie: sessionid=fd7i64x53xvaq1d4vpcxqj74kai4s9n7; expires=Sun, 11 Oct 2026 17:38:23 GMT; HttpOnly; Max-Age=1209600; Path=/; SameSite=Lax
...
      <li>session : 1 visite(s) de ce navigateur (1 dans le gabarit)</li>
      <li>client : 1001 visite(s) de ce navigateur (cookie en clair),
        1 (cookie signé)</li>
...

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é.