diff --git a/composer.json b/composer.json index be32d03e..f719196a 100644 --- a/composer.json +++ b/composer.json @@ -95,7 +95,7 @@ "analyze": "Lance l'analyse statique du code du framework", "cs": "Vérifie les normes de codage", "cs:fix": "Corrige le style de codage", - "phpstan:baseline": "Exécute PHPStan puis transférer toutes les erreurs vers la ligne de base.", + "phpstan:baseline": "Exécute PHPStan puis transférer toutes les erreurs vers le fichier de baseline.", "phpstan:check": "Exécute PHPStan avec la prise en charge des identifiants", "test": "Execute les tests unitaires" }, diff --git a/phpstan-baseline.php b/phpstan-baseline.php index 1c412b58..6f18fc2d 100644 --- a/phpstan-baseline.php +++ b/phpstan-baseline.php @@ -697,6 +697,24 @@ 'count' => 3, 'path' => __DIR__ . '/src/Router/Dispatcher.php', ]; +$ignoreErrors[] = [ + // identifier: booleanAnd.alwaysFalse + 'message' => '#^Result of && is always false\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Security/Hashing/Handlers/ArgonHandler.php', +]; +$ignoreErrors[] = [ + // identifier: identical.alwaysFalse + 'message' => '#^Strict comparison using \\=\\=\\= between \'standard\' and \'sodium\' will always evaluate to false\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Security/Hashing/Handlers/ArgonHandler.php', +]; +$ignoreErrors[] = [ + // identifier: method.notFound + 'message' => '#^Call to an undefined method BlitzPHP\\\\Contracts\\\\Security\\\\HasherInterface\\:\\:verifyConfiguration\\(\\)\\.$#', + 'count' => 1, + 'path' => __DIR__ . '/src/Security/Hashing/Hasher.php', +]; $ignoreErrors[] = [ // identifier: class.notFound 'message' => '#^Call to method directive\\(\\) on an unknown class Jenssegers\\\\Blade\\\\Blade\\.$#', diff --git a/spec/system/framework/Security/Encryption/Encryption.spec.php b/spec/system/framework/Security/Encryption/Encryption.spec.php index be775295..6dc9ed16 100644 --- a/spec/system/framework/Security/Encryption/Encryption.spec.php +++ b/spec/system/framework/Security/Encryption/Encryption.spec.php @@ -66,8 +66,8 @@ }); }); - describe('Service', static function (): void { - it(': Le service encrypter fonctionne', static function (): void { + describe('Service', function (): void { + it(': Le service encrypter fonctionne', function (): void { $config = config('encryption'); $config['driver'] = 'OpenSSL'; $config['key'] = 'anything'; @@ -77,32 +77,32 @@ expect($encrypter)->toBeAnInstanceOf(EncrypterInterface::class); }); - it(': Le service encrypter leve une exception si le pilote est mauvais', static function (): void { + it(': Le service encrypter leve une exception si le pilote est mauvais', function (): void { // ask for a bad driver $config = config('encryption'); $config['driver'] = 'Bogus'; $config['key'] = 'anything'; - expect(static function () use ($config): void { + expect(function () use ($config): void { service('encrypter', $config); })->toThrow(new EncryptionException()); }); - it(": Le service encrypter leve une exception s'il n'y a pas de cle", static function (): void { - expect(static function (): void { + it(": Le service encrypter leve une exception s'il n'y a pas de cle", function (): void { + expect(function (): void { service('encrypter'); })->toThrow(new EncryptionException()); }); - it(': Service encrypter partagé', static function (): void { + it(': Service encrypter partagé', function (): void { $config = config('encryption'); $config['driver'] = 'OpenSSL'; $config['key'] = 'anything'; - $encrypter = single_service('encrypter', $config); + $encrypter = service('encrypter', $config); $config['key'] = 'Abracadabra'; - $encrypter = single_service('encrypter', $config); + $encrypter = service('encrypter', $config, true); expect($encrypter->key)->toBe('anything'); }); @@ -120,8 +120,8 @@ }); }); - describe('Decryptage', static function (): void { - it(': Decrypte une chaine codée avec AES-128-CBC', static function (): void { + describe('Decryptage', function (): void { + it(': Decrypte une chaine codée avec AES-128-CBC', function (): void { $config = config('encryption'); $config['driver'] = 'OpenSSL'; $config['key'] = hex2bin('64c70b0b8d45b80b9eba60b8b3c8a34d0193223d20fea46f8644b848bf7ce67f'); @@ -138,7 +138,7 @@ expect($decrypted)->toBe($expected); }); - it(': Decrypte une chaine codée avec AES-256-CTR', static function (): void { + it(': Decrypte une chaine codée avec AES-256-CTR', function (): void { $config = config('encryption'); $config['driver'] = 'OpenSSL'; $config['key'] = hex2bin('64c70b0b8d45b80b9eba60b8b3c8a34d0193223d20fea46f8644b848bf7ce67f'); @@ -155,7 +155,7 @@ expect($decrypted)->toBe($expected); }); - it(': Decrypte une chaine codée avec base64_encode', static function (): void { + it(': Decrypte une chaine codée avec base64_encode', function (): void { $config = config('encryption'); $config['driver'] = 'OpenSSL'; $config['key'] = hex2bin('64c70b0b8d45b80b9eba60b8b3c8a34d0193223d20fea46f8644b848bf7ce67f'); diff --git a/spec/system/framework/Security/Hashing/Hasher.spec.php b/spec/system/framework/Security/Hashing/Hasher.spec.php new file mode 100644 index 00000000..c073b8ce --- /dev/null +++ b/spec/system/framework/Security/Hashing/Hasher.spec.php @@ -0,0 +1,166 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use BlitzPHP\Contracts\Security\HasherInterface; +use BlitzPHP\Exceptions\HashingException; +use BlitzPHP\Security\Hashing\Handlers\Argon2IdHandler; +use BlitzPHP\Security\Hashing\Handlers\ArgonHandler; +use BlitzPHP\Security\Hashing\Handlers\BcryptHandler; +use BlitzPHP\Security\Hashing\Hasher; +use BlitzPHP\Spec\ReflectionHelper; + +use function Kahlan\expect; + +describe('Security / Hashing', function (): void { + beforeEach(function (): void { + $this->hasher = new Hasher(); + }); + + describe('Hasher', function (): void { + it(": L'utilisation d'un mauvais pilote leve une exception", function (): void { + $config = (object) config('hashing'); + $config->driver = 'Bogus'; + + expect(function () use ($config): void { + $this->hasher->initialize($config); + })->toThrow(new HashingException()); + }); + + it(": L'abscence du pilote leve une exception", function (): void { + // ask for a bad driver + $config = (object) config('hashing'); + $config->driver = ''; + + expect(function () use ($config): void { + $this->hasher->initialize($config); + })->toThrow(new HashingException()); + }); + + it(':isHashed', function () { + $hash = $this->hasher->make('password'); + + expect($this->hasher->isHashed($hash))->toBeTruthy(); + expect($this->hasher->isHashed('password'))->toBeFalsy(); + }); + }); + + describe('Service', function (): void { + it(': Le service hashing fonctionne', function (): void { + $config = config('hashing'); + $config['driver'] = 'bcrypt'; + + $hasher = service('hashing', $config); + + expect($hasher)->toBeAnInstanceOf(HasherInterface::class); + }); + + it(': Le service hashing leve une exception si le pilote est mauvais', function (): void { + $config = config('hashing'); + $config['driver'] = 'Bogus'; + + expect(function () use ($config): void { + single_service('hashing', $config); + })->toThrow(new HashingException()); + }); + + it(': Service hashing partagé', function (): void { + $config = config('hashing'); + $config['driver'] = 'bcrypt'; + + $hasher = service('hashing', $config); + + $config['driver'] = 'argon'; + $hasher = service('hashing', $config, true); + + expect(ReflectionHelper::getPrivateProperty($hasher, 'driver'))->toBe('bcrypt'); + }); + }); + + describe(':check' , function () { + it('Les valeurs vide renvoient false', function () { + $hasher = service('hashing'); + expect($hasher->check('', ''))->toBeFalsy(); + expect($hasher->check('test', ''))->toBeFalsy(); + + $hasher = new BcryptHandler(); + expect($hasher->check('password', ''))->toBeFalsy(); + $hasher = new ArgonHandler(); + expect($hasher->check('password', ''))->toBeFalsy(); + $hasher = new Argon2IdHandler(); + expect($hasher->check('password', ''))->toBeFalsy(); + }); + }); + + describe('Drivers', function (): void { + it(': Bcrypt', function (): void { + $hasher = new BcryptHandler(); + $value = $hasher->make('password'); + + expect($value)->not->toBe('password'); + expect($hasher->check('password', $value))->toBeTruthy(); + expect($hasher->needsRehash($value))->toBeFalsy(); + expect($hasher->needsRehash($value, ['rounds' => 1]))->toBeTruthy(); + expect($hasher->info($value)['algoName'])->toBe('bcrypt'); + expect($hasher->info($value)['options']['cost'])->toBeGreaterThan(11); // >= 12 + expect($this->hasher->isHashed($value))->toBeTruthy(); + }); + + it(': Argon', function (): void { + $hasher = new ArgonHandler(); + $value = $hasher->make('password'); + + expect($value)->not->toBe('password'); + expect($hasher->check('password', $value))->toBeTruthy(); + expect($hasher->needsRehash($value))->toBeFalsy(); + expect($hasher->needsRehash($value, ['threads' => 1]))->toBeTruthy(); + expect($hasher->info($value)['algoName'])->toBe('argon2i'); + expect($this->hasher->isHashed($value))->toBeTruthy(); + }); + + it(': Argon2id', function (): void { + $hasher = new Argon2IdHandler(); + $value = $hasher->make('password'); + + expect($value)->not->toBe('password'); + expect($hasher->check('password', $value))->toBeTruthy(); + expect($hasher->needsRehash($value))->toBeFalsy(); + expect($hasher->needsRehash($value, ['threads' => 1]))->toBeTruthy(); + expect($hasher->info($value)['algoName'])->toBe('argon2id'); + expect($this->hasher->isHashed($value))->toBeTruthy(); + }); + }); + + describe('Verification', function (): void { + it('Bcrypt', function (): void { + $argonHandler = new ArgonHandler(['verify' => true]); + $argonHashed = $argonHandler->make('password'); + + expect(fn() => (new BcryptHandler(['verify' => true]))->check('password', $argonHashed)) + ->toThrow(new RuntimeException()); + }); + + it('Argon', function (): void { + $argonHandler = new BcryptHandler(['verify' => true]); + $argonHashed = $argonHandler->make('password'); + + expect(fn() => (new ArgonHandler(['verify' => true]))->check('password', $argonHashed)) + ->toThrow(new RuntimeException()); + }); + + it('Argon2id', function (): void { + $argonHandler = new BcryptHandler(['verify' => true]); + $argonHashed = $argonHandler->make('password'); + + expect(fn() => (new Argon2IdHandler(['verify' => true]))->check('password', $argonHashed)) + ->toThrow(new RuntimeException()); + }); + }); +}); diff --git a/src/Cli/Commands/Encryption/GenerateKey.php b/src/Cli/Commands/Encryption/GenerateKey.php index 1f0da4f6..5ec97731 100644 --- a/src/Cli/Commands/Encryption/GenerateKey.php +++ b/src/Cli/Commands/Encryption/GenerateKey.php @@ -13,6 +13,7 @@ use BlitzPHP\Cli\Console\Command; use BlitzPHP\Loader\DotEnv; +use BlitzPHP\Security\Encryption\Encryption; /** * Genere une nouvelle cle d'encryption. @@ -32,20 +33,20 @@ class GenerateKey extends Command /** * @var string Description */ - protected $description = 'Génère une nouvelle clé d\'encryption et la met dans le fichier `.env`.'; + protected $description = 'Génère une nouvelle clé de chiffrememt et la met dans le fichier `.env`.'; /** * @var string */ - protected $service = 'Service de d\'encryption'; + protected $service = 'Service de chiffrememt'; /** * @var array Options */ protected $options = [ '--force' => 'Force l\'écrasement de clé existante dans le fichier `.env`.', - '--length' => ['La longeur de la chaîne aléatoire qui doit être retournée en bytes. Par défaut "32".', 32], - '--prefix' => ['Prefix à ajouter à la clé encodée (doit être hex2bin ou base64). Par défaut "hex2bin".', 'hex2bin'], + '--length' => ['La longueur de la chaîne aléatoire qui doit être retournée en bytes.', 32], + '--prefix' => ['Prefix à ajouter à la clé encodée (doit être hex2bin ou base64).', 'hex2bin'], '--show' => 'Indique qu\'on souhaite afficher la clé générée dans le terminal après l\'avoir mis dans le fichier `.env`.', ]; @@ -68,7 +69,7 @@ public function execute(array $params) $length = 32; } - $this->task('Génération d\'une nouvelle clé d\'encryption'); + $this->task('Génération d\'une nouvelle clé de chiffrememt'); $encodedKey = $this->generateRandomKey($prefix, $length); @@ -79,12 +80,12 @@ public function execute(array $params) } if (! $this->setNewEncryptionKey($encodedKey)) { - $this->writer->error('Erreur dans la configuration d\'une nouvelle cle d\'encryption dans le fichier `.env`.', true); + $this->writer->error('Erreur dans la configuration d\'une nouvelle clé de chiffrememt dans le fichier `.env`.', true); return; } - $this->success('Une nouvelle clé d\'encryption de l\'application a été définie avec succès.'); + $this->success('Une nouvelle clé de chiffrement de l\'application a été définie avec succès.'); } /** @@ -92,7 +93,7 @@ public function execute(array $params) */ protected function generateRandomKey(string $prefix, int $length): string { - $key = random_bytes($length); // @todo prevoir l'utilisation d'une classe Encryption + $key = Encryption::createKey($length); if ($prefix === 'hex2bin') { return 'hex2bin:' . bin2hex($key); diff --git a/src/Cli/Commands/Routes/Routes.php b/src/Cli/Commands/Routes/Routes.php index 5e9993f6..77f1216c 100644 --- a/src/Cli/Commands/Routes/Routes.php +++ b/src/Cli/Commands/Routes/Routes.php @@ -82,7 +82,7 @@ class Routes extends Command protected array $headers = ['Domain', 'Method', 'Route', 'Name', 'Handler', 'Middleware']; /** - * @var array + * @var array */ protected array $verbColors = [ 'GET' => Color::BLUE, @@ -209,7 +209,7 @@ protected function getRouteInformation(array $route, SampleURIGenerator $uriGene 'domain' => $route['domain'] ?? '', 'method' => $route['method'], 'route' => $route['route'], - 'uri' => $sampleUri, + 'uri' => $sampleUri ?? '', 'name' => $route['name'], 'handler' => ltrim($route['handler'], '\\'), 'middleware' => $route['middleware'], @@ -302,22 +302,6 @@ protected function getColumns(): array return array_map('strtolower', $this->headers); } - /** - * Convertir les routes donnees en JSON. - */ - protected function asJson(Collection $routes) - { - $this->json( - $routes->map(static function ($route) { - $route['middleware'] = empty($route['middleware']) ? [] : explode(' ', $route['middleware']); - - return $route; - }) - ->values() - ->toArray() - ); - } - /** * Affiche les informations relatives à la route sur la console. * @@ -326,29 +310,41 @@ protected function asJson(Collection $routes) protected function displayRoutes(array $routes, int $total): void { $routes = collect($routes)->map(static fn ($route) => array_merge($route, [ - 'route' => $route['domain'] ? ($route['domain'] . '/' . ltrim($route['route'], '/')) : $route['route'], - 'name' => $route['route'] === $route['name'] ? null : $route['name'], - ])); + 'middleware' => empty($route['middleware']) ? [] : explode(' ', $route['middleware']), + 'name' => $route['route'] === $route['name'] ? null : $route['name'], + 'route' => $route['domain'] ? ($route['domain'] . '/' . ltrim($route['route'], '/')) : $route['route'], + ]))->values(); if ($this->option('json')) { - $this->asJson($routes); + $this->json($routes); return; } - $maxMethodLength = $routes->map(static fn ($route) => strlen($route['method']))->max(); + $maxMethodLength = $routes->map(static fn ($route) => strlen($route['method']) + 3)->max(); + $verbose = $this->option('verbosity'); - foreach ($routes->values()->toArray() as $route) { + foreach ($routes->toArray() as $route) { $left = implode('', [ $this->color->line(str_pad($route['method'], $maxMethodLength), ['fg' => $this->verbColors[$route['method']]]), ' ', $route['route'], ]); - $right = implode(' > ', array_filter([$route['name'], $route['handler']])); + $right = implode(' › ', array_filter([$route['name'], $route['handler']])); $this->justify($left, $right, [ 'second' => ['fg' => Color::fg256(6), 'bold' => 1], ]); + if ($verbose) { + foreach ($route['middleware'] as $middleware) { + $this->write( + $this->color->line( + sprintf('%s⇂ %s', str_repeat(' ', $maxMethodLength), $middleware), + ['fg' => Color::fg256(60)] + ) + )->eol(); + } + } } if ($this->option('show-stats')) { diff --git a/src/Cli/Commands/Utilities/Publish.php b/src/Cli/Commands/Utilities/Publish.php index 4b9de3d0..8df456f6 100644 --- a/src/Cli/Commands/Utilities/Publish.php +++ b/src/Cli/Commands/Utilities/Publish.php @@ -73,12 +73,12 @@ public function execute(array $params) $publisher::class, count($publisher->getPublished()), $publisher->getDestination(), - ])); + ]))->eol(); } else { $this->fail(lang('Publisher.publishFailure', [ $publisher::class, $publisher->getDestination(), - ])); + ]))->eol(); foreach ($publisher->getErrors() as $file => $exception) { $this->write($file); diff --git a/src/Cli/Console/Console.php b/src/Cli/Console/Console.php index 56e845a8..a9ed9ef7 100644 --- a/src/Cli/Console/Console.php +++ b/src/Cli/Console/Console.php @@ -319,7 +319,7 @@ private function addCommand(string $className, ?Logger $logger = null) $console->start($instance); } - $parameters = $command->values(false); + $parameters = $command->values(); if ($arguments === null || $arguments === []) { $arguments = $command->args(); } diff --git a/src/Constants/schemas/hashing.config.php b/src/Constants/schemas/hashing.config.php new file mode 100644 index 00000000..5b75c4e2 --- /dev/null +++ b/src/Constants/schemas/hashing.config.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use Nette\Schema\Expect; + +return Expect::structure([ + 'driver' => Expect::anyOf('bcrypt', 'argon', 'argon2id')->default('bcrypt'), + 'bcrypt' => Expect::structure([ + 'rounds' => Expect::int()->default(12), + 'verify' => Expect::bool()->default(true), + ]), + 'argon' => Expect::structure([ + 'memory' => Expect::int()->default(65536), + 'threads' => Expect::int()->default(1), + 'time' => Expect::int()->default(4), + 'verify' => Expect::bool()->default(true), + ]), +])->otherItems(); diff --git a/src/Container/Services.php b/src/Container/Services.php index e3db8e48..b34e750d 100644 --- a/src/Container/Services.php +++ b/src/Container/Services.php @@ -27,6 +27,7 @@ use BlitzPHP\Contracts\Router\RouteCollectionInterface; use BlitzPHP\Contracts\Router\RouterInterface; use BlitzPHP\Contracts\Security\EncrypterInterface; +use BlitzPHP\Contracts\Security\HasherInterface; use BlitzPHP\Contracts\Session\CookieManagerInterface; use BlitzPHP\Contracts\Session\SessionInterface; use BlitzPHP\Debug\Logger; @@ -48,6 +49,7 @@ use BlitzPHP\Router\RouteCollection; use BlitzPHP\Router\Router; use BlitzPHP\Security\Encryption\Encryption; +use BlitzPHP\Security\Hashing\Hasher; use BlitzPHP\Session\Cookie\Cookie; use BlitzPHP\Session\Cookie\CookieManager; use BlitzPHP\Session\Handlers\Database as DatabaseSessionHandler; @@ -238,6 +240,25 @@ public static function encrypter(?array $config = null, bool $shared = false): E return static::$instances[Encryption::class] = $encryption; } + /** + * La classe Encryption fournit un cryptage bidirectionnel. + * + * @return Hasher + */ + public static function hashing(?array $config = null, bool $shared = true): HasherInterface + { + if (true === $shared && isset(static::$instances[Hasher::class])) { + return static::$instances[Hasher::class]; + } + + $config ??= config('hashing'); + $config = (object) $config; + $hasher = new Hasher($config); + $hasher->initialize($config); + + return static::$instances[Hasher::class] = $hasher; + } + /** * Gestionnaire d'evenement * diff --git a/src/Exceptions/HashingException.php b/src/Exceptions/HashingException.php new file mode 100644 index 00000000..cde1c50b --- /dev/null +++ b/src/Exceptions/HashingException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Exceptions; + +/** + * Hashing exception + */ +class HashingException extends EncryptionException +{ +} diff --git a/src/Security/Encryption/Handlers/BaseHandler.php b/src/Security/Encryption/Handlers/BaseHandler.php index 46590cc4..6d014b4b 100644 --- a/src/Security/Encryption/Handlers/BaseHandler.php +++ b/src/Security/Encryption/Handlers/BaseHandler.php @@ -12,6 +12,7 @@ namespace BlitzPHP\Security\Encryption\Handlers; use BlitzPHP\Contracts\Security\EncrypterInterface; +use BlitzPHP\Utilities\String\Text; /** * Classe de base pour les gestionnaires de chiffrement @@ -34,6 +35,8 @@ public function __construct(?object $config = null) foreach (get_object_vars($config) as $key => $value) { if (property_exists($this, $key)) { $this->{$key} = $value; + } elseif (property_exists($this, $key = Text::camel($key))) { + $this->{$key} = $value; } } } diff --git a/src/Security/Hashing/Handlers/Argon2IdHandler.php b/src/Security/Hashing/Handlers/Argon2IdHandler.php new file mode 100644 index 00000000..20893d9a --- /dev/null +++ b/src/Security/Hashing/Handlers/Argon2IdHandler.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Security\Hashing\Handlers; + +use RuntimeException; + +/** + * Gestionnaire de hashage basé sur Argon2Id + * + * @credit Laravel 11 - \Illuminate\Hashing\Argon2IdHasher + */ +class Argon2IdHandler extends ArgonHandler +{ + /** + * {@inheritDoc} + * + * @throws RuntimeException + */ + public function check(string $value, string $hashedValue, array $options = []): bool + { + if ($hashedValue === '') { + return false; + } + + if ($this->verifyAlgorithm && ! $this->isUsingCorrectAlgorithm($hashedValue)) { + throw new RuntimeException("Ce mot de passe n'utilise pas l'algorithme Argon2id."); + } + + return password_verify($value, $hashedValue); + } + + /** + * {@inheritDoc} + */ + protected function isUsingCorrectAlgorithm(string $hashedValue): bool + { + return $this->info($hashedValue)['algoName'] === 'argon2id'; + } + + /** + * {@inheritDoc} + */ + protected function algorithm(): string + { + return PASSWORD_ARGON2ID; + } +} diff --git a/src/Security/Hashing/Handlers/ArgonHandler.php b/src/Security/Hashing/Handlers/ArgonHandler.php new file mode 100644 index 00000000..5c29a7c7 --- /dev/null +++ b/src/Security/Hashing/Handlers/ArgonHandler.php @@ -0,0 +1,211 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Security\Hashing\Handlers; + +use BlitzPHP\Contracts\Security\HasherInterface; +use Error; +use RuntimeException; + +/** + * Gestionnaire de hashage basé sur Argon + * + * @credit Laravel 11 - \Illuminate\Hashing\ArgonHasher + */ +class ArgonHandler extends BaseHandler implements HasherInterface +{ + /** + * Le facteur de coût de la mémoire par défaut. + */ + protected int $memory = 1024; + + /** + * Le facteur de coût du temps par défaut. + */ + protected int $time = 2; + + /** + * Le facteur de filetage par défaut. + */ + protected int $threads = 2; + + /** + * Indique s'il faut effectuer une vérification de l'algorithme. + */ + protected bool $verifyAlgorithm = false; + + /** + * Créer une nouvelle instance de Hacheur. + */ + public function __construct(array $options = []) + { + $this->time = $options['time'] ?? $this->time; + $this->memory = $options['memory'] ?? $this->memory; + $this->threads = $this->threads($options); + $this->verifyAlgorithm = $options['verify'] ?? $this->verifyAlgorithm; + } + + /** + * {@inheritDoc} + * + * @throws RuntimeException + */ + public function make(string $value, array $options = []): string + { + try { + $hash = password_hash($value, $this->algorithm(), [ + 'memory_cost' => $this->memory($options), + 'time_cost' => $this->time($options), + 'threads' => $this->threads($options), + ]); + } catch (Error) { + throw new RuntimeException("Le hachage Argon2 n'est pas supporté."); + } + + return $hash; + } + + /** + * Obtient l'algorithme à utiliser pour le hachage. + */ + protected function algorithm(): string + { + return PASSWORD_ARGON2I; + } + + /** + * {@inheritDoc} + * + * @throws RuntimeException + */ + public function check(string $value, string $hashedValue, array $options = []): bool + { + if ($hashedValue === '') { + return false; + } + + if ($this->verifyAlgorithm && ! $this->isUsingCorrectAlgorithm($hashedValue)) { + throw new RuntimeException("Ce mot de passe n'utilise pas l'algorithme Argon2i."); + } + + return parent::check($value, $hashedValue, $options); + } + + /** + * {@inheritDoc} + */ + public function needsRehash(string $hashedValue, array $options = []): bool + { + return password_needs_rehash($hashedValue, $this->algorithm(), [ + 'memory_cost' => $this->memory($options), + 'time_cost' => $this->time($options), + 'threads' => $this->threads($options), + ]); + } + + /** + * Vérifie que la configuration est inférieure ou égale à ce qui est configuré. + * + * @internal + */ + public function verifyConfiguration(string $value): bool + { + return $this->isUsingCorrectAlgorithm($value) && $this->isUsingValidOptions($value); + } + + /** + * Vérifie l'algorithme de la valeur hachée. + */ + protected function isUsingCorrectAlgorithm(string $hashedValue): bool + { + return $this->info($hashedValue)['algoName'] === 'argon2i'; + } + + /** + * Vérifie les options de la valeur hachée. + */ + protected function isUsingValidOptions(string $hashedValue): bool + { + ['options' => $options] = $this->info($hashedValue); + + if ( + ! is_int($options['memory_cost'] ?? null) + || ! is_int($options['time_cost'] ?? null) + || ! is_int($options['threads'] ?? null) + ) { + return false; + } + + return ! ( + $options['memory_cost'] > $this->memory + || $options['time_cost'] > $this->time + || $options['threads'] > $this->threads + ); + } + + /** + * Définit le facteur de mémoire du mot de passe par défaut. + */ + public function setMemory(int $memory): self + { + $this->memory = $memory; + + return $this; + } + + /** + * Définit le facteur de synchronisation du mot de passe par défaut. + */ + public function setTime(int $time): self + { + $this->time = $time; + + return $this; + } + + /** + * Définit le facteur de filtrage du mot de passe par défaut. + */ + public function setThreads(int $threads): self + { + $this->threads = $threads; + + return $this; + } + + /** + * Extrait la valeur du coût de la mémoire du tableau d'options. + */ + protected function memory(array $options): int + { + return $options['memory'] ?? $this->memory; + } + + /** + * Extrait la valeur du coût du temps du tableau des options. + */ + protected function time(array $options): int + { + return $options['time'] ?? $this->time; + } + + /** + * Extrait la valeur du facteur de filtrage du tableau d'options. + */ + protected function threads(array $options): int + { + if (defined('PASSWORD_ARGON2_PROVIDER') && PASSWORD_ARGON2_PROVIDER === 'sodium') { + return 1; + } + + return $options['threads'] ?? $this->threads; + } +} diff --git a/src/Security/Hashing/Handlers/BaseHandler.php b/src/Security/Hashing/Handlers/BaseHandler.php new file mode 100644 index 00000000..d2704f1c --- /dev/null +++ b/src/Security/Hashing/Handlers/BaseHandler.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Security\Hashing\Handlers; + +use BlitzPHP\Contracts\Security\HasherInterface; + +/** + * Gestionnaire de hashage de base + * + * @credit Laravel 11 - \Illuminate\Hashing\AbstractHasher + */ +abstract class BaseHandler implements HasherInterface +{ + /** + * {@inheritDoc} + */ + public function info(string $hashedValue): array + { + return password_get_info($hashedValue); + } + + /** + * {@inheritDoc} + */ + public function check(string $value, string $hashedValue, array $options = []): bool + { + if ($hashedValue === '') { + return false; + } + + return password_verify($value, $hashedValue); + } +} diff --git a/src/Security/Hashing/Handlers/BcryptHandler.php b/src/Security/Hashing/Handlers/BcryptHandler.php new file mode 100644 index 00000000..3694d0c1 --- /dev/null +++ b/src/Security/Hashing/Handlers/BcryptHandler.php @@ -0,0 +1,139 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Security\Hashing\Handlers; + +use BlitzPHP\Contracts\Security\HasherInterface; +use Error; +use RuntimeException; + +/** + * Gestionnaire de hashage basé sur Bcrypt + * + * @credit Laravel 11 - \Illuminate\Hashing\BcryptHasher + */ +class BcryptHandler extends BaseHandler implements HasherInterface +{ + /** + * Le facteur de coût par défaut. + */ + protected int $rounds = 12; + + /** + * Indique s'il faut effectuer une vérification de l'algorithme. + */ + protected bool $verifyAlgorithm = false; + + /** + * Créer une nouvelle instance de Hacheur. + */ + public function __construct(array $options = []) + { + $this->rounds = $options['rounds'] ?? $this->rounds; + $this->verifyAlgorithm = $options['verify'] ?? $this->verifyAlgorithm; + } + + /** + * {@inheritDoc} + * + * @throws RuntimeException + */ + public function make(string $value, array $options = []): string + { + try { + $hash = password_hash($value, PASSWORD_BCRYPT, [ + 'cost' => $this->cost($options), + ]); + } catch (Error) { + throw new RuntimeException("Le hachage Bcrypt n'est pas supporté."); + } + + return $hash; + } + + /** + * {@inheritDoc} + * + * @throws RuntimeException + */ + public function check(string $value, string $hashedValue, array $options = []): bool + { + if ($hashedValue === '') { + return false; + } + + if ($this->verifyAlgorithm && ! $this->isUsingCorrectAlgorithm($hashedValue)) { + throw new RuntimeException("Ce mot de passe n'utilise pas l'algorithme Bcrypt."); + } + + return parent::check($value, $hashedValue, $options); + } + + /** + * {@inheritDoc} + */ + public function needsRehash(string $hashedValue, array $options = []): bool + { + return password_needs_rehash($hashedValue, PASSWORD_BCRYPT, [ + 'cost' => $this->cost($options), + ]); + } + + /** + * Vérifie que la configuration est inférieure ou égale à ce qui est configuré. + * + * @internal + */ + public function verifyConfiguration(string $value): bool + { + return $this->isUsingCorrectAlgorithm($value) && $this->isUsingValidOptions($value); + } + + /** + * Vérifie l'algorithme de la valeur hachée. + */ + protected function isUsingCorrectAlgorithm(string $hashedValue): bool + { + return $this->info($hashedValue)['algoName'] === 'bcrypt'; + } + + /** + * Vérifie les options de la valeur hachée. + */ + protected function isUsingValidOptions(string $hashedValue): bool + { + ['options' => $options] = $this->info($hashedValue); + + if (! is_int($options['cost'] ?? null)) { + return false; + } + + return ! ($options['cost'] > $this->rounds); + } + + /** + * Défini le facteur de travail du mot de passe par défaut. + */ + public function setRounds(int $rounds): self + { + $this->rounds = (int) $rounds; + + return $this; + } + + /** + * Extrait la valeur du coût a partir du tableau d'options. + */ + protected function cost(array $options = []): int + { + return $options['rounds'] ?? $this->rounds; + } +} diff --git a/src/Security/Hashing/Hasher.php b/src/Security/Hashing/Hasher.php new file mode 100644 index 00000000..e615d301 --- /dev/null +++ b/src/Security/Hashing/Hasher.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Security\Hashing; + +use BlitzPHP\Contracts\Security\HasherInterface; +use BlitzPHP\Exceptions\HashingException; + +/** + * Gestionnaire de hashage de BlitzPHP + */ +class Hasher implements HasherInterface +{ + /** + * Le hasheur que nous utilisons + */ + protected ?HasherInterface $hasher = null; + + /** + * Le pilote utilisé + */ + protected string $driver = ''; + + /** + * Pilotes aux classes de gestionnaires, par ordre de préférence + */ + protected array $drivers = [ + 'bcrypt', + 'argon', + 'argon2id', + ]; + + /** + * Constructeur + */ + public function __construct(protected ?object $config = null) + { + $config ??= (object) config('hashing'); + + $this->config = $config; + $this->driver = $config->driver; + } + + /** + * {@inheritDoc} + */ + public function info(string $hashedValue): array + { + return $this->driver()->info($hashedValue); + } + + /** + * {@inheritDoc} + */ + public function make(string $value, array $options = []): string + { + return $this->driver()->make($value, $options); + } + + /** + * {@inheritDoc} + */ + public function check(string $value, string $hashedValue, array $options = []): bool + { + return $this->driver()->check($value, $hashedValue, $options); + } + + /** + * {@inheritDoc} + */ + public function needsRehash(string $hashedValue, array $options = []): bool + { + return $this->driver()->needsRehash($hashedValue, $options); + } + + /** + * Détermine si une chaîne donnée est déjà hachée. + */ + public function isHashed(string $value): bool + { + return $this->driver()->info($value)['algo'] !== null; + } + + /** + * Vérifie que la configuration est inférieure ou égale à ce qui est configuré. + * + * @internal + */ + public function verifyConfiguration(array $value): bool + { + return $this->driver()->verifyConfiguration($value); + } + + /** + * Initialiser ou réinitialiser le hasheur + * + * @throws HashingException + */ + public function initialize(?object $config = null): HasherInterface + { + if ($config) { + $this->driver = $config->driver; + } + + if ($this->driver === '') { + throw HashingException::noDriverRequested(); + } + + if (! in_array($this->driver, $this->drivers, true)) { + throw HashingException::unKnownHandler($this->driver); + } + + $handlerName = 'BlitzPHP\\Security\\Hashing\\Handlers\\' . ucfirst($this->driver) . 'Handler'; + $params = (array) $config; + $this->hasher = new $handlerName($params[$this->driver]); + + return $this->hasher; + } + + private function driver(): HasherInterface + { + if (null === $this->hasher) { + $this->hasher = $this->initialize($this->config); + } + + return $this->hasher; + } +}