diff --git a/api/components/User/Component.php b/api/components/User/Component.php index 09be245..0cfbbcd 100644 --- a/api/components/User/Component.php +++ b/api/components/User/Component.php @@ -3,17 +3,24 @@ namespace api\components\User; use api\models\AccountIdentity; use common\models\AccountSession; +use Emarref\Jwt\Algorithm\AlgorithmInterface; use Emarref\Jwt\Algorithm\Hs256; use Emarref\Jwt\Claim; use Emarref\Jwt\Encryption\Factory as EncryptionFactory; +use Emarref\Jwt\Encryption\Factory; +use Emarref\Jwt\Exception\VerificationException; use Emarref\Jwt\Jwt; use Emarref\Jwt\Token; +use Emarref\Jwt\Verification\Context as VerificationContext; use Yii; use yii\base\ErrorException; use yii\base\InvalidConfigException; use yii\web\IdentityInterface; use yii\web\User as YiiUserComponent; +/** + * @property AccountSession|null $activeSession + */ class Component extends YiiUserComponent { public $secret; @@ -43,8 +50,7 @@ class Component extends YiiUserComponent { $id = $identity->getId(); $ip = Yii::$app->request->userIP; - - $jwt = $this->getJWT($identity); + $token = $this->createToken($identity); if ($rememberMe) { $session = new AccountSession(); $session->account_id = $id; @@ -53,10 +59,14 @@ class Component extends YiiUserComponent { if (!$session->save()) { throw new ErrorException('Cannot save account session model'); } + + $token->addClaim(new SessionIdClaim($session->id)); } else { $session = null; } + $jwt = $this->serializeToken($token); + Yii::info("User '{$id}' logged in from {$ip}.", __METHOD__); $result = new LoginResult($identity, $jwt, $session); @@ -70,7 +80,8 @@ class Component extends YiiUserComponent { $transaction = Yii::$app->db->beginTransaction(); try { $identity = new AccountIdentity($account->attributes); - $jwt = $this->getJWT($identity); + $token = $this->createToken($identity); + $jwt = $this->serializeToken($token); $result = new RenewResult($identity, $jwt); @@ -89,26 +100,79 @@ class Component extends YiiUserComponent { return $result; } - public function getJWT(IdentityInterface $identity) { + /** + * @param string $jwtString + * @return Token распаршенный токен + * @throws VerificationException если один из Claims не пройдёт проверку + */ + public function parseToken(string $jwtString) : Token { + $hostInfo = Yii::$app->request->hostInfo; + $jwt = new Jwt(); + $token = $jwt->deserialize($jwtString); + $context = new VerificationContext(Factory::create($this->getAlgorithm())); + $context->setAudience($hostInfo); + $context->setIssuer($hostInfo); + $jwt->verify($token, $context); + + return $token; + } + + /** + * Метод находит AccountSession модель, относительно которой был выдан текущий JWT токен. + * В случае, если на пути поиска встретится ошибка, будет возвращено значение null. Возможные кейсы: + * - Юзер не авторизован + * - Почему-то нет заголовка с токеном + * - Во время проверки токена возникла ошибка, что привело к исключению + * - В токене не найдено ключа сессии. Такое возможно, если юзер выбрал "не запоминать меня" или просто старые + * токены, без поддержки сохранения используемой сессии + * + * @return AccountSession|null + */ + public function getActiveSession() { + if ($this->getIsGuest()) { + return null; + } + + $authHeader = Yii::$app->request->getHeaders()->get('Authorization'); + if ($authHeader === null || !preg_match('/^Bearer\s+(.*?)$/', $authHeader, $matches)) { + return null; + } + + $token = $matches[1]; + try { + $token = $this->parseToken($token); + } catch (VerificationException $e) { + return null; + } + + $sessionId = $token->getPayload()->findClaimByName(SessionIdClaim::NAME); + if ($sessionId === null) { + return null; + } + + return AccountSession::findOne($sessionId->getValue()); + } + + public function getAlgorithm() : AlgorithmInterface { + return new Hs256($this->secret); + } + + protected function serializeToken(Token $token) : string { + return (new Jwt())->serialize($token, EncryptionFactory::create($this->getAlgorithm())); + } + + protected function createToken(IdentityInterface $identity) : Token { $token = new Token(); foreach($this->getClaims($identity) as $claim) { $token->addClaim($claim); } - return $jwt->serialize($token, EncryptionFactory::create($this->getAlgorithm())); - } - - /** - * @return Hs256 - */ - public function getAlgorithm() { - return new Hs256($this->secret); + return $token; } /** * @param IdentityInterface $identity - * * @return Claim\AbstractClaim[] */ protected function getClaims(IdentityInterface $identity) { diff --git a/api/components/User/SessionIdClaim.php b/api/components/User/SessionIdClaim.php new file mode 100644 index 0000000..1b4047d --- /dev/null +++ b/api/components/User/SessionIdClaim.php @@ -0,0 +1,17 @@ +deserialize($token); /** @var \api\components\User\Component $component */ $component = Yii::$app->user; - - $hostInfo = Yii::$app->request->hostInfo; - $context = new VerificationContext(Factory::create($component->getAlgorithm())); - $context->setAudience($hostInfo); - $context->setIssuer($hostInfo); try { - $jwt->verify($token, $context); + $token = $component->parseToken($token); } catch (VerificationException $e) { if (StringHelper::startsWith($e->getMessage(), 'Token expired at')) { $message = 'Token expired'; @@ -40,8 +31,8 @@ class AccountIdentity extends Account implements IdentityInterface { } // Если исключение выше не случилось, то значит всё оке - /** @var \Emarref\Jwt\Claim\JwtId $jti */ - $jti = $token->getPayload()->findClaimByName('jti'); + /** @var JwtId $jti */ + $jti = $token->getPayload()->findClaimByName(JwtId::NAME); $account = static::findOne($jti->getValue()); if ($account === null) { throw new UnauthorizedHttpException('Invalid token'); diff --git a/api/models/profile/ChangePasswordForm.php b/api/models/profile/ChangePasswordForm.php index b685250..64bc5f2 100644 --- a/api/models/profile/ChangePasswordForm.php +++ b/api/models/profile/ChangePasswordForm.php @@ -5,6 +5,7 @@ use api\models\base\PasswordProtectedForm; use common\models\Account; use common\validators\PasswordValidate; use Yii; +use yii\base\ErrorException; use yii\helpers\ArrayHelper; class ChangePasswordForm extends PasswordProtectedForm { @@ -20,6 +21,11 @@ class ChangePasswordForm extends PasswordProtectedForm { */ private $_account; + public function __construct(Account $account, array $config = []) { + $this->_account = $account; + parent::__construct($config); + } + /** * @inheritdoc */ @@ -41,34 +47,40 @@ class ChangePasswordForm extends PasswordProtectedForm { } /** - * @return boolean if password was changed. + * @return boolean */ public function changePassword() { if (!$this->validate()) { return false; } + $transaction = Yii::$app->db->beginTransaction(); $account = $this->_account; $account->setPassword($this->newPassword); if ($this->logoutAll) { - // TODO: реализовать процесс разлогинивания всех авторизованных устройств и дописать под это всё тесты + /** @var \api\components\User\Component $userComponent */ + $userComponent = Yii::$app->user; + $sessions = $account->sessions; + $activeSession = $userComponent->getActiveSession(); + foreach ($sessions as $session) { + if (!$activeSession || $activeSession->id !== $session->id) { + $session->delete(); + } + } } - return $account->save(); + if (!$account->save()) { + throw new ErrorException('Cannot save user model'); + } + + $transaction->commit(); + + return true; } protected function getAccount() { return $this->_account; } - /** - * @param Account $account - * @param array $config - */ - public function __construct(Account $account, array $config = []) { - $this->_account = $account; - parent::__construct($config); - } - } diff --git a/tests/codeception/api/unit/components/User/ComponentTest.php b/tests/codeception/api/unit/components/User/ComponentTest.php index 8eb132b..6f666e8 100644 --- a/tests/codeception/api/unit/components/User/ComponentTest.php +++ b/tests/codeception/api/unit/components/User/ComponentTest.php @@ -9,10 +9,14 @@ use Codeception\Specify; use common\models\AccountSession; use Emarref\Jwt\Algorithm\AlgorithmInterface; use Emarref\Jwt\Claim\ClaimInterface; +use Emarref\Jwt\Token; use tests\codeception\api\unit\DbTestCase; use tests\codeception\common\_support\ProtectedCaller; use tests\codeception\common\fixtures\AccountFixture; use tests\codeception\common\fixtures\AccountSessionFixture; +use Yii; +use yii\web\HeaderCollection; +use yii\web\Request; /** * @property AccountFixture $accounts @@ -22,29 +26,14 @@ class ComponentTest extends DbTestCase { use Specify; use ProtectedCaller; - private $originalRemoteHost; - /** * @var Component */ private $component; public function _before() { - $this->originalRemoteHost = $_SERVER['REMOTE_ADDR'] ?? null; - $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; parent::_before(); - - $this->component = new Component([ - 'identityClass' => AccountIdentity::class, - 'enableSession' => false, - 'loginUrl' => null, - 'secret' => 'secret', - ]); - } - - public function _after() { - parent::_after(); - $_SERVER['REMOTE_ADDR'] = $this->originalRemoteHost; + $this->component = new Component($this->getComponentArguments()); } public function fixtures() { @@ -55,6 +44,7 @@ class ComponentTest extends DbTestCase { } public function testLogin() { + $this->mockRequest(); $this->specify('success get LoginResult object without session value', function() { $account = new AccountIdentity(['id' => 1]); $result = $this->component->login($account, false); @@ -78,29 +68,84 @@ class ComponentTest extends DbTestCase { public function testRenew() { $this->specify('success get RenewResult object', function() { + $userIP = '192.168.0.1'; + $this->mockRequest($userIP); /** @var AccountSession $session */ $session = AccountSession::findOne($this->sessions['admin']['id']); $callTime = time(); - $usedRemoteAddr = $_SERVER['REMOTE_ADDR'] ?? null; - $_SERVER['REMOTE_ADDR'] = '192.168.0.1'; $result = $this->component->renew($session); expect($result)->isInstanceOf(RenewResult::class); expect(is_string($result->getJwt()))->true(); expect($result->getIdentity()->getId())->equals($session->account_id); $session->refresh(); expect($session->last_refreshed_at)->greaterOrEquals($callTime); - expect($session->getReadableIp())->equals($_SERVER['REMOTE_ADDR']); - $_SERVER['REMOTE_ADDR'] = $usedRemoteAddr; + expect($session->getReadableIp())->equals($userIP); }); } - public function testGetJWT() { + public function testParseToken() { + $this->mockRequest(); + $this->specify('success get RenewResult object', function() { + $identity = new AccountIdentity(['id' => 1]); + $token = $this->callProtected($this->component, 'createToken', $identity); + $jwt = $this->callProtected($this->component, 'serializeToken', $token); + + expect($this->component->parseToken($jwt))->isInstanceOf(Token::class); + }); + } + + public function testGetActiveSession() { + $this->specify('get used account session', function() { + /** @var AccountIdentity $identity */ + $identity = AccountIdentity::findOne($this->accounts['admin']['id']); + $result = $this->component->login($identity, true); + $this->component->logout(); + + /** @var Component|\PHPUnit_Framework_MockObject_MockObject $component */ + $component = $this->getMock(Component::class, ['getIsGuest'], [$this->getComponentArguments()]); + $component + ->expects($this->any()) + ->method('getIsGuest') + ->will($this->returnValue(false)); + + /** @var HeaderCollection|\PHPUnit_Framework_MockObject_MockObject $headersCollection */ + $headersCollection = $this->getMock(HeaderCollection::class, ['get']); + $headersCollection + ->expects($this->any()) + ->method('get') + ->with($this->equalTo('Authorization')) + ->will($this->returnValue('Bearer ' . $result->getJwt())); + + /** @var Request|\PHPUnit_Framework_MockObject_MockObject $request */ + $request = $this->getMock(Request::class, ['getHeaders']); + $request + ->expects($this->any()) + ->method('getHeaders') + ->will($this->returnValue($headersCollection)); + + Yii::$app->set('request', $request); + + $session = $component->getActiveSession(); + expect($session)->isInstanceOf(AccountSession::class); + expect($session->id)->equals($result->getSession()->id); + }); + } + + public function testSerializeToken() { $this->specify('get string, contained jwt token', function() { - expect($this->component->getJWT(new AccountIdentity(['id' => 1]))) + $token = new Token(); + expect($this->callProtected($this->component, 'serializeToken', $token)) ->regExp('/^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+\/=]*$/'); }); } + public function testCreateToken() { + $this->specify('create token', function() { + expect($this->callProtected($this->component, 'createToken', new AccountIdentity(['id' => 1]))) + ->isInstanceOf(Token::class); + }); + } + public function testGetAlgorithm() { $this->specify('get expected hash algorithm object', function() { expect($this->component->getAlgorithm())->isInstanceOf(AlgorithmInterface::class); @@ -117,4 +162,33 @@ class ComponentTest extends DbTestCase { }); } + /** + * @return \PHPUnit_Framework_MockObject_MockObject + */ + private function mockRequest($userIP = '127.0.0.1') { + $request = $this->getMock(Request::class, ['getHostInfo', 'getUserIP']); + $request + ->expects($this->any()) + ->method('getHostInfo') + ->will($this->returnValue('http://localhost')); + + $request + ->expects($this->any()) + ->method('getUserIP') + ->will($this->returnValue($userIP)); + + Yii::$app->set('request', $request); + + return $request; + } + + private function getComponentArguments() { + return [ + 'identityClass' => AccountIdentity::class, + 'enableSession' => false, + 'loginUrl' => null, + 'secret' => 'secret', + ]; + } + } diff --git a/tests/codeception/api/unit/models/AccountIdentityTest.php b/tests/codeception/api/unit/models/AccountIdentityTest.php index 060728e..fd351e9 100644 --- a/tests/codeception/api/unit/models/AccountIdentityTest.php +++ b/tests/codeception/api/unit/models/AccountIdentityTest.php @@ -5,6 +5,7 @@ use api\models\AccountIdentity; use Codeception\Specify; use Exception; use tests\codeception\api\unit\DbTestCase; +use tests\codeception\common\_support\ProtectedCaller; use tests\codeception\common\fixtures\AccountFixture; use Yii; use yii\web\IdentityInterface; @@ -15,6 +16,7 @@ use yii\web\UnauthorizedHttpException; */ class AccountIdentityTest extends DbTestCase { use Specify; + use ProtectedCaller; public function fixtures() { return [ @@ -51,8 +53,9 @@ class AccountIdentityTest extends DbTestCase { $component = Yii::$app->user; /** @var AccountIdentity $account */ $account = AccountIdentity::findOne($this->accounts['admin']['id']); + $token = $this->callProtected($component, 'createToken', $account); - return $component->getJWT($account); + return $this->callProtected($component, 'serializeToken', $token); } } diff --git a/tests/codeception/api/unit/models/profile/ChangePasswordFormTest.php b/tests/codeception/api/unit/models/profile/ChangePasswordFormTest.php index 90e4550..5c3b2da 100644 --- a/tests/codeception/api/unit/models/profile/ChangePasswordFormTest.php +++ b/tests/codeception/api/unit/models/profile/ChangePasswordFormTest.php @@ -1,25 +1,28 @@ [ - 'class' => AccountFixture::class, - 'dataFile' => '@tests/codeception/common/fixtures/data/accounts.php', - ], + 'accounts' => AccountFixture::class, + 'accountSessions' => AccountSessionFixture::class, ]; } @@ -63,32 +66,73 @@ class ChangePasswordFormTest extends DbTestCase { } public function testChangePassword() { - /** @var Account $account */ - $account = Account::findOne($this->accounts['admin']['id']); - $model = new ChangePasswordForm($account, [ - 'password' => 'password_0', - 'newPassword' => 'my-new-password', - 'newRePassword' => 'my-new-password', - ]); - $this->specify('successfully change password with modern hash strategy', function() use ($model, $account) { + $this->specify('successfully change password with modern hash strategy', function() { + /** @var Account $account */ + $account = Account::findOne($this->accounts['admin']['id']); + $model = new ChangePasswordForm($account, [ + 'password' => 'password_0', + 'newPassword' => 'my-new-password', + 'newRePassword' => 'my-new-password', + ]); + $callTime = time(); expect('form should return true', $model->changePassword())->true(); expect('new password should be successfully stored into account', $account->validatePassword('my-new-password'))->true(); expect('password change time updated', $account->password_changed_at)->greaterOrEquals($callTime); }); - /** @var Account $account */ - $account = Account::findOne($this->accounts['user-with-old-password-type']['id']); - $model = new ChangePasswordForm($account, [ - 'password' => '12345678', - 'newPassword' => 'my-new-password', - 'newRePassword' => 'my-new-password', - ]); - $this->specify('successfully change password with legacy hash strategy', function() use ($model, $account) { + $this->specify('successfully change password with legacy hash strategy', function() { + /** @var Account $account */ + $account = Account::findOne($this->accounts['user-with-old-password-type']['id']); + $model = new ChangePasswordForm($account, [ + 'password' => '12345678', + 'newPassword' => 'my-new-password', + 'newRePassword' => 'my-new-password', + ]); + $callTime = time(); - expect('form should return true', $model->changePassword())->true(); - expect('new password should be successfully stored into account', $account->validatePassword('my-new-password'))->true(); - expect('password change time updated', $account->password_changed_at)->greaterOrEquals($callTime); + expect($model->changePassword())->true(); + expect($account->validatePassword('my-new-password'))->true(); + expect($account->password_changed_at)->greaterOrEquals($callTime); + expect($account->password_hash_strategy)->equals(Account::PASS_HASH_STRATEGY_YII2); + }); + } + + public function testChangePasswordWithLogout() { + /** @var Component|\PHPUnit_Framework_MockObject_MockObject $component */ + $component = $this->getMock(Component::class, ['getActiveSession'], [[ + 'identityClass' => AccountIdentity::class, + 'enableSession' => false, + 'loginUrl' => null, + 'secret' => 'secret', + ]]); + + /** @var AccountSession $session */ + $session = AccountSession::findOne($this->accountSessions['admin2']['id']); + + $component + ->expects($this->any()) + ->method('getActiveSession') + ->will($this->returnValue($session)); + + Yii::$app->set('user', $component); + + $this->specify('change password with removing all session, except current', function() use ($session) { + /** @var Account $account */ + $account = Account::findOne($this->accounts['admin']['id']); + + $model = new ChangePasswordForm($account, [ + 'password' => 'password_0', + 'newPassword' => 'my-new-password', + 'newRePassword' => 'my-new-password', + 'logoutAll' => true, + ]); + + expect($model->changePassword())->true(); + /** @var AccountSession[] $sessions */ + $sessions = $account->getSessions()->all(); + expect(count($sessions))->equals(1); + expect($sessions[0]->id)->equals($session->id); }); } diff --git a/tests/codeception/common/_support/FixtureHelper.php b/tests/codeception/common/_support/FixtureHelper.php index 8631656..0bbf737 100644 --- a/tests/codeception/common/_support/FixtureHelper.php +++ b/tests/codeception/common/_support/FixtureHelper.php @@ -4,6 +4,7 @@ namespace tests\codeception\common\_support; use Codeception\Module; use Codeception\TestCase; use tests\codeception\common\fixtures\AccountFixture; +use tests\codeception\common\fixtures\AccountSessionFixture; use tests\codeception\common\fixtures\EmailActivationFixture; use tests\codeception\common\fixtures\OauthClientFixture; use tests\codeception\common\fixtures\OauthScopeFixture; @@ -46,10 +47,8 @@ class FixtureHelper extends Module { public function fixtures() { return [ - 'accounts' => [ - 'class' => AccountFixture::class, - 'dataFile' => '@tests/codeception/common/fixtures/data/accounts.php', - ], + 'accounts' => AccountFixture::class, + 'accountSessions' => AccountSessionFixture::class, 'emailActivations' => EmailActivationFixture::class, 'oauthClients' => [ 'class' => OauthClientFixture::class, diff --git a/tests/codeception/common/fixtures/data/account-sessions.php b/tests/codeception/common/fixtures/data/account-sessions.php index 5e43bc5..fb9581b 100644 --- a/tests/codeception/common/fixtures/data/account-sessions.php +++ b/tests/codeception/common/fixtures/data/account-sessions.php @@ -8,4 +8,12 @@ return [ 'created_at' => time(), 'last_refreshed_at' => time(), ], + 'admin2' => [ + 'id' => 2, + 'account_id' => 1, + 'refresh_token' => 'RI5CdxTama2ZijwYw03rJAq84M2JzPM3gDeIDGI8', + 'last_used_ip' => ip2long('136.243.88.97'), + 'created_at' => time(), + 'last_refreshed_at' => time(), + ], ];