feat: connexion JSON pour l API d administration #164

Merged
Corentin merged 2 commits from feat/admin-api-login into dev 2026-09-26 15:29:12 +02:00
Owner

Ajoute la connexion JSON a l'API d'administration : POST /admin/api/auth/login,
POST /admin/api/auth/logout, GET /admin/api/auth/me. Objectif : une demonstration
Postman ou Bruno qui s'authentifie sans copier le cookie de session depuis un navigateur.

Le modele de securite ne change pas : meme session serveur en cookie HttpOnly +
SameSite=Strict, meme jeton CSRF synchroniseur, aucun jeton en local storage.
AuthService::authenticate() est reutilise tel quel (memes limitations par compte et par
IP, meme message d'echec generique, meme regeneration de session).

Anti-enumeration : mesure du canal auxiliaire par le temps

Le leurre est calibre sur un hachage argon2id REELLEMENT STOCKE, pas sur la configuration
courante. Mesure de bout en bout sur POST /admin/api/auth/login (pile jetable, 25 series
entrelacees par chemin) :

Chemin Mediane Moyenne Min Max
compte inexistant 257.4 ms 259.8 ms 239.1 ms 282.9 ms
mot de passe faux 251.4 ms 255.0 ms 240.4 ms 278.7 ms
compte verrouille 253.3 ms 254.6 ms 237.5 ms 308.2 ms
connexion reussie 252.6 ms 260.8 ms 242.2 ms 294.2 ms

Ecart maximal entre medianes : 6.0 ms, pour un ecart-type intra-echantillon de 13.8 ms --
l'ecart est sous le bruit de mesure.

Seconde mesure avec une configuration argon2 DIVERGENTE des hachages stockes (env
16384/2/1, hachages 65536/4/1), le cas qui rouvrirait l'ecart si le leurre suivait la
configuration : 256.5 / 250.7 / 252.9 ms, ecart de 5.7 ms. Un leurre calibre sur cette
configuration aurait coute 29.7 ms contre 244.0 ms pour une verification reelle (mesure
directe de password_verify() sur cette image), soit plus de 200 ms d'ecart.

Journal des tentatives non amplifiable

Une IP verrouillee est rejetee AVANT toute ecriture (testIpLockedIsRejectedBeforeAnyWrite),
ce qui borne la croissance de audit_log et login_throttle ; la purge cron
(docker/cron/scripts/purge-throttle.sh, purge-audit-log.sh) prend le relais. L'avis
d'incident du cache est journalise une seule fois via un marqueur sur disque plutot qu'un
drapeau static remis a zero a chaque requete
(testCacheFailureMarkerIsWrittenOnceNotOnEveryFailure).

Correction trouvee en relecture

referenceHashForDecoy() prenait SELECT password_hash FROM user LIMIT 1. L'anonymisation
RGPD garde la ligne user en y ecrivant password_hash = '' : servir un tel tombstone
comme reference faisait retomber le leurre sur la configuration. La requete filtre
desormais sur password_hash <> '' et trie par cle primaire (sans tri, la ligne rendue
dependait du plan choisi par le moteur).

Verifications

  • 1543 tests PHP, 4311 assertions, PHPStan niveau 6 propre
  • 334 tests JS
  • gitleaks (meme configuration que la CI) sur les commits de la branche : aucune fuite
  • environnements Postman et Bruno commis sans aucune valeur, garde automatisee dans
    tests/js/api-collection-secrets.test.js (5 surfaces verifiees)
Ajoute la connexion JSON a l'API d'administration : `POST /admin/api/auth/login`, `POST /admin/api/auth/logout`, `GET /admin/api/auth/me`. Objectif : une demonstration Postman ou Bruno qui s'authentifie sans copier le cookie de session depuis un navigateur. Le modele de securite ne change pas : meme session serveur en cookie HttpOnly + SameSite=Strict, meme jeton CSRF synchroniseur, aucun jeton en local storage. `AuthService::authenticate()` est reutilise tel quel (memes limitations par compte et par IP, meme message d'echec generique, meme regeneration de session). ## Anti-enumeration : mesure du canal auxiliaire par le temps Le leurre est calibre sur un hachage argon2id REELLEMENT STOCKE, pas sur la configuration courante. Mesure de bout en bout sur `POST /admin/api/auth/login` (pile jetable, 25 series entrelacees par chemin) : | Chemin | Mediane | Moyenne | Min | Max | |---|---|---|---|---| | compte inexistant | 257.4 ms | 259.8 ms | 239.1 ms | 282.9 ms | | mot de passe faux | 251.4 ms | 255.0 ms | 240.4 ms | 278.7 ms | | compte verrouille | 253.3 ms | 254.6 ms | 237.5 ms | 308.2 ms | | connexion reussie | 252.6 ms | 260.8 ms | 242.2 ms | 294.2 ms | Ecart maximal entre medianes : 6.0 ms, pour un ecart-type intra-echantillon de 13.8 ms -- l'ecart est sous le bruit de mesure. Seconde mesure avec une configuration argon2 DIVERGENTE des hachages stockes (env 16384/2/1, hachages 65536/4/1), le cas qui rouvrirait l'ecart si le leurre suivait la configuration : 256.5 / 250.7 / 252.9 ms, ecart de 5.7 ms. Un leurre calibre sur cette configuration aurait coute 29.7 ms contre 244.0 ms pour une verification reelle (mesure directe de `password_verify()` sur cette image), soit plus de 200 ms d'ecart. ## Journal des tentatives non amplifiable Une IP verrouillee est rejetee AVANT toute ecriture (`testIpLockedIsRejectedBeforeAnyWrite`), ce qui borne la croissance de `audit_log` et `login_throttle` ; la purge cron (`docker/cron/scripts/purge-throttle.sh`, `purge-audit-log.sh`) prend le relais. L'avis d'incident du cache est journalise une seule fois via un marqueur sur disque plutot qu'un drapeau `static` remis a zero a chaque requete (`testCacheFailureMarkerIsWrittenOnceNotOnEveryFailure`). ## Correction trouvee en relecture `referenceHashForDecoy()` prenait `SELECT password_hash FROM user LIMIT 1`. L'anonymisation RGPD garde la ligne `user` en y ecrivant `password_hash = ''` : servir un tel tombstone comme reference faisait retomber le leurre sur la configuration. La requete filtre desormais sur `password_hash <> ''` et trie par cle primaire (sans tri, la ligne rendue dependait du plan choisi par le moteur). ## Verifications - 1543 tests PHP, 4311 assertions, PHPStan niveau 6 propre - 334 tests JS - gitleaks (meme configuration que la CI) sur les commits de la branche : aucune fuite - environnements Postman et Bruno commis sans aucune valeur, garde automatisee dans `tests/js/api-collection-secrets.test.js` (5 surfaces verifiees)
feat: JSON login for the admin API under /admin/api/auth
All checks were successful
CI / secret-scan (push) Successful in 24s
CI / php-lint (push) Successful in 27s
CI / static-tests (push) Successful in 2m41s
CI / js-tests (push) Successful in 48s
e4d01564fe
POST /admin/api/auth/login, POST /admin/api/auth/logout and
GET /admin/api/auth/me let a Postman or Bruno demo authenticate without
copying the session cookie out of a browser. The security model is unchanged:
same server session in an HttpOnly, SameSite=Strict cookie, same synchroniser
CSRF token, no token in local storage.

