Some checks failed
CI / php-lint (push) Successful in 19s
CI / secret-scan (pull_request) Successful in 8s
CI / secret-scan (push) Successful in 10s
CI / static-tests (push) Successful in 30s
CI / php-lint (pull_request) Successful in 19s
CI / auto-merge (push) Has been skipped
CI / static-tests (pull_request) Successful in 30s
CI / auto-merge (pull_request) Failing after 4s
Ferme le finding HIGH de la revue Produits (#17) : le PIN d'action sensible etait verifie sans limitation de tentatives. Conception via panel multi-agents (3 lentilles + synthese + passe adversariale, holds=true) puis revue de l'implementation (holds=true). Dimension du throttle = UTILISATEUR AGISSANT (identite de session, RG-T02), pas l'email cible (contournable par rotation) ni l'IP (collateral sur poste partage). Table dediee pin_throttle (entite 22) STRICTEMENT SEPAREE des compteurs de login (user.failed_login_attempts / login_throttle) : un echec de PIN n'incremente aucun compteur de connexion (pas d'escalade DoS vers le login). - db/migrations/0002_pin_throttle.sql : table cle sur actor_user_id (UNIQUE, FK -> user ON DELETE CASCADE), separee du login. Appliquee a la base dev. - ThrottlePolicy : dimension 'pin' (bornes propres PIN_THROTTLE_*, 30s..300s, plus permissives que le login : controle de dissuasion, residuel Faible). - PinThrottle (nouveau) : isLocked / recordFailure (upsert atomique + backoff, une transaction, miroir d'AuthService) / reset (UPDATE simple). N'ecrit jamais user/login_throttle/audit_log. - PinVerifier::payTimingDecoy : parite de timing du chemin verrouille. - ProductController update/destroy : gate AVANT verification (leurre + 422 generique, pas de pin.failed sous verrou actif = borne anti-flood de l'audit) ; recordFailure sur PIN faux ; reset sur succes, cle sur l'acteur de SESSION. - Docs Merise 21 -> 22 entites : RG-T22 (mlt), entite 22 pin_throttle (mcd/mld/dictionary), couverture MCT 22/22 (mct). - .env.example + docker-compose : PIN_THROTTLE_THRESHOLD/BASE/MAX/WINDOW. - Journal RNCP : docs/journal/2026-06-15--p3-throttle-pin-rg-t22.md. Tests : 188 verts (525 assertions), PHPStan L6 propre.
157 lines
5.4 KiB
PHP
157 lines
5.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Auth;
|
|
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use PHPUnit\Framework\TestCase;
|
|
use App\Auth\ThrottlePolicy;
|
|
use App\Core\Config;
|
|
|
|
/**
|
|
* Le backoff degressif est le calcul de securite le plus delicat de l'auth :
|
|
* on le verrouille par des cas explicites (seuil, doublement, plafond, debordement).
|
|
*/
|
|
final class ThrottlePolicyTest extends TestCase
|
|
{
|
|
/** @var list<string> */
|
|
private array $touchedKeys = [];
|
|
|
|
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 policy(int $threshold = 5, int $base = 60, int $max = 900): ThrottlePolicy
|
|
{
|
|
return new ThrottlePolicy($threshold, $base, $max);
|
|
}
|
|
|
|
public function testNoLockoutBelowThreshold(): void
|
|
{
|
|
$policy = $this->policy();
|
|
|
|
self::assertSame(0, $policy->lockoutSeconds(0));
|
|
self::assertSame(0, $policy->lockoutSeconds(4));
|
|
}
|
|
|
|
public function testBaseDelayAtThreshold(): void
|
|
{
|
|
self::assertSame(60, $this->policy()->lockoutSeconds(5));
|
|
}
|
|
|
|
/**
|
|
* @return list<array{0: int, 1: int}>
|
|
*/
|
|
public static function degressiveCurveProvider(): array
|
|
{
|
|
// threshold=5, base=60, max=900 : 60, 120, 240, 480, puis plafond 900.
|
|
return [
|
|
[5, 60],
|
|
[6, 120],
|
|
[7, 240],
|
|
[8, 480],
|
|
[9, 900], // 60*2^4 = 960 -> plafonne a 900
|
|
[10, 900], // au-dela : reste plafonne
|
|
[20, 900],
|
|
];
|
|
}
|
|
|
|
#[DataProvider('degressiveCurveProvider')]
|
|
public function testDegressiveCurveIsCappedAtMax(int $attempts, int $expected): void
|
|
{
|
|
self::assertSame($expected, $this->policy()->lockoutSeconds($attempts));
|
|
}
|
|
|
|
public function testNoIntegerOverflowForHugeAttemptCount(): void
|
|
{
|
|
// Un compteur enorme ne doit jamais deborder en negatif ni lever : on
|
|
// reste plafonne au maximum configure.
|
|
self::assertSame(900, $this->policy()->lockoutSeconds(1000));
|
|
self::assertSame(900, $this->policy()->lockoutSeconds(PHP_INT_MAX));
|
|
}
|
|
|
|
public function testIsLockedUntilFutureIsTrue(): void
|
|
{
|
|
$now = 1_000_000;
|
|
$future = date('Y-m-d H:i:s', $now + 120);
|
|
|
|
self::assertTrue($this->policy()->isLockedUntil($future, $now));
|
|
}
|
|
|
|
public function testIsLockedUntilPastOrNullIsFalse(): void
|
|
{
|
|
$now = 1_000_000;
|
|
$past = date('Y-m-d H:i:s', $now - 1);
|
|
|
|
self::assertFalse($this->policy()->isLockedUntil($past, $now));
|
|
self::assertFalse($this->policy()->isLockedUntil(null, $now));
|
|
self::assertFalse($this->policy()->isLockedUntil('', $now));
|
|
}
|
|
|
|
public function testIsLockedUntilUnparseableIsFalse(): void
|
|
{
|
|
self::assertFalse($this->policy()->isLockedUntil('not-a-date', 1_000_000));
|
|
}
|
|
|
|
public function testFromConfigAccountReadsAccountKeys(): void
|
|
{
|
|
$this->setEnv('ACCOUNT_LOCKOUT_THRESHOLD', '3');
|
|
$this->setEnv('ACCOUNT_LOCKOUT_BASE_SECONDS', '30');
|
|
$this->setEnv('ACCOUNT_LOCKOUT_MAX_SECONDS', '600');
|
|
|
|
$policy = ThrottlePolicy::fromConfig(new Config(), 'account');
|
|
|
|
self::assertSame(0, $policy->lockoutSeconds(2));
|
|
self::assertSame(30, $policy->lockoutSeconds(3));
|
|
self::assertSame(60, $policy->lockoutSeconds(4));
|
|
self::assertSame(600, $policy->lockoutSeconds(99));
|
|
}
|
|
|
|
public function testFromConfigIpUsesIpThresholdWithSharedCurve(): void
|
|
{
|
|
$this->setEnv('IP_THROTTLE_MAX_ATTEMPTS', '20');
|
|
$this->setEnv('ACCOUNT_LOCKOUT_BASE_SECONDS', '60');
|
|
$this->setEnv('ACCOUNT_LOCKOUT_MAX_SECONDS', '900');
|
|
|
|
$policy = ThrottlePolicy::fromConfig(new Config(), 'ip');
|
|
|
|
self::assertSame(0, $policy->lockoutSeconds(19));
|
|
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
|
|
}
|
|
}
|