Anti-rejeu TOTP¶
Objectif : empêcher qu'un même code TOTP soit rejoué dans sa fenêtre de validité.
Ce que vous allez apprendre : un code reste valide ~30 s.
record_used marque une step (fenêtre de temps) consommée pour un facteur ; is_replay refuse ensuite sa réutilisation.
step_for_time calcule la step d'un instant.
Deuxième palier du niveau avancé de la progression MFA.
Module opt-in
Ce starter suppose forge-mvc-mfa installé.
État en mémoire, aucune base, aucune clé.
Ce que ce starter montre¶
step_for_time(time.time())→ la step courante ;record_used(factor_id, step)puisis_replay(factor_id, step)→ rejeu refusé ;- un état en mémoire (comme le rate-limit).
Classes Forge utilisées¶
| Classe / fonction | Rôle dans ce starter | Référence |
|---|---|---|
forge_mvc_mfa.step_for_time |
Calculer la step TOTP d'un instant. | MFA |
forge_mvc_mfa.record_used |
Marquer une step consommée pour un facteur. | MFA |
forge_mvc_mfa.is_replay |
Refuser une step déjà consommée. | MFA |
Tester¶
Ouvrez https://localhost:8000/mfa-replay et cliquez deux fois dans la même fenêtre (~30 s) : la seconde est refusée.
Le contrôleur¶
# mvc/controllers/mfa_replay_controller.py
import time
from core.http.request import Request
from core.http.response import Response
from core.mvc.controller.base_controller import BaseController
from forge_mvc_mfa import is_replay, record_used, step_for_time
_FACTOR_ID = 1
class MfaReplayController(BaseController):
"""Starter pédagogique : empêcher le rejeu d'un code TOTP."""
@staticmethod
def index(request: Request) -> Response:
step = step_for_time(time.time())
return BaseController.render(
"mfa_replay/index.html",
context={
"csrf_token": BaseController.csrf_token(request),
"step": step,
"replayed": is_replay(_FACTOR_ID, step),
},
request=request,
)
@staticmethod
def use(request: Request) -> Response:
step = step_for_time(time.time())
already = is_replay(_FACTOR_ID, step)
if not already:
record_used(_FACTOR_ID, step)
return BaseController.render(
"mfa_replay/index.html",
context={
"csrf_token": BaseController.csrf_token(request),
"step": step,
"accepted": not already,
"replayed": is_replay(_FACTOR_ID, step),
},
request=request,
)
La vue¶
<!-- mvc/views/mfa_replay/index.html -->
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Anti-rejeu TOTP - Forge</title>
</head>
<body>
<h1>Anti-rejeu TOTP</h1>
<p>Step TOTP courante : <code>{{ step }}</code></p>
<p>Déjà consommée pour le facteur démo : <strong>{% if replayed %}oui{% else %}non{% endif %}</strong></p>
{% if accepted is defined %}
{% if accepted %}
<p data-level="success">✓ Step consommée maintenant - toute réutilisation sera refusée.</p>
{% else %}
<p data-level="error">✗ Step déjà consommée : rejeu refusé.</p>
{% endif %}
{% endif %}
<form method="post" action="/mfa-replay">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Consommer la step courante</button>
</form>
<p>Cliquez deux fois dans la même fenêtre (~30 s) : la seconde est refusée.</p>
</body>
</html>
La route¶
# mvc/routes/__init__.py
from mvc.controllers.mfa_replay_controller import MfaReplayController
with router.group("", public=True) as public:
public.add("GET", "/mfa-replay", MfaReplayController.index, name="mfa_replay_index")
public.add("POST", "/mfa-replay", MfaReplayController.use, name="mfa_replay_use")
Comprendre ce code¶
- Sans anti-rejeu, un attaquant interceptant un code valide pourrait le rejouer dans les ~30 s.
- On raisonne par step (numéro de fenêtre), pas par code : une step consommée est refusée pour ce facteur.
- L'état vit en mémoire, avec purge opportuniste des vieilles steps.
En mémoire veut dire par processus
Le registre vit dans la mémoire du processus.
Derrière un serveur à plusieurs workers, gunicorn typiquement, chaque worker a le sien, et un même code peut donc être accepté une fois par worker.
La fenêtre est courte, un code TOTP vivant trente secondes, et l'attaquant doit déjà détenir le code.
Mais en production multi-worker, cette garde est affaiblie.
Forge livre un registre partagé, adossé à la base, que l'application pose au démarrage en une ligne.
from forge_mvc_mfa import set_replay_store
from forge_mvc_mfa.replay_store_db import DbTotpReplayStore
set_replay_store(DbTotpReplayStore())
Sa table se provisionne par forge mfa:init puis forge migration:apply.
Le défaut ne change pas, le choix reste le vôtre selon votre modèle de menace.
À retenir¶
- Un code TOTP ne doit être accepté qu'une fois dans sa fenêtre.
record_used+is_replayportent cette garde, par facteur et par step.verify_totp_codeà lui seul ne protège pas du rejeu : d'où cette brique.- Le registre par défaut vaut par processus : en multi-worker, posez
DbTotpReplayStore.
Après ce starter¶
Dernier palier : protéger le secret lui-même, chiffré au repos.