fix(admin): chemin d'echec PIN atomique (pin.failed + throttle dans 1 transaction) (#30)
This commit is contained in:
parent
2cc499dc71
commit
05da325d05
2 changed files with 70 additions and 39 deletions
|
|
@ -75,47 +75,67 @@ 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
|
||||
// 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,
|
||||
],
|
||||
);
|
||||
// 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;
|
||||
// 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],
|
||||
);
|
||||
});
|
||||
$db->execute(
|
||||
'UPDATE pin_throttle SET lockout_until = :lock WHERE actor_user_id = :uid',
|
||||
['lock' => $lockUntil, 'uid' => $actorUserId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
[
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue