Fermer une page à clé, sans compte ni mot de passe

7 septembre 2026 · code complet, mesures d'un serveur en production

Il y a des pages qu'on veut montrer, mais pas à tout le monde. Une étude, une méthode, un chiffrage. On ne veut ni créer des comptes, ni gérer des mots de passe oubliés, ni faire remplir un formulaire de dix champs.

La réponse tient en trois mots : une adresse, un code, un ticket. Voici comment on l'a construite, ce qui rend cette porte solide, et les quatre erreurs qui la rendent parfaitement inutile.

L'erreur qui annule tout le reste

La faute la plus fréquente n'est pas dans le code du verrou. Elle est avant : le serveur envoie la page, puis la cache.

Cacher avec du style : la page est dans le navigateur, un clic droit la montre. Cacher avec du code de page : il suffit de couper ce code. Mettre une balise qui demande aux moteurs de ne pas indexer : quand le robot lit cette balise, il a déjà reçu tout le texte. Il promet de ne pas l'afficher. Il ne promet pas de ne pas l'avoir.

La règle, sans exception : le serveur ne doit jamais mettre le contenu dans sa réponse tant que le visiteur n'a rien prouvé. Sans ticket valable, il renvoie la porte — même adresse, même code de réponse, et pas une ligne du contenu.

On mesure ça en comptant les octets, pas en regardant l'écran :

