23 tâches TDD : setup deps/env, auth/db, tokens, models, mailer, init_auth, 6 routes Flask, 6 pages Dash, navbar, tests CSRF, smoke test end-to-end. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
82 KiB
Comptes utilisateurs — Plan d'implémentation
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implémenter les fondations des comptes utilisateurs (inscription + vérification email, connexion, reset password, page compte) pour decp.info.
Architecture: Nouveau package src/auth/ intégré à l'app Flask sous-jacente de Dash. Routes Flask natives pour les actions (POST), pages Dash pour le rendu (formulaires HTML). SQLite + sqlite3 stdlib pour la persistance utilisateurs. Flask-Login pour les sessions, Flask-Mail pour SMTP, Flask-WTF pour CSRF. Hashage via werkzeug.security.
Tech Stack: Flask, Flask-Login, Flask-Mail, Flask-WTF, werkzeug.security, sqlite3, Dash, dash-bootstrap-components, pytest.
Spec de référence : docs/superpowers/specs/2026-04-20-comptes-utilisateurs-design.md.
Vue d'ensemble des fichiers
Créés :
src/auth/__init__.py— exports publicssrc/auth/db.py— SQLite (schéma + CRUD users + CRUD tokens)src/auth/models.py— classeUserFlask-Loginsrc/auth/tokens.py— génération/validation tokenssrc/auth/mailer.py— envoi d'emailssrc/auth/setup.py—init_auth(app)+ helpers (safe_next)src/auth/routes.py— routes Flask (/auth/*)src/auth/templates/emails/verify_email.htmlsrc/auth/templates/emails/verify_email.txtsrc/auth/templates/emails/reset_password.htmlsrc/auth/templates/emails/reset_password.txtsrc/pages/connexion.pysrc/pages/inscription.pysrc/pages/compte.pysrc/pages/mot_de_passe_oublie.pysrc/pages/reinitialiser_mot_de_passe.pysrc/pages/verification_email.pytests/auth/__init__.pytests/auth/conftest.pytests/auth/test_db.pytests/auth/test_tokens.pytests/auth/test_mailer.pytests/auth/test_setup.pytests/auth/test_signup.pytests/auth/test_verify_email.pytests/auth/test_login.pytests/auth/test_password_reset.pytests/auth/test_account.pytests/auth/test_csrf.py
Modifiés :
pyproject.toml— dépendances + variables d'env tests.template.env— nouvelles variables d'environnementsrc/app.py— ajoutinit_auth(app)+ mise à jour navbar
Task 1 : Dépendances et variables d'environnement
Files:
-
Modify:
pyproject.toml -
Modify:
.template.env -
Step 1 : Ajouter les dépendances dans
pyproject.toml
Dans la section dependencies, ajouter :
"flask-login",
"flask-mail",
"flask-wtf",
"email-validator",
- Step 2 : Ajouter les variables d'env de tests dans
pyproject.toml
Dans [tool.pytest.ini_options].env, ajouter :
"USERS_DB_PATH=tests/users.test.sqlite",
"SECRET_KEY=test-secret-do-not-use-in-prod",
"MAIL_FROM=test@decp.info",
"APP_BASE_URL=http://localhost:8050",
"SMTP_HOST=localhost",
"SMTP_PORT=25",
"WTF_CSRF_ENABLED=False",
- Step 3 : Compléter
.template.env
Ajouter à la fin du fichier :
# Comptes utilisateurs
USERS_DB_PATH=users.sqlite
SECRET_KEY= # à générer : python -c "import secrets; print(secrets.token_hex(32))"
APP_BASE_URL=http://localhost:8050
# SMTP pour envoi d'emails (vérification email, reset mot de passe)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_USE_TLS=True
MAIL_FROM=noreply@decp.info
- Step 4 : Installer les dépendances
Run: uv sync
Expected : installation de flask-login, flask-mail, flask-wtf, email-validator.
- Step 5 : Commit
git add pyproject.toml .template.env uv.lock
git commit -m "Dépendances auth : Flask-Login/Mail/WTF, email-validator (#73)"
Task 2 : Module src/auth/db.py — schéma et PRAGMAs
Files:
-
Create:
src/auth/__init__.py -
Create:
src/auth/db.py -
Create:
tests/auth/__init__.py -
Create:
tests/auth/conftest.py -
Create:
tests/auth/test_db.py -
Step 1 : Créer
src/auth/__init__.pyvide
- Step 2 : Créer
tests/auth/__init__.pyvide
- Step 3 : Écrire la fixture DB dans
tests/auth/conftest.py
import os
import tempfile
from pathlib import Path
import pytest
@pytest.fixture
def users_db_path(monkeypatch, tmp_path):
db_path = tmp_path / "users.test.sqlite"
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
yield db_path
- Step 4 : Écrire les tests du schéma dans
tests/auth/test_db.py
import sqlite3
from src.auth.db import get_conn, init_schema
def test_init_schema_creates_tables(users_db_path):
init_schema()
conn = get_conn()
tables = {
row[0]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
}
assert {"users", "email_verification_tokens", "password_reset_tokens"} <= tables
def test_init_schema_is_idempotent(users_db_path):
init_schema()
init_schema()
conn = get_conn()
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
names = [r[0] for r in tables]
assert names.count("users") == 1
def test_pragmas_active(users_db_path):
init_schema()
conn = get_conn()
assert conn.execute("PRAGMA foreign_keys").fetchone()[0] == 1
assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal"
- Step 5 : Lancer les tests (ils doivent échouer)
Run: rtk uv run pytest tests/auth/test_db.py -v
Expected : ModuleNotFoundError: No module named 'src.auth.db'.
- Step 6 : Implémenter
src/auth/db.py— schéma et connexion
import os
import sqlite3
from pathlib import Path
from threading import Lock
_conn: sqlite3.Connection | None = None
_conn_lock = Lock()
USERS_SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
email_verified INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE TABLE IF NOT EXISTS email_verification_tokens (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
"""
def _db_path() -> Path:
return Path(os.getenv("USERS_DB_PATH", "users.sqlite"))
def get_conn() -> sqlite3.Connection:
global _conn
with _conn_lock:
if _conn is None:
_conn = sqlite3.connect(
str(_db_path()), check_same_thread=False, isolation_level=None
)
_conn.row_factory = sqlite3.Row
_conn.execute("PRAGMA foreign_keys = ON")
_conn.execute("PRAGMA journal_mode = WAL")
return _conn
def reset_conn_for_tests() -> None:
global _conn
with _conn_lock:
if _conn is not None:
_conn.close()
_conn = None
def init_schema() -> None:
conn = get_conn()
conn.executescript(USERS_SCHEMA)
- Step 7 : Ajouter la réinitialisation de connexion entre tests
Dans tests/auth/conftest.py, mettre à jour la fixture :
import os
import tempfile
from pathlib import Path
import pytest
@pytest.fixture
def users_db_path(monkeypatch, tmp_path):
from src.auth.db import reset_conn_for_tests
db_path = tmp_path / "users.test.sqlite"
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
reset_conn_for_tests()
yield db_path
reset_conn_for_tests()
- Step 8 : Relancer les tests (ils doivent passer)
Run: rtk uv run pytest tests/auth/test_db.py -v
Expected : 3 passed.
- Step 9 : Commit
git add src/auth/__init__.py src/auth/db.py tests/auth/__init__.py tests/auth/conftest.py tests/auth/test_db.py
git commit -m "src/auth/db.py : schéma SQLite et connexion (#73)"
Task 3 : CRUD utilisateurs dans src/auth/db.py
Files:
-
Modify:
src/auth/db.py -
Modify:
tests/auth/test_db.py -
Step 1 : Ajouter les tests CRUD users à
tests/auth/test_db.py
import pytest
from src.auth.db import (
create_user,
get_conn,
get_user_by_email,
get_user_by_id,
init_schema,
set_email_verified,
update_password_hash,
)
def test_create_user_and_get_by_email(users_db_path):
init_schema()
user_id = create_user("alice@example.com", "hash-bidon")
assert user_id > 0
row = get_user_by_email("alice@example.com")
assert row is not None
assert row["email"] == "alice@example.com"
assert row["email_verified"] == 0
def test_email_is_lowercased(users_db_path):
init_schema()
create_user("Alice@Example.COM", "hash")
row = get_user_by_email("alice@example.com")
assert row is not None
row_upper = get_user_by_email("ALICE@example.com")
assert row_upper is not None
assert row["id"] == row_upper["id"]
def test_duplicate_email_raises(users_db_path):
init_schema()
create_user("alice@example.com", "hash")
with pytest.raises(Exception):
create_user("alice@example.com", "autre")
def test_get_user_by_id(users_db_path):
init_schema()
uid = create_user("a@b.c", "h")
row = get_user_by_id(uid)
assert row["email"] == "a@b.c"
assert get_user_by_id(999999) is None
def test_set_email_verified(users_db_path):
init_schema()
uid = create_user("a@b.c", "h")
assert get_user_by_id(uid)["email_verified"] == 0
set_email_verified(uid)
assert get_user_by_id(uid)["email_verified"] == 1
def test_update_password_hash(users_db_path):
init_schema()
uid = create_user("a@b.c", "old")
update_password_hash(uid, "new")
assert get_user_by_id(uid)["password_hash"] == "new"
- Step 2 : Lancer les tests (ils doivent échouer)
Run: rtk uv run pytest tests/auth/test_db.py -v
Expected : ImportError sur les nouvelles fonctions.
- Step 3 : Implémenter les CRUD users dans
src/auth/db.py
Ajouter à la fin du fichier :
from datetime import datetime, timezone
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def create_user(email: str, password_hash: str) -> int:
conn = get_conn()
now = _now()
cur = conn.execute(
"INSERT INTO users (email, password_hash, email_verified, created_at, updated_at) "
"VALUES (?, ?, 0, ?, ?)",
(email.lower(), password_hash, now, now),
)
return cur.lastrowid
def get_user_by_email(email: str) -> sqlite3.Row | None:
return get_conn().execute(
"SELECT * FROM users WHERE email = ?", (email.lower(),)
).fetchone()
def get_user_by_id(user_id: int) -> sqlite3.Row | None:
return get_conn().execute(
"SELECT * FROM users WHERE id = ?", (user_id,)
).fetchone()
def set_email_verified(user_id: int) -> None:
get_conn().execute(
"UPDATE users SET email_verified = 1, updated_at = ? WHERE id = ?",
(_now(), user_id),
)
def update_password_hash(user_id: int, password_hash: str) -> None:
get_conn().execute(
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?",
(password_hash, _now(), user_id),
)
def delete_user(user_id: int) -> None:
get_conn().execute("DELETE FROM users WHERE id = ?", (user_id,))
- Step 4 : Relancer les tests
Run: rtk uv run pytest tests/auth/test_db.py -v
Expected : 9 passed.
- Step 5 : Commit
git add src/auth/db.py tests/auth/test_db.py
git commit -m "src/auth/db.py : CRUD users (#73)"
Task 4 : CRUD tokens dans src/auth/db.py
Files:
-
Modify:
src/auth/db.py -
Modify:
tests/auth/test_db.py -
Step 1 : Ajouter les tests tokens à
tests/auth/test_db.py
from datetime import datetime, timedelta, timezone
from src.auth.db import (
create_email_verification_token,
create_password_reset_token,
delete_email_verification_tokens_for_user,
delete_password_reset_tokens_for_user,
find_email_verification_token,
find_password_reset_token,
purge_expired_tokens,
)
def _future(hours: int = 1) -> str:
return (datetime.now(timezone.utc) + timedelta(hours=hours)).isoformat()
def _past(hours: int = 1) -> str:
return (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
def test_email_verification_token_roundtrip(users_db_path):
init_schema()
uid = create_user("a@b.c", "h")
create_email_verification_token("hashed-token", uid, _future(24))
row = find_email_verification_token("hashed-token")
assert row is not None
assert row["user_id"] == uid
def test_password_reset_token_roundtrip(users_db_path):
init_schema()
uid = create_user("a@b.c", "h")
create_password_reset_token("reset-hash", uid, _future(1))
row = find_password_reset_token("reset-hash")
assert row is not None
assert row["user_id"] == uid
def test_delete_tokens_for_user(users_db_path):
init_schema()
uid = create_user("a@b.c", "h")
create_email_verification_token("t1", uid, _future(1))
create_email_verification_token("t2", uid, _future(1))
delete_email_verification_tokens_for_user(uid)
assert find_email_verification_token("t1") is None
assert find_email_verification_token("t2") is None
def test_delete_password_reset_tokens_for_user(users_db_path):
init_schema()
uid = create_user("a@b.c", "h")
create_password_reset_token("r1", uid, _future(1))
delete_password_reset_tokens_for_user(uid)
assert find_password_reset_token("r1") is None
def test_purge_expired_tokens(users_db_path):
init_schema()
uid = create_user("a@b.c", "h")
create_email_verification_token("live", uid, _future(1))
create_email_verification_token("expired", uid, _past(1))
create_password_reset_token("live-r", uid, _future(1))
create_password_reset_token("expired-r", uid, _past(1))
purge_expired_tokens()
assert find_email_verification_token("live") is not None
assert find_email_verification_token("expired") is None
assert find_password_reset_token("live-r") is not None
assert find_password_reset_token("expired-r") is None
def test_cascade_delete_on_user_delete(users_db_path):
init_schema()
uid = create_user("a@b.c", "h")
create_email_verification_token("t", uid, _future(1))
delete_user(uid)
assert find_email_verification_token("t") is None
- Step 2 : Lancer les tests (ils doivent échouer)
Run: rtk uv run pytest tests/auth/test_db.py -v
Expected : ImportError.
- Step 3 : Implémenter les CRUD tokens dans
src/auth/db.py
Ajouter à la fin du fichier :
def create_email_verification_token(
token_hash: str, user_id: int, expires_at: str
) -> None:
get_conn().execute(
"INSERT INTO email_verification_tokens (token_hash, user_id, expires_at, created_at) "
"VALUES (?, ?, ?, ?)",
(token_hash, user_id, expires_at, _now()),
)
def find_email_verification_token(token_hash: str) -> sqlite3.Row | None:
return get_conn().execute(
"SELECT * FROM email_verification_tokens "
"WHERE token_hash = ? AND expires_at > ?",
(token_hash, _now()),
).fetchone()
def delete_email_verification_tokens_for_user(user_id: int) -> None:
get_conn().execute(
"DELETE FROM email_verification_tokens WHERE user_id = ?", (user_id,)
)
def create_password_reset_token(
token_hash: str, user_id: int, expires_at: str
) -> None:
get_conn().execute(
"INSERT INTO password_reset_tokens (token_hash, user_id, expires_at, created_at) "
"VALUES (?, ?, ?, ?)",
(token_hash, user_id, expires_at, _now()),
)
def find_password_reset_token(token_hash: str) -> sqlite3.Row | None:
return get_conn().execute(
"SELECT * FROM password_reset_tokens "
"WHERE token_hash = ? AND expires_at > ?",
(token_hash, _now()),
).fetchone()
def delete_password_reset_tokens_for_user(user_id: int) -> None:
get_conn().execute(
"DELETE FROM password_reset_tokens WHERE user_id = ?", (user_id,)
)
def purge_expired_tokens() -> None:
now = _now()
conn = get_conn()
conn.execute(
"DELETE FROM email_verification_tokens WHERE expires_at <= ?", (now,)
)
conn.execute(
"DELETE FROM password_reset_tokens WHERE expires_at <= ?", (now,)
)
- Step 4 : Relancer les tests
Run: rtk uv run pytest tests/auth/test_db.py -v
Expected : 15 passed.
- Step 5 : Commit
git add src/auth/db.py tests/auth/test_db.py
git commit -m "src/auth/db.py : CRUD tokens de vérification et reset (#73)"
Task 5 : Module src/auth/tokens.py
Files:
-
Create:
src/auth/tokens.py -
Create:
tests/auth/test_tokens.py -
Step 1 : Écrire les tests dans
tests/auth/test_tokens.py
from datetime import datetime, timedelta, timezone
from src.auth import db
from src.auth.tokens import (
consume_password_reset_token,
consume_verification_token,
create_password_reset_token,
create_verification_token,
hash_token,
)
def test_hash_token_is_stable():
t = "abc123"
assert hash_token(t) == hash_token(t)
assert hash_token(t) != hash_token("abc124")
assert len(hash_token(t)) == 64 # sha256 hex
def test_create_verification_token_returns_plain_token(users_db_path):
db.init_schema()
uid = db.create_user("a@b.c", "h")
plain = create_verification_token(uid)
assert isinstance(plain, str)
assert len(plain) >= 32
# stocké en DB sous forme hashée
row = db.find_email_verification_token(hash_token(plain))
assert row is not None
assert row["user_id"] == uid
def test_consume_verification_token_succeeds_once(users_db_path):
db.init_schema()
uid = db.create_user("a@b.c", "h")
plain = create_verification_token(uid)
user_id = consume_verification_token(plain)
assert user_id == uid
# usage unique : les tokens de cet user sont supprimés
assert consume_verification_token(plain) is None
def test_consume_invalid_verification_token(users_db_path):
db.init_schema()
assert consume_verification_token("n-existe-pas") is None
def test_verification_token_expires(users_db_path, monkeypatch):
db.init_schema()
uid = db.create_user("a@b.c", "h")
plain = create_verification_token(uid, expires_in_hours=-1)
assert consume_verification_token(plain) is None
def test_create_password_reset_token_deletes_previous(users_db_path):
db.init_schema()
uid = db.create_user("a@b.c", "h")
old = create_password_reset_token(uid)
new = create_password_reset_token(uid)
# l'ancien a été supprimé
assert consume_password_reset_token(old) is None
# le nouveau fonctionne
assert consume_password_reset_token(new) == uid
def test_consume_password_reset_token_is_single_use(users_db_path):
db.init_schema()
uid = db.create_user("a@b.c", "h")
plain = create_password_reset_token(uid)
assert consume_password_reset_token(plain) == uid
assert consume_password_reset_token(plain) is None
- Step 2 : Lancer les tests (ils doivent échouer)
Run: rtk uv run pytest tests/auth/test_tokens.py -v
Expected : ModuleNotFoundError.
- Step 3 : Implémenter
src/auth/tokens.py
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from src.auth import db
VERIFICATION_TTL_HOURS = 24
RESET_TTL_HOURS = 1
def hash_token(plain: str) -> str:
return hashlib.sha256(plain.encode("utf-8")).hexdigest()
def _expires_at(hours: int) -> str:
return (datetime.now(timezone.utc) + timedelta(hours=hours)).isoformat()
def create_verification_token(
user_id: int, expires_in_hours: int = VERIFICATION_TTL_HOURS
) -> str:
plain = secrets.token_urlsafe(32)
db.create_email_verification_token(
hash_token(plain), user_id, _expires_at(expires_in_hours)
)
return plain
def consume_verification_token(plain: str) -> int | None:
row = db.find_email_verification_token(hash_token(plain))
if row is None:
return None
user_id = row["user_id"]
db.delete_email_verification_tokens_for_user(user_id)
return user_id
def create_password_reset_token(
user_id: int, expires_in_hours: int = RESET_TTL_HOURS
) -> str:
db.delete_password_reset_tokens_for_user(user_id)
plain = secrets.token_urlsafe(32)
db.create_password_reset_token(
hash_token(plain), user_id, _expires_at(expires_in_hours)
)
return plain
def consume_password_reset_token(plain: str) -> int | None:
row = db.find_password_reset_token(hash_token(plain))
if row is None:
return None
user_id = row["user_id"]
db.delete_password_reset_tokens_for_user(user_id)
return user_id
def validate_password_reset_token(plain: str) -> int | None:
"""Check token without consuming (used to render the reset form)."""
row = db.find_password_reset_token(hash_token(plain))
return row["user_id"] if row else None
- Step 4 : Relancer les tests
Run: rtk uv run pytest tests/auth/test_tokens.py -v
Expected : 7 passed.
- Step 5 : Commit
git add src/auth/tokens.py tests/auth/test_tokens.py
git commit -m "src/auth/tokens.py : génération et validation des tokens (#73)"
Task 6 : Modèle User Flask-Login
Files:
-
Create:
src/auth/models.py -
Modify:
tests/auth/test_db.py(ou nouveautest_models.py) -
Step 1 : Écrire un test minimal dans un nouveau fichier
tests/auth/test_models.py
from src.auth import db
from src.auth.models import User, load_user
def test_user_from_row(users_db_path):
db.init_schema()
uid = db.create_user("a@b.c", "hash")
db.set_email_verified(uid)
row = db.get_user_by_id(uid)
user = User(row)
assert user.id == uid
assert user.email == "a@b.c"
assert user.is_authenticated is True
assert user.is_active is True
assert user.is_anonymous is False
assert user.get_id() == str(uid)
def test_load_user_returns_none_if_missing(users_db_path):
db.init_schema()
assert load_user("999999") is None
def test_load_user_returns_user(users_db_path):
db.init_schema()
uid = db.create_user("a@b.c", "h")
user = load_user(str(uid))
assert user is not None
assert user.id == uid
- Step 2 : Lancer les tests (ils doivent échouer)
Run: rtk uv run pytest tests/auth/test_models.py -v
Expected : ModuleNotFoundError.
- Step 3 : Implémenter
src/auth/models.py
import sqlite3
from src.auth import db
class User:
def __init__(self, row: sqlite3.Row):
self.id: int = row["id"]
self.email: str = row["email"]
self.email_verified: bool = bool(row["email_verified"])
@property
def is_authenticated(self) -> bool:
return True
@property
def is_active(self) -> bool:
return True
@property
def is_anonymous(self) -> bool:
return False
def get_id(self) -> str:
return str(self.id)
def load_user(user_id: str) -> User | None:
try:
uid = int(user_id)
except (TypeError, ValueError):
return None
row = db.get_user_by_id(uid)
return User(row) if row else None
- Step 4 : Relancer les tests
Run: rtk uv run pytest tests/auth/test_models.py -v
Expected : 3 passed.
- Step 5 : Commit
git add src/auth/models.py tests/auth/test_models.py
git commit -m "src/auth/models.py : classe User Flask-Login (#73)"
Task 7 : Module src/auth/mailer.py et templates
Files:
-
Create:
src/auth/mailer.py -
Create:
src/auth/templates/emails/verify_email.html -
Create:
src/auth/templates/emails/verify_email.txt -
Create:
src/auth/templates/emails/reset_password.html -
Create:
src/auth/templates/emails/reset_password.txt -
Create:
tests/auth/test_mailer.py -
Step 1 : Créer les 4 templates d'email
src/auth/templates/emails/verify_email.txt :
Bonjour,
Pour vérifier votre adresse email et activer votre compte decp.info, ouvrez le lien suivant :
{{ link }}
Ce lien est valide pendant 24 heures.
Si vous n'êtes pas à l'origine de cette inscription, ignorez cet email.
— decp.info
src/auth/templates/emails/verify_email.html :
<!DOCTYPE html>
<html lang="fr">
<body style="font-family: sans-serif; max-width: 600px; margin: auto;">
<h2 style="color: #333;">decp.info</h2>
<p>Bonjour,</p>
<p>
Pour vérifier votre adresse email et activer votre compte decp.info,
cliquez sur le lien ci-dessous :
</p>
<p><a href="{{ link }}">Vérifier mon adresse email</a></p>
<p style="color: #666;">
Ou copiez cette URL dans votre navigateur : {{ link }}
</p>
<p><em>Ce lien est valide pendant 24 heures.</em></p>
<p>
Si vous n'êtes pas à l'origine de cette inscription, ignorez cet email.
</p>
<p>— decp.info</p>
</body>
</html>
src/auth/templates/emails/reset_password.txt :
Bonjour,
Vous avez demandé la réinitialisation de votre mot de passe decp.info. Pour choisir un nouveau mot de passe, ouvrez le lien suivant :
{{ link }}
Ce lien est valide pendant 1 heure.
Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.
— decp.info
src/auth/templates/emails/reset_password.html :
<!DOCTYPE html>
<html lang="fr">
<body style="font-family: sans-serif; max-width: 600px; margin: auto;">
<h2 style="color: #333;">decp.info</h2>
<p>Bonjour,</p>
<p>
Vous avez demandé la réinitialisation de votre mot de passe decp.info.
Pour choisir un nouveau mot de passe, cliquez sur le lien ci-dessous :
</p>
<p><a href="{{ link }}">Réinitialiser mon mot de passe</a></p>
<p style="color: #666;">
Ou copiez cette URL dans votre navigateur : {{ link }}
</p>
<p><em>Ce lien est valide pendant 1 heure.</em></p>
<p>Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.</p>
<p>— decp.info</p>
</body>
</html>
- Step 2 : Écrire les tests du mailer dans
tests/auth/test_mailer.py
import pytest
from flask import Flask
from flask_mail import Mail
from src.auth import mailer
@pytest.fixture
def mail_app(monkeypatch, tmp_path):
app = Flask(
__name__,
template_folder=str(
(__import__("pathlib").Path(mailer.__file__).parent / "templates").resolve()
),
)
app.config["MAIL_SUPPRESS_SEND"] = True
app.config["MAIL_DEFAULT_SENDER"] = "noreply@decp.info"
app.config["TESTING"] = True
mail = Mail(app)
monkeypatch.setattr(mailer, "_mail", mail)
monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050")
return app
def test_send_verification_email(mail_app):
with mail_app.app_context(), mail_app.test_request_context():
with mail_app.extensions["mail"].record_messages() as outbox:
mailer.send_verification_email("a@b.c", "TOKEN123")
assert len(outbox) == 1
msg = outbox[0]
assert msg.recipients == ["a@b.c"]
assert "verification-email?token=TOKEN123" in msg.body
assert "verification-email?token=TOKEN123" in msg.html
def test_send_reset_email(mail_app):
with mail_app.app_context(), mail_app.test_request_context():
with mail_app.extensions["mail"].record_messages() as outbox:
mailer.send_reset_email("a@b.c", "RESET456")
assert len(outbox) == 1
msg = outbox[0]
assert "reinitialiser-mot-de-passe?token=RESET456" in msg.body
- Step 3 : Lancer les tests (échouent)
Run: rtk uv run pytest tests/auth/test_mailer.py -v
Expected : ModuleNotFoundError.
- Step 4 : Implémenter
src/auth/mailer.py
import os
from pathlib import Path
from flask import Flask, render_template
from flask_mail import Mail, Message
TEMPLATES_DIR = Path(__file__).parent / "templates"
_mail: Mail | None = None
def init_mailer(app: Flask) -> None:
global _mail
app.config["MAIL_SERVER"] = os.getenv("SMTP_HOST", "")
app.config["MAIL_PORT"] = int(os.getenv("SMTP_PORT", "587"))
app.config["MAIL_USERNAME"] = os.getenv("SMTP_USERNAME") or None
app.config["MAIL_PASSWORD"] = os.getenv("SMTP_PASSWORD") or None
app.config["MAIL_USE_TLS"] = (
os.getenv("SMTP_USE_TLS", "True").lower() == "true"
)
app.config["MAIL_DEFAULT_SENDER"] = os.getenv(
"MAIL_FROM", "noreply@decp.info"
)
app.config["MAIL_SUPPRESS_SEND"] = (
os.getenv("DEVELOPMENT", "False").lower() == "true"
)
# Inclure les templates du package auth dans la recherche Jinja
app.jinja_loader.searchpath.append(str(TEMPLATES_DIR))
_mail = Mail(app)
def _base_url() -> str:
return os.getenv("APP_BASE_URL", "http://localhost:8050").rstrip("/")
def _send(subject: str, recipient: str, txt_template: str, html_template: str, **ctx) -> None:
assert _mail is not None, "Mailer non initialisé (init_mailer(app) non appelé)"
msg = Message(subject=subject, recipients=[recipient])
msg.body = render_template(txt_template, **ctx)
msg.html = render_template(html_template, **ctx)
_mail.send(msg)
def send_verification_email(email: str, token: str) -> None:
link = f"{_base_url()}/verification-email?token={token}"
_send(
"Vérification de votre adresse email — decp.info",
email,
"emails/verify_email.txt",
"emails/verify_email.html",
link=link,
)
def send_reset_email(email: str, token: str) -> None:
link = f"{_base_url()}/reinitialiser-mot-de-passe?token={token}"
_send(
"Réinitialisation de votre mot de passe — decp.info",
email,
"emails/reset_password.txt",
"emails/reset_password.html",
link=link,
)
- Step 5 : Relancer les tests
Run: rtk uv run pytest tests/auth/test_mailer.py -v
Expected : 2 passed.
- Step 6 : Commit
git add src/auth/mailer.py src/auth/templates tests/auth/test_mailer.py
git commit -m "src/auth/mailer.py : envoi d'emails HTML+texte (#73)"
Task 8 : Setup init_auth et helper safe_next
Files:
-
Create:
src/auth/setup.py -
Create:
tests/auth/test_setup.py -
Step 1 : Écrire les tests dans
tests/auth/test_setup.py
import pytest
from flask import Flask
from flask_login import current_user
from src.auth.setup import init_auth, safe_next
def test_safe_next_allows_relative_path():
assert safe_next("/acheteur?id=1") == "/acheteur?id=1"
def test_safe_next_rejects_absolute_urls():
assert safe_next("https://evil.com/phish") == "/"
def test_safe_next_rejects_protocol_relative():
assert safe_next("//evil.com") == "/"
def test_safe_next_rejects_empty():
assert safe_next("") == "/"
assert safe_next(None) == "/"
def test_safe_next_custom_fallback():
assert safe_next("", fallback="/compte") == "/compte"
def test_init_auth_requires_secret_key(monkeypatch, users_db_path):
monkeypatch.delenv("SECRET_KEY", raising=False)
app = Flask(__name__)
with pytest.raises(RuntimeError, match="SECRET_KEY"):
init_auth(app)
def test_init_auth_anonymous_user_not_authenticated(users_db_path):
app = Flask(__name__)
app.config["SECRET_KEY"] = "test"
init_auth(app)
with app.test_request_context("/"):
assert current_user.is_authenticated is False
- Step 2 : Lancer les tests
Run: rtk uv run pytest tests/auth/test_setup.py -v
Expected : ModuleNotFoundError.
- Step 3 : Implémenter
src/auth/setup.py
import os
from flask import Flask
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect
from src.auth import db, mailer
from src.auth.models import load_user
from src.utils import DEVELOPMENT, logger
_csrf: CSRFProtect | None = None
_login_manager: LoginManager | None = None
def safe_next(url: str | None, fallback: str = "/") -> str:
if not url or not url.startswith("/") or url.startswith("//"):
return fallback
return url
def init_auth(app: Flask) -> None:
global _csrf, _login_manager
secret = os.getenv("SECRET_KEY")
if not secret:
raise RuntimeError(
"SECRET_KEY est obligatoire pour l'authentification. "
"Définissez-la dans .env (voir .template.env)."
)
app.config["SECRET_KEY"] = secret
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["SESSION_COOKIE_SECURE"] = not DEVELOPMENT
app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 24 * 30 # 30 jours
db.init_schema()
db.purge_expired_tokens()
mailer.init_mailer(app)
_login_manager = LoginManager()
_login_manager.login_view = "/connexion"
_login_manager.user_loader(load_user)
_login_manager.init_app(app)
_csrf = CSRFProtect(app)
if not os.getenv("SMTP_HOST"):
logger.warning(
"SMTP_HOST non défini : les emails d'auth échoueront. "
"Définissez les variables SMTP_* dans .env pour envoyer des emails."
)
- Step 4 : Relancer les tests
Run: rtk uv run pytest tests/auth/test_setup.py -v
Expected : 7 passed.
- Step 5 : Commit
git add src/auth/setup.py tests/auth/test_setup.py
git commit -m "src/auth/setup.py : init_auth et helper safe_next (#73)"
Task 9 : Intégrer init_auth dans src/app.py
Files:
-
Modify:
src/app.py -
Create:
tests/auth/test_app_integration.py -
Step 1 : Écrire un test d'intégration
tests/auth/test_app_integration.py :
def test_app_imports_cleanly(users_db_path):
from src.app import app
assert app is not None
# Flask-Login manager attaché
assert "login_manager" in app.server.extensions or hasattr(
app.server, "login_manager"
)
- Step 2 : Modifier
src/app.pypour appelerinit_auth
Ajouter après l'initialisation du cache (après cache.init_app(...)) :
from src.auth.setup import init_auth
init_auth(app.server)
- Step 3 : Vérifier que l'app démarre et que les tests existants passent
Run: rtk uv run pytest tests/auth/test_app_integration.py -v
Expected : 1 passed.
Run: rtk uv run pytest tests/ -v -k "not test_auth" puis rtk uv run pytest tests/auth/ -v
Expected : tests existants verts, tests auth verts.
- Step 4 : Commit
git add src/app.py tests/auth/test_app_integration.py
git commit -m "src/app.py : initialisation de l'authentification au démarrage (#73)"
Task 10 : Routes Flask — squelette et fixture client
Files:
-
Create:
src/auth/routes.py -
Modify:
src/auth/setup.py(enregistrer le blueprint) -
Modify:
tests/auth/conftest.py(ajouter fixture client + mail_outbox) -
Step 1 : Créer le blueprint vide
src/auth/routes.py
from flask import Blueprint
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
- Step 2 : Enregistrer le blueprint dans
src/auth/setup.py
Juste avant CSRFProtect(app), ajouter :
from src.auth.routes import auth_bp
app.register_blueprint(auth_bp)
- Step 3 : Ajouter les fixtures
clientetmail_outboxàtests/auth/conftest.py
Remplacer le contenu du fichier par :
import importlib
import os
from pathlib import Path
import pytest
@pytest.fixture
def users_db_path(monkeypatch, tmp_path):
from src.auth.db import reset_conn_for_tests
db_path = tmp_path / "users.test.sqlite"
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
reset_conn_for_tests()
yield db_path
reset_conn_for_tests()
@pytest.fixture
def app(users_db_path, monkeypatch):
# Empêcher l'import de src.app de rejouer côté data ; on charge
# uniquement la stack auth sur une app Flask minimale.
from flask import Flask
from src.auth.setup import init_auth
app = Flask(__name__)
init_auth(app)
yield app
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def mail_outbox(app):
mail = app.extensions["mail"]
with mail.record_messages() as outbox:
yield outbox
- Step 4 : Vérifier que les tests existants passent toujours
Run: rtk uv run pytest tests/auth/ -v
Expected : tous les tests auth passent.
- Step 5 : Commit
git add src/auth/routes.py src/auth/setup.py tests/auth/conftest.py
git commit -m "src/auth/routes.py : blueprint auth et fixtures de test (#73)"
Task 11 : Route /auth/signup
Files:
-
Modify:
src/auth/routes.py -
Create:
tests/auth/test_signup.py -
Step 1 : Écrire les tests dans
tests/auth/test_signup.py
from src.auth import db
def _signup(client, email="alice@example.com", password="password12", confirm=None):
return client.post(
"/auth/signup",
data={
"email": email,
"password": password,
"password_confirm": confirm if confirm is not None else password,
},
)
def test_signup_creates_unverified_user(client, mail_outbox):
resp = _signup(client)
assert resp.status_code == 302
assert "pending_verification=1" in resp.headers["Location"]
row = db.get_user_by_email("alice@example.com")
assert row is not None
assert row["email_verified"] == 0
assert len(mail_outbox) == 1
assert mail_outbox[0].recipients == ["alice@example.com"]
def test_signup_rejects_short_password(client, mail_outbox):
resp = _signup(client, password="short", confirm="short")
assert resp.status_code == 302
assert "error=password_too_short" in resp.headers["Location"]
assert db.get_user_by_email("alice@example.com") is None
assert len(mail_outbox) == 0
def test_signup_rejects_mismatched_passwords(client, mail_outbox):
resp = _signup(client, password="password12", confirm="different12")
assert "error=password_mismatch" in resp.headers["Location"]
assert db.get_user_by_email("alice@example.com") is None
assert len(mail_outbox) == 0
def test_signup_rejects_invalid_email(client, mail_outbox):
resp = _signup(client, email="pas-un-email")
assert "error=invalid_email" in resp.headers["Location"]
assert len(mail_outbox) == 0
def test_signup_rejects_duplicate_email(client, mail_outbox):
_signup(client)
resp = _signup(client)
assert "error=email_taken" in resp.headers["Location"]
assert len(mail_outbox) == 1 # seul le premier signup a envoyé l'email
def test_signup_email_lowercased(client, mail_outbox):
_signup(client, email="Alice@Example.COM")
assert db.get_user_by_email("alice@example.com") is not None
- Step 2 : Lancer les tests (ils doivent échouer)
Run: rtk uv run pytest tests/auth/test_signup.py -v
Expected : 404 (la route n'existe pas encore).
- Step 3 : Implémenter la route dans
src/auth/routes.py
Remplacer le contenu du fichier par :
from email_validator import EmailNotValidError, validate_email
from flask import Blueprint, redirect, request, url_for
from werkzeug.security import generate_password_hash
from src.auth import db, mailer, tokens
from src.utils import logger
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
MIN_PASSWORD_LENGTH = 8
def _redirect_with_error(path: str, error: str, email: str | None = None) -> "Response":
url = f"{path}?error={error}"
if email:
url += f"&email={email}"
return redirect(url)
@auth_bp.route("/signup", methods=["POST"])
def signup():
email = (request.form.get("email") or "").strip()
password = request.form.get("password") or ""
password_confirm = request.form.get("password_confirm") or ""
try:
valid = validate_email(email, check_deliverability=False)
email = valid.normalized.lower()
except EmailNotValidError:
return _redirect_with_error("/inscription", "invalid_email", email)
if len(password) < MIN_PASSWORD_LENGTH:
return _redirect_with_error("/inscription", "password_too_short", email)
if password != password_confirm:
return _redirect_with_error("/inscription", "password_mismatch", email)
if db.get_user_by_email(email) is not None:
return _redirect_with_error("/inscription", "email_taken", email)
user_id = db.create_user(email, generate_password_hash(password))
token = tokens.create_verification_token(user_id)
try:
mailer.send_verification_email(email, token)
except Exception:
logger.exception("Échec d'envoi de l'email de vérification")
db.delete_user(user_id)
return _redirect_with_error("/inscription", "email_send_failed", email)
return redirect("/connexion?pending_verification=1")
- Step 4 : Relancer les tests
Run: rtk uv run pytest tests/auth/test_signup.py -v
Expected : 6 passed.
- Step 5 : Commit
git add src/auth/routes.py tests/auth/test_signup.py
git commit -m "src/auth/routes.py : route /auth/signup (#73)"
Task 12 : Route /auth/verify-email
Files:
-
Modify:
src/auth/routes.py -
Create:
tests/auth/test_verify_email.py -
Step 1 : Écrire les tests
tests/auth/test_verify_email.py :
from src.auth import db, tokens
def test_verify_email_valid_token_marks_user_verified(client, users_db_path):
db.init_schema()
uid = db.create_user("a@b.c", "hash")
token = tokens.create_verification_token(uid)
resp = client.get(f"/auth/verify-email?token={token}")
assert resp.status_code == 302
assert "/connexion?verified=1" in resp.headers["Location"]
assert db.get_user_by_id(uid)["email_verified"] == 1
def test_verify_email_invalid_token(client):
resp = client.get("/auth/verify-email?token=invalide")
assert "error=invalid_token" in resp.headers["Location"]
def test_verify_email_missing_token(client):
resp = client.get("/auth/verify-email")
assert "error=invalid_token" in resp.headers["Location"]
def test_verify_email_single_use(client, users_db_path):
db.init_schema()
uid = db.create_user("a@b.c", "h")
token = tokens.create_verification_token(uid)
client.get(f"/auth/verify-email?token={token}")
resp = client.get(f"/auth/verify-email?token={token}")
assert "error=invalid_token" in resp.headers["Location"]
- Step 2 : Lancer les tests
Run: rtk uv run pytest tests/auth/test_verify_email.py -v
Expected : 404.
- Step 3 : Ajouter la route dans
src/auth/routes.py
À la fin du fichier :
@auth_bp.route("/verify-email", methods=["GET"])
def verify_email():
token = request.args.get("token") or ""
if not token:
return redirect("/verification-email?error=invalid_token")
user_id = tokens.consume_verification_token(token)
if user_id is None:
return redirect("/verification-email?error=invalid_token")
db.set_email_verified(user_id)
return redirect("/connexion?verified=1")
- Step 4 : Ajuster le premier test (redirection vers
/connexionaprès succès, page/verification-emailpour les erreurs)
Ce comportement reflète la page d'erreur dédiée côté Dash (cf. Task 20). Mettre à jour le test si le code diffère.
Run: rtk uv run pytest tests/auth/test_verify_email.py -v
Expected : 4 passed.
- Step 5 : Commit
git add src/auth/routes.py tests/auth/test_verify_email.py
git commit -m "src/auth/routes.py : route /auth/verify-email (#73)"
Task 13 : Routes /auth/login et /auth/logout
Files:
-
Modify:
src/auth/routes.py -
Create:
tests/auth/test_login.py -
Step 1 : Écrire les tests dans
tests/auth/test_login.py
from werkzeug.security import generate_password_hash
from src.auth import db
def _make_verified_user(email="a@b.c", password="password12"):
db.init_schema()
uid = db.create_user(email, generate_password_hash(password))
db.set_email_verified(uid)
return uid
def test_login_success(client, users_db_path):
_make_verified_user()
resp = client.post(
"/auth/login",
data={"email": "a@b.c", "password": "password12"},
)
assert resp.status_code == 302
assert resp.headers["Location"].endswith("/compte")
def test_login_wrong_password(client, users_db_path):
_make_verified_user()
resp = client.post(
"/auth/login", data={"email": "a@b.c", "password": "wrong-password"}
)
assert "error=invalid_credentials" in resp.headers["Location"]
def test_login_unknown_email_same_error(client, users_db_path):
db.init_schema()
resp = client.post(
"/auth/login",
data={"email": "inexistant@example.com", "password": "x" * 12},
)
assert "error=invalid_credentials" in resp.headers["Location"]
def test_login_unverified_user(client, users_db_path):
db.init_schema()
db.create_user("a@b.c", generate_password_hash("password12"))
resp = client.post(
"/auth/login", data={"email": "a@b.c", "password": "password12"}
)
assert "error=email_not_verified" in resp.headers["Location"]
def test_login_respects_safe_next(client, users_db_path):
_make_verified_user()
resp = client.post(
"/auth/login",
data={"email": "a@b.c", "password": "password12", "next": "/tableau"},
)
assert resp.headers["Location"].endswith("/tableau")
def test_login_rejects_absolute_next(client, users_db_path):
_make_verified_user()
resp = client.post(
"/auth/login",
data={
"email": "a@b.c",
"password": "password12",
"next": "https://evil.com",
},
)
assert resp.headers["Location"].endswith("/compte")
def test_logout_clears_session(client, users_db_path):
_make_verified_user()
client.post(
"/auth/login", data={"email": "a@b.c", "password": "password12"}
)
resp = client.post("/auth/logout")
assert resp.status_code == 302
assert resp.headers["Location"].endswith("/")
- Step 2 : Lancer les tests
Run: rtk uv run pytest tests/auth/test_login.py -v
Expected : tests rouges (404).
- Step 3 : Ajouter les routes dans
src/auth/routes.py
Ajouter à la fin :
from flask_login import login_required, login_user, logout_user
from werkzeug.security import check_password_hash
from src.auth.models import User
from src.auth.setup import safe_next
# Hash bidon pré-calculé pour uniformiser le timing login
_DUMMY_HASH = generate_password_hash("dummy-password-for-timing")
@auth_bp.route("/login", methods=["POST"])
def login():
email = (request.form.get("email") or "").strip().lower()
password = request.form.get("password") or ""
next_url = safe_next(request.form.get("next"), fallback="/compte")
row = db.get_user_by_email(email)
if row is None:
check_password_hash(_DUMMY_HASH, password) # uniformiser le temps
return _redirect_with_error("/connexion", "invalid_credentials", email)
if not check_password_hash(row["password_hash"], password):
return _redirect_with_error("/connexion", "invalid_credentials", email)
if not row["email_verified"]:
return _redirect_with_error("/connexion", "email_not_verified", email)
login_user(User(row), remember=True)
return redirect(next_url)
@auth_bp.route("/logout", methods=["POST"])
def logout():
logout_user()
return redirect("/")
- Step 4 : Relancer les tests
Run: rtk uv run pytest tests/auth/test_login.py -v
Expected : 7 passed.
- Step 5 : Commit
git add src/auth/routes.py tests/auth/test_login.py
git commit -m "src/auth/routes.py : routes /auth/login et /auth/logout (#73)"
Task 14 : Routes password reset (request + perform)
Files:
-
Modify:
src/auth/routes.py -
Create:
tests/auth/test_password_reset.py -
Step 1 : Écrire les tests dans
tests/auth/test_password_reset.py
from werkzeug.security import check_password_hash, generate_password_hash
from src.auth import db, tokens
def _make_user():
db.init_schema()
uid = db.create_user("a@b.c", generate_password_hash("old-password12"))
db.set_email_verified(uid)
return uid
def test_request_reset_sends_email_for_existing_user(client, mail_outbox, users_db_path):
_make_user()
resp = client.post(
"/auth/request-password-reset", data={"email": "a@b.c"}
)
assert resp.status_code == 302
assert "pending=1" in resp.headers["Location"]
assert len(mail_outbox) == 1
def test_request_reset_same_response_for_unknown_email(client, mail_outbox, users_db_path):
db.init_schema()
resp = client.post(
"/auth/request-password-reset", data={"email": "absent@b.c"}
)
assert "pending=1" in resp.headers["Location"]
assert len(mail_outbox) == 0
def test_perform_reset_with_valid_token(client, users_db_path):
uid = _make_user()
token = tokens.create_password_reset_token(uid)
resp = client.post(
"/auth/reset-password",
data={
"token": token,
"password": "new-password12",
"password_confirm": "new-password12",
},
)
assert resp.status_code == 302
assert "password_changed=1" in resp.headers["Location"]
row = db.get_user_by_id(uid)
assert check_password_hash(row["password_hash"], "new-password12")
def test_perform_reset_rejects_expired_token(client, users_db_path):
uid = _make_user()
token = tokens.create_password_reset_token(uid, expires_in_hours=-1)
resp = client.post(
"/auth/reset-password",
data={
"token": token,
"password": "new-password12",
"password_confirm": "new-password12",
},
)
assert "error=invalid_token" in resp.headers["Location"]
def test_perform_reset_rejects_short_password(client, users_db_path):
uid = _make_user()
token = tokens.create_password_reset_token(uid)
resp = client.post(
"/auth/reset-password",
data={"token": token, "password": "short", "password_confirm": "short"},
)
assert "error=password_too_short" in resp.headers["Location"]
def test_perform_reset_rejects_mismatched(client, users_db_path):
uid = _make_user()
token = tokens.create_password_reset_token(uid)
resp = client.post(
"/auth/reset-password",
data={
"token": token,
"password": "new-password12",
"password_confirm": "other-password99",
},
)
assert "error=password_mismatch" in resp.headers["Location"]
- Step 2 : Lancer les tests
Run: rtk uv run pytest tests/auth/test_password_reset.py -v
Expected : 404.
- Step 3 : Ajouter les routes à
src/auth/routes.py
À la fin :
@auth_bp.route("/request-password-reset", methods=["POST"])
def request_password_reset():
email = (request.form.get("email") or "").strip().lower()
try:
valid = validate_email(email, check_deliverability=False)
email = valid.normalized.lower()
except EmailNotValidError:
return redirect("/mot-de-passe-oublie?pending=1")
row = db.get_user_by_email(email)
if row is None:
return redirect("/mot-de-passe-oublie?pending=1")
token = tokens.create_password_reset_token(row["id"])
try:
mailer.send_reset_email(email, token)
except Exception:
logger.exception("Échec d'envoi de l'email de réinitialisation")
return _redirect_with_error(
"/mot-de-passe-oublie", "email_send_failed", email
)
return redirect("/mot-de-passe-oublie?pending=1")
@auth_bp.route("/reset-password", methods=["POST"])
def reset_password():
token = request.form.get("token") or ""
password = request.form.get("password") or ""
password_confirm = request.form.get("password_confirm") or ""
user_id = tokens.validate_password_reset_token(token)
if user_id is None:
return redirect(
f"/reinitialiser-mot-de-passe?token={token}&error=invalid_token"
)
if len(password) < MIN_PASSWORD_LENGTH:
return redirect(
f"/reinitialiser-mot-de-passe?token={token}&error=password_too_short"
)
if password != password_confirm:
return redirect(
f"/reinitialiser-mot-de-passe?token={token}&error=password_mismatch"
)
consumed = tokens.consume_password_reset_token(token)
if consumed is None:
return redirect(
f"/reinitialiser-mot-de-passe?token={token}&error=invalid_token"
)
db.update_password_hash(consumed, generate_password_hash(password))
return redirect("/connexion?password_changed=1")
- Step 4 : Relancer les tests
Run: rtk uv run pytest tests/auth/test_password_reset.py -v
Expected : 6 passed.
- Step 5 : Commit
git add src/auth/routes.py tests/auth/test_password_reset.py
git commit -m "src/auth/routes.py : routes de réinitialisation du mot de passe (#73)"
Task 15 : Route /auth/change-password
Files:
-
Modify:
src/auth/routes.py -
Create:
tests/auth/test_account.py -
Step 1 : Écrire les tests dans
tests/auth/test_account.py
from werkzeug.security import check_password_hash, generate_password_hash
from src.auth import db
def _login(client, email="a@b.c", password="old-password12"):
db.init_schema()
uid = db.create_user(email, generate_password_hash(password))
db.set_email_verified(uid)
client.post("/auth/login", data={"email": email, "password": password})
return uid
def test_change_password_requires_login(client, users_db_path):
resp = client.post(
"/auth/change-password",
data={
"current_password": "whatever",
"password": "new-password12",
"password_confirm": "new-password12",
},
)
assert resp.status_code in (302, 401)
def test_change_password_success(client, users_db_path):
uid = _login(client)
resp = client.post(
"/auth/change-password",
data={
"current_password": "old-password12",
"password": "new-password12",
"password_confirm": "new-password12",
},
)
assert resp.status_code == 302
assert "password_changed=1" in resp.headers["Location"]
row = db.get_user_by_id(uid)
assert check_password_hash(row["password_hash"], "new-password12")
def test_change_password_wrong_current(client, users_db_path):
uid = _login(client)
resp = client.post(
"/auth/change-password",
data={
"current_password": "wrong",
"password": "new-password12",
"password_confirm": "new-password12",
},
)
assert "error=invalid_current_password" in resp.headers["Location"]
row = db.get_user_by_id(uid)
assert check_password_hash(row["password_hash"], "old-password12")
def test_change_password_short(client, users_db_path):
_login(client)
resp = client.post(
"/auth/change-password",
data={
"current_password": "old-password12",
"password": "short",
"password_confirm": "short",
},
)
assert "error=password_too_short" in resp.headers["Location"]
def test_change_password_mismatch(client, users_db_path):
_login(client)
resp = client.post(
"/auth/change-password",
data={
"current_password": "old-password12",
"password": "new-password12",
"password_confirm": "autre-password12",
},
)
assert "error=password_mismatch" in resp.headers["Location"]
- Step 2 : Lancer les tests
Run: rtk uv run pytest tests/auth/test_account.py -v
Expected : 404 sur tous sauf peut-être le premier.
- Step 3 : Ajouter la route à
src/auth/routes.py
À la fin :
from flask_login import current_user
@auth_bp.route("/change-password", methods=["POST"])
@login_required
def change_password():
current_pw = request.form.get("current_password") or ""
password = request.form.get("password") or ""
password_confirm = request.form.get("password_confirm") or ""
row = db.get_user_by_id(current_user.id)
if not check_password_hash(row["password_hash"], current_pw):
return _redirect_with_error("/compte", "invalid_current_password")
if len(password) < MIN_PASSWORD_LENGTH:
return _redirect_with_error("/compte", "password_too_short")
if password != password_confirm:
return _redirect_with_error("/compte", "password_mismatch")
db.update_password_hash(current_user.id, generate_password_hash(password))
return redirect("/compte?password_changed=1")
- Step 4 : Relancer les tests
Run: rtk uv run pytest tests/auth/test_account.py -v
Expected : 5 passed.
- Step 5 : Commit
git add src/auth/routes.py tests/auth/test_account.py
git commit -m "src/auth/routes.py : route /auth/change-password (#73)"
Task 16 : Page Dash /inscription
Files:
-
Create:
src/pages/inscription.py -
Step 1 : Créer la page Dash
import dash_bootstrap_components as dbc
from dash import Input, Output, callback, dcc, html, register_page
from flask_wtf.csrf import generate_csrf
NAME = "Inscription"
register_page(
__name__,
path="/inscription",
title="Inscription | decp.info",
name=NAME,
description="Créer un compte decp.info.",
)
ERROR_MESSAGES = {
"invalid_email": "Adresse email invalide.",
"password_too_short": "Le mot de passe doit faire au moins 8 caractères.",
"password_mismatch": "Les mots de passe ne correspondent pas.",
"email_taken": "Un compte existe déjà avec cet email.",
"email_send_failed": "Erreur technique lors de l'envoi de l'email. Réessayez plus tard.",
}
def layout(error: str | None = None, email: str | None = None, **_):
alert = None
if error and error in ERROR_MESSAGES:
alert = dbc.Alert(ERROR_MESSAGES[error], color="danger")
return dbc.Container(
className="py-4",
style={"maxWidth": "500px"},
children=[
html.H2("Créer un compte"),
alert,
html.Form(
method="POST",
action="/auth/signup",
children=[
dcc.Input(type="hidden", id="csrf-signup", name="csrf_token"),
dbc.Label("Adresse email"),
dbc.Input(
type="email",
name="email",
required=True,
value=email or "",
className="mb-3",
),
dbc.Label("Mot de passe (8 caractères minimum)"),
dbc.Input(
type="password",
name="password",
required=True,
minLength=8,
className="mb-3",
),
dbc.Label("Confirmer le mot de passe"),
dbc.Input(
type="password",
name="password_confirm",
required=True,
minLength=8,
className="mb-3",
),
dbc.Button(
"Créer le compte", type="submit", color="primary"
),
],
),
html.Hr(),
dcc.Link("Déjà un compte ? Se connecter", href="/connexion"),
],
)
@callback(Output("csrf-signup", "value"), Input("csrf-signup", "id"))
def _fill_csrf(_):
return generate_csrf()
- Step 2 : Vérifier manuellement le rendu
Démarrer l'app : rtk uv run run.py (avec .env configuré).
Ouvrir http://localhost:8050/inscription, vérifier le rendu du formulaire.
- Step 3 : Commit
git add src/pages/inscription.py
git commit -m "src/pages/inscription.py : page d'inscription (#73)"
Task 17 : Page Dash /connexion
Files:
-
Create:
src/pages/connexion.py -
Step 1 : Créer la page
import dash_bootstrap_components as dbc
from dash import Input, Output, callback, dcc, html, register_page
from flask_wtf.csrf import generate_csrf
NAME = "Connexion"
register_page(
__name__,
path="/connexion",
title="Connexion | decp.info",
name=NAME,
description="Se connecter à decp.info.",
)
ERROR_MESSAGES = {
"invalid_credentials": "Identifiants invalides.",
"email_not_verified": "Vérifiez d'abord votre adresse email (consultez votre boîte de réception).",
}
INFO_MESSAGES = {
"pending_verification": (
"Compte créé. Un email de vérification a été envoyé. "
"Cliquez sur le lien reçu avant de vous connecter."
),
"verified": "Adresse email vérifiée. Vous pouvez maintenant vous connecter.",
"password_changed": "Mot de passe mis à jour. Connectez-vous avec le nouveau.",
}
def layout(error: str | None = None, email: str | None = None, **kwargs):
alerts = []
if error and error in ERROR_MESSAGES:
alerts.append(dbc.Alert(ERROR_MESSAGES[error], color="danger"))
for flag, msg in INFO_MESSAGES.items():
if kwargs.get(flag) == "1":
alerts.append(dbc.Alert(msg, color="info"))
next_url = kwargs.get("next", "")
return dbc.Container(
className="py-4",
style={"maxWidth": "500px"},
children=[
html.H2("Connexion"),
*alerts,
html.Form(
method="POST",
action="/auth/login",
children=[
dcc.Input(type="hidden", id="csrf-login", name="csrf_token"),
dcc.Input(type="hidden", name="next", value=next_url),
dbc.Label("Adresse email"),
dbc.Input(
type="email",
name="email",
required=True,
value=email or "",
className="mb-3",
),
dbc.Label("Mot de passe"),
dbc.Input(
type="password",
name="password",
required=True,
className="mb-3",
),
dbc.Button("Se connecter", type="submit", color="primary"),
],
),
html.Hr(),
html.Div(
[
dcc.Link("Créer un compte", href="/inscription"),
html.Span(" · "),
dcc.Link("Mot de passe oublié ?", href="/mot-de-passe-oublie"),
]
),
],
)
@callback(Output("csrf-login", "value"), Input("csrf-login", "id"))
def _fill_csrf(_):
return generate_csrf()
- Step 2 : Vérification manuelle
Redémarrer l'app, ouvrir http://localhost:8050/connexion. Tester ?pending_verification=1 et ?error=invalid_credentials.
- Step 3 : Commit
git add src/pages/connexion.py
git commit -m "src/pages/connexion.py : page de connexion (#73)"
Task 18 : Page Dash /compte
Files:
-
Create:
src/pages/compte.py -
Step 1 : Créer la page avec protection login_required côté Flask
import dash_bootstrap_components as dbc
from dash import Input, Output, callback, dcc, html, register_page
from flask import redirect
from flask_login import current_user
from flask_wtf.csrf import generate_csrf
NAME = "Mon compte"
register_page(
__name__,
path="/compte",
title="Mon compte | decp.info",
name=NAME,
description="Page de gestion de compte.",
)
ERROR_MESSAGES = {
"invalid_current_password": "Le mot de passe actuel est incorrect.",
"password_too_short": "Le nouveau mot de passe doit faire au moins 8 caractères.",
"password_mismatch": "Les nouveaux mots de passe ne correspondent pas.",
}
def layout(error: str | None = None, password_changed: str | None = None, **_):
if not current_user.is_authenticated:
# Redirection par layout de page Dash : on rend un lien car Dash ne gère pas
# de redirect HTTP natif. On compte sur le middleware Flask enregistré dans
# app.py pour intercepter /compte quand l'user est anonyme (cf. Task 20).
return dcc.Location(href="/connexion?next=/compte", id="compte-redirect")
alerts = []
if error and error in ERROR_MESSAGES:
alerts.append(dbc.Alert(ERROR_MESSAGES[error], color="danger"))
if password_changed == "1":
alerts.append(dbc.Alert("Mot de passe mis à jour.", color="success"))
return dbc.Container(
className="py-4",
style={"maxWidth": "600px"},
children=[
html.H2("Mon compte"),
html.P([html.Strong("Email : "), current_user.email]),
*alerts,
html.H4("Changer le mot de passe", className="mt-4"),
html.Form(
method="POST",
action="/auth/change-password",
children=[
dcc.Input(type="hidden", id="csrf-change", name="csrf_token"),
dbc.Label("Mot de passe actuel"),
dbc.Input(
type="password",
name="current_password",
required=True,
className="mb-3",
),
dbc.Label("Nouveau mot de passe (8 caractères minimum)"),
dbc.Input(
type="password",
name="password",
required=True,
minLength=8,
className="mb-3",
),
dbc.Label("Confirmer le nouveau mot de passe"),
dbc.Input(
type="password",
name="password_confirm",
required=True,
minLength=8,
className="mb-3",
),
dbc.Button("Changer", type="submit", color="primary"),
],
),
html.Hr(className="mt-5"),
html.Form(
method="POST",
action="/auth/logout",
children=[
dcc.Input(type="hidden", id="csrf-logout", name="csrf_token"),
dbc.Button("Déconnexion", type="submit", color="secondary"),
],
),
],
)
@callback(Output("csrf-change", "value"), Input("csrf-change", "id"))
def _fill_csrf_change(_):
return generate_csrf()
@callback(Output("csrf-logout", "value"), Input("csrf-logout", "id"))
def _fill_csrf_logout(_):
return generate_csrf()
- Step 2 : Vérification manuelle
Se connecter, ouvrir /compte, essayer de changer le mot de passe. Se déconnecter.
- Step 3 : Commit
git add src/pages/compte.py
git commit -m "src/pages/compte.py : page mon compte (protégée) (#73)"
Task 19 : Pages Dash /mot-de-passe-oublie et /reinitialiser-mot-de-passe
Files:
-
Create:
src/pages/mot_de_passe_oublie.py -
Create:
src/pages/reinitialiser_mot_de_passe.py -
Step 1 : Créer
src/pages/mot_de_passe_oublie.py
import dash_bootstrap_components as dbc
from dash import Input, Output, callback, dcc, html, register_page
from flask_wtf.csrf import generate_csrf
NAME = "Mot de passe oublié"
register_page(
__name__,
path="/mot-de-passe-oublie",
title="Mot de passe oublié | decp.info",
name=NAME,
description="Réinitialiser votre mot de passe decp.info.",
)
ERROR_MESSAGES = {
"email_send_failed": "Erreur technique lors de l'envoi de l'email. Réessayez plus tard.",
}
def layout(error: str | None = None, pending: str | None = None, email: str | None = None, **_):
alerts = []
if error and error in ERROR_MESSAGES:
alerts.append(dbc.Alert(ERROR_MESSAGES[error], color="danger"))
if pending == "1":
alerts.append(
dbc.Alert(
"Si un compte existe avec cet email, un lien de réinitialisation vient d'être envoyé. "
"Vérifiez votre boîte de réception.",
color="info",
)
)
return dbc.Container(
className="py-4",
style={"maxWidth": "500px"},
children=[
html.H2("Mot de passe oublié"),
*alerts,
html.P(
"Saisissez votre adresse email. Si un compte existe, "
"vous recevrez un lien pour choisir un nouveau mot de passe."
),
html.Form(
method="POST",
action="/auth/request-password-reset",
children=[
dcc.Input(type="hidden", id="csrf-forgot", name="csrf_token"),
dbc.Label("Adresse email"),
dbc.Input(
type="email",
name="email",
required=True,
value=email or "",
className="mb-3",
),
dbc.Button(
"Envoyer le lien", type="submit", color="primary"
),
],
),
html.Hr(),
dcc.Link("Retour à la connexion", href="/connexion"),
],
)
@callback(Output("csrf-forgot", "value"), Input("csrf-forgot", "id"))
def _fill_csrf(_):
return generate_csrf()
- Step 2 : Créer
src/pages/reinitialiser_mot_de_passe.py
import dash_bootstrap_components as dbc
from dash import Input, Output, callback, dcc, html, register_page
from flask_wtf.csrf import generate_csrf
from src.auth.tokens import validate_password_reset_token
NAME = "Réinitialiser le mot de passe"
register_page(
__name__,
path="/reinitialiser-mot-de-passe",
title="Nouveau mot de passe | decp.info",
name=NAME,
description="Choisir un nouveau mot de passe.",
)
ERROR_MESSAGES = {
"invalid_token": "Lien invalide ou expiré. Demandez un nouveau lien de réinitialisation.",
"password_too_short": "Le mot de passe doit faire au moins 8 caractères.",
"password_mismatch": "Les mots de passe ne correspondent pas.",
}
def layout(token: str | None = None, error: str | None = None, **_):
if not token or validate_password_reset_token(token) is None:
return dbc.Container(
className="py-4",
style={"maxWidth": "500px"},
children=[
html.H2("Lien invalide"),
dbc.Alert(ERROR_MESSAGES["invalid_token"], color="danger"),
dcc.Link(
"Demander un nouveau lien", href="/mot-de-passe-oublie"
),
],
)
alerts = []
if error and error in ERROR_MESSAGES:
alerts.append(dbc.Alert(ERROR_MESSAGES[error], color="danger"))
return dbc.Container(
className="py-4",
style={"maxWidth": "500px"},
children=[
html.H2("Choisir un nouveau mot de passe"),
*alerts,
html.Form(
method="POST",
action="/auth/reset-password",
children=[
dcc.Input(type="hidden", id="csrf-reset", name="csrf_token"),
dcc.Input(type="hidden", name="token", value=token),
dbc.Label("Nouveau mot de passe (8 caractères minimum)"),
dbc.Input(
type="password",
name="password",
required=True,
minLength=8,
className="mb-3",
),
dbc.Label("Confirmer le nouveau mot de passe"),
dbc.Input(
type="password",
name="password_confirm",
required=True,
minLength=8,
className="mb-3",
),
dbc.Button("Valider", type="submit", color="primary"),
],
),
],
)
@callback(Output("csrf-reset", "value"), Input("csrf-reset", "id"))
def _fill_csrf(_):
return generate_csrf()
- Step 3 : Vérification manuelle
Tester le flux complet : /mot-de-passe-oublie → saisir email d'un user → lien reçu → /reinitialiser-mot-de-passe?token=… → changer le mot de passe → reconnexion OK.
- Step 4 : Commit
git add src/pages/mot_de_passe_oublie.py src/pages/reinitialiser_mot_de_passe.py
git commit -m "src/pages : pages mot de passe oublié et réinitialisation (#73)"
Task 20 : Page Dash /verification-email
Files:
-
Create:
src/pages/verification_email.py -
Step 1 : Créer la page
Cette page affiche uniquement un message selon la query string. Le serveur Flask gère la validation via /auth/verify-email et redirige ici en cas d'erreur.
import dash_bootstrap_components as dbc
from dash import dcc, html, register_page
NAME = "Vérification email"
register_page(
__name__,
path="/verification-email",
title="Vérification email | decp.info",
name=NAME,
description="Vérification de l'adresse email.",
)
def layout(error: str | None = None, token: str | None = None, **_):
if error == "invalid_token":
return dbc.Container(
className="py-4",
style={"maxWidth": "500px"},
children=[
html.H2("Lien invalide ou expiré"),
dbc.Alert(
"Le lien de vérification est invalide ou a expiré. "
"Connectez-vous pour demander un nouveau lien.",
color="danger",
),
dcc.Link("Aller à la connexion", href="/connexion"),
],
)
# Cas où un utilisateur arrive ici sans passer par /auth/verify-email :
# on redirige vers la connexion.
return dcc.Location(href="/connexion", id="verif-redirect")
- Step 2 : Modifier la route
/auth/verify-emailpour rediriger vers cette page en cas d'erreur
Dans src/auth/routes.py, la route verify_email utilise déjà /verification-email?error=invalid_token. Vérifier que c'est le cas.
- Step 3 : Vérification manuelle
Cliquer sur un lien invalide : http://localhost:8050/auth/verify-email?token=bidon → doit rediriger vers /verification-email?error=invalid_token.
- Step 4 : Commit
git add src/pages/verification_email.py
git commit -m "src/pages/verification_email.py : page statut vérification email (#73)"
Task 21 : Mise à jour de la navbar
Files:
-
Modify:
src/app.py -
Step 1 : Mettre à jour
src/app.py
Remplacer le bloc dbc.Collapse actuel dans la navbar par une version qui ajoute un élément « Connexion » ou un dropdown « email » selon current_user.
Localiser le bloc dbc.Collapse(...) dans src/app.py (commence par dbc.Collapse(dbc.Nav(...))). Le remplacer par :
dbc.Collapse(
dbc.Nav(
[
dbc.NavItem(
dbc.NavLink(
page["name"].replace(" ", " "),
href=page["relative_path"],
active="exact",
)
)
for page in page_registry.values()
if page["name"]
in ["Recherche", "À propos", "Tableau", "Observatoire"]
]
+ [html.Div(id="auth-nav-slot")],
className="ms-auto",
navbar=True,
),
id="navbar-collapse",
navbar=True,
),
- Step 2 : Ajouter un callback qui remplit le slot selon l'état de connexion
À la fin de src/app.py, avant app.layout = ... :
from dash import Input, Output, callback
from flask_login import current_user
@callback(
Output("auth-nav-slot", "children"),
Input("auth-nav-slot", "id"),
)
def _auth_nav(_):
if current_user.is_authenticated:
email = current_user.email
display = email if len(email) <= 30 else email[:27] + "..."
return dbc.DropdownMenu(
label=display,
nav=True,
in_navbar=True,
children=[
dbc.DropdownMenuItem("Mon compte", href="/compte"),
dbc.DropdownMenuItem(
html.Form(
method="POST",
action="/auth/logout",
style={"display": "inline"},
children=[
dcc.Input(
type="hidden",
id="csrf-navbar-logout",
name="csrf_token",
),
html.Button(
"Déconnexion",
type="submit",
className="btn btn-link p-0",
style={
"textDecoration": "none",
"color": "inherit",
},
),
],
)
),
],
)
return dbc.NavItem(dbc.NavLink("Connexion", href="/connexion"))
@callback(
Output("csrf-navbar-logout", "value"),
Input("csrf-navbar-logout", "id"),
)
def _csrf_navbar_logout(_):
from flask_wtf.csrf import generate_csrf
return generate_csrf()
- Step 3 : Vérifier que les pages protégées fonctionnent
dash.register_page crée un layout statique ; la protection /compte est assurée par la redirection dcc.Location dans src/pages/compte.py (cf. Task 18).
- Step 4 : Vérifier visuellement
Démarrer l'app. Déconnecté → lien « Connexion » en haut à droite. Se connecter → dropdown avec l'email et les items « Mon compte » / « Déconnexion ».
- Step 5 : Commit
git add src/app.py
git commit -m "src/app.py : navbar avec lien Connexion / dropdown utilisateur (#73)"
Task 22 : Test CSRF
Files:
-
Create:
tests/auth/test_csrf.py -
Step 1 : Écrire le test avec CSRF explicitement activé
import pytest
from flask import Flask
from flask_login import current_user
from src.auth import db
from src.auth.setup import init_auth
@pytest.fixture
def csrf_app(users_db_path, monkeypatch):
monkeypatch.setenv("WTF_CSRF_ENABLED", "True")
app = Flask(__name__)
app.config["WTF_CSRF_ENABLED"] = True
init_auth(app)
# Réactiver CSRF après init (init_auth respecte la config existante)
return app
@pytest.fixture
def csrf_client(csrf_app):
return csrf_app.test_client()
def test_post_without_csrf_rejected(csrf_client, users_db_path):
resp = csrf_client.post(
"/auth/signup",
data={
"email": "a@b.c",
"password": "password12",
"password_confirm": "password12",
},
)
assert resp.status_code == 400 # CSRF failed
def test_post_with_csrf_accepted(csrf_app, csrf_client, users_db_path):
with csrf_app.test_request_context():
from flask_wtf.csrf import generate_csrf
token = generate_csrf()
# Injecter le token via un session cookie pré-établi
with csrf_client.session_transaction() as sess:
# flask-wtf stocke le token en session ; on le réutilise tel quel
pass
# Exemple simplifié : on teste via un GET qui fournit le token, puis on le POST.
# En pratique, on passe par une requête préalable à une page qui inclut le CSRF.
# Ce test valide que CSRFProtect est bien installé : si le POST sans token échoue
# en 400, c'est suffisant pour prouver la couverture.
Note : le test principal est test_post_without_csrf_rejected. Le second est illustratif ; si trop fragile, le supprimer.
- Step 2 : Lancer les tests
Run: rtk uv run pytest tests/auth/test_csrf.py -v
Expected : test_post_without_csrf_rejected passe (400). Le second peut être skippé ou simplifié.
- Step 3 : Commit
git add tests/auth/test_csrf.py
git commit -m "tests/auth/test_csrf.py : vérifie que CSRF est actif (#73)"
Task 23 : Vérification finale — tous les tests passent et smoke test manuel
Files:
-
Aucun (validation)
-
Step 1 : Lancer la suite complète
Run: rtk uv run pytest -v
Expected : tous les tests passent (existants + nouveaux tests auth).
- Step 2 : Smoke test manuel end-to-end
- Configurer
.envavec un SMTP réel (ouDEVELOPMENT=Truepour capturer les emails dans la console). rtk uv run run.py.- Ouvrir
http://localhost:8050/inscription, créer un compte. - Vérifier que l'email de vérification s'affiche dans la console (ou arrive réellement).
- Cliquer le lien → doit rediriger vers
/connexion?verified=1. - Se connecter, vérifier redirection vers
/compte. - Changer le mot de passe.
- Se déconnecter.
- Utiliser « Mot de passe oublié », recevoir le lien, réinitialiser, se reconnecter avec le nouveau mot de passe.
- Step 3 : Mettre à jour le CHANGELOG
Ajouter sous la section en cours :
- comptes utilisateurs : inscription avec vérification d'email, connexion,
réinitialisation de mot de passe, page compte (#73)
- Step 4 : Commit final
git add CHANGELOG.md
git commit -m "Changelog : comptes utilisateurs (#73)"
Self-review
Couverture du spec :
- Inscription email + password hashé → Task 11 (route signup) ✓
- Vérification obligatoire de l'email → Task 11 + Task 12 + Task 13 (refus login non vérifié) ✓
- Connexion email + password → Task 13 ✓
- Lien de réinitialisation du mot de passe → Task 19 (page) + Task 14 (routes) ✓
- Page compte avec changement de mot de passe → Task 15 (route) + Task 18 (page) ✓
- SQLite à la racine (configurable) → Task 2 ✓
- SMTP via variables d'env → Task 1 + Task 7 ✓
- Sessions Flask-Login → Task 8 ✓
- Tokens hashés en DB, usage unique → Task 5 + Task 4 ✓
- Hashage
werkzeug.security→ Task 11, Task 13, Task 14, Task 15 ✓ - CSRF Flask-WTF → Task 8 + Task 22 ✓
- Pages Dash séparées (5 + verification-email) → Tasks 16–20 ✓
- Navbar dropdown selon connexion → Task 21 ✓
- Tests unitaires + intégration Flask → toutes les tasks ✓
- Messages d'erreur via query string → routes + pages ✓
- Validation
next(rejet URLs absolues) → Task 13 (safe_next) ✓ - Anti-énumération (message générique) → Task 13 + Task 14 ✓
- Timing uniforme sur login → Task 13 (
_DUMMY_HASH) ✓ - Rollback inscription si SMTP KO → Task 11 ✓
SECRET_KEYobligatoire → Task 8 ✓MAIL_SUPPRESS_SENDen dev → Task 7 ✓- Purge tokens expirés au démarrage → Task 8 ✓