diff --git a/src/Checkpoints/ActivationCheckpoint.php b/src/Checkpoints/ActivationCheckpoint.php new file mode 100644 index 0000000..b0e8755 --- /dev/null +++ b/src/Checkpoints/ActivationCheckpoint.php @@ -0,0 +1,75 @@ +activations = $activations; + } + + /** + * @inheritdoc + */ + public function login(UserInterface $user): bool + { + return $this->checkActivation($user); + } + + /** + * Checks the activation status of the given user. + * + * @param UserInterface $user + * + * @throws NotActivatedException + * + * @return bool + */ + protected function checkActivation(UserInterface $user): bool + { + $completed = $this->activations->completed($user); + + if (! $completed) { + $exception = new NotActivatedException('Your account has not been activated yet.'); + + $exception->setUser($user); + + throw $exception; + } + + return true; + } + + /** + * @inheritdoc + */ + public function check(UserInterface $user): bool + { + return $this->checkActivation($user); + } +} diff --git a/src/Checkpoints/AuthenticatedCheckpoint.php b/src/Checkpoints/AuthenticatedCheckpoint.php new file mode 100644 index 0000000..3dd1df3 --- /dev/null +++ b/src/Checkpoints/AuthenticatedCheckpoint.php @@ -0,0 +1,21 @@ +user; + } + + /** + * Sets the user associated with Laratrust (does not log in). + * + * @param UserInterface + * + * @return void + */ + public function setUser(UserInterface $user): void + { + $this->user = $user; + } +} diff --git a/src/Checkpoints/ThrottleCheckpoint.php b/src/Checkpoints/ThrottleCheckpoint.php new file mode 100644 index 0000000..0d23813 --- /dev/null +++ b/src/Checkpoints/ThrottleCheckpoint.php @@ -0,0 +1,148 @@ +throttle = $throttle; + + if (isset($ipAddress)) { + $this->ipAddress = $ipAddress; + } + } + + /** + * @inheritdoc + */ + public function login(UserInterface $user): bool + { + return $this->checkThrottling('login', $user); + } + + /** + * Checks the throttling status of the given user. + * + * @param string $action + * @param UserInterface|null $user + * + * @return bool + */ + protected function checkThrottling(string $action, ?UserInterface $user = null): bool + { + // If we are just checking an existing logged in person, the global delay + // shouldn't stop them being logged in at all. Only their IP address and + // user a + if ($action === 'login') { + $globalDelay = $this->throttle->globalDelay(); + + if ($globalDelay > 0) { + $this->throwException("Too many unsuccessful attempts have been made globally, logins are locked for another [{$globalDelay}] second(s).", 'global', $globalDelay); + } + } + + // Suspicious activity from a single IP address will not only lock + // logins but also any logged in users from that IP address. This + // should deter a single hacker who may have guessed a password + // within the configured throttling limit. + if (isset($this->ipAddress)) { + $ipDelay = $this->throttle->ipDelay($this->ipAddress); + + if ($ipDelay > 0) { + $this->throwException("Suspicious activity has occured on your IP address and you have been denied access for another [{$ipDelay}] second(s).", 'ip', $ipDelay); + } + } + + // We will only suspend people logging into a user account. This will + // leave the logged in user unaffected. Picture a famous person who's + // account is being locked as they're logged in, purely because + // others are trying to hack it. + if ($action === 'login' && isset($user)) { + $userDelay = $this->throttle->userDelay($user); + + if ($userDelay > 0) { + $this->throwException("Too many unsuccessful login attempts have been made against your account. Please try again after another [{$userDelay}] second(s).", 'user', $userDelay); + } + } + + return true; + } + + /** + * Throws a throttling exception. + * + * @param string $message + * @param string $type + * @param int $delay + * + * @throws ThrottlingException + * + * @return void + */ + protected function throwException(string $message, string $type, int $delay): void + { + $exception = new ThrottlingException($message); + + $exception->setDelay($delay); + + $exception->setType($type); + + throw $exception; + } + + /** + * @inheritdoc + */ + public function check(UserInterface $user): bool + { + return $this->checkThrottling('check', $user); + } + + /** + * @inheritdoc + */ + public function fail(?UserInterface $user = null): bool + { + // We'll check throttling firstly from any previous attempts. This + // will throw the required exceptions if the user has already + // tried to login too many times. + $this->checkThrottling('login', $user); + + // Now we've checked previous attempts, we'll log this latest attempt. + // It'll be picked up the next time if the user tries again. + $this->throttle->log($this->ipAddress, $user); + + return true; + } +} diff --git a/src/Checkpoints/ThrottlingException.php b/src/Checkpoints/ThrottlingException.php new file mode 100644 index 0000000..8a0ebf9 --- /dev/null +++ b/src/Checkpoints/ThrottlingException.php @@ -0,0 +1,85 @@ +delay; + } + + /** + * Sets the delay. + * + * @param int $delay + * + * @return $this + */ + public function setDelay(int $delay): self + { + $this->delay = $delay; + + return $this; + } + + /** + * Returns the type. + * + * @return string + */ + public function getType(): string + { + return $this->type; + } + + /** + * Sets the type. + * + * @param string $type + * + * @return $this + */ + public function setType(string $type): self + { + $this->type = $type; + + return $this; + } + + /** + * Returns a Carbon object representing the time which the throttle is lifted. + * + * @return Carbon + */ + public function getFree(): Carbon + { + return Carbon::now()->addSeconds($this->delay); + } +} diff --git a/tests/Checkpoints/ActivationCheckpointTest.php b/tests/Checkpoints/ActivationCheckpointTest.php new file mode 100644 index 0000000..6d5fda3 --- /dev/null +++ b/tests/Checkpoints/ActivationCheckpointTest.php @@ -0,0 +1,100 @@ +activations->shouldReceive('completed')->once()->andReturn(true); + + $status = $this->checkpoint->login($this->user); + + $this->assertTrue($status); + } + + /** @test */ + public function an_exception_will_be_thrown_when_authenticating_a_non_activated_user() + { + $this->activations->shouldReceive('completed')->once()->andReturn(false); + + try { + $this->checkpoint->check($this->user); + } catch (NotActivatedException $e) { + $this->assertInstanceOf(EloquentUser::class, $e->getUser()); + } + } + + /** @test */ + public function can_return_true_when_fail_is_called() + { + $this->assertTrue($this->checkpoint->fail()); + } + + /** @test */ + public function an_exception_will_be_thrown_when_the_user_is_not_activated_and_determining_if_the_user_is_logged_in() + { + $this->expectException(NotActivatedException::class); + + $this->activations->shouldReceive('completed')->once(); + + $this->checkpoint->check($this->user); + } + + /** + * @inheritdoc + */ + protected function setUp(): void + { + $this->activations = m::mock(IlluminateActivationRepository::class); + $this->user = m::mock(EloquentUser::class); + $this->checkpoint = new ActivationCheckpoint($this->activations); + } + + /** + * @inheritdoc + */ + protected function tearDown(): void + { + $this->activations = null; + $this->user = null; + $this->checkpoint = null; + + m::close(); + } +} diff --git a/tests/Checkpoints/ThrottleCheckpointTest.php b/tests/Checkpoints/ThrottleCheckpointTest.php new file mode 100644 index 0000000..1ccb4e9 --- /dev/null +++ b/tests/Checkpoints/ThrottleCheckpointTest.php @@ -0,0 +1,164 @@ +throttle->shouldReceive('globalDelay')->once()->andReturn(0); + $this->throttle->shouldReceive('ipDelay')->once(); + $this->throttle->shouldReceive('userDelay')->once()->andReturn(0); + + $status = $this->checkpoint->login($this->user); + + $this->assertTrue($status); + } + + /** @test */ + public function can_check_if_the_user_is_being_throttled() + { + $this->throttle->shouldReceive('ipDelay')->once(); + + $status = $this->checkpoint->check($this->user); + + $this->assertTrue($status); + } + + /** @test */ + public function can_log_a_throttling_event() + { + $this->throttle->shouldReceive('globalDelay')->once(); + $this->throttle->shouldReceive('ipDelay')->once(); + $this->throttle->shouldReceive('userDelay')->once(); + $this->throttle->shouldReceive('log')->once(); + + $this->checkpoint->fail($this->user); + + $this->assertTrue(true); // TODO: Add proper assertion later + } + + /** @test */ + public function testWithIpAddress() + { + $this->throttle->shouldReceive('globalDelay')->once(); + $this->throttle->shouldReceive('ipDelay')->once(); + $this->throttle->shouldReceive('userDelay')->once(); + $this->throttle->shouldReceive('log')->once(); + + $status = $this->checkpoint->fail($this->user); + + $this->assertTrue(true); // TODO: Add proper assertion later + } + + /** @test */ + public function testFailedLogin() + { + $this->expectException(ThrottlingException::class); + $this->expectExceptionMessage('Too many unsuccessful attempts have been made globally, logins are locked for another [10] second(s).'); + + $this->throttle->shouldReceive('globalDelay')->once()->andReturn(10); + + $this->checkpoint->login($this->user); + } + + /** @test */ + public function testThrowsExceptionWithIpDelay() + { + $this->expectException(ThrottlingException::class); + $this->expectExceptionMessage('Suspicious activity has occured on your IP address and you have been denied access for another [10] second(s).'); + + $this->throttle->shouldReceive('globalDelay')->once(); + $this->throttle->shouldReceive('ipDelay')->once()->andReturn(10); + + $this->checkpoint->fail($this->user); + } + + /** @test */ + public function testThrowsExceptionWithUserDelay() + { + $this->expectException(ThrottlingException::class); + $this->expectExceptionMessage('Too many unsuccessful login attempts have been made against your account. Please try again after another [10] second(s).'); + + $this->throttle->shouldReceive('globalDelay')->once(); + $this->throttle->shouldReceive('ipDelay')->once()->andReturn(0); + $this->throttle->shouldReceive('userDelay')->once()->andReturn(10); + + $this->checkpoint->fail($this->user); + } + + /** @test */ + public function testGetThrottlingExceptionAttributes() + { + $this->throttle->shouldReceive('globalDelay')->once(); + $this->throttle->shouldReceive('ipDelay')->once()->andReturn(0); + $this->throttle->shouldReceive('userDelay')->once()->andReturn(10); + + try { + $this->checkpoint->fail($this->user); + } catch (ThrottlingException $e) { + $this->assertSame(10, $e->getDelay()); + $this->assertSame('user', $e->getType()); + $this->assertEqualsWithDelta(Carbon::now()->addSeconds(10), $e->getFree(), 3); + } + } + + /** + * @inheritdoc + */ + protected function setUp(): void + { + $this->throttle = m::mock(IlluminateThrottleRepository::class); + $this->user = m::mock(EloquentUser::class); + $this->checkpoint = new ThrottleCheckpoint($this->throttle, '127.0.0.1'); + } + + /** + * @inheritdoc + */ + protected function tearDown(): void + { + $this->throttle = null; + $this->user = null; + $this->checkpoint = null; + + m::close(); + } +}