From 7a23141adfd2c99b32e973ea1c8cf53837019bfd Mon Sep 17 00:00:00 2001 From: mchev Date: Tue, 14 Nov 2023 18:08:31 +0100 Subject: [PATCH] Block by country --- .github/ISSUE_TEMPLATE/bug.yml | 56 ------------------------ .github/ISSUE_TEMPLATE/config.yml | 8 ---- README.md | 44 +++++++++++++++---- composer.json | 5 ++- config/config.php | 47 ++++++++++++++++++--- src/BanhammerServiceProvider.php | 6 +++ src/Exceptions/BanhammerException.php | 35 +++++++++++++++ src/Middleware/AuthBanned.php | 5 +-- src/Middleware/BlockByCountry.php | 61 +++++++++++++++++++++++++++ src/Middleware/IPBanned.php | 17 ++++++-- src/Middleware/LogoutBanned.php | 5 +-- src/Services/IpApiService.php | 44 +++++++++++++++++++ src/Traits/Bannable.php | 31 +++++++++++--- tests/Unit/IPBannedMiddlewareTest.php | 4 +- 14 files changed, 269 insertions(+), 99 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug.yml delete mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 src/Exceptions/BanhammerException.php create mode 100644 src/Middleware/BlockByCountry.php create mode 100644 src/Services/IpApiService.php diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml deleted file mode 100644 index 4b8f798..0000000 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Bug Report -description: Report an Issue or Bug with the Package -title: "[Bug]: " -labels: ["bug"] -body: - - type: markdown - attributes: - value: | - We're sorry to hear you have a problem. Can you help us solve it by providing the following details. - - type: textarea - id: what-happened - attributes: - label: What happened? - description: What did you expect to happen? - placeholder: I cannot currently do X thing because when I do, it breaks X thing. - validations: - required: true - - type: textarea - id: how-to-reproduce - attributes: - label: How to reproduce the bug - description: How did this occur, please add any config values used and provide a set of reliable steps if possible. - placeholder: When I do X I see Y. - validations: - required: true - - type: input - id: package-version - attributes: - label: Package Version - description: What version of our Package are you running? Please be as specific as possible - placeholder: 2.0.0 - validations: - required: true - - type: input - id: php-version - attributes: - label: PHP Version - description: What version of PHP are you running? Please be as specific as possible - placeholder: 8.2.0 - validations: - required: true - - type: input - id: laravel-version - attributes: - label: Laravel Version - description: What version of Laravel are you running? Please be as specific as possible - placeholder: 9.0.0 - validations: - required: true - - type: textarea - id: notes - attributes: - label: Notes - description: Use this field to provide any other notes that you feel might be relevant to the issue. - validations: - required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 339c565..0000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,8 +0,0 @@ -blank_issues_enabled: false -contact_links: - - name: Ask a question - url: https://github.com/mchev/banhammer/discussions/new?category=q-a - about: Ask the community for help - - name: Request a feature - url: https://github.com/mchev/banhammer/discussions/new?category=ideas - about: Share ideas for new features \ No newline at end of file diff --git a/README.md b/README.md index 19fffc3..b51185d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ 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/ban.php` file. +It is possible to define the table name and the fallback_url in the `config/banhammer.php` file. ## Usage @@ -178,6 +178,32 @@ $users->bans()->whereMeta('username', 'Jane')->get(); $users->whereBansMeta('username', 'Jane')->get(); ``` +### Blocking Access from Specific Countries + +To enhance the security of your application, you can restrict access from specific countries by enabling the country-blocking feature in the configuration file. Follow these simple steps: + +1. Open your Banhammer configuration file (config/ban.php). + +2. Set the 'block_by_country' configuration option to true to enable country-based blocking. + +```php +'block_by_country' => true, +``` + +3. Specify the list of countries you want to block by adding their country codes to the 'blocked_countries' array. + +```php +'blocked_countries' => ['FR', 'ES'], +``` + +By configuring these settings, you effectively block access to your application for users originating from the specified countries. This helps improve the security and integrity of your system by preventing unwanted traffic from regions you've identified as potential risks. + +**Important Notice:** +The Banhammer package utilizes the free version of ip-api.com for geolocation data. Keep in mind that their endpoints have a rate limit of 45 HTTP requests per minute from a single IP address. If you exceed this limit, your requests will be throttled, and you may receive a 429 HTTP status code until your rate limit window is reset. + +**Developer Note:** +While Banhammer currently relies on the free version of [ip-api.com](https://ip-api.com/) for geolocation data, I'm open to exploring better alternatives. If you have suggestions for a more robust or efficient solution, or if you'd like to contribute improvements, please feel free to [open an issue](https://github.com/mchev/banhammer/issues) or submit a [pull request](https://github.com/mchev/banhammer/pulls). + ### Middleware To prevent banned users from accessing certain parts of your application, simply add the `auth.banned` middleware on the concerned routes. ```php @@ -256,18 +282,18 @@ composer test Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. -## Roadmap +## Roadmap / Todo -- [ ] Handle UUIDs and ULIDs -- [ ] More tests -- [ ] Block IP range -- [ ] Auto block IP (Rate Limiting) -- [x] Cache -- [x] Ban history (expired, not expired) +- [ ] Web page to dispay infos (ips banned, block by country enabled, etc.). Dev mode only +- [ ] Block by country feature ## Contributing -Please see [CONTRIBUTING](CONTRIBUTING.md) for details. +To encourage active collaboration, Banhammer strongly encourages pull requests, not just bug reports. Pull requests will only be reviewed when marked as "ready for review" (not in the "draft" state) and all tests for new features are passing. Lingering, non-active pull requests left in the "draft" state will be closed after a few days. + +However, if you file a bug report, your issue should contain a title and a clear description of the issue. You should also include as much relevant information as possible and a code sample that demonstrates the issue. The goal of a bug report is to make it easy for yourself - and others - to replicate the bug and develop a fix. + +Remember, bug reports are created in the hope that others with the same problem will be able to collaborate with you on solving it. Do not expect that the bug report will automatically see any activity or that others will jump to fix it. ## Security Vulnerabilities diff --git a/composer.json b/composer.json index 3e2b5b0..d0f43bb 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,14 @@ { "name": "mchev/banhammer", - "description": "BanHamer for Laravel allows you to ban any Model by key and by IP.", + "description": "Banhammer for Laravel allows you to ban any Model by key and by IP.", "keywords": [ "mchev", "laravel", "bans-for-laravel", "ban", "bannable", - "ip" + "ip", + "country" ], "homepage": "https://github.com/mchev/banhammer", "license": "MIT", diff --git a/config/config.php b/config/config.php index 5b0410b..f663fd4 100644 --- a/config/config.php +++ b/config/config.php @@ -7,7 +7,8 @@ | Table Name |-------------------------------------------------------------------------- | - | Change here the name of the table that will be created during the migration to use the ban system. + | Specify the name of the table created during migration for the ban system. + | This table will store information about banned users. | */ @@ -15,24 +16,56 @@ /* |-------------------------------------------------------------------------- - | Where to redirect banned Models + | Where to Redirect Banned Users |-------------------------------------------------------------------------- | - | Url to which the user will be redirected when he/she tries to log in after being banned. - | If not defined, the banned user will be redirected to the previous page. + | Define the URL to which users will be redirected when attempting to log in + | after being banned. If not defined, the banned user will be redirected to + | the previous page they tried to access. | */ - 'fallback_url' => null, // null | "/oops" + 'fallback_url' => null, // Examples: null (default), "/oops", "/login" /* |-------------------------------------------------------------------------- | 403 Message |-------------------------------------------------------------------------- | - | The message that will be displayed if no fallback url for banned users has been defined. + | The message that will be displayed if no fallback URL is defined for banned users. | */ - '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 + |-------------------------------------------------------------------------- + | + | Determine whether to block users based on their country. This setting uses + | the value of BANHAMMER_BLOCK_BY_COUNTRY from the environment. Enabling this + | feature may result in up to 45 HTTP requests per minute with the free version + | of https://ip-api.com/. + | + */ + + 'block_by_country' => env('BANHAMMER_BLOCK_BY_COUNTRY', false), + + /* + |-------------------------------------------------------------------------- + | List of Blocked Countries + |-------------------------------------------------------------------------- + | + | Specify the countries where users will be blocked if 'block_by_country' is true. + | Add country codes to the array to restrict access from those countries. + | + */ + + 'blocked_countries' => [], // Examples: ['US', 'CA', 'GB'] ]; diff --git a/src/BanhammerServiceProvider.php b/src/BanhammerServiceProvider.php index ca7afdf..f0bfa19 100644 --- a/src/BanhammerServiceProvider.php +++ b/src/BanhammerServiceProvider.php @@ -8,6 +8,7 @@ use Mchev\Banhammer\Commands\ClearBans; use Mchev\Banhammer\Commands\DeleteExpired; use Mchev\Banhammer\Middleware\AuthBanned; +use Mchev\Banhammer\Middleware\BlockByCountry; use Mchev\Banhammer\Middleware\IPBanned; use Mchev\Banhammer\Middleware\LogoutBanned; use Mchev\Banhammer\Models\Ban; @@ -29,6 +30,10 @@ public function boot(): void $router->aliasMiddleware('ip.banned', IPBanned::class); $router->aliasMiddleware('logout.banned', LogoutBanned::class); + if (config('ban.block_by_country')) { + $router->pushMiddlewareToGroup('web', BlockByCountry::class); + } + if ($this->app->runningInConsole()) { // Publishing the config. $this->publishes([ @@ -46,6 +51,7 @@ public function boot(): void $schedule = $this->app->make(Schedule::class); $schedule->command('banhammer:unban')->everyMinute(); }); + } /** diff --git a/src/Exceptions/BanhammerException.php b/src/Exceptions/BanhammerException.php new file mode 100644 index 0000000..019a60a --- /dev/null +++ b/src/Exceptions/BanhammerException.php @@ -0,0 +1,35 @@ +getMessage()}"); + } + + /** + * Render the exception into an HTTP response. + */ + public function render(Request $request): Response + { + return (config('ban.fallback_url')) + ? redirect(config('ban.fallback_url')) + : abort(403, $this->getMessage()); + } +} diff --git a/src/Middleware/AuthBanned.php b/src/Middleware/AuthBanned.php index efbf153..cec434e 100644 --- a/src/Middleware/AuthBanned.php +++ b/src/Middleware/AuthBanned.php @@ -3,6 +3,7 @@ namespace Mchev\Banhammer\Middleware; use Closure; +use Mchev\Banhammer\Exceptions\BanhammerException; use Symfony\Component\HttpFoundation\Response; class AuthBanned @@ -10,9 +11,7 @@ class AuthBanned public function handle($request, Closure $next): Response { if ($request->user() && $request->user()->isBanned()) { - return (config('ban.fallback_url')) - ? redirect(config('ban.fallback_url')) - : abort(403, config('ban.message')); + throw new BanhammerException(config('ban.messages.user')); } return $next($request); diff --git a/src/Middleware/BlockByCountry.php b/src/Middleware/BlockByCountry.php new file mode 100644 index 0000000..ecb0c46 --- /dev/null +++ b/src/Middleware/BlockByCountry.php @@ -0,0 +1,61 @@ +ipApiService = $ipApiService; + } + + public function handle(Request $request, Closure $next) + { + $blockedCountries = config('ban.blocked_countries'); + + if ($blockedCountries && ! empty($blockedCountries)) { + $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, [ + 'ip' => $ip, + ]); + } + + 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 + throw new BanhammerException(config('ban.messages.country')); + } + } + } + + 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/Middleware/IPBanned.php b/src/Middleware/IPBanned.php index 6818b0b..2f70869 100644 --- a/src/Middleware/IPBanned.php +++ b/src/Middleware/IPBanned.php @@ -3,6 +3,8 @@ namespace Mchev\Banhammer\Middleware; use Closure; +use Illuminate\Support\Facades\Log; +use Mchev\Banhammer\Exceptions\BanhammerException; use Mchev\Banhammer\IP; use Symfony\Component\HttpFoundation\Response; @@ -10,10 +12,17 @@ class IPBanned { public function handle($request, Closure $next): Response { - if ($request->ip() && in_array($request->ip(), IP::getBannedIPsFromCache())) { - return (config('ban.fallback_url')) - ? redirect(config('ban.fallback_url')) - : abort(403, config('ban.message')); + try { + $bannedIPs = IP::getBannedIPsFromCache(); + + if ($request->ip() && in_array($request->ip(), $bannedIPs)) { + throw new BanhammerException(config('ban.messages.ip')); + } + } catch (\Exception $e) { + // Log the exception + Log::error('IPBanned Middleware Exception: '.$e->getMessage(), ['exception' => $e]); + + throw $e; } return $next($request); diff --git a/src/Middleware/LogoutBanned.php b/src/Middleware/LogoutBanned.php index f339b7e..5e00760 100644 --- a/src/Middleware/LogoutBanned.php +++ b/src/Middleware/LogoutBanned.php @@ -3,6 +3,7 @@ namespace Mchev\Banhammer\Middleware; use Closure; +use Mchev\Banhammer\Exceptions\BanhammerException; use Mchev\Banhammer\IP; use Symfony\Component\HttpFoundation\Response; @@ -18,9 +19,7 @@ public function handle($request, Closure $next): Response $request->session()->regenerateToken(); } - return (config('ban.fallback_url')) - ? redirect(config('ban.fallback_url')) - : abort(403, config('ban.message')); + throw new BanhammerException(config('ban.messages.user')); } return $next($request); diff --git a/src/Services/IpApiService.php b/src/Services/IpApiService.php new file mode 100644 index 0000000..93574cc --- /dev/null +++ b/src/Services/IpApiService.php @@ -0,0 +1,44 @@ +addDay(), function () use ($ip) { + $response = Http::get("http://ip-api.com/json/{$ip}?fields=status,message,countryCode,query"); + + return $response->json(); + }); + + return $geolocationData; + } catch (\Exception $e) { + // Log the error + Log::error('IP-API Service Error: '.$e->getMessage(), ['exception' => $e]); + + // Handle the error as needed + 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/src/Traits/Bannable.php b/src/Traits/Bannable.php index b3c7d50..4b6f95f 100644 --- a/src/Traits/Bannable.php +++ b/src/Traits/Bannable.php @@ -17,28 +17,34 @@ public function bans(): MorphMany } /** - * If model is not banned. + * Check if the model is banned. */ public function isBanned(): bool { - return $this->bans->filter(function ($ban) { - return $ban->notExpired(); - })->isNotEmpty(); + return $this->bans->first(function ($ban) { + return $ban->expired_at === null || $ban->expired_at->isFuture(); + }) !== null; } /** - * If model is not banned. + * Check if the model is not banned. */ public function isNotBanned(): bool { return ! $this->isBanned(); } + /** + * Ban the model with the specified attributes. + */ public function ban(array $attributes = []): Ban { return $this->bans()->create($attributes); } + /** + * Ban the model until the specified date. + */ public function banUntil(string $date): Ban { return $this->ban([ @@ -46,11 +52,17 @@ public function banUntil(string $date): Ban ]); } + /** + * Unban the model by deleting all bans. + */ public function unban(): void { $this->bans()->each(fn ($ban) => $ban->delete()); } + /** + * Scope a query to include only models that are currently banned. + */ public function scopeBanned(Builder $query): void { $query->whereHas('bans', function ($query) { @@ -58,11 +70,17 @@ public function scopeBanned(Builder $query): void }); } + /** + * Scope a query to include only models that are not currently banned. + */ public function scopeNotBanned(Builder $query): void { $query->whereDoesntHave('bans'); } + /** + * Scope a query to include only models with bans having a specific meta key and value. + */ public function scopeWhereBansMeta(Builder $query, string $key, $value): void { $query->whereHas('bans', function ($query) use ($key, $value) { @@ -70,6 +88,9 @@ public function scopeWhereBansMeta(Builder $query, string $key, $value): void }); } + /** + * Scope a query to include only models with bans created by a specific type. + */ public function scopeBannedByType(Builder $query, string $className): void { $query->whereHas('bans', function ($query) use ($className) { diff --git a/tests/Unit/IPBannedMiddlewareTest.php b/tests/Unit/IPBannedMiddlewareTest.php index 01b856f..024b314 100644 --- a/tests/Unit/IPBannedMiddlewareTest.php +++ b/tests/Unit/IPBannedMiddlewareTest.php @@ -4,10 +4,10 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\Request; +use Mchev\Banhammer\Exceptions\BanhammerException; use Mchev\Banhammer\IP; use Mchev\Banhammer\Middleware\IPBanned; use Mchev\Banhammer\Tests\TestCase; -use Symfony\Component\HttpKernel\Exception\HttpException; class IPBannedMiddlewareTest extends TestCase { @@ -28,7 +28,7 @@ public function it_blocks_the_banned_ip() (new IPBanned())->handle($request, function () { // }); - } catch (HttpException $e) { + } catch (BanhammerException $e) { $this->assertEquals(403, $e->getStatusCode()); } }