| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Unit\Auth;
- use App\Auth\LoginThrottle;
- use Monolog\Handler\NullHandler;
- use Monolog\Logger;
- use PHPUnit\Framework\TestCase;
- use Psr\Log\LoggerInterface;
- /**
- * Brute-force lockout progression: 5/10/15 attempts trigger 60/300/1800-second
- * locks. The (username, ip) tuple gates the bucket so the legit admin from
- * another IP isn't locked out by an attacker spraying one address.
- */
- final class LoginThrottleTest extends TestCase
- {
- public function testFastRetryUnderFive(): void
- {
- $t = $this->throttle();
- for ($i = 0; $i < 4; ++$i) {
- $t->recordFailure('admin', '10.0.0.1');
- }
- self::assertFalse($t->isLocked('admin', '10.0.0.1'));
- }
- public function testFifthFailureLocksForOneMinute(): void
- {
- $t = $this->throttle();
- for ($i = 0; $i < 5; ++$i) {
- $t->recordFailure('admin', '10.0.0.1');
- }
- self::assertTrue($t->isLocked('admin', '10.0.0.1'));
- self::assertGreaterThanOrEqual(60, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
- self::assertLessThanOrEqual(60, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
- }
- public function testTenthFailureLocksForFiveMinutes(): void
- {
- $now = 1000000;
- $t = $this->throttle($now);
- for ($i = 0; $i < 10; ++$i) {
- $t->recordFailure('admin', '10.0.0.1');
- }
- self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
- }
- public function testFifteenthFailureLocksForThirtyMinutes(): void
- {
- $now = 1000000;
- $t = $this->throttle($now);
- for ($i = 0; $i < 15; ++$i) {
- $t->recordFailure('admin', '10.0.0.1');
- }
- self::assertSame(1800, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
- }
- public function testLockoutExpiresAfterTimeAdvance(): void
- {
- $now = 1000000;
- $t = new LoginThrottle($this->logger(), function () use (&$now): int {
- return $now;
- });
- for ($i = 0; $i < 5; ++$i) {
- $t->recordFailure('admin', '10.0.0.1');
- }
- self::assertTrue($t->isLocked('admin', '10.0.0.1'));
- $now += 61;
- self::assertFalse($t->isLocked('admin', '10.0.0.1'));
- }
- public function testDifferentIpHasIndependentBucket(): void
- {
- $t = $this->throttle();
- for ($i = 0; $i < 5; ++$i) {
- $t->recordFailure('admin', '10.0.0.1');
- }
- self::assertTrue($t->isLocked('admin', '10.0.0.1'));
- self::assertFalse($t->isLocked('admin', '10.0.0.2'));
- }
- public function testClearResetsBucket(): void
- {
- $t = $this->throttle();
- for ($i = 0; $i < 5; ++$i) {
- $t->recordFailure('admin', '10.0.0.1');
- }
- $t->clear('admin', '10.0.0.1');
- self::assertFalse($t->isLocked('admin', '10.0.0.1'));
- self::assertSame(0, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
- }
- public function testUsernameCaseDoesNotMultiplyBuckets(): void
- {
- $t = $this->throttle();
- for ($i = 0; $i < 3; ++$i) {
- $t->recordFailure('admin', '10.0.0.1');
- }
- for ($i = 0; $i < 2; ++$i) {
- $t->recordFailure('ADMIN', '10.0.0.1');
- }
- self::assertTrue($t->isLocked('Admin', '10.0.0.1'));
- }
- public function testPerUsernameBucketLocksOutAcrossDistinctIps(): void
- {
- $t = $this->throttle();
- // 24 failures from 24 distinct IPs — each is a fresh per-IP bucket
- // (only 1 attempt apiece), but the per-username counter accumulates.
- for ($i = 0; $i < 24; ++$i) {
- $t->recordFailure('admin', '10.0.0.' . $i);
- }
- self::assertFalse($t->isLocked('admin', '10.0.0.99'));
- // 25th attempt from yet another IP trips the per-username lockout.
- $t->recordFailure('admin', '10.0.0.99');
- self::assertTrue($t->isLocked('admin', '10.0.1.1'));
- }
- public function testPerUsernameLadderProgresses(): void
- {
- $now = 1000000;
- $t = $this->throttle($now);
- for ($i = 0; $i < 25; ++$i) {
- $t->recordFailure('admin', '10.0.0.' . $i);
- }
- self::assertSame(60, $t->lockoutSecondsRemaining('admin', '10.99.0.1'));
- for ($i = 25; $i < 50; ++$i) {
- $t->recordFailure('admin', '10.0.0.' . $i);
- }
- self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.99.0.1'));
- for ($i = 50; $i < 100; ++$i) {
- $t->recordFailure('admin', '10.0.0.' . $i);
- }
- self::assertSame(1800, $t->lockoutSecondsRemaining('admin', '10.99.0.1'));
- }
- public function testLockoutSecondsRemainingReturnsLargerOfBuckets(): void
- {
- $now = 1000000;
- $t = $this->throttle($now);
- // 5 failures from one IP → per-IP locked for 60s.
- for ($i = 0; $i < 5; ++$i) {
- $t->recordFailure('admin', '10.0.0.1');
- }
- // Plus 45 more from distinct IPs → per-username at count=50 → 300s.
- for ($i = 0; $i < 45; ++$i) {
- $t->recordFailure('admin', '10.1.0.' . $i);
- }
- // Asked from yet-another-IP, only the per-username lock applies — 300s.
- self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.99.0.1'));
- // Asked from the per-IP bucket's IP, 300s still wins (max of 60/300).
- self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.0.0.1'));
- }
- public function testClearResetsBothBuckets(): void
- {
- $t = $this->throttle();
- // Build per-username pressure.
- for ($i = 0; $i < 25; ++$i) {
- $t->recordFailure('admin', '10.0.0.' . $i);
- }
- self::assertTrue($t->isLocked('admin', '10.99.0.1'));
- $t->clear('admin', '10.0.0.0');
- self::assertFalse($t->isLocked('admin', '10.99.0.1'));
- self::assertSame(0, $t->lockoutSecondsRemaining('admin', '10.99.0.1'));
- }
- public function testEmptyUsernameStillBucketsPerUsername(): void
- {
- $t = $this->throttle();
- for ($i = 0; $i < 25; ++$i) {
- $t->recordFailure('', '10.0.0.' . $i);
- }
- self::assertTrue($t->isLocked('', '10.99.0.1'));
- }
- private function throttle(?int $fixedTime = null): LoginThrottle
- {
- if ($fixedTime === null) {
- return new LoginThrottle($this->logger());
- }
- return new LoginThrottle($this->logger(), static fn (): int => $fixedTime);
- }
- private function logger(): LoggerInterface
- {
- $l = new Logger('test');
- $l->pushHandler(new NullHandler());
- return $l;
- }
- }
|