| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123 |
- <?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'));
- }
- 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;
- }
- }
|