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.

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

25-jwt/
├── app.py
├── auth/
│   ├── securite.py
│   ├── service.py
│   └── utilisateurs.py
├── config.py
├── controllers/
│   ├── auth.py
│   └── profil.py
└── templates/
    ├── auth/
    │   └── login.html
    ├── layout.html
    └── profil/
        └── profil.html

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 :

1
2
3
4
5
6
# config.py : la configuration de l'application, lue par app.config.from_pyfile.
# Seules les variables en MAJUSCULES sont retenues.

# la clé qui signe les jetons JWT (au moins 32 caractères), et leur durée de vie
JWT_SECRET = "une-cle-secrete-d-au-moins-32-caracteres-pour-l-exemple-25"
JWT_DUREE_MINUTES = 60

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.

# app.py : l'application de l'exemple 25
from pathlib import Path

from flask import Blueprint, Flask

from auth import securite
from auth.service import AuthService
from auth.utilisateurs import UtilisateursService
from controllers.auth import auth
from controllers.profil import profil

BOOTSTRAP = Path(__file__).resolve().parent.parent / "commun" / "bootstrap"


def create_app():
    app = Flask(__name__)
    app.jinja_env.trim_blocks = True
    app.jinja_env.lstrip_blocks = True
    # la configuration : le fichier config.py, puis les variables d'environnement
    # qui commencent par FLASK_ (FLASK_JWT_SECRET remplace JWT_SECRET)
    app.config.from_pyfile("config.py")
    app.config.from_prefixed_env()
    # les services de l'authentification
    utilisateurs = UtilisateursService()
    app.extensions["auth"] = AuthService(utilisateurs, app.config["JWT_SECRET"],
                                         app.config["JWT_DUREE_MINUTES"])
    app.register_blueprint(Blueprint("bootstrap", __name__, static_folder=BOOTSTRAP,
                                     static_url_path="/bootstrap"))
    app.register_blueprint(auth)
    app.register_blueprint(profil)
    # l'utilisateur de chaque requête, lu dans le jeton du cookie
    securite.enregistrer(app)
    return app


if __name__ == "__main__":
    create_app().run(port=5000, debug=True)
  • 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 :

FLASK_JWT_SECRET="une-autre-cle-d-au-moins-trente-deux-caracteres" python app.py

et sous [Windows] (PowerShell) :

$env:FLASK_JWT_SECRET="une-autre-cle-d-au-moins-trente-deux-caracteres"
python app.py

8.1.2. Les utilisateurs : auth/utilisateurs.py

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

import bcrypt


@dataclass(frozen=True)
class Utilisateur:
    login: str
    nom: str
    role: str
    hachage_mot_de_passe: bytes


# le hachage d'un mot de passe : bcrypt, avec un coût de 10 (2^10 tours) ;
# gensalt tire un "sel" aléatoire, qui est rangé dans le hachage lui-même
def hacher(mot_de_passe: str) -> bytes:
    return bcrypt.hashpw(mot_de_passe.encode(), bcrypt.gensalt(rounds=10))


class UtilisateursService:

    def __init__(self):
        # on ne conserve JAMAIS un mot de passe, seulement son hachage
        self._utilisateurs = {u.login: u for u in (
            Utilisateur("marie", "Marie Pélissier", "USER", hacher("marie")),
            Utilisateur("admin", "Administrateur", "ADMIN", hacher("admin")),
        )}

    def chercher(self, login: str) -> Utilisateur | None:
        return self._utilisateurs.get(login)
  • 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

# auth/service.py : la vérification des mots de passe et les jetons JWT
from datetime import datetime, timedelta, timezone

import bcrypt
import jwt
from flask import current_app

from auth.utilisateurs import Utilisateur, UtilisateursService

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


