Fermer une page à clé, sans compte ni mot de passe
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.
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]
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)
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.
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.