From 73338421690b6859077a7b9d79acf82384ec6771 Mon Sep 17 00:00:00 2001 From: smiley Date: Tue, 12 Nov 2024 20:44:33 +0100 Subject: [PATCH] :octocat: use chillerlan/php-standard-utilities --- composer.json | 2 + phpstan-baseline.neon | 5 -- phpstan.dist.neon | 1 - src/Core/OAuth1Provider.php | 6 +- src/Core/OAuthProvider.php | 14 ++--- src/Core/PKCETrait.php | 29 ++------- src/Core/Utilities.php | 90 +--------------------------- src/OAuthOptionsTrait.php | 7 ++- src/Storage/FileStorage.php | 66 +++++++------------- src/Storage/OAuthStorageAbstract.php | 9 +-- tests/Core/OAuthOptionsTest.php | 5 +- tests/Core/UtilitiesTest.php | 25 +------- 12 files changed, 52 insertions(+), 207 deletions(-) diff --git a/composer.json b/composer.json index 25381bb..ebca93d 100644 --- a/composer.json +++ b/composer.json @@ -1,4 +1,5 @@ { + "$schema": "https://getcomposer.org/schema.json", "name": "chillerlan/php-oauth", "description": "A fully transparent, framework agnostic PSR-18 OAuth client.", "homepage": "https://github.com/chillerlan/php-oauth", @@ -36,6 +37,7 @@ "ext-sodium": "*", "chillerlan/php-http-message-utils": "^2.2.2", "chillerlan/php-settings-container": "^3.2.1", + "chillerlan/php-standard-utilities": "^1.0", "psr/http-client": "^1.0", "psr/http-message": "^1.1 || ^2.0", "psr/log": "^1.1 || ^2.0 || ^3.0" diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c569eae..d3d387f 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -20,11 +20,6 @@ parameters: count: 1 path: src/Core/OAuthProvider.php - - - message: "#^Parameter \\#1 \\$content of method Psr\\\\Http\\\\Message\\\\StreamFactoryInterface\\:\\:createStream\\(\\) expects string, string\\|false given\\.$#" - count: 1 - path: src/Core/OAuthProvider.php - - message: "#^Parameter \\#1 \\$length of function random_bytes expects int\\<1, max\\>, int given\\.$#" count: 1 diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 6657ad1..584f3a7 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -13,7 +13,6 @@ parameters: # see https://github.com/phpstan/phpstan-src/blob/1.8.x/build/ignore-by-php-version.neon.php includes: - phpstan-baseline.neon - - .phpstan/ignore-by-php-version.php - vendor/phpstan/phpstan/conf/bleedingEdge.neon - vendor/phpstan/phpstan-deprecation-rules/rules.neon - vendor/chillerlan/php-settings-container/rules-magic-access.neon diff --git a/src/Core/OAuth1Provider.php b/src/Core/OAuth1Provider.php index 01f5e99..ac8daf0 100644 --- a/src/Core/OAuth1Provider.php +++ b/src/Core/OAuth1Provider.php @@ -15,9 +15,9 @@ use chillerlan\HTTP\Utils\{MessageUtil, QueryUtil}; use chillerlan\OAuth\Providers\ProviderException; +use chillerlan\Utilities\Str; use Psr\Http\Message\{RequestInterface, ResponseInterface, UriInterface}; -use function array_merge, hash_hmac, implode, in_array, sodium_bin2base64, sprintf, strtoupper, time; -use const SODIUM_BASE64_VARIANT_ORIGINAL; +use function array_merge, hash_hmac, implode, in_array, sprintf, strtoupper, time; /** * Implements an abstract OAuth1 (1.0a) provider with all methods required by the OAuth1Interface. @@ -186,7 +186,7 @@ protected function getSignature( $hash = hash_hmac('sha1', implode('&', $data), implode('&', $key), true); - return sodium_bin2base64($hash, SODIUM_BASE64_VARIANT_ORIGINAL); + return Str::base64encode($hash); } /** diff --git a/src/Core/OAuthProvider.php b/src/Core/OAuthProvider.php index 689c2b0..60999ad 100644 --- a/src/Core/OAuthProvider.php +++ b/src/Core/OAuthProvider.php @@ -18,17 +18,17 @@ use chillerlan\OAuth\Providers\ProviderException; use chillerlan\OAuth\Storage\{MemoryStorage, OAuthStorageInterface}; use chillerlan\Settings\SettingsContainerInterface; +use chillerlan\Utilities\Str; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\{ RequestFactoryInterface, RequestInterface, ResponseInterface, StreamFactoryInterface, StreamInterface, UriFactoryInterface }; use Psr\Log\{LoggerInterface, NullLogger}; -use ReflectionClass, UnhandledMatchError; -use function array_merge, array_shift, explode, implode, in_array, is_array, is_string, - json_encode, ltrim, random_bytes, rtrim, sodium_bin2hex, sodium_bin2base64, - sprintf, str_contains, str_starts_with, strip_tags, strtolower; -use const PHP_QUERY_RFC1738, SODIUM_BASE64_VARIANT_ORIGINAL; +use ReflectionClass; +use function array_merge, array_shift, explode, implode, in_array, is_array, is_string, ltrim, + random_bytes, rtrim, sodium_bin2hex, sprintf, str_contains, str_starts_with, strip_tags, strtolower; +use const PHP_QUERY_RFC1738; /** * Implements an abstract OAuth provider with all methods required by the OAuthInterface. @@ -271,7 +271,7 @@ protected function cleanBodyParams(iterable $params):array{ * Adds an "Authorization: Basic " header to the given request */ protected function addBasicAuthHeader(RequestInterface $request):RequestInterface{ - $auth = sodium_bin2base64(sprintf('%s:%s', $this->options->key, $this->options->secret), SODIUM_BASE64_VARIANT_ORIGINAL); + $auth = Str::base64encode(sprintf('%s:%s', $this->options->key, $this->options->secret)); return $request->withHeader('Authorization', sprintf('Basic %s', $auth)); } @@ -363,7 +363,7 @@ final protected function setRequestBody(StreamInterface|array|string $body, Requ $body = match($contentType){ 'application/x-www-form-urlencoded' => QueryUtil::build($body, PHP_QUERY_RFC1738), - 'application/json', 'application/vnd.api+json' => json_encode($body), + 'application/json', 'application/vnd.api+json' => Str::jsonEncode($body, 0), default => throw new ProviderException( sprintf('invalid content-type "%s" for the given array body', $contentType), ), diff --git a/src/Core/PKCETrait.php b/src/Core/PKCETrait.php index b74abfb..b948f49 100644 --- a/src/Core/PKCETrait.php +++ b/src/Core/PKCETrait.php @@ -12,10 +12,7 @@ namespace chillerlan\OAuth\Core; use chillerlan\OAuth\Providers\ProviderException; -use function hash; -use function random_int; -use function sodium_bin2base64; -use const PHP_VERSION_ID; +use chillerlan\Utilities\{Crypto, Str}; use const SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING; /** @@ -78,27 +75,9 @@ final public function setCodeVerifier(array $params):array{ * * @see \chillerlan\OAuth\Core\PKCE::generateVerifier() * @see \chillerlan\OAuth\Core\OAuth2Provider::setCodeChallenge() - * - * @noinspection PhpFullyQualifiedNameUsageInspection - * @SuppressWarnings(PHPMD.MissingImport) */ final public function generateVerifier(int $length):string{ - - // use the Randomizer if available - // https://github.com/phpstan/phpstan/issues/7843 - if(PHP_VERSION_ID >= 80300){ - $randomizer = new \Random\Randomizer(new \Random\Engine\Secure); - - return $randomizer->getBytesFromString(PKCE::VERIFIER_CHARSET, $length); - } - - $str = ''; - - for($i = 0; $i < $length; $i++){ - $str .= PKCE::VERIFIER_CHARSET[random_int(0, 65)]; - } - - return $str; + return Crypto::randomString($length, PKCE::VERIFIER_CHARSET); } /** @@ -114,12 +93,12 @@ final public function generateChallenge(string $verifier, string $challengeMetho } $verifier = match($challengeMethod){ - PKCE::CHALLENGE_METHOD_S256 => hash('sha256', $verifier, true), + PKCE::CHALLENGE_METHOD_S256 => Crypto::sha256($verifier, true), // no other hash methods yet default => throw new ProviderException('invalid PKCE challenge method'), // @codeCoverageIgnore }; - return sodium_bin2base64($verifier, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); + return Str::base64encode($verifier, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); } } diff --git a/src/Core/Utilities.php b/src/Core/Utilities.php index f32f942..1f291c3 100644 --- a/src/Core/Utilities.php +++ b/src/Core/Utilities.php @@ -13,49 +13,29 @@ namespace chillerlan\OAuth\Core; +use chillerlan\Utilities\File; use DirectoryIterator; use InvalidArgumentException; use ReflectionClass; -use RuntimeException; use function hash; -use function random_bytes; -use function realpath; -use function sodium_base642bin; -use function sodium_bin2base64; -use function sodium_bin2hex; -use function sodium_crypto_secretbox; -use function sodium_crypto_secretbox_keygen; -use function sodium_crypto_secretbox_open; -use function sodium_hex2bin; -use function sodium_memzero; use function substr; use function trim; -use const SODIUM_BASE64_VARIANT_ORIGINAL; -use const SODIUM_CRYPTO_SECRETBOX_NONCEBYTES; /** * Common utilities for use with the OAuth providers */ class Utilities{ - final public const ENCRYPT_FORMAT_BINARY = 0b00; - final public const ENCRYPT_FORMAT_BASE64 = 0b01; - final public const ENCRYPT_FORMAT_HEX = 0b10; - /** * Fetches a list of provider classes in the given directory * * @return array> */ public static function getProviders(string|null $providerDir = null, string|null $namespace = null):array{ - $providerDir = realpath(($providerDir ?? __DIR__.'/../Providers')); + $providerDir = File::realpath(($providerDir ?? __DIR__.'/../Providers')); $namespace = trim(($namespace ?? 'chillerlan\\OAuth\\Providers'), '\\'); $providers = []; - if($providerDir === false){ - throw new InvalidArgumentException('invalid $providerDir'); - } - foreach(new DirectoryIterator($providerDir) as $e){ if($e->getExtension() !== 'php'){ @@ -79,70 +59,4 @@ public static function getProviders(string|null $providerDir = null, string|null return $providers; } - /** - * Creates a new cryptographically secure random encryption key (in hexadecimal format) - */ - public static function createEncryptionKey():string{ - return sodium_bin2hex(sodium_crypto_secretbox_keygen()); - } - - /** - * encrypts the given $data with $key, $format output [binary, base64, hex] - * - * @see \sodium_crypto_secretbox() - * @see \sodium_bin2base64() - * @see \sodium_bin2hex() - */ - public static function encrypt(string $data, string $keyHex, int $format = self::ENCRYPT_FORMAT_HEX):string{ - $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); - $box = sodium_crypto_secretbox($data, $nonce, sodium_hex2bin($keyHex)); - - $out = match($format){ - self::ENCRYPT_FORMAT_BINARY => $nonce.$box, - self::ENCRYPT_FORMAT_BASE64 => sodium_bin2base64($nonce.$box, SODIUM_BASE64_VARIANT_ORIGINAL), - self::ENCRYPT_FORMAT_HEX => sodium_bin2hex($nonce.$box), - default => throw new InvalidArgumentException('invalid format'), // @codeCoverageIgnore - }; - - sodium_memzero($data); - sodium_memzero($keyHex); - sodium_memzero($nonce); - sodium_memzero($box); - - return $out; - } - - /** - * decrypts the given $encrypted data with $key from $format input [binary, base64, hex] - * - * @see \sodium_crypto_secretbox_open() - * @see \sodium_base642bin() - * @see \sodium_hex2bin() - */ - public static function decrypt(string $encrypted, string $keyHex, int $format = self::ENCRYPT_FORMAT_HEX):string{ - - $bin = match($format){ - self::ENCRYPT_FORMAT_BINARY => $encrypted, - self::ENCRYPT_FORMAT_BASE64 => sodium_base642bin($encrypted, SODIUM_BASE64_VARIANT_ORIGINAL), - self::ENCRYPT_FORMAT_HEX => sodium_hex2bin($encrypted), - default => throw new InvalidArgumentException('invalid format'), // @codeCoverageIgnore - }; - - $nonce = substr($bin, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); - $box = substr($bin, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); - $data = sodium_crypto_secretbox_open($box, $nonce, sodium_hex2bin($keyHex)); - - sodium_memzero($encrypted); - sodium_memzero($keyHex); - sodium_memzero($bin); - sodium_memzero($nonce); - sodium_memzero($box); - - if($data === false){ - throw new RuntimeException('decryption failed'); // @codeCoverageIgnore - } - - return $data; - } - } diff --git a/src/OAuthOptionsTrait.php b/src/OAuthOptionsTrait.php index 8107745..f8993ea 100644 --- a/src/OAuthOptionsTrait.php +++ b/src/OAuthOptionsTrait.php @@ -12,7 +12,8 @@ namespace chillerlan\OAuth; use chillerlan\OAuth\Storage\OAuthStorageException; -use function is_dir, is_writable, max, min, preg_match, realpath, sprintf, trim; +use chillerlan\Utilities\{Directory, File}; +use function max, min, preg_match, sprintf, trim; /** * The settings for the OAuth provider @@ -126,9 +127,9 @@ protected function set_storageEncryptionKey(string $storageEncryptionKey):void{ * sets and verifies the file storage path */ protected function set_fileStoragePath(string $fileStoragePath):void{ - $path = realpath(trim($fileStoragePath)); + $path = File::realpath(trim($fileStoragePath)); - if($path === false || !is_dir($path) || !is_writable($path)){ + if(!Directory::isWritable($path) || !Directory::isReadable($path)){ throw new OAuthStorageException(sprintf('invalid storage path "%s"', $fileStoragePath)); } diff --git a/src/Storage/FileStorage.php b/src/Storage/FileStorage.php index bf8a35b..3913237 100644 --- a/src/Storage/FileStorage.php +++ b/src/Storage/FileStorage.php @@ -12,12 +12,13 @@ namespace chillerlan\OAuth\Storage; use chillerlan\OAuth\OAuthOptions; -use chillerlan\OAuth\Core\{AccessToken, Utilities}; +use chillerlan\OAuth\Core\AccessToken; use chillerlan\Settings\SettingsContainerInterface; +use chillerlan\Utilities\{Crypto, Directory, File}; use Psr\Log\{LoggerInterface, NullLogger}; use DirectoryIterator; -use function dirname, file_exists, file_get_contents, file_put_contents, hash, implode, - is_dir, is_file, mkdir, sprintf, str_starts_with, substr, trim, unlink; +use Throwable; +use function dirname, implode, str_starts_with, substr, trim; use const DIRECTORY_SEPARATOR; /** @@ -31,7 +32,7 @@ */ class FileStorage extends OAuthStorageAbstract{ - final protected const ENCRYPT_FORMAT = Utilities::ENCRYPT_FORMAT_BINARY; + final protected const ENCRYPT_FORMAT = Crypto::ENCRYPT_FORMAT_BINARY; /** * A *unique* ID to identify the user within your application, e.g. database row id or UUID @@ -57,6 +58,7 @@ public function __construct( if(empty($this->options->fileStoragePath)){ throw new OAuthStorageException('no storage path given'); } + } @@ -71,17 +73,11 @@ public function storeAccessToken(AccessToken $token, string $provider):static{ } public function getAccessToken(string $provider):AccessToken{ - $tokenData = $this->loadFile($this::KEY_TOKEN, $provider); - - if($tokenData === null){ - throw new ItemNotFoundException($this::KEY_TOKEN); - } - - return $this->fromStorage($tokenData); + return $this->fromStorage($this->loadFile($this::KEY_TOKEN, $provider)); } public function hasAccessToken(string $provider):bool{ - return file_exists($this->getFilepath($this::KEY_TOKEN, $provider)); + return File::exists($this->getFilepath($this::KEY_TOKEN, $provider)); } public function clearAccessToken(string $provider):static{ @@ -115,10 +111,6 @@ public function storeCSRFState(string $state, string $provider):static{ public function getCSRFState(string $provider):string{ $state = $this->loadFile($this::KEY_STATE, $provider); - if($state === null){ - throw new ItemNotFoundException($this::KEY_STATE); - } - if($this->options->useStorageEncryption === true){ return $this->decrypt($state); } @@ -127,7 +119,7 @@ public function getCSRFState(string $provider):string{ } public function hasCSRFState(string $provider):bool{ - return file_exists($this->getFilepath($this::KEY_STATE, $provider)); + return File::exists($this->getFilepath($this::KEY_STATE, $provider)); } public function clearCSRFState(string $provider):static{ @@ -161,10 +153,6 @@ public function storeCodeVerifier(string $verifier, string $provider):static{ public function getCodeVerifier(string $provider):string{ $verifier = $this->loadFile($this::KEY_VERIFIER, $provider); - if($verifier === null){ - throw new ItemNotFoundException($this::KEY_VERIFIER); - } - if($this->options->useStorageEncryption === true){ return $this->decrypt($verifier); } @@ -173,7 +161,7 @@ public function getCodeVerifier(string $provider):string{ } public function hasCodeVerifier(string $provider):bool{ - return file_exists($this->getFilepath($this::KEY_VERIFIER, $provider)); + return File::exists($this->getFilepath($this::KEY_VERIFIER, $provider)); } public function clearCodeVerifier(string $provider):static{ @@ -196,20 +184,16 @@ public function clearAllCodeVerifiers():static{ /** * fetched the content from a file */ - protected function loadFile(string $key, string $provider):string|null{ + protected function loadFile(string $key, string $provider):string{ $path = $this->getFilepath($key, $provider); - if(is_file($path)){ - $contents = file_get_contents($path); - - if($contents === false){ - throw new OAuthStorageException('file_get_contents() error'); // @codeCoverageIgnore - } - - return $contents; + try{ + return File::load($path); + } + catch(Throwable){ + throw new ItemNotFoundException($key); } - return null; } /** @@ -219,26 +203,18 @@ protected function saveFile(string $data, string $key, string $provider):void{ $path = $this->getFilepath($key, $provider); $dir = dirname($path); - if(!is_dir($dir) && !mkdir($dir, 0o755, true)){ - throw new OAuthStorageException(sprintf('could not create directory "%s"', $dir)); // @codeCoverageIgnore - } - - if(file_put_contents($path, $data) === false){ - throw new OAuthStorageException('saving to file failed'); // @codeCoverageIgnore + if(!Directory::exists($dir)){ + Directory::create($dir, 0o755, true); // @codeCoverageIgnore } + File::save($path, $data); } /** * deletes an existing file */ protected function deleteFile(string $key, string $provider):void{ - $path = $this->getFilepath($key, $provider); - - if(is_file($path) && !unlink($path)){ - throw new OAuthStorageException(sprintf('error deleting file "%s"', $path)); // @codeCoverageIgnore - } - + File::delete($this->getFilepath($key, $provider)); } /** @@ -261,7 +237,7 @@ protected function deleteAll(string $key):void{ */ protected function getFilepath(string $key, string $provider):string{ $provider = $this->getProviderName($provider); - $hash = hash('sha256', $provider.$this->oauthUser.$key); + $hash = Crypto::sha256($provider.$this->oauthUser.$key); $path = [$this->options->fileStoragePath, $provider]; for($i = 1; $i <= 2; $i++){ // @todo: subdir depth to options? diff --git a/src/Storage/OAuthStorageAbstract.php b/src/Storage/OAuthStorageAbstract.php index e876924..23325ff 100644 --- a/src/Storage/OAuthStorageAbstract.php +++ b/src/Storage/OAuthStorageAbstract.php @@ -12,8 +12,9 @@ namespace chillerlan\OAuth\Storage; use chillerlan\OAuth\OAuthOptions; -use chillerlan\OAuth\Core\{AccessToken, Utilities}; +use chillerlan\OAuth\Core\AccessToken; use chillerlan\Settings\SettingsContainerInterface; +use chillerlan\Utilities\Crypto; use Psr\Log\{LoggerInterface, NullLogger}; use function trim; @@ -31,7 +32,7 @@ abstract class OAuthStorageAbstract implements OAuthStorageInterface{ * * @var int */ - protected const ENCRYPT_FORMAT = Utilities::ENCRYPT_FORMAT_HEX; + protected const ENCRYPT_FORMAT = Crypto::ENCRYPT_FORMAT_HEX; /** * The options instance @@ -104,14 +105,14 @@ public function fromStorage(mixed $data):AccessToken{ * encrypts the given $data */ protected function encrypt(string $data):string{ - return Utilities::encrypt($data, $this->options->storageEncryptionKey, $this::ENCRYPT_FORMAT); + return Crypto::encrypt($data, $this->options->storageEncryptionKey, $this::ENCRYPT_FORMAT); } /** * decrypts the given $encrypted data */ protected function decrypt(string $encrypted):string{ - return Utilities::decrypt($encrypted, $this->options->storageEncryptionKey, $this::ENCRYPT_FORMAT); + return Crypto::decrypt($encrypted, $this->options->storageEncryptionKey, $this::ENCRYPT_FORMAT); } } diff --git a/tests/Core/OAuthOptionsTest.php b/tests/Core/OAuthOptionsTest.php index 37c87be..7b59e1f 100644 --- a/tests/Core/OAuthOptionsTest.php +++ b/tests/Core/OAuthOptionsTest.php @@ -13,6 +13,7 @@ use chillerlan\OAuth\OAuthOptions; use chillerlan\OAuth\Storage\OAuthStorageException; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; /** @@ -42,8 +43,8 @@ public function testSetFileStoragePath():void{ } public function testSetFileStoragePathInvalidException():void{ - $this->expectException(OAuthStorageException::class); - $this->expectExceptionMessage('invalid storage path "/foo"'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('invalid file path'); /** @phan-suppress-next-line PhanNoopNew */ new OAuthOptions(['fileStoragePath' => '/foo']); } diff --git a/tests/Core/UtilitiesTest.php b/tests/Core/UtilitiesTest.php index 03847f7..2be47e2 100644 --- a/tests/Core/UtilitiesTest.php +++ b/tests/Core/UtilitiesTest.php @@ -12,7 +12,6 @@ namespace chillerlan\OAuthTest\Core; use chillerlan\OAuth\Core\{OAuthInterface, Utilities}; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use InvalidArgumentException, ReflectionClass; @@ -39,31 +38,9 @@ public function testGetProvidersWithGivenPath():void{ public function testGetProvidersInvalidPathException():void{ $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('invalid $providerDir'); + $this->expectExceptionMessage('invalid file path'); Utilities::getProviders('/foo'); } - /** - * @return array> - */ - public static function encryptionFormatProvider():array{ - return [ - 'binary' => [Utilities::ENCRYPT_FORMAT_BINARY], - 'base64' => [Utilities::ENCRYPT_FORMAT_BASE64], - 'hex' => [Utilities::ENCRYPT_FORMAT_HEX], - ]; - } - - #[DataProvider('encryptionFormatProvider')] - public function testEncryptDecrypt(int $format):void{ - $data = 'hello this is a test string!'; - $key = Utilities::createEncryptionKey(); - - $encrypted = Utilities::encrypt($data, $key, $format); - $decrypted = Utilities::decrypt($encrypted, $key, $format); - - $this::assertSame($data, $decrypted); - } - }