1
0

LoginThrottleTest.php 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  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. private function throttle(?int $fixedTime = null): LoginThrottle
  96. {
  97. if ($fixedTime === null) {
  98. return new LoginThrottle($this->logger());
  99. }
  100. return new LoginThrottle($this->logger(), static fn (): int => $fixedTime);
  101. }
  102. private function logger(): LoggerInterface
  103. {
  104. $l = new Logger('test');
  105. $l->pushHandler(new NullHandler());
  106. return $l;
  107. }
  108. }