diff --git a/.env.example b/.env.example index 27853b2..f42aa6c 100644 --- a/.env.example +++ b/.env.example @@ -98,6 +98,15 @@ IP_THROTTLE_MAX_ATTEMPTS=20 # par IP sur la fenetre STAFF_PIN_MIN_LENGTH=4 STAFF_PIN_MAX_LENGTH=12 +# Throttle du PIN d'action sensible (RG-T22) - compteurs SEPARES du login : la +# dimension est l'utilisateur AGISSANT (session), pas l'email cible ni l'IP. Bornes +# volontairement plus permissives que le login (controle de dissuasion) : ne pas +# bloquer un manager en plein rush sur quelques fautes de frappe. +PIN_THROTTLE_THRESHOLD=5 # echecs avant le backoff (par acteur) +PIN_THROTTLE_BASE_SECONDS=30 # 1er palier (vs 60 au login) +PIN_THROTTLE_MAX_SECONDS=300 # plafond du backoff (5 min, vs 900 au login) +PIN_THROTTLE_WINDOW_SECONDS=900 # fenetre glissante (15 min) + # Expiration du token de reinitialisation de mot de passe (secondes). PASSWORD_RESET_TTL=3600 # 1h diff --git a/db/migrations/0002_pin_throttle.sql b/db/migrations/0002_pin_throttle.sql new file mode 100644 index 0000000..3746fc2 --- /dev/null +++ b/db/migrations/0002_pin_throttle.sql @@ -0,0 +1,39 @@ +-- db/migrations/0002_pin_throttle.sql +-- ============================================================================= +-- Wakdo - Migration 0002 : pin_throttle (entite 22, RG-T22) +-- ============================================================================= +-- Purpose : Throttle des tentatives de PIN d'action sensible, par UTILISATEUR +-- AGISSANT (identite de session authentifiee, GuardResult->userId). +-- STRICTEMENT SEPARE des compteurs de connexion +-- (user.failed_login_attempts / user.lockout_until / login_throttle) +-- pour qu'un echec de PIN ne verrouille jamais la CONNEXION d'un +-- compte (pas d'escalade DoS sur la surface plus sensible). Sibling de +-- login_throttle (4.21) : meme forme, dimension differente (l'acteur, +-- pas l'IP). Le runner db/migrate.sh applique *.sql dans l'ordre +-- lexicographique via la table schema_migrations. +-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci. +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE pin_throttle ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + actor_user_id INT UNSIGNED NOT NULL, + failed_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0, + window_started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + lockout_until DATETIME NULL, + last_attempt_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_pin_throttle_actor_user_id (actor_user_id), + KEY idx_pin_throttle_lockout_until (lockout_until), + CONSTRAINT fk_pin_throttle_actor_user_id FOREIGN KEY (actor_user_id) + REFERENCES user (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Note : pas de seed. La cle est l'acteur (un user back-office authentifie), donc +-- la FK ON DELETE CASCADE est sure (contrairement a login_throttle, dont la cle +-- est une IP arbitraire et qui n'a pas de FK). La purge cron des lignes sans +-- verrou actif au-dela de THROTTLE_PURGE_AFTER_HOURS s'aligne sur login_throttle : +-- DELETE FROM pin_throttle +-- WHERE (lockout_until IS NULL OR lockout_until < NOW()) +-- AND last_attempt_at < NOW() - INTERVAL HOUR; diff --git a/docker-compose.yml b/docker-compose.yml index 303f000..d273c17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -150,6 +150,11 @@ services: # Bornes du PIN equipier (actions sensibles, P3) : longueur min ET max. STAFF_PIN_MIN_LENGTH: ${STAFF_PIN_MIN_LENGTH} STAFF_PIN_MAX_LENGTH: ${STAFF_PIN_MAX_LENGTH} + # Throttle du PIN d'action sensible (RG-T22) : compteurs SEPARES du login. + PIN_THROTTLE_THRESHOLD: ${PIN_THROTTLE_THRESHOLD} + PIN_THROTTLE_BASE_SECONDS: ${PIN_THROTTLE_BASE_SECONDS} + PIN_THROTTLE_MAX_SECONDS: ${PIN_THROTTLE_MAX_SECONDS} + PIN_THROTTLE_WINDOW_SECONDS: ${PIN_THROTTLE_WINDOW_SECONDS} # Expiration du token de reinitialisation de mot de passe (mlt.md 12.3). PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL} UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB} diff --git a/docs/journal/2026-06-15--p3-throttle-pin-rg-t22.md b/docs/journal/2026-06-15--p3-throttle-pin-rg-t22.md new file mode 100644 index 0000000..48bfc15 --- /dev/null +++ b/docs/journal/2026-06-15--p3-throttle-pin-rg-t22.md @@ -0,0 +1,211 @@ +# P3 securite — throttle du PIN d'action sensible (RG-T22), design multi-agents + verification adversariale + +**Date** : 2026-06-15 (suite de la session CRUD Produits #17) +**Branche** : working tree sur `dev` (chunk non commite ; base `dev` = `2756fb4`) +**PR** : ouverte vers `dev` apres revue de l'implementation (auto-merge sur CI verte) +**Duree estimee** : session longue (finalisation + merge Produits, puis design + build + docs Merise du throttle) + +--- + +## Ce qui a ete fait + +Deux temps dans la session. + +### 1. Finalisation et merge du CRUD Produits (PR #17) + +Le CRUD produits (cas riche : `price_cents`, `vat_rate {55,100}`, `category_id`, suppression FK-safe) +a ete termine, revu (6 findings : 1 HIGH, 1 LOW, 4 MEDIUM de couverture), corrige, puis merge sur `dev` +en auto-merge sur CI verte (squash, `dev` = `2756fb4`). La revue avait remonte un finding **HIGH** : le PIN +d'action sensible (`PinVerifier`) verifie le PIN avec parite de timing mais **sans limitation de +tentatives**. Mitigation shippee dans #17 : chaque echec ecrit une ligne `audit_log` `pin.failed` +(detectable). Le throttle complet a ete arbitre comme chunk dedie — ce qui suit. + +### 2. Throttle du PIN (RG-T22) — conception puis construction + +**Conception via un panel multi-agents** (3 lentilles independantes : Ockham / efficacite-menace / +anti-DoS) -> synthese -> passe adversariale. Le panel a tranche la **dimension** du compteur et a integre +deux correctifs d'emblee. Verdict de l'adversaire : la conception tient (`holds = true`). + +Artefacts produits (tous dans le working tree, **non commites**) : + +- `db/migrations/0002_pin_throttle.sql` — nouvelle table (entite 22), cle sur `actor_user_id` + (UNIQUE, FK -> `user` ON DELETE CASCADE), separee des compteurs de connexion. **Appliquee a la base + dev** via `bash db/migrate.sh`. +- `src/app/Auth/ThrottlePolicy.php` — dimension `'pin'` ajoutee a `fromConfig` (bornes propres + `PIN_THROTTLE_*` : base 30s, plafond 300s). +- `src/app/Auth/PinThrottle.php` (nouveau) — `isLocked` / `recordFailure` (upsert atomique + backoff, + une transaction) / `reset`. +- `src/app/Auth/PinVerifier.php` — methode additive `payTimingDecoy` (parite de timing du chemin + verrouille). +- `src/app/Controllers/ProductController.php` — cablage dans `update` (branche prix/TVA) et `destroy` : + gate avant verification, `recordFailure` sur PIN faux, `reset` apres l'effet reussi. +- Config : `.env.example` + `docker-compose.yml` (`PIN_THROTTLE_THRESHOLD/BASE/MAX/WINDOW`). +- Docs Merise portees de 21 a 22 entites : RG-T22 dans `mlt.md`, entite 22 `pin_throttle` dans + `mcd.md` / `mld.md` / `dictionary.md`, couverture MCT 22/22 dans `mct.md`. +- Tests : +16 (dimension `pin` de `ThrottlePolicy` ; `PinThrottleTest` ; cas de controleur ; leurre de + timing ; integration `PinThrottleDbTest`). **188 tests / 525 assertions verts, PHPStan L6 propre.** + +--- + +## Pourquoi — decisions et alternatives + +### Decision 1 — Compter les echecs par utilisateur AGISSANT (et non par email cible ni par IP) + +- **Decision** : la dimension du throttle est l'identite de session authentifiee qui realise l'action + (`$guard->userId`), stockee dans une table dediee `pin_throttle` cle sur `actor_user_id`. +- **Alternatives considerees** : + - *par email cible* : contournable par rotation des emails (le modele "identifiant equipier + PIN" + verifie un email arbitraire) ; + - *par IP* : sur un poste a session partagee, tous les equipiers sortent par la meme IP ; un verrou IP + priverait de re-autorisation l'ensemble des equipiers honnetes du comptoir ; + - *hybride cible + IP avec delai `usleep`* : ajoute une colonne de portee, ~6 cles de config, un `usleep` + qui retient un worker PHP-FPM, et une surface de blocage d'un collegue ; + - *globale* : un seul attaquant degraderait l'autorisation sensible de tout le magasin. +- **Raison du choix** : la cle "acteur" est la seule non-contournable (changer d'acteur impose une + reconnexion, elle-meme throttlee et auditee cote login) ET sans collateral sur un poste partage + (verrouiller l'attaquant n'affecte aucun autre `user_id`). Elle dissout la tension rotation/collateral + qui force les autres pistes a un delai par IP. Rasoir d'Ockham (#37) : une table, un collaborateur, deux + points d'appel, `PinVerifier` inchange. + +### Decision 2 — Table dediee, separee des compteurs de connexion + +- **Decision** : compteurs `pin_throttle` physiquement distincts de `user.failed_login_attempts` / + `user.lockout_until` / `login_throttle`. +- **Alternative** : reutiliser les colonnes de login existantes. +- **Raison** : un echec de PIN n'incremente aucun compteur de login ; sinon, marteler le PIN d'une victime + verrouillerait sa connexion (escalade de deni de service vers une surface plus sensible). Un test de + regression verifie l'absence d'ecriture vers `user`/`login_throttle` sur le chemin d'echec. + +### Decision 3 — Backoff plus permissif que le login + +- **Decision** : base 30s, plafond 300s (le login est a 60s / 900s). +- **Raison** : RG-T13 cadre le PIN comme un controle de dissuasion (risque residuel Faible) ; un faux + positif bloque un manager en plein rush. Le backoff reste degressif, pas un verrou definitif. + +### Decision 4 — Correctifs adversariaux integres a la conception (pas en second passage) + +- **Anti-flood de l'audit** : sous verrou actif, aucune nouvelle ligne `pin.failed` (les echecs ayant + arme le verrou sont deja audites) — sinon le chemin verrouille, moins couteux, gonflerait le journal + append-only et noierait l'alerte de volume. +- **Parite de timing** : `payTimingDecoy` paie le cout argon2id sur le chemin verrouille, pour que la + latence ne distingue pas "verrouille" de "mauvais PIN". + +### Methodo — pourquoi un panel + une passe adversariale + +Challenge Before Confirm (mantra IA-16) sur un finding de severite HIGH avec migration de schema (peu +reversible) : faire produire trois conceptions independantes, les arbitrer, puis tenter de casser la +retenue. La passe adversariale a confirme que les quatre attaques visees (rotation d'email, falsification +de `X-Forwarded-For`, contamination du compteur de login, collateral de borne partagee) echouent par +construction, et a remonte les deux correctifs ci-dessus. + +--- + +## Comment — points techniques cles + +- **Upsert atomique, miroir de la dimension IP d'`AuthService`** : `INSERT ... ON DUPLICATE KEY UPDATE + failed_attempts = IF(window_started_at < :cutoff, 1, failed_attempts + 1) ...`. L'increment est calcule + cote SQL sous le verrou de ligne pris sur la cle UNIQUE, ce qui serialise les POST concurrents (anti + lost-update). Placeholders nommes distincts car `PDO::ATTR_EMULATE_PREPARES = false` interdit de lier un + meme nom deux fois (`src/app/Auth/PinThrottle.php`). +- **Gate-before-verify** : `isLocked($actorId)` est evalue AVANT `resolveActingUser`. Un acteur verrouille + recoit le meme 422 generique "Email ou PIN invalide" (anti-enumeration) ; meme un PIN correct est bloque + tant que le verrou court. +- **Le piege du `reset`** : a un succes, deux identites sont en portee — l'acteur de session + (`$guard->userId`, celui qui a ete incremente) et l'equipier resolu par le PIN (`$actor['id']`, ecrit + dans `audit_log`). Le `reset` cible l'acteur de **session** ; le confondre laisserait le compteur de + l'agissant sans purge. Un test l'asserte explicitement (`ProductControllerTest`). +- **FK ON DELETE CASCADE** (contrairement a `login_throttle`, sans FK) : la cle est un utilisateur + back-office authentifie, donc supprimer/anonymiser le compte retire proprement sa ligne de throttle + (etat ephemere, par opposition a `audit_log` qui est permanent et en SET NULL). + +--- + +## Criteres RNCP couverts + +- **Bloc 2 - Cr 3.a / 3.b** : extension du modele Merise (dictionnaire/MCD/MLD) — entite 22 `pin_throttle`, + FK et cardinalite (assoc R9), coherence 22/22 verifiee dans les quatre docs. +- **Bloc 2 - Cr 4.e (securite)** : requetes preparees (anti-injection), reponse generique + (anti-enumeration), separation dure des compteurs (anti escalade de DoS), gate avant verification. +- **Bloc 2 - Cr 4.c (POO / namespaces)** : `PinThrottle` (classe dediee), reutilisation de `ThrottlePolicy` + (math pure), cablage via les controleurs heritant d'`AdminController`. +- **Bloc 2 - Cr 4.g (preparation livraison)** : 188 tests PHPUnit verts, PHPStan niveau 6 propre, test + d'integration contre une vraie MariaDB. +- **Bloc 2 - Cr 3.d (RGPD)** : FK ON DELETE CASCADE (l'etat de throttle suit l'anonymisation du compte) et + purge cron documentee (minimisation / limitation de conservation). +- **Bloc 5 - Cr 7.b.3 (cron) / Cr 7.d.2 (tests avant deploiement)** : predicat de purge `pin_throttle` + aligne sur `login_throttle` ; le chunk passera la CI (PHPUnit + PHPStan + secret-scan) avant merge. + +--- + +## Questions anticipees du jury + +- **Q** : "Pourquoi compter les echecs de PIN sur l'utilisateur agissant plutot que sur l'IP, comme pour le login ?" + **R** : Sur une borne a session partagee, tous les equipiers sortent par la meme IP ; un verrou par IP + les priverait tous de re-autorisation. La cle "acteur" verrouille seulement l'individu qui multiplie les + echecs, sans toucher ses collegues, et reste non-contournable (changer d'acteur impose une reconnexion, + deja throttlee cote login). + +- **Q** : "Un attaquant qui martele le PIN d'un collegue peut-il bloquer sa connexion ?" + **R** : Non. Les compteurs du PIN vivent dans une table separee (`pin_throttle`), distincte de + `user.failed_login_attempts` et de `login_throttle`. Un echec de PIN n'ecrit aucun compteur de login ; + un test de regression le verifie. + +- **Q** : "Pourquoi un backoff degressif et pas un verrou definitif ?" + **R** : Le PIN est un controle de dissuasion a risque residuel Faible ; un verrou dur bloquerait un + manager sur quelques fautes de frappe en plein service. Le backoff ralentit la force brute (de quelques + essais a une poignee par fenetre) tout en s'auto-resorbant. + +- **Q** : "Comment avez-vous valide cette conception de securite ?" + **R** : Trois conceptions independantes ont ete produites puis arbitrees, et une passe adversariale a + tente de casser la retenue (rotation d'email, falsification d'en-tete proxy, contamination du login, + collateral de borne). Les quatre echouent par construction ; la passe a aussi remonte deux correctifs + (anti-flood de l'audit, parite de timing) integres avant la fin. + +- **Q** : "Pourquoi ajouter une 22e table plutot que des colonnes sur `user` ?" + **R** : Des colonnes sur `user` devraient porter sur l'utilisateur cible (contournable par rotation) ou + ajouter une 4e dimension de verrou sur la table de comptes. Une table dediee, cle sur l'acteur, garde + `user` epuree et garantit la separation des compteurs par construction. + +--- + +## Points d'amelioration conscients + +- **Couverture CI de l'increment SQL** : les tests unitaires stubbent le compteur relu apres l'upsert + (`FakeDatabase.pinThrottleAttempts` fixe), donc la semantique reelle de l'increment + fenetre glissante + n'est prouvee que par `PinThrottleDbTest` (integration), auto-skippee sans MariaDB. C'est la posture + STANDARD du projet (CI sans Composer ni base : `AuthServiceDbTest`, `PinVerifierDbTest`... skippent de + meme) ; verifiee en local avec `WAKDO_DB_TESTS=1`. A garder en tete si la CI gagne un service DB. +- **Cron de purge non encore etendu** : le predicat de purge `pin_throttle` est documente (`mlt.md` 13.5) + mais le job cron lui-meme (`docker/cron`) n'a pas ete edite. Sans impact fonctionnel (la table tient une + ligne par utilisateur back-office) ; a brancher avec le job `login_throttle` existant. +- **Dimension par IP volontairement absente** : choix documente (collateral de borne partagee). A + reconsiderer seulement si un abus par IP est observe en pratique. +- **Detection** : l'alerte sur le volume de `pin.failed` est le vrai controle detectif ; elle reste a + outiller cote supervision (hors code applicatif). Un PIN de plus de 4 chiffres pour les roles sensibles + est recommande. + +--- + +## Etat a la reprise + +- Chunk throttle PIN complet (source + tests + migration + docs Merise + `.env.example` + compose + ce + journal), vert (188 tests, PHPStan L6), revue adversariale de l'implementation passee (`holds = true`), + commite et pousse cette session avec PR vers `dev` (auto-merge sur CI verte). Migration `0002` deja + appliquee a la base dev. +- **Prochaine action** : suite P3 : Menus (+ slots), Ingredients/stock, Users + matrice RBAC, Stats. + Differe : etendre le cron de purge a `pin_throttle` ; alerte de volume `pin.failed` (supervision). + +--- + +## Liens vers artefacts + +- CRUD Produits merge : commit `49ab77b` -> `dev` `2756fb4` (PR #17, squash). +- Throttle PIN (non commite) : `src/app/Auth/PinThrottle.php`, `src/app/Auth/ThrottlePolicy.php`, + `src/app/Auth/PinVerifier.php`, `src/app/Controllers/ProductController.php`, + `db/migrations/0002_pin_throttle.sql`. +- Tests : `tests/Unit/Auth/PinThrottleTest.php`, `tests/Unit/Auth/ThrottlePolicyTest.php`, + `tests/Unit/Admin/ProductControllerTest.php`, `tests/Integration/PinThrottleDbTest.php`, + `tests/Support/FakeDatabase.php`. +- Docs Merise (RG-T22, entite 22) : `docs/merise/{mlt,mcd,mld,dictionary,mct}.md`. +- Config : `.env.example`, `docker-compose.yml` (`PIN_THROTTLE_*`). +- Resume roulant : `docs/SESSION_RESUME.md` (entree Produits #17 = suite 4). diff --git a/docs/journal/README.md b/docs/journal/README.md index 0b8b5f8..2b7afa7 100644 --- a/docs/journal/README.md +++ b/docs/journal/README.md @@ -31,6 +31,7 @@ Les fichiers sont ordonnes chronologiquement par leur nom. | 2026-04-24 | [infra-docker](2026-04-24--infra-docker.md) | Stack Docker complete (compose + 4 services), referentiel RNCP integre, cross-check mappings Cr 4.f | `feat/infra-docker` | | 2026-04-30 | [smoke-test-infra](2026-04-30--smoke-test-infra.md) | Smoke test bout-en-bout sur serveur reel : fusion .env, switch FQDN sur stark.a3n.fr, subnet explicite RFC 1918, fix init cron + healthz | `feat/infra-docker` | | 2026-06-04 | [conception-prodlike-revision](2026-06-04--conception-prodlike-revision.md) | Revue d'alignement P1 + decisions prod-like du modele de donnees (drop commande_event, nommage EN, TVA par produit apres fact-check BOFiP, perso menus/ingredients, allergenes, ~16 entites) | `feat/p1-conception` | +| 2026-06-15 | [p3-throttle-pin-rg-t22](2026-06-15--p3-throttle-pin-rg-t22.md) | P3 securite : throttle du PIN d'action sensible (RG-T22) — design multi-agents + verification adversariale, dimension "utilisateur agissant", entite 22 `pin_throttle` | `feat/p3-pin-throttle` -> `dev` | *Mis a jour a chaque nouvelle entree.* diff --git a/docs/merise/dictionary.md b/docs/merise/dictionary.md index b818d77..e45529d 100644 --- a/docs/merise/dictionary.md +++ b/docs/merise/dictionary.md @@ -1,7 +1,7 @@ # Dictionnaire de Donnees — Wakdo **Phase Merise** : P1 - Conception, etape 1 (dictionnaire de donnees d'abord, mantra #33) -**Version** : v0.2 — prod-like, 21 entites (19 prod-like + couche security-by-design, incl. la nouvelle entite `login_throttle`) +**Version** : v0.3 — prod-like, 22 entites (19 prod-like + couche security-by-design, incl. les entites `login_throttle` et `pin_throttle`) **Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) **Branche** : `feat/p1-conception` **Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; couche security-by-design en cours (voir note 13) @@ -616,6 +616,29 @@ que 24h. --- +### 3.22 `pin_throttle` + +Throttle du PIN d'action sensible (RG-T22), complement de RG-T13. Une ligne par utilisateur AGISSANT +(l'identite de session qui soumet email+PIN), STRICTEMENT SEPAREE des compteurs de connexion +(`user.failed_login_attempts` / `login_throttle`) : un echec de PIN n'incremente aucun compteur de login. +Ajout security-by-design (voir note 13). + +| Attribut | Type | NULL | Default | Contrainte | Notes | +|---|---|---|---|---|---| +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | +| `actor_user_id` | INT UNSIGNED | NO | — | UNIQUE, FK -> `user(id)` ON DELETE CASCADE | l'utilisateur agissant (session), une ligne par acteur, upsertee | +| `failed_attempts` | SMALLINT UNSIGNED | NO | 0 | — | echecs de PIN consecutifs de cet acteur dans la fenetre courante | +| `window_started_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | debut de la fenetre de comptage courante | +| `lockout_until` | DATETIME | YES | NULL | — | fin de la fenetre de backoff degressif ; NULL = non throttle | +| `last_attempt_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | timestamp de la derniere tentative echouee | + +**FK ON DELETE CASCADE** (contrairement a `login_throttle`) : la cle est un utilisateur back-office +authentifie, donc supprimer/anonymiser le compte purge proprement sa ligne de throttle. Memes bornes de +backoff que RG-8 mais PROPRES au PIN (PIN_THROTTLE_*, plus permissives). Meme purge cron quotidienne que +`login_throttle` (lignes sans lockout actif > 24h). + +--- + ## 4. Notes de modelisation ### Note 1 — Pourquoi `INT UNSIGNED` en centimes pour les prix @@ -878,6 +901,12 @@ une surcharge forte ; un reapprovisionnement au-dessus de la bande critique rend sur `user`. Cela ajoute une seconde dimension de throttling, de sorte qu'une seule IP martelant de nombreux comptes soit ralentie independamment du compteur de n'importe quel compte. Un cron quotidien purge les lignes inactives et non verrouillees. +**Throttle du PIN d'action sensible (par acteur).** `pin_throttle` (3.22) suit `failed_attempts` et +`lockout_until` par utilisateur AGISSANT (l'identite de session qui valide une action sensible), +dans une table separee des compteurs de connexion. La dimension est l'acteur (et non l'email cible, +contournable par rotation, ni l'IP, qui penaliserait tous les equipiers d'un poste partage) ; le verrou +est un backoff degressif aux bornes propres (PIN_THROTTLE_*). Meme purge cron que `login_throttle`. RG-T22. + References : `docs/notes/revue-alignement-p1.md` §7 (decisions D), carte d'impact security-by-design (2026-06-11). Modele de menace et matrice de classification des donnees : `PROJECT_CONTEXT.md` §19 (a venir). @@ -908,18 +937,19 @@ References : `docs/notes/revue-alignement-p1.md` §7 (decisions D), carte d'impa | 19 | `stock_movement` | audit | nouveau — journal d'audit de stock append-only | | 20 | `audit_log` | audit | nouveau (security-by-design) — journal append-only d'actions sensibles | | 21 | `login_throttle` | security | nouveau (security-by-design) - throttle anti-brute-force par IP | +| 22 | `pin_throttle` | security | nouveau (security-by-design) - throttle du PIN d'action sensible par acteur (RG-T22) | **Retire de v0.1** : `commande_event` (remplace par les timestamps de phase sur `customer_order`), `menu_produit` (remplace par le modele `menu_slot` + `menu_slot_option`). -**Total : 21 entites** (19 prod-like v0.2 + `audit_log` et `login_throttle` de la -couche security-by-design). +**Total : 22 entites** (19 prod-like v0.2 + `audit_log`, `login_throttle` et `pin_throttle` +de la couche security-by-design). Le security-by-design ajoute aussi des colonnes (au-dela des deux nouvelles entites) : cycle de vie d'auth de `user` + `pin_hash` + `anonymized_at` (3.14), `customer_order.acting_user_id` + `idempotency_key` (3.10), et le modele de stock en pourcentage sur `ingredient` (3.6) — `stock_capacity`, `critical_stock_pct`, plus le renommage de `low_stock_threshold` en `low_stock_pct`. `login_throttle` (3.21) est la 21e -entite. Voir note 13. +entite et `pin_throttle` (3.22) la 22e. Voir note 13. --- diff --git a/docs/merise/mcd.md b/docs/merise/mcd.md index 16fd019..20a667c 100644 --- a/docs/merise/mcd.md +++ b/docs/merise/mcd.md @@ -1,7 +1,7 @@ # Modele Conceptuel de Donnees (MCD) — Wakdo **Phase Merise** : P1 - Conception, etape 2 (data dictionary first, mantra #33) -**Version** : v0.2 — prod-like, 21 entites (19 prod-like + couche security-by-design) +**Version** : v0.3 — prod-like, 22 entites (19 prod-like + couche security-by-design) **Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) **Branche** : `feat/p1-conception` **Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; couche security-by-design (audit_log + colonnes imputabilite/auth) en cours @@ -21,7 +21,7 @@ structure relationnelle : combien de X par Y, si la participation est obligatoir leurs propres attributs. **Sources** : -- `docs/merise/dictionary.md` (v0.2 — 21 entites, source de verite pour tous les noms, types, ENUMs) +- `docs/merise/dictionary.md` (v0.3 — 22 entites, source de verite pour tous les noms, types, ENUMs) - `docs/notes/revue-alignement-p1.md` §7 (table de decisions D1-D8 + stock) - `docs/PROJECT_CONTEXT.md` (regles metier : composition de menu, flux de commande, RBAC, modes de service) - `docs/merise/_sources/` (donnees de l'ecole : 9 categories, 53 produits, 13 menus) @@ -62,7 +62,7 @@ Les associations N-N qui portent leurs propres attributs deviennent des **entite ## 3. Decomposition par sous-domaine -Le modele de 21 entites est divise en 4 sous-domaines pour la lisibilite. Au-dela d'environ +Le modele de 22 entites est divise en 4 sous-domaines pour la lisibilite. Au-dela d'environ 5 entites, un diagramme plat unique devient difficile a lire ; la decomposition est la pratique Merise standard pour les modeles de cette taille. @@ -71,17 +71,19 @@ Merise standard pour les modeles de cette taille. | Catalogue | category, product, menu, menu_slot, menu_slot_option | 5 | | Ingredients & Stock | ingredient, product_ingredient, allergen, ingredient_allergen, stock_movement | 5 | | Order | customer_order, order_item, order_item_selection, order_item_modifier | 4 | -| RBAC & Audit | user, role, role_visible_source, permission, role_permission, audit_log, login_throttle | 7 | +| RBAC & Audit | user, role, role_visible_source, permission, role_permission, audit_log, login_throttle, pin_throttle | 8 | > **Couche security-by-design (2026-06-11)** : `audit_log` (entite 20) est un journal transverse, > append-only des actions sensibles ; il est place dans le sous-domaine RBAC & Audit parce que > ses references (`actor_user_id`, `actor_role_id`) sont des entites RBAC. `login_throttle` > (entite 21) est un throttle anti-brute-force par IP source, indexe par IP et ne portant aucune FK ; il se situe -> dans le meme sous-domaine parce qu'il protege le chemin d'authentification. Nouvelles colonnes sur des entites existantes : +> dans le meme sous-domaine parce qu'il protege le chemin d'authentification. `pin_throttle` (entite 22, +> RG-T22) est un throttle du PIN d'action sensible par utilisateur AGISSANT (FK `actor_user_id -> user`, +> ON DELETE CASCADE), compteurs separes du login. Nouvelles colonnes sur des entites existantes : > `user` cycle de vie auth + `pin_hash` + `anonymized_at`, `customer_order.acting_user_id` > + `idempotency_key`. Voir note 13 du dictionnaire. -**Note sur l'absence d'un diagramme global** : un unique diagramme ER de 21 entites serait +**Note sur l'absence d'un diagramme global** : un unique diagramme ER de 22 entites serait illisible et impossible a maintenir. La decomposition par sous-domaine ci-dessous est le choix structurel intentionnel. Chaque sous-domaine est un `erDiagram` Mermaid (faisant autorite, rendu nativement) avec un rendu SVG portable dans `docs/merise/_diagrams/` ; voir la section 11 pour les @@ -444,6 +446,14 @@ erDiagram datetime lockout_until datetime last_attempt_at } + pin_throttle { + int id PK + int actor_user_id FK,UK + smallint failed_attempts + datetime window_started_at + datetime lockout_until + datetime last_attempt_at + } user }o--|| role : "holds" role ||--o{ role_visible_source : "sees_source" @@ -451,11 +461,14 @@ erDiagram permission ||--o{ role_permission : "granted_to" user |o--o{ audit_log : "performs" role |o--o{ audit_log : "context_of" + user ||--o{ pin_throttle : "pin_throttled_as" ``` > `login_throttle` est une entite autonome sans association : elle est indexee par IP source > (`ip_address UNIQUE`), pas par un acteur modelise, donc elle ne porte aucune FK et ne se connecte a aucune -> autre entite du diagramme. +> autre entite du diagramme. `pin_throttle` (RG-T22), au contraire, est cle par l'utilisateur AGISSANT +> (`actor_user_id UNIQUE`, FK -> `user` ON DELETE CASCADE) : c'est la dimension qui rend le throttle du PIN +> non contournable par rotation d'email et sans collateral sur un poste partage. ### 7.2 Cardinalites des associations @@ -467,6 +480,7 @@ erDiagram | R4 | granted_to | permission | (0,N) | role_permission | (1,1) | Une permission peut n'etre encore accordee a aucun role (declaree au seed, pas encore distribuee) ou a plusieurs. Chaque ligne de mapping reference une permission. | | R5 | performs | user | (0,1) | audit_log | (0,N) | Une action sensible capturee sous PIN enregistre son utilisateur agissant ; les entrees automatisees/non attribuables portent NULL. Un utilisateur peut avoir journalise un nombre quelconque d'actions. ON DELETE SET NULL preserve la trace lors de l'anonymisation/suppression de l'utilisateur. | | R6 | context_of | role | (0,1) | audit_log | (0,N) | Chaque ligne d'audit peut denormaliser le role de l'acteur au moment de l'action (NULL autorise). Un role peut etre le contexte de nombreuses lignes d'audit. ON DELETE SET NULL preserve la trace. | +| R9 | pin_throttled_as | user | (1,1) | pin_throttle | (0,1) | Throttle du PIN d'action sensible (RG-T22) : au plus une ligne `pin_throttle` par utilisateur agissant (cle UNIQUE `actor_user_id`), creee au premier echec et upsertee ensuite. ON DELETE CASCADE : l'etat de throttle (ephemere) part avec le compte supprime/anonymise. | ### 7.3 Notes sur le sous-domaine RBAC @@ -500,11 +514,19 @@ de la derniere tentative echouee. Elle n'a aucune FK (une IP n'est pas une entit cron quotidien purge les lignes sans lockout actif dont le `last_attempt_at` est plus ancien que 24h. Voir dictionnaire 3.21 et note 13. +**`pin_throttle` (security-by-design, RG-T22)** : throttle du PIN d'action sensible, distinct du throttle +de connexion. La dimension est l'utilisateur AGISSANT (l'identite de session qui soumet email+PIN), pas +l'email cible (contournable par rotation) ni l'IP (qui penaliserait tous les equipiers d'un poste partage). +Une ligne par acteur (`actor_user_id UNIQUE`, FK -> `user` ON DELETE CASCADE), upsertee a chaque echec hors +verrou ; memes colonnes que `login_throttle` mais des bornes propres (PIN_THROTTLE_*, plus permissives). +Compteurs physiquement separes du login : un echec de PIN n'incremente aucun compteur de connexion. Meme +purge cron quotidienne. Association R9 (`user` 1 -- 0,N `pin_throttle`). Voir dictionnaire 3.22 et note 13. + --- ## 8. Validation croisee MCD <-> dictionnaire -Verification que les 21 entites du dictionnaire apparaissent dans le MCD et reciproquement. +Verification que les 22 entites du dictionnaire apparaissent dans le MCD et reciproquement. | # | Entite du dictionnaire (section 3) | Sous-domaine dans le MCD | Presente | |---|---|---|---| @@ -529,8 +551,9 @@ Verification que les 21 entites du dictionnaire apparaissent dans le MCD et reci | 19 | `stock_movement` (3.19) | Ingredients & Stock | Oui | | 20 | `audit_log` (3.20) | RBAC & Audit | Oui | | 21 | `login_throttle` (3.21) | RBAC & Audit | Oui | +| 22 | `pin_throttle` (3.22) | RBAC & Audit | Oui | -**Resultat** : 21/21 entites tracees (19 prod-like + `audit_log` et `login_throttle` +**Resultat** : 22/22 entites tracees (19 prod-like + `audit_log`, `login_throttle` et `pin_throttle` security-by-design). Aucune entite du dictionnaire n'est absente du MCD. Aucune entite du MCD ne tombe en dehors du dictionnaire. @@ -605,7 +628,7 @@ ecritures d'audit, reset/lockout, anonymisation). Les ajouts de la couche traite Le modele graphique faisant autorite est l'ensemble des blocs `erDiagram` Mermaid des sections 4-7, un par sous-domaine. Ils s'affichent nativement sur Forgejo et GitHub. Le MCD est decompose par -sous-domaine a dessein : un unique diagramme de 21 entites ne peut etre dispose sans croisement de +sous-domaine a dessein : un unique diagramme de 22 entites ne peut etre dispose sans croisement de lignes de relation (limite de planarite intrinseque, et `erDiagram` n'offre aucun controle de mise en page manuel). Chaque sous-domaine reste a 5-8 entites, ce que la mise en page automatique gere proprement. La vue integree a travers les sous-domaines est la table de validation croisee de la section 8. diff --git a/docs/merise/mct.md b/docs/merise/mct.md index 1e1dff0..0295db2 100644 --- a/docs/merise/mct.md +++ b/docs/merise/mct.md @@ -668,5 +668,10 @@ peut etre formalisee lorsque l'UI d'audit sera specifiee en P3). il est lu ET ecrit (upserte) par `AUTHENTICATE_USER` (25). Sa purge quotidienne des lignes obsoletes est un cron, documente dans `mlt.md`, hors du perimetre des operations MCT. -**Conclusion** : 21/21 entites couvertes (19 prod-like + `audit_log` + `login_throttle`). Coherence MCT <-> MCD -validee. +(****) `pin_throttle` (entite 22, security-by-design, RG-T22) est le verrou de throttling du PIN d'action +sensible par utilisateur AGISSANT : il est lu (gate avant verification) ET ecrit (upserte sur echec, remis +a zero sur succes) par les operations sensibles sous PIN (ex. UPDATE_PRODUCT prix/TVA, DELETE_PRODUCT). Sa +purge quotidienne suit celle de `login_throttle` (cron, `mlt.md`), hors du perimetre des operations MCT. + +**Conclusion** : 22/22 entites couvertes (19 prod-like + `audit_log` + `login_throttle` + `pin_throttle`). +Coherence MCT <-> MCD validee. diff --git a/docs/merise/mld.md b/docs/merise/mld.md index 7f84821..8d0c57d 100644 --- a/docs/merise/mld.md +++ b/docs/merise/mld.md @@ -1,7 +1,7 @@ # Modele Logique de Donnees (MLD) — Wakdo **Phase Merise** : P1 - Conception, etape 5 (apres MCD, MCT, MLT) -**Version** : v0.2 — prod-like, 21 tables (19 prod-like + couche security-by-design) +**Version** : v0.3 — prod-like, 22 tables (19 prod-like + couche security-by-design) **Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) **Branche** : `feat/p1-conception` **Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; couche security-by-design (audit_log + colonnes imputabilite/auth) en cours @@ -93,14 +93,14 @@ en plus de la PK composite des FK. Applique a `product_ingredient`. --- -## 4. Schema relationnel (21 tables) +## 4. Schema relationnel (22 tables) Les tables sont ordonnees par dependance (tables sans FK d'abord, puis tables qui en dependent). ### Diagrammes relationnels (par sous-domaine) Le schema relationnel est presente sous forme de quatre vues Mermaid `erDiagram`, une par sous-domaine (meme -decomposition que le MCD ; un unique diagramme de 21 tables ne se disposerait pas proprement). Elles different +decomposition que le MCD ; un unique diagramme de 22 tables ne se disposerait pas proprement). Elles different du MCD : les entites associatives sont resolues en tables de jointure avec PK composites, le polymorphisme de `order_item` apparait sous forme de deux FK nullables (`product_id` / `menu_id`), et chaque cle etrangere est explicite. Les horodatages d'audit (`created_at` / `updated_at`) sont presents sur la plupart des @@ -358,6 +358,14 @@ erDiagram datetime lockout_until datetime last_attempt_at } + pin_throttle { + int id PK + int actor_user_id FK,UK + smallint failed_attempts + datetime window_started_at + datetime lockout_until + datetime last_attempt_at + } role ||--o{ user : "role_id (RESTRICT)" role ||--o{ role_visible_source : "role_id (CASCADE)" @@ -365,10 +373,12 @@ erDiagram permission ||--o{ role_permission : "permission_id (CASCADE)" user ||--o{ audit_log : "actor_user_id (SET NULL, nullable)" role ||--o{ audit_log : "actor_role_id (SET NULL, nullable)" + user ||--o{ pin_throttle : "actor_user_id (CASCADE)" ``` > `login_throttle` n'a pas de FK (une IP n'est pas une entite modelisee) ; elle est autonome, cle par -> `ip_address`. +> `ip_address`. `pin_throttle` (RG-T22) est cle par `actor_user_id` (FK -> `user`, ON DELETE CASCADE) : +> le throttle du PIN porte sur l'utilisateur AGISSANT, dimension distincte du login. --- @@ -1083,6 +1093,37 @@ Pas de `updated_at` : les lignes sont upsertees par IP, pas editees via une UI. --- +### 4.22 `pin_throttle` + +Throttle du PIN d'action sensible par utilisateur AGISSANT (security-by-design, RG-T22). Separe des +compteurs de connexion (`user.failed_login_attempts` / `lockout_until` / `login_throttle`) : un echec de +PIN n'incremente aucun compteur de login. + +``` +pin_throttle (id, actor_user_id, failed_attempts, window_started_at, + [lockout_until], last_attempt_at) + + PK : id + UK : actor_user_id + IDX : lockout_until + FK : actor_user_id -> user(id) ON DELETE CASCADE +``` + +| Colonne | Type | NULL | Notes | +|---|---|---|---| +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `actor_user_id` | INT UNSIGNED | NO | Utilisateur agissant (session), une ligne par acteur, upsertee. UNIQUE. FK -> `user(id)` ON DELETE CASCADE | +| `failed_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Echecs de PIN consecutifs de cet acteur dans la fenetre courante | +| `window_started_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Debut de la fenetre de comptage courante | +| `lockout_until` | DATETIME | YES | Fin de la fenetre de backoff degressif ; NULL = pas throttle | +| `last_attempt_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Horodatage de la derniere tentative echouee | + +FK ON DELETE CASCADE (contrairement a `login_throttle`) : la cle est un user back-office authentifie, donc +supprimer/anonymiser le compte purge sa ligne de throttle. Append/upsert par acteur ; meme purge cron que +`login_throttle`. Pas de `updated_at` (lignes upsertees, pas editees via une UI). + +--- + ## 5. Resume de l'integrite referentielle | Colonne FK | References | ON DELETE | Justification | @@ -1115,6 +1156,7 @@ Pas de `updated_at` : les lignes sont upsertees par IP, pas editees via une UI. | `customer_order.acting_user_id` | `user(id)` | SET NULL | Attribution du personnel preservee comme principal anonymise ; commande conservee | | `audit_log.actor_user_id` | `user(id)` | SET NULL | Piste d'audit preservee a l'anonymisation de l'utilisateur ; seul le lien est rompu | | `audit_log.actor_role_id` | `role(id)` | SET NULL | Contexte de role conserve jusqu'a la suppression du role ; denormalise donc il survit a l'anonymisation de l'utilisateur | +| `pin_throttle.actor_user_id` | `user(id)` | CASCADE | Etat de throttle ephemere : il part avec le compte agissant supprime/anonymise (contrairement a l'audit, permanent) | **Cle utilisee** : CASCADE = l'enfant n'a pas de sens sans le parent ; RESTRICT = la suppression du parent est bloquee tant que des enfants existent ; SET NULL = l'enfant est preserve, seul le lien est rompu. @@ -1175,6 +1217,7 @@ MCT / MLT. | `audit_log` | `(entity_type, entity_id)` | "qu'est-il arrive a ce produit/commande/utilisateur ?" | | `audit_log` | `(action_code, created_at)` | Audit par type d'action sur une plage de temps | | `login_throttle` | `lockout_until` | Purge cron quotidienne des lignes sans verrouillage actif | +| `pin_throttle` | `lockout_until` | Purge cron quotidienne des lignes sans verrouillage actif (RG-T22) | **Index non ajoutes** (intentionnel) : - `customer_order.order_number` : l'index UK suffit ; aucune requete de plage attendue sur cette colonne. @@ -1186,7 +1229,7 @@ MCT / MLT. ## 8. Validation croisee MLD <-> MCD -Verification que les 21 entites MCD (19 prod-like + 2 security-by-design) correspondent a une table, +Verification que les 22 entites MCD (19 prod-like + 3 security-by-design) correspondent a une table, et que toutes les tables se rattachent au MCD. | Entite MCD | Table MLD | Type de mapping | Notes | @@ -1212,8 +1255,9 @@ et que toutes les tables se rattachent au MCD. | `stock_movement` (C19) | `stock_movement` (4.19) | entite 1:1 | Nouvelle entite (v0.2) | | `audit_log` (R5/R6) | `audit_log` (4.20) | entite 1:1 | Nouvelle entite (security-by-design) | | `login_throttle` (R7) | `login_throttle` (4.21) | entite 1:1 | Nouvelle entite (security-by-design) | +| `pin_throttle` (R9) | `pin_throttle` (4.22) | entite 1:1 | Nouvelle entite (security-by-design, RG-T22) | -**Resultat** : 21/21 entites mappees (19 prod-like + `audit_log` + `login_throttle`). Aucune entite +**Resultat** : 22/22 entites mappees (19 prod-like + `audit_log` + `login_throttle` + `pin_throttle`). Aucune entite sans table ; aucune table hors du MCD. Nouvelles colonnes sur les tables existantes : `user` (cycle de vie auth + `pin_hash` + `anonymized_at`), `customer_order` (`idempotency_key`, `acting_user_id`), `ingredient` (`stock_capacity`, `low_stock_pct`, `critical_stock_pct` ; @@ -1250,6 +1294,7 @@ sur `customer_order` — decision 2.A) ; le modele de composition fixe `menu_pro | `stock_movement` | ~500k | 180 octets | ~90 MB | | `audit_log` | ~5k-10k | 200 octets | ~2 MB | | `login_throttle` | ~100-1k | 80 octets | < 1 MB | +| `pin_throttle` | ~10-100 | 80 octets | < 1 MB (1 ligne par user back-office) | **Total estime** : ~190 MB de donnees + ~60-80 MB pour les index = ~250-270 MB sur 6 mois (`audit_log` est negligeable : les actions sensibles sont d'un ordre de grandeur plus rares que les commandes). @@ -1300,6 +1345,7 @@ ingredient ; il portera une amplification d'ecriture significative a l'echelle. - `stock_movement` (depend de `ingredient`, `customer_order`, `user`) - `audit_log` (depend de `user`, `role`) - `login_throttle` (pas de FK, peut etre cree a n'importe quel moment) + - `pin_throttle` (FK `actor_user_id -> user`, donc apres le bloc `user`) Note : `customer_order` porte desormais `acting_user_id -> user`, donc `user` doit etre cree avant `customer_order` (deja le cas : le bloc RBAC precede `customer_order`). diff --git a/docs/merise/mlt.md b/docs/merise/mlt.md index db30820..37b626f 100644 --- a/docs/merise/mlt.md +++ b/docs/merise/mlt.md @@ -4,7 +4,7 @@ **Version** : v0.2 — prod-like, machine a 4 etats (+ couche security-by-design 2026-06-11) **Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) **Branche** : `feat/p1-conception` -**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; regles security-by-design ajoutees (RG-T13-T21 : PIN, audit, escaping, allowlists, idempotence, decrement atomique, disponibilite produit calculee (RG-T21) ; ops RESET_PASSWORD, ERASE_USER_PII, throttling d'authentification ; table de throttle par IP `login_throttle`) +**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; regles security-by-design ajoutees (RG-T13-T22 : PIN, audit, escaping, allowlists, idempotence, decrement atomique, disponibilite produit calculee (RG-T21), throttling du PIN d'action sensible par utilisateur agissant (RG-T22) ; ops RESET_PASSWORD, ERASE_USER_PII, throttling d'authentification ; tables de throttle `login_throttle` (par IP) et `pin_throttle` (par acteur)) **Auteur** : BYAN (couche methodologie) --- @@ -58,6 +58,7 @@ Ces regles s'appliquent a plusieurs operations et sont centralisees ici pour evi | **RG-T19** | **Idempotence** : `POST /api/orders` porte un `idempotency_key` (UUID) genere par le client. Avant de creer, le rechercher sur `customer_order.idempotency_key` (UNIQUE) ; si une ligne existe, retourner cette commande au lieu de creer un doublon (retry reseau rejoue). | 3.3, 4.1 | | **RG-T20** | **Decrement de stock atomique** : pendant la transition `paid`, chaque `ingredient` affecte est decremente par une unique instruction auto-verrouillante `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` — pas de lecture-gate prealable, pas de `SELECT ... FOR UPDATE`. Les commandes concurrentes sur le meme ingredient appliquent leurs deltas sans perte de mise a jour et sans souci d'ordonnancement de deadlock. `stock_quantity` est signe et peut devenir negatif quand les ventes depassent le stock compte (l'ampleur de la survente est remontee aux managers) ; le decrement ne bloque pas sur un plancher. | 3.3, 4.1 | | **RG-T21** | **Disponibilite produit calculee** : la commandabilite effective d'un produit est calculee, pas stockee. Il est commandable lorsque `product.is_available = 1` ET que chaque ingredient non retirable (`is_removable = 0`) de son `product_ingredient` a `stock_quantity > stock_capacity * critical_stock_pct / 100`. A la bande critique, un ingredient requis met le produit en rupture sans ecriture et sans cascade ; un reapprovisionnement au-dessus de la bande critique le rend commandable a nouveau de lui-meme ; un retrait manuel (`product.is_available = 0`) est une surcharge forte ; un ingredient retirable/optionnel a la bande critique ne bloque pas le produit (seul son supplement devient indisponible). | 3.1, 3.3, 4.1, 5.1 | +| **RG-T22** | **Throttling du PIN d'action sensible** (complement de RG-T13). Les tentatives de PIN echouees sont comptabilisees PAR UTILISATEUR AGISSANT (l'identite de session authentifiee qui soumet email+PIN, RG-T02), dans une table dediee `pin_throttle` (entite 22) STRICTEMENT SEPAREE des compteurs de connexion (`user.failed_login_attempts` / `user.lockout_until` / `login_throttle`) : un echec de PIN n'incremente aucun compteur de login, sinon spammer le PIN d'une victime verrouillerait sa CONNEXION (escalade de DoS sur une surface plus sensible). La dimension est l'AGISSANT et non l'email cible (un compteur par email cible serait contourne par rotation d'emails, RG-T13 verifiant un email arbitraire) ni l'IP (un verrou par IP priverait de re-autorisation tous les equipiers honnetes d'un poste a session partagee). A chaque echec hors verrou : upsert atomique de la ligne cle sur `actor_user_id` (insert sinon increment ; fenetre glissante reinitialisee via `window_started_at` quand elle expire), `last_attempt_at = NOW()`, et au-dela d'un seuil (suggestion 5) pose `lockout_until` avec le meme backoff degressif que RG-8 mais des bornes propres (PIN_THROTTLE_*, plus permissives : base 30s, plafond 300s — ne pas bloquer un manager en plein rush). Backoff degressif, pas verrou definitif. Le verrou est evalue AVANT la verification argon2id ; un acteur verrouille recoit le MEME message generique 'Email ou PIN invalide' (ne revele ni l'existence d'un compte ni l'etat de verrou, RG-2) et l'on paie un leurre de timing pour egaliser la latence avec le chemin mauvais-PIN. Sous verrou actif, aucune nouvelle ligne `audit_log` `pin.failed` n'est ecrite (les echecs ayant arme le verrou sont deja audites), ce qui borne l'amplification de l'audit append-only (RG-T14). En cas d'erreur de lecture du throttle, la requete echoue (fail-closed, pas de contournement silencieux du verrou). Le hook est pose sur la branche de changement sensible dans `update` (prix/TVA) et inconditionnellement dans `delete`. Purge cron des lignes sans verrou actif au-dela de THROTTLE_PURGE_AFTER_HOURS, comme `login_throttle`. Detection : un pic de `pin.failed` reste le controle detectif (alerte de volume) ; un PIN de plus de 4 chiffres pour les roles sensibles est recommande. | 8.2, 8.3, 8.6, 9.2, 10.1-10.5 | --- @@ -659,6 +660,10 @@ techniques, pas de declencheur utilisateur) mais sont documentes ici par coheren | **[RG-2]** | Les lignes servant encore un verrouillage actif sont conservees ; le compteur par IP (S1) est borne par cette purge de sorte que la table ne croit pas de maniere illimitee a cause de tentatives ponctuelles. | | **[POST-1]** | Lignes `login_throttle` obsoletes retirees ; throttles actifs et activite recente preserves. | +La meme purge s'applique a `pin_throttle` (RG-T22), avec le meme predicat et le meme +seuil `THROTTLE_PURGE_AFTER_HOURS` : +`DELETE FROM pin_throttle WHERE (lockout_until IS NULL OR lockout_until < NOW()) AND last_attempt_at < NOW() - INTERVAL 24 HOUR`. + --- ## 14. Machine a etats — recapitulatif de coherence (MLT) diff --git a/src/app/Auth/PinThrottle.php b/src/app/Auth/PinThrottle.php new file mode 100644 index 0000000..0066cba --- /dev/null +++ b/src/app/Auth/PinThrottle.php @@ -0,0 +1,142 @@ + non + * verrouille (defensif). + */ + public function isLocked(int $actorUserId, ?int $now = null): bool + { + if ($actorUserId <= 0) { + return false; + } + + $now ??= time(); + + $row = $this->db->fetch( + 'SELECT lockout_until FROM pin_throttle WHERE actor_user_id = :uid', + ['uid' => $actorUserId], + ); + + $lockoutUntil = is_string($row['lockout_until'] ?? null) ? (string) $row['lockout_until'] : null; + + return ThrottlePolicy::fromConfig($this->config, 'pin')->isLockedUntil($lockoutUntil, $now); + } + + /** + * Enregistre un echec de PIN pour l'utilisateur agissant, en une transaction + * (RG-T08) : upsert atomique du compteur (fenetre glissante reinitialisee en SQL + * si expiree, verrou de ligne anti lost-update) puis pose du verrou degressif. + * Ne touche JAMAIS user ni login_throttle (RG-T22) et n'ecrit pas d'audit_log. + */ + public function recordFailure(int $actorUserId, ?int $now = null): void + { + if ($actorUserId <= 0) { + return; + } + + $now ??= time(); + $nowDt = date('Y-m-d H:i:s', $now); + $windowSeconds = $this->config->int('PIN_THROTTLE_WINDOW_SECONDS', 900); + $windowCutoff = date('Y-m-d H:i:s', $now - $windowSeconds); + $policy = ThrottlePolicy::fromConfig($this->config, 'pin'); + + $this->db->transaction(function (DatabaseInterface $db) use ($actorUserId, $nowDt, $windowCutoff, $policy, $now): void { + // Increment ATOMIQUE cote SQL sous le verrou de ligne pris par l'upsert + // (anti lost-update sous POSTs concurrents). Placeholders distincts : en + // prepare reelle (EMULATE_PREPARES = false) un meme nom ne peut etre lie + // qu'une fois. Meme forme que AuthService (dimension IP). + $db->execute( + 'INSERT INTO pin_throttle (actor_user_id, failed_attempts, window_started_at, last_attempt_at) ' + . 'VALUES (:uid, 1, :now_i, :now_li) ' + . 'ON DUPLICATE KEY UPDATE ' + . 'failed_attempts = IF(window_started_at < :cutoff, 1, failed_attempts + 1), ' + . 'window_started_at = IF(window_started_at < :cutoff2, :now_w, window_started_at), ' + . 'last_attempt_at = :now_lu', + [ + 'uid' => $actorUserId, + 'now_i' => $nowDt, + 'now_li' => $nowDt, + 'cutoff' => $windowCutoff, + 'cutoff2' => $windowCutoff, + 'now_w' => $nowDt, + 'now_lu' => $nowDt, + ], + ); + + // Relit le compteur autoritaire (ligne deja verrouillee par cette tx) + // pour calculer le backoff en PHP, puis pose le verrou. + $row = $db->fetch('SELECT failed_attempts FROM pin_throttle WHERE actor_user_id = :uid', ['uid' => $actorUserId]); + $attempts = (int) ($row['failed_attempts'] ?? 1); + $lockSeconds = $policy->lockoutSeconds($attempts); + $lockUntil = $lockSeconds > 0 ? date('Y-m-d H:i:s', $now + $lockSeconds) : null; + + $db->execute( + 'UPDATE pin_throttle SET lockout_until = :lock WHERE actor_user_id = :uid', + ['lock' => $lockUntil, 'uid' => $actorUserId], + ); + }); + } + + /** + * PIN valide : remet a zero le compteur de l'utilisateur agissant (un manager + * qui s'est trompe puis a reussi n'est pas penalise plus tard). UPDATE simple + * (0 ligne si aucune n'existait, benin), SANS transaction propre : le controleur + * l'appelle apres l'effet reussi, sur sa propre connexion. + */ + public function reset(int $actorUserId, ?int $now = null): void + { + if ($actorUserId <= 0) { + return; + } + + $now ??= time(); + $nowDt = date('Y-m-d H:i:s', $now); + + $this->db->execute( + 'UPDATE pin_throttle SET failed_attempts = 0, lockout_until = NULL, ' + . 'window_started_at = :now_w, last_attempt_at = :now_l WHERE actor_user_id = :uid', + ['now_w' => $nowDt, 'now_l' => $nowDt, 'uid' => $actorUserId], + ); + } +} diff --git a/src/app/Auth/PinVerifier.php b/src/app/Auth/PinVerifier.php index 99e1d7c..4bcf4c7 100644 --- a/src/app/Auth/PinVerifier.php +++ b/src/app/Auth/PinVerifier.php @@ -98,6 +98,18 @@ final class PinVerifier return ['id' => (int) ($row['id'] ?? 0), 'role_id' => (int) ($row['role_id'] ?? 0)]; } + /** + * Paie le cout de hachage d'un leurre argon2id sans verifier de PIN reel. Sert + * au chemin "acteur verrouille" (RG-T22) : quand le throttle bloque AVANT toute + * verification, on paie quand meme ce cout pour egaliser le timing avec le + * chemin mauvais-PIN. Sans lui, une reponse verrouillee reviendrait en + * microsecondes (aucun verify) et trahirait l'etat de verrou par la latence. + */ + public function payTimingDecoy(string $pin): void + { + $this->hasher->verifyDecoy($pin); + } + /** * Politique de PIN a verifier cote serveur avant de hacher un nouveau PIN * (P3, definition du PIN) : chiffres ASCII uniquement, bornes min ET max diff --git a/src/app/Auth/ThrottlePolicy.php b/src/app/Auth/ThrottlePolicy.php index e513cb3..130a970 100644 --- a/src/app/Auth/ThrottlePolicy.php +++ b/src/app/Auth/ThrottlePolicy.php @@ -11,9 +11,11 @@ use App\Core\Config; * superglobale : c'est le calcul de securite le plus delicat (backoff degressif * + evaluation du verrou), donc isole ici pour etre entierement testable. * - * La meme courbe sert aux deux dimensions : par compte (user.lockout_until, - * seuil ACCOUNT_LOCKOUT_THRESHOLD) et par IP source (login_throttle.lockout_until, - * seuil IP_THROTTLE_MAX_ATTEMPTS), instanciees via fromConfig(). + * La meme courbe sert a trois dimensions, instanciees via fromConfig() : par + * compte (user.lockout_until, seuil ACCOUNT_LOCKOUT_THRESHOLD) et par IP source + * (login_throttle.lockout_until, seuil IP_THROTTLE_MAX_ATTEMPTS) pour la connexion + * (RG-8), et par utilisateur agissant (pin_throttle.lockout_until, RG-T22) pour le + * PIN d'action sensible, avec ses propres bornes (PIN_THROTTLE_*, plus permissives). */ final class ThrottlePolicy { @@ -67,12 +69,24 @@ final class ThrottlePolicy } /** - * Construit la politique pour la dimension 'account' (par compte) ou 'ip' - * (par IP source). RG-8 precise "le meme backoff degressif" pour l'IP, donc - * la dimension IP reutilise base/max et prend IP_THROTTLE_MAX_ATTEMPTS comme seuil. + * Construit la politique pour la dimension 'account' (par compte), 'ip' (par IP + * source) ou 'pin' (par utilisateur agissant, RG-T22). RG-8 precise "le meme + * backoff degressif" pour l'IP, donc la dimension IP reutilise base/max et prend + * IP_THROTTLE_MAX_ATTEMPTS comme seuil. La dimension 'pin' a ses PROPRES bornes + * (PIN_THROTTLE_*) : volontairement plus permissives que le login (base 30s, + * plafond 300s) car un faux positif bloque un manager en plein rush et le PIN + * est un controle de dissuasion (residuel Faible). */ public static function fromConfig(Config $config, string $dimension): self { + if ($dimension === 'pin') { + return new self( + $config->int('PIN_THROTTLE_THRESHOLD', 5), + $config->int('PIN_THROTTLE_BASE_SECONDS', 30), + $config->int('PIN_THROTTLE_MAX_SECONDS', 300), + ); + } + $base = $config->int('ACCOUNT_LOCKOUT_BASE_SECONDS', 60); $max = $config->int('ACCOUNT_LOCKOUT_MAX_SECONDS', 900); diff --git a/src/app/Controllers/ProductController.php b/src/app/Controllers/ProductController.php index 72a49a0..6a487f4 100644 --- a/src/app/Controllers/ProductController.php +++ b/src/app/Controllers/ProductController.php @@ -8,6 +8,7 @@ use PDOException; use App\Auth\Csrf; use App\Auth\GuardResult; use App\Auth\PasswordHasher; +use App\Auth\PinThrottle; use App\Auth\PinVerifier; use App\Catalogue\CategoryRepository; use App\Catalogue\ProductRepository; @@ -142,9 +143,22 @@ class ProductController extends AdminController } // Changement sensible : exige email + PIN (modele equipier + PIN, RG-T13). + // RG-T22 : verrou de throttle PIN par UTILISATEUR AGISSANT (session), evalue + // AVANT la verification argon2id. Un acteur verrouille recoit le MEME 422 + // generique ; on paie un leurre de timing (parite avec le chemin mauvais-PIN) + // et on n'ecrit PAS de nouvelle ligne pin.failed (les echecs ayant arme le + // verrou sont deja audites : borne l'amplification de l'audit append-only). + $actorId = $guard->userId ?? 0; + if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) { + $this->pinVerifier()->payTimingDecoy($form['pin'] ?? ''); + + return $this->renderForm($guard, $id, $form, ['pin' => 'Email ou PIN invalide (requis pour modifier prix/TVA).'], 422); + } + $actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? ''); if ($actor === null) { $this->logFailedPin(trim($form['pin_email'] ?? ''), $id); + $this->pinThrottle()->recordFailure($actorId); return $this->renderForm($guard, $id, $form, ['pin' => 'Email ou PIN invalide (requis pour modifier prix/TVA).'], 422); } @@ -156,6 +170,12 @@ class ProductController extends AdminController $this->writeAudit($db, 'product.update', $actor['id'], $actor['role_id'], $id, $summary); }); + // PIN valide : reinitialise le compteur de throttle de l'acteur de SESSION + // (RG-T22), apres l'effet reussi. Cle = $actorId ($guard->userId), la meme + // qu'a l'increment ; surtout PAS $actor['id'] (l'equipier resolu par le PIN, + // un autre individu) sinon le compteur de l'agissant ne serait jamais purge. + $this->pinThrottle()->reset($actorId); + $this->setFlash('Produit mis a jour (changement de prix/TVA trace).'); return $this->redirect('/admin/products'); @@ -201,9 +221,19 @@ class ProductController extends AdminController return $this->notFound($guard); } + // RG-T22 : meme garde que update() (verrou par utilisateur agissant, AVANT + // la verification, leurre de timing, pas de pin.failed sous verrou actif). + $actorId = $guard->userId ?? 0; + if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) { + $this->pinVerifier()->payTimingDecoy($form['pin'] ?? ''); + + return $this->renderDelete($guard, $id, $product, 'Email ou PIN invalide (requis pour supprimer).'); + } + $actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? ''); if ($actor === null) { $this->logFailedPin(trim($form['pin_email'] ?? ''), $id); + $this->pinThrottle()->recordFailure($actorId); return $this->renderDelete($guard, $id, $product, 'Email ou PIN invalide (requis pour supprimer).'); } @@ -225,6 +255,11 @@ class ProductController extends AdminController throw $exception; } + // PIN valide et suppression effective : reinitialise le compteur de l'acteur + // de session (RG-T22, cle = $actorId). Apres le try/catch : non atteint si la + // FK a bloque (422), ce qui est benin (l'acteur n'est pas un attaquant). + $this->pinThrottle()->reset($actorId); + $this->setFlash('Produit supprime.'); return $this->redirect('/admin/products'); @@ -245,6 +280,11 @@ class ProductController extends AdminController return new PinVerifier($this->db(), $this->config, $this->passwordHasher()); } + protected function pinThrottle(): PinThrottle + { + return new PinThrottle($this->db(), $this->config); + } + protected function passwordHasher(): PasswordHasher { return new PasswordHasher($this->config); @@ -330,8 +370,11 @@ class ProductController extends AdminController * le brute-force d'attribution detectable/alertable (un pic de pin.failed pour * un email cible est visible en revue). Acteur inconnu (PIN non resolu). * - * NB : ce n'est PAS un verrou. Un throttling degressif du PIN (par compte/IP) - * reste a ajouter en hardening dedie (decision de schema, cf. SESSION_RESUME). + * NB : cette ligne d'audit n'est PAS le verrou. Le throttle degressif (par + * utilisateur agissant) est porte par PinThrottle / RG-T22 ; il ecrit une + * nouvelle ligne pin.failed UNIQUEMENT hors verrou actif (sous verrou, les + * echecs ayant arme le verrou sont deja audites), ce qui borne l'amplification + * de l'audit append-only (RG-T14). */ private function logFailedPin(string $email, int $productId): void { diff --git a/tests/Integration/PinThrottleDbTest.php b/tests/Integration/PinThrottleDbTest.php new file mode 100644 index 0000000..2549e40 --- /dev/null +++ b/tests/Integration/PinThrottleDbTest.php @@ -0,0 +1,123 @@ +config = new Config(); + $this->db = new Database($this->config); + + try { + $this->db->fetch('SELECT 1'); + } catch (Throwable $exception) { + self::markTestSkipped('Base injoignable: ' . $exception->getMessage()); + } + + $roleRow = $this->db->fetch('SELECT id FROM role ORDER BY id LIMIT 1'); + $roleId = (int) ($roleRow['id'] ?? 0); + self::assertGreaterThan(0, $roleId, 'role seede attendu'); + + $hasher = new PasswordHasher($this->config); + $this->db->execute( + 'INSERT INTO user (email, password_hash, first_name, last_name, role_id, is_active) ' + . 'VALUES (:email, :pwd, :fn, :ln, :role, 1)', + [ + 'email' => 'it-pinthr-' . bin2hex(random_bytes(6)) . '@wakdo.invalid', + 'pwd' => $hasher->hash('IntegrationPass1'), + 'fn' => 'Integration', + 'ln' => 'PinThrottle', + 'role' => $roleId, + ], + ); + $this->userId = (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); + } + + protected function tearDown(): void + { + if ($this->userId === 0) { + return; + } + + // FK ON DELETE CASCADE : la ligne pin_throttle de cet acteur part avec lui. + $this->db->execute('DELETE FROM user WHERE id = :id', ['id' => $this->userId]); + $this->userId = 0; + } + + private function throttle(): PinThrottle + { + return new PinThrottle($this->db, $this->config); + } + + public function testRecordFailureIncrementsAndLocksWithoutTouchingLoginCounters(): void + { + $now = time(); + $throttle = $this->throttle(); + + for ($i = 0; $i < 5; $i++) { + $throttle->recordFailure($this->userId, $now); + } + + $row = $this->db->fetch('SELECT failed_attempts, lockout_until FROM pin_throttle WHERE actor_user_id = :id', ['id' => $this->userId]); + self::assertNotNull($row); + self::assertSame(5, (int) ($row['failed_attempts'] ?? 0)); + self::assertNotNull($row['lockout_until'] ?? null, 'verrou pose au seuil'); + self::assertTrue(strtotime((string) $row['lockout_until']) > $now, 'verrou dans le futur'); + + self::assertTrue($throttle->isLocked($this->userId, $now)); + + // ISOLATION : aucun compteur de connexion touche par les echecs de PIN. + $userRow = $this->db->fetch('SELECT failed_login_attempts, lockout_until FROM user WHERE id = :id', ['id' => $this->userId]); + self::assertSame(0, (int) ($userRow['failed_login_attempts'] ?? -1)); + self::assertNull($userRow['lockout_until'] ?? null); + } + + public function testResetClearsTheActorRow(): void + { + $now = time(); + $throttle = $this->throttle(); + + for ($i = 0; $i < 5; $i++) { + $throttle->recordFailure($this->userId, $now); + } + $throttle->reset($this->userId, $now); + + $row = $this->db->fetch('SELECT failed_attempts, lockout_until FROM pin_throttle WHERE actor_user_id = :id', ['id' => $this->userId]); + self::assertSame(0, (int) ($row['failed_attempts'] ?? -1)); + self::assertNull($row['lockout_until'] ?? null); + self::assertFalse($throttle->isLocked($this->userId, $now)); + } +} diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index 4db6b5e..c3d0051 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -142,6 +142,15 @@ final class FakeDatabase implements DatabaseInterface */ public ?array $actingUserRow = null; + /** + * lockout_until renvoye pour la porte du throttle PIN (RG-T22, PinThrottle::isLocked) ; + * null = pas de verrou. + */ + public ?string $pinThrottleLockoutUntil = null; + + /** Compteur pin_throttle relu apres l'upsert (PinThrottle::recordFailure) ; 1 par defaut. */ + public int $pinThrottleAttempts = 1; + /** Si non nul, execute() leve cette exception (simulation panne DB / violation de contrainte). */ public ?Throwable $failOnExecute = null; @@ -229,6 +238,14 @@ final class FakeDatabase implements DatabaseInterface return $this->categorySlugTaken ? ['id' => 1] : null; } + if (str_contains($sql, 'lockout_until FROM pin_throttle')) { + return ['lockout_until' => $this->pinThrottleLockoutUntil]; + } + + if (str_contains($sql, 'failed_attempts FROM pin_throttle')) { + return ['failed_attempts' => $this->pinThrottleAttempts]; + } + if (str_contains($sql, 'SELECT lockout_until FROM login_throttle')) { return ['lockout_until' => $this->ipLockoutUntil]; } diff --git a/tests/Unit/Admin/ProductControllerTest.php b/tests/Unit/Admin/ProductControllerTest.php index 9d98fc6..1a476eb 100644 --- a/tests/Unit/Admin/ProductControllerTest.php +++ b/tests/Unit/Admin/ProductControllerTest.php @@ -326,13 +326,93 @@ final class ProductControllerTest extends TestCase self::assertFalse($db->wrote('INSERT INTO product')); } + public function testUpdateLockedActorReturnsGeneric422WithoutVerifyingOrAuditing(): void + { + // RG-T22 : acteur de session verrouille. Le verrou est evalue AVANT la + // verification ; meme un PIN valide est bloque, le 422 reste generique, et + // AUCUNE nouvelle ligne pin.failed n'est ecrite (borne anti-flood). + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'Big Mac', 'description' => null, 'price_cents' => 590, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1]; + $this->actingPin($db); // PIN '4729' valide en base + $db->pinThrottleLockoutUntil = '2099-01-01 00:00:00'; // acteur verrouille + + $form = $this->validForm(['price_cents' => '620', 'pin_email' => 'staff@wakdo.local', 'pin' => '4729']); + $response = $this->controller($this->post($form, '/admin/products/5'), $db)->update(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertStringContainsString('PIN', $response->body()); + self::assertFalse($db->wrote('UPDATE product SET')); // PIN valide mais verrou prioritaire + self::assertSame([], $db->auditActions()); // pas de pin.failed sous verrou + } + + public function testUpdateWrongPinRecordsFailureOnSessionActor(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'Big Mac', 'description' => null, 'price_cents' => 590, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1]; + $db->actingUserRow = null; // email/PIN invalide + + $form = $this->validForm(['price_cents' => '620', 'pin_email' => 'ghost@wakdo.local', 'pin' => '0000']); + $response = $this->controller($this->post($form, '/admin/products/5'), $db)->update(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertSame(['pin.failed'], $db->auditActions()); // detectabilite preservee + // RG-T22 : le compteur est incremente sur l'AGISSANT (session id 1), pas sur + // l'email cible tente (qui serait contournable par rotation). + $upsert = $this->findWrite($db, 'INSERT INTO pin_throttle'); + self::assertNotNull($upsert); + self::assertSame(1, $upsert['params']['uid'] ?? null); + } + + public function testUpdateValidPinResetsThrottleOnSessionActorNotResolvedUser(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'category_id' => 3, 'name' => 'Big Mac', 'description' => null, 'price_cents' => 590, 'vat_rate' => 100, 'image_path' => null, 'is_available' => 1, 'display_order' => 1]; + $this->actingPin($db); + + $form = $this->validForm(['price_cents' => '620', 'pin_email' => 'staff@wakdo.local', 'pin' => '4729']); + $response = $this->controller($this->post($form, '/admin/products/5'), $db)->update(['id' => '5']); + + self::assertSame(302, $response->status()); + // L'audit porte l'acteur RESOLU PAR PIN (id 9)... + $audit = $this->firstAudit($db); + self::assertSame(9, $audit['params']['uid'] ?? null); + // ...mais le reset du throttle porte l'acteur de SESSION (id 1), le seul qui + // a ete incremente. Confondre les deux laisserait le compteur de l'agissant + // jamais purge (must-fix de revue). + $reset = $this->findWrite($db, 'UPDATE pin_throttle SET failed_attempts = 0'); + self::assertNotNull($reset); + self::assertSame(1, $reset['params']['uid'] ?? null); + } + + public function testDestroyLockedActorReturnsGeneric422(): void + { + $db = $this->permittedDb(); + $db->productRow = ['id' => 5, 'name' => 'Big Mac']; + $this->actingPin($db); + $db->pinThrottleLockoutUntil = '2099-01-01 00:00:00'; + + $response = $this->controller($this->post(['_csrf' => $this->csrf, 'pin_email' => 'staff@wakdo.local', 'pin' => '4729'], '/admin/products/5/delete'), $db)->destroy(['id' => '5']); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('DELETE FROM product')); + self::assertSame([], $db->auditActions()); + } + /** * @return array{sql: string, params: array}|null */ private function firstAudit(FakeDatabase $db): ?array + { + return $this->findWrite($db, 'INSERT INTO audit_log'); + } + + /** + * @return array{sql: string, params: array}|null + */ + private function findWrite(FakeDatabase $db, string $needle): ?array { foreach ($db->writes as $write) { - if (str_contains($write['sql'], 'INSERT INTO audit_log')) { + if (str_contains($write['sql'], $needle)) { return $write; } } diff --git a/tests/Unit/Auth/PinThrottleTest.php b/tests/Unit/Auth/PinThrottleTest.php new file mode 100644 index 0000000..7b0fc65 --- /dev/null +++ b/tests/Unit/Auth/PinThrottleTest.php @@ -0,0 +1,169 @@ + */ + private array $touchedKeys = []; + + private FakeDatabase $db; + + protected function setUp(): void + { + $this->setEnv('PIN_THROTTLE_THRESHOLD', '5'); + $this->setEnv('PIN_THROTTLE_BASE_SECONDS', '30'); + $this->setEnv('PIN_THROTTLE_MAX_SECONDS', '300'); + $this->setEnv('PIN_THROTTLE_WINDOW_SECONDS', '900'); + + $this->db = new FakeDatabase(); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function throttle(): PinThrottle + { + return new PinThrottle($this->db, new Config()); + } + + /** + * @return array{sql: string, params: array}|null + */ + private function find(string $needle): ?array + { + foreach ($this->db->writes as $write) { + if (str_contains($write['sql'], $needle)) { + return $write; + } + } + + return null; + } + + private function assertNoLoginCounterTouched(): void + { + // Invariant dur RG-T22 : un echec de PIN ne touche JAMAIS les compteurs de + // connexion. Retirer cette separation ferait virer ce test au rouge. + foreach ($this->db->writes as $write) { + self::assertStringNotContainsString('failed_login_attempts', $write['sql']); + self::assertStringNotContainsString('login_throttle', $write['sql']); + self::assertStringNotContainsString('audit_log', $write['sql']); + } + } + + public function testIsLockedTrueWhenLockoutInFuture(): void + { + $now = 1_000_000; + $this->db->pinThrottleLockoutUntil = date('Y-m-d H:i:s', $now + 60); + + self::assertTrue($this->throttle()->isLocked(9, $now)); + } + + public function testIsLockedFalseWhenNoLockOrPast(): void + { + $now = 1_000_000; + + $this->db->pinThrottleLockoutUntil = null; + self::assertFalse($this->throttle()->isLocked(9, $now)); + + $this->db->pinThrottleLockoutUntil = date('Y-m-d H:i:s', $now - 1); + self::assertFalse($this->throttle()->isLocked(9, $now)); + } + + public function testIsLockedFalseWhenNoActor(): void + { + // actorUserId <= 0 (pas de session derriere guard()) : non verrouille, et + // aucune lecture inutile (defensif). + self::assertFalse($this->throttle()->isLocked(0)); + self::assertSame([], $this->db->reads); + } + + public function testRecordFailureOneTransactionUpsertThenLockNoLoginState(): void + { + // Au seuil : le compteur relu vaut 5 -> backoff 30s -> verrou pose. + $this->db->pinThrottleAttempts = 5; + + $this->throttle()->recordFailure(9, 1_000_000); + + // Une seule transaction (RG-T08). + self::assertSame(['begin', 'commit'], $this->db->transactionEvents); + + $upsert = $this->find('INSERT INTO pin_throttle'); + self::assertNotNull($upsert); + self::assertStringContainsString('ON DUPLICATE KEY UPDATE', $upsert['sql']); + self::assertSame(9, $upsert['params']['uid'] ?? null); + + $lock = $this->find('UPDATE pin_throttle SET lockout_until'); + self::assertNotNull($lock); + self::assertSame(date('Y-m-d H:i:s', 1_000_000 + 30), $lock['params']['lock'] ?? null); + self::assertSame(9, $lock['params']['uid'] ?? null); + + $this->assertNoLoginCounterTouched(); + } + + public function testRecordFailureBelowThresholdSetsNoLock(): void + { + $this->db->pinThrottleAttempts = 1; // sous le seuil 5 + + $this->throttle()->recordFailure(9, 1_000_000); + + $lock = $this->find('UPDATE pin_throttle SET lockout_until'); + self::assertNotNull($lock); + self::assertArrayHasKey('lock', $lock['params']); + self::assertNull($lock['params']['lock']); // verrou null sous le seuil + $this->assertNoLoginCounterTouched(); + } + + public function testRecordFailureNoActorIsNoop(): void + { + $this->throttle()->recordFailure(0); + + self::assertSame([], $this->db->writes); + self::assertSame([], $this->db->transactionEvents); + } + + public function testResetClearsActorCounterNoLoginState(): void + { + $this->throttle()->reset(9, 1_000_000); + + $reset = $this->find('UPDATE pin_throttle SET failed_attempts = 0'); + self::assertNotNull($reset); + self::assertStringContainsString('lockout_until = NULL', $reset['sql']); + self::assertSame(9, $reset['params']['uid'] ?? null); + // reset = UPDATE simple, hors transaction propre (inclus dans l'effet controleur). + self::assertSame([], $this->db->transactionEvents); + $this->assertNoLoginCounterTouched(); + } + + public function testResetNoActorIsNoop(): void + { + $this->throttle()->reset(0); + + self::assertSame([], $this->db->writes); + } +} diff --git a/tests/Unit/Auth/PinVerifierTest.php b/tests/Unit/Auth/PinVerifierTest.php index 0080db5..5c74922 100644 --- a/tests/Unit/Auth/PinVerifierTest.php +++ b/tests/Unit/Auth/PinVerifierTest.php @@ -123,6 +123,17 @@ final class PinVerifierTest extends TestCase self::assertNull($this->verifier()->resolveActingUser('staff@wakdo.local', '')); } + public function testPayTimingDecoyHashesWithoutTouchingDatabase(): void + { + // Chemin "acteur verrouille" (RG-T22) : on paie le cout argon2id sans aucune + // lecture/ecriture DB, pour egaliser le timing avec le chemin mauvais-PIN + // sans introduire d'oracle (aucune requete = rien a observer). + $this->verifier()->payTimingDecoy('4729'); + + self::assertSame([], $this->db->reads); + self::assertSame([], $this->db->writes); + } + public function testMeetsLengthPolicy(): void { $verifier = $this->verifier(); diff --git a/tests/Unit/Auth/ThrottlePolicyTest.php b/tests/Unit/Auth/ThrottlePolicyTest.php index a93702f..6d8969b 100644 --- a/tests/Unit/Auth/ThrottlePolicyTest.php +++ b/tests/Unit/Auth/ThrottlePolicyTest.php @@ -130,4 +130,28 @@ final class ThrottlePolicyTest extends TestCase self::assertSame(60, $policy->lockoutSeconds(20)); self::assertSame(120, $policy->lockoutSeconds(21)); } + + public function testFromConfigPinReadsPinKeysWithItsOwnBounds(): void + { + // RG-T22 : la dimension 'pin' a ses propres bornes (PIN_THROTTLE_*), distinctes + // du login, et volontairement plus permissives (base 30s, plafond 300s). + $this->setEnv('PIN_THROTTLE_THRESHOLD', '5'); + $this->setEnv('PIN_THROTTLE_BASE_SECONDS', '30'); + $this->setEnv('PIN_THROTTLE_MAX_SECONDS', '300'); + // Cles du login mises a des valeurs differentes : si 'pin' les lisait par + // erreur, la courbe ci-dessous changerait. + $this->setEnv('ACCOUNT_LOCKOUT_THRESHOLD', '3'); + $this->setEnv('ACCOUNT_LOCKOUT_BASE_SECONDS', '60'); + $this->setEnv('ACCOUNT_LOCKOUT_MAX_SECONDS', '900'); + + $policy = ThrottlePolicy::fromConfig(new Config(), 'pin'); + + self::assertSame(0, $policy->lockoutSeconds(4)); + self::assertSame(30, $policy->lockoutSeconds(5)); + self::assertSame(60, $policy->lockoutSeconds(6)); + self::assertSame(120, $policy->lockoutSeconds(7)); + self::assertSame(240, $policy->lockoutSeconds(8)); + self::assertSame(300, $policy->lockoutSeconds(9)); // plafond PIN (300), pas 480 + self::assertSame(300, $policy->lockoutSeconds(40)); // plafond + garde anti-debordement + } }