diff --git a/api/components/User/Component.php b/api/components/User/Component.php index f93635a..10d2acf 100644 --- a/api/components/User/Component.php +++ b/api/components/User/Component.php @@ -1,4 +1,6 @@ privateKeyPath, 'private key path must be specified'); } - public function getPublicKey() { - if (empty($this->publicKey)) { - if (!($this->publicKey = file_get_contents($this->publicKeyPath))) { - throw new InvalidConfigException('invalid public key path'); - } - } - - return $this->publicKey; - } - - public function getPrivateKey() { - if (empty($this->privateKey)) { - if (!($this->privateKey = file_get_contents($this->privateKeyPath))) { - throw new InvalidConfigException('invalid private key path'); - } - } - - return $this->privateKey; - } - public function findIdentityByAccessToken($accessToken): ?IdentityInterface { if (empty($accessToken)) { return null; @@ -109,29 +95,17 @@ class Component extends YiiUserComponent { return null; } - public function createJwtAuthenticationToken(Account $account, bool $rememberMe): AuthenticationResult { - $ip = Yii::$app->request->userIP; + public function createJwtAuthenticationToken(Account $account, AccountSession $session = null): Token { $token = $this->createToken($account); - if ($rememberMe) { - $session = new AccountSession(); - $session->account_id = $account->id; - $session->setIp($ip); - $session->generateRefreshToken(); - if (!$session->save()) { - throw new ThisShouldNotHappenException('Cannot save account session model'); - } - + if ($session !== null) { $token->addClaim(new Claim\JwtId($session->id)); } else { - $session = null; // If we don't remember a session, the token should live longer // so that the session doesn't end while working with the account $token->addClaim(new Claim\Expiration((new DateTime())->add(new DateInterval($this->sessionTimeout)))); } - $jwt = $this->serializeToken($token); - - return new AuthenticationResult($account, $jwt, $session); + return $token; } public function renewJwtAuthenticationToken(AccountSession $session): AuthenticationResult { @@ -155,6 +129,13 @@ class Component extends YiiUserComponent { return $result; } + public function serializeToken(Token $token): string { + $encryption = $this->getEncryptionForVersion(self::LATEST_JWT_VERSION); + $this->prepareEncryptionForEncoding($encryption); + + return (new Jwt())->serialize($token, $encryption); + } + /** * @param string $jwtString * @return Token @@ -170,9 +151,10 @@ class Component extends YiiUserComponent { throw new VerificationException('Incorrect token encoding', 0, $e); } - $version = $notVerifiedToken->getHeader()->findParameterByName('v'); - $version = $version ? $version->getValue() : null; - $encryption = $this->getEncryption($version); + $versionHeader = $notVerifiedToken->getHeader()->findParameterByName('v'); + $version = $versionHeader ? $versionHeader->getValue() : 0; + $encryption = $this->getEncryptionForVersion($version); + $this->prepareEncryptionForDecoding($encryption); $context = new VerificationContext($encryption); $context->setSubject(self::JWT_SUBJECT_PREFIX); @@ -240,28 +222,27 @@ class Component extends YiiUserComponent { } } - public function getAlgorithm(): AlgorithmInterface { - return new Rs256(); - } - - public function getEncryption(?int $version): EncryptionInterface { - $algorithm = $version ? new Rs256() : new Hs256($this->secret); - $encryption = EncryptionFactory::create($algorithm); - - if ($version) { - $encryption->setPublicKey($this->getPublicKey())->setPrivateKey($this->getPrivateKey()); + private function getPublicKey() { + if (empty($this->publicKey)) { + if (!($this->publicKey = file_get_contents($this->publicKeyPath))) { + throw new InvalidConfigException('invalid public key path'); + } } - return $encryption; + return $this->publicKey; } - protected function serializeToken(Token $token): string { - $encryption = $this->getEncryption(1); + private function getPrivateKey() { + if (empty($this->privateKey)) { + if (!($this->privateKey = file_get_contents($this->privateKeyPath))) { + throw new InvalidConfigException('invalid private key path'); + } + } - return (new Jwt())->serialize($token, $encryption); + return $this->privateKey; } - protected function createToken(Account $account): Token { + private function createToken(Account $account): Token { $token = new Token(); $token->addHeader(new Custom('v', 1)); foreach ($this->getClaims($account) as $claim) { @@ -275,7 +256,7 @@ class Component extends YiiUserComponent { * @param Account $account * @return Claim\AbstractClaim[] */ - protected function getClaims(Account $account): array { + private function getClaims(Account $account): array { $currentTime = new DateTime(); return [ @@ -286,7 +267,22 @@ class Component extends YiiUserComponent { ]; } - private function getBearerToken() { + private function getEncryptionForVersion(int $version): EncryptionInterface { + return EncryptionFactory::create($this->getAlgorithm($version ?? 0)); + } + + private function getAlgorithm(int $version): AlgorithmInterface { + switch ($version) { + case 0: + return new Hs256($this->secret); + case 1: + return new Rs256(); + } + + throw new InvalidArgumentException('Unsupported token version'); + } + + private function getBearerToken(): ?string { $authHeader = Yii::$app->request->getHeaders()->get('Authorization'); if ($authHeader === null || !preg_match('/^Bearer\s+(.*?)$/', $authHeader, $matches)) { return null; @@ -295,4 +291,16 @@ class Component extends YiiUserComponent { return $matches[1]; } + private function prepareEncryptionForEncoding(EncryptionInterface $encryption): void { + if ($encryption instanceof AsymmetricEncryption) { + $encryption->setPrivateKey($this->getPrivateKey()); + } + } + + private function prepareEncryptionForDecoding(EncryptionInterface $encryption) { + if ($encryption instanceof AsymmetricEncryption) { + $encryption->setPublicKey($this->getPublicKey()); + } + } + } diff --git a/api/models/authentication/ConfirmEmailForm.php b/api/models/authentication/ConfirmEmailForm.php index 5d6b7cc..9738bd2 100644 --- a/api/models/authentication/ConfirmEmailForm.php +++ b/api/models/authentication/ConfirmEmailForm.php @@ -4,12 +4,14 @@ declare(strict_types=1); namespace api\models\authentication; use api\aop\annotations\CollectModelMetrics; +use api\components\User\AuthenticationResult; use api\models\base\ApiForm; use api\validators\EmailActivationKeyValidator; use common\models\Account; +use common\models\AccountSession; use common\models\EmailActivation; +use Webmozart\Assert\Assert; use Yii; -use yii\base\ErrorException; class ConfirmEmailForm extends ApiForm { @@ -23,8 +25,8 @@ class ConfirmEmailForm extends ApiForm { /** * @CollectModelMetrics(prefix="signup.confirmEmail") - * @return \api\components\User\AuthenticationResult|bool - * @throws ErrorException + * @return AuthenticationResult|bool + * @throws \Throwable */ public function confirm() { if (!$this->validate()) { @@ -37,17 +39,22 @@ class ConfirmEmailForm extends ApiForm { $confirmModel = $this->key; $account = $confirmModel->account; $account->status = Account::STATUS_ACTIVE; - if (!$confirmModel->delete()) { - throw new ErrorException('Unable remove activation key.'); - } + Assert::notSame($confirmModel->delete(), false, 'Unable remove activation key.'); - if (!$account->save()) { - throw new ErrorException('Unable activate user account.'); - } + Assert::true($account->save(), 'Unable activate user account.'); + + $session = new AccountSession(); + $session->account_id = $account->id; + $session->setIp(Yii::$app->request->userIP); + $session->generateRefreshToken(); + Assert::true($session->save(), 'Cannot save account session model'); + + $token = Yii::$app->user->createJwtAuthenticationToken($account, $session); + $jwt = Yii::$app->user->serializeToken($token); $transaction->commit(); - return Yii::$app->user->createJwtAuthenticationToken($account, true); + return new AuthenticationResult($account, $jwt, $session); } } diff --git a/api/models/authentication/LoginForm.php b/api/models/authentication/LoginForm.php index 8919300..9925a7f 100644 --- a/api/models/authentication/LoginForm.php +++ b/api/models/authentication/LoginForm.php @@ -1,12 +1,17 @@ hasErrors()) { - if ($this->getAccount() === null) { - $this->addError($attribute, E::LOGIN_NOT_EXIST); - } + public function validateLogin(string $attribute): void { + if (!$this->hasErrors() && $this->getAccount() === null) { + $this->addError($attribute, E::LOGIN_NOT_EXIST); } } - public function validatePassword($attribute) { + public function validatePassword(string $attribute): void { if (!$this->hasErrors()) { $account = $this->getAccount(); if ($account === null || !$account->validatePassword($this->password)) { @@ -58,11 +61,12 @@ class LoginForm extends ApiForm { } } - public function validateTotp($attribute) { + public function validateTotp(string $attribute): void { if ($this->hasErrors()) { return; } + /** @var Account $account */ $account = $this->getAccount(); if (!$account->is_otp_enabled) { return; @@ -73,8 +77,9 @@ class LoginForm extends ApiForm { $validator->validateAttribute($this, $attribute); } - public function validateActivity($attribute) { + public function validateActivity(string $attribute): void { if (!$this->hasErrors()) { + /** @var Account $account */ $account = $this->getAccount(); if ($account->status === Account::STATUS_BANNED) { $this->addError($attribute, E::ACCOUNT_BANNED); @@ -92,20 +97,37 @@ class LoginForm extends ApiForm { /** * @CollectModelMetrics(prefix="authentication.login") - * @return \api\components\User\AuthenticationResult|bool + * @return AuthenticationResult|bool */ public function login() { if (!$this->validate()) { return false; } + $transaction = Yii::$app->db->beginTransaction(); + + /** @var Account $account */ $account = $this->getAccount(); if ($account->password_hash_strategy !== Account::PASS_HASH_STRATEGY_YII2) { $account->setPassword($this->password); - $account->save(); + Assert::true($account->save(), 'Unable to upgrade user\'s password'); } - return Yii::$app->user->createJwtAuthenticationToken($account, $this->rememberMe); + $session = null; + if ($this->rememberMe) { + $session = new AccountSession(); + $session->account_id = $account->id; + $session->setIp(Yii::$app->request->userIP); + $session->generateRefreshToken(); + Assert::true($session->save(), 'Cannot save account session model'); + } + + $token = Yii::$app->user->createJwtAuthenticationToken($account, $session); + $jwt = Yii::$app->user->serializeToken($token); + + $transaction->commit(); + + return new AuthenticationResult($account, $jwt, $session); } } diff --git a/api/models/authentication/RecoverPasswordForm.php b/api/models/authentication/RecoverPasswordForm.php index 358fec0..6f0e186 100644 --- a/api/models/authentication/RecoverPasswordForm.php +++ b/api/models/authentication/RecoverPasswordForm.php @@ -1,4 +1,6 @@ EmailActivation::TYPE_FORGOT_PASSWORD_KEY], ['newPassword', 'required', 'message' => E::NEW_PASSWORD_REQUIRED], @@ -28,18 +30,16 @@ class RecoverPasswordForm extends ApiForm { ]; } - public function validatePasswordAndRePasswordMatch($attribute) { - if (!$this->hasErrors()) { - if ($this->newPassword !== $this->newRePassword) { - $this->addError($attribute, E::NEW_RE_PASSWORD_DOES_NOT_MATCH); - } + public function validatePasswordAndRePasswordMatch(string $attribute): void { + if (!$this->hasErrors() && $this->newPassword !== $this->newRePassword) { + $this->addError($attribute, E::NEW_RE_PASSWORD_DOES_NOT_MATCH); } } /** * @CollectModelMetrics(prefix="authentication.recoverPassword") * @return \api\components\User\AuthenticationResult|bool - * @throws ErrorException + * @throws \Throwable */ public function recoverPassword() { if (!$this->validate()) { @@ -52,17 +52,16 @@ class RecoverPasswordForm extends ApiForm { $confirmModel = $this->key; $account = $confirmModel->account; $account->password = $this->newPassword; - if (!$confirmModel->delete()) { - throw new ErrorException('Unable remove activation key.'); - } + Assert::notSame($confirmModel->delete(), false, 'Unable remove activation key.'); - if (!$account->save(false)) { - throw new ErrorException('Unable activate user account.'); - } + Assert::true($account->save(), 'Unable activate user account.'); + + $token = Yii::$app->user->createJwtAuthenticationToken($account); + $jwt = Yii::$app->user->serializeToken($token); $transaction->commit(); - return Yii::$app->user->createJwtAuthenticationToken($account, false); + return new \api\components\User\AuthenticationResult($account, $jwt, null); } } diff --git a/api/tests/_support/FunctionalTester.php b/api/tests/_support/FunctionalTester.php index d39a6a2..f1d66f6 100644 --- a/api/tests/_support/FunctionalTester.php +++ b/api/tests/_support/FunctionalTester.php @@ -19,8 +19,9 @@ class FunctionalTester extends Actor { throw new InvalidArgumentException("Cannot find account for username \"{$asUsername}\""); } - $result = Yii::$app->user->createJwtAuthenticationToken($account, false); - $this->amBearerAuthenticated($result->getJwt()); + $token = Yii::$app->user->createJwtAuthenticationToken($account); + $jwt = Yii::$app->user->serializeToken($token); + $this->amBearerAuthenticated($jwt); return $account->id; } diff --git a/api/tests/unit/components/User/ComponentTest.php b/api/tests/unit/components/User/ComponentTest.php index 607b494..a22fced 100644 --- a/api/tests/unit/components/User/ComponentTest.php +++ b/api/tests/unit/components/User/ComponentTest.php @@ -1,24 +1,22 @@ mockRequest(); + // Token without session $account = new Account(['id' => 1]); - $result = $this->component->createJwtAuthenticationToken($account, false); - $this->assertInstanceOf(AuthenticationResult::class, $result); - $this->assertNull($result->getSession()); - $this->assertSame($account, $result->getAccount()); - $payloads = (new Jwt())->deserialize($result->getJwt())->getPayload(); - /** @noinspection NullPointerExceptionInspection */ - $this->assertEqualsWithDelta(time(), $payloads->findClaimByName(Claim\IssuedAt::NAME)->getValue(), 3); - /** @noinspection SummerTimeUnsafeTimeManipulationInspection */ - /** @noinspection NullPointerExceptionInspection */ + $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); - /** @noinspection NullPointerExceptionInspection */ $this->assertSame('ely|1', $payloads->findClaimByName('sub')->getValue()); - /** @noinspection NullPointerExceptionInspection */ $this->assertSame('accounts_web_user', $payloads->findClaimByName('ely-scopes')->getValue()); $this->assertNull($payloads->findClaimByName('jti')); - /** @var Account $account */ - $account = $this->tester->grabFixture('accounts', 'admin'); - $result = $this->component->createJwtAuthenticationToken($account, true); - $this->assertInstanceOf(AuthenticationResult::class, $result); - $this->assertInstanceOf(AccountSession::class, $result->getSession()); - $this->assertSame($account, $result->getAccount()); - /** @noinspection NullPointerExceptionInspection */ - $this->assertTrue($result->getSession()->refresh()); - $payloads = (new Jwt())->deserialize($result->getJwt())->getPayload(); - /** @noinspection NullPointerExceptionInspection */ - $this->assertEqualsWithDelta(time(), $payloads->findClaimByName(Claim\IssuedAt::NAME)->getValue(), 3); - /** @noinspection NullPointerExceptionInspection */ + $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); - /** @noinspection NullPointerExceptionInspection */ $this->assertSame('ely|1', $payloads->findClaimByName('sub')->getValue()); - /** @noinspection NullPointerExceptionInspection */ $this->assertSame('accounts_web_user', $payloads->findClaimByName('ely-scopes')->getValue()); - /** @noinspection NullPointerExceptionInspection */ - $this->assertSame($result->getSession()->id, $payloads->findClaimByName('jti')->getValue()); + $this->assertSame(2, $payloads->findClaimByName('jti')->getValue()); } public function testRenewJwtAuthenticationToken() { @@ -105,15 +85,19 @@ class ComponentTest extends TestCase { public function testParseToken() { $this->mockRequest(); - $token = $this->callProtected($this->component, 'createToken', new Account(['id' => 1])); - $jwt = $this->callProtected($this->component, 'serializeToken', $token); - $this->assertInstanceOf(Token::class, $this->component->parseToken($jwt), 'success get RenewResult object'); + $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'); - $result = $this->component->createJwtAuthenticationToken($account, true); - $this->component->logout(); + /** @var AccountSession $session */ + $session = $this->tester->grabFixture('sessions', 'admin'); + $token = $this->component->createJwtAuthenticationToken($account, $session); + $jwt = $this->component->serializeToken($token); /** @var Component|\PHPUnit\Framework\MockObject\MockObject $component */ $component = $this->getMockBuilder(Component::class) @@ -125,39 +109,42 @@ class ComponentTest extends TestCase { ->method('getIsGuest') ->willReturn(false); - $this->mockAuthorizationHeader($result->getJwt()); + $this->mockAuthorizationHeader($jwt); - $session = $component->getActiveSession(); - $this->assertInstanceOf(AccountSession::class, $session); - /** @noinspection NullPointerExceptionInspection */ - $this->assertSame($session->id, $result->getSession()->id); + $foundSession = $component->getActiveSession(); + $this->assertInstanceOf(AccountSession::class, $foundSession); + $this->assertSame($session->id, $foundSession->id); } public function testTerminateSessions() { /** @var AccountSession $session */ - $session = AccountSession::findOne($this->tester->grabFixture('sessions', 'admin2')['id']); + $session = $this->tester->grabFixture('sessions', 'admin2'); /** @var Component|\Mockery\MockInterface $component */ - $component = mock(Component::class . '[getActiveSession]', [$this->getComponentConfig()])->shouldDeferMissing(); + $component = mock(Component::class . '[getActiveSession]', [$this->getComponentConfig()])->makePartial(); $component->shouldReceive('getActiveSession')->times(1)->andReturn($session); /** @var Account $account */ $account = $this->tester->grabFixture('accounts', 'admin'); - $component->createJwtAuthenticationToken($account, true); + $component->createJwtAuthenticationToken($account); + // Dry run: no sessions should be removed $component->terminateSessions($account, Component::KEEP_MINECRAFT_SESSIONS | Component::KEEP_SITE_SESSIONS); $this->assertNotEmpty($account->getMinecraftAccessKeys()->all()); $this->assertNotEmpty($account->getSessions()->all()); + // All Minecraft sessions should be removed. Web sessions should be kept $component->terminateSessions($account, Component::KEEP_SITE_SESSIONS); $this->assertEmpty($account->getMinecraftAccessKeys()->all()); $this->assertNotEmpty($account->getSessions()->all()); + // All sessions should be removed except the current one $component->terminateSessions($account, Component::KEEP_CURRENT_SESSION); $sessions = $account->getSessions()->all(); $this->assertCount(1, $sessions); $this->assertSame($session->id, $sessions[0]->id); + // With no arguments each and every session should be removed $component->terminateSessions($account); $this->assertEmpty($account->getSessions()->all()); $this->assertEmpty($account->getMinecraftAccessKeys()->all()); @@ -165,7 +152,7 @@ class ComponentTest extends TestCase { private function mockRequest($userIP = '127.0.0.1') { /** @var Request|\Mockery\MockInterface $request */ - $request = mock(Request::class . '[getHostInfo,getUserIP]')->shouldDeferMissing(); + $request = mock(Request::class . '[getHostInfo,getUserIP]')->makePartial(); $request->shouldReceive('getHostInfo')->andReturn('http://localhost'); $request->shouldReceive('getUserIP')->andReturn($userIP); diff --git a/api/tests/unit/models/JwtIdentityTest.php b/api/tests/unit/models/JwtIdentityTest.php index fdd51f2..04855ea 100644 --- a/api/tests/unit/models/JwtIdentityTest.php +++ b/api/tests/unit/models/JwtIdentityTest.php @@ -1,19 +1,15 @@ 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())->setPrivateKey(Yii::$app->user->privateKey)); - + $expiredToken = $this->generateToken(time() - 3600); JwtIdentity::findIdentityByAccessToken($expiredToken); } @@ -51,14 +41,17 @@ class JwtIdentityTest extends TestCase { JwtIdentity::findIdentityByAccessToken(''); } - protected function generateToken() { + private function generateToken(int $expiresAt = null): string { /** @var \api\components\User\Component $component */ $component = Yii::$app->user; /** @var \common\models\Account $account */ $account = $this->tester->grabFixture('accounts', 'admin'); - $token = $this->callProtected($component, 'createToken', $account); + $token = $component->createJwtAuthenticationToken($account); + if ($expiresAt !== null) { + $token->addClaim(new ExpirationClaim($expiresAt)); + } - return $this->callProtected($component, 'serializeToken', $token); + return $component->serializeToken($token); } }