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')); } public function testRecordFailureLogsFingerprintsNotRawIdentifiers(): void { // SEC_REVIEW F34: a SIEM operator reading these logs must not see // the attempted username (which can be a password typed in the // wrong field) or the raw client IP. They must see a stable // fingerprint instead so triage can still correlate. $handler = new TestHandler(); $logger = new Logger('test'); $logger->pushHandler($handler); $t = new LoginThrottle($logger); $rawUser = 'hunter2-typed-into-username-field'; $rawIp = '198.51.100.77'; for ($i = 0; $i < 5; ++$i) { $t->recordFailure($rawUser, $rawIp); } self::assertNotEmpty($handler->getRecords(), 'expected lockout/failure events to be logged'); foreach ($handler->getRecords() as $record) { $serialized = json_encode([ 'message' => $record->message, 'context' => $record->context, ], \JSON_THROW_ON_ERROR); self::assertStringNotContainsString($rawUser, $serialized); self::assertStringNotContainsString($rawIp, $serialized); self::assertArrayHasKey('username_fp', $record->context); self::assertArrayHasKey('source_ip_fp', $record->context); self::assertArrayNotHasKey('username', $record->context); self::assertArrayNotHasKey('source_ip', $record->context); } } 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; } }