- AuthApiController (extends MeController) reuses AuthService::authenticate()
  as-is: same per-account and per-IP throttling, same degressive backoff, same
  generic failure message, same session regeneration.
- The timing decoy is calibrated on a STORED argon2id hash rather than on the
  current configuration, so a deployment that changes ARGON2_* without
  rehashing does not reopen a measurable gap between an unknown email and an
  existing account. A successful login rehashes on the fly when the stored
  cost has drifted from the configured one.
- The account-lock path performs the same observable work as the
  unknown-email path (same decoy call, same IP counter, same audit row) and
  stays on 401. Only the IP lock is exposed, as 429 plus Retry-After.
- The cache-failure notice is logged once through an on-disk marker instead of
  a per-request static flag, so an unauthenticated visitor cannot grow the
  error log by repeating a login attempt. A locked IP is rejected before any
  write, which bounds the attempt journal.
- Cache directory guards (symlink, foreign owner, permissions, poisoned
  content, wrong cost) are covered by unit tests.
- Content-Type check on /admin/api/* compares the exact media type instead of
  a prefix, so application/jsonp is no longer accepted.
- Postman and Bruno collections regenerated from scripts/gen_postman.py and
  scripts/gen_bruno.py; the committed environments hold no value, enforced by
  tests/js/api-collection-secrets.test.js. docs/api/postman.md is replaced by
  docs/api/demo-api.md, which covers both clients.
fix: never calibrate the timing decoy on an anonymised tombstone
All checks were successful
CI / secret-scan (push) Successful in 23s
CI / php-lint (push) Successful in 26s
CI / static-tests (push) Successful in 2m34s
CI / js-tests (push) Successful in 48s
CI / secret-scan (pull_request) Successful in 22s
CI / php-lint (pull_request) Successful in 25s
CI / static-tests (pull_request) Successful in 2m27s
CI / js-tests (pull_request) Successful in 40s
0e4e5c10ae
AuthService::referenceHashForDecoy() picked a stored argon2id hash with
"SELECT password_hash FROM user LIMIT 1". Two holes:

- RGPD anonymisation (UserRepository::anonymise(), mlt 10.5) keeps the user
  row and writes password_hash = ''. An empty string is not an argon2id hash,
  so PasswordHasher::verifyDecoy() rejects it and falls back to the decoy
  calibrated on the CONFIGURATION, which is the timing gap this method exists
  to close. The shipped Postman and Bruno collections anonymise a user
  themselves, so a real deployment holds such rows.
- Without an ORDER BY, SQL guarantees no row order: which row came back
  depended on the plan the engine picked, so a tombstone could be served.

The query now filters on password_hash <> '' and orders by primary key.
AuthServiceTest::testReferenceHashQueryExcludesAnonymisedTombstones is the
regression net on the query itself (checked by mutation: removing either
clause turns it red); AuthServiceDbTest::testUnknownEmailNeverCalibrates
DecoyOnAnAnonymisedTombstone proves the property end to end against MariaDB
with a real tombstone in the table.
Corentin scheduled this pull request to auto merge when all checks succeed 2026-09-26 15:21:03 +02:00
Sign in to join this conversation.
No reviewers
No labels
auto-merge
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
AcadeNice/corentin_wakdo!164
No description provided.