Replace emarref/jwt with lcobucci/jwt

Refactor all JWT-related components
Replace RS256 with ES256 as a preferred JWT algorithm
This commit is contained in:
ErickSkrauch
2019-08-01 12:17:12 +03:00
parent 4c2a9cc172
commit 45c2ed601d
47 changed files with 805 additions and 621 deletions

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace api\tests\unit\components\Tokens;
use api\tests\unit\TestCase;
use InvalidArgumentException;
use Lcobucci\JWT\Parser;
use Lcobucci\JWT\Token;
use Yii;
class ComponentTest extends TestCase {
/**
* @var \api\components\Tokens\Component
*/
private $component;
protected function _setUp() {
parent::_setUp();
$this->component = Yii::$app->tokens;
}
public function testCreate() {
// Run without any arguments
$token = $this->component->create();
$this->assertSame('ES256', $token->getHeader('alg'));
$this->assertEmpty(array_diff(array_keys($token->getClaims()), ['iat', 'exp']));
$this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 1);
$this->assertEqualsWithDelta(time() + 3600, $token->getClaim('exp'), 2);
// Pass custom payloads
$token = $this->component->create(['find' => 'me']);
$this->assertArrayHasKey('find', $token->getClaims());
$this->assertSame('me', $token->getClaim('find'));
// Pass custom headers
$token = $this->component->create([], ['find' => 'me']);
$this->assertArrayHasKey('find', $token->getHeaders());
$this->assertSame('me', $token->getHeader('find'));
}
public function testParse() {
// Valid token signed with HS256
$token = $this->component->parse('eyJhbGciOiJIUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ1Mjc0NzYsImV4cCI6MTU2NDUzMTA3Niwic3ViIjoiZWx5fDEiLCJqdGkiOjMwNjk1OTJ9.ixapBbhaUCejbcPTnFi5nqk75XKd1_lQJd1ZPgGTLEc');
$this->assertValidParsedToken($token, 'HS256');
// Valid token signed with ES256
$token = $this->component->parse('eyJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ1Mjc0NzYsImV4cCI6MTU2NDUzMTA3Niwic3ViIjoiZWx5fDEiLCJqdGkiOjMwNjk1OTJ9.M8Kam9bv0BXui3k7Posq_vc0I95Kb_Tw7L2vPdEPlwsHqh1VJHoWtlQc32_SlsotttL7j6RYbffBkRFX2wDGFQ');
$this->assertValidParsedToken($token, 'ES256');
// Valid token signed with ES256, but the signature is invalid
$token = $this->component->parse('eyJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ1Mjc0NzYsImV4cCI6MTU2NDUzMTA3Niwic3ViIjoiZWx5fDEiLCJqdGkiOjMwNjk1OTJ9.xxx');
$this->assertValidParsedToken($token, 'ES256');
// Completely invalid token
$this->expectException(InvalidArgumentException::class);
$this->component->parse('How do you tame a horse in Minecraft?');
}
/**
* @dataProvider getVerifyCases
*/
public function testVerify(Token $token, bool $shouldBeValid) {
$this->assertSame($shouldBeValid, $this->component->verify($token));
}
public function getVerifyCases() {
yield 'HS256' => [
(new Parser())->parse('eyJhbGciOiJIUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ1Mjc0NzYsImV4cCI6MTU2NDUzMTA3Niwic3ViIjoiZWx5fDEiLCJqdGkiOjMwNjk1OTJ9.ixapBbhaUCejbcPTnFi5nqk75XKd1_lQJd1ZPgGTLEc'),
true,
];
yield 'ES256' => [
(new Parser())->parse('eyJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ1Mjc0NzYsImV4cCI6MTU2NDUzMTA3Niwic3ViIjoiZWx5fDEiLCJqdGkiOjMwNjk1OTJ9.M8Kam9bv0BXui3k7Posq_vc0I95Kb_Tw7L2vPdEPlwsHqh1VJHoWtlQc32_SlsotttL7j6RYbffBkRFX2wDGFQ'),
true,
];
yield 'ES256 with an invalid signature' => [
(new Parser())->parse('eyJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ1Mjc0NzYsImV4cCI6MTU2NDUzMTA3Niwic3ViIjoiZWx5fDEiLCJqdGkiOjMwNjk1OTJ9.xxx'),
false,
];
}
private function assertValidParsedToken(Token $token, string $expectedAlg) {
$this->assertSame($expectedAlg, $token->getHeader('alg'));
$this->assertSame(1564527476, $token->getClaim('iat'));
$this->assertSame(1564531076, $token->getClaim('exp'));
$this->assertSame('ely|1', $token->getClaim('sub'));
$this->assertSame(3069592, $token->getClaim('jti'));
$this->assertSame('accounts_web_user', $token->getClaim('ely-scopes'));
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace api\tests\unit\components\Tokens;
use api\components\Tokens\TokensFactory;
use api\tests\unit\TestCase;
use common\models\Account;
use common\models\AccountSession;
class TokensFactoryTest extends TestCase {
public function testCreateForAccount() {
$account = new Account();
$account->id = 1;
$token = TokensFactory::createForAccount($account);
$this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 1);
$this->assertEqualsWithDelta(time() + 60 * 60 * 24 * 7, $token->getClaim('exp'), 2);
$this->assertSame('ely|1', $token->getClaim('sub'));
$this->assertSame('accounts_web_user', $token->getClaim('ely-scopes'));
$this->assertArrayNotHasKey('jti', $token->getClaims());
$session = new AccountSession();
$session->id = 2;
$token = TokensFactory::createForAccount($account, $session);
$this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 1);
$this->assertEqualsWithDelta(time() + 3600, $token->getClaim('exp'), 2);
$this->assertSame('ely|1', $token->getClaim('sub'));
$this->assertSame('accounts_web_user', $token->getClaim('ely-scopes'));
$this->assertSame(2, $token->getClaim('jti'));
}
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace codeception\api\unit\components\User;
use api\components\User\Component;
use api\components\User\Identity;
use api\components\User\IdentityFactory;
use api\tests\unit\TestCase;
use common\models\Account;
use common\models\AccountSession;
@@ -36,29 +36,7 @@ class ComponentTest extends TestCase {
];
}
public function testCreateJwtAuthenticationToken() {
$this->mockRequest();
// Token without session
$account = new Account(['id' => 1]);
$token = $this->component->createJwtAuthenticationToken($account);
$payloads = $token->getPayload();
$this->assertEqualsWithDelta(time(), $payloads->findClaimByName('iat')->getValue(), 3);
$this->assertEqualsWithDelta(time() + 60 * 60 * 24 * 7, $payloads->findClaimByName('exp')->getValue(), 3);
$this->assertSame('ely|1', $payloads->findClaimByName('sub')->getValue());
$this->assertSame('accounts_web_user', $payloads->findClaimByName('ely-scopes')->getValue());
$this->assertNull($payloads->findClaimByName('jti'));
$session = new AccountSession(['id' => 2]);
$token = $this->component->createJwtAuthenticationToken($account, $session);
$payloads = $token->getPayload();
$this->assertEqualsWithDelta(time(), $payloads->findClaimByName('iat')->getValue(), 3);
$this->assertEqualsWithDelta(time() + 3600, $payloads->findClaimByName('exp')->getValue(), 3);
$this->assertSame('ely|1', $payloads->findClaimByName('sub')->getValue());
$this->assertSame('accounts_web_user', $payloads->findClaimByName('ely-scopes')->getValue());
$this->assertSame(2, $payloads->findClaimByName('jti')->getValue());
}
// TODO: move test to refresh token form
public function testRenewJwtAuthenticationToken() {
$userIP = '192.168.0.1';
$this->mockRequest($userIP);
@@ -83,14 +61,6 @@ class ComponentTest extends TestCase {
$this->assertSame($session->id, $payloads->findClaimByName('jti')->getValue(), 'session has not changed');
}
public function testParseToken() {
$this->mockRequest();
$account = new Account(['id' => 1]);
$token = $this->component->createJwtAuthenticationToken($account);
$jwt = $this->component->serializeToken($token);
$this->component->parseToken($jwt);
}
public function testGetActiveSession() {
/** @var Account $account */
$account = $this->tester->grabFixture('accounts', 'admin');
@@ -172,7 +142,7 @@ class ComponentTest extends TestCase {
private function getComponentConfig() {
return [
'identityClass' => Identity::class,
'identityClass' => IdentityFactory::class,
'enableSession' => false,
'loginUrl' => null,
'secret' => 'secret',

View File

@@ -1,63 +0,0 @@
<?php
namespace api\tests\unit\components\User;
use api\components\User\AuthenticationResult;
use api\tests\unit\TestCase;
use common\models\Account;
use common\models\AccountSession;
use Emarref\Jwt\Algorithm\Hs256;
use Emarref\Jwt\Claim\Expiration;
use Emarref\Jwt\Encryption\Factory as EncryptionFactory;
use Emarref\Jwt\Jwt;
use Emarref\Jwt\Token;
class JwtAuthenticationResultTest extends TestCase {
public function testGetAccount() {
$account = new Account();
$account->id = 123;
$model = new AuthenticationResult($account, '', null);
$this->assertSame($account, $model->getAccount());
}
public function testGetJwt() {
$model = new AuthenticationResult(new Account(), 'mocked jwt', null);
$this->assertSame('mocked jwt', $model->getJwt());
}
public function testGetSession() {
$model = new AuthenticationResult(new Account(), '', null);
$this->assertNull($model->getSession());
$session = new AccountSession();
$session->id = 321;
$model = new AuthenticationResult(new Account(), '', $session);
$this->assertSame($session, $model->getSession());
}
public function testGetAsResponse() {
$jwtToken = $this->createJwtToken(time() + 3600);
$model = new AuthenticationResult(new Account(), $jwtToken, null);
$result = $model->getAsResponse();
$this->assertSame($jwtToken, $result['access_token']);
$this->assertSame(3600, $result['expires_in']);
/** @noinspection SummerTimeUnsafeTimeManipulationInspection */
$jwtToken = $this->createJwtToken(time() + 86400);
$session = new AccountSession();
$session->refresh_token = 'refresh token';
$model = new AuthenticationResult(new Account(), $jwtToken, $session);
$result = $model->getAsResponse();
$this->assertSame($jwtToken, $result['access_token']);
$this->assertSame('refresh token', $result['refresh_token']);
$this->assertSame(86400, $result['expires_in']);
}
private function createJwtToken(int $expires): string {
$token = new Token();
$token->addClaim(new Expiration($expires));
return (new Jwt())->serialize($token, EncryptionFactory::create(new Hs256('123')));
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace api\tests\unit\models\authentication;
use api\models\authentication\AuthenticationResult;
use api\tests\unit\TestCase;
use Lcobucci\JWT\Token;
use Yii;
class AuthenticationResultTest extends TestCase {
public function testGetters() {
$token = new Token();
$model = new AuthenticationResult($token);
$this->assertSame($token, $model->getToken());
$this->assertNull($model->getRefreshToken());
$model = new AuthenticationResult($token, 'refresh_token');
$this->assertSame('refresh_token', $model->getRefreshToken());
}
public function testGetAsResponse() {
$token = Yii::$app->tokens->create();
$jwt = (string)$token;
$model = new AuthenticationResult($token);
$result = $model->formatAsOAuth2Response();
$this->assertSame($jwt, $result['access_token']);
$this->assertEqualsWithDelta(3600, $result['expires_in'], 1);
$this->assertArrayNotHasKey('refresh_token', $result);
$model = new AuthenticationResult($token, 'refresh_token');
$result = $model->formatAsOAuth2Response();
$this->assertSame($jwt, $result['access_token']);
$this->assertEqualsWithDelta(3600, $result['expires_in'], 1);
$this->assertSame('refresh_token', $result['refresh_token']);
}
}

View File

@@ -1,11 +1,11 @@
<?php
declare(strict_types=1);
namespace api\tests\_support\models\authentication;
use api\components\User\AuthenticationResult;
use api\models\authentication\ConfirmEmailForm;
use api\tests\unit\TestCase;
use common\models\Account;
use common\models\AccountSession;
use common\models\EmailActivation;
use common\tests\fixtures\EmailActivationFixture;
@@ -21,8 +21,8 @@ class ConfirmEmailFormTest extends TestCase {
$fixture = $this->tester->grabFixture('emailActivations', 'freshRegistrationConfirmation');
$model = $this->createModel($fixture['key']);
$result = $model->confirm();
$this->assertInstanceOf(AuthenticationResult::class, $result);
$this->assertInstanceOf(AccountSession::class, $result->getSession(), 'session was generated');
$this->assertNotNull($result);
$this->assertNotNull($result->getRefreshToken(), 'session was generated');
$activationExists = EmailActivation::find()->andWhere(['key' => $fixture['key']])->exists();
$this->assertFalse($activationExists, 'email activation key is not exist');
/** @var Account $account */

View File

@@ -1,7 +1,8 @@
<?php
declare(strict_types=1);
namespace api\tests\_support\models\authentication;
use api\components\User\AuthenticationResult;
use api\models\authentication\LoginForm;
use api\tests\unit\TestCase;
use Codeception\Specify;
@@ -135,7 +136,7 @@ class LoginFormTest extends TestCase {
'status' => Account::STATUS_ACTIVE,
]),
]);
$this->assertInstanceOf(AuthenticationResult::class, $model->login(), 'model should login user');
$this->assertNotNull($model->login(), 'model should login user');
$this->assertEmpty($model->getErrors(), 'error message should not be set');
}
@@ -144,7 +145,7 @@ class LoginFormTest extends TestCase {
'login' => $this->tester->grabFixture('accounts', 'user-with-old-password-type')['username'],
'password' => '12345678',
]);
$this->assertInstanceOf(AuthenticationResult::class, $model->login());
$this->assertNotNull($model->login());
$this->assertEmpty($model->getErrors());
$this->assertSame(
Account::PASS_HASH_STRATEGY_YII2,

View File

@@ -2,7 +2,7 @@
namespace api\tests\_support\models\authentication;
use api\components\User\Component;
use api\components\User\Identity;
use api\components\User\IdentityFactory;
use api\models\authentication\LogoutForm;
use api\tests\unit\TestCase;
use Codeception\Specify;
@@ -59,7 +59,7 @@ class LogoutFormTest extends TestCase {
private function getComponentArgs() {
return [
'identityClass' => Identity::class,
'identityClass' => IdentityFactory::class,
'enableSession' => false,
'loginUrl' => null,
'secret' => 'secret',

View File

@@ -1,7 +1,8 @@
<?php
declare(strict_types=1);
namespace api\tests\_support\models\authentication;
use api\components\User\AuthenticationResult;
use api\models\authentication\RecoverPasswordForm;
use api\tests\unit\TestCase;
use common\models\Account;
@@ -24,8 +25,8 @@ class RecoverPasswordFormTest extends TestCase {
'newRePassword' => '12345678',
]);
$result = $model->recoverPassword();
$this->assertInstanceOf(AuthenticationResult::class, $result);
$this->assertNull($result->getSession(), 'session was not generated');
$this->assertNotNull($result);
$this->assertNull($result->getRefreshToken(), 'session was not generated');
$this->assertFalse(EmailActivation::find()->andWhere(['key' => $fixture['key']])->exists());
/** @var Account $account */
$account = Account::findOne($fixture['account_id']);

View File

@@ -1,7 +1,8 @@
<?php
declare(strict_types=1);
namespace codeception\api\unit\models\authentication;
use api\components\User\AuthenticationResult;
use api\models\authentication\RefreshTokenForm;
use api\tests\unit\TestCase;
use Codeception\Specify;
@@ -44,7 +45,7 @@ class RefreshTokenFormTest extends TestCase {
public function testRenew() {
$model = new RefreshTokenForm();
$model->refresh_token = $this->tester->grabFixture('sessions', 'admin')['refresh_token'];
$this->assertInstanceOf(AuthenticationResult::class, $model->renew());
$this->assertNotNull($model->renew());
}
}

View File

@@ -2,7 +2,7 @@
namespace api\tests\unit\modules\accounts\models;
use api\components\User\Component;
use api\components\User\Identity;
use api\components\User\IdentityFactory;
use api\modules\accounts\models\ChangePasswordForm;
use api\tests\unit\TestCase;
use common\components\UserPass;
@@ -57,7 +57,7 @@ class ChangePasswordFormTest extends TestCase {
public function testPerformAction() {
$component = mock(Component::class . '[terminateSessions]', [[
'identityClass' => Identity::class,
'identityClass' => IdentityFactory::class,
'enableSession' => false,
'loginUrl' => null,
'secret' => 'secret',
@@ -119,7 +119,7 @@ class ChangePasswordFormTest extends TestCase {
/** @var Component|\Mockery\MockInterface $component */
$component = mock(Component::class . '[terminateSessions]', [[
'identityClass' => Identity::class,
'identityClass' => IdentityFactory::class,
'enableSession' => false,
'loginUrl' => null,
'secret' => 'secret',

View File

@@ -2,7 +2,7 @@
namespace api\tests\unit\modules\accounts\models;
use api\components\User\Component;
use api\components\User\Identity;
use api\components\User\IdentityFactory;
use api\modules\accounts\models\EnableTwoFactorAuthForm;
use api\tests\unit\TestCase;
use common\helpers\Error as E;
@@ -20,7 +20,7 @@ class EnableTwoFactorAuthFormTest extends TestCase {
/** @var Component|\Mockery\MockInterface $component */
$component = mock(Component::class . '[terminateSessions]', [[
'identityClass' => Identity::class,
'identityClass' => IdentityFactory::class,
'enableSession' => false,
'loginUrl' => null,
'secret' => 'secret',