Skip to content

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

25-jwt/
├── comptes/
│   ├── __init__.py
│   └── views.py
├── config/
│   ├── settings.py
│   ├── urls.py
│   └── ...
├── securite/
│   ├── __init__.py
│   ├── auth.py
│   ├── decorateurs.py
│   ├── middleware.py
│   └── utilisateurs.py
├── templates/
│   ├── auth/
│   │   └── login.html
│   ├── profil/
│   │   └── profil.html
│   └── layout.html
└── manage.py

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 :

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

import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-jr8u&rl2ff+bpg(h18&^u2dtu^qtulf9yoy!e&la+nt_z-vb*8"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "fr"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

INSTALLED_APPS = [
    "django.contrib.staticfiles",
    "securite",
    "comptes",
]

MIDDLEWARE = [
    "django.middleware.csrf.CsrfViewMiddleware",
    # l'AUTHENTIFICATION : à chaque requête, l'utilisateur est retrouvé d'après le
    # jeton JWT de son cookie (cf. securite/middleware.py)
    "securite.middleware.Authentification",
]

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
    },
]

STATIC_URL = "static/"
STATICFILES_DIRS = [
    BASE_DIR.parent / "commun" / "static",
]

# nos propres réglages : la clé qui signe les jetons JWT (au moins 32 caractères), et
# leur durée de vie. os.environ.get : la variable d'environnement du même nom si elle
# existe (en production), la valeur écrite ici sinon (en développement)
JWT_SECRET = os.environ.get("JWT_SECRET", "une-cle-secrete-d-au-moins-32-caracteres-pour-l-exemple-25")
JWT_DUREE_MINUTES = int(os.environ.get("JWT_DUREE_MINUTES", "60"))
  • 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 :

JWT_SECRET="une-autre-cle-d-au-moins-trente-deux-caracteres" python manage.py runserver

et sous [Windows] ([PowerShell]) :

$env:JWT_SECRET="une-autre-cle-d-au-moins-trente-deux-caracteres"
python manage.py runserver

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

# securite/utilisateurs.py : les utilisateurs, en mémoire (l'exemple 28 et l'étude de
# cas les mettront dans une base)

from dataclasses import dataclass, field


@dataclass(frozen=True)
class Utilisateur:
    login: str
    nom: str
    # le rôle : USER ou ADMIN
    role: str
    # le HACHAGE du mot de passe (vide pour un utilisateur lu dans un jeton)
    hachage_mot_de_passe: str = ""
    # le contenu du jeton qui a authentifié l'utilisateur ; field(default_factory=dict) :
    # un NOUVEAU dictionnaire vide pour chaque objet
    claims: dict = field(default_factory=dict)


# on ne conserve JAMAIS un mot de passe, seulement son hachage, calculé une fois pour
# toutes par la fonction make_password de Django (algorithme PBKDF2-SHA256, 1 500 000
# itérations, un sel aléatoire) :
# python -c "from django.conf import settings; settings.configure();
#            from django.contrib.auth.hashers import make_password; print(make_password('marie'))"
UTILISATEURS = {
    "marie": ("Marie Pélissier", "USER",
              "pbkdf2_sha256$1500000$pjnFn2tL1G4bu5rVFTv6sZ$Y6v+fj3dC+Y0LZYJGxeE1OOi9beaZVuMVm6R6tljEwI="),
    "admin": ("Administrateur", "ADMIN",
              "pbkdf2_sha256$1500000$UTT2hXVaLIEAuGHPFxgnv9$D9+Owt9lDG14VrvArR3n0GGQXWjY9Sg8cgFwip4+yx4="),
}


def chercher(login: str) -> Utilisateur | None:
    if login not in UTILISATEURS:
        return None
    # le "déballage" d'un tuple : ses trois éléments dans trois variables
    nom, role, hachage = UTILISATEURS[login]
    return Utilisateur(login, nom, role, hachage)
  • 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

# securite/auth.py : la vérification des mots de passe et les jetons JWT

import time

import jwt
from django.conf import settings
from django.contrib.auth.hashers import check_password

from . import utilisateurs
from .utilisateurs import Utilisateur

# le nom du cookie qui transporte le jeton
COOKIE = "jeton"


# l'utilisateur, ou None si le login est inconnu ou le mot de passe faux.
# check_password hache le mot de passe saisi avec le sel et l'algorithme du hachage
# enregistré, puis compare les deux résultats
def valider(login: str, mot_de_passe: str) -> Utilisateur | None:
    if not login or not mot_de_passe:
        return None
    utilisateur = utilisateurs.chercher(login)
    if utilisateur is None or not check_password(mot_de_passe, utilisateur.hachage_mot_de_passe):
        return None
    return utilisateur


# le jeton JWT de l'utilisateur, signé avec la clé secrète (HMAC-SHA256)
def creer_jeton(utilisateur: Utilisateur) -> str:
    maintenant = int(time.time())
    # le contenu du jeton : les "claims" (affirmations sur l'utilisateur)
    claims = {
        "sub": utilisateur.login,
        "nom": utilisateur.nom,
        "role": utilisateur.role,
        "iat": maintenant,
        "exp": maintenant + settings.JWT_DUREE_MINUTES * 60,
    }
    return jwt.encode(claims, settings.JWT_SECRET, algorithm="HS256")


# l'utilisateur décrit par un jeton dont la signature et la date d'expiration sont
# valides ; None sinon (jeton falsifié, expiré ou illisible)
def verifier(jeton: str) -> Utilisateur | None:
    try:
        # decode vérifie la signature ET la date d'expiration (claim exp)
        claims = jwt.decode(jeton, settings.JWT_SECRET, algorithms=["HS256"])
    except jwt.InvalidTokenError:
        return None
    return Utilisateur(claims["sub"], claims["nom"], claims["role"], claims=claims)
  • 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

# securite/middleware.py : l'AUTHENTIFICATION : à chaque requête, qui est l'utilisateur ?

from django.shortcuts import redirect

from . import auth


class Authentification:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        # par défaut, le visiteur est anonyme
        request.utilisateur = None
        jeton = request.COOKIES.get(auth.COOKIE)
        if jeton is not None:
            utilisateur = auth.verifier(jeton)
            if utilisateur is None:
                # un jeton invalide (expiré, falsifié) : direction /login, et l'on
                # efface le cookie
                reponse = redirect("login")
                reponse.delete_cookie(auth.COOKIE)
                return reponse
            request.utilisateur = utilisateur
        return self.get_response(request)

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

# securite/decorateurs.py : le contrôle d'accès des vues

from functools import wraps

from django.shortcuts import redirect


# @connexion_requise : la vue n'est exécutée que pour un utilisateur authentifié ; un
# visiteur anonyme est redirigé vers la page de connexion
def connexion_requise(vue):
    @wraps(vue)
    def enveloppe(request, *args, **kwargs):
        if request.utilisateur is None:
            return redirect("login")
        return vue(request, *args, **kwargs)
    return enveloppe

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

# comptes/views.py : connexion, déconnexion, et une page réservée aux utilisateurs
# authentifiés

from django.conf import settings
from django.shortcuts import redirect, render
from django.views.decorators.http import require_GET, require_http_methods, require_POST

from securite import auth
from securite.decorateurs import connexion_requise


@require_GET
def accueil(request):
    return redirect("profil")


# GET : le formulaire ; POST : la connexion (le jeton anti-CSRF a été vérifié par
# CsrfViewMiddleware)
@require_http_methods(["GET", "POST"])
def login(request):
    if request.method == "GET":
        return render(request, "auth/login.html")
    login = request.POST.get("login", "")
    utilisateur = auth.valider(login, request.POST.get("mot_de_passe", ""))
    if utilisateur is None:
        # un message volontairement vague : on ne dit pas si c'est le login ou le mot de passe
        return render(request, "auth/login.html",
                      {"login": login, "erreur": "Login ou mot de passe incorrect."})
    # le jeton, dans un cookie : le JavaScript de la page ne peut pas le lire
    # (httponly) ; il n'accompagne pas les requêtes POST venues d'autres sites
    # (samesite) ; il disparaît en même temps que le jeton expire (max_age)
    reponse = redirect("profil")
    reponse.set_cookie(auth.COOKIE, auth.creer_jeton(utilisateur),
                       max_age=settings.JWT_DUREE_MINUTES * 60, httponly=True, samesite="Lax")
    return reponse


# la déconnexion : POST seulement (un simple lien, placé sur un autre site, ne peut
# pas déconnecter l'utilisateur à son insu) ; on efface le cookie
@require_POST
def logout(request):
    reponse = redirect("login")
    reponse.delete_cookie(auth.COOKIE)
    return reponse


# request.utilisateur : l'utilisateur authentifié, trouvé par le middleware
@require_GET
@connexion_requise
def profil(request):
    return render(request, "profil/profil.html", {"utilisateur": request.utilisateur})
  • 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

{% extends "layout.html" %}

{% block contenu %}
  <h1 class="h3">Connexion</h1>
  <form method="post" action="{% url 'login' %}">
    {% csrf_token %}
    <div class="mb-2">
      <label class="form-label" for="login">Login</label>
      <input class="form-control" id="login" name="login" value="{{ login }}" required>
    </div>
    <div class="mb-3">
      <label class="form-label" for="mot_de_passe">Mot de passe</label>
      <input class="form-control" type="password" id="mot_de_passe" name="mot_de_passe" required>
    </div>
    <button class="btn btn-primary" type="submit">Se connecter</button>
  </form>
  {% if erreur %}
    <div class="alert alert-danger mt-3">{{ erreur }}</div>
  {% endif %}
  <p class="text-muted small mt-3">Comptes de test (mot de passe = login) : marie (USER), admin (ADMIN).</p>
{% endblock %}
  • 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).
{% extends "layout.html" %}

{% block contenu %}
  <h1 class="h3">Bonjour {{ utilisateur.nom }}</h1>
  <p>Votre rôle : <b>{% if utilisateur.role == "ADMIN" %}administrateur{% else %}utilisateur{% endif %}</b>.</p>
  <p>Le contenu de votre jeton :</p>
  <table class="table table-sm w-auto">
    {% for cle, valeur in utilisateur.claims.items %}
      <tr><td><code>{{ cle }}</code></td><td>{{ valeur }}</td></tr>
    {% endfor %}
  </table>
  {# la déconnexion : un formulaire POST réduit à son bouton #}
  <form method="post" action="{% url 'logout' %}">
    {% csrf_token %}
    <button class="btn btn-outline-secondary" type="submit">Se déconnecter</button>
  </form>
{% endblock %}
  • 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é :

curl -i http://localhost:8000/profil
1
2
3
HTTP/1.1 302 Found
Content-Type: text/html; charset=utf-8
Location: /login

Un mot de passe erroné :

Image

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 :

curl -c jeton.txt -b jeton.txt http://localhost:8000/login
curl -i -c jeton.txt -b jeton.txt -d "csrfmiddlewaretoken=...&login=marie&mot_de_passe=marie" http://localhost:8000/login
1
2
3
4
HTTP/1.1 302 Found
Content-Type: text/html; charset=utf-8
Location: /profil
Set-Cookie: jeton=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYXJpZSIsIm5vbSI6Ik1hcmllIFBcdTAwZTlsaXNzaWVyIiwicm9sZSI6IlVTRVIiLCJpYXQiOjE3OTA1MzA5MzksImV4cCI6MTc5MDUzNDUzOX0.Ui3suZbbc_T83_kehZinNIlKEoyPmnoywZpv3WymcwE; expires=Sun, 27 Sep 2026 18:42:19 GMT; HttpOnly; Max-Age=3600; Path=/; SameSite=Lax

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

{"alg":"HS256","typ":"JWT"}
{"sub":"marie","nom":"Marie Pélissier","role":"USER","iat":1790530939,"exp":1790534539}

(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 :

Image

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 :

curl -i -b "jeton=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.<contenu modifié>.Ui3suZbbc_T83_kehZinNIlKEoyPmnoywZpv3WymcwE" http://localhost:8000/profil
1
2
3
4
HTTP/1.1 302 Found
Content-Type: text/html; charset=utf-8
Location: /login
Set-Cookie: jeton=""; expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/

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

# securite/utilisateurs.py : les utilisateurs, en mémoire (l'exemple 28 et l'étude de
# cas les mettront dans une base)

from dataclasses import dataclass, field


@dataclass(frozen=True)
class Utilisateur:
    login: str
    nom: str
    # le rôle : USER ou ADMIN
    role: str
    # le HACHAGE du mot de passe (vide pour un utilisateur lu dans un jeton)
    hachage_mot_de_passe: str = ""
    # le contenu du jeton qui a authentifié l'utilisateur ; field(default_factory=dict) :
    # un NOUVEAU dictionnaire vide pour chaque objet
    claims: dict = field(default_factory=dict)

    # la HIÉRARCHIE des rôles : un administrateur a aussi les droits d'un utilisateur
    def a_le_role(self, role: str) -> bool:
        return role in HIERARCHIE.get(self.role, set())

    # pour les gabarits, qui ne savent pas appeler une méthode avec un paramètre
    @property
    def est_admin(self) -> bool:
        return self.a_le_role("ADMIN")


# chaque rôle, et l'ensemble des rôles qu'il possède ({...} : un ensemble, set)
HIERARCHIE = {
    "ADMIN": {"ADMIN", "USER"},
    "USER": {"USER"},
}


# on ne conserve JAMAIS un mot de passe, seulement son hachage, calculé une fois pour
# toutes par la fonction make_password de Django (algorithme PBKDF2-SHA256, 1 500 000
# itérations, un sel aléatoire) :
# python -c "from django.conf import settings; settings.configure();
#            from django.contrib.auth.hashers import make_password; print(make_password('marie'))"
UTILISATEURS = {
    "marie": ("Marie Pélissier", "USER",
              "pbkdf2_sha256$1500000$pjnFn2tL1G4bu5rVFTv6sZ$Y6v+fj3dC+Y0LZYJGxeE1OOi9beaZVuMVm6R6tljEwI="),
    "admin": ("Administrateur", "ADMIN",
              "pbkdf2_sha256$1500000$UTT2hXVaLIEAuGHPFxgnv9$D9+Owt9lDG14VrvArR3n0GGQXWjY9Sg8cgFwip4+yx4="),
}


def chercher(login: str) -> Utilisateur | None:
    if login not in UTILISATEURS:
        return None
    # le "déballage" d'un tuple : ses trois éléments dans trois variables
    nom, role, hachage = UTILISATEURS[login]
    return Utilisateur(login, nom, role, hachage)
  • 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

# securite/decorateurs.py : le contrôle d'accès des vues

from functools import wraps

from django.core.exceptions import PermissionDenied
from django.shortcuts import redirect


# @connexion_requise : la vue n'est exécutée que pour un utilisateur authentifié ; un
# visiteur anonyme est redirigé vers la page de connexion
def connexion_requise(vue):
    @wraps(vue)
    def enveloppe(request, *args, **kwargs):
        if request.utilisateur is None:
            return redirect("login")
        return vue(request, *args, **kwargs)
    return enveloppe


# @role_requis("ADMIN") : un décorateur PARAMÉTRÉ (cf. exemple 08). La vue n'est
# exécutée que si l'utilisateur authentifié a le rôle demandé ; sinon, l'exception
# PermissionDenied produit une réponse 403 (cf. securite/erreurs.py)
def role_requis(role):
    def decorateur(vue):
        @wraps(vue)
        @connexion_requise
        def enveloppe(request, *args, **kwargs):
            if not request.utilisateur.a_le_role(role):
                raise PermissionDenied(f"rôle {role} requis")
            return vue(request, *args, **kwargs)
        return enveloppe
    return decorateur
  • 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

1
2
3
4
5
6
7
8
# securite/erreurs.py : la réponse faite à un utilisateur AUTHENTIFIÉ qui demande une
# page qui lui est interdite (cf. handler403 dans config/urls.py)

from django.shortcuts import render


def acces_refuse(request, exception):
    return render(request, "erreurs/403.html", status=403)
# config/urls.py : la table de routage principale

from django.urls import path

from comptes import views
from securite.erreurs import acces_refuse

urlpatterns = [
    path("", views.accueil),
    path("login", views.login, name="login"),
    path("logout", views.logout, name="logout"),
    path("profil", views.profil, name="profil"),
    path("admin", views.admin, name="admin"),
]

# la vue des erreurs 403 (exception PermissionDenied)
handler403 = acces_refuse

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

# comptes/views.py : connexion, déconnexion, et des pages réservées aux utilisateurs
# authentifiés

from django.conf import settings
from django.shortcuts import redirect, render
from django.views.decorators.http import require_GET, require_http_methods, require_POST

from securite import auth
from securite.decorateurs import connexion_requise, role_requis


@require_GET
def accueil(request):
    return redirect("profil")


# GET : le formulaire ; POST : la connexion (le jeton anti-CSRF a été vérifié par
# CsrfViewMiddleware)
@require_http_methods(["GET", "POST"])
def login(request):
    if request.method == "GET":
        return render(request, "auth/login.html")
    login = request.POST.get("login", "")
    utilisateur = auth.valider(login, request.POST.get("mot_de_passe", ""))
    if utilisateur is None:
        # un message volontairement vague : on ne dit pas si c'est le login ou le mot de passe
        return render(request, "auth/login.html",
                      {"login": login, "erreur": "Login ou mot de passe incorrect."})
    # le jeton, dans un cookie : le JavaScript de la page ne peut pas le lire
    # (httponly) ; il n'accompagne pas les requêtes POST venues d'autres sites
    # (samesite) ; il disparaît en même temps que le jeton expire (max_age)
    reponse = redirect("profil")
    reponse.set_cookie(auth.COOKIE, auth.creer_jeton(utilisateur),
                       max_age=settings.JWT_DUREE_MINUTES * 60, httponly=True, samesite="Lax")
    return reponse


# la déconnexion : POST seulement (un simple lien, placé sur un autre site, ne peut
# pas déconnecter l'utilisateur à son insu) ; on efface le cookie
@require_POST
def logout(request):
    reponse = redirect("login")
    reponse.delete_cookie(auth.COOKIE)
    return reponse


# request.utilisateur : l'utilisateur authentifié, trouvé par le middleware
@require_GET
@connexion_requise
def profil(request):
    return render(request, "profil/profil.html", {"utilisateur": request.utilisateur})


# une page réservée au rôle ADMIN
@require_GET
@role_requis("ADMIN")
def admin(request):
    return render(request, "admin/admin.html", {"nom": request.utilisateur.nom})
  • 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

{% extends "layout.html" %}

{% block contenu %}
  <h1 class="h3">Bonjour {{ utilisateur.nom }}</h1>
  <p>Votre rôle : <b>{% if utilisateur.role == "ADMIN" %}administrateur{% else %}utilisateur{% endif %}</b>.</p>
  {# est_admin : l'utilisateur a-t-il le rôle ADMIN ? #}
  {% if utilisateur.est_admin %}
    <p><a href="{% url 'admin' %}">L'espace d'administration</a></p>
  {% else %}
    <p><a href="{% url 'admin' %}">L'espace d'administration</a> (qui vous est interdit)</p>
  {% endif %}
  <p>Le contenu de votre jeton :</p>
  <table class="table table-sm w-auto">
    {% for cle, valeur in utilisateur.claims.items %}
      <tr><td><code>{{ cle }}</code></td><td>{{ valeur }}</td></tr>
    {% endfor %}
  </table>
  {# la déconnexion : un formulaire POST réduit à son bouton #}
  <form method="post" action="{% url 'logout' %}">
    {% csrf_token %}
    <button class="btn btn-outline-secondary" type="submit">Se déconnecter</button>
  </form>
{% endblock %}
  • 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 :

Image

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

Image

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 :

curl -i -b jeton.txt http://localhost:8000/admin
curl -i http://localhost:8000/admin
HTTP/1.1 403 Forbidden
Content-Type: text/html; charset=utf-8
...
  <div class="alert alert-danger">
    <h1 class="h4">Erreur 403</h1>
    <p class="mb-0">Accès refusé : cette page est réservée aux administrateurs.</p>
  </div>
...
HTTP/1.1 302 Found
Content-Type: text/html; charset=utf-8
Location: /login

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 :

27-captcha/
├── comptes/
│   └── views.py
├── config/
│   └── settings.py
├── securite/
│   ├── auth.py
│   ├── captchas.py
│   ├── decorateurs.py
│   ├── dessin_captcha.py
│   ├── limiteur.py
│   ├── middleware.py
│   └── utilisateurs.py
├── templates/
│   └── auth/
│       └── login.html
└── ...

8.3.1. Le dessin du captcha : securite/dessin_captcha.py

# securite/dessin_captcha.py : le dessin d'un captcha, une image SVG (du texte XML)
# fabriquée à la main

import math
import secrets

LARGEUR = 150
HAUTEUR = 50

# La "police" : chaque caractère est une liste de traits, chaque trait une suite de
# points x, y dans une grille de 4 x 6 (y vers le bas).
# Caractères trop faciles à confondre entre eux : exclus (0 O, 1 I L).
POLICE = {
    "A": [[0, 6, 2, 0, 4, 6], [0.8, 3.8, 3.2, 3.8]],
    "B": [[0, 0, 0, 6, 3, 6, 4, 5, 4, 4, 3, 3, 0, 3], [0, 0, 3, 0, 4, 1, 4, 2, 3, 3]],
    "C": [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5]],
    "D": [[0, 0, 0, 6, 2.5, 6, 4, 4.5, 4, 1.5, 2.5, 0, 0, 0]],
    "E": [[4, 0, 0, 0, 0, 6, 4, 6], [0, 3, 3, 3]],
    "F": [[4, 0, 0, 0, 0, 6], [0, 3, 3, 3]],
    "G": [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5, 4, 3.5, 2.5, 3.5]],
    "H": [[0, 0, 0, 6], [4, 0, 4, 6], [0, 3, 4, 3]],
    "J": [[4, 0, 4, 5, 3, 6, 1, 6, 0, 5]],
    "K": [[0, 0, 0, 6], [4, 0, 0, 3.5], [1.3, 2.6, 4, 6]],
    "M": [[0, 6, 0, 0, 2, 3.5, 4, 0, 4, 6]],
    "N": [[0, 6, 0, 0, 4, 6, 4, 0]],
    "P": [[0, 6, 0, 0, 3, 0, 4, 1, 4, 2, 3, 3, 0, 3]],
    "Q": [[1, 0, 3, 0, 4, 1, 4, 5, 3, 6, 1, 6, 0, 5, 0, 1, 1, 0], [2.5, 4.5, 4, 6.3]],
    "R": [[0, 6, 0, 0, 3, 0, 4, 1, 4, 2, 3, 3, 0, 3], [2, 3, 4, 6]],
    "S": [[4, 1, 3, 0, 1, 0, 0, 1, 0, 2, 1, 3, 3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
    "T": [[0, 0, 4, 0], [2, 0, 2, 6]],
    "U": [[0, 0, 0, 5, 1, 6, 3, 6, 4, 5, 4, 0]],
    "V": [[0, 0, 2, 6, 4, 0]],
    "W": [[0, 0, 1, 6, 2, 2.5, 3, 6, 4, 0]],
    "X": [[0, 0, 4, 6], [4, 0, 0, 6]],
    "Y": [[0, 0, 2, 3, 4, 0], [2, 3, 2, 6]],
    "Z": [[0, 0, 4, 0, 0, 6, 4, 6]],
    "2": [[0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 0, 6, 4, 6]],
    "3": [[0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 3, 3, 1.5, 3], [3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
    "4": [[3, 6, 3, 0, 0, 4, 4, 4]],
    "5": [[4, 0, 0, 0, 0, 3, 3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
    "6": [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5, 4, 4, 3, 3, 0, 3]],
    "7": [[0, 0, 4, 0, 1.5, 6]],
    "8": [[1, 3, 0, 2, 0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 3, 3, 1, 3, 0, 4, 0, 5, 1, 6, 3, 6, 4, 5,
           4, 4, 3, 3]],
    "9": [[4, 3, 1, 3, 0, 2, 0, 1, 1, 0, 3, 0, 4, 1, 4, 5, 3, 6, 1, 6, 0, 5]],
}

# quelques couleurs foncées, bien lisibles sur le fond clair
COULEURS = ["#1f4e79", "#7b2d26", "#2e6b30", "#5b2c83", "#8a5a00", "#23395d", "#9c2a5b"]

# un générateur "cryptographique", imprévisible (contrairement au module random,
# dont la suite pourrait être devinée)
_hasard = secrets.SystemRandom()


def texte_aleatoire(longueur: int) -> str:
    alphabet = list(POLICE)
    return "".join(secrets.choice(alphabet) for _ in range(longueur))


# un nombre à virgule, au hasard, entre minimum et maximum
def hasard(minimum: float, maximum: float) -> float:
    return _hasard.uniform(minimum, maximum)


def couleur() -> str:
    return secrets.choice(COULEURS)


def svg(texte: str) -> str:
    l, h = LARGEUR, HAUTEUR
    morceaux = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{l}" height="{h}" viewBox="0 0 {l} {h}">',
                '<rect width="100%" height="100%" fill="#f5f7fa"/>']
    # les caractères : chacun agrandi, penché, décalé, et chaque point légèrement
    # déplacé au hasard
    pas = (l - 20) / len(texte)
    for i, caractere in enumerate(texte):
        echelle = hasard(4.6, 5.6)    # taille d'une unité de la grille, en pixels
        angle = hasard(-0.35, 0.35)   # rotation, en radians (environ ±20°)
        cx = 10 + pas * (i + 0.5) + hasard(-3, 3)    # centre du caractère
        cy = h / 2 + hasard(-4, 4)
        for trait in POLICE[caractere]:
            d = []
            # range(0, n, 2) : 0, 2, 4... un point = deux nombres
            for k in range(0, len(trait), 2):
                # point de la grille, centré sur (2, 3), déformé, tourné, placé
                x = (trait[k] - 2 + hasard(-0.2, 0.2)) * echelle
                y = (trait[k + 1] - 3 + hasard(-0.2, 0.2)) * echelle
                px = cx + x * math.cos(angle) - y * math.sin(angle)
                py = cy + x * math.sin(angle) + y * math.cos(angle)
                d.append(f"{'M' if k == 0 else 'L'}{px:.1f} {py:.1f}")
            morceaux.append(f'<path d="{" ".join(d)}" fill="none" stroke="{couleur()}" '
                            f'stroke-width="{hasard(2.2, 3):.1f}" stroke-linecap="round" '
                            f'stroke-linejoin="round"/>')
    # 3 traits de brouillage : des courbes qui traversent l'image
    for _ in range(3):
        morceaux.append(f'<path d="M{hasard(0, 20):.1f} {hasard(5, 45):.1f} '
                        f'C{hasard(30, 60):.1f} {hasard(0, 50):.1f} {hasard(90, 120):.1f} '
                        f'{hasard(0, 50):.1f} {hasard(130, 150):.1f} {hasard(5, 45):.1f}" '
                        f'fill="none" stroke="{couleur()}" stroke-width="1.3"/>')
    morceaux.append("</svg>")
    return "".join(morceaux)

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

# securite/captchas.py : les captchas en attente, gardés par le serveur, dans le cache

import secrets
from dataclasses import dataclass

from django.core.cache import cache

from . import dessin_captcha

# la durée de vie d'un captcha, en secondes
DUREE_VIE = 600


# un captcha : son identifiant, et son image (SVG)
@dataclass(frozen=True)
class Captcha:
    id: str
    svg: str


def generer() -> Captcha:
    texte = dessin_captcha.texte_aleatoire(5)
    # un identifiant aléatoire, impossible à deviner : 12 octets tirés par un
    # générateur cryptographique, écrits en hexadécimal
    id = secrets.token_hex(12)
    # la réponse attendue, dans le cache, pour 10 minutes : le cache l'oubliera de
    # lui-même ensuite
    cache.set(f"captcha.{id}", texte, DUREE_VIE)
    return Captcha(id, dessin_captcha.svg(texte))


# le captcha est CONSOMMÉ dans tous les cas : une image ne sert qu'une fois
def verifier(id: str, saisie: str) -> bool:
    if not id or not saisie:
        return False
    # get rend la valeur, ou None ; delete retire la réponse du cache, qu'elle soit
    # bonne ou non
    attendu = cache.get(f"captcha.{id}")
    cache.delete(f"captcha.{id}")
    if attendu is None:
        return False    # identifiant inconnu, ou captcha expiré
    # casefold : la chaîne en minuscules, pour une comparaison qui ignore la casse
    return attendu.casefold() == saisie.strip().casefold()
  • 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

# securite/limiteur.py : la limitation des tentatives de connexion, par adresse IP

import time

from django.conf import settings
from django.core.cache import cache


# les échecs d'un client sont comptés dans une FENÊTRE de temps fixe : au plus
# LOGIN_MAX_ECHECS échecs par fenêtre de LOGIN_FENETRE_SECONDES secondes
class Limiteur:
    def __init__(self, ip: str):
        self.cle = f"login.{ip}"

    # les secondes à attendre si le client est bloqué, 0 sinon
    def attente(self) -> int:
        etat = cache.get(self.cle)
        if etat is None or etat["echecs"] < settings.LOGIN_MAX_ECHECS:
            return 0
        return max(1, int(etat["fin"] - time.time()))

    # un échec de plus
    def echec(self) -> None:
        etat = cache.get(self.cle)
        if etat is None:
            # une nouvelle fenêtre commence
            etat = {"echecs": 0, "fin": time.time() + settings.LOGIN_FENETRE_SECONDES}
        etat["echecs"] += 1
        # la clé disparaît du cache à la fin de la fenêtre (timeout, en secondes)
        cache.set(self.cle, etat, timeout=max(1, int(etat["fin"] - time.time())))

    # une connexion réussie efface les échecs
    def effacer(self) -> None:
        cache.delete(self.cle)

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

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

import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-y=#3-i_9nrm%&a&_%_v^ajq460=!u*ry3uit-nj9_thr&v3ev)"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "fr"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

INSTALLED_APPS = [
    "django.contrib.staticfiles",
    "securite",
    "comptes",
]

MIDDLEWARE = [
    "django.middleware.csrf.CsrfViewMiddleware",
    # l'AUTHENTIFICATION : à chaque requête, l'utilisateur est retrouvé d'après le
    # jeton JWT de son cookie (cf. securite/middleware.py)
    "securite.middleware.Authentification",
]

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
    },
]

STATIC_URL = "static/"
STATICFILES_DIRS = [
    BASE_DIR.parent / "commun" / "static",
]

# nos propres réglages : la clé qui signe les jetons JWT (au moins 32 caractères), et
# leur durée de vie. os.environ.get : la variable d'environnement du même nom si elle
# existe (en production), la valeur écrite ici sinon (en développement)
JWT_SECRET = os.environ.get("JWT_SECRET", "une-cle-secrete-d-au-moins-32-caracteres-pour-l-exemple-27")
JWT_DUREE_MINUTES = int(os.environ.get("JWT_DUREE_MINUTES", "60"))

# le CACHE de l'application : ici, la mémoire du processus du serveur (c'est le
# cache par défaut de Django). Les captchas en attente et les compteurs d'échecs y
# sont rangés
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
    },
}

# la limitation des tentatives de connexion : 3 échecs par fenêtre de 60 secondes
LOGIN_MAX_ECHECS = 3
LOGIN_FENETRE_SECONDES = 60
  • 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

# comptes/views.py : connexion (avec captcha et limitation des tentatives),
# déconnexion, et une page réservée aux utilisateurs authentifiés

from django.conf import settings
from django.shortcuts import redirect, render
from django.views.decorators.http import require_GET, require_http_methods, require_POST

from securite import auth, captchas
from securite.decorateurs import connexion_requise
from securite.limiteur import Limiteur


@require_GET
def accueil(request):
    return redirect("profil")


# GET : le formulaire ; POST : la connexion, avec captcha et limitation des tentatives
@require_http_methods(["GET", "POST"])
def login(request):
    if request.method == "GET":
        return page_login(request)
    login = request.POST.get("login", "")
    # le limiteur de CE client : ses échecs sont comptés sous son adresse IP
    limiteur = Limiteur(request.META["REMOTE_ADDR"])
    # 1. une adresse bloquée n'est même pas écoutée
    secondes = limiteur.attente()
    if secondes > 0:
        return page_login(request, login, f"Trop de tentatives : réessayez dans {secondes} s.")
    # 2. le captcha d'abord (peu coûteux), le mot de passe (PBKDF2, coûteux) ensuite
    if not captchas.verifier(request.POST.get("captcha_id", ""), request.POST.get("captcha", "")):
        limiteur.echec()
        return page_login(request, login, "Le code de l'image est incorrect.")
    utilisateur = auth.valider(login, request.POST.get("mot_de_passe", ""))
    if utilisateur is None:
        limiteur.echec()
        return page_login(request, login, "Login ou mot de passe incorrect.")
    limiteur.effacer()
    reponse = redirect("profil")
    reponse.set_cookie(auth.COOKIE, auth.creer_jeton(utilisateur),
                       max_age=settings.JWT_DUREE_MINUTES * 60, httponly=True, samesite="Lax")
    return reponse


# chaque affichage du formulaire fabrique un NOUVEAU captcha
def page_login(request, login="", erreur=None):
    return render(request, "auth/login.html",
                  {"login": login, "erreur": erreur, "captcha": captchas.generer()})


# la déconnexion : POST seulement (un simple lien, placé sur un autre site, ne peut
# pas déconnecter l'utilisateur à son insu) ; on efface le cookie
@require_POST
def logout(request):
    reponse = redirect("login")
    reponse.delete_cookie(auth.COOKIE)
    return reponse


# request.utilisateur : l'utilisateur authentifié, trouvé par le middleware
@require_GET
@connexion_requise
def profil(request):
    return render(request, "profil/profil.html", {"utilisateur": request.utilisateur})
  • 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

{% extends "layout.html" %}

{% block contenu %}
  <h1 class="h3">Connexion</h1>
  <form method="post" action="{% url 'login' %}">
    {% csrf_token %}
    <div class="mb-2">
      <label class="form-label" for="login">Login</label>
      <input class="form-control" id="login" name="login" value="{{ login }}" required>
    </div>
    <div class="mb-2">
      <label class="form-label" for="mot_de_passe">Mot de passe</label>
      <input class="form-control" type="password" id="mot_de_passe" name="mot_de_passe" required>
    </div>
    <div class="mb-3">
      <label class="form-label" for="captcha">Recopiez le code de l'image</label>
      {# l'image SVG, fabriquée par le serveur : insérée sans échappement (filtre safe) #}
      <div class="mb-1">{{ captcha.svg|safe }}</div>
      {# l'identifiant du captcha voyage dans un champ caché ; la réponse, elle, reste sur le serveur #}
      <input type="hidden" name="captcha_id" value="{{ captcha.id }}">
      <input class="form-control" id="captcha" name="captcha" autocomplete="off" required>
    </div>
    <button class="btn btn-primary" type="submit">Se connecter</button>
  </form>
  {% if erreur %}
    <div class="alert alert-danger mt-3">{{ erreur }}</div>
  {% endif %}
  <p class="text-muted small mt-3">Comptes de test (mot de passe = login) : marie (USER), admin (ADMIN).</p>
{% endblock %}
  • 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

Image

Un code mal recopié :

Image

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

Image

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

curl -b cookies.txt -d "csrfmiddlewaretoken=...&login=marie&mot_de_passe=marie&captcha_id=abc&captcha=ABCDE" http://localhost:8000/login
1
2
3
4
    <div class="alert alert-danger mt-3">Le code de l&#x27;image est incorrect.</div>
    <div class="alert alert-danger mt-3">Le code de l&#x27;image est incorrect.</div>
    <div class="alert alert-danger mt-3">Le code de l&#x27;image est incorrect.</div>
    <div class="alert alert-danger mt-3">Trop de tentatives : réessayez dans 59 s.</div>

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 ([&#x27;]).