From ccac907ae16affd37d173a3289eeaefbfc0e0ea8 Mon Sep 17 00:00:00 2001 From: mchev Date: Sun, 7 Jan 2024 11:46:12 +0100 Subject: [PATCH] Block by country middleware and tests --- README.md | 75 +++++++++- config/config.php | 15 +- src/Middleware/BlockByCountry.php | 52 +++---- src/Services/IpApiService.php | 8 -- tests/Unit/BlockByCountryMiddlewareTest.php | 147 ++++++++++++++++++++ 5 files changed, 259 insertions(+), 38 deletions(-) create mode 100644 tests/Unit/BlockByCountryMiddlewareTest.php diff --git a/README.md b/README.md index b51185d..1c6d51a 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,39 @@ Banhammer for Laravel offers a very simple way to ban any Model by ID and by IP. Banned models can have an expiration date and will be automatically unbanned using the Scheduler. +## Table of Contents +1. [Introduction](#banhammer-a-model-and-ip-ban-package-for-laravel) + - [Badges](#badges) +2. [Version Compatibility](#version-compatibility) +3. [Installation](#installation) + - [Composer Dependencies](#composer-dependencies) +4. [Upgrading To 2.0 from 1.x](#upgrading-to-20-from-1x) + - [Composer Dependencies](#composer-dependencies-1) + - [Configuration Changes](#configuration-changes) +5. [Usage](#usage) + - [Making a Model Bannable](#to-make-a-model-bannable-add-the-mchevbanhammertraitsbannable-trait-to-the-model) + - [Ban / Unban](#ban--unban) + - [IP](#ip) + - [Metas](#metas) + - [Blocking Access from Specific Countries](#blocking-access-from-specific-countries) + - [Middleware](#middleware) + - [Scheduler](#scheduler) + - [Events](#events) + - [Miscellaneous](#misc) +6. [Testing](#testing) +7. [Changelog](#changelog) +8. [Roadmap / Todo](#roadmap--todo) +9. [Contributing](#contributing) +10. [Security Vulnerabilities](#security-vulnerabilities) +11. [Credits](#credits) +12. [License](#license) + ## Version Compatibility Laravel | Banhammer :---------------------|:---------- - ^9.0 | 1.x.x - ^10.0 | 1.x.x + ^9.0 | 1.x, 2.x + ^10.0 | 1.x, 2.x ## Installation @@ -36,7 +63,49 @@ You can publish the config file with: php artisan vendor:publish --tag="banhammer-config" ``` -It is possible to define the table name and the fallback_url in the `config/banhammer.php` file. +It is possible to define the table name and the fallback_url in the `config/ban.php` file. + +## Upgrading To 2.0 from 1.x + +### Composer Dependencies + +To upgrade to Banhammer version 2.0, please follow these steps: + +1. Update the package version in your application's composer.json file: + +```json +"require": { + "mchev/banhammer": "^2.0" +} +``` + +Run the following command in your terminal: + +```bash +composer update mchev/banhammer +``` + +### Configuration Changes + +1. Check the config/ban.php file and make the following corrections: +```diff +- 'message' => 'You have been banned.', ++ 'messages' => [ ++ 'user' => 'Your account has been banned.', ++ 'ip' => 'Access from your IP address is restricted.', ++ 'country' => 'Access from your country is restricted.', ++ ], ++ ++ 'block_by_country' => env('BANHAMMER_BLOCK_BY_COUNTRY', false), ++ ++ 'blocked_countries' => [], // Examples: ['US', 'CA', 'GB'] +``` + +- Update the 'message' key to 'messages' and provide specific messages for user, IP, and country bans. +- Introduce the 'block_by_country' configuration option to enable or disable country-based blocking. +- Configure the list of 'blocked_countries' to specify the countries from which access is restricted. + +These changes ensure compatibility with Banhammer version 2.0 and allow for more granular control over ban messages and country-based restrictions. After making these adjustments, your application should seamlessly migrate to the latest version of Banhammer. ## Usage diff --git a/config/config.php b/config/config.php index f663fd4..200ff27 100644 --- a/config/config.php +++ b/config/config.php @@ -66,6 +66,19 @@ | */ - 'blocked_countries' => [], // Examples: ['US', 'CA', 'GB'] + 'blocked_countries' => [], // Examples: ['US', 'CA', 'GB', 'FR', 'ES', 'DE'] + + /* + |-------------------------------------------------------------------------- + | Cache Duration for IP Geolocation + |-------------------------------------------------------------------------- + | + | This configuration option determines the duration, in minutes, for which + | the IP geolocation data will be stored in the cache. This helps prevent + | excessive requests and enables the middleware to efficiently determine + | whether to block a request based on the user's country. + | + */ + 'cache_duration' => 120, // Duration in minutes ]; diff --git a/src/Middleware/BlockByCountry.php b/src/Middleware/BlockByCountry.php index ecb0c46..079ec5b 100644 --- a/src/Middleware/BlockByCountry.php +++ b/src/Middleware/BlockByCountry.php @@ -4,17 +4,16 @@ use Closure; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Mchev\Banhammer\Exceptions\BanhammerException; use Mchev\Banhammer\Services\IpApiService; class BlockByCountry { - protected IpApiService $ipApiService; - - public function __construct(IpApiService $ipApiService) + public function __construct(protected IpApiService $ipApiService) { - $this->ipApiService = $ipApiService; + // } public function handle(Request $request, Closure $next) @@ -25,26 +24,33 @@ public function handle(Request $request, Closure $next) $ip = $request->ip(); if (! is_null($ip)) { - try { - // Get geolocation data using the IpApiService - $geolocationData = $this->ipApiService->getGeolocationData($ip); - - if ($geolocationData['status'] === 'fail') { - Log::notice('Banhammer country check failure: '.$message, [ + $cacheKey = 'banhammer_'.$ip; + $cachedResult = Cache::get($cacheKey); + + if ($cachedResult === null) { + try { + $geolocationData = $this->ipApiService->getGeolocationData($ip); + + if ($geolocationData['status'] === 'fail') { + Log::notice('Banhammer country check failure: '.$geolocationData['message'], [ + 'ip' => $ip, + ]); + Cache::put($cacheKey, 'allowed', now()->addMinutes(config('ban.cache_duration'))); + } else { + if (in_array($geolocationData['countryCode'], $blockedCountries)) { + Cache::put($cacheKey, 'blocked', now()->addMinutes(config('ban.cache_duration'))); + throw new BanhammerException(config('ban.messages.country')); + } + } + + } catch (\Exception $e) { + Log::debug('Banhammer Exception: '.$e->getMessage(), [ 'ip' => $ip, + 'country' => $geolocationData['countryCode'] ?? null, ]); - } - - if ($this->isCountryBlocked($geolocationData, $blockedCountries)) { throw new BanhammerException(config('ban.messages.country')); } - } catch (\Exception $e) { - Log::debug('Banhammer Exception: '.$e->getMessage(), [ - 'ip' => $ip, - 'country' => $geolocationData['countryCode'] ?? null, - ]); - - // Rethrow the exception to ensure the ban is enforced + } elseif ($cachedResult === 'blocked') { throw new BanhammerException(config('ban.messages.country')); } } @@ -52,10 +58,4 @@ public function handle(Request $request, Closure $next) return $next($request); } - - protected function isCountryBlocked(array $geolocationData, array $blockedCountries): bool - { - return isset($geolocationData['countryCode']) && - $this->ipApiService->isCountryBlocked($geolocationData['countryCode'], $blockedCountries); - } } diff --git a/src/Services/IpApiService.php b/src/Services/IpApiService.php index 93574cc..fad24a7 100644 --- a/src/Services/IpApiService.php +++ b/src/Services/IpApiService.php @@ -33,12 +33,4 @@ public function getGeolocationData(string $ip): ?array return null; } } - - /** - * Check if the country code is in the list of blocked countries. - */ - public function isCountryBlocked(string $countryCode, array $blockedCountries): bool - { - return in_array($countryCode, $blockedCountries); - } } diff --git a/tests/Unit/BlockByCountryMiddlewareTest.php b/tests/Unit/BlockByCountryMiddlewareTest.php new file mode 100644 index 0000000..9a7db7c --- /dev/null +++ b/tests/Unit/BlockByCountryMiddlewareTest.php @@ -0,0 +1,147 @@ +ipApiService = Mockery::mock(IpApiService::class); + + // Create an instance of the middleware with the mock service + $this->middleware = new BlockByCountry($this->ipApiService); + } + + protected function tearDown(): void + { + parent::tearDown(); + + // Close Mockery expectations + Mockery::close(); + } + + /** @test */ + public function it_allows_request_from_non_blocked_country() + { + // Given + $ip = '100.42.30.255'; // IP from a country not in the blocked list + + // Setting configuration using Config facade + config(['ban.block_by_country' => true]); + config(['ban.blocked_countries' => ['FR', 'US']]); + + // Set up the mock behavior + $this->ipApiService + ->shouldReceive('getGeolocationData') + ->andReturn(['status' => 'success', 'countryCode' => 'CA']); + + // Create a dummy request + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => $ip]); + + // When + $response = $this->middleware->handle($request, function ($req) { + return response()->json(['status' => 'success']); + }); + + // Then + $this->assertEquals('success', json_decode($response->getContent(), true)['status']); + } + + /** @test */ + public function it_blocks_request_from_blocked_country() + { + // Given + $ip = '100.42.30.255'; // IP from a blocked country + + // Setting configuration using Config facade + config(['ban.block_by_country' => true]); + config(['ban.blocked_countries' => ['FR', 'US']]); + + // Set up the mock behavior + $this->ipApiService + ->shouldReceive('getGeolocationData') + ->andReturn(['status' => 'success', 'countryCode' => 'US']); + + // Create a dummy request + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => $ip]); + + // When + $this->expectException(BanhammerException::class); + $this->middleware->handle($request, function ($req) { + // Dummy callback, as we are not actually calling the next middleware or endpoint + }); + } + + /** @test */ + public function it_allows_request_when_country_check_fails() + { + // Given + $ip = '100.42.30.255'; // IP from a country not in the blocked list + + // Setting configuration using Config facade + config(['ban.block_by_country' => true]); + config(['ban.blocked_countries' => ['FR', 'US']]); + + // Set up the mock behavior + $this->ipApiService + ->shouldReceive('getGeolocationData') + ->andReturn(['status' => 'fail', 'message' => 'Failed to get geolocation']); + + // Create a dummy request + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => $ip]); + + // When + $response = $this->middleware->handle($request, function ($req) { + return response()->json(['status' => 'success']); + }); + + // Then + $this->assertEquals('success', json_decode($response->getContent(), true)['status']); + } + + /** @test */ + public function it_allows_request_when_cache_is_present() + { + // Given + $ip = '100.42.30.255'; // IP from a country not in the blocked list + + // Setting configuration using Config facade + config(['ban.block_by_country' => true]); + config(['ban.blocked_countries' => ['FR', 'US']]); + + // Set up the mock behavior + $this->ipApiService + ->shouldReceive('getGeolocationData') + ->andReturn(['status' => 'success', 'countryCode' => 'CA']); + + // Set up cache + Cache::put('banhammer_'.$ip, 'blocked', now()->addMinutes(config('ban.cache_duration'))); + + // Create a dummy request + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => $ip]); + + // When + $this->expectException(BanhammerException::class); + $this->middleware->handle($request, function ($req) { + // Dummy callback, as we are not actually calling the next middleware or endpoint + }); + } +}