user = 'Ed-' . bin2hex(random_bytes(4)); $_SERVER['REMOTE_ADDR'] = '198.51.100.' . random_int(1, 254) . '-' . bin2hex(random_bytes(4)); } protected function tearDown(): void { foreach (array($this->user, 'other-' . $this->user) as $user) { foreach (array_keys(login_limits($user)) as $key) @unlink(login_throttle_file($key)); } parent::tearDown(); } private function failLogins(int $times, ?string $user = NULL): void { for ($i = 0; $i < $times; $i++) record_login_failure($user ?? $this->user); } public function testLimitsArePerUserAndAddressPerAddressAndPerUser(): void { $address = $_SERVER['REMOTE_ADDR']; $user = strtolower($this->user); $this->assertSame(array("user-ip:$user|$address" => 5, "ip:$address" => 20, "user:$user" => 50), login_limits($this->user)); } public function testUsernameCaseDoesNotMatter(): void { $this->assertSame(login_limits(strtoupper($this->user)), login_limits(strtolower($this->user))); } public function testThrottleFilesAreHashedOutsideTheWebRoot(): void { $file = login_throttle_file('ip:192.0.2.1'); $this->assertSame(sys_get_temp_dir() . '/xxx-login-throttle/' . hash('sha256', 'ip:192.0.2.1'), $file); $this->assertDirectoryExists(dirname($file)); $this->assertSame(0700, fileperms(dirname($file)) & 0777); } public function testFourFailuresDoNotBlock(): void { $this->failLogins(4); $this->assertSame(0, login_blocked_for($this->user)); } public function testFiveFailuresBlockForTheWindow(): void { $this->failLogins(5); $wait = login_blocked_for($this->user); $this->assertGreaterThan(LOGIN_WINDOW - 5, $wait); $this->assertLessThanOrEqual(LOGIN_WINDOW, $wait); } public function testEachFailureCountsForEveryKey(): void { $this->failLogins(2); foreach (array_keys(login_limits($this->user)) as $key) $this->assertCount(2, login_failures($key)); } public function testOldFailuresAreForgotten(): void { $keys = array_keys(login_limits($this->user)); $old = time() - LOGIN_WINDOW - 1; file_put_contents(login_throttle_file($keys[0]), json_encode(array_fill(0, 5, $old))); $this->assertSame(array(), login_failures($keys[0])); $this->assertSame(0, login_blocked_for($this->user)); } public function testTheWaitEndsAWindowAfterTheOldestFailure(): void { $keys = array_keys(login_limits($this->user)); $oldest = time() - 600; file_put_contents(login_throttle_file($keys[0]), json_encode(array($oldest, time(), time(), time(), time()))); $this->assertSame($oldest + LOGIN_WINDOW - time(), login_blocked_for($this->user)); } public function testDamagedFilesCountAsNoFailures(): void { $keys = array_keys(login_limits($this->user)); file_put_contents(login_throttle_file($keys[0]), 'not json'); $this->assertSame(array(), login_failures($keys[0])); file_put_contents(login_throttle_file($keys[0]), json_encode(array('1', NULL, time()))); $this->assertCount(1, login_failures($keys[0])); } public function testTwentyFailuresFromOneAddressBlockEveryUser(): void { $this->failLogins(4); for ($i = 0; $i < 4; $i++) $this->failLogins(4, "user$i-" . $this->user); $this->assertGreaterThan(0, login_blocked_for('other-' . $this->user)); for ($i = 0; $i < 4; $i++) { foreach (array_keys(login_limits("user$i-" . $this->user)) as $key) @unlink(login_throttle_file($key)); } } public function testFiftyFailuresForAUserBlockEveryAddress(): void { $keys = array_keys(login_limits($this->user)); file_put_contents(login_throttle_file($keys[2]), json_encode(array_fill(0, 50, time()))); $_SERVER['REMOTE_ADDR'] = '203.0.113.9-' . bin2hex(random_bytes(4)); $this->assertGreaterThan(0, login_blocked_for($this->user)); } public function testClearingForgetsOnlyThisUserFromThisAddress(): void { $this->failLogins(3); clear_login_failures($this->user); $keys = array_keys(login_limits($this->user)); $this->assertSame(array(), login_failures($keys[0])); $this->assertCount(3, login_failures($keys[1])); $this->assertCount(3, login_failures($keys[2])); } }