Merge branch 'develop'

This commit is contained in:
Vincent BRIDIER 2023-06-05 12:34:45 +02:00
commit 5f0c6b136f
No known key found for this signature in database
GPG key ID: 8571BFA20420D512
12 changed files with 330 additions and 52 deletions

1
.gitignore vendored
View file

@ -3,4 +3,5 @@ vendor
.phpunit.result.cache
.phpunit.cache
.vscode/launch.json
composer.lock
build

View file

@ -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 :

View file

@ -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

View file

@ -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": {

View file

@ -1,5 +1,9 @@
<?php
return [
'endpoint' => 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()
];

View file

@ -15,6 +15,7 @@
<rule ref="Generic.CodeAnalysis.UnnecessaryFinalModifier"/>
<rule ref="Generic.CodeAnalysis.UnusedFunctionParameter"/>
<rule ref="Generic.CodeAnalysis.UselessOverridingMethod"/>
<rule ref="Generic.Commenting.DocComment.SpacingBeforeTags"/>
<rule ref="Generic.Commenting.Todo"/>
<rule ref="Generic.Commenting.Fixme"/>
<rule ref="Generic.ControlStructures.InlineControlStructure"/>
@ -114,4 +115,10 @@
<rule ref="Squiz.Commenting.PostStatementComment"/>
<rule ref="Squiz.Commenting.VariableComment"/>
<rule ref="SlevomatCodingStandard.Namespaces.UnusedUses.UnusedUse" />
<!-- For Laravel migrations -->
<rule ref="PSR1.Classes.ClassDeclaration.MissingNamespace">
<exclude-pattern>*/database/migrations/*</exclude-pattern>
</rule>
</ruleset>

249
src/AdminSecure.php Normal file
View file

@ -0,0 +1,249 @@
<?php
namespace LDC\WebAdminSecureLaravel;
use Dxw\CIDR\IPAddress;
use Dxw\CIDR\IPv4Address;
use Dxw\CIDR\IPv4Range;
use Dxw\CIDR\IPv6Address;
use Dxw\CIDR\IPv6Range;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use LDC\WebAdminSecureLaravel\Exceptions\AdminSecureException;
use LDC\WebAdminSecureLaravel\Exceptions\AdminSecureInvalidAdressException;
abstract class AdminSecure
{
/**
* The cache key where to store ip list.
*
* @var string
*/
private static string $cache_key = 'ldcweb_ip_list';
/**
* The cache key where to store config cidr list.
*
* @var string
*/
private static string $cache_config_key = 'ldcweb_ip_cidr_config_list';
/**
* Private ipV4 CIDR list
*
* @var array
*/
private static array $private_ip_v4_cidr_list = [
'10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'
];
/**
* Private ipV4 CIDR loopback
*
* @var string
*/
private static string $private_ip_v4_loopback_cidr = '127.0.0.0/8';
/**
* Private ipV6 CIDR list
*
* @var array
*/
private static array $private_ip_v6_cidr_list = [
'fd00::/8'
];
/**
* Private ipV6 CIDR loopback
*
* @var string
*/
private static string $private_ip_v6_loopback_cidr = '::1/128';
/**
* Check if ip is private
*
* @param \Dxw\CIDR\IPv4Address|\Dxw\CIDR\IPv6Address $ip_address
* @return boolean
*/
public static function ipIsPrivate(IPv4Address|IPv6Address $ip_address): bool
{
return \get_class($ip_address) === IPv4Address::class ?
self::ipV4IsPrivate($ip_address) : self::ipV6IsPrivate($ip_address);
}
/**
* Fetch client IP
*
* @return \Dxw\CIDR\IPv4Address|\Dxw\CIDR\IPv6Address
* @throws \LDC\WebAdminSecureLaravel\AdminSecureException If the ip is invalid.
*/
public static function fetchClientIp(): IPv4Address|IPv6Address
{
// ! HA PROXY HACK.
$ip = request()->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();
}
}

View file

@ -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);
}

View file

@ -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 <vincent.bridier@ldc.fr>
*/
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;

View file

@ -1,6 +1,6 @@
<?php
namespace LDC\WebAdminSecureLaravel;
namespace LDC\WebAdminSecureLaravel\Exceptions;
class AdminSecureException extends \Exception
{

View file

@ -0,0 +1,7 @@
<?php
namespace LDC\WebAdminSecureLaravel\Exceptions;
class AdminSecureInvalidAdressException extends AdminSecureException
{
}

View file

@ -3,31 +3,46 @@
namespace LDC\WebAdminSecureLaravel\Http\Middleware;
use Closure;
use Dxw\CIDR\IP;
use Dxw\CIDR\IPv4Address;
use Dxw\CIDR\IPv4Range;
use Dxw\CIDR\IPv6Address;
use Dxw\CIDR\IPv6Range;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use LDC\WebAdminSecureLaravel\AdminSecure;
class AdminSecureMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
if (\config('app.env') === 'local') {
if (!\app()->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'));
}
}