9.1 KiB
PTA V7.11 — Connexion par jeton, espace DSI et journal d'activité
Le problème traité
Depuis l'origine, role.php était un sélecteur, pas une authentification : n'importe qui
ouvrant l'application choisissait librement son rôle et son identité, y compris « Service Enfance »,
qui valide les plannings de toute la collectivité. Les READMEs successifs et CLAUDE.md la
désignaient comme la faille ouverte la plus importante du projet.
Deux conséquences, au-delà du contrôle d'accès lui-même :
- aucune action n'était imputable — un planning validé, un agent modifié, un lieu créé ne portaient le nom de personne ;
- rien n'était traçable : pas de journal, donc aucune façon de répondre à « qui a rouvert cette semaine ? » ou « depuis quand cet accès est-il utilisé ? ».
Ce qui change
1. On entre avec un jeton, plus avec un choix
Chaque utilisateur — agent, responsable de lieu, administrateur du Service Enfance, DSI — reçoit un
jeton personnel de la forme PTA-XXXXXX-XXXXXX-XXXXXX-XXXXXX, tiré au sort par le serveur sur
120 bits d'entropie, dans un alphabet sans caractères ambigus (ni I, ni O, ni 0, ni 1).
Il n'y a aucun mot de passe dans PTA : rien à choisir, rien à renouveler périodiquement, aucune procédure de réinitialisation à écrire. Un jeton perdu ne se retrouve pas, il se régénère, ce qui révoque l'ancien à la seconde.
Deux façons de le présenter, l'une et l'autre acceptées :
- le champ de saisie de
role.php(POST) — le jeton ne transite alors ni par l'URL, ni par l'historique du navigateur, ni par les journaux de nginx ; - le lien direct
role.php?token=…— plus commode à envoyer par courriel, mais le jeton s'inscrit dans l'historique et les journaux du serveur web, et un lien transféré donne l'accès. La session ouverte, une redirection immédiate retire le jeton de la barre d'adresse.
Le rôle, l'agent et le lieu ne sont plus déclarés par l'utilisateur : ils sont lus sur son compte.
Le reste de la mécanique de droits n'a pas bougé d'une ligne — Access interroge les mêmes vues SQL
qu'en V7.8.
2. La base ne conserve jamais le jeton
compte_acces ne stocke qu'une empreinte SHA-256 du jeton et ses onze premiers caractères, qui
servent uniquement à reconnaître une ligne dans la liste. Un vol du dump SQL ou un accès à phpMyAdmin
ne donne donc aucun accès applicatif.
Conséquence assumée, visible partout dans l'interface : un jeton n'est affiché qu'une seule fois, dans le panneau « Jetons à transmettre » qui suit sa création. Il faut le copier à ce moment-là.
L'empreinte n'est volontairement pas salée, contrairement à ce qu'on ferait d'un mot de passe : un jeton est tiré par le serveur, pas devinable par dictionnaire, et l'absence de sel est ce qui permet de retrouver le compte en une lecture d'index à la connexion plutôt qu'en comparant à chaque compte.
3. Un quatrième rôle : DSI
DSI rejoint AGENT, RESPONSABLE_STRUCTURE et SERVICE_ENFANCE dans acces_role_page et
acces_role_permission. C'est une administration technique, pas fonctionnelle : la DSI ne voit
ni planning, ni quota, ni agent — une seule page lui est ouverte, dsi.php, qui contient :
- les comptes d'accès : création, régénération de jeton, désactivation, suppression, filtres par type, par état et par nom ;
- « Générer les jetons manquants » : équipe d'un coup tous les agents et tous les lieux actifs qui n'ont pas encore de compte. C'est l'outil de mise en service — sans lui, personne ne pourrait entrer le jour du déploiement ;
- le journal d'activité : recherche filtrée et paginée, quatre chiffres de tête, purge des entrées anciennes.
Deux permissions nouvelles, can_manage_access et can_read_journal, sont accordées à la seule DSI
et refusées explicitement aux trois rôles métier.
4. Ce qui est journalisé — et ce qui ne l'est pas
journal_activite est alimenté par App\Core\Journal, jamais par des déclencheurs SQL : la base ne
sait pas qui agit, la connexion MariaDB étant la même pour tout le monde.
| Catégorie | Contenu |
|---|---|
CONNEXION |
Connexion réussie, jeton refusé, blocage anti-force-brute, déconnexion |
ECRITURE |
Toute modification en base, avec la table et l'identifiant visés |
ACCES_REFUSE |
Page interdite, rôle insuffisant, périmètre dépassé, jeton CSRF invalide |
ADMINISTRATION |
Actions de la DSI sur les comptes et le journal, purge comprise |
SYSTEME |
Réservé aux incidents techniques |
Les lectures ne sont pas tracées : elles représenteraient l'essentiel du volume pour une valeur d'audit très faible.
Deux garanties tenues par le code :
- écrire dans le journal ne peut jamais faire échouer l'action journalisée — toute erreur
d'insertion est avalée et renvoyée dans
error_log; - la trace est figée : rôle et libellé de l'acteur sont copiés au moment de l'action, et la clé
étrangère vers
compte_accesest enON DELETE SET NULL. Supprimer un compte n'efface pas son histoire, et renommer un agent ne la réécrit pas.
5. Freins et garde-fous
- Anti-force-brute : au-delà de 10 jetons refusés en 15 minutes depuis une même adresse IP, plus aucun jeton n'est même comparé, et le blocage est journalisé. Le compteur, c'est le journal lui-même — aucune table supplémentaire.
- Révocation immédiate :
Accessrelit le compte à chaque requête. Désactiver un compte, désactiver l'agent qui le porte ou fermer son lieu referme la session en cours à la requête suivante, sans attendre son expiration. - On ne se coupe pas la branche : la DSI ne peut ni désactiver ni supprimer le compte avec lequel elle est connectée, et le modèle refuse de retirer le dernier accès DSI actif.
- Purge plancher à 30 jours : une purge plus courte reviendrait à effacer les traces d'un incident avant même qu'il soit constaté.
Mise en service
-
Jouer les migrations (
docker compose up -d --builds'en charge) ;migration_v7_9e_…crée les tables, les vues, le rôle DSI et un compte DSI d'installation. -
Se connecter avec le jeton d'installation, écrit en clair dans la migration :
PTA-INSTALLATION-DSIIl est public — il est versionné dans le dépôt. La page DSI affiche un bandeau rouge tant qu'il n'a pas été remplacé.
-
Régénérer immédiatement ce jeton (bouton « Régénérer » sur la ligne du compte DSI), le copier et le conserver. Créer au passage un second compte DSI si plusieurs personnes doivent administrer.
-
Cliquer sur « Générer les jetons manquants » : un jeton par agent et par lieu actif. Le panneau les affiche tous, une seule fois — les copier avant de le refermer, puis les distribuer.
-
Créer les comptes Service Enfance un par un (ils ne correspondent à aucune identité en base, leur libellé est saisi à la main).
Perte du jeton DSI
C'est le seul cas qui ne se résout pas dans l'interface. Procédure de secours, en SQL :
DELETE FROM compte_acces WHERE id_compte = 1;
puis rejouer la migration (docker compose run --rm db-migrate après avoir touché le fichier, ou
directement l'INSERT de la section 3 de la migration) : le compte d'installation est recréé avec
son jeton public, à régénérer aussitôt.
Fichiers
| Fichier | Rôle |
|---|---|
database/migration_v7_9e_dsi_comptes_journal.sql |
Rôle DSI, compte_acces, journal_activite, trois vues, compte d'installation |
app/Core/Journal.php |
Écriture des traces ; connexion posée par app/bootstrap.php |
app/Core/Access.php |
ROLE_DSI, openSession() (remplace select()), relecture du compte, journalisation des refus |
app/Models/CompteModel.php |
Jetons : génération, empreinte, authentification, cycle de vie des comptes |
app/Models/JournalModel.php |
Lecture paginée, statistiques, compteur d'échecs, purge |
app/Controllers/RoleController.php |
Connexion par jeton (champ et lien), déconnexion, frein anti-force-brute |
app/Controllers/DsiController.php |
Les sept endpoints de l'espace DSI |
dsi.php, api/dsi_*.php |
Points d'entrée minces |
app/Views/pages/role.php |
Écran de connexion (le sélecteur de rôle a disparu) |
app/Views/partials/dsi.php, assets/js/dsi.js |
Interface de l'espace DSI |
Ce que la V7.11 ne fait pas
- Pas d'annuaire ni de SSO : le jeton reste un secret partagé hors de l'application (courriel,
papier). Un raccordement LDAP ou OpenID Connect resterait à faire, et ne toucherait que
RoleControlleretCompteModel. - Pas d'expiration automatique des jetons : un jeton vit tant que la DSI ne le régénère pas. Une
date de péremption serait une colonne de plus sur
compte_acceset une condition de plus dansv_acces_compte. - Pas d'export du journal : il se consulte à l'écran, filtre par filtre. Un export CSV demanderait un endpoint supplémentaire.
- Pas de purge automatique : la purge est déclenchée à la main par la DSI, et journalisée.