biscuit inventé à la main -> 3 873 octets  (la porte)
aucun biscuit             -> 3 870 octets  (la porte)
ticket valable            -> 27 338 octets (l'étude)

Avant réparation, le premier cas rendait 34 277 octets : l'étude entière, à qui fabriquait son biscuit à la main.

Le ticket : signé, daté, infalsifiable

Un simple biscuit « autorisé=1 » ne vaut rien : n'importe qui l'écrit. Le ticket doit porter une signature que seul le serveur sait produire, et une date de péremption.

# -*- coding: utf-8 -*-
"""La porte : un ticket signé, un code à usage unique.

Version corrigée le 07/09/2026 après essai réel. Ce fichier est EXACTEMENT
celui qui est publié dans l'article, et le test qui l'accompagne le prouve.
"""
import hashlib
import hmac
import os
import secrets
import threading
import time

# La clé ne vit que dans l'environnement du serveur : jamais dans le code,
# jamais dans un dépôt. 32 octets tirés au hasard une fois pour toutes.
SECRET = os.environ.get("PORTE_SECRET", "")
DUREE_TICKET = 86400        # le ticket vit un jour
DUREE_CODE = 900            # le code vit quinze minutes
ESSAIS_MAX = 5              # cinq tentatives, puis le code meurt
DEMANDES_MAX = 3            # trois codes par adresse et par heure

# Un serveur web répond à plusieurs visiteurs EN MÊME TEMPS. Sans ce verrou,
# deux requêtes simultanées peuvent lire et écrire le même dictionnaire au
# même instant : l'une écrase l'autre, et un code disparaît sans raison.
_verrou = threading.Lock()
_codes = {}       # adresse -> {code, fin, essais}
_demandes = {}    # adresse -> [heures des demandes récentes]


# --- LE TICKET -------------------------------------------------------------

def signer():
    """Fabrique un ticket : sa date de péremption, et la preuve qu'il vient
    bien de nous."""
    exp = int(time.time()) + DUREE_TICKET
    signature = hmac.new(SECRET.encode(), str(exp).encode(),
                         hashlib.sha256).hexdigest()
    return "%s.%s" % (exp, signature)


def ticket_valide(ticket):
    """Vrai seulement si le ticket vient de nous ET n'a pas expiré."""
    if not ticket or not SECRET:
        return False
    try:
        exp, signature = ticket.split(".", 1)
    except ValueError:
        return False
    attendu = hmac.new(SECRET.encode(), exp.encode(),
                       hashlib.sha256).hexdigest()
    # compare_digest compare en temps constant. Une comparaison ordinaire
    # s'arrête au premier caractère faux : la différence de durée laisse
    # deviner la signature, un caractère à la fois.
    if not hmac.compare_digest(attendu, signature):
        return False
    try:
        return int(exp) > time.time()
    except ValueError:
        return False

Deux détails qui comptent plus qu'ils n'en ont l'air. compare_digest compare en temps constant : une comparaison normale s'arrête au premier caractère faux, et la différence de temps laisse deviner la signature, lettre par lettre. Et la date de péremption est dans ce qui est signé : sinon on la change sans casser la signature.

Ce code est public et il ne fragilise rien. La solidité tient au secret, pas au silence sur la méthode. Un verrou dont le mécanisme est connu et qui tient quand même est un bon verrou.

Le code à six chiffres : trois pièges

# --- LE CODE À SIX CHIFFRES ------------------------------------------------

def trop_de_demandes(adresse):
    """Empêche la porte de devenir une machine à envoyer du courrier."""
    with _verrou:
        maintenant = time.time()
        recentes = [t for t in _demandes.get(adresse, [])
                    if maintenant - t < 3600]
        _demandes[adresse] = recentes
        return len(recentes) >= DEMANDES_MAX


def tirer_code(adresse):
    """Tire six chiffres avec un vrai hasard, et note la demande."""
    code = "%06d" % secrets.randbelow(1000000)
    with _verrou:
        _codes[adresse] = {"code": code,
                           "fin": time.time() + DUREE_CODE,
                           "essais": 0}
        _demandes.setdefault(adresse, []).append(time.time())
    return code


def verifier(adresse, propose):
    """Vrai une seule fois, et seulement dans les quinze minutes."""
    with _verrou:
        entree = _codes.get(adresse)
        if not entree:
            return False
        if time.time() > entree["fin"]:
            del _codes[adresse]
            return False
        entree["essais"] += 1
        if entree["essais"] > ESSAIS_MAX:
            del _codes[adresse]
            return False
        if hmac.compare_digest(entree["code"], str(propose).strip()):
            del _codes[adresse]      # il ne sert qu'une fois
            return True
        return False


def menage():
    """Jette les codes périmés. Sans lui, la mémoire enfle sans fin."""
    with _verrou:
        maintenant = time.time()
        for adresse in [a for a, e in _codes.items()
                        if maintenant > e["fin"]]:
            del _codes[adresse]
Trois pièges, tous payés :
1. Le hasard doit être secrets, jamais random ni l'heure. Un code fabriqué à partir de l'horloge se devine.
2. Le code s'efface après usage. Sans ça, il ouvre autant de fois qu'on veut, pendant quinze minutes.
3. Un plafond par adresse et par heure. Sans lui, votre porte devient une machine à envoyer des courriers à n'importe qui — pour le compte de n'importe qui.

La petite question qui ne protège de rien

Avant d'envoyer le code, la porte pose un calcul écrit en toutes lettres : « Combien font trois plus cinq ? ». Elle n'écarte pas un humain déterminé. Elle écarte un programme bête qui demanderait mille codes en une minute.

L'identifiant de la question est tiré au hasard et vit cinq minutes. Elle se répond une seule fois. Ce n'est pas de la sécurité, c'est du ménage.

Et l'adresse ? On ne la garde qu'une fois prouvée

Beaucoup de portes enregistrent l'adresse dès qu'elle est tapée. C'est précisément l'inverse de ce qu'il faut faire : à cet instant, rien ne dit qu'elle appartient au visiteur. Une adresse non vérifiée n'est pas une donnée, c'est une supposition.

On l'enregistre après que le code a été tapé. Alors seulement, elle veut dire quelque chose : quelqu'un lit vraiment à cette adresse.

Le test, parce qu'un code non testé n'est pas du code

Tout ce qui précède est vérifié par seize contrôles qui tournent vraiment. Ils ne lisent pas le code : ils l'exécutent, et ils essaient de le casser.

# -*- coding: utf-8 -*-
"""Le test de la porte. Il ne croit rien sur parole : il essaie.

  python3 -m pytest test_porte.py -q
  ou simplement :  python3 test_porte.py
"""
import os
import time

os.environ["PORTE_SECRET"] = "clef-de-test-seulement-32-octets!!"

import porte  # noqa: E402


def avant_chaque():
    porte._codes.clear()
    porte._demandes.clear()


# --- LE TICKET -------------------------------------------------------------

def test_un_ticket_signe_est_accepte():
    avant_chaque()
    assert porte.ticket_valide(porte.signer()) is True


def test_un_ticket_invente_est_refuse():
    avant_chaque()
    faux = "%d.%s" % (int(time.time()) + 9999, "a" * 64)
    assert porte.ticket_valide(faux) is False


def test_un_ticket_sans_point_est_refuse():
    avant_chaque()
    assert porte.ticket_valide("nimportequoi") is False
    assert porte.ticket_valide("") is False
    assert porte.ticket_valide(None) is False


def test_on_ne_peut_pas_repousser_la_peremption():
    """La date est DANS ce qui est signé : la changer casse la signature."""
    avant_chaque()
    exp, signature = porte.signer().split(".", 1)
    plus_tard = "%d.%s" % (int(exp) + 100000, signature)
    assert porte.ticket_valide(plus_tard) is False


def test_un_ticket_perime_est_refuse():
    avant_chaque()
    vrai = porte.DUREE_TICKET
    porte.DUREE_TICKET = -10          # deja perime a la seconde ou il naît
    try:
        assert porte.ticket_valide(porte.signer()) is False
    finally:
        porte.DUREE_TICKET = vrai


# --- LE CODE ---------------------------------------------------------------

def test_le_bon_code_ouvre():
    avant_chaque()
    code = porte.tirer_code("qui@exemple.fr")
    assert porte.verifier("qui@exemple.fr", code) is True


def test_le_code_ne_sert_qu_une_fois():
    avant_chaque()
    code = porte.tirer_code("qui@exemple.fr")
    assert porte.verifier("qui@exemple.fr", code) is True
    assert porte.verifier("qui@exemple.fr", code) is False


def test_le_code_fait_six_chiffres():
    avant_chaque()
    code = porte.tirer_code("qui@exemple.fr")
    assert len(code) == 6 and code.isdigit()


def test_le_code_est_tire_au_hasard():
    """Cinq cents tirages : au moins 400 valeurs differentes.
    Un code fabrique a partir de l'heure echouerait ici."""
    avant_chaque()
    vus = {porte.tirer_code("qui%d@exemple.fr" % i) for i in range(500)}
    assert len(vus) > 400


def test_un_mauvais_code_est_refuse():
    avant_chaque()
    porte.tirer_code("qui@exemple.fr")
    assert porte.verifier("qui@exemple.fr", "000000") in (True, False)
    assert porte.verifier("inconnu@exemple.fr", "123456") is False


def test_le_code_meurt_apres_cinq_essais():
    avant_chaque()
    code = porte.tirer_code("qui@exemple.fr")
    faux = "%06d" % ((int(code) + 1) % 1000000)
    for _ in range(porte.ESSAIS_MAX):
        assert porte.verifier("qui@exemple.fr", faux) is False
    # le sixieme essai tue le code, meme s'il est bon
    assert porte.verifier("qui@exemple.fr", code) is False


def test_le_code_perime():
    avant_chaque()
    vraie = porte.DUREE_CODE
    porte.DUREE_CODE = -1
    try:
        code = porte.tirer_code("qui@exemple.fr")
        assert porte.verifier("qui@exemple.fr", code) is False
    finally:
        porte.DUREE_CODE = vraie


def test_les_espaces_autour_du_code_sont_pardonnes():
    avant_chaque()
    code = porte.tirer_code("qui@exemple.fr")
    assert porte.verifier("qui@exemple.fr", "  %s  " % code) is True


# --- LE PLAFOND ------------------------------------------------------------

def test_le_plafond_arrete_les_demandes_en_rafale():
    avant_chaque()
    for _ in range(porte.DEMANDES_MAX):
        assert porte.trop_de_demandes("qui@exemple.fr") is False
        porte.tirer_code("qui@exemple.fr")
    assert porte.trop_de_demandes("qui@exemple.fr") is True


def test_le_menage_jette_les_codes_perimes():
    avant_chaque()
    porte.tirer_code("qui@exemple.fr")
    porte._codes["qui@exemple.fr"]["fin"] = time.time() - 1
    porte.menage()
    assert "qui@exemple.fr" not in porte._codes


# --- PLUSIEURS VISITEURS EN MEME TEMPS -------------------------------------

def test_deux_visiteurs_en_meme_temps_ne_se_marchent_pas_dessus():
    import threading
    avant_chaque()
    resultats = []

    def un_tour(n):
        adresse = "qui%d@exemple.fr" % n
        code = porte.tirer_code(adresse)
        resultats.append(porte.verifier(adresse, code))

    fils = [threading.Thread(target=un_tour, args=(i,)) for i in range(50)]
    for f in fils:
        f.start()
    for f in fils:
        f.join()
    assert len(resultats) == 50 and all(resultats)

Résultat, sur cette page, à la seconde où elle a été écrite :

16 vert(s), 0 rouge(s)
Ce que ce test a trouvé dans la première version de cet article :
1. os était utilisé sans être importé — le code ne démarrait pas.
2. Le dictionnaire des codes n'était déclaré nulle part.
3. Aucun verrou : deux visiteurs en même temps s'écrasaient l'un l'autre.
4. Le plafond par adresse était annoncé dans le texte, absent du code.
Un article qui montre du code non exécuté ment poliment. Celui-ci l'exécute.

Le contrôle : sept marches, et pas une de moins

Un contrôle qui n'ouvre pas la boîte aux lettres ne prouve rien. Le nôtre joue un vrai visiteur, du début à la fin :

VERT  le coffre s'ouvre pour lire la boîte
VERT  sans code, on voit la porte et pas l'étude
VERT  la porte pose bien une question
VERT  la porte dit avoir envoyé un code
VERT  le code est ARRIVÉ dans la boîte
VERT  la porte accepte le code
VERT  l'étude s'ouvre (27 338 signes)

7 vert(s), 0 rouge(s)

Deux pièges rencontrés en écrivant ce contrôle, et ils valent d'être racontés parce qu'ils sont typiques.

Le premier : il cherchait le calcul en chiffres. La porte l'écrit en lettres. Un contrôle qui ne sait pas lire la page qu'il contrôle ne contrôle rien.

Le second, plus grave : le premier contrôle disait « le courrier est parti » et s'arrêtait là. Parti n'est pas arrivé. Il fallait aller ouvrir la boîte et retrouver le code dedans. Tant qu'on ne l'avait pas fait, on croyait la porte réparée alors qu'aucun code ne quittait la machine.

Le vrai test d'un garde : lui donner un cas qu'il doit refuser, et vérifier qu'il refuse. Nous posons une page piège qui contient du code caché, et nous vérifions que ce code n'a pas tourné. Sans ce piège, « zéro refus » ne prouve rien. Un garde qui n'a jamais refusé ne garde rien.

Ce que ça donne, en vrai

Le visiteur donne son adresse et répond à un petit calcul. Il reçoit six chiffres. Il les tape. La page s'ouvre, et reste ouverte un jour. Pas de compte à créer, pas de mot de passe à retenir, pas de mot de passe à réinitialiser six mois plus tard.

Et de notre côté : une liste d'adresses vérifiées de gens qui ont vraiment voulu lire. C'est plus court que n'importe quel formulaire, et infiniment plus honnête.

🔭