From bdc96d82c1e3a12d84f0e6b230834a7f64854b6c Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 30 May 2016 02:44:17 +0300 Subject: [PATCH] =?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', + ], + ], +];