class AuthService:

    def __init__(self, utilisateurs: UtilisateursService, secret: str, duree_minutes: int):
        self._utilisateurs = utilisateurs
        self._secret = secret
        self.duree = timedelta(minutes=duree_minutes)

    # l'utilisateur, ou None si le login est inconnu ou le mot de passe faux.
    # checkpw hache le mot de passe saisi avec le sel du hachage enregistré,
    # puis compare les deux résultats
    def valider(self, login: str | None, mot_de_passe: str | None) -> Utilisateur | None:
        if not login or mot_de_passe is None:
            return None
        utilisateur = self._utilisateurs.chercher(login)
        if utilisateur is None or not bcrypt.checkpw(mot_de_passe.encode(),
                                                     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(self, utilisateur: Utilisateur) -> str:
        maintenant = datetime.now(timezone.utc)
        # le contenu du jeton : les "claims" (affirmations sur l'utilisateur)
        claims = {
            "sub": utilisateur.login,
            "nom": utilisateur.nom,
            "role": utilisateur.role,
            "iat": maintenant,
            "exp": maintenant + self.duree,
        }
        return jwt.encode(claims, self._secret, algorithm="HS256")

    # les claims d'un jeton dont la signature et la date d'expiration sont
    # valides ; None sinon (jeton falsifié, expiré ou illisible)
    def verifier(self, jeton: str) -> dict | None:
        try:
            return jwt.decode(jeton, self._secret, algorithms=["HS256"])
        except jwt.InvalidTokenError:
            return None


def auth_service() -> AuthService:
    return current_app.extensions["auth"]
  • 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

# auth/securite.py : l'utilisateur de chaque requête, et la protection des actions
from functools import wraps

from flask import g, redirect, request, url_for

from auth.service import COOKIE, auth_service


def enregistrer(app):

    # avant chaque action : le jeton du cookie -> g.utilisateur (ses claims), ou None
    @app.before_request
    def lire_jeton():
        jeton = request.cookies.get(COOKIE)
        g.utilisateur = auth_service().verifier(jeton) if jeton else None


# un décorateur : l'action n'est exécutée que pour un utilisateur authentifié ;
# sinon, direction la page de connexion
def connexion_requise(action):
    @wraps(action)
    def action_protegee(*args, **kwargs):
        if g.utilisateur is None:
            reponse = redirect(url_for("auth.formulaire"))
            # un cookie présent mais invalide (jeton expiré, falsifié) : on l'efface
            if COOKIE in request.cookies:
                reponse.delete_cookie(COOKIE)
            return reponse
        return action(*args, **kwargs)

    return action_protegee
  • 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

# controllers/auth.py : connexion et déconnexion
from flask import Blueprint, redirect, render_template, request, url_for

from auth.service import COOKIE, auth_service

auth = Blueprint("auth", __name__)


@auth.get("/")
def accueil():
    return redirect(url_for("profil.afficher"))


@auth.get("/login")
def formulaire():
    return render_template("auth/login.html")


@auth.post("/login")
def connexion():
    login = request.form.get("login")
    utilisateur = auth_service().valider(login, request.form.get("motDePasse"))
    if utilisateur is None:
        # un message volontairement vague : on ne dit pas si c'est le login ou le mot de passe
        return render_template("auth/login.html", login=login,
                               erreur="Login ou mot de passe incorrect.")
    reponse = redirect(url_for("profil.afficher"))
    # 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.set_cookie(COOKIE, auth_service().creer_jeton(utilisateur), httponly=True,
                       samesite="Lax", max_age=auth_service().duree)
    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
@auth.post("/logout")
def deconnexion():
    reponse = redirect(url_for("auth.formulaire"))
    reponse.delete_cookie(COOKIE)
    return reponse
  • 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.
# controllers/profil.py : une page réservée aux utilisateurs authentifiés
from flask import Blueprint, g, render_template

from auth.securite import connexion_requise

profil = Blueprint("profil", __name__)


@profil.get("/profil")
@connexion_requise
def afficher():
    # g.utilisateur : les claims du jeton, déjà vérifié (cf. auth/securite.py)
    return render_template("profil/profil.html", nom=g.utilisateur["nom"],
                           admin=g.utilisateur["role"] == "ADMIN", claims=g.utilisateur)
  • 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

{% extends "layout.html" %}

{% block contenu %}
  <h1 class="h3">Connexion</h1>
  <form method="post" action="/login">
    <div class="mb-2">
      <label class="form-label" for="login">Login</label>
      <input class="form-control" id="login" name="login" value="{{ login or '' }}" required>
    </div>
    <div class="mb-3">
      <label class="form-label" for="motDePasse">Mot de passe</label>
      <input class="form-control" type="password" id="motDePasse" name="motDePasse" 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 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).
{% extends "layout.html" %}

