fix(admin): chemin d'echec PIN atomique (pin.failed + throttle dans 1 transaction)
All checks were successful
CI / php-lint (pull_request) Successful in 25s
CI / static-tests (push) Successful in 37s
CI / auto-merge (pull_request) Successful in 7s
CI / secret-scan (pull_request) Successful in 11s
CI / auto-merge (push) Has been skipped
CI / static-tests (pull_request) Successful in 41s
CI / secret-scan (push) Successful in 8s
CI / php-lint (push) Successful in 24s

Sur un PIN invalide, ProductController ecrivait la trace audit pin.failed
(autocommit) PUIS appelait PinThrottle::recordFailure (qui ouvrait SA propre
transaction) : deux ecritures non atomiques, un crash entre les deux laissait un
etat partiel, en tension avec le claim RG-T08 (audit dans la meme transaction que
l'effet).

- PinThrottle : extraction de recordFailureWithin(db, ...) (memes effets, SANS
  transaction propre) ; recordFailure() reste l'API autonome (l'enveloppe).
- ProductController (update + destroy) : les chemins d'echec PIN enveloppent
  logFailedPin(, ...) + recordFailureWithin(, ...) dans UNE transaction.
- logFailedPin prend desormais le  de la transaction.

Aucun changement de test necessaire (les assertions audit/throttle tiennent dans
la transaction). 188 verts, PinThrottleDbTest 2/2 contre la vraie DB, PHPStan L6.
This commit is contained in:
Imugiii 2026-06-16 12:15:21 +00:00
parent ad5203d3fc
commit a3fa72cf86
2 changed files with 70 additions and 39 deletions

View file

@ -75,13 +75,34 @@ final class PinThrottle
return; return;
} }
// Variante autonome : ouvre sa propre transaction. Le controleur, lui,
// prefere recordFailureWithin() pour ecrire la trace pin.failed et cet
// increment dans UNE SEULE transaction (RG-T08).
$this->db->transaction(function (DatabaseInterface $db) use ($actorUserId, $now): void {
$this->recordFailureWithin($db, $actorUserId, $now);
});
}
/**
* Variante SANS transaction propre : suppose que l'appelant a deja ouvert une
* transaction (le controleur enveloppe la trace audit pin.failed (RG-T14) et
* cet increment dans la meme, RG-T08 : pas d'etat partiel si crash entre les
* deux). Memes effets que recordFailure : upsert atomique sous verrou de ligne,
* fenetre glissante reinitialisee en SQL, backoff degressif. Ne touche jamais
* user ni login_throttle (RG-T22).
*/
public function recordFailureWithin(DatabaseInterface $db, int $actorUserId, ?int $now = null): void
{
if ($actorUserId <= 0) {
return;
}
$now ??= time(); $now ??= time();
$nowDt = date('Y-m-d H:i:s', $now); $nowDt = date('Y-m-d H:i:s', $now);
$windowSeconds = $this->config->int('PIN_THROTTLE_WINDOW_SECONDS', 900); $windowSeconds = $this->config->int('PIN_THROTTLE_WINDOW_SECONDS', 900);
$windowCutoff = date('Y-m-d H:i:s', $now - $windowSeconds); $windowCutoff = date('Y-m-d H:i:s', $now - $windowSeconds);
$policy = ThrottlePolicy::fromConfig($this->config, 'pin'); $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 // Increment ATOMIQUE cote SQL sous le verrou de ligne pris par l'upsert
// (anti lost-update sous POSTs concurrents). Placeholders distincts : en // (anti lost-update sous POSTs concurrents). Placeholders distincts : en
// prepare reelle (EMULATE_PREPARES = false) un meme nom ne peut etre lie // prepare reelle (EMULATE_PREPARES = false) un meme nom ne peut etre lie
@ -115,7 +136,6 @@ final class PinThrottle
'UPDATE pin_throttle SET lockout_until = :lock WHERE actor_user_id = :uid', 'UPDATE pin_throttle SET lockout_until = :lock WHERE actor_user_id = :uid',
['lock' => $lockUntil, 'uid' => $actorUserId], ['lock' => $lockUntil, 'uid' => $actorUserId],
); );
});
} }
/** /**

View file

@ -157,8 +157,14 @@ class ProductController extends AdminController
$actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? ''); $actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? '');
if ($actor === null) { if ($actor === null) {
$this->logFailedPin(trim($form['pin_email'] ?? ''), $id); // RG-T08 : la trace pin.failed (RG-T14) et l'increment du throttle
$this->pinThrottle()->recordFailure($actorId); // (RG-T22) sont ecrits dans UNE meme transaction (pas d'etat partiel
// si crash entre les deux ecritures).
$email = trim($form['pin_email'] ?? '');
$this->db()->transaction(function (DatabaseInterface $db) use ($email, $id, $actorId): void {
$this->logFailedPin($db, $email, $id);
$this->pinThrottle()->recordFailureWithin($db, $actorId);
});
return $this->renderForm($guard, $id, $form, ['pin' => 'Email ou PIN invalide (requis pour modifier prix/TVA).'], 422); return $this->renderForm($guard, $id, $form, ['pin' => 'Email ou PIN invalide (requis pour modifier prix/TVA).'], 422);
} }
@ -232,8 +238,13 @@ class ProductController extends AdminController
$actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? ''); $actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? '');
if ($actor === null) { if ($actor === null) {
$this->logFailedPin(trim($form['pin_email'] ?? ''), $id); // RG-T08 : trace pin.failed (RG-T14) + increment throttle (RG-T22) dans
$this->pinThrottle()->recordFailure($actorId); // UNE meme transaction (pas d'etat partiel si crash entre les deux).
$email = trim($form['pin_email'] ?? '');
$this->db()->transaction(function (DatabaseInterface $db) use ($email, $id, $actorId): void {
$this->logFailedPin($db, $email, $id);
$this->pinThrottle()->recordFailureWithin($db, $actorId);
});
return $this->renderDelete($guard, $id, $product, 'Email ou PIN invalide (requis pour supprimer).'); return $this->renderDelete($guard, $id, $product, 'Email ou PIN invalide (requis pour supprimer).');
} }
@ -376,9 +387,9 @@ class ProductController extends AdminController
* echecs ayant arme le verrou sont deja audites), ce qui borne l'amplification * echecs ayant arme le verrou sont deja audites), ce qui borne l'amplification
* de l'audit append-only (RG-T14). * de l'audit append-only (RG-T14).
*/ */
private function logFailedPin(string $email, int $productId): void private function logFailedPin(DatabaseInterface $db, string $email, int $productId): void
{ {
$this->db()->execute( $db->execute(
'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) '
. 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)',
[ [