diff --git a/.env-dist b/.env-dist index 95d87e4..1c79be3 100644 --- a/.env-dist +++ b/.env-dist @@ -7,6 +7,8 @@ EMAILS_RENDERER_HOST=http://emails-renderer:3000 ## Security params JWT_USER_SECRET= +JWT_PUBLIC_KEY= +JWT_PRIVATE_KEY= ## External services RECAPTCHA_PUBLIC= diff --git a/api/components/User/Component.php b/api/components/User/Component.php index f215fd0..30b5357 100644 --- a/api/components/User/Component.php +++ b/api/components/User/Component.php @@ -9,12 +9,15 @@ use DateInterval; use DateTime; use Emarref\Jwt\Algorithm\AlgorithmInterface; use Emarref\Jwt\Algorithm\Hs256; +use Emarref\Jwt\Algorithm\Rs256; use Emarref\Jwt\Claim; use Emarref\Jwt\Encryption\Factory as EncryptionFactory; use Emarref\Jwt\Exception\VerificationException; +use Emarref\Jwt\HeaderParameter\Custom; use Emarref\Jwt\Token; use Emarref\Jwt\Verification\Context as VerificationContext; use Exception; +use Webmozart\Assert\Assert; use Yii; use yii\base\InvalidConfigException; use yii\web\UnauthorizedHttpException; @@ -43,6 +46,10 @@ class Component extends YiiUserComponent { public $secret; + public $publicKey; + + public $privateKey; + public $expirationTimeout = 'PT1H'; public $sessionTimeout = 'P7D'; @@ -54,8 +61,16 @@ class Component extends YiiUserComponent { public function init() { parent::init(); - if (!$this->secret) { - throw new InvalidConfigException('secret must be specified'); + Assert::notEmpty($this->secret, 'secret must be specified'); + Assert::notEmpty($this->publicKey, 'public key must be specified'); + Assert::notEmpty($this->privateKey, 'private key must be specified'); + + if (!($this->publicKey = file_get_contents($this->publicKey))) { + throw new InvalidConfigException('invalid public key'); + } + + if (!($this->privateKey = file_get_contents($this->privateKey))) { + throw new InvalidConfigException('invalid private key'); } } @@ -138,7 +153,18 @@ class Component extends YiiUserComponent { throw new VerificationException('Incorrect token encoding', 0, $e); } - $context = new VerificationContext(EncryptionFactory::create($this->getAlgorithm())); + $algorithm = $this->getAlgorithm(); + $version = $notVerifiedToken->getHeader()->findParameterByName('v'); + if ($version === null) { + $algorithm = new Hs256($this->secret); + } + + $encryption = EncryptionFactory::create($algorithm); + if ($version !== null) { + $encryption->setPublicKey($this->publicKey); + } + + $context = new VerificationContext($encryption); $context->setSubject(self::JWT_SUBJECT_PREFIX); $jwt->verify($notVerifiedToken, $context); @@ -205,15 +231,18 @@ class Component extends YiiUserComponent { } public function getAlgorithm(): AlgorithmInterface { - return new Hs256($this->secret); + return new Rs256(); } protected function serializeToken(Token $token): string { - return (new Jwt())->serialize($token, EncryptionFactory::create($this->getAlgorithm())); + $encryption = EncryptionFactory::create($this->getAlgorithm())->setPrivateKey($this->privateKey); + + return (new Jwt())->serialize($token, $encryption); } protected function createToken(Account $account): Token { $token = new Token(); + $token->addHeader(new Custom('v', 1)); foreach ($this->getClaims($account) as $claim) { $token->addClaim($claim); } diff --git a/api/config/config.php b/api/config/config.php index 58b593e..82ffbba 100644 --- a/api/config/config.php +++ b/api/config/config.php @@ -11,6 +11,8 @@ return [ 'user' => [ 'class' => api\components\User\Component::class, 'secret' => getenv('JWT_USER_SECRET'), + 'publicKey' => getenv('JWT_PUBLIC_KEY') ?: '/data/certs/public.crt', + 'privateKey' => getenv('JWT_PRIVATE_KEY') ?: '/data/certs/private.key', ], 'log' => [ 'traceLevel' => YII_DEBUG ? 3 : 0, diff --git a/api/tests/unit/components/User/ComponentTest.php b/api/tests/unit/components/User/ComponentTest.php index 17aca72..177f457 100644 --- a/api/tests/unit/components/User/ComponentTest.php +++ b/api/tests/unit/components/User/ComponentTest.php @@ -189,6 +189,8 @@ class ComponentTest extends TestCase { 'enableSession' => false, 'loginUrl' => null, 'secret' => 'secret', + 'publicKey' => 'data/certs/public.crt', + 'privateKey' => 'data/certs/private.key', ]; } diff --git a/api/tests/unit/models/JwtIdentityTest.php b/api/tests/unit/models/JwtIdentityTest.php index 94347be..fdd51f2 100644 --- a/api/tests/unit/models/JwtIdentityTest.php +++ b/api/tests/unit/models/JwtIdentityTest.php @@ -8,6 +8,7 @@ use common\tests\_support\ProtectedCaller; use common\tests\fixtures\AccountFixture; use Emarref\Jwt\Claim; use Emarref\Jwt\Encryption\Factory as EncryptionFactory; +use Emarref\Jwt\HeaderParameter\Custom; use Emarref\Jwt\Token; use Yii; @@ -33,10 +34,11 @@ class JwtIdentityTest extends TestCase { */ public function testFindIdentityByAccessTokenWithExpiredToken() { $token = new Token(); + $token->addHeader(new Custom('v', 1)); $token->addClaim(new Claim\IssuedAt(1464593193)); $token->addClaim(new Claim\Expiration(1464596793)); $token->addClaim(new Claim\Subject('ely|' . $this->tester->grabFixture('accounts', 'admin')['id'])); - $expiredToken = (new Jwt())->serialize($token, EncryptionFactory::create(Yii::$app->user->getAlgorithm())); + $expiredToken = (new Jwt())->serialize($token, EncryptionFactory::create(Yii::$app->user->getAlgorithm())->setPrivateKey(Yii::$app->user->privateKey)); JwtIdentity::findIdentityByAccessToken($expiredToken); } diff --git a/api/tests/unit/models/authentication/LogoutFormTest.php b/api/tests/unit/models/authentication/LogoutFormTest.php index ec512d0..1c94729 100644 --- a/api/tests/unit/models/authentication/LogoutFormTest.php +++ b/api/tests/unit/models/authentication/LogoutFormTest.php @@ -63,6 +63,8 @@ class LogoutFormTest extends TestCase { 'enableSession' => false, 'loginUrl' => null, 'secret' => 'secret', + 'publicKey' => 'data/certs/public.crt', + 'privateKey' => 'data/certs/private.key', ]; } diff --git a/api/tests/unit/modules/accounts/models/ChangePasswordFormTest.php b/api/tests/unit/modules/accounts/models/ChangePasswordFormTest.php index 552a185..487a25e 100644 --- a/api/tests/unit/modules/accounts/models/ChangePasswordFormTest.php +++ b/api/tests/unit/modules/accounts/models/ChangePasswordFormTest.php @@ -61,6 +61,8 @@ class ChangePasswordFormTest extends TestCase { 'enableSession' => false, 'loginUrl' => null, 'secret' => 'secret', + 'publicKey' => 'data/certs/public.crt', + 'privateKey' => 'data/certs/private.key', ]]); $component->shouldNotReceive('terminateSessions'); @@ -121,6 +123,8 @@ class ChangePasswordFormTest extends TestCase { 'enableSession' => false, 'loginUrl' => null, 'secret' => 'secret', + 'publicKey' => 'data/certs/public.crt', + 'privateKey' => 'data/certs/private.key', ]]); $component->shouldReceive('terminateSessions')->once()->withArgs([$account, Component::KEEP_CURRENT_SESSION]); diff --git a/api/tests/unit/modules/accounts/models/EnableTwoFactorAuthFormTest.php b/api/tests/unit/modules/accounts/models/EnableTwoFactorAuthFormTest.php index 84878ff..d1e1a60 100644 --- a/api/tests/unit/modules/accounts/models/EnableTwoFactorAuthFormTest.php +++ b/api/tests/unit/modules/accounts/models/EnableTwoFactorAuthFormTest.php @@ -24,6 +24,8 @@ class EnableTwoFactorAuthFormTest extends TestCase { 'enableSession' => false, 'loginUrl' => null, 'secret' => 'secret', + 'publicKey' => 'data/certs/public.crt', + 'privateKey' => 'data/certs/private.key', ]]); $component->shouldReceive('terminateSessions')->withArgs([$account, Component::KEEP_CURRENT_SESSION]); diff --git a/common/tests/unit/rbac/rules/AccountOwnerTest.php b/common/tests/unit/rbac/rules/AccountOwnerTest.php index 184bbb6..db74e6b 100644 --- a/common/tests/unit/rbac/rules/AccountOwnerTest.php +++ b/common/tests/unit/rbac/rules/AccountOwnerTest.php @@ -13,7 +13,11 @@ use const common\LATEST_RULES_VERSION; class AccountOwnerTest extends TestCase { public function testIdentityIsNull() { - $component = mock(Component::class . '[findIdentityByAccessToken]', [['secret' => 'secret']]); + $component = mock(Component::class . '[findIdentityByAccessToken]', [[ + 'secret' => 'secret', + 'publicKey' => 'data/certs/public.crt', + 'privateKey' => 'data/certs/private.key', + ]]); $component->shouldDeferMissing(); $component->shouldReceive('findIdentityByAccessToken')->andReturn(null); @@ -34,7 +38,11 @@ class AccountOwnerTest extends TestCase { $identity = mock(IdentityInterface::class); $identity->shouldReceive('getAccount')->andReturn($account); - $component = mock(Component::class . '[findIdentityByAccessToken]', [['secret' => 'secret']]); + $component = mock(Component::class . '[findIdentityByAccessToken]', [[ + 'secret' => 'secret', + 'publicKey' => 'data/certs/public.crt', + 'privateKey' => 'data/certs/private.key', + ]]); $component->shouldDeferMissing(); $component->shouldReceive('findIdentityByAccessToken')->withArgs(['token'])->andReturn($identity); diff --git a/common/tests/unit/rbac/rules/OauthClientOwnerTest.php b/common/tests/unit/rbac/rules/OauthClientOwnerTest.php index 95582af..2e3a0b1 100644 --- a/common/tests/unit/rbac/rules/OauthClientOwnerTest.php +++ b/common/tests/unit/rbac/rules/OauthClientOwnerTest.php @@ -34,7 +34,11 @@ class OauthClientOwnerTest extends TestCase { $identity->shouldReceive('getAccount')->andReturn($account); /** @var Component|\Mockery\MockInterface $component */ - $component = mock(Component::class . '[findIdentityByAccessToken]', [['secret' => 'secret']]); + $component = mock(Component::class . '[findIdentityByAccessToken]', [[ + 'secret' => 'secret', + 'publicKey' => 'data/certs/public.crt', + 'privateKey' => 'data/certs/private.key', + ]]); $component->shouldDeferMissing(); $component->shouldReceive('findIdentityByAccessToken')->withArgs(['token'])->andReturn($identity); diff --git a/data/.gitignore b/data/.gitignore index d6b7ef3..84a9470 100644 --- a/data/.gitignore +++ b/data/.gitignore @@ -1,2 +1,3 @@ * +!certs/* !.gitignore diff --git a/data/certs/private.key b/data/certs/private.key new file mode 100644 index 0000000..5423a00 --- /dev/null +++ b/data/certs/private.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEwAIBADANBgkqhkiG9w0BAQEFAASCBKowggSmAgEAAoIBAQDbTqmRpLg3XjDH +3Z97uHdNq4F5j77Rp+M7ctyfUhtb+U7VWppjk2Dxyp2/iPzKK3K0lC91zlnxO4HT +jFCWTIQzSfiFx/Z6nbUXYFZunzRkbt6UgXjUhnYLSIVvNDneph/BZTSxNThky7a8 +weng1+1e7cYcYx7pJWUXB9XINEKdyZ/pF+kB8UPK/LCLY4jFTm7t+N1Rm1R6VpEy +VqhwoDTefkiP9H/QZBp4Ihy48v/NTtgHdsc3Yz+//M6km39MmxIh4wBrZiIictzg +5Xmd1vXamDYFbGZHpKRuujCSufZaglrjGvgaAq1lSS+Cwc5eNCDTlw8OWGJyeSMy +AvYKK5pnAgMBAAECggEBAKcg02kCtsC7L0GhS6Dle0XdpdYWDb2IzErJxghEckUt +QT6mxXGNJxwc5QrKQptvcQLcyy5kC3cjelTVYbSoqzbK8HJDaTsYZKFj8XpsKWlA +dK+H26Vasyr2IXoVuuRKhXjEv9ssS8XE2YYP4URQSb1GRuvrPes/bEKY3fqsmPfU +/rpaUNG9OvskfIDzT+VoEe5RfPW0+uchHZHypWdnhSxLC/oH8KjcUxmCdQ3q46fT +2GhDJnDLXC8MGbyUp7Nw+eSg+4UTCjaNqV7c4vOSXqSBPch7nYFf1YqYuseok21t +UK1G55JrBfsUAmldSi1UVdnAanVRNZiC2LsdDe9PpUECgYEA7kVk7nFqtHqx6EOz +4p6AeDlslrPEWz996AgV1qezBboGlpPkDv+of5cOG4ZMpDJD5KbSIJXTPC06G+3V +VgYpg7cYO9il3I5vaxo64dC9Ib5HQe8UTreVI5763S7Zq7V0jWKOzrlKzA/KQl3x +1kHXS5levDp1uuwAdRBn6DvXnv0CgYEA66ALVI1BUU+OhqSGRQu9pZATfyB5hJaD +1iICiOgl1LRwMJW7/uWUTQ+h5H3lYDmyf+y9/8x8jTfEVZYEwV2bw9wzII87YA9R +pKQl+HMlynrgYWZ2Z94mRFs3poxU8AgpU9MDN84b2cHyP3TGhQjkdtdyFE4lcCiQ +yQqnWa+BBjMCgYEArKeKQKHcoVT7D4PnmIIkM3ng7r7qvPggAv/A219/gNnQplIa +AqhM78+EgHtrk9t8iPY88zG99DANmGlZmlEyyefl3o/ZeB2aLPC/1BvOwOHBfsyA +WZ37qukrfRTS0/LTtxPAyZlI0t9qP3cVo5zoJjbHh/uQjdcvaaRutsCOOP0CgYEA +10TB9T6UdVgM6+A2N7CxVCicV2HxA3yL+D/cNv55SaqMcSbrucY/xmPI0btfq5kr +BorhT2mgRVi0zEiiEZOXMsrj/xQ899cnDRdXBXUWCrZWd0YoWV7xcTQxVL0TALVE +JKw9XWe1tC3oR6dFk9d6+0R8miaHN8An/zT3jg21AFcCgYEAslWiTkT1ULAAhlHa +KLbSW1slYJR8/i9mwIDOoD2BvVJUSqbowAogD4mXRm6S77AxoQX4nygzE6XscR4V +h+fINRJeh7yrFk5x/GUjh7tQo9EITjY89X0s35hZ27i61l66eZ5u06j4xE5+Y424 +HMsBjKAmKFNPebTWFcAlXXaeCPU= +-----END PRIVATE KEY----- diff --git a/data/certs/public.crt b/data/certs/public.crt new file mode 100644 index 0000000..8659eb4 --- /dev/null +++ b/data/certs/public.crt @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIICljCCAX4CCQDA6sdDyK1Y/zANBgkqhkiG9w0BAQsFADANMQswCQYDVQQGEwJC +WTAeFw0xOTA3MjQxMDI5NTdaFw0yMTA3MjMxMDI5NTdaMA0xCzAJBgNVBAYTAkJZ +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA206pkaS4N14wx92fe7h3 +TauBeY++0afjO3Lcn1IbW/lO1VqaY5Ng8cqdv4j8yitytJQvdc5Z8TuB04xQlkyE +M0n4hcf2ep21F2BWbp80ZG7elIF41IZ2C0iFbzQ53qYfwWU0sTU4ZMu2vMHp4Nft +Xu3GHGMe6SVlFwfVyDRCncmf6RfpAfFDyvywi2OIxU5u7fjdUZtUelaRMlaocKA0 +3n5Ij/R/0GQaeCIcuPL/zU7YB3bHN2M/v/zOpJt/TJsSIeMAa2YiInLc4OV5ndb1 +2pg2BWxmR6Skbrowkrn2WoJa4xr4GgKtZUkvgsHOXjQg05cPDlhicnkjMgL2Ciua +ZwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQB+i6Q3Ltg5MPEqHZ3GCpsFMV+xWKp5 +TSgguFr422az9v/Da01VHOX884D0dZt1r6W+zzfIQzIXpRqQkl4YuS1N17Q/KN3E +7rJ0R7gsXM7+KiGVrZyoZlxRaRXCiErUWBOxamIPy07zOWLnWa1kZZNDvgiurMbF +yaREQargFM8G91zkA6XiMXFoermARYB6RLtyHD0EC3I2DSZpOuMD9Kg1k/uw6f3W +xwsQY6kpzoZkYfTqoM4ky16yNPRf9vsej2dYlRr1YPWWQOicY1TrwFJMKoogylTD +lN61u8WED7Z8M00F6FYuuFffzt2Si9GrYeTuf8ZShpKiDqK0P22oiAao +-----END CERTIFICATE-----