{% block contenu %}
  <h1 class="h3">Bonjour {{ nom }}</h1>
  <p>Votre rôle : <b>{{ "administrateur" if admin else "utilisateur" }}</b>.</p>
  <p>Le contenu de votre jeton :</p>
  <table class="table table-sm w-auto">
    {# une boucle sur un dictionnaire : items() donne les couples (clé, valeur) #}
    {% for cle, valeur in 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="/logout">
    <button class="btn btn-outline-secondary" type="submit">Se déconnecter</button>
  </form>
{% endblock %}
  • 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é :

curl -i http://localhost:5000/profil
1
2
3
4
5
HTTP/1.1 302 FOUND
Content-Type: text/html; charset=utf-8
Content-Length: 199
Location: /login
...

Un mot de passe erroné :

Image

La connexion réussie. Avec [curl], on conserve le cookie reçu dans un fichier :

curl -i -c jeton.txt -d "login=marie&motDePasse=marie" http://localhost:5000/login
1
2
3
4
5
6
HTTP/1.1 302 FOUND
Content-Type: text/html; charset=utf-8
Content-Length: 201
Location: /profil
Set-Cookie: jeton=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYXJpZSIsIm5vbSI6Ik1hcmllIFBcdTAwZTlsaXNzaWVyIiwicm9sZSI6IlVTRVIiLCJpYXQiOjE3OTA0MzAwMTgsImV4cCI6MTc5MDQzMzYxOH0.Rw_mzqGIMz1o1T6VgyhyT0G_r42ZwhdriV9zri08uos; Expires=Sat, 26 Sep 2026 14:40:18 GMT; Max-Age=3600; HttpOnly; 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":1790430018,"exp":1790433618}

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 :

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é>.Rw_mzqGIMz1o1T6VgyhyT0G_r42ZwhdriV9zri08uos" http://localhost:5000/profil
1
2
3
4
5
6
HTTP/1.1 302 FOUND
Content-Type: text/html; charset=utf-8
Content-Length: 199
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é. 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

# auth/securite.py : l'utilisateur de chaque requête, et la protection des actions
from functools import wraps

from flask import abort, g, redirect, request, url_for

from auth.service import COOKIE, auth_service


def enregistrer(app):

    # avant chaque action : le jeton du cookie -> g.utilisateur (ses claims), ou None
    @app.before_request
    def lire_jeton():
        jeton = request.cookies.get(COOKIE)
        g.utilisateur = auth_service().verifier(jeton) if jeton else None


# un décorateur : l'action n'est exécutée que pour un utilisateur authentifié ;
# sinon, direction la page de connexion
def connexion_requise(action):
    @wraps(action)
    def action_protegee(*args, **kwargs):
        if g.utilisateur is None:
            reponse = redirect(url_for("auth.formulaire"))
            # un cookie présent mais invalide (jeton expiré, falsifié) : on l'efface
            if COOKIE in request.cookies:
                reponse.delete_cookie(COOKIE)
            return reponse
        return action(*args, **kwargs)

    return action_protegee


# un décorateur à PARAMÈTRE : @role_requis("ADMIN"). role_requis("ADMIN") rend
# le décorateur proprement dit, qui reçoit l'action. Il s'utilise APRÈS (sous)
# @connexion_requise : l'utilisateur est alors forcément authentifié
def role_requis(role: str):
    def decorateur(action):
        @wraps(action)
        def action_protegee(*args, **kwargs):
            if g.utilisateur["role"] != role:
                abort(403)  # accès refusé
            return action(*args, **kwargs)

        return action_protegee

    return decorateur
  • 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

# controllers/admin.py : une page réservée au rôle ADMIN
from flask import Blueprint, g, render_template

from auth.securite import connexion_requise, role_requis

admin = Blueprint("admin", __name__)


# les décorateurs s'appliquent de bas en haut : role_requis enveloppe l'action,
# puis connexion_requise enveloppe le tout. À l'exécution, c'est donc
# connexion_requise qui vérifie EN PREMIER que l'utilisateur est authentifié
@admin.get("/admin")
@connexion_requise
@role_requis("ADMIN")
def afficher():
    return render_template("admin/admin.html", nom=g.utilisateur["nom"])
  • 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

# app.py : l'application de l'exemple 26
from pathlib import Path

from flask import Blueprint, Flask, render_template

from auth import securite
from auth.service import AuthService
from auth.utilisateurs import UtilisateursService
from controllers.admin import admin
from controllers.auth import auth
from controllers.profil import profil

BOOTSTRAP = Path(__file__).resolve().parent.parent / "commun" / "bootstrap"


def create_app():
    app = Flask(__name__)
    app.jinja_env.trim_blocks = True
    app.jinja_env.lstrip_blocks = True
    # la configuration : le fichier config.py, puis les variables d'environnement
    # qui commencent par FLASK_ (FLASK_JWT_SECRET remplace JWT_SECRET)
    app.config.from_pyfile("config.py")
    app.config.from_prefixed_env()
    # les services de l'authentification
    utilisateurs = UtilisateursService()
    app.extensions["auth"] = AuthService(utilisateurs, app.config["JWT_SECRET"],
                                         app.config["JWT_DUREE_MINUTES"])
    app.register_blueprint(Blueprint("bootstrap", __name__, static_folder=BOOTSTRAP,
                                     static_url_path="/bootstrap"))
    app.register_blueprint(auth)
    app.register_blueprint(profil)
    app.register_blueprint(admin)
    # l'utilisateur de chaque requête, lu dans le jeton du cookie
    securite.enregistrer(app)

    # rôle insuffisant : abort(403) (cf. auth/securite.py) -> notre page d'erreur
    @app.errorhandler(403)
    def acces_refuse(e):
        return render_template("erreur.html", code=403), 403

    return app


if __name__ == "__main__":
    create_app().run(port=5000, debug=True)
  • lignes 37-39 : un gestionnaire d'erreur enregistré pour un code : l'erreur 403 affiche notre page.
1
2
3
4
5
6
7
8
9
{% extends "layout.html" %}

{% block contenu %}
  <div class="alert alert-danger">
    <h1 class="h4">Erreur {{ code }}</h1>
    <p class="mb-0">{{ "Accès refusé : cette page est réservée aux administrateurs." if code == 403 else "Une erreur est survenue." }}</p>
  </div>
  <p><a href="/profil">Retour au profil</a></p>
{% endblock %}

8.2.4. 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, puis un visiteur anonyme :

curl -i -b jeton.txt http://localhost:5000/admin
curl -i http://localhost:5000/admin
HTTP/1.1 403 FORBIDDEN
Content-Type: text/html; charset=utf-8
Content-Length: 498
...
  <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
Content-Length: 199
Location: /login

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

# auth/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

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

# 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]],
}

ALPHABET = "".join(POLICE)

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


def texte_aleatoire(longueur: int) -> str:
    return "".join(_hasard.choice(ALPHABET) for _ in range(longueur))


def hasard(minimum: float, maximum: float) -> float:
    return _hasard.uniform(minimum, maximum)


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


def svg(texte: str) -> str:
    morceaux = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{LARGEUR}" height="{HAUTEUR}" '
                f'viewBox="0 0 {LARGEUR} {HAUTEUR}">',
                '<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 = (LARGEUR - 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 = HAUTEUR / 2 + hasard(-4, 4)
        d = []
        for trait in POLICE[caractere]:
            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} {hasard(0, 50):.1f} '
            f'{hasard(130, 150):.1f} {hasard(5, 45):.1f}" fill="none" stroke="{couleur()}" '
            f'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.

  • 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

