LoginThrottleTest.php 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Unit\Auth;
  4. use App\Auth\LoginThrottle;
  5. use Monolog\Handler\NullHandler;
  6. use Monolog\Logger;
  7. use PHPUnit\Framework\TestCase;
  8. use Psr\Log\LoggerInterface;
  9. /**
  10. * Brute-force lockout progression: 5/10/15 attempts trigger 60/300/1800-second
  11. * locks. The (username, ip) tuple gates the bucket so the legit admin from
  12. * another IP isn't locked out by an attacker spraying one address.
  13. */
  14. final class LoginThrottleTest extends TestCase
  15. {
  16. public function testFastRetryUnderFive(): void
  17. {
  18. $t = $this->throttle();
  19. for ($i = 0; $i < 4; ++$i) {
  20. $t->recordFailure('admin', '10.0.0.1');
  21. }
  22. self::assertFalse($t->isLocked('admin', '10.0.0.1'));
  23. }
  24. public function testFifthFailureLocksForOneMinute(): void
  25. {
  26. $t = $this->throttle();
  27. for ($i = 0; $i < 5; ++$i) {
  28. $t->recordFailure('admin', '10.0.0.1');
  29. }
  30. self::assertTrue($t->isLocked('admin', '10.0.0.1'));
  31. self::assertGreaterThanOrEqual(60, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
  32. self::assertLessThanOrEqual(60, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
  33. }
  34. public function testTenthFailureLocksForFiveMinutes(): void
  35. {
  36. $now = 1000000;
  37. $t = $this->throttle($now);
  38. for ($i = 0; $i < 10; ++$i) {
  39. $t->recordFailure('admin', '10.0.0.1');
  40. }
  41. self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
  42. }
  43. public function testFifteenthFailureLocksForThirtyMinutes(): void
  44. {
  45. $now = 1000000;
  46. $t = $this->throttle($now);
  47. for ($i = 0; $i < 15; ++$i) {
  48. $t->recordFailure('admin', '10.0.0.1');
  49. }
  50. self::assertSame(1800, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
  51. }
  52. public function testLockoutExpiresAfterTimeAdvance(): void
  53. {
  54. $now = 1000000;
  55. $t = new LoginThrottle($this->logger(), function () use (&$now): int {
  56. return $now;
  57. });
  58. for ($i = 0; $i < 5; ++$i) {
  59. $t->recordFailure('admin', '10.0.0.1');
  60. }
  61. self::assertTrue($t->isLocked('admin', '10.0.0.1'));
  62. $now += 61;
  63. self::assertFalse($t->isLocked('admin', '10.0.0.1'));
  64. }
  65. public function testDifferentIpHasIndependentBucket(): void
  66. {
  67. $t = $this->throttle();
  68. for ($i = 0; $i < 5; ++$i) {
  69. $t->recordFailure('admin', '10.0.0.1');
  70. }
  71. self::assertTrue($t->isLocked('admin', '10.0.0.1'));
  72. self::assertFalse($t->isLocked('admin', '10.0.0.2'));
  73. }
  74. public function testClearResetsBucket(): void
  75. {
  76. $t = $this->throttle();
  77. for ($i = 0; $i < 5; ++$i) {
  78. $t->recordFailure('admin', '10.0.0.1');
  79. }
  80. $t->clear('admin', '10.0.0.1');
  81. self::assertFalse($t->isLocked('admin', '10.0.0.1'));
  82. self::assertSame(0, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
  83. }
  84. public function testUsernameCaseDoesNotMultiplyBuckets(): void
  85. {
  86. $t = $this->throttle();
  87. for ($i = 0; $i < 3; ++$i) {
  88. $t->recordFailure('admin', '10.0.0.1');
  89. }
  90. for ($i = 0; $i < 2; ++$i) {
  91. $t->recordFailure('ADMIN', '10.0.0.1');
  92. }
  93. self::assertTrue($t->isLocked('Admin', '10.0.0.1'));
  94. }
  95. public function testPerUsernameBucketLocksOutAcrossDistinctIps(): void
  96. {
  97. $t = $this->throttle();
  98. // 24 failures from 24 distinct IPs — each is a fresh per-IP bucket
  99. // (only 1 attempt apiece), but the per-username counter accumulates.
  100. for ($i = 0; $i < 24; ++$i) {
  101. $t->recordFailure('admin', '10.0.0.' . $i);
  102. }
  103. self::assertFalse($t->isLocked('admin', '10.0.0.99'));
  104. // 25th attempt from yet another IP trips the per-username lockout.
  105. $t->recordFailure('admin', '10.0.0.99');
  106. self::assertTrue($t->isLocked('admin', '10.0.1.1'));
  107. }
  108. public function testPerUsernameLadderProgresses(): void
  109. {
  110. $now = 1000000;
  111. $t = $this->throttle($now);
  112. for ($i = 0; $i < 25; ++$i) {
  113. $t->recordFailure('admin', '10.0.0.' . $i);
  114. }
  115. self::assertSame(60, $t->lockoutSecondsRemaining('admin', '10.99.0.1'));
  116. for ($i = 25; $i < 50; ++$i) {
  117. $t->recordFailure('admin', '10.0.0.' . $i);
  118. }
  119. self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.99.0.1'));
  120. for ($i = 50; $i < 100; ++$i) {
  121. $t->recordFailure('admin', '10.0.0.' . $i);
  122. }
  123. self::assertSame(1800, $t->lockoutSecondsRemaining('admin', '10.99.0.1'));
  124. }
  125. public function testLockoutSecondsRemainingReturnsLargerOfBuckets(): void
  126. {
  127. $now = 1000000;
  128. $t = $this->throttle($now);
  129. // 5 failures from one IP → per-IP locked for 60s.
  130. for ($i = 0; $i < 5; ++$i) {
  131. $t->recordFailure('admin', '10.0.0.1');
  132. }
  133. // Plus 45 more from distinct IPs → per-username at count=50 → 300s.
  134. for ($i = 0; $i < 45; ++$i) {
  135. $t->recordFailure('admin', '10.1.0.' . $i);
  136. }
  137. // Asked from yet-another-IP, only the per-username lock applies — 300s.
  138. self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.99.0.1'));
  139. // Asked from the per-IP bucket's IP, 300s still wins (max of 60/300).
  140. self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
  141. }
  142. public function testClearResetsBothBuckets(): void
  143. {
  144. $t = $this->throttle();
  145. // Build per-username pressure.
  146. for ($i = 0; $i < 25; ++$i) {
  147. $t->recordFailure('admin', '10.0.0.' . $i);
  148. }
  149. self::assertTrue($t->isLocked('admin', '10.99.0.1'));
  150. $t->clear('admin', '10.0.0.0');
  151. self::assertFalse($t->isLocked('admin', '10.99.0.1'));
  152. self::assertSame(0, $t->lockoutSecondsRemaining('admin', '10.99.0.1'));
  153. }
  154. public function testEmptyUsernameStillBucketsPerUsername(): void
  155. {
  156. $t = $this->throttle();
  157. for ($i = 0; $i < 25; ++$i) {
  158. $t->recordFailure('', '10.0.0.' . $i);
  159. }
  160. self::assertTrue($t->isLocked('', '10.99.0.1'));
  161. }
  162. private function throttle(?int $fixedTime = null): LoginThrottle
  163. {
  164. if ($fixedTime === null) {
  165. return new LoginThrottle($this->logger());
  166. }
  167. return new LoginThrottle($this->logger(), static fn (): int => $fixedTime);
  168. }
  169. private function logger(): LoggerInterface
  170. {
  171. $l = new Logger('test');
  172. $l->pushHandler(new NullHandler());
  173. return $l;
  174. }
  175. }