# 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 publics - `src/auth/db.py` — SQLite (schéma + CRUD users + CRUD tokens) - `src/auth/models.py` — classe `User` Flask-Login - `src/auth/tokens.py` — génération/validation tokens - `src/auth/mailer.py` — envoi d'emails - `src/auth/setup.py` — `init_auth(app)` + helpers (`safe_next`) - `src/auth/routes.py` — routes Flask (`/auth/*`) - `src/auth/templates/emails/verify_email.html` - `src/auth/templates/emails/verify_email.txt` - `src/auth/templates/emails/reset_password.html` - `src/auth/templates/emails/reset_password.txt` - `src/pages/connexion.py` - `src/pages/inscription.py` - `src/pages/compte.py` - `src/pages/mot_de_passe_oublie.py` - `src/pages/reinitialiser_mot_de_passe.py` - `src/pages/verification_email.py` - `tests/auth/__init__.py` - `tests/auth/conftest.py` - `tests/auth/test_db.py` - `tests/auth/test_tokens.py` - `tests/auth/test_mailer.py` - `tests/auth/test_setup.py` - `tests/auth/test_signup.py` - `tests/auth/test_verify_email.py` - `tests/auth/test_login.py` - `tests/auth/test_password_reset.py` - `tests/auth/test_account.py` - `tests/auth/test_csrf.py` **Modifiés :** - `pyproject.toml` — dépendances + variables d'env tests - `.template.env` — nouvelles variables d'environnement - `src/app.py` — ajout `init_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 : ```toml "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 : ```toml "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** ```bash 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__.py` vide** ```python ``` - [ ] **Step 2 : Créer `tests/auth/__init__.py` vide** ```python ``` - [ ] **Step 3 : Écrire la fixture DB dans `tests/auth/conftest.py`** ```python 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`** ```python 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** ```python 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 : ```python 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** ```bash 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`** ```python 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 : ```python 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** ```bash 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`** ```python 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 : ```python 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** ```bash 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`** ```python 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`** ```python 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** ```bash 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 nouveau `test_models.py`) - [ ] **Step 1 : Écrire un test minimal dans un nouveau fichier `tests/auth/test_models.py`** ```python 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`** ```python 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** ```bash 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` : ```html

decp.info

Bonjour,

Pour vérifier votre adresse email et activer votre compte decp.info, cliquez sur le lien ci-dessous :

Vérifier mon adresse email

Ou copiez cette URL dans votre navigateur : {{ 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/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` : ```html

decp.info

Bonjour,

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 :

Réinitialiser mon mot de passe

Ou copiez cette URL dans votre navigateur : {{ link }}

Ce lien est valide pendant 1 heure.

Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.

— decp.info

``` - [ ] **Step 2 : Écrire les tests du mailer dans `tests/auth/test_mailer.py`** ```python 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`** ```python 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** ```bash 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`** ```python 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`** ```python 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** ```bash 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` : ```python 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.py` pour appeler `init_auth`** Ajouter après l'initialisation du cache (après `cache.init_app(...)`) : ```python 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** ```bash 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`** ```python 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 : ```python from src.auth.routes import auth_bp app.register_blueprint(auth_bp) ``` - [ ] **Step 3 : Ajouter les fixtures `client` et `mail_outbox` à `tests/auth/conftest.py`** Remplacer le contenu du fichier par : ```python 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** ```bash 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`** ```python 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 : ```python 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** ```bash 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` : ```python 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 : ```python @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 `/connexion` après succès, page `/verification-email` pour 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** ```bash 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`** ```python 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 : ```python 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** ```bash 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`** ```python 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 : ```python @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** ```bash 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`** ```python 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 : ```python 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** ```bash 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** ```python 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** ```bash 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** ```python 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** ```bash 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** ```python 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** ```bash 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`** ```python 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`** ```python 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** ```bash 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. ```python 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-email` pour 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** ```bash 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 : ```python 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 = ...` : ```python 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** ```bash 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é** ```python 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** ```bash 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** 1. Configurer `.env` avec un SMTP réel (ou `DEVELOPMENT=True` pour capturer les emails dans la console). 2. `rtk uv run run.py`. 3. Ouvrir `http://localhost:8050/inscription`, créer un compte. 4. Vérifier que l'email de vérification s'affiche dans la console (ou arrive réellement). 5. Cliquer le lien → doit rediriger vers `/connexion?verified=1`. 6. Se connecter, vérifier redirection vers `/compte`. 7. Changer le mot de passe. 8. Se déconnecter. 9. 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** ```bash 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_KEY` obligatoire → Task 8 ✓ - `MAIL_SUPPRESS_SEND` en dev → Task 7 ✓ - Purge tokens expirés au démarrage → Task 8 ✓