# auth/captcha.py : les captchas en attente, gardés en mémoire par le serveur
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta
from threading import Lock

from flask import current_app

from auth import dessin_captcha

DUREE_VIE = timedelta(minutes=10)


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


class CaptchaService:

    def __init__(self):
        # identifiant -> (réponse attendue, date d'expiration)
        self._en_attente: dict[str, tuple[str, datetime]] = {}
        self._verrou = Lock()

    def generer(self) -> Captcha:
        texte = dessin_captcha.texte_aleatoire(5)
        id = secrets.token_urlsafe(16)  # un identifiant aléatoire, impossible à deviner
        with self._verrou:
            self._en_attente[id] = (texte, datetime.now() + 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(self, id: str | None, saisie: str | None) -> bool:
        with self._verrou:
            attendu = self._en_attente.pop(id, None) if id else None
        if attendu is None or saisie is None:
            return False
        texte, expire = attendu
        return expire > datetime.now() and texte.lower() == saisie.strip().lower()


def captcha_service() -> CaptchaService:
    return current_app.extensions["captchas"]
  • 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

# auth/limiteur.py : compte les échecs de connexion de chaque adresse IP
import math
from datetime import datetime, timedelta
from threading import Lock

from flask import current_app

MAX_ECHECS = 3
DUREE_BLOCAGE = timedelta(minutes=1)


class Compteur:
    def __init__(self):
        self.echecs = 0
        self.bloque_jusqua = datetime.min


class LimiteurService:

    def __init__(self):
        self._compteurs: dict[str, Compteur] = {}
        # plusieurs requêtes simultanées, un seul accès à la fois
        self._verrou = Lock()

    # le nombre de secondes de blocage restantes (0 : pas bloquée)
    def secondes_blocage(self, ip: str) -> int:
        with self._verrou:
            compteur = self._compteurs.get(ip)
            if compteur is None:
                return 0
            reste = (compteur.bloque_jusqua - datetime.now()).total_seconds()
            return max(0, math.ceil(reste))

    def echec(self, ip: str):
        with self._verrou:
            compteur = self._compteurs.setdefault(ip, Compteur())
            compteur.echecs += 1
            if compteur.echecs >= MAX_ECHECS:
                compteur.bloque_jusqua = datetime.now() + DUREE_BLOCAGE
                compteur.echecs = 0  # après le blocage, on repart de zéro

    def reussite(self, ip: str):
        with self._verrou:
            self._compteurs.pop(ip, None)


def limiteur_service() -> LimiteurService:
    return current_app.extensions["limiteur"]
  • 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

# controllers/auth.py : connexion (avec captcha et limitation des tentatives) et déconnexion
from flask import Blueprint, redirect, render_template, request, url_for

from auth.captcha import captcha_service
from auth.limiteur import limiteur_service
from auth.service import COOKIE, auth_service

auth = Blueprint("auth", __name__)


@auth.get("/")
def accueil():
    return redirect(url_for("profil.afficher"))


@auth.get("/login")
def formulaire():
    return page_login()


@auth.post("/login")
def connexion():
    login = request.form.get("login")
    # l'adresse IP du client
    ip = request.remote_addr

    # 1. une adresse bloquée n'est même pas écoutée
    secondes = limiteur_service().secondes_blocage(ip)
    if secondes > 0:
        return page_login(login, f"Trop de tentatives : réessayez dans {secondes} s.")
    # 2. le captcha d'abord (peu coûteux), le mot de passe (bcrypt, coûteux) ensuite
    if not captcha_service().verifier(request.form.get("captchaId"), request.form.get("captcha")):
        limiteur_service().echec(ip)
        return page_login(login, "Le code de l'image est incorrect.")
    utilisateur = auth_service().valider(login, request.form.get("motDePasse"))
    if utilisateur is None:
        limiteur_service().echec(ip)
        return page_login(login, "Login ou mot de passe incorrect.")
    limiteur_service().reussite(ip)
    reponse = redirect(url_for("profil.afficher"))
    reponse.set_cookie(COOKIE, auth_service().creer_jeton(utilisateur), httponly=True,
                       samesite="Lax", max_age=auth_service().duree)
    return reponse


@auth.post("/logout")
def deconnexion():
    reponse = redirect(url_for("auth.formulaire"))
    reponse.delete_cookie(COOKIE)
    return reponse


# chaque affichage du formulaire fabrique un NOUVEAU captcha
def page_login(login: str | None = None, erreur: str | None = None):
    return render_template("auth/login.html", login=login, erreur=erreur,
                           captcha=captcha_service().generer())
  • 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

{% extends "layout.html" %}

{% block contenu %}
  <h1 class="h3">Connexion</h1>
  <form method="post" action="/login">
    <div class="mb-2">
      <label class="form-label" for="login">Login</label>
      <input class="form-control" id="login" name="login" value="{{ login or '' }}" required>
    </div>
    <div class="mb-2">
      <label class="form-label" for="motDePasse">Mot de passe</label>
      <input class="form-control" type="password" id="motDePasse" name="motDePasse" 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 (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="captchaId" 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 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

Image

Un code mal recopié :

Image

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

Image

Avec [curl], quatre tentatives avec un identifiant de captcha inventé :

curl -d "login=marie&motDePasse=marie&captchaId=abc&captcha=XXXXX" http://localhost:5000/login
1
2
3
4
  <div class="alert alert-danger mt-3">Le code de l&#39;image est incorrect.</div>
  <div class="alert alert-danger mt-3">Le code de l&#39;image est incorrect.</div>
  <div class="alert alert-danger mt-3">Le code de l&#39;image est incorrect.</div>
  <div class="alert alert-danger mt-3">Trop de tentatives : réessayez dans 60 s.</div>

Même avec le bon mot de passe, la quatrième tentative est refusée sans examen.