diff --git a/.gitignore b/.gitignore index 5601611..166758b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ vendor .phpunit.result.cache .phpunit.cache .vscode/launch.json +composer.lock build diff --git a/README.md b/README.md index 48b858a..2835451 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,13 @@ composer req ldc/web-admin-secure-laravel:1.0.1 .env.example : ```dotenv -LDC_WEB_API_KEY="MY_KEY" +# ADMIN SECURE +# - API ENDPOINT (Optional) +LDC_WEB_API_ENDPOINT="https://ldc-web.ldc.fr/api/ldc-networks" +# - API KEY +LDC_WEB_API_KEY="CE8S52Qq7stf5k8frF4T" +# - LIST SEPARATED WITH ',' (Optional) +LDC_WEB_CIDR_MANUAL_ALLOWED_LIST="" ``` README.md : diff --git a/changelog.md b/changelog.md index c23f50e..0fba420 100644 --- a/changelog.md +++ b/changelog.md @@ -1,4 +1,10 @@ # Changelog + +## **Version 2.0.0** + *Changed* + - Compatible only with **php 8.2** + - Allow connections from private ip ranges + - Allow setting Cidr list into config ## **Version 1.0.1** *Changed* - Run scheduled only on production envirronement diff --git a/composer.json b/composer.json index f599007..7286a3e 100644 --- a/composer.json +++ b/composer.json @@ -10,16 +10,17 @@ } ], "require": { - "php": "~7.3|7.4|~8", - "illuminate/support": "~8|~9", - "laravel/framework": "~8|~9" + "php": ">=8.2", + "illuminate/support": "~10", + "laravel/framework": "~10", + "dxw/cidr": "^3.1" }, "require-dev": { - "phpunit/phpunit": "~9.0", - "squizlabs/php_codesniffer": "^3.5", - "orchestra/testbench": "^6.24", - "slevomat/coding-standard": "^7.0", - "phpunit/php-code-coverage": "^9.2" + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^3", + "orchestra/testbench": "^8", + "slevomat/coding-standard": "^7", + "phpunit/php-code-coverage": "^9" }, "autoload": { "psr-4": { diff --git a/config/admin-secure.php b/config/admin-secure.php index 0c592ad..0137c15 100644 --- a/config/admin-secure.php +++ b/config/admin-secure.php @@ -1,5 +1,9 @@ env('LDC_WEB_API_ENDPOINT', 'https://ldc-web.ldc.fr/api/ldc-networks'), 'apiKey' => env('LDC_WEB_API_KEY'), + 'cidrManualAllowedList' => collect(explode(',', strval(env( + 'LDC_WEB_CIDR_MANUAL_ALLOWED_LIST' + ))))->filter() ]; diff --git a/phpcs.xml b/phpcs.xml index 6f641ba..2e8e8ed 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -15,6 +15,7 @@ + @@ -114,4 +115,10 @@ + + + + */database/migrations/* + + diff --git a/src/AdminSecure.php b/src/AdminSecure.php new file mode 100644 index 0000000..dfee257 --- /dev/null +++ b/src/AdminSecure.php @@ -0,0 +1,249 @@ +server('HTTP_X_FORWARDED_FOR') ?? request()->getClientIp(); + $ip = IPAddress::Make($ip); + + if ($ip->isErr()) { + throw new AdminSecureException(\trans('Client IP not valid ') . $ip->getErr()); + } + return $ip->unwrap(); + } + + /** + * Fetch Ip Adress list from LDC Web + * + * @return array<\Dxw\CIDR\IPv4Address|\Dxw\CIDR\IPv6Address> + * @throws \LDC\WebAdminSecureLaravel\AdminSecureException If Wrong env value. + * @throws \Illuminate\Http\Client\RequestException If calling admin secure fails. + * @phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber + */ + public static function fetchIpList(): array + { + // phpcs:enable + $endpoint = config('admin-secure.endpoint'); + $key = config('admin-secure.apiKey'); + if (!$endpoint or !\filter_var($endpoint, \FILTER_VALIDATE_URL)) { + throw new AdminSecureException('LDC_WEB_API_ENDPOINT is required in .env to ba a valid url.'); + } + if (!$key) { + throw new AdminSecureException('LDC_WEB_API_KEY is required in .env .'); + } + + $response = Http::get($endpoint, ['token' => $key])->throw(); + $adresses = self::parseAdresses($response); + + Log::info(\sprintf('ADMINSECURE : Fetched %d ip(s) from LDC WEB', \count($adresses))); + + return $adresses; + } + + /** + * Fetch Ip Adress list from LDC Web + * + * @return array<\Dxw\CIDR\IPv4Range|\Dxw\CIDR\IPv6Range> + * @throws \LDC\WebAdminSecureLaravel\AdminSecureException If Wrong env value. + * @phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber + */ + public static function fetchConfigAllowedCidrList(): array + { + $allowed_cidr_config_list = \collect(config('admin-secure.cidrManualAllowedList'))->filter(); + $allowed_cidr_config_list_hash = \md5($allowed_cidr_config_list->implode(',')); + $allowed_cidr_list = $allowed_cidr_config_list + ->map(function (string $cidr) { + $ip_range = !IPv4Range::Make($cidr)->isErr() ? IPv4Range::Make($cidr) : IPv6Range::Make($cidr); + if ($ip_range->isErr()) { + throw new AdminSecureException(\trans( + 'LDC_WEB_CIDR_MANUAL_ALLOWED_LIST not valid (ipv4 or ipv6 cidr) :cidr', + [ + 'cidr' => $cidr + ] + )); + } + return $ip_range->unwrap(); + })->all(); + + // * Flush cache if hash config hash changed + if ($allowed_cidr_config_list_hash !== \collect(Cache::get(self::$cache_config_key))->get('hash')) { + Cache::flush(self::$cache_config_key); + } + + /** @var array<\Dxw\CIDR\IPv4Range|\Dxw\CIDR\IPv6Range> $allowed_cidr_list */ + return \collect(Cache::rememberForever(self::$cache_config_key, fn () => [ + 'list' => $allowed_cidr_list, + 'hash' => $allowed_cidr_config_list_hash + ]))->get('list'); + } + + /** + * Get ip list from cache + * + * @return array<\Dxw\CIDR\IPv4Address|\Dxw\CIDR\IPv6Address> + */ + public static function getCacheList(): array + { + try { + $ip_list = Cache::get(self::$cache_key, self::fetchIpList()); + self::validateIpList($ip_list); + /** @var array<\Dxw\CIDR\IPv4Address|\Dxw\CIDR\IPv6Address> $ip_list */ + return $ip_list; + } catch (AdminSecureInvalidAdressException) { + $ip_list = self::fetchIpList(); + self::validateIpList($ip_list); + self::setCacheList($ip_list); + return $ip_list; + } + } + + /** + * Set ip list to cache + * + * @param array<\Dxw\CIDR\IPv4Address|\Dxw\CIDR\IPv6Address> $ip_list + * @return void + */ + public static function setCacheList(array $ip_list): void + { + self::validateIpList($ip_list); + Cache::put(self::$cache_key, $ip_list); + } + + /** + * Check if ipV4 is private + * + * @param \Dxw\CIDR\IPv4Address $ipv4_address + * @return boolean + */ + private static function ipV4IsPrivate(IPv4Address $ipv4_address): bool + { + return \collect(self::$private_ip_v4_cidr_list) + ->concat([self::$private_ip_v4_loopback_cidr]) + ->map(fn (string $ipv4_cidr) => IPv4Range::Make($ipv4_cidr)->unwrap()) + ->reduce(fn (bool|null $initial, IPv4Range $ipv4_range) => + $initial || $ipv4_range->containsAddress($ipv4_address)); + } + + /** + * Check if ipV6 is private + * + * @param \Dxw\CIDR\IPv6Address $ipv6_address + * @return boolean + */ + private static function ipV6IsPrivate(IPv6Address $ipv6_address): bool + { + return \collect(self::$private_ip_v6_cidr_list) + ->concat([self::$private_ip_v6_loopback_cidr]) + ->map(fn (string $ipv6_cidr) => IPv6Range::Make($ipv6_cidr)->unwrap()) + ->reduce(fn (bool|null $initial, IPv6Range $ipv6_range) => + $initial || $ipv6_range->containsAddress($ipv6_address)); + } + + /** + * Validate ip list + * + * @param array $ip_list + * @return void + * @throws \LDC\WebAdminSecureLaravel\Exceptions\AdminSecureInvalidAdressException If an IP is invalid. + */ + private static function validateIpList(array $ip_list): void + { + \collect($ip_list)->each(function ($ip) { + if (!\is_object($ip) or !\in_array(\get_class($ip), [IPv4Address::class, IPv6Address::class])) { + throw new AdminSecureInvalidAdressException('Invalid adresse ' . \strval(\json_encode($ip))); + } + }); + } + + /** + * Parse adresses + * + * @param \Illuminate\Http\Client\Response $response + * @return array<\Dxw\CIDR\IPAddress> + */ + private static function parseAdresses(Response $response): array + { + return \collect($response->json()) + ->map(fn (string $ip) => IPAddress::Make($ip)->unwrap())->all(); + } +} diff --git a/src/AdminSecureServiceProvider.php b/src/AdminSecureServiceProvider.php index 9d6c7ec..ed8c5c5 100644 --- a/src/AdminSecureServiceProvider.php +++ b/src/AdminSecureServiceProvider.php @@ -3,6 +3,7 @@ namespace LDC\WebAdminSecureLaravel; use Illuminate\Console\Scheduling\Schedule; +use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\ServiceProvider; use Illuminate\Routing\Router; use LDC\WebAdminSecureLaravel\Console\SyncFromLDCWeb; @@ -21,22 +22,22 @@ class AdminSecureServiceProvider extends ServiceProvider __DIR__ . '/../config' => config_path(), ], 'config'); - // Register the command if we are using the application via the CLI . - if ($this->app->runningInConsole()) { - $this->commands([ - SyncFromLDCWeb::class, - ]); - } + // * Register the command if we are using the application via the CLI . + $this->commands([SyncFromLDCWeb::class]); + + // * Set filter condition + $this->app->singleton('hasToFilterIp', fn (Application $app) => $app->environment('production')); // * Cron Report everyday $this->app->booted(function () { - if ($this->app->environment('production')) { + if ($this->app->make('hasToFilterIp')) { /** @var \Illuminate\Console\Scheduling\Schedule */ $schedule = $this->app->make(Schedule::class); $schedule->command('ldcweb:admin-secure-sync')->daily(); } }); + /** @var \Illuminate\Routing\Router */ $router = $this->app->make(Router::class); $router->aliasMiddleware('admin-secure', AdminSecureMiddleware::class); } diff --git a/src/Console/SyncFromLDCWeb.php b/src/Console/SyncFromLDCWeb.php index da62c24..7a0d7b7 100644 --- a/src/Console/SyncFromLDCWeb.php +++ b/src/Console/SyncFromLDCWeb.php @@ -3,12 +3,11 @@ namespace LDC\WebAdminSecureLaravel\Console; use Illuminate\Console\Command; -use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Cache; -use LDC\WebAdminSecureLaravel\AdminSecureException; +use LDC\WebAdminSecureLaravel\AdminSecure; /** * Send Report Status To LDC Web. + * * @author Vincent BRIDIER */ class SyncFromLDCWeb extends Command @@ -25,36 +24,18 @@ class SyncFromLDCWeb extends Command * * @var string */ - protected $description = 'Sync IP ranges from LDC Web'; - - /** - * The call endpoint - * - * @var string - */ - private $endpoint = 'https://ldc-web.ldc.fr/api/ldc-networks'; + protected $description = 'Sync IP list from LDC Web'; /** * Execute the console command. * * @return integer + * @throws \LDC\WebAdminSecureLaravel\Exceptions\AdminSecureException If missing LDC_WEB_API_KEY key. */ - public function handle() + public function handle(): int { - $this->info('Sync IP Ranges from LDC Web.'); - - if (!config('admin-secure.apiKey')) { - throw new AdminSecureException('LDC_WEB_API_KEY is required in .env'); - } - - $response = Http::get($this->endpoint, [ - 'token' => config('admin-secure.apiKey') - ]); - - $response->throw(); - - Cache::put('ldcweb_ip_ranges', \json_decode($response->body())); - + $this->info($this->description); + AdminSecure::setCacheList(AdminSecure::fetchIpList()); $this->info('Done.'); return 0; diff --git a/src/AdminSecureException.php b/src/Exceptions/AdminSecureException.php similarity index 54% rename from src/AdminSecureException.php rename to src/Exceptions/AdminSecureException.php index f333cc7..10a92a1 100644 --- a/src/AdminSecureException.php +++ b/src/Exceptions/AdminSecureException.php @@ -1,6 +1,6 @@ make('hasToFilterIp')) { return $next($request); } - /** HA PROXY HACK */ - $ip = request()->server('HTTP_X_FORWARDED_FOR') ?? request()->getClientIp(); - $restrictedIps = Cache::get('ldcweb_ip_ranges'); + $client_ip = AdminSecure::fetchClientIp(); + $ip_is_allowed = \collect(AdminSecure::fetchIpList()) + ->reduce(fn (bool|null $initial, IPv4Address|IPv6Address $ip_address) => $initial || IP::contains( + $ip_address, + $client_ip + )->unwrap()); - if (!is_null($restrictedIps) && in_array($ip, $restrictedIps)) { + $ip_is_allowed_by_config = \collect(AdminSecure::fetchConfigAllowedCidrList()) + ->reduce(fn (bool|null $initial, IPv4Range|IPv6Range $ip_range) => $initial || IP::contains( + $ip_range, + $client_ip + )->unwrap()); + + if ($ip_is_allowed or $ip_is_allowed_by_config or AdminSecure::ipIsPrivate($client_ip)) { return $next($request); } - abort(403); + + abort(403, \trans('Denied IP')); } }