From 98c01625d1d8162f06ffa9ec33f028f22c109cdb Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sun, 29 May 2016 23:38:19 +0300 Subject: [PATCH 1/5] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20php?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 5 ++++- docker/php/php.ini | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 docker/php/php.ini diff --git a/Dockerfile b/Dockerfile index 5ba392e..28ea9d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,13 +47,16 @@ RUN chmod a+x /usr/local/bin/composer WORKDIR /var/www/html +# Custorm php configuration +COPY ./docker/php/php.ini /usr/local/etc/php/ + # Copy the working dir to the image's web root COPY . /var/www/html # The following directories are .dockerignored to not pollute the docker images # with local logs and published assets from development. So we need to create # empty dirs and set right permissions inside the container. -RUN mkdir api/runtime api/web/assets console/runtime \ +RUN mkdir -p api/runtime api/web/assets console/runtime \ && chown www-data:www-data api/runtime api/web/assets console/runtime # Expose everything under /var/www (vendor + html) diff --git a/docker/php/php.ini b/docker/php/php.ini new file mode 100644 index 0000000..a9c3fab --- /dev/null +++ b/docker/php/php.ini @@ -0,0 +1,2 @@ +error_reporting = E_ALL; +display_errors = On; From bdc96d82c1e3a12d84f0e6b230834a7f64854b6c Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 30 May 2016 02:44:17 +0300 Subject: [PATCH 2/5] =?UTF-8?q?=D0=A0=D0=B5=D0=BE=D1=80=D0=B3=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=B2=D1=8B=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B0=20JWT=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=85=D0=B0=D0=BD=D0=B8=D0=B7=D0=BC=20=D1=81=D0=BE?= =?UTF-8?q?=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D0=B5?= =?UTF-8?q?=D1=81=D1=81=D0=B8=D0=B9=20=D0=B8=20refresh=5Ftoken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/components/User/Component.php | 102 ++++++++++++++++++ api/components/User/LoginResult.php | 61 +++++++++++ api/config/main.php | 2 + api/controllers/AuthenticationController.php | 14 ++- api/controllers/Controller.php | 2 +- api/controllers/SignupController.php | 7 +- api/models/AccountIdentity.php | 58 ++++++++-- .../authentication/ConfirmEmailForm.php | 6 +- api/models/authentication/LoginForm.php | 25 +++-- .../authentication/RecoverPasswordForm.php | 8 +- api/traits/AccountFinder.php | 10 +- common/models/Account.php | 14 +-- common/models/AccountSession.php | 54 ++++++++++ composer.json | 5 +- .../m160517_151805_account_sessions.php | 24 +++++ environments/dev/api/config/params-local.php | 2 +- .../docker/api/config/params-local.php | 2 +- environments/prod/api/config/params-local.php | 2 +- .../api/_pages/AuthenticationRoute.php | 12 ++- .../api/_support/FunctionalTester.php | 14 ++- .../api/functional/EmailConfirmationCest.php | 2 +- .../codeception/api/functional/LoginCest.php | 17 ++- .../api/functional/RecoverPasswordCest.php | 4 +- .../unit/components/User/ComponentTest.php | 101 +++++++++++++++++ .../api/unit/models/AccountIdentityTest.php | 64 +++++++++++ .../authentication/ConfirmEmailFormTest.php | 6 +- .../models/authentication/LoginFormTest.php | 36 ++++--- .../RecoverPasswordFormTest.php | 5 +- .../api/unit/traits/AccountFinderTest.php | 14 +++ .../common/fixtures/AccountFixture.php | 2 + .../common/fixtures/AccountSessionFixture.php | 17 +++ .../common/fixtures/data/account-sessions.php | 11 ++ .../common/unit/models/AccountSessionTest.php | 35 ++++++ tests/codeception/config/api/config.php | 11 +- 34 files changed, 676 insertions(+), 73 deletions(-) create mode 100644 api/components/User/Component.php create mode 100644 api/components/User/LoginResult.php create mode 100644 common/models/AccountSession.php create mode 100644 console/migrations/m160517_151805_account_sessions.php create mode 100644 tests/codeception/api/unit/components/User/ComponentTest.php create mode 100644 tests/codeception/api/unit/models/AccountIdentityTest.php create mode 100644 tests/codeception/common/fixtures/AccountSessionFixture.php create mode 100644 tests/codeception/common/fixtures/data/account-sessions.php create mode 100644 tests/codeception/common/unit/models/AccountSessionTest.php diff --git a/api/components/User/Component.php b/api/components/User/Component.php new file mode 100644 index 0000000..0965720 --- /dev/null +++ b/api/components/User/Component.php @@ -0,0 +1,102 @@ +secret) { + throw new InvalidConfigException('secret must be specified'); + } + } + + /** + * @param IdentityInterface $identity + * @param bool $rememberMe + * + * @return LoginResult|bool + * @throws ErrorException + */ + public function login(IdentityInterface $identity, $rememberMe = false) { + if (!$this->beforeLogin($identity, false, $rememberMe)) { + return false; + } + + $this->switchIdentity($identity, 0); + + $id = $identity->getId(); + $ip = Yii::$app->request->userIP; + + $jwt = $this->getJWT($identity); + if ($rememberMe) { + $session = new AccountSession(); + $session->account_id = $id; + $session->setIp($ip); + $session->generateRefreshToken(); + if (!$session->save()) { + throw new ErrorException('Cannot save account session model'); + } + } else { + $session = null; + } + + Yii::info("User '{$id}' logged in from {$ip}.", __METHOD__); + + $result = new LoginResult($identity, $jwt, $session); + $this->afterLogin($identity, false, $rememberMe); + + return $result; + } + + public function getJWT(IdentityInterface $identity) { + $jwt = new Jwt(); + $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); + } + + /** + * @param IdentityInterface $identity + * + * @return Claim\AbstractClaim[] + */ + protected function getClaims(IdentityInterface $identity) { + $currentTime = time(); + $hostInfo = Yii::$app->request->hostInfo; + + return [ + new Claim\Audience($hostInfo), + new Claim\Issuer($hostInfo), + new Claim\IssuedAt($currentTime), + new Claim\Expiration($currentTime + $this->expirationTimeout), + new Claim\JwtId($identity->getId()), + ]; + } + +} diff --git a/api/components/User/LoginResult.php b/api/components/User/LoginResult.php new file mode 100644 index 0000000..5c7d394 --- /dev/null +++ b/api/components/User/LoginResult.php @@ -0,0 +1,61 @@ +identity = $identity; + $this->jwt = $jwt; + $this->session = $session; + } + + public function getIdentity() : IdentityInterface { + return $this->identity; + } + + public function getJwt() : string { + return $this->jwt; + } + + /** + * @return AccountSession|null + */ + public function getSession() { + return $this->session; + } + + public function getAsResponse() { + /** @var Component $component */ + $component = Yii::$app->user; + $response = [ + 'access_token' => $this->getJwt(), + 'expires_in' => $component->expirationTimeout, + ]; + + $session = $this->getSession(); + if ($session !== null) { + $response['refresh_token'] = $session->refresh_token; + } + + return $response; + } + +} diff --git a/api/config/main.php b/api/config/main.php index ff3af4c..acf31c0 100644 --- a/api/config/main.php +++ b/api/config/main.php @@ -13,9 +13,11 @@ return [ 'controllerNamespace' => 'api\controllers', 'components' => [ 'user' => [ + 'class' => \api\components\User\Component::class, 'identityClass' => \api\models\AccountIdentity::class, 'enableSession' => false, 'loginUrl' => null, + 'secret' => $params['userSecret'], ], 'log' => [ 'traceLevel' => YII_DEBUG ? 3 : 0, diff --git a/api/controllers/AuthenticationController.php b/api/controllers/AuthenticationController.php index 323c145..4110582 100644 --- a/api/controllers/AuthenticationController.php +++ b/api/controllers/AuthenticationController.php @@ -40,7 +40,7 @@ class AuthenticationController extends Controller { public function actionLogin() { $model = new LoginForm(); $model->load(Yii::$app->request->post()); - if (($jwt = $model->login()) === false) { + if (($result = $model->login()) === false) { $data = [ 'success' => false, 'errors' => $this->normalizeModelErrors($model->getErrors()), @@ -53,10 +53,9 @@ class AuthenticationController extends Controller { return $data; } - return [ + return array_merge([ 'success' => true, - 'jwt' => $jwt, - ]; + ], $result->getAsResponse()); } public function actionForgotPassword() { @@ -98,17 +97,16 @@ class AuthenticationController extends Controller { public function actionRecoverPassword() { $model = new RecoverPasswordForm(); $model->load(Yii::$app->request->post()); - if (($jwt = $model->recoverPassword()) === false) { + if (($result = $model->recoverPassword()) === false) { return [ 'success' => false, 'errors' => $this->normalizeModelErrors($model->getErrors()), ]; } - return [ + return array_merge([ 'success' => true, - 'jwt' => $jwt, - ]; + ], $result->getAsResponse()); } } diff --git a/api/controllers/Controller.php b/api/controllers/Controller.php index b5d716a..1739d20 100644 --- a/api/controllers/Controller.php +++ b/api/controllers/Controller.php @@ -15,7 +15,7 @@ class Controller extends \yii\rest\Controller { $parentBehaviors = parent::behaviors(); // Добавляем авторизатор для входа по jwt токенам $parentBehaviors['authenticator'] = [ - 'class' => HttpBearerAuth::className(), + 'class' => HttpBearerAuth::class, ]; // xml нам не понадобится diff --git a/api/controllers/SignupController.php b/api/controllers/SignupController.php index a981509..996e8ef 100644 --- a/api/controllers/SignupController.php +++ b/api/controllers/SignupController.php @@ -79,17 +79,16 @@ class SignupController extends Controller { public function actionConfirm() { $model = new ConfirmEmailForm(); $model->load(Yii::$app->request->post()); - if (!($jwt = $model->confirm())) { + if (!($result = $model->confirm())) { return [ 'success' => false, 'errors' => $this->normalizeModelErrors($model->getErrors()), ]; } - return [ + return array_merge([ 'success' => true, - 'jwt' => $jwt, - ]; + ], $result->getAsResponse()); } } diff --git a/api/models/AccountIdentity.php b/api/models/AccountIdentity.php index e2f9527..bd26737 100644 --- a/api/models/AccountIdentity.php +++ b/api/models/AccountIdentity.php @@ -2,17 +2,61 @@ namespace api\models; use common\models\Account; +use Emarref\Jwt\Encryption\Factory; +use Emarref\Jwt\Exception\VerificationException; +use Emarref\Jwt\Jwt; +use Emarref\Jwt\Verification\Context as VerificationContext; +use Yii; use yii\base\NotSupportedException; +use yii\helpers\StringHelper; use yii\web\IdentityInterface; +use yii\web\UnauthorizedHttpException; -/** - * @method static findIdentityByAccessToken($token, $type = null) этот метод реализуется в UserTrait, который - * подключён в родительском Account и позволяет выполнить условия интерфейса - * @method string getId() метод реализован в родительском классе, т.к. UserTrait требует, чтобы этот метод - * присутствовал обязательно, но при этом не навязывает его как абстрактный - */ class AccountIdentity extends Account implements IdentityInterface { + /** + * @inheritdoc + */ + public static function findIdentityByAccessToken($token, $type = null) { + $jwt = new Jwt(); + $token = $jwt->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); + } catch (VerificationException $e) { + if (StringHelper::startsWith($e->getMessage(), 'Token expired at')) { + $message = 'Token expired'; + } else { + $message = 'Incorrect token'; + } + + throw new UnauthorizedHttpException($message); + } + + // Если исключение выше не случилось, то значит всё оке + /** @var \Emarref\Jwt\Claim\JwtId $jti */ + $jti = $token->getPayload()->findClaimByName('jti'); + $account = static::findOne($jti->getValue()); + if ($account === null) { + throw new UnauthorizedHttpException('Invalid token'); + } + + return $account; + } + + /** + * @inheritdoc + */ + public function getId() { + return $this->id; + } + /** * @inheritdoc */ @@ -31,7 +75,7 @@ class AccountIdentity extends Account implements IdentityInterface { * @inheritdoc */ public function validateAuthKey($authKey) { - return $this->getAuthKey() === $authKey; + throw new NotSupportedException('This method used for cookie auth, except we using JWT tokens'); } } diff --git a/api/models/authentication/ConfirmEmailForm.php b/api/models/authentication/ConfirmEmailForm.php index c555e2d..0c46680 100644 --- a/api/models/authentication/ConfirmEmailForm.php +++ b/api/models/authentication/ConfirmEmailForm.php @@ -1,6 +1,7 @@ getJWT(); + /** @var \api\components\User\Component $component */ + $component = Yii::$app->user; + + return $component->login(new AccountIdentity($account->attributes), true); } } diff --git a/api/models/authentication/LoginForm.php b/api/models/authentication/LoginForm.php index 5f1a77f..0a24652 100644 --- a/api/models/authentication/LoginForm.php +++ b/api/models/authentication/LoginForm.php @@ -1,17 +1,21 @@ hasErrors()) { - if (!$this->getAccount()) { + if ($this->getAccount() === null) { $this->addError($attribute, 'error.' . $attribute . '_not_exist'); } } @@ -40,7 +44,7 @@ class LoginForm extends ApiForm { public function validatePassword($attribute) { if (!$this->hasErrors()) { $account = $this->getAccount(); - if (!$account || !$account->validatePassword($this->password)) { + if ($account === null || !$account->validatePassword($this->password)) { $this->addError($attribute, 'error.' . $attribute . '_incorrect'); } } @@ -60,24 +64,27 @@ class LoginForm extends ApiForm { } /** - * @return bool|string JWT с информацией об аккаунте + * @return \api\components\User\LoginResult|bool */ public function login() { if (!$this->validate()) { return false; } - if ($this->rememberMe) { - // TODO: здесь нужно записать какую-то - } - $account = $this->getAccount(); if ($account->password_hash_strategy === Account::PASS_HASH_STRATEGY_OLD_ELY) { $account->setPassword($this->password); $account->save(); } - return $account->getJWT(); + /** @var \api\components\User\Component $component */ + $component = Yii::$app->user; + + return $component->login($account, $this->rememberMe); + } + + protected function getAccountClassName() { + return AccountIdentity::class; } } diff --git a/api/models/authentication/RecoverPasswordForm.php b/api/models/authentication/RecoverPasswordForm.php index dd139a9..b90dd2d 100644 --- a/api/models/authentication/RecoverPasswordForm.php +++ b/api/models/authentication/RecoverPasswordForm.php @@ -1,6 +1,7 @@ getJWT(); + /** @var \api\components\User\Component $component */ + $component = Yii::$app->user; + + return $component->login(new AccountIdentity($account->attributes), false); } } diff --git a/api/traits/AccountFinder.php b/api/traits/AccountFinder.php index 5465536..3bc1236 100644 --- a/api/traits/AccountFinder.php +++ b/api/traits/AccountFinder.php @@ -14,7 +14,8 @@ trait AccountFinder { */ public function getAccount() { if ($this->account === null) { - $this->account = Account::findOne([$this->getLoginAttribute() => $this->getLogin()]); + $className = $this->getAccountClassName(); + $this->account = $className::findOne([$this->getLoginAttribute() => $this->getLogin()]); } return $this->account; @@ -24,4 +25,11 @@ trait AccountFinder { return strpos($this->getLogin(), '@') ? 'email' : 'username'; } + /** + * @return Account|string + */ + protected function getAccountClassName() { + return Account::class; + } + } diff --git a/common/models/Account.php b/common/models/Account.php index a1b5bd4..b297e3b 100644 --- a/common/models/Account.php +++ b/common/models/Account.php @@ -3,7 +3,6 @@ namespace common\models; use common\components\UserPass; use common\validators\LanguageValidator; -use damirka\JWT\UserTrait as UserJWTTrait; use Ely\Yii2\TempmailValidator; use Yii; use yii\base\InvalidConfigException; @@ -29,15 +28,14 @@ use yii\db\ActiveRecord; * * Отношения: * @property EmailActivation[] $emailActivations - * @property OauthSession[] $sessions + * @property OauthSession[] $oauthSessions * @property UsernameHistory[] $usernameHistory + * @property AccountSession[] $sessions * * Поведения: * @mixin TimestampBehavior */ class Account extends ActiveRecord { - use UserJWTTrait; - const STATUS_DELETED = -10; const STATUS_REGISTERED = 0; const STATUS_ACTIVE = 10; @@ -121,7 +119,7 @@ class Account extends ActiveRecord { return $this->hasMany(EmailActivation::class, ['account_id' => 'id']); } - public function getSessions() { + public function getOauthSessions() { return $this->hasMany(OauthSession::class, ['owner_id' => 'id']); } @@ -129,6 +127,10 @@ class Account extends ActiveRecord { return $this->hasMany(UsernameHistory::class, ['account_id' => 'id']); } + public function getSessions() { + return $this->hasMany(AccountSession::class, ['account_id' => 'id']); + } + /** * Метод проверяет, может ли текущий пользователь быть автоматически авторизован * для указанного клиента без запроса доступа к необходимому списку прав @@ -144,7 +146,7 @@ class Account extends ActiveRecord { } /** @var OauthSession|null $session */ - $session = $this->getSessions()->andWhere(['client_id' => $client->id])->one(); + $session = $this->getOauthSessions()->andWhere(['client_id' => $client->id])->one(); if ($session !== null) { $existScopes = $session->getScopes()->members(); if (empty(array_diff(array_keys($scopes), $existScopes))) { diff --git a/common/models/AccountSession.php b/common/models/AccountSession.php new file mode 100644 index 0000000..05ad1d5 --- /dev/null +++ b/common/models/AccountSession.php @@ -0,0 +1,54 @@ + TimestampBehavior::class, + 'updatedAtAttribute' => 'last_refreshed_at', + ] + ]; + } + + public function getAccount() { + return $this->hasOne(Account::class, ['id' => 'account_id']); + } + + public function generateRefreshToken() { + $this->refresh_token = Yii::$app->security->generateRandomString(96); + } + + public function setIp($ip) { + $this->last_used_ip = ip2long($ip); + } + + public function getReadableIp() { + return long2ip($this->last_used_ip); + } + +} diff --git a/composer.json b/composer.json index 432dc87..2edb789 100644 --- a/composer.json +++ b/composer.json @@ -16,15 +16,14 @@ "require": { "php": "~7.0.6", "yiisoft/yii2": "~2.0.8", - "yiisoft/yii2-bootstrap": "*", "yiisoft/yii2-swiftmailer": "*", "ramsey/uuid": "~3.1", "league/oauth2-server": "~4.1.5", "yiisoft/yii2-redis": "~2.0.0", - "damirka/yii2-jwt": "~0.1.0", "guzzlehttp/guzzle": "~5.3.0", "php-amqplib/php-amqplib": "~2.6.2", - "ely/yii2-tempmail-validator": "~1.0.0" + "ely/yii2-tempmail-validator": "~1.0.0", + "emarref/jwt": "~1.0.0" }, "require-dev": { "yiisoft/yii2-codeception": "*", diff --git a/console/migrations/m160517_151805_account_sessions.php b/console/migrations/m160517_151805_account_sessions.php new file mode 100644 index 0000000..4cc0fdd --- /dev/null +++ b/console/migrations/m160517_151805_account_sessions.php @@ -0,0 +1,24 @@ +createTable('{{%accounts_sessions}}', [ + 'id' => $this->primaryKey(), + 'account_id' => $this->db->getTableSchema('{{%accounts}}')->getColumn('id')->dbType . ' NOT NULL', + 'refresh_token' => $this->string()->notNull()->unique(), + 'last_used_ip' => $this->integer()->unsigned()->notNull(), + 'created_at' => $this->integer()->notNull(), + 'last_refreshed_at' => $this->integer()->notNull(), + ], $this->tableOptions); + + $this->addForeignKey('FK_account_session_to_account', '{{%accounts_sessions}}', 'account_id', '{{%accounts}}', 'id', 'CASCADE', 'CASCADE'); + } + + public function safeDown() { + $this->dropTable('{{%accounts_sessions}}'); + } + +} diff --git a/environments/dev/api/config/params-local.php b/environments/dev/api/config/params-local.php index 2481e4b..07dfeb8 100644 --- a/environments/dev/api/config/params-local.php +++ b/environments/dev/api/config/params-local.php @@ -1,4 +1,4 @@ 'some-long-secret-key', + 'userSecret' => 'some-long-secret-key', ]; diff --git a/environments/docker/api/config/params-local.php b/environments/docker/api/config/params-local.php index 2481e4b..07dfeb8 100644 --- a/environments/docker/api/config/params-local.php +++ b/environments/docker/api/config/params-local.php @@ -1,4 +1,4 @@ 'some-long-secret-key', + 'userSecret' => 'some-long-secret-key', ]; diff --git a/environments/prod/api/config/params-local.php b/environments/prod/api/config/params-local.php index 2481e4b..07dfeb8 100644 --- a/environments/prod/api/config/params-local.php +++ b/environments/prod/api/config/params-local.php @@ -1,4 +1,4 @@ 'some-long-secret-key', + 'userSecret' => 'some-long-secret-key', ]; diff --git a/tests/codeception/api/_pages/AuthenticationRoute.php b/tests/codeception/api/_pages/AuthenticationRoute.php index 615ec63..31ee65c 100644 --- a/tests/codeception/api/_pages/AuthenticationRoute.php +++ b/tests/codeception/api/_pages/AuthenticationRoute.php @@ -8,12 +8,18 @@ use yii\codeception\BasePage; */ class AuthenticationRoute extends BasePage { - public function login($login = '', $password = '') { + public function login($login = '', $password = '', $rememberMe = false) { $this->route = ['authentication/login']; - $this->actor->sendPOST($this->getUrl(), [ + $params = [ 'login' => $login, 'password' => $password, - ]); + ]; + + if ($rememberMe) { + $params['rememberMe'] = 1; + } + + $this->actor->sendPOST($this->getUrl(), $params); } public function forgotPassword($login = '') { diff --git a/tests/codeception/api/_support/FunctionalTester.php b/tests/codeception/api/_support/FunctionalTester.php index 7ba0f4d..cc985c6 100644 --- a/tests/codeception/api/_support/FunctionalTester.php +++ b/tests/codeception/api/_support/FunctionalTester.php @@ -34,8 +34,8 @@ class FunctionalTester extends Actor { } $this->canSeeResponseIsJson(); - $this->canSeeResponseJsonMatchesJsonPath('$.jwt'); - $jwt = $this->grabDataFromResponseByJsonPath('$.jwt')[0]; + $this->canSeeAuthCredentials(false); + $jwt = $this->grabDataFromResponseByJsonPath('$.access_token')[0]; $this->amBearerAuthenticated($jwt); } @@ -43,4 +43,14 @@ class FunctionalTester extends Actor { $this->haveHttpHeader('Authorization', null); } + public function canSeeAuthCredentials($expectRefresh = false) { + $this->canSeeResponseJsonMatchesJsonPath('$.access_token'); + $this->canSeeResponseJsonMatchesJsonPath('$.expires_in'); + if ($expectRefresh) { + $this->canSeeResponseJsonMatchesJsonPath('$.refresh_token'); + } else { + $this->cantSeeResponseJsonMatchesJsonPath('$.refresh_token'); + } + } + } diff --git a/tests/codeception/api/functional/EmailConfirmationCest.php b/tests/codeception/api/functional/EmailConfirmationCest.php index 0223d44..1fe0dc0 100644 --- a/tests/codeception/api/functional/EmailConfirmationCest.php +++ b/tests/codeception/api/functional/EmailConfirmationCest.php @@ -36,7 +36,7 @@ class EmailConfirmationCest { 'success' => true, ]); $I->cantSeeResponseJsonMatchesJsonPath('$.errors'); - $I->canSeeResponseJsonMatchesJsonPath('$.jwt'); + $I->canSeeAuthCredentials(true); } } diff --git a/tests/codeception/api/functional/LoginCest.php b/tests/codeception/api/functional/LoginCest.php index de5b79a..4c39e63 100644 --- a/tests/codeception/api/functional/LoginCest.php +++ b/tests/codeception/api/functional/LoginCest.php @@ -111,8 +111,8 @@ class LoginCest { $I->canSeeResponseContainsJson([ 'success' => true, ]); - $I->canSeeResponseJsonMatchesJsonPath('$.jwt'); $I->cantSeeResponseJsonMatchesJsonPath('$.errors'); + $I->canSeeAuthCredentials(false); } public function testLoginByEmailCorrect(FunctionalTester $I) { @@ -124,6 +124,7 @@ class LoginCest { 'success' => true, ]); $I->cantSeeResponseJsonMatchesJsonPath('$.errors'); + $I->canSeeAuthCredentials(false); } public function testLoginInAccWithPasswordMethod(FunctionalTester $I) { @@ -134,8 +135,20 @@ class LoginCest { $I->canSeeResponseContainsJson([ 'success' => true, ]); - $I->canSeeResponseJsonMatchesJsonPath('$.jwt'); $I->cantSeeResponseJsonMatchesJsonPath('$.errors'); + $I->canSeeAuthCredentials(false); + } + + public function testLoginByEmailWithRemember(FunctionalTester $I) { + $route = new AuthenticationRoute($I); + + $I->wantTo('login into account using correct data and get refresh_token'); + $route->login('admin@ely.by', 'password_0', true); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + $I->cantSeeResponseJsonMatchesJsonPath('$.errors'); + $I->canSeeAuthCredentials(true); } } diff --git a/tests/codeception/api/functional/RecoverPasswordCest.php b/tests/codeception/api/functional/RecoverPasswordCest.php index 6060896..6bd797f 100644 --- a/tests/codeception/api/functional/RecoverPasswordCest.php +++ b/tests/codeception/api/functional/RecoverPasswordCest.php @@ -15,10 +15,10 @@ class RecoverPasswordCest { $I->canSeeResponseContainsJson([ 'success' => true, ]); - $I->canSeeResponseJsonMatchesJsonPath('$.jwt'); + $I->canSeeAuthCredentials(false); $I->wantTo('ensure, that jwt token is valid'); - $jwt = $I->grabDataFromResponseByJsonPath('$.jwt')[0]; + $jwt = $I->grabDataFromResponseByJsonPath('$.access_token')[0]; $I->amBearerAuthenticated($jwt); $accountRoute = new AccountsRoute($I); $accountRoute->current(); diff --git a/tests/codeception/api/unit/components/User/ComponentTest.php b/tests/codeception/api/unit/components/User/ComponentTest.php new file mode 100644 index 0000000..cdb8c93 --- /dev/null +++ b/tests/codeception/api/unit/components/User/ComponentTest.php @@ -0,0 +1,101 @@ +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; + } + + public function fixtures() { + return [ + 'accounts' => AccountFixture::class, + 'sessions' => AccountSessionFixture::class, + ]; + } + + public function testLogin() { + $this->specify('success get LoginResult object without session value', function() { + $account = new AccountIdentity(['id' => 1]); + $result = $this->component->login($account, false); + expect($result)->isInstanceOf(LoginResult::class); + expect($result->getSession())->null(); + expect(is_string($result->getJwt()))->true(); + expect($result->getIdentity())->equals($account); + }); + + $this->specify('success get LoginResult object with session value if rememberMe is true', function() { + /** @var AccountIdentity $account */ + $account = AccountIdentity::findOne($this->accounts['admin']['id']); + $result = $this->component->login($account, true); + expect($result)->isInstanceOf(LoginResult::class); + expect($result->getSession())->isInstanceOf(AccountSession::class); + expect(is_string($result->getJwt()))->true(); + expect($result->getIdentity())->equals($account); + expect($result->getSession()->refresh())->true(); + }); + } + + public function testGetJWT() { + $this->specify('get string, contained jwt token', function() { + expect($this->component->getJWT(new AccountIdentity(['id' => 1]))) + ->regExp('/^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+\/=]*$/'); + }); + } + + public function testGetAlgorithm() { + $this->specify('get expected hash algorithm object', function() { + expect($this->component->getAlgorithm())->isInstanceOf(AlgorithmInterface::class); + }); + } + + public function testGetClaims() { + $this->specify('get expected array of claims', function() { + $claims = $this->callProtected($this->component, 'getClaims', new AccountIdentity(['id' => 1])); + expect(is_array($claims))->true(); + expect('all array items should have valid type', array_filter($claims, function($claim) { + return !$claim instanceof ClaimInterface; + }))->isEmpty(); + }); + } + +} diff --git a/tests/codeception/api/unit/models/AccountIdentityTest.php b/tests/codeception/api/unit/models/AccountIdentityTest.php new file mode 100644 index 0000000..fd3973c --- /dev/null +++ b/tests/codeception/api/unit/models/AccountIdentityTest.php @@ -0,0 +1,64 @@ + [ + 'class' => AccountFixture::class, + 'dataFile' => '@tests/codeception/common/fixtures/data/accounts.php', + ], + ]; + } + + public function testFindIdentityByAccessToken() { + $this->specify('success validate passed jwt token', function() { + $identity = AccountIdentity::findIdentityByAccessToken($this->generateToken()); + expect($identity)->isInstanceOf(IdentityInterface::class); + expect($identity->getId())->equals($this->accounts['admin']['id']); + }); + + // TODO: нормально оттестить исключение, если токен истёк + return; + + $this->specify('get unauthorized with "Token expired message if token valid, but expire"', function() { + $originalTimezone = date_default_timezone_get(); + date_default_timezone_set('America/Los_Angeles'); + try { + $token = $this->generateToken(); + date_default_timezone_set($originalTimezone); + AccountIdentity::findIdentityByAccessToken($token); + } catch (Exception $e) { + expect($e)->isInstanceOf(UnauthorizedHttpException::class); + expect($e->getMessage())->equals('Token expired'); + return; + } + + expect('if test valid, this should not happened', false)->true(); + }); + } + + protected function generateToken() { + /** @var \api\components\User\Component $component */ + $component = Yii::$app->user; + /** @var AccountIdentity $account */ + $account = AccountIdentity::findOne($this->accounts['admin']['id']); + + return $component->getJWT($account); + } + +} diff --git a/tests/codeception/api/unit/models/authentication/ConfirmEmailFormTest.php b/tests/codeception/api/unit/models/authentication/ConfirmEmailFormTest.php index 03aec1a..4e62a6c 100644 --- a/tests/codeception/api/unit/models/authentication/ConfirmEmailFormTest.php +++ b/tests/codeception/api/unit/models/authentication/ConfirmEmailFormTest.php @@ -1,9 +1,11 @@ emailActivations['freshRegistrationConfirmation']; $model = $this->createModel($fixture['key']); $this->specify('expect true result', function() use ($model, $fixture) { - expect('model return successful result', $model->confirm())->notEquals(false); + $result = $model->confirm(); + expect($result)->isInstanceOf(LoginResult::class); + expect('session was generated', $result->getSession())->isInstanceOf(AccountSession::class); $activationExists = EmailActivation::find()->andWhere(['key' => $fixture['key']])->exists(); expect('email activation key is not exist', $activationExists)->false(); /** @var Account $user */ diff --git a/tests/codeception/api/unit/models/authentication/LoginFormTest.php b/tests/codeception/api/unit/models/authentication/LoginFormTest.php index dfc134d..372653e 100644 --- a/tests/codeception/api/unit/models/authentication/LoginFormTest.php +++ b/tests/codeception/api/unit/models/authentication/LoginFormTest.php @@ -1,6 +1,8 @@ originalRemoteAddr = $_SERVER['REMOTE_ADDR'] ?? null; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + parent::setUp(); + } + + public function tearDown() { + parent::tearDown(); + $_SERVER['REMOTE_ADDR'] = $this->originalRemoteAddr; + } + public function fixtures() { return [ - 'accounts' => [ - 'class' => AccountFixture::class, - 'dataFile' => '@tests/codeception/common/fixtures/data/accounts.php', - ], + 'accounts' => AccountFixture::class, ]; } @@ -36,7 +48,7 @@ class LoginFormTest extends DbTestCase { $this->specify('no errors if login exists', function () { $model = $this->createModel([ 'login' => 'mr-test', - 'account' => new Account(), + 'account' => new AccountIdentity(), ]); $model->validateLogin('login'); expect($model->getErrors('login'))->isEmpty(); @@ -47,7 +59,7 @@ class LoginFormTest extends DbTestCase { $this->specify('error.password_incorrect if password invalid', function () { $model = $this->createModel([ 'password' => '87654321', - 'account' => new Account(['password' => '12345678']), + 'account' => new AccountIdentity(['password' => '12345678']), ]); $model->validatePassword('password'); expect($model->getErrors('password'))->equals(['error.password_incorrect']); @@ -56,7 +68,7 @@ class LoginFormTest extends DbTestCase { $this->specify('no errors if password valid', function () { $model = $this->createModel([ 'password' => '12345678', - 'account' => new Account(['password' => '12345678']), + 'account' => new AccountIdentity(['password' => '12345678']), ]); $model->validatePassword('password'); expect($model->getErrors('password'))->isEmpty(); @@ -66,7 +78,7 @@ class LoginFormTest extends DbTestCase { public function testValidateActivity() { $this->specify('error.account_not_activated if account in not activated state', function () { $model = $this->createModel([ - 'account' => new Account(['status' => Account::STATUS_REGISTERED]), + 'account' => new AccountIdentity(['status' => Account::STATUS_REGISTERED]), ]); $model->validateActivity('login'); expect($model->getErrors('login'))->equals(['error.account_not_activated']); @@ -74,7 +86,7 @@ class LoginFormTest extends DbTestCase { $this->specify('no errors if account active', function () { $model = $this->createModel([ - 'account' => new Account(['status' => Account::STATUS_ACTIVE]), + 'account' => new AccountIdentity(['status' => Account::STATUS_ACTIVE]), ]); $model->validateActivity('login'); expect($model->getErrors('login'))->isEmpty(); @@ -86,13 +98,13 @@ class LoginFormTest extends DbTestCase { $model = $this->createModel([ 'login' => 'erickskrauch', 'password' => '12345678', - 'account' => new Account([ + 'account' => new AccountIdentity([ 'username' => 'erickskrauch', 'password' => '12345678', 'status' => Account::STATUS_ACTIVE, ]), ]); - expect('model should login user', $model->login())->notEquals(false); + expect('model should login user', $model->login())->isInstanceOf(LoginResult::class); expect('error message should not be set', $model->errors)->isEmpty(); }); } @@ -103,7 +115,7 @@ class LoginFormTest extends DbTestCase { 'login' => $this->accounts['user-with-old-password-type']['username'], 'password' => '12345678', ]); - expect($model->login())->notEquals(false); + expect($model->login())->isInstanceOf(LoginResult::class); expect($model->errors)->isEmpty(); expect($model->getAccount()->password_hash_strategy)->equals(Account::PASS_HASH_STRATEGY_YII2); }); diff --git a/tests/codeception/api/unit/models/authentication/RecoverPasswordFormTest.php b/tests/codeception/api/unit/models/authentication/RecoverPasswordFormTest.php index e06530c..3e5aec7 100644 --- a/tests/codeception/api/unit/models/authentication/RecoverPasswordFormTest.php +++ b/tests/codeception/api/unit/models/authentication/RecoverPasswordFormTest.php @@ -1,6 +1,7 @@ '12345678', 'newRePassword' => '12345678', ]); - expect($model->recoverPassword())->notEquals(false); + $result = $model->recoverPassword(); + expect($result)->isInstanceOf(LoginResult::class); + expect('session was not generated', $result->getSession())->null(); $activationExists = EmailActivation::find()->andWhere(['key' => $fixture['key']])->exists(); expect($activationExists)->false(); /** @var Account $account */ diff --git a/tests/codeception/api/unit/traits/AccountFinderTest.php b/tests/codeception/api/unit/traits/AccountFinderTest.php index 6fb913b..2b09d42 100644 --- a/tests/codeception/api/unit/traits/AccountFinderTest.php +++ b/tests/codeception/api/unit/traits/AccountFinderTest.php @@ -1,6 +1,7 @@ id)->equals($this->accounts['admin']['id']); }); + $this->specify('founded account for passed login data with changed account model class name', function() { + /** @var AccountFinderTestTestClass $model */ + $model = new class extends AccountFinderTestTestClass { + protected function getAccountClassName() { + return AccountIdentity::class; + } + }; + $model->login = $this->accounts['admin']['email']; + $account = $model->getAccount(); + expect($account)->isInstanceOf(AccountIdentity::class); + expect($account->id)->equals($this->accounts['admin']['id']); + }); + $this->specify('null, if account not founded', function() { $model = new AccountFinderTestTestClass(); $model->login = 'unexpected'; diff --git a/tests/codeception/common/fixtures/AccountFixture.php b/tests/codeception/common/fixtures/AccountFixture.php index 9122747..239bfa4 100644 --- a/tests/codeception/common/fixtures/AccountFixture.php +++ b/tests/codeception/common/fixtures/AccountFixture.php @@ -8,4 +8,6 @@ class AccountFixture extends ActiveFixture { public $modelClass = Account::class; + public $dataFile = '@tests/codeception/common/fixtures/data/accounts.php'; + } diff --git a/tests/codeception/common/fixtures/AccountSessionFixture.php b/tests/codeception/common/fixtures/AccountSessionFixture.php new file mode 100644 index 0000000..fb066a6 --- /dev/null +++ b/tests/codeception/common/fixtures/AccountSessionFixture.php @@ -0,0 +1,17 @@ + [ + 'id' => 1, + 'account_id' => 1, + 'refresh_token' => 'SOutIr6Seeaii3uqMVy3Wan8sKFVFrNz', + 'last_used_ip' => ip2long('127.0.0.1'), + 'created_at' => time(), + 'last_refreshed_at' => time(), + ], +]; diff --git a/tests/codeception/common/unit/models/AccountSessionTest.php b/tests/codeception/common/unit/models/AccountSessionTest.php new file mode 100644 index 0000000..66a8f14 --- /dev/null +++ b/tests/codeception/common/unit/models/AccountSessionTest.php @@ -0,0 +1,35 @@ +specify('method call will set refresh_token value', function() { + $model = new AccountSession(); + $model->generateRefreshToken(); + expect($model->refresh_token)->notNull(); + }); + } + + public function testSetIp() { + $this->specify('method should convert passed ip string to long', function() { + $model = new AccountSession(); + $model->setIp('127.0.0.1'); + expect($model->last_used_ip)->equals(2130706433); + }); + } + + public function testGetReadableIp() { + $this->specify('method should convert stored ip long into readable ip string', function() { + $model = new AccountSession(); + $model->last_used_ip = 2130706433; + expect($model->getReadableIp())->equals('127.0.0.1'); + }); + } + +} diff --git a/tests/codeception/config/api/config.php b/tests/codeception/config/api/config.php index e0a28e6..564ecf0 100644 --- a/tests/codeception/config/api/config.php +++ b/tests/codeception/config/api/config.php @@ -1,5 +1,8 @@ [ + 'user' => [ + 'secret' => 'tests-secret-key', + ], + ], +]; From cb038c897b96269520fd33cf1cb8fa14cb0f2b70 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 30 May 2016 21:11:22 +0300 Subject: [PATCH 3/5] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0=20=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B5=D1=80=D0=B0=20=D0=BE=20=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D1=91=D0=BA=D1=88=D0=B5=D0=BC=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B?= =?UTF-8?q?=20doc-=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B2?= =?UTF-8?q?=20=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D0=BE=D0=BC=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D1=80=D0=BE=D0=BB=D0=BB=D0=B5=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/controllers/Controller.php | 5 +++++ .../api/functional/AccountsCurrentCest.php | 18 ++++++++++++++++++ .../api/unit/models/AccountIdentityTest.php | 18 ++++++------------ 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/api/controllers/Controller.php b/api/controllers/Controller.php index 1739d20..c60e326 100644 --- a/api/controllers/Controller.php +++ b/api/controllers/Controller.php @@ -7,6 +7,11 @@ use yii\filters\auth\HttpBearerAuth; /** * @property \common\models\Account|null $account + * + * Поведения: + * @mixin \yii\filters\ContentNegotiator + * @mixin \yii\filters\VerbFilter + * @mixin \yii\filters\auth\CompositeAuth */ class Controller extends \yii\rest\Controller { use ApiNormalize; diff --git a/tests/codeception/api/functional/AccountsCurrentCest.php b/tests/codeception/api/functional/AccountsCurrentCest.php index 05d102b..114826f 100644 --- a/tests/codeception/api/functional/AccountsCurrentCest.php +++ b/tests/codeception/api/functional/AccountsCurrentCest.php @@ -34,4 +34,22 @@ class AccountsCurrentCest { $I->canSeeResponseJsonMatchesJsonPath('$.passwordChangedAt'); } + public function testExpiredCurrent(FunctionalTester $I) { + // Устанавливаем заведомо истёкший токен + $I->amBearerAuthenticated( + 'eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJodHRwOlwvXC9sb2NhbGhvc3QiLCJpc3MiOiJodHRwOlwvXC9sb2NhbGhvc3QiLCJpYXQiO' . + 'jE0NjQ2Mjc1NDUsImV4cCI6MTQ2NDYzMTE0NSwianRpIjoxfQ.9c1mm0BK-cuW1qh15F12s2Fh37IN43YeeZeU4DFtlrE' + ); + + $this->route->current(); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'name' => 'Unauthorized', + 'message' => 'Token expired', + 'code' => 0, + 'status' => 401, + ]); + } + } diff --git a/tests/codeception/api/unit/models/AccountIdentityTest.php b/tests/codeception/api/unit/models/AccountIdentityTest.php index fd3973c..060728e 100644 --- a/tests/codeception/api/unit/models/AccountIdentityTest.php +++ b/tests/codeception/api/unit/models/AccountIdentityTest.php @@ -18,10 +18,7 @@ class AccountIdentityTest extends DbTestCase { public function fixtures() { return [ - 'accounts' => [ - 'class' => AccountFixture::class, - 'dataFile' => '@tests/codeception/common/fixtures/data/accounts.php', - ], + 'accounts' => AccountFixture::class, ]; } @@ -32,16 +29,13 @@ class AccountIdentityTest extends DbTestCase { expect($identity->getId())->equals($this->accounts['admin']['id']); }); - // TODO: нормально оттестить исключение, если токен истёк - return; + $this->specify('get unauthorized exception with "Token expired" message if token valid, but expire', function() { + $expiredToken = 'eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJodHRwOlwvXC9sb2NhbGhvc3Q6ODA4MCIsImlzcyI6Imh0d' . + 'HA6XC9cL2xvY2FsaG9zdDo4MDgwIiwiaWF0IjoxNDY0NTkzMTkzLCJleHAiOjE0NjQ1OTY3OTN9.DV' . + '8uwh0OQhBYXkrNvxwJeO-kEjb9MQeLr3-6GoHM7RY'; - $this->specify('get unauthorized with "Token expired message if token valid, but expire"', function() { - $originalTimezone = date_default_timezone_get(); - date_default_timezone_set('America/Los_Angeles'); try { - $token = $this->generateToken(); - date_default_timezone_set($originalTimezone); - AccountIdentity::findIdentityByAccessToken($token); + AccountIdentity::findIdentityByAccessToken($expiredToken); } catch (Exception $e) { expect($e)->isInstanceOf(UnauthorizedHttpException::class); expect($e->getMessage())->equals('Token expired'); From 1945a7baecd0d3528a805f74e505c53891591676 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Tue, 31 May 2016 01:03:30 +0300 Subject: [PATCH 4/5] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D1=80=D0=BE=D1=83=D1=82=20=D0=B8=20=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20access=5Ftoken=20?= =?UTF-8?q?=D0=BF=D0=BE=20refresh=5Ftoken'=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/components/User/Component.php | 25 ++++++++ api/components/User/RenewResult.php | 42 ++++++++++++++ api/controllers/AuthenticationController.php | 21 ++++++- .../authentication/RefreshTokenForm.php | 58 +++++++++++++++++++ common/models/AccountSession.php | 2 +- .../api/_pages/AuthenticationRoute.php | 7 +++ .../api/functional/RefreshTokenCest.php | 32 ++++++++++ .../unit/components/User/ComponentTest.php | 19 ++++++ .../authentication/RefreshTokenFormTest.php | 55 ++++++++++++++++++ 9 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 api/components/User/RenewResult.php create mode 100644 api/models/authentication/RefreshTokenForm.php create mode 100644 tests/codeception/api/functional/RefreshTokenCest.php create mode 100644 tests/codeception/api/unit/models/authentication/RefreshTokenFormTest.php diff --git a/api/components/User/Component.php b/api/components/User/Component.php index 0965720..09be245 100644 --- a/api/components/User/Component.php +++ b/api/components/User/Component.php @@ -1,6 +1,7 @@ account; + $transaction = Yii::$app->db->beginTransaction(); + try { + $identity = new AccountIdentity($account->attributes); + $jwt = $this->getJWT($identity); + + $result = new RenewResult($identity, $jwt); + + $session->setIp(Yii::$app->request->userIP); + $session->last_refreshed_at = time(); + if (!$session->save()) { + throw new ErrorException('Cannot update session info'); + } + + $transaction->commit(); + } catch (ErrorException $e) { + $transaction->rollBack(); + throw $e; + } + + return $result; + } + public function getJWT(IdentityInterface $identity) { $jwt = new Jwt(); $token = new Token(); diff --git a/api/components/User/RenewResult.php b/api/components/User/RenewResult.php new file mode 100644 index 0000000..71c81ca --- /dev/null +++ b/api/components/User/RenewResult.php @@ -0,0 +1,42 @@ +identity = $identity; + $this->jwt = $jwt; + } + + public function getIdentity() : IdentityInterface { + return $this->identity; + } + + public function getJwt() : string { + return $this->jwt; + } + + public function getAsResponse() { + /** @var Component $component */ + $component = Yii::$app->user; + + return [ + 'access_token' => $this->getJwt(), + 'expires_in' => $component->expirationTimeout, + ]; + } + +} diff --git a/api/controllers/AuthenticationController.php b/api/controllers/AuthenticationController.php index 4110582..125066b 100644 --- a/api/controllers/AuthenticationController.php +++ b/api/controllers/AuthenticationController.php @@ -4,6 +4,7 @@ namespace api\controllers; use api\models\authentication\ForgotPasswordForm; use api\models\authentication\LoginForm; use api\models\authentication\RecoverPasswordForm; +use api\models\authentication\RefreshTokenForm; use common\helpers\StringHelper; use Yii; use yii\filters\AccessControl; @@ -14,13 +15,13 @@ class AuthenticationController extends Controller { public function behaviors() { return ArrayHelper::merge(parent::behaviors(), [ 'authenticator' => [ - 'except' => ['login', 'forgot-password', 'recover-password'], + 'except' => ['login', 'forgot-password', 'recover-password', 'refresh-token'], ], 'access' => [ 'class' => AccessControl::class, 'rules' => [ [ - 'actions' => ['login', 'forgot-password', 'recover-password'], + 'actions' => ['login', 'forgot-password', 'recover-password', 'refresh-token'], 'allow' => true, 'roles' => ['?'], ], @@ -34,6 +35,7 @@ class AuthenticationController extends Controller { 'login' => ['POST'], 'forgot-password' => ['POST'], 'recover-password' => ['POST'], + 'refresh-token' => ['POST'], ]; } @@ -109,4 +111,19 @@ class AuthenticationController extends Controller { ], $result->getAsResponse()); } + public function actionRefreshToken() { + $model = new RefreshTokenForm(); + $model->load(Yii::$app->request->post()); + if (($result = $model->renew()) === false) { + return [ + 'success' => false, + 'errors' => $this->normalizeModelErrors($model->getErrors()), + ]; + } + + return array_merge([ + 'success' => true, + ], $result->getAsResponse()); + } + } diff --git a/api/models/authentication/RefreshTokenForm.php b/api/models/authentication/RefreshTokenForm.php new file mode 100644 index 0000000..e637e98 --- /dev/null +++ b/api/models/authentication/RefreshTokenForm.php @@ -0,0 +1,58 @@ +hasErrors()) { + /** @var AccountSession|null $token */ + if ($this->getSession() === null) { + $this->addError('refresh_token', 'error.refresh_token_not_exist'); + } + } + } + + /** + * @return \api\components\User\RenewResult|bool + */ + public function renew() { + if (!$this->validate()) { + return false; + } + + /** @var \api\components\User\Component $component */ + $component = Yii::$app->user; + + return $component->renew($this->getSession()); + } + + /** + * @return AccountSession|null + */ + public function getSession() { + if ($this->session === null) { + $this->session = AccountSession::findOne(['refresh_token' => $this->refresh_token]); + } + + return $this->session; + } + +} diff --git a/common/models/AccountSession.php b/common/models/AccountSession.php index 05ad1d5..b165802 100644 --- a/common/models/AccountSession.php +++ b/common/models/AccountSession.php @@ -12,7 +12,7 @@ use yii\db\ActiveRecord; * @property string $refresh_token * @property integer $last_used_ip * @property integer $created_at - * @property integer $last_refreshed + * @property integer $last_refreshed_at * * Отношения: * @property Account $account diff --git a/tests/codeception/api/_pages/AuthenticationRoute.php b/tests/codeception/api/_pages/AuthenticationRoute.php index 31ee65c..1b0d448 100644 --- a/tests/codeception/api/_pages/AuthenticationRoute.php +++ b/tests/codeception/api/_pages/AuthenticationRoute.php @@ -38,4 +38,11 @@ class AuthenticationRoute extends BasePage { ]); } + public function refreshToken($refreshToken = null) { + $this->route = ['authentication/refresh-token']; + $this->actor->sendPOST($this->getUrl(), [ + 'refresh_token' => $refreshToken, + ]); + } + } diff --git a/tests/codeception/api/functional/RefreshTokenCest.php b/tests/codeception/api/functional/RefreshTokenCest.php new file mode 100644 index 0000000..b0df007 --- /dev/null +++ b/tests/codeception/api/functional/RefreshTokenCest.php @@ -0,0 +1,32 @@ +wantTo('get error.refresh_token_not_exist if passed token is invalid'); + $route->refreshToken('invalid-token'); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'refresh_token' => 'error.refresh_token_not_exist', + ], + ]); + } + + public function testRefreshToken(FunctionalTester $I) { + $route = new AuthenticationRoute($I); + + $I->wantTo('get new access_token by my refresh_token'); + $route->refreshToken('SOutIr6Seeaii3uqMVy3Wan8sKFVFrNz'); + $I->canSeeResponseCodeIs(200); + $I->canSeeAuthCredentials(false); + } + +} diff --git a/tests/codeception/api/unit/components/User/ComponentTest.php b/tests/codeception/api/unit/components/User/ComponentTest.php index cdb8c93..8eb132b 100644 --- a/tests/codeception/api/unit/components/User/ComponentTest.php +++ b/tests/codeception/api/unit/components/User/ComponentTest.php @@ -3,6 +3,7 @@ namespace codeception\api\unit\components\User; use api\components\User\Component; use api\components\User\LoginResult; +use api\components\User\RenewResult; use api\models\AccountIdentity; use Codeception\Specify; use common\models\AccountSession; @@ -75,6 +76,24 @@ class ComponentTest extends DbTestCase { }); } + public function testRenew() { + $this->specify('success get RenewResult object', function() { + /** @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; + }); + } + public function testGetJWT() { $this->specify('get string, contained jwt token', function() { expect($this->component->getJWT(new AccountIdentity(['id' => 1]))) diff --git a/tests/codeception/api/unit/models/authentication/RefreshTokenFormTest.php b/tests/codeception/api/unit/models/authentication/RefreshTokenFormTest.php new file mode 100644 index 0000000..f12db8a --- /dev/null +++ b/tests/codeception/api/unit/models/authentication/RefreshTokenFormTest.php @@ -0,0 +1,55 @@ + AccountSessionFixture::class, + ]; + } + + public function testValidateRefreshToken() { + $this->specify('error.refresh_token_not_exist if passed token not exists', function() { + /** @var RefreshTokenForm $model */ + $model = new class extends RefreshTokenForm { + public function getSession() { + return null; + } + }; + $model->validateRefreshToken(); + expect($model->getErrors('refresh_token'))->equals(['error.refresh_token_not_exist']); + }); + + $this->specify('no errors if token exists', function() { + /** @var RefreshTokenForm $model */ + $model = new class extends RefreshTokenForm { + public function getSession() { + return new AccountSession(); + } + }; + $model->validateRefreshToken(); + expect($model->getErrors('refresh_token'))->isEmpty(); + }); + } + + public function testRenew() { + $this->specify('success renew token', function() { + $model = new RefreshTokenForm(); + $model->refresh_token = $this->sessions['admin']['refresh_token']; + expect($model->renew())->isInstanceOf(RenewResult::class); + }); + } + +} From 113b9f98d82576a3aa370703418d77168adb02bd Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sun, 5 Jun 2016 17:01:35 +0300 Subject: [PATCH 5/5] =?UTF-8?q?=D0=9E=D1=82=D1=80=D0=B5=D1=84=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B5=D0=BD=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D1=82=20User\Component=20=D0=94=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4?= =?UTF-8?q?=20getActiveSession=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B8?= =?UTF-8?q?=20"=D0=A0=D0=B0=D0=B7=D0=BB=D0=BE=D0=B3=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B2=D1=81=D0=B5=D1=85"=20=D0=B2=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D0=B5=20=D1=81=D0=BC=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/components/User/Component.php | 90 +++++++++++-- api/components/User/SessionIdClaim.php | 17 +++ api/models/AccountIdentity.php | 19 +-- api/models/profile/ChangePasswordForm.php | 36 ++++-- .../unit/components/User/ComponentTest.php | 118 ++++++++++++++---- .../api/unit/models/AccountIdentityTest.php | 5 +- .../models/profile/ChangePasswordFormTest.php | 90 +++++++++---- .../common/_support/FixtureHelper.php | 7 +- .../common/fixtures/data/account-sessions.php | 8 ++ 9 files changed, 301 insertions(+), 89 deletions(-) create mode 100644 api/components/User/SessionIdClaim.php 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(), + ], ];