5a7a4da027
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>
2905 lines
82 KiB
Markdown
2905 lines
82 KiB
Markdown
# 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
|
||
<!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` :
|
||
|
||
```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`**
|
||
|
||
```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 ✓
|