fix(admin): chemin d'echec PIN atomique (pin.failed + throttle dans 1 transaction) (#30)
Some checks failed
CI / secret-scan (push) Has been cancelled
CI / php-lint (push) Has been cancelled
CI / static-tests (push) Has been cancelled
CI / auto-merge (push) Has been cancelled

This commit is contained in:
Corentin JOGUET 2026-06-16 14:21:50 +02:00
parent 2cc499dc71
commit 05da325d05
2 changed files with 70 additions and 39 deletions

View file

@ -75,13 +75,34 @@ final class PinThrottle
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();
$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
@ -115,7 +136,6 @@ final class PinThrottle
'UPDATE pin_throttle SET lockout_until = :lock WHERE actor_user_id = :uid',
['lock' => $lockUntil, 'uid' => $actorUserId],
);
});
}
/**

View file

@ -157,8 +157,14 @@ class ProductController extends AdminController
$actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? '');
if ($actor === null) {
$this->logFailedPin(trim($form['pin_email'] ?? ''), $id);
$this->pinThrottle()->recordFailure($actorId);
// RG-T08 : la trace pin.failed (RG-T14) et l'increment du throttle
// (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);
}
@ -232,8 +238,13 @@ class ProductController extends AdminController
$actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? '');
if ($actor === null) {
$this->logFailedPin(trim($form['pin_email'] ?? ''), $id);
$this->pinThrottle()->recordFailure($actorId);
// RG-T08 : trace pin.failed (RG-T14) + increment throttle (RG-T22) dans
// 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).');
}
@ -380,9 +391,9 @@ class ProductController extends AdminController
* 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
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) '
. 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)',
[