Dev #4

Merged
w-vandal merged 16 commits from dev into main 2026-08-29 04:02:11 +00:00
12 changed files with 181 additions and 4 deletions
Showing only changes of commit 9efe119936 - Show all commits
+1
View File
@@ -24,6 +24,7 @@ import routes # noqa: F401 - enregistre toutes les routes sur `app`
from core import auth_guard # noqa: F401 - enregistre la garde de connexion (après les routes)
from core import csrf # noqa: F401 - enregistre csrf_token() comme variable globale Jinja
from core import csrf_guard # noqa: F401 - enregistre la vérification du jeton CSRF
from core import recovery_codes_flash # noqa: F401 - enregistre pop_recovery_codes() comme variable globale Jinja
def _open_browser():
+2
View File
@@ -19,6 +19,7 @@ from .totp_qrcode_svg import totp_provisioning_uri, totp_qrcode_svg
from .rate_limit import lockout_minutes_for, seconds_locked_remaining, lockout_message
from .record_failed_attempt import record_failed_attempt
from .reset_failed_attempts import reset_failed_attempts
from .recovery_codes import generate_recovery_codes, verify_recovery_code
__all__ = [
"users_db_path", "ensure_users_schema", "is_first_user",
@@ -29,4 +30,5 @@ __all__ = [
"totp_provisioning_uri", "totp_qrcode_svg",
"lockout_minutes_for", "seconds_locked_remaining", "lockout_message",
"record_failed_attempt", "reset_failed_attempts",
"generate_recovery_codes", "verify_recovery_code",
]
+15
View File
@@ -29,5 +29,20 @@ def ensure_users_schema():
conn.execute("ALTER TABLE _users ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0")
if "locked_until" not in cols:
conn.execute("ALTER TABLE _users ADD COLUMN locked_until TEXT")
# Codes de récupération 2FA (voir recovery_codes.py) : table séparée
# plutôt qu'une colonne JSON sur _users, pour pouvoir marquer/consommer
# chaque code UN PAR UN (used_at) sans avoir à relire/réécrire toute
# la liste à chaque connexion.
conn.execute(
"""
CREATE TABLE IF NOT EXISTS _recovery_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
code_hash TEXT NOT NULL,
used_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
"""
)
conn.commit()
conn.close()
+60
View File
@@ -0,0 +1,60 @@
import secrets
from werkzeug.security import check_password_hash, generate_password_hash
from .connection import connect
_CODE_COUNT = 10
def _format_code(raw):
return "-".join(raw[i:i + 4] for i in range(0, len(raw), 4))
def generate_recovery_codes(user_id):
"""(Re)génère les codes de récupération 2FA d'un utilisateur : un
nouvel appel invalide tout jeu de codes précédent (un seul jeu valide
à la fois, pour ne jamais avoir à deviner lesquels tiennent encore).
Les codes ne sont renvoyés EN CLAIR qu'ICI, à l'instant de leur
création — seul leur hash (werkzeug, comme les mots de passe) est
conservé en base, pour qu'une fuite de la base ne les rende pas
directement réutilisables."""
conn = connect()
conn.execute("DELETE FROM _recovery_codes WHERE user_id = ?", (user_id,))
codes = []
for _ in range(_CODE_COUNT):
code = _format_code(secrets.token_hex(6))
codes.append(code)
conn.execute(
"INSERT INTO _recovery_codes (user_id, code_hash) VALUES (?, ?)",
(user_id, generate_password_hash(code)),
)
conn.commit()
conn.close()
return codes
def verify_recovery_code(user_id, code):
"""Un code n'est utilisable qu'UNE SEULE FOIS (used_at) : consommé dès
qu'il sert à une connexion réussie, pour qu'un code intercepté une
fois (capture d'écran, historique du navigateur...) ne redonne pas un
accès permanent."""
code = (code or "").strip().lower()
if not code:
return False
conn = connect()
rows = conn.execute(
"SELECT id, code_hash FROM _recovery_codes WHERE user_id = ? AND used_at IS NULL",
(user_id,),
).fetchall()
for row in rows:
if check_password_hash(row["code_hash"], code):
conn.execute(
"UPDATE _recovery_codes SET used_at = CURRENT_TIMESTAMP WHERE id = ?",
(row["id"],),
)
conn.commit()
conn.close()
return True
conn.close()
return False
+15
View File
@@ -0,0 +1,15 @@
"""Affiche UNE SEULE FOIS les codes de récupération 2FA (voir
auth/recovery_codes.py et routes/auth/register_2fa.py) : posés en session
au moment de la redirection qui suit leur génération, puis consommés
(session.pop) dès le tout premier rendu de base.html qui suit — jamais
revus après ce rendu, comme un message flash à usage unique."""
from flask import session
from .flask_app import app
def pop_recovery_codes():
return session.pop("recovery_codes_to_show", None)
app.jinja_env.globals["pop_recovery_codes"] = pop_recovery_codes
+11 -1
View File
@@ -18,9 +18,19 @@ def login_2fa():
error = None
if request.method == "POST":
locked_seconds = auth.seconds_locked_remaining(user)
# Un code de récupération (format "xxxx-xxxx-xxxx") est accepté à la
# place du code TOTP habituel — utile si le téléphone est perdu/hors
# service (voir auth/recovery_codes.py). verify_totp est essayé en
# premier : verify_recovery_code CONSOMME le code dès qu'il
# correspond, on ne veut donc pas en griller un pour rien sur une
# saisie qui aurait en fait été un TOTP valide.
code = request.form.get("code", "")
valid = not locked_seconds and (
auth.verify_totp(user["totp_secret"], code) or auth.verify_recovery_code(user_id, code)
)
if locked_seconds:
error = auth.lockout_message(locked_seconds)
elif not auth.verify_totp(user["totp_secret"], request.form.get("code", "")):
elif not valid:
just_locked_minutes = auth.record_failed_attempt(user_id)
error = auth.lockout_message(just_locked_minutes * 60) if just_locked_minutes else "Code invalide."
else:
+8
View File
@@ -40,6 +40,14 @@ def register_2fa():
user = auth.get_user_by_id(user_id)
session.pop("pending_register_user_id", None)
session["user_id"] = user_id
# Générés ici, une seule fois, à l'instant même où la 2FA
# devient active (voir auth/recovery_codes.py) : c'est la SEULE
# occasion où ils sont visibles en clair (seul leur hash reste
# en base) — posés en session pour être affichés une unique
# fois par la page suivante (voir core/recovery_codes_flash.py
# et le modal dans base.html), plutôt que d'interrompre ici la
# redirection habituelle vers le tableau de bord/l'accueil.
session["recovery_codes_to_show"] = auth.generate_recovery_codes(user_id)
if user["role"] == "admin":
return redirect(url_for("index"))
return redirect(url_for("game_dashboard", slug=user["project_slug"]))
+3
View File
@@ -588,3 +588,6 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
/* ---------- Inscription : QR code de la double authentification ---------- */
.totpQrWrap{ background:#fff; padding:16px; border-radius:8px; display:inline-block; max-width:220px; }
.totpQrWrap svg{ display:block; width:100%; height:auto; }
.recoveryCodesGrid{ display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.recoveryCode{ display:block; text-align:center; padding:8px; background:#161616; border-radius:6px; letter-spacing:1px; }
+3 -3
View File
@@ -4,7 +4,7 @@
<section class="section">
<div class="container" style="max-width:420px;">
<h1 class="title">Code de vérification</h1>
<p class="hint mb-4">Ouvre ton application d'authentification et saisis le code à 6 chiffres affiché.</p>
<p class="hint mb-4">Ouvre ton application d'authentification et saisis le code à 6 chiffres affiché, ou un de tes codes de récupération si tu n'y as plus accès.</p>
{% if error %}
<div class="notification is-danger">{{ error }}</div>
@@ -12,8 +12,8 @@
<form method="post" action="{{ url_for('login_2fa') }}">
<div class="field">
<label class="label">Code à 6 chiffres</label>
<div class="control"><input class="input" type="text" name="code" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" required autofocus></div>
<label class="label">Code à 6 chiffres ou code de récupération</label>
<div class="control"><input class="input" type="text" name="code" autocomplete="one-time-code" required autofocus></div>
</div>
<div class="field mt-4">
<button type="submit" class="button primary is-fullwidth">Se connecter</button>
+18
View File
@@ -22,6 +22,24 @@
ne rechargeait pas la page en entier (Ctrl+F5), et inversement
laissée en place en revenant sur l'accueil (liste des jeux) où elle
n'a pourtant rien à faire. -->
{% set recovery_codes = pop_recovery_codes() %}
{% if recovery_codes %}
<div class="modal is-active" id="recoveryCodesModal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box">
<h2 class="title is-4">Tes codes de récupération</h2>
<p class="hint mb-4">Note ces 10 codes en sécurité (gestionnaire de mots de passe, papier...) : chacun ne fonctionne qu'une seule fois et permet de te connecter si tu perds l'accès à ton application d'authentification. Ils ne seront plus jamais affichés.</p>
<div class="recoveryCodesGrid">
{% for code in recovery_codes %}
<code class="recoveryCode">{{ code }}</code>
{% endfor %}
</div>
<button type="button" class="button primary is-fullwidth mt-4" onclick="document.getElementById('recoveryCodesModal').remove()">J'ai noté mes codes</button>
</div>
</div>
</div>
{% endif %}
<div id="pageChrome">
<nav class="navbar topbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
+1
View File
@@ -33,6 +33,7 @@ import routes # noqa: F401 - enregistre toutes les routes sur flask_app
from core import auth_guard # noqa: F401 - enregistre la garde de connexion (après les routes)
from core import csrf # noqa: F401 - enregistre csrf_token() comme variable globale Jinja
from core import csrf_guard # noqa: F401 - enregistre la vérification du jeton CSRF (désactivée en TESTING, voir test_csrf.py)
from core import recovery_codes_flash # noqa: F401 - enregistre pop_recovery_codes() comme variable globale Jinja
import auth
import db
+44
View File
@@ -263,6 +263,50 @@ def test_successful_login_resets_the_failed_attempts_counter(anon_client):
_cleanup_project("resetcounter@example.com")
def test_recovery_codes_are_shown_once_after_confirming_2fa(anon_client):
_register(anon_client, "recoveryshown@example.com")
resp, secret = _confirm_2fa(anon_client)
try:
html = anon_client.get(resp.headers["Location"]).get_data(as_text=True)
assert "recoveryCodesModal" in html
# Jamais réaffichés sur un chargement suivant (jeton flash à usage
# unique, voir core/recovery_codes_flash.py).
html_again = anon_client.get(resp.headers["Location"]).get_data(as_text=True)
assert "recoveryCodesModal" not in html_again
finally:
_cleanup_project("recoveryshown@example.com")
def test_login_with_a_recovery_code_instead_of_totp(anon_client):
_register(anon_client, "recoverylogin@example.com")
_confirm_2fa(anon_client)
user = auth.get_user_by_email("recoverylogin@example.com")
slug = user["project_slug"]
codes = auth.generate_recovery_codes(user["id"])
try:
anon_client.post("/logout")
anon_client.post("/login", data={"email": "recoverylogin@example.com", "password": "Sup3r$ecret!"})
resp = anon_client.post("/login/2fa", data={"code": codes[0]}, follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["Location"] == "/game/" + slug
# Un code déjà utilisé ne fonctionne plus une seconde fois.
anon_client.post("/logout")
anon_client.post("/login", data={"email": "recoverylogin@example.com", "password": "Sup3r$ecret!"})
resp = anon_client.post("/login/2fa", data={"code": codes[0]})
assert "Code invalide" in resp.get_data(as_text=True)
# Un autre code du même lot, lui, fonctionne toujours.
anon_client.post("/logout")
anon_client.post("/login", data={"email": "recoverylogin@example.com", "password": "Sup3r$ecret!"})
resp = anon_client.post("/login/2fa", data={"code": codes[1]}, follow_redirects=False)
assert resp.status_code == 302
finally:
_cleanup_project("recoverylogin@example.com")
def test_admin_role_is_not_restricted_to_a_single_project(client, game):
"""`client`/`game` (conftest.py) sont déjà connectés en tant qu'admin
de la session de tests — la création d'un second jeu, déjà exercée par