diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0275667..74f9294 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,7 @@ jobs: - name: Install PHP uses: shivammathur/setup-php@2.17.0 with: - php-version: '7.4' + php-version: '8.0' extensions: mbstring, intl coverage: none env: @@ -41,7 +41,7 @@ jobs: run: composer update --prefer-dist --no-interaction --no-progress --optimize-autoloader - name: PHPStan tests - run: vendor/bin/phpstan analyze -l 8 -a vendor/yiisoft/yii2/Yii.php --no-progress src + run: vendor/bin/phpstan analyze -l 9 -a vendor/yiisoft/yii2/Yii.php --no-progress src Infection: name: PHP ${{ matrix.php }} @@ -49,7 +49,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['7.4', '8.0'] + php: ['7.4', '8.0', '8.1'] steps: - name: Checkout @@ -80,11 +80,11 @@ jobs: run: composer update --no-interaction --no-progress --optimize-autoloader - name: Run PHPUnit - if: matrix.php != '7.4' + if: matrix.php != '8.0' run: vendor/bin/phpunit - name: Run Infection with PHPUnit - if: matrix.php == '7.4' + if: matrix.php == '8.0' run: | mkdir -p build/logs vendor/bin/phpunit --coverage-xml=build/logs/coverage-xml --log-junit=build/logs/junit.xml diff --git a/README.md b/README.md index ae0363e..326879a 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Configuration array can be as the following: - key (Jwt::KEY) - _string_, default `''`, - passphrase (Jwt::PASSPHRASE) - _string_, default `''`, - store (Jwt::STORE) - _string_, default `Jwt::STORE_IN_MEMORY`, - available: `Jwt::STORE_IN_MEMORY`, `Jwt::STORE_LOCAL_FILE_REFERENCE` + available: `Jwt::STORE_IN_MEMORY`, `Jwt::STORE_LOCAL_FILE_REFERENCE` (deprecated since 3.2.0, will be removed in 4.0.0) (see https://lcobucci-jwt.readthedocs.io/en/latest/configuration/) - method (Jwt::METHOD) - _string_, default `Jwt::METHOD_PLAIN`, available: `Jwt::METHOD_PLAIN`, `Jwt::METHOD_BASE64`, `Jwt::METHOD_FILE` @@ -185,6 +185,9 @@ thrown. There are several ways to provide constraints: ] ``` +**Note: By default, this package is not adding any constraints out-of-the-box, you must configure them yourself like +in the examples above.** + ## Using component for REST authentication Configure the `authenticator` behavior in the controller. @@ -207,7 +210,7 @@ class ExampleController extends Controller There are special options available: - jwt - _string_ ID of component (default with `'jwt'`), component configuration _array_, or an instance of `bizley\jwt\Jwt`, -- auth - `\Closure` or `null` (default) - anonymous function with signature `function (\Lcobucci\JWT\Token $token)` that +- auth - callable or `null` (default) - anonymous function with signature `function (\Lcobucci\JWT\Token $token)` that should return identity of user authenticated with the JWT payload information. If $auth is not provided method `yii\web\User::loginByAccessToken()` will be called instead. diff --git a/composer.json b/composer.json index f3eb226..6a3bb5f 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "infection/infection": "*", "phpstan/phpstan": "*", "phpunit/phpunit": "^9.3", - "roave/security-advisories": "dev-master" + "roave/security-advisories": "dev-latest" }, "autoload": { "psr-4": { diff --git a/infection.json.dist b/infection.json.dist index 4cadc7d..ebe54a8 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -15,7 +15,7 @@ "MethodCallRemoval": { "ignore": [ "bizley\\jwt\\Jwt::init::190", - "bizley\\jwt\\JwtHttpBearerAuth::init::68" + "bizley\\jwt\\JwtHttpBearerAuth::init::69" ] } }, diff --git a/phpstan.sh b/phpstan.sh index b26ff95..379fce4 100755 --- a/phpstan.sh +++ b/phpstan.sh @@ -1,2 +1,2 @@ #!/bin/sh -vendor/bin/phpstan analyze -l 8 -a vendor/yiisoft/yii2/Yii.php src +vendor/bin/phpstan analyze -l 9 -a vendor/yiisoft/yii2/Yii.php src diff --git a/src/Jwt.php b/src/Jwt.php index 6c61b65..6ce4419 100644 --- a/src/Jwt.php +++ b/src/Jwt.php @@ -4,7 +4,6 @@ namespace bizley\jwt; -use Closure; use Lcobucci\JWT\Builder; use Lcobucci\JWT\ClaimsFormatter; use Lcobucci\JWT\Configuration; @@ -24,6 +23,7 @@ use function count; use function in_array; use function is_array; +use function is_callable; use function is_string; use function reset; use function strpos; @@ -49,7 +49,7 @@ class Jwt extends Component public const EDDSA = 'EdDSA'; public const STORE_IN_MEMORY = 'in_memory'; - public const STORE_LOCAL_FILE_REFERENCE = 'local_file_reference'; + public const STORE_LOCAL_FILE_REFERENCE = 'local_file_reference'; // deprecated since 3.2.0, will be removed in 4.0.0 public const METHOD_PLAIN = 'plain'; public const METHOD_BASE64 = 'base64'; @@ -68,28 +68,28 @@ class Jwt extends Component * This can be a simple string, an instance of Key, or a configuration array. * The configuration takes the following array keys: * - 'key' => Key's value or path to the key file. - * - 'store' => Either `Jwt::STORE_IN_MEMORY` or `Jwt::STORE_LOCAL_FILE_REFERENCE` - whether to keep the key in - * the memory or as a reference to a local file. + * - 'store' => Either `Jwt::STORE_IN_MEMORY` or `Jwt::STORE_LOCAL_FILE_REFERENCE` (deprecated) - + * whether to keep the key in the memory or as a reference to a local file. * - 'method' => `Jwt::METHOD_PLAIN`, `Jwt::METHOD_BASE64`, or `Jwt::METHOD_FILE` - whether the key is a plain * text, base64 encoded text, or a file. - * In case the 'store' is set to `Jwt::STORE_LOCAL_FILE_REFERENCE`, only `Jwt::METHOD_FILE` method - * is available. + * In case the 'store' is set to `Jwt::STORE_LOCAL_FILE_REFERENCE` (deprecated), only + * `Jwt::METHOD_FILE` method is available. * - 'passphrase' => Key's passphrase. * In case a simple string is provided (and it does not start with 'file://' or '@') the following configuration * is assumed: * [ - * 'key' => // the original given value, - * 'store' => Jwt::STORE_IN_MEMORY, - * 'method' => Jwt::METHOD_PLAIN, - * 'passphrase' => '', + * 'key' => // the original given value, + * 'store' => Jwt::STORE_IN_MEMORY, + * 'method' => Jwt::METHOD_PLAIN, + * 'passphrase' => '', * ] * In case a simple string is provided and it does start with 'file://' (direct file path) or '@' (Yii alias) * the following configuration is assumed: * [ - * 'key' => // the original given value, - * 'store' => Jwt::STORE_IN_MEMORY, - * 'method' => Jwt::METHOD_FILE, - * 'passphrase' => '', + * 'key' => // the original given value, + * 'store' => Jwt::STORE_IN_MEMORY, + * 'method' => Jwt::METHOD_FILE, + * 'passphrase' => '', * ] * If you want to override the assumed configuration, you must provide it directly. * @since 3.0.0 @@ -108,13 +108,13 @@ class Jwt extends Component /** * @var string|Signer|null Signer ID or Signer instance to be used for signing/verifying. * See $signers for available values. In case it's not set, no algorithm will be used, which may be handy if you - * want to do some testing but it's NOT recommended for production environments. + * want to do some testing, but it's NOT recommended for production environments. * @since 3.0.0 */ public $signer; /** - * @var array> Default signers configuration. When instantiated it will use selected array to + * @var array Default signers configuration. When instantiated it will use selected array to * spread into `Yii::createObject($type, array $params = [])` method so the first array element is $type, and * the second is $params. * Since 3.0.0 configuration is done using arrays. @@ -171,9 +171,9 @@ class Jwt extends Component public $decoder; /** - * @var array>|Validation\Constraint[]|Closure|null List of constraints that will be used to validate - * against or an anonymous function that can be resolved as such list. The signature of the function should be - * `function(\bizley\jwt\Jwt $jwt)` where $jwt will be an instance of this component. + * @var array|(callable(): mixed)|string>|(callable(): mixed)|null List of constraints that + * will be used to validate against or an anonymous function that can be resolved as such list. The signature of + * the function should be `function(\bizley\jwt\Jwt $jwt)` where $jwt will be an instance of this component. * For the constraints you can use instances of Lcobucci\JWT\Validation\Constraint or configuration arrays to be * resolved as such. * @since 3.0.0 @@ -218,7 +218,7 @@ public function init(): void } /** - * @param array $config + * @param array|(callable(): mixed)|string> $config * @return object * @throws InvalidConfigException */ @@ -325,6 +325,9 @@ private function prepareKey($key): Signer\Key } if (is_string($key)) { + if ($key === '') { + throw new InvalidConfigException('Empty string used as a key configuration!'); + } if (strpos($key, '@') === 0) { $keyConfig = [ self::KEY => Yii::getAlias($key), @@ -434,8 +437,9 @@ private function prepareValidationConstraints(): array return $constraints; } - if ($this->validationConstraints instanceof Closure) { - return ($this->validationConstraints)($this); + if (is_callable($this->validationConstraints)) { + /** @phpstan-ignore-next-line */ + return call_user_func($this->validationConstraints, $this); } return []; diff --git a/src/JwtHttpBearerAuth.php b/src/JwtHttpBearerAuth.php index 468751c..ea3ae64 100644 --- a/src/JwtHttpBearerAuth.php +++ b/src/JwtHttpBearerAuth.php @@ -4,8 +4,9 @@ namespace bizley\jwt; -use Closure; +use Lcobucci\JWT\Encoding\CannotDecodeContent; use Lcobucci\JWT\Token; +use Lcobucci\JWT\Validation; use Throwable; use Yii; use yii\base\InvalidConfigException; @@ -42,14 +43,14 @@ class JwtHttpBearerAuth extends HttpBearerAuth { /** - * @var string|array|Jwt application component ID of the JWT handler, configuration array, or JWT handler object - * itself. By default it's assumes that component of ID "jwt" has been configured. + * @var string|array|Jwt application component ID of the JWT handler, configuration array, or + * JWT handler object itself. By default, it's assumes that component of ID "jwt" has been configured. */ public $jwt = 'jwt'; /** - * @var Closure|null anonymous function that should return identity of user authenticated with the JWT payload - * information. It should have the following signature: + * @var (callable(): mixed)|null anonymous function that should return identity of user authenticated with the JWT + * payload information. It should have the following signature: * * ```php * function (Token $token) @@ -58,7 +59,7 @@ class JwtHttpBearerAuth extends HttpBearerAuth * where $token is JSON Web Token provided in the HTTP header. * If $auth is not provided method User::loginByAccessToken() will be called instead. */ - public ?Closure $auth = null; + public $auth; /** * @throws InvalidConfigException @@ -91,6 +92,13 @@ public function getJwtComponent(): Jwt * @param Request $request * @param Response $response * @return IdentityInterface|null the authenticated user identity. If authentication information is not provided, null will be returned. + * @throws InvalidConfigException When JWT configuration has not been properly initialized. + * @throws CannotDecodeContent When something goes wrong while decoding token. + * @throws Token\InvalidTokenStructure When token string structure is invalid. + * @throws Token\UnsupportedHeaderFound When parsed token has an unsupported header. + * @throws Validation\RequiredConstraintsViolated When constraint is not present in token. + * @throws Validation\NoConstraintsGiven When no constraints are provided. + * @throws Validation\ConstraintViolation When constraint is violated. * @throws UnauthorizedHttpException if authentication information is provided but is invalid. */ public function authenticate($user, $request, $response): ?IdentityInterface // BC signature @@ -109,19 +117,19 @@ public function authenticate($user, $request, $response): ?IdentityInterface // $token = $this->processToken($matches[1]); } catch (Throwable $exception) { Yii::warning($exception->getMessage(), 'JwtHttpBearerAuth'); - $this->fail($response); + throw $exception; } if ($token !== null) { - if ($this->auth instanceof Closure) { + if (is_callable($this->auth, true)) { $identity = call_user_func($this->auth, $token); } else { $identity = $user->loginByAccessToken($token->toString(), get_class($this)); } } - if ($identity === null) { - $this->fail($response); + if (!$identity instanceof IdentityInterface) { + return null; } return $identity; diff --git a/tests/BearerTest.php b/tests/BearerTest.php index 794beab..8468749 100644 --- a/tests/BearerTest.php +++ b/tests/BearerTest.php @@ -16,7 +16,9 @@ use Lcobucci\JWT\Token; use Lcobucci\JWT\Validation\Constraint\IssuedBy; use Lcobucci\JWT\Validation\Constraint\LooseValidAt; +use Lcobucci\JWT\Validation\NoConstraintsGiven; use PHPUnit\Framework\TestCase; +use stdClass; use Yii; use yii\base\InvalidConfigException; use yii\log\Logger; @@ -27,9 +29,6 @@ class BearerTest extends TestCase { - /** - * @throws InvalidConfigException - */ protected function setUp(): void { new Application([ @@ -72,54 +71,45 @@ public function testEmptyPattern(): void $controller->run('test'); } - /** - * @throws InvalidConfigException - */ public function testHttpBearerAuthNoHeader(): void { + $this->expectException(UnauthorizedHttpException::class); + $this->expectExceptionMessage('Your request was made with invalid or expired JSON Web Token.'); + /* @var $controller Controller */ $controller = Yii::$app->createController('test-auth')[0]; - - try { - $controller->run('filtered'); - self::fail('Should throw UnauthorizedHttpException'); - } catch (UnauthorizedHttpException $e) { - self::assertArrayHasKey('WWW-Authenticate', Yii::$app->getResponse()->getHeaders()); - } + $controller->run('filtered'); } - public function providerForInvalidHeaderToken(): array + public function testHttpBearerAuthInvalidToken(): void { - return [ - 'invalid token' => ['Bearer InvalidToken'], - 'invalid header value' => ['InvalidHeaderValue'] - ]; + $this->expectException(Token\InvalidTokenStructure::class); + $this->expectExceptionMessage('The JWT string must have two dots'); + + Yii::$app->request->headers->set('Authorization', 'Bearer InvalidToken'); + + /* @var $controller Controller */ + $controller = Yii::$app->createController('test-auth')[0]; + $controller->run('filtered'); } - /** - * @dataProvider providerForInvalidHeaderToken - * @throws InvalidConfigException - */ - public function testHttpBearerAuthInvalidTokenOrHeader(string $headerValue): void + public function testHttpBearerAuthInvalidHeader(): void { - Yii::$app->request->headers->set('Authorization', $headerValue); + $this->expectException(UnauthorizedHttpException::class); + $this->expectExceptionMessage('Your request was made with invalid or expired JSON Web Token.'); + + Yii::$app->request->headers->set('Authorization', 'InvalidHeaderValue'); /* @var $controller Controller */ $controller = Yii::$app->createController('test-auth')[0]; - - try { - $controller->run('filtered'); - self::fail('Should throw UnauthorizedHttpException'); - } catch (UnauthorizedHttpException $e) { - self::assertArrayHasKey('WWW-Authenticate', Yii::$app->getResponse()->getHeaders()); - } + $controller->run('filtered'); } - /** - * @throws InvalidConfigException - */ public function testHttpBearerAuthExpiredToken(): void { + $this->expectException(UnauthorizedHttpException::class); + $this->expectExceptionMessage('Your request was made with invalid or expired JSON Web Token.'); + $now = new DateTimeImmutable(); $this->getJwt()->getConfiguration()->setValidationConstraints(new LooseValidAt(SystemClock::fromSystemTimezone())); @@ -134,18 +124,9 @@ public function testHttpBearerAuthExpiredToken(): void /* @var $controller Controller */ $controller = Yii::$app->createController('test-auth')[0]; - - try { - $controller->run('filtered'); - self::fail('Should throw UnauthorizedHttpException'); - } catch (UnauthorizedHttpException $e) { - self::assertArrayHasKey('WWW-Authenticate', Yii::$app->getResponse()->getHeaders()); - } + $controller->run('filtered'); } - /** - * @throws InvalidConfigException - */ public function testHttpBearerAuth(): void { $now = new DateTimeImmutable(); @@ -172,9 +153,6 @@ public function testHttpBearerAuth(): void self::assertEquals('test', $controller->run('filtered')); } - /** - * @throws InvalidConfigException - */ public function testHttpBearerAuthCustom(): void { $now = new DateTimeImmutable(); @@ -202,22 +180,60 @@ public function testHttpBearerAuthCustom(): void self::assertEquals('test', $controller->run('filtered')); } - /** - * @throws InvalidConfigException - */ - public function testHandlingEmptyFailure(): void + public function testHttpBearerAuthCustomNoIdentity(): void { - Yii::$app->request->headers->set('Authorization', "Bearer Token"); + $this->expectException(UnauthorizedHttpException::class); + $this->expectExceptionMessage('Your request was made with invalid or expired JSON Web Token.'); - /** @var Controller $controller */ - $controller = Yii::$app->createController('test-stub2')[0]; + $now = new DateTimeImmutable(); + + $this->getJwt()->getConfiguration()->setValidationConstraints(new LooseValidAt(SystemClock::fromSystemTimezone())); + + $token = $this->getJwt()->getBuilder() + ->relatedTo('test') + ->issuedAt($now) + ->expiresAt($now->modify('+1 hour')) + ->getToken($this->getJwt()->getConfiguration()->signer(), $this->getJwt()->getConfiguration()->signingKey()); + + $JWT = $token->toString(); + + Yii::$app->request->headers->set('Authorization', "Bearer $JWT"); + + /** @var TestAuthController $controller */ + $controller = Yii::$app->createController('test-auth')[0]; + $controller->filterConfig['auth'] = static function (Token $token) { + return null; + }; + $controller->run('filtered'); + } + + public function testHttpBearerAuthCustomNotIdentityInterface(): void + { + $this->expectException(UnauthorizedHttpException::class); + $this->expectExceptionMessage('Your request was made with invalid or expired JSON Web Token.'); + + $now = new DateTimeImmutable(); + + $this->getJwt()->getConfiguration()->setValidationConstraints(new LooseValidAt(SystemClock::fromSystemTimezone())); + + $token = $this->getJwt()->getBuilder() + ->relatedTo('test') + ->issuedAt($now) + ->expiresAt($now->modify('+1 hour')) + ->getToken($this->getJwt()->getConfiguration()->signer(), $this->getJwt()->getConfiguration()->signingKey()); - self::assertNull($controller->run('test')); + $JWT = $token->toString(); + + Yii::$app->request->headers->set('Authorization', "Bearer $JWT"); + + /** @var TestAuthController $controller */ + $controller = Yii::$app->createController('test-auth')[0]; + $controller->filterConfig['auth'] = static function (Token $token) { + return new stdClass(); + }; + $controller->run('filtered'); } - /** - * @throws InvalidConfigException - */ public function testMethodsVisibility(): void { $filter = new JwtHttpBearerAuth(['jwt' => new Jwt()]); @@ -240,11 +256,10 @@ public function testFailVisibility(): void self::assertSame(2, $filter->flag); } - /** - * @throws InvalidConfigException - */ public function testFailedToken(): void { + $this->expectException(NoConstraintsGiven::class); + $logger = $this->createMock(Logger::class); $logger->expects(self::exactly(2))->method('log')->withConsecutive( ['Route to run: test-stub2/test', 8, 'yii\base\Controller::runAction'], diff --git a/tests/JwtTest.php b/tests/JwtTest.php index 1bd8b06..9458463 100644 --- a/tests/JwtTest.php +++ b/tests/JwtTest.php @@ -136,6 +136,7 @@ public function testAssertFail(): void public function providerForInvalidKey(): array { return [ + 'empty' => ['', 'Empty string used as a key configuration!'], 'object' => [new stdClass(), 'Invalid key configuration!'], 'int value' => [[Jwt::KEY => 1], 'Invalid key value!'], 'array value' => [[Jwt::KEY => []], 'Invalid key value!'], @@ -311,7 +312,7 @@ public function providerForSignerSignatureConverter(): array */ public function testPrepareSignatureConverter(string $signerId): void { - new Jwt(['signer' => $signerId]); + new Jwt(['signer' => $signerId, 'signingKey' => ' ', 'verifyingKey' => ' ']); $this->assertInstanceOf( Signer\Ecdsa\MultibyteStringConverter::class, \Yii::$container->get(Signer\Ecdsa\SignatureConverter::class)