ARCH-004 et la moitie NotificationService d ARCH-003.
app/routes/users.py faisait 1 699 lignes et couvrait six sujets qui ne
partageaient rien d autre qu un prefixe d URL. Il devient un paquet :
blueprint.py l objet Blueprint, seul
_shared.py helpers de formulaire, gamertags, validation de PDF
accounts.py 348 l. liste, creation, edition, suppression, fiche
availability.py 233 l. disponibilites joueur et creneaux coach
contracts.py 206 l. depot, signature, telechargement
notes.py 376 l. notes d equipe et notes nominatives
one_on_one.py 259 l. demandes de seance individuelle
profile.py 132 l. profil de la personne connectee
Aucun fichier ne depasse 400 lignes -- le critere d acceptation de l audit.
**Un seul blueprint, pas six.** Les endpoints restent `users.*`. Les
renommer aurait touche 137 appels `url_for` dans les gabarits, pour un
benefice nul : l objectif est un fichier qu on peut lire, pas une carte
d URL a reapprendre. Les 30 endpoints sont identiques avant et apres,
verifie sur url_map.
send_discord_notification part dans app/services/notifications.py. Elle
tirait `requests`, `logging` et le bot Discord dans un module dont le sujet
est le traitement HTTP, et se trouvait coincee entre deux definitions de
route. Son `except Exception` est conserve et documente : une notification
qui n arrive pas ne doit pas annuler la transaction qu elle annoncait.
tests/test_route_map.py, nouveau : il parcourt gabarits et code, releve
tout endpoint nomme litteralement dans un url_for, et verifie qu il existe
dans la carte. C est le mode de defaillance de ce genre de decoupage --
pas une erreur a l import, mais un BuildError chez la premiere personne qui
ouvre la page concernee. Un test de garde verifie aussi que le scan trouve
quelque chose, sinon le reste serait vide de sens.
Piege rencontre, et corrige : test_role_change patchait
`app.routes.users.update_user_gamertags`. Apres le decoupage ce nom est un
reexport, pas celui qu accounts.py resout -- le patch aurait pu laisser la
route appeler la vraie fonction et le test passer sans rien verifier. Ici
monkeypatch a echoue bruyamment, mais la cible est desormais explicite et
le test enregistre que la doublure a bien ete appelee.
347 tests passent (263 + 84, dont 82 parametres par la carte des routes).
Co-Authored-By: Claude Opus 5 <[email protected]>
QUA-002, premiere moitie. **Ce commit ne fait que reformater** : aucun
changement de comportement, aucune ligne de logique touchee. 72 fichiers,
4 restaient deja conformes. Il est isole exprès, pour que `git log -p` sur
les commits voisins reste lisible.
`quote-style = "preserve"` etait deja pose dans pyproject.toml, ce qui
evite le brassage guillemets simples / doubles : le diff porte sur les
retours a la ligne, l indentation des appels longs et les virgules
finales, pas sur le style de chaine.
Verification : 263 tests passent avant et apres, ruff check propre.
L activation en CI arrive dans le commit suivant, separement, pour que ce
diff-ci ne contienne rien d autre.
Co-Authored-By: Claude Opus 5 <[email protected]>
Quatre constats de la liste des gains rapides, tous sur auth.py.
SEC-017 -- enumeration de comptes
Le formulaire repondait "il vous reste 3 tentative(s)" a un compte connu et
"verifiez le nom d utilisateur et le mot de passe" a un inconnu. Le decompte
lui-meme etait la fuite : la meme information, etalee sur cinq requetes.
S y ajoutait un ecart de temps de reponse, check_password n etant appele que
si la ligne existait -- scrypt est cher, l ecart est mesurable.
Un seul message pour tous les echecs, et la verification s execute
desormais sur les deux branches : contre un hachage aleatoire tire une fois
par processus quand l identifiant n existe pas.
SEC-018 -- verrou de compte declenchable par un tiers
Cinq mauvaises reponses mettaient un compte connu hors service pendant
quinze minutes, indefiniment renouvelables. Sur un compte president, c est
toute l administration, et aucun ecran ne permettait de defaire.
Le compteur et la fenetre restent -- ce sont la trace qu un administrateur
lit quand un compte est pilonne, et la fenetre double jusqu a un plafond.
Ce qui change : de bons identifiants passent, fenetre ouverte ou non, et
remettent le compteur a zero. Le proprietaire du compte ne peut plus etre
bloque par un tiers.
Ce que cela coute, dit franchement : un verrou dur n arretait de toute
facon pas un attaquant ayant trouve le mot de passe -- il lui suffisait
d attendre. Le debit de tentatives reste borne par la limite de 10/minute
par IP. Une limite par couple (compte, IP) demanderait un stockage dedie ;
elle attend Alembic.
L evenement account.locked devient account.throttled : "locked" affirmait
plus que ce qui se passe.
SEC-019 -- deconnexion en GET
/auth/logout n avait pas de methods, donc GET, donc hors protection CSRF :
n importe quelle page pouvait deconnecter un visiteur avec une balise img.
La route passe en POST et l entree de navigation devient un formulaire avec
jeton. Le style suit -- les regles .nav-links visaient les liens seuls.
SEC-020 -- validation de redirection
is_safe_url interrogeait urlparse().netloc. urlparse lit /\evil.com comme
un chemin, sans netloc ; plusieurs navigateurs normalisent l antislash en
barre oblique avant de resoudre, ce qui en fait //evil.com. La fonction
refuse maintenant antislash et caracteres de controle, exige un chemin
enracine, et compare l origine explicitement.
Le xfail(strict) qui documentait SEC-017 est leve. 40 tests dans
test_auth_session.py, dont la table des cibles refusees.
Co-Authored-By: Claude Opus 5 <[email protected]>
ARCH-008. `role` est le discriminateur polymorphe, et SQLAlchemy fixe la
classe d une instance au chargement. edit_user ecrivait donc la colonne par
un UPDATE de niveau instruction puis relisait la ligne -- c est correct.
La suite ne l etait pas.
db.session.commit()
db.session.remove() # discard stale session entirely
user = User.query.get(user_id_local)
Deux defauts dans ces trois lignes.
1. remove() jette la session entiere. Tout ce que la requete tenait encore
se retrouvait detache, current_user compris ; le moindre acces a un
attribut ensuite levait DetachedInstanceError. La route ne survivait
qu en ayant recopie le nom et l id de l acteur dans des variables
locales avant -- un contournement, pas la correction. Un expunge de la
seule instance perimee suffit.
2. Le commit intermediaire coupait l edition en deux. Le role etait acquis
avant que le reste du formulaire soit applique : une erreur ensuite
laissait un compte promu et le reste perdu, sans qu aucune interface ne
le signale. Sans ce commit, l UPDATE reste dans la transaction, la
relecture le voit, et l ensemble part en un seul commit.
Les evenements d audit passent apres le commit. account.role_changed etait
journalise avant l UPDATE : le journal affirmait un changement que la
transaction pouvait encore annuler.
tests/test_role_change.py, 6 tests. Un seul echoue sur le code d avant --
celui de l atomicite ; les cinq autres fixent le comportement qui marchait
deja, pour que la suite de la vague D ne le casse pas.
Co-Authored-By: Claude Opus 5 <[email protected]>