Dev #4
@@ -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():
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user