From d85451567a30810974bd0657b60d0857a75be87f Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sun, 25 Dec 2016 21:21:04 +0300 Subject: [PATCH 01/23] 1.1.6-dev [skip ci] --- common/config/config.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/config/config.php b/common/config/config.php index aa068c7..39ad823 100644 --- a/common/config/config.php +++ b/common/config/config.php @@ -1,6 +1,6 @@ '1.1.5', + 'version' => '1.1.6-dev', 'vendorPath' => dirname(dirname(__DIR__)) . '/vendor', 'components' => [ 'cache' => [ From 213782ff62beb27f4cb4512c3d4f336b220bd599 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Fri, 9 Dec 2016 23:42:07 +0300 Subject: [PATCH 02/23] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B4=D0=BB=D1=8F=20"=D0=B2=D0=BD=D1=83=D1=82?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D0=BD=D0=B8=D1=85"=20scopes,=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=B8=D1=82=D1=8C=20=D0=BA=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D1=8B=D0=B5=20=D0=B2=D0=BE=20=D0=B2=D1=80=D0=B5?= =?UTF-8?q?=D0=BC=D1=8F=20oauth=20=D0=BF=D1=80=D0=BE=D1=86=D0=B5=D1=81?= =?UTF-8?q?=D1=81=D0=B0=20=D0=BD=D0=B5=D0=BB=D1=8C=D0=B7=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OAuth2/Storage/ScopeStorage.php | 3 +- common/components/Annotations/Reader.php | 18 +++++ common/components/Annotations/RedisCache.php | 65 +++++++++++++++++++ common/models/OauthScope.php | 55 ++++++++++++++-- composer.json | 3 +- .../api/functional/OauthAuthCodeCest.php | 15 +++++ .../common/unit/models/OauthScopeTest.php | 12 ++++ 7 files changed, 162 insertions(+), 9 deletions(-) create mode 100644 common/components/Annotations/Reader.php create mode 100644 common/components/Annotations/RedisCache.php create mode 100644 tests/codeception/common/unit/models/OauthScopeTest.php diff --git a/api/components/OAuth2/Storage/ScopeStorage.php b/api/components/OAuth2/Storage/ScopeStorage.php index be42d1e..f653af8 100644 --- a/api/components/OAuth2/Storage/ScopeStorage.php +++ b/api/components/OAuth2/Storage/ScopeStorage.php @@ -12,7 +12,8 @@ class ScopeStorage extends AbstractStorage implements ScopeInterface { * @inheritdoc */ public function get($scope, $grantType = null, $clientId = null) { - if (!in_array($scope, OauthScope::getScopes(), true)) { + $scopes = $grantType === 'authorization_code' ? OauthScope::getPublicScopes() : OauthScope::getScopes(); + if (!in_array($scope, $scopes, true)) { return null; } diff --git a/common/components/Annotations/Reader.php b/common/components/Annotations/Reader.php new file mode 100644 index 0000000..3397fa4 --- /dev/null +++ b/common/components/Annotations/Reader.php @@ -0,0 +1,18 @@ +cache и как-то надобность в отдельном кэше отпала, так что пока забьём + * и оставим как заготовку на будущее + * + * @return \Minime\Annotations\Interfaces\ReaderInterface + */ + public static function createFromDefaults() { + return parent::createFromDefaults(); + //return new self(new \Minime\Annotations\Parser(), new RedisCache()); + } + +} diff --git a/common/components/Annotations/RedisCache.php b/common/components/Annotations/RedisCache.php new file mode 100644 index 0000000..4556347 --- /dev/null +++ b/common/components/Annotations/RedisCache.php @@ -0,0 +1,65 @@ +getRedisKey($key)->setValue(Json::encode($annotations))->expire(3600); + $this->getRedisKeysSet()->add($key); + } + + /** + * Retrieves cached annotations from docblock uuid + * + * @param string $key cache entry uuid + * @return array cached annotation AST + */ + public function get($key) { + $result = $this->getRedisKey($key)->getValue(); + if ($result === null) { + return []; + } + + return Json::decode($result); + } + + /** + * Resets cache + */ + public function clear() { + /** @var array $keys */ + $keys = $this->getRedisKeysSet()->getValue(); + foreach ($keys as $key) { + $this->getRedisKey($key)->delete(); + } + } + + private function getRedisKey(string $key): Key { + return new Key('annotations', 'cache', $key); + } + + private function getRedisKeysSet(): Set { + return new Set('annotations', 'cache', 'keys'); + } + +} diff --git a/common/models/OauthScope.php b/common/models/OauthScope.php index 9fbf035..958d2da 100644 --- a/common/models/OauthScope.php +++ b/common/models/OauthScope.php @@ -1,6 +1,11 @@ getConstants(); + $reader = Reader::createFromDefaults(); + foreach ($constants as $constName => $value) { + $annotations = $reader->getConstantAnnotations(static::class, $constName); + $isInternal = $annotations->get('internal', false); + $keyValue = [ + 'value' => $value, + ]; + if ($isInternal) { + $keyValue['internal'] = true; + } + $scopes[$constName] = $keyValue; + } + + Yii::$app->cache->set($cacheKey, $scopes, 3600); + } + + return $scopes; } } diff --git a/composer.json b/composer.json index c786485..689fbe4 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,8 @@ "ely/amqp-controller": "dev-master#d7f8cdbc66c45e477c9c7d5d509bc0c1b11fd3ec", "ely/email-renderer": "dev-master#ef1cb3f7a13196524b97ca5aa0a2d5867f2d9207", "predis/predis": "^1.0", - "mito/yii2-sentry": "dev-fix_init#27f00805cb906f73b2c6f8181c1c655decb9be70" + "mito/yii2-sentry": "dev-fix_init#27f00805cb906f73b2c6f8181c1c655decb9be70", + "minime/annotations": "~3.0" }, "require-dev": { "yiisoft/yii2-codeception": "*", diff --git a/tests/codeception/api/functional/OauthAuthCodeCest.php b/tests/codeception/api/functional/OauthAuthCodeCest.php index 2ed740f..be61f3e 100644 --- a/tests/codeception/api/functional/OauthAuthCodeCest.php +++ b/tests/codeception/api/functional/OauthAuthCodeCest.php @@ -281,6 +281,21 @@ class OauthAuthCodeCest { 'statusCode' => 400, ]); $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); + + $I->wantTo('check behavior on request internal scope'); + $this->route->$action($this->buildQueryParams('ely', 'http://ely.by', 'code', [ + S::MINECRAFT_SERVER_SESSION, + S::ACCOUNT_BLOCK, + ])); + $I->canSeeResponseCodeIs(400); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'error' => 'invalid_scope', + 'parameter' => S::ACCOUNT_BLOCK, + 'statusCode' => 400, + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); } } diff --git a/tests/codeception/common/unit/models/OauthScopeTest.php b/tests/codeception/common/unit/models/OauthScopeTest.php new file mode 100644 index 0000000..8adbca6 --- /dev/null +++ b/tests/codeception/common/unit/models/OauthScopeTest.php @@ -0,0 +1,12 @@ + Date: Sun, 11 Dec 2016 14:37:55 +0300 Subject: [PATCH 03/23] =?UTF-8?q?=D0=91=D0=B0=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=8F=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20API=20=D0=B4=D0=BB=D1=8F=20=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=B0=D0=BA=D0=BA=D0=B0?= =?UTF-8?q?=D1=83=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/AccountsController.php | 30 +++++++ api/modules/internal/models/BlockForm.php | 80 +++++++++++++++++++ common/models/amqp/AccountBanned.php | 14 ++++ 3 files changed, 124 insertions(+) create mode 100644 api/modules/internal/controllers/AccountsController.php create mode 100644 api/modules/internal/models/BlockForm.php create mode 100644 common/models/amqp/AccountBanned.php diff --git a/api/modules/internal/controllers/AccountsController.php b/api/modules/internal/controllers/AccountsController.php new file mode 100644 index 0000000..2c86fd3 --- /dev/null +++ b/api/modules/internal/controllers/AccountsController.php @@ -0,0 +1,30 @@ + [ + 'class' => AccessControl::class, + 'rules' => [ + [ + 'actions' => ['block'], + 'allow' => true, + 'roles' => [S::ACCOUNT_BLOCK], + ], + ], + ], + ]); + } + + public function actionBlock(int $accountId) { + + } + +} diff --git a/api/modules/internal/models/BlockForm.php b/api/modules/internal/models/BlockForm.php new file mode 100644 index 0000000..a60b757 --- /dev/null +++ b/api/modules/internal/models/BlockForm.php @@ -0,0 +1,80 @@ + self::DURATION_FOREVER], + [['message'], 'string'], + ]; + } + + public function getAccount(): Account { + return $this->account; + } + + public function ban(): bool { + $transaction = Yii::$app->db->beginTransaction(); + + $account = $this->account; + $account->status = Account::STATUS_BANNED; + $account->save(); + + $this->createTask(); + + $transaction->commit(); + + return true; + } + + public function createTask() { + $model = new AccountBanned(); + $model->accountId = $this->account->id; + $model->duration = $this->duration; + $model->message = $this->message; + + $message = Amqp::getInstance()->prepareMessage($model, [ + 'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT, + ]); + + Amqp::sendToEventsExchange('accounts.account-banned', $message); + } + + public function __construct(Account $account, array $config = []) { + $this->account = $account; + parent::__construct($config); + } + +} diff --git a/common/models/amqp/AccountBanned.php b/common/models/amqp/AccountBanned.php new file mode 100644 index 0000000..1b1198e --- /dev/null +++ b/common/models/amqp/AccountBanned.php @@ -0,0 +1,14 @@ + Date: Fri, 16 Dec 2016 11:32:13 +0300 Subject: [PATCH 04/23] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20unit-=D1=82=D0=B5=D1=81=D1=82=D1=8B=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=B0?= =?UTF-8?q?=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/modules/internal/models/BlockForm.php | 7 ++- .../modules/internal/models/BlockFormTest.php | 47 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 tests/codeception/api/unit/modules/internal/models/BlockFormTest.php diff --git a/api/modules/internal/models/BlockForm.php b/api/modules/internal/models/BlockForm.php index a60b757..7cb0bdb 100644 --- a/api/modules/internal/models/BlockForm.php +++ b/api/modules/internal/models/BlockForm.php @@ -7,6 +7,7 @@ use common\models\Account; use common\models\amqp\AccountBanned; use PhpAmqpLib\Message\AMQPMessage; use Yii; +use yii\base\ErrorException; class BlockForm extends ApiForm { @@ -27,7 +28,7 @@ class BlockForm extends ApiForm { * * @var string */ - public $message; + public $message = ''; /** * @var Account @@ -50,7 +51,9 @@ class BlockForm extends ApiForm { $account = $this->account; $account->status = Account::STATUS_BANNED; - $account->save(); + if (!$account->save()) { + throw new ErrorException('Cannot ban account'); + } $this->createTask(); diff --git a/tests/codeception/api/unit/modules/internal/models/BlockFormTest.php b/tests/codeception/api/unit/modules/internal/models/BlockFormTest.php new file mode 100644 index 0000000..055eaec --- /dev/null +++ b/tests/codeception/api/unit/modules/internal/models/BlockFormTest.php @@ -0,0 +1,47 @@ +getMockBuilder(Account::class) + ->setMethods(['save']) + ->getMock(); + + $account->expects($this->once()) + ->method('save') + ->willReturn(true); + + $model = new BlockForm($account); + $this->assertTrue($model->ban()); + $this->assertEquals(Account::STATUS_BANNED, $account->status); + $this->tester->canSeeAmqpMessageIsCreated('events'); + } + + public function testCreateTask() { + $account = new Account(); + $account->id = 3; + + $model = new BlockForm($account); + $model->createTask(); + $message = json_decode($this->tester->grabLastSentAmqpMessage('events')->body, true); + $this->assertSame(3, $message['accountId']); + $this->assertSame(-1, $message['duration']); + $this->assertSame('', $message['message']); + + $model = new BlockForm($account); + $model->duration = 123; + $model->message = 'test'; + $model->createTask(); + $message = json_decode($this->tester->grabLastSentAmqpMessage('events')->body, true); + $this->assertSame(3, $message['accountId']); + $this->assertSame(123, $message['duration']); + $this->assertSame('test', $message['message']); + } + +} From 1e7039c05c2df7cebe33772741ed2b11594bbe9f Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Fri, 16 Dec 2016 11:36:21 +0300 Subject: [PATCH 05/23] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D0=BB=D0=B5=D1=80=20=D0=B4=D0=BB=D1=8F=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D1=8B=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D1=83=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/AccountsController.php | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/api/modules/internal/controllers/AccountsController.php b/api/modules/internal/controllers/AccountsController.php index 2c86fd3..fd4eb56 100644 --- a/api/modules/internal/controllers/AccountsController.php +++ b/api/modules/internal/controllers/AccountsController.php @@ -3,8 +3,12 @@ namespace api\modules\internal\controllers; use api\components\ApiUser\AccessControl; use api\controllers\Controller; +use api\modules\internal\models\BlockForm; +use common\models\Account; use common\models\OauthScope as S; +use Yii; use yii\helpers\ArrayHelper; +use yii\web\NotFoundHttpException; class AccountsController extends Controller { @@ -24,7 +28,28 @@ class AccountsController extends Controller { } public function actionBlock(int $accountId) { + $account = $this->findAccount($accountId); + $model = new BlockForm($account); + $model->load(Yii::$app->request->post()); + if (!$model->ban()) { + return [ + 'success' => false, + 'errors' => $model->getFirstErrors(), + ]; + } + return [ + 'success' => true, + ]; + } + + private function findAccount(int $accountId): Account { + $account = Account::findOne($accountId); + if ($account === null) { + throw new NotFoundHttpException(); + } + + return $account; } } From 79bbc12206818d36d4d1e50d02ae4f84907cf838 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sun, 18 Dec 2016 02:20:53 +0300 Subject: [PATCH 06/23] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE=D0=BB=D0=BB?= =?UTF-8?q?=D0=B5=D1=80=20=D0=B4=D0=BB=D1=8F=20=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=B0=D0=BA=D0=BA=D0=B0?= =?UTF-8?q?=D1=83=D0=BD=D1=82=D0=B0=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20client=5Fcredentials=20grant=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20oAuth=20=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=20=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82?= =?UTF-8?q?=D1=83=D1=80=D1=8B=20OauthScopes=20=D1=87=D1=82=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=20=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE=20=D0=B1=D1=8B=D0=BB?= =?UTF-8?q?=D0=BE=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B8=D1=82=D1=8C?= =?UTF-8?q?=20=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB=D1=8C=D1=86=D0=B0=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=20=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D0=B5=20=D0=B8=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B5=20?= =?UTF-8?q?(=D0=BC=D0=B0=D1=88=D0=B8=D0=BD=D0=BD=D1=8B=D0=B5)=20=D0=98?= =?UTF-8?q?=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=BB=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0,=20=D0=B2=D0=BD=D0=B5=D0=B4=D1=80=D1=8F?= =?UTF-8?q?=D1=8E=D1=82=D1=81=D1=8F=20=D1=84=D0=B8=D1=88=D0=BA=D0=B8=20PHP?= =?UTF-8?q?=207.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/components/ApiUser/Component.php | 4 +- api/components/ApiUser/Identity.php | 13 ++--- .../OAuth2/Entities/ClientEntity.php | 10 ++++ .../OAuth2/Grants/ClientCredentialsGrant.php | 20 ++++++++ .../OAuth2/Storage/ClientStorage.php | 1 + .../OAuth2/Storage/ScopeStorage.php | 24 ++++++++- api/config/config.php | 8 ++- api/controllers/OauthController.php | 13 ++--- api/modules/internal/Module.php | 19 +++++++ .../controllers/AccountsController.php | 11 ++-- api/modules/internal/helpers/Error.php | 8 +++ .../models/{BlockForm.php => BanForm.php} | 22 ++++++-- common/models/OauthScope.php | 41 +++++++-------- common/models/OauthScopeQuery.php | 50 +++++++++++++++++++ ..._oauth_clients_allow_null_redirect_uri.php | 15 ++++++ .../codeception/api/_pages/InternalRoute.php | 16 ++++++ .../api/functional/_steps/OauthSteps.php | 21 ++++++-- .../api/functional/internal/BanCest.php | 47 +++++++++++++++++ .../{BlockFormTest.php => BanFormTest.php} | 25 ++++++++-- .../common/fixtures/data/oauth-clients.php | 20 ++++++++ .../common/unit/models/OauthScopeTest.php | 12 ----- 21 files changed, 332 insertions(+), 68 deletions(-) create mode 100644 api/components/OAuth2/Grants/ClientCredentialsGrant.php create mode 100644 api/modules/internal/Module.php create mode 100644 api/modules/internal/helpers/Error.php rename api/modules/internal/models/{BlockForm.php => BanForm.php} (76%) create mode 100644 common/models/OauthScopeQuery.php create mode 100644 console/migrations/m161228_101022_oauth_clients_allow_null_redirect_uri.php create mode 100644 tests/codeception/api/_pages/InternalRoute.php create mode 100644 tests/codeception/api/functional/internal/BanCest.php rename tests/codeception/api/unit/modules/internal/models/{BlockFormTest.php => BanFormTest.php} (64%) delete mode 100644 tests/codeception/common/unit/models/OauthScopeTest.php diff --git a/api/components/ApiUser/Component.php b/api/components/ApiUser/Component.php index 46acfc1..d6f4655 100644 --- a/api/components/ApiUser/Component.php +++ b/api/components/ApiUser/Component.php @@ -6,8 +6,8 @@ use yii\web\User as YiiUserComponent; /** * @property Identity|null $identity * - * @method Identity|null getIdentity() - * @method Identity|null loginByAccessToken(string $token, $type = null) + * @method Identity|null getIdentity($autoRenew = true) + * @method Identity|null loginByAccessToken($token, $type = null) */ class Component extends YiiUserComponent { diff --git a/api/components/ApiUser/Identity.php b/api/components/ApiUser/Identity.php index fb3510d..953b5a7 100644 --- a/api/components/ApiUser/Identity.php +++ b/api/components/ApiUser/Identity.php @@ -26,7 +26,8 @@ class Identity implements IdentityInterface { /** * @inheritdoc */ - public static function findIdentityByAccessToken($token, $type = null) { + public static function findIdentityByAccessToken($token, $type = null): self { + /** @var AccessTokenEntity|null $model */ $model = Yii::$app->oauth->getAuthServer()->getAccessTokenStorage()->get($token); if ($model === null) { throw new UnauthorizedHttpException('Incorrect token'); @@ -41,19 +42,19 @@ class Identity implements IdentityInterface { $this->_accessToken = $accessToken; } - public function getAccount() : Account { + public function getAccount(): Account { return $this->getSession()->account; } - public function getClient() : OauthClient { + public function getClient(): OauthClient { return $this->getSession()->client; } - public function getSession() : OauthSession { + public function getSession(): OauthSession { return OauthSession::findOne($this->_accessToken->getSessionId()); } - public function getAccessToken() : AccessTokenEntity { + public function getAccessToken(): AccessTokenEntity { return $this->_accessToken; } @@ -62,7 +63,7 @@ class Identity implements IdentityInterface { * У нас права привязываются к токенам, так что возвращаем именно его id. * @inheritdoc */ - public function getId() { + public function getId(): string { return $this->_accessToken->getId(); } diff --git a/api/components/OAuth2/Entities/ClientEntity.php b/api/components/OAuth2/Entities/ClientEntity.php index 8636cf1..e88f424 100644 --- a/api/components/OAuth2/Entities/ClientEntity.php +++ b/api/components/OAuth2/Entities/ClientEntity.php @@ -3,6 +3,8 @@ namespace api\components\OAuth2\Entities; class ClientEntity extends \League\OAuth2\Server\Entity\ClientEntity { + private $isTrusted; + public function setId(string $id) { $this->id = $id; } @@ -19,4 +21,12 @@ class ClientEntity extends \League\OAuth2\Server\Entity\ClientEntity { $this->redirectUri = $redirectUri; } + public function setIsTrusted(bool $isTrusted) { + $this->isTrusted = $isTrusted; + } + + public function isTrusted(): bool { + return $this->isTrusted; + } + } diff --git a/api/components/OAuth2/Grants/ClientCredentialsGrant.php b/api/components/OAuth2/Grants/ClientCredentialsGrant.php new file mode 100644 index 0000000..4e7b467 --- /dev/null +++ b/api/components/OAuth2/Grants/ClientCredentialsGrant.php @@ -0,0 +1,20 @@ +server); + } + + protected function createRefreshTokenEntity() { + return new Entities\RefreshTokenEntity($this->server); + } + + protected function createSessionEntity() { + return new Entities\SessionEntity($this->server); + } + +} diff --git a/api/components/OAuth2/Storage/ClientStorage.php b/api/components/OAuth2/Storage/ClientStorage.php index 90d024b..9a339f0 100644 --- a/api/components/OAuth2/Storage/ClientStorage.php +++ b/api/components/OAuth2/Storage/ClientStorage.php @@ -74,6 +74,7 @@ class ClientStorage extends AbstractStorage implements ClientInterface { $entity->setId($model->id); $entity->setName($model->name); $entity->setSecret($model->secret); + $entity->setIsTrusted($model->is_trusted); $entity->setRedirectUri($model->redirect_uri); return $entity; diff --git a/api/components/OAuth2/Storage/ScopeStorage.php b/api/components/OAuth2/Storage/ScopeStorage.php index f653af8..d5223e5 100644 --- a/api/components/OAuth2/Storage/ScopeStorage.php +++ b/api/components/OAuth2/Storage/ScopeStorage.php @@ -1,10 +1,12 @@ onlyPublic()->usersScopes(); + } elseif ($grantType === 'client_credentials') { + $query->machineScopes(); + $isTrusted = false; + if ($clientId !== null) { + $client = $this->server->getClientStorage()->get($clientId); + if (!$client instanceof ClientEntity) { + throw new ErrorException('client storage must return instance of ' . ClientEntity::class); + } + + $isTrusted = $client->isTrusted(); + } + + if (!$isTrusted) { + $query->onlyPublic(); + } + } + + $scopes = $query->all(); if (!in_array($scope, $scopes, true)) { return null; } diff --git a/api/config/config.php b/api/config/config.php index 68e83c2..fcb7178 100644 --- a/api/config/config.php +++ b/api/config/config.php @@ -7,7 +7,7 @@ $params = array_merge( return [ 'id' => 'accounts-site-api', 'basePath' => dirname(__DIR__), - 'bootstrap' => ['log', 'authserver'], + 'bootstrap' => ['log', 'authserver', 'internal'], 'controllerNamespace' => 'api\controllers', 'params' => $params, 'components' => [ @@ -75,10 +75,11 @@ return [ ], 'oauth' => [ 'class' => api\components\OAuth2\Component::class, - 'grantTypes' => ['authorization_code'], + 'grantTypes' => ['authorization_code', 'client_credentials'], 'grantMap' => [ 'authorization_code' => api\components\OAuth2\Grants\AuthCodeGrant::class, 'refresh_token' => api\components\OAuth2\Grants\RefreshTokenGrant::class, + 'client_credentials' => api\components\OAuth2\Grants\ClientCredentialsGrant::class, ], ], 'errorHandler' => [ @@ -96,5 +97,8 @@ return [ 'mojang' => [ 'class' => api\modules\mojang\Module::class, ], + 'internal' => [ + 'class' => api\modules\internal\Module::class, + ], ], ]; diff --git a/api/controllers/OauthController.php b/api/controllers/OauthController.php index 8df62a0..9428287 100644 --- a/api/controllers/OauthController.php +++ b/api/controllers/OauthController.php @@ -7,7 +7,9 @@ use api\components\OAuth2\Exception\AccessDeniedException; use common\models\Account; use common\models\OauthClient; use common\models\OauthScope; +use League\OAuth2\Server\AuthorizationServer; use League\OAuth2\Server\Exception\OAuthException; +use League\OAuth2\Server\Grant\AuthCodeGrant; use Yii; use yii\filters\AccessControl; use yii\helpers\ArrayHelper; @@ -274,17 +276,12 @@ class OauthController extends Controller { return $response; } - /** - * @return \League\OAuth2\Server\AuthorizationServer - */ - private function getServer() { + private function getServer(): AuthorizationServer { return Yii::$app->oauth->authServer; } - /** - * @return \League\OAuth2\Server\Grant\AuthCodeGrant - */ - private function getGrantType() { + private function getGrantType(): AuthCodeGrant { + /** @noinspection PhpIncompatibleReturnTypeInspection */ return $this->getServer()->getGrantType('authorization_code'); } diff --git a/api/modules/internal/Module.php b/api/modules/internal/Module.php new file mode 100644 index 0000000..5213e72 --- /dev/null +++ b/api/modules/internal/Module.php @@ -0,0 +1,19 @@ +getUrlManager()->addRules([ + '/internal///' => "{$this->id}//", + ], false); + } + +} diff --git a/api/modules/internal/controllers/AccountsController.php b/api/modules/internal/controllers/AccountsController.php index fd4eb56..e666a0c 100644 --- a/api/modules/internal/controllers/AccountsController.php +++ b/api/modules/internal/controllers/AccountsController.php @@ -3,7 +3,7 @@ namespace api\modules\internal\controllers; use api\components\ApiUser\AccessControl; use api\controllers\Controller; -use api\modules\internal\models\BlockForm; +use api\modules\internal\models\BanForm; use common\models\Account; use common\models\OauthScope as S; use Yii; @@ -14,11 +14,14 @@ class AccountsController extends Controller { public function behaviors() { return ArrayHelper::merge(parent::behaviors(), [ + 'authenticator' => [ + 'user' => Yii::$app->apiUser, + ], 'access' => [ 'class' => AccessControl::class, 'rules' => [ [ - 'actions' => ['block'], + 'actions' => ['ban'], 'allow' => true, 'roles' => [S::ACCOUNT_BLOCK], ], @@ -27,9 +30,9 @@ class AccountsController extends Controller { ]); } - public function actionBlock(int $accountId) { + public function actionBan(int $accountId) { $account = $this->findAccount($accountId); - $model = new BlockForm($account); + $model = new BanForm($account); $model->load(Yii::$app->request->post()); if (!$model->ban()) { return [ diff --git a/api/modules/internal/helpers/Error.php b/api/modules/internal/helpers/Error.php new file mode 100644 index 0000000..86b45b5 --- /dev/null +++ b/api/modules/internal/helpers/Error.php @@ -0,0 +1,8 @@ + self::DURATION_FOREVER], [['message'], 'string'], + [['account'], 'validateAccountActivity'], ]; } @@ -46,7 +48,17 @@ class BlockForm extends ApiForm { return $this->account; } + public function validateAccountActivity() { + if ($this->account->status === Account::STATUS_BANNED) { + $this->addError('account', E::ACCOUNT_ALREADY_BANNED); + } + } + public function ban(): bool { + if (!$this->validate()) { + return false; + } + $transaction = Yii::$app->db->beginTransaction(); $account = $this->account; @@ -62,7 +74,7 @@ class BlockForm extends ApiForm { return true; } - public function createTask() { + public function createTask(): void { $model = new AccountBanned(); $model->accountId = $this->account->id; $model->duration = $this->duration; diff --git a/common/models/OauthScope.php b/common/models/OauthScope.php index 958d2da..bb4da54 100644 --- a/common/models/OauthScope.php +++ b/common/models/OauthScope.php @@ -4,32 +4,33 @@ namespace common\models; use common\components\Annotations\Reader; use ReflectionClass; use Yii; -use yii\helpers\ArrayHelper; class OauthScope { + /** + * @owner user + */ const OFFLINE_ACCESS = 'offline_access'; + /** + * @owner user + */ const MINECRAFT_SERVER_SESSION = 'minecraft_server_session'; + /** + * @owner user + */ const ACCOUNT_INFO = 'account_info'; + /** + * @owner user + */ const ACCOUNT_EMAIL = 'account_email'; - - /** @internal */ + /** + * @internal + * @owner machine + */ const ACCOUNT_BLOCK = 'account_block'; - public static function getScopes(): array { - return ArrayHelper::getColumn(static::queryScopes(), 'value'); - } - - public static function getPublicScopes(): array { - return ArrayHelper::getColumn(array_filter(static::queryScopes(), function($value) { - return !isset($value['internal']); - }), 'value'); - } - - public static function getInternalScopes(): array { - return ArrayHelper::getColumn(array_filter(static::queryScopes(), function($value) { - return isset($value['internal']); - }), 'value'); + public static function find(): OauthScopeQuery { + return new OauthScopeQuery(static::queryScopes()); } private static function queryScopes(): array { @@ -43,12 +44,12 @@ class OauthScope { foreach ($constants as $constName => $value) { $annotations = $reader->getConstantAnnotations(static::class, $constName); $isInternal = $annotations->get('internal', false); + $owner = $annotations->get('owner', 'user'); $keyValue = [ 'value' => $value, + 'internal' => $isInternal, + 'owner' => $owner, ]; - if ($isInternal) { - $keyValue['internal'] = true; - } $scopes[$constName] = $keyValue; } diff --git a/common/models/OauthScopeQuery.php b/common/models/OauthScopeQuery.php new file mode 100644 index 0000000..27577c5 --- /dev/null +++ b/common/models/OauthScopeQuery.php @@ -0,0 +1,50 @@ +internal = false; + return $this; + } + + public function onlyInternal(): self { + $this->internal = true; + return $this; + } + + public function usersScopes(): self { + $this->owner = 'user'; + return $this; + } + + public function machineScopes(): self { + $this->owner = 'machine'; + return $this; + } + + public function all(): array { + return ArrayHelper::getColumn(array_filter($this->scopes, function($value) { + $shouldCheckInternal = $this->internal !== null; + $isInternalMatch = $value['internal'] === $this->internal; + $shouldCheckOwner = $this->owner !== null; + $isOwnerMatch = $value['owner'] === $this->owner; + + return (!$shouldCheckInternal || $isInternalMatch) + && (!$shouldCheckOwner || $isOwnerMatch); + }), 'value'); + } + + public function __construct(array $scopes) { + $this->scopes = $scopes; + } + +} diff --git a/console/migrations/m161228_101022_oauth_clients_allow_null_redirect_uri.php b/console/migrations/m161228_101022_oauth_clients_allow_null_redirect_uri.php new file mode 100644 index 0000000..1734f95 --- /dev/null +++ b/console/migrations/m161228_101022_oauth_clients_allow_null_redirect_uri.php @@ -0,0 +1,15 @@ +alterColumn('{{%oauth_clients}}', 'redirect_uri', $this->string()); + } + + public function safeDown() { + $this->alterColumn('{{%oauth_clients}}', 'redirect_uri', $this->string()->notNull()); + } + +} diff --git a/tests/codeception/api/_pages/InternalRoute.php b/tests/codeception/api/_pages/InternalRoute.php new file mode 100644 index 0000000..36ce520 --- /dev/null +++ b/tests/codeception/api/_pages/InternalRoute.php @@ -0,0 +1,16 @@ +route = '/internal/accounts/' . $accountId . '/ban'; + $this->actor->sendPOST($this->getUrl()); + } + +} diff --git a/tests/codeception/api/functional/_steps/OauthSteps.php b/tests/codeception/api/functional/_steps/OauthSteps.php index c16aaf6..3e0c467 100644 --- a/tests/codeception/api/functional/_steps/OauthSteps.php +++ b/tests/codeception/api/functional/_steps/OauthSteps.php @@ -3,11 +3,12 @@ namespace tests\codeception\api\functional\_steps; use common\models\OauthScope as S; use tests\codeception\api\_pages\OauthRoute; +use tests\codeception\api\FunctionalTester; -class OauthSteps extends \tests\codeception\api\FunctionalTester { +class OauthSteps extends FunctionalTester { public function getAuthCode(array $permissions = []) { - // TODO: по идее можно напрямую сделать зпись в базу, что ускорит процесс тестирования + // TODO: по идее можно напрямую сделать запись в базу, что ускорит процесс тестирования $this->loggedInAsActiveAccount(); $route = new OauthRoute($this); $route->complete([ @@ -31,7 +32,7 @@ class OauthSteps extends \tests\codeception\api\FunctionalTester { } public function getRefreshToken(array $permissions = []) { - // TODO: по идее можно напрямую сделать зпись в базу, что ускорит процесс тестирования + // TODO: по идее можно напрямую сделать запись в базу, что ускорит процесс тестирования $authCode = $this->getAuthCode(array_merge([S::OFFLINE_ACCESS], $permissions)); $response = $this->issueToken($authCode); @@ -51,4 +52,18 @@ class OauthSteps extends \tests\codeception\api\FunctionalTester { return json_decode($this->grabResponse(), true); } + public function getAccessTokenByClientCredentialsGrant(array $permissions = [], $useTrusted = true) { + $route = new OauthRoute($this); + $route->issueToken([ + 'client_id' => $useTrusted ? 'trusted-client' : 'default-client', + 'client_secret' => $useTrusted ? 'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9' : 'AzWRy7ZjS1yRQUk2vRBDic8fprOKDB1W', + 'grant_type' => 'client_credentials', + 'scope' => implode(',', $permissions), + ]); + + $response = json_decode($this->grabResponse(), true); + + return $response['access_token']; + } + } diff --git a/tests/codeception/api/functional/internal/BanCest.php b/tests/codeception/api/functional/internal/BanCest.php new file mode 100644 index 0000000..9f46806 --- /dev/null +++ b/tests/codeception/api/functional/internal/BanCest.php @@ -0,0 +1,47 @@ +route = new InternalRoute($I); + } + + public function testBanAccount(OauthSteps $I) { + $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::ACCOUNT_BLOCK]); + $I->amBearerAuthenticated($accessToken); + + $this->route->ban(1); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + } + + public function testBanBannedAccount(OauthSteps $I) { + $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::ACCOUNT_BLOCK]); + $I->amBearerAuthenticated($accessToken); + + $this->route->ban(10); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'account' => 'error.account_already_banned', + ], + ]); + } + +} diff --git a/tests/codeception/api/unit/modules/internal/models/BlockFormTest.php b/tests/codeception/api/unit/modules/internal/models/BanFormTest.php similarity index 64% rename from tests/codeception/api/unit/modules/internal/models/BlockFormTest.php rename to tests/codeception/api/unit/modules/internal/models/BanFormTest.php index 055eaec..b63c15a 100644 --- a/tests/codeception/api/unit/modules/internal/models/BlockFormTest.php +++ b/tests/codeception/api/unit/modules/internal/models/BanFormTest.php @@ -1,11 +1,26 @@ status = Account::STATUS_ACTIVE; + $form = new BanForm($account); + $form->validateAccountActivity(); + $this->assertEmpty($form->getErrors('account')); + + $account = new Account(); + $account->status = Account::STATUS_BANNED; + $form = new BanForm($account); + $form->validateAccountActivity(); + $this->assertEquals([E::ACCOUNT_ALREADY_BANNED], $form->getErrors('account')); + } public function testBan() { /** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */ @@ -17,7 +32,7 @@ class BlockFormTest extends TestCase { ->method('save') ->willReturn(true); - $model = new BlockForm($account); + $model = new BanForm($account); $this->assertTrue($model->ban()); $this->assertEquals(Account::STATUS_BANNED, $account->status); $this->tester->canSeeAmqpMessageIsCreated('events'); @@ -27,14 +42,14 @@ class BlockFormTest extends TestCase { $account = new Account(); $account->id = 3; - $model = new BlockForm($account); + $model = new BanForm($account); $model->createTask(); $message = json_decode($this->tester->grabLastSentAmqpMessage('events')->body, true); $this->assertSame(3, $message['accountId']); $this->assertSame(-1, $message['duration']); $this->assertSame('', $message['message']); - $model = new BlockForm($account); + $model = new BanForm($account); $model->duration = 123; $model->message = 'test'; $model->createTask(); diff --git a/tests/codeception/common/fixtures/data/oauth-clients.php b/tests/codeception/common/fixtures/data/oauth-clients.php index e7ddba5..e8c2dfc 100644 --- a/tests/codeception/common/fixtures/data/oauth-clients.php +++ b/tests/codeception/common/fixtures/data/oauth-clients.php @@ -30,4 +30,24 @@ return [ 'is_trusted' => 0, 'created_at' => 1479937982, ], + 'trustedClient' => [ + 'id' => 'trusted-client', + 'secret' => 'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9', + 'name' => 'Trusted client', + 'description' => 'Это клиент, которому мы доверяем', + 'redirect_uri' => null, + 'account_id' => null, + 'is_trusted' => 1, + 'created_at' => 1482922663, + ], + 'defaultClient' => [ + 'id' => 'default-client', + 'secret' => 'AzWRy7ZjS1yRQUk2vRBDic8fprOKDB1W', + 'name' => 'Default client', + 'description' => 'Это обычный клиент, каких может быть много', + 'redirect_uri' => null, + 'account_id' => null, + 'is_trusted' => 0, + 'created_at' => 1482922711, + ], ]; diff --git a/tests/codeception/common/unit/models/OauthScopeTest.php b/tests/codeception/common/unit/models/OauthScopeTest.php deleted file mode 100644 index 8adbca6..0000000 --- a/tests/codeception/common/unit/models/OauthScopeTest.php +++ /dev/null @@ -1,12 +0,0 @@ - Date: Thu, 29 Dec 2016 02:01:26 +0300 Subject: [PATCH 07/23] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=87?= =?UTF-8?q?=D0=B8=D0=BA=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=BE=D0=B1=D1=8B?= =?UTF-8?q?=D1=82=D0=B8=D1=8F=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/config/config.php | 9 ----- autocompletion.php | 12 +++---- common/config/config.php | 9 +++++ common/models/Account.php | 15 ++++++--- .../controllers/AccountQueueController.php | 33 +++++++++++++++++-- .../common/fixtures/data/account-sessions.php | 8 +++++ .../common/fixtures/data/oauth-sessions.php | 7 ++++ .../AccountQueueControllerTest.php | 19 +++++++++++ 8 files changed, 89 insertions(+), 23 deletions(-) diff --git a/api/config/config.php b/api/config/config.php index fcb7178..c00e318 100644 --- a/api/config/config.php +++ b/api/config/config.php @@ -73,15 +73,6 @@ return [ 'response' => [ 'format' => yii\web\Response::FORMAT_JSON, ], - 'oauth' => [ - 'class' => api\components\OAuth2\Component::class, - 'grantTypes' => ['authorization_code', 'client_credentials'], - 'grantMap' => [ - 'authorization_code' => api\components\OAuth2\Grants\AuthCodeGrant::class, - 'refresh_token' => api\components\OAuth2\Grants\RefreshTokenGrant::class, - 'client_credentials' => api\components\OAuth2\Grants\ClientCredentialsGrant::class, - ], - ], 'errorHandler' => [ 'class' => api\components\ErrorHandler::class, ], diff --git a/autocompletion.php b/autocompletion.php index f91608c..d01c0d8 100644 --- a/autocompletion.php +++ b/autocompletion.php @@ -16,12 +16,13 @@ class Yii extends \yii\BaseYii { * Class BaseApplication * Used for properties that are identical for both WebApplication and ConsoleApplication * - * @property \yii\swiftmailer\Mailer $mailer - * @property \common\components\Redis\Connection $redis + * @property \yii\swiftmailer\Mailer $mailer + * @property \common\components\Redis\Connection $redis * @property \common\components\RabbitMQ\Component $amqp - * @property \GuzzleHttp\Client $guzzle - * @property \common\components\EmailRenderer $emailRenderer - * @property \mito\sentry\Component $sentry + * @property \GuzzleHttp\Client $guzzle + * @property \common\components\EmailRenderer $emailRenderer + * @property \mito\sentry\Component $sentry + * @property \api\components\OAuth2\Component $oauth */ abstract class BaseApplication extends yii\base\Application { } @@ -33,7 +34,6 @@ abstract class BaseApplication extends yii\base\Application { * @property \api\components\User\Component $user User component. * @property \api\components\ApiUser\Component $apiUser Api User component. * @property \api\components\ReCaptcha\Component $reCaptcha - * @property \api\components\OAuth2\Component $oauth * * @method \api\components\User\Component getUser() */ diff --git a/common/config/config.php b/common/config/config.php index 39ad823..10eb75a 100644 --- a/common/config/config.php +++ b/common/config/config.php @@ -69,6 +69,15 @@ return [ 'class' => common\components\EmailRenderer::class, 'basePath' => '/images/emails', ], + 'oauth' => [ + 'class' => api\components\OAuth2\Component::class, + 'grantTypes' => ['authorization_code', 'client_credentials'], + 'grantMap' => [ + 'authorization_code' => api\components\OAuth2\Grants\AuthCodeGrant::class, + 'refresh_token' => api\components\OAuth2\Grants\RefreshTokenGrant::class, + 'client_credentials' => api\components\OAuth2\Grants\ClientCredentialsGrant::class, + ], + ], ], 'aliases' => [ '@bower' => '@vendor/bower-asset', diff --git a/common/models/Account.php b/common/models/Account.php index 2bc1e93..ba346da 100644 --- a/common/models/Account.php +++ b/common/models/Account.php @@ -29,10 +29,11 @@ use const common\LATEST_RULES_VERSION; * @property string $profileLink ссылка на профиль на Ely без поддержки static url (только для записи) * * Отношения: - * @property EmailActivation[] $emailActivations - * @property OauthSession[] $oauthSessions - * @property UsernameHistory[] $usernameHistory - * @property AccountSession[] $sessions + * @property EmailActivation[] $emailActivations + * @property OauthSession[] $oauthSessions + * @property UsernameHistory[] $usernameHistory + * @property AccountSession[] $sessions + * @property MinecraftAccessKey[] $minecraftAccessKeys * * Поведения: * @mixin TimestampBehavior @@ -99,7 +100,7 @@ class Account extends ActiveRecord { } public function getOauthSessions() { - return $this->hasMany(OauthSession::class, ['owner_id' => 'id']); + return $this->hasMany(OauthSession::class, ['owner_id' => 'id'])->andWhere(['owner_type' => 'user']); } public function getUsernameHistory() { @@ -110,6 +111,10 @@ class Account extends ActiveRecord { return $this->hasMany(AccountSession::class, ['account_id' => 'id']); } + public function getMinecraftAccessKeys() { + return $this->hasMany(MinecraftAccessKey::class, ['account_id' => 'id']); + } + /** * Выполняет проверку, принадлежит ли этому нику аккаунт у Mojang * diff --git a/console/controllers/AccountQueueController.php b/console/controllers/AccountQueueController.php index fe3d910..be77b59 100644 --- a/console/controllers/AccountQueueController.php +++ b/console/controllers/AccountQueueController.php @@ -3,10 +3,13 @@ namespace console\controllers; use common\components\Mojang\Api as MojangApi; use common\components\Mojang\exceptions\NoContentException; +use common\models\Account; +use common\models\amqp\AccountBanned; use common\models\amqp\UsernameChanged; use common\models\MojangUsername; use Ely\Amqp\Builder\Configurator; use GuzzleHttp\Exception\RequestException; +use Yii; class AccountQueueController extends AmqpController { @@ -17,16 +20,18 @@ class AccountQueueController extends AmqpController { public function configure(Configurator $configurator) { $configurator->exchange->topic()->durable(); $configurator->queue->name('accounts-accounts-events')->durable(); - $configurator->bind->routingKey('accounts.username-changed'); + $configurator->bind->routingKey('accounts.username-changed') + ->add()->routingKey('account.account-banned'); } public function getRoutesMap() { return [ 'accounts.username-changed' => 'routeUsernameChanged', + 'accounts.account-banned' => 'routeAccountBanned', ]; } - public function routeUsernameChanged(UsernameChanged $body) { + public function routeUsernameChanged(UsernameChanged $body): bool { $mojangApi = $this->createMojangApi(); try { $response = $mojangApi->usernameToUUID($body->newUsername); @@ -58,10 +63,32 @@ class AccountQueueController extends AmqpController { return true; } + public function routeAccountBanned(AccountBanned $body): bool { + $account = Account::findOne($body->accountId); + if ($account === null) { + Yii::warning('Cannot find banned account ' . $body->accountId . '. Skipping.'); + return true; + } + + foreach ($account->sessions as $authSession) { + $authSession->delete(); + } + + foreach ($account->minecraftAccessKeys as $key) { + $key->delete(); + } + + foreach ($account->oauthSessions as $oauthSession) { + $oauthSession->delete(); + } + + return true; + } + /** * @return MojangApi */ - protected function createMojangApi() : MojangApi { + protected function createMojangApi(): MojangApi { return new MojangApi(); } diff --git a/tests/codeception/common/fixtures/data/account-sessions.php b/tests/codeception/common/fixtures/data/account-sessions.php index fb9581b..1a68919 100644 --- a/tests/codeception/common/fixtures/data/account-sessions.php +++ b/tests/codeception/common/fixtures/data/account-sessions.php @@ -16,4 +16,12 @@ return [ 'created_at' => time(), 'last_refreshed_at' => time(), ], + 'banned-user-session' => [ + 'id' => 3, + 'account_id' => 10, + 'refresh_token' => 'Af7fIuV6eL61tRUHn40yhmDRXN1OQxKR', + 'last_used_ip' => ip2long('182.123.234.123'), + 'created_at' => time(), + 'last_refreshed_at' => time(), + ], ]; diff --git a/tests/codeception/common/fixtures/data/oauth-sessions.php b/tests/codeception/common/fixtures/data/oauth-sessions.php index ebbc2d2..69e0536 100644 --- a/tests/codeception/common/fixtures/data/oauth-sessions.php +++ b/tests/codeception/common/fixtures/data/oauth-sessions.php @@ -7,4 +7,11 @@ return [ 'client_id' => 'test1', 'client_redirect_uri' => 'http://test1.net/oauth', ], + 'banned-account-session' => [ + 'id' => 2, + 'owner_type' => 'user', + 'owner_id' => 10, + 'client_id' => 'test1', + 'client_redirect_uri' => 'http://test1.net/oauth', + ], ]; diff --git a/tests/codeception/console/unit/controllers/AccountQueueControllerTest.php b/tests/codeception/console/unit/controllers/AccountQueueControllerTest.php index 4aaa57f..4c9e236 100644 --- a/tests/codeception/console/unit/controllers/AccountQueueControllerTest.php +++ b/tests/codeception/console/unit/controllers/AccountQueueControllerTest.php @@ -4,6 +4,7 @@ namespace codeception\console\unit\controllers; use common\components\Mojang\Api; use common\components\Mojang\exceptions\NoContentException; use common\components\Mojang\response\UsernameToUUIDResponse; +use common\models\amqp\AccountBanned; use common\models\amqp\UsernameChanged; use common\models\MojangUsername; use console\controllers\AccountQueueController; @@ -143,4 +144,22 @@ class AccountQueueControllerTest extends TestCase { $this->assertNotEquals($mojangInfo->uuid, $mojangUsername->uuid); } + public function testRouteAccountBanned() { + /** @var \common\models\Account $bannedAccount */ + $bannedAccount = $this->tester->grabFixture('accounts', 'banned-account'); + $this->tester->haveFixtures([ + 'oauthSessions' => \tests\codeception\common\fixtures\OauthSessionFixture::class, + 'minecraftAccessKeys' => \tests\codeception\common\fixtures\MinecraftAccessKeyFixture::class, + 'authSessions' => \tests\codeception\common\fixtures\AccountSessionFixture::class, + ]); + + $body = new AccountBanned(); + $body->accountId = $bannedAccount->id; + + $this->controller->routeAccountBanned($body); + $this->assertEmpty($bannedAccount->sessions); + $this->assertEmpty($bannedAccount->minecraftAccessKeys); + $this->assertEmpty($bannedAccount->oauthSessions); + } + } From 6f81c38b7fb6520b8a8cc3a4697e57c5840c2322 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Thu, 5 Jan 2017 00:57:04 +0300 Subject: [PATCH 08/23] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/AccountsController.php | 30 ++++++++ api/modules/internal/helpers/Error.php | 1 + api/modules/internal/models/PardonForm.php | 72 +++++++++++++++++++ common/models/amqp/AccountPardoned.php | 10 +++ .../codeception/api/_pages/InternalRoute.php | 5 ++ .../api/functional/internal/PardonCest.php | 47 ++++++++++++ .../internal/models/PardonFormTest.php | 52 ++++++++++++++ 7 files changed, 217 insertions(+) create mode 100644 api/modules/internal/models/PardonForm.php create mode 100644 common/models/amqp/AccountPardoned.php create mode 100644 tests/codeception/api/functional/internal/PardonCest.php create mode 100644 tests/codeception/api/unit/modules/internal/models/PardonFormTest.php diff --git a/api/modules/internal/controllers/AccountsController.php b/api/modules/internal/controllers/AccountsController.php index e666a0c..34f4dbe 100644 --- a/api/modules/internal/controllers/AccountsController.php +++ b/api/modules/internal/controllers/AccountsController.php @@ -4,6 +4,7 @@ namespace api\modules\internal\controllers; use api\components\ApiUser\AccessControl; use api\controllers\Controller; use api\modules\internal\models\BanForm; +use api\modules\internal\models\PardonForm; use common\models\Account; use common\models\OauthScope as S; use Yii; @@ -30,8 +31,22 @@ class AccountsController extends Controller { ]); } + public function verbs() { + return [ + 'ban' => ['POST', 'DELETE'], + ]; + } + public function actionBan(int $accountId) { $account = $this->findAccount($accountId); + if (Yii::$app->request->isPost) { + return $this->banAccount($account); + } else { + return $this->pardonAccount($account); + } + } + + private function banAccount(Account $account) { $model = new BanForm($account); $model->load(Yii::$app->request->post()); if (!$model->ban()) { @@ -46,6 +61,21 @@ class AccountsController extends Controller { ]; } + private function pardonAccount(Account $account) { + $model = new PardonForm($account); + $model->load(Yii::$app->request->post()); + if (!$model->pardon()) { + return [ + 'success' => false, + 'errors' => $model->getFirstErrors(), + ]; + } + + return [ + 'success' => true, + ]; + } + private function findAccount(int $accountId): Account { $account = Account::findOne($accountId); if ($account === null) { diff --git a/api/modules/internal/helpers/Error.php b/api/modules/internal/helpers/Error.php index 86b45b5..7a7dec6 100644 --- a/api/modules/internal/helpers/Error.php +++ b/api/modules/internal/helpers/Error.php @@ -4,5 +4,6 @@ namespace api\modules\internal\helpers; final class Error { public const ACCOUNT_ALREADY_BANNED = 'error.account_already_banned'; + public const ACCOUNT_NOT_BANNED = 'error.account_not_banned'; } diff --git a/api/modules/internal/models/PardonForm.php b/api/modules/internal/models/PardonForm.php new file mode 100644 index 0000000..60c10e5 --- /dev/null +++ b/api/modules/internal/models/PardonForm.php @@ -0,0 +1,72 @@ +account; + } + + public function validateAccountBanned(): void { + if ($this->account->status !== Account::STATUS_BANNED) { + $this->addError('account', E::ACCOUNT_NOT_BANNED); + } + } + + public function pardon(): bool { + if (!$this->validate()) { + return false; + } + + $transaction = Yii::$app->db->beginTransaction(); + + $account = $this->account; + $account->status = Account::STATUS_ACTIVE; + if (!$account->save()) { + throw new ErrorException('Cannot pardon account'); + } + + $this->createTask(); + + $transaction->commit(); + + return true; + } + + public function createTask(): void { + $model = new AccountPardoned(); + $model->accountId = $this->account->id; + + $message = Amqp::getInstance()->prepareMessage($model, [ + 'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT, + ]); + + Amqp::sendToEventsExchange('accounts.account-pardoned', $message); + } + + public function __construct(Account $account, array $config = []) { + $this->account = $account; + parent::__construct($config); + } + +} diff --git a/common/models/amqp/AccountPardoned.php b/common/models/amqp/AccountPardoned.php new file mode 100644 index 0000000..31599ed --- /dev/null +++ b/common/models/amqp/AccountPardoned.php @@ -0,0 +1,10 @@ +actor->sendPOST($this->getUrl()); } + public function pardon($accountId) { + $this->route = '/internal/accounts/' . $accountId . '/ban'; + $this->actor->sendDELETE($this->getUrl()); + } + } diff --git a/tests/codeception/api/functional/internal/PardonCest.php b/tests/codeception/api/functional/internal/PardonCest.php new file mode 100644 index 0000000..c7aea10 --- /dev/null +++ b/tests/codeception/api/functional/internal/PardonCest.php @@ -0,0 +1,47 @@ +route = new InternalRoute($I); + } + + public function testPardonAccount(OauthSteps $I) { + $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::ACCOUNT_BLOCK]); + $I->amBearerAuthenticated($accessToken); + + $this->route->pardon(10); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + } + + public function testPardonNotBannedAccount(OauthSteps $I) { + $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::ACCOUNT_BLOCK]); + $I->amBearerAuthenticated($accessToken); + + $this->route->pardon(1); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'account' => 'error.account_not_banned', + ], + ]); + } + +} diff --git a/tests/codeception/api/unit/modules/internal/models/PardonFormTest.php b/tests/codeception/api/unit/modules/internal/models/PardonFormTest.php new file mode 100644 index 0000000..c05d9a0 --- /dev/null +++ b/tests/codeception/api/unit/modules/internal/models/PardonFormTest.php @@ -0,0 +1,52 @@ +status = Account::STATUS_BANNED; + $form = new PardonForm($account); + $form->validateAccountBanned(); + $this->assertEmpty($form->getErrors('account')); + + $account = new Account(); + $account->status = Account::STATUS_ACTIVE; + $form = new PardonForm($account); + $form->validateAccountBanned(); + $this->assertEquals([E::ACCOUNT_NOT_BANNED], $form->getErrors('account')); + } + + public function testPardon() { + /** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */ + $account = $this->getMockBuilder(Account::class) + ->setMethods(['save']) + ->getMock(); + + $account->expects($this->once()) + ->method('save') + ->willReturn(true); + + $account->status = Account::STATUS_BANNED; + $model = new PardonForm($account); + $this->assertTrue($model->pardon()); + $this->assertEquals(Account::STATUS_ACTIVE, $account->status); + $this->tester->canSeeAmqpMessageIsCreated('events'); + } + + public function testCreateTask() { + $account = new Account(); + $account->id = 3; + + $model = new PardonForm($account); + $model->createTask(); + $message = json_decode($this->tester->grabLastSentAmqpMessage('events')->body, true); + $this->assertSame(3, $message['accountId']); + } + +} From 7241e93fe56b50f752d78450ea4078104b402ca3 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Thu, 5 Jan 2017 02:01:31 +0300 Subject: [PATCH 09/23] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20oauth=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BF=D0=BE=20client=5Fcreden?= =?UTF-8?q?tials=20grant=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.json | 2 +- .../OauthClientCredentialsGrantCest.php | 120 ++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 tests/codeception/api/functional/OauthClientCredentialsGrantCest.php diff --git a/composer.json b/composer.json index 689fbe4..2734f1e 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "yiisoft/yii2": "2.0.10", "yiisoft/yii2-swiftmailer": "*", "ramsey/uuid": "^3.5.0", - "league/oauth2-server": "dev-improvements#b9277ccd664dcb80a766b73674d21de686cb9dda", + "league/oauth2-server": "dev-improvements#fbaa9b0bd3d8050235ba7dde90f731764122bc20", "yiisoft/yii2-redis": "~2.0.0", "guzzlehttp/guzzle": "^6.0.0", "php-amqplib/php-amqplib": "^2.6.2", diff --git a/tests/codeception/api/functional/OauthClientCredentialsGrantCest.php b/tests/codeception/api/functional/OauthClientCredentialsGrantCest.php new file mode 100644 index 0000000..e27ccbb --- /dev/null +++ b/tests/codeception/api/functional/OauthClientCredentialsGrantCest.php @@ -0,0 +1,120 @@ +route = new OauthRoute($I); + } + + public function testIssueTokenWithWrongArgs(FunctionalTester $I) { + $I->wantTo('check behavior on on request without any credentials'); + $this->route->issueToken($this->buildParams()); + $I->canSeeResponseCodeIs(400); + $I->canSeeResponseContainsJson([ + 'error' => 'invalid_request', + ]); + + $I->wantTo('check behavior on passing invalid client_id'); + $this->route->issueToken($this->buildParams( + 'invalid-client', + 'invalid-secret', + ['invalid-scope'] + )); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'error' => 'invalid_client', + ]); + + $I->wantTo('check behavior on passing invalid client_secret'); + $this->route->issueToken($this->buildParams( + 'ely', + 'invalid-secret', + ['invalid-scope'] + )); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'error' => 'invalid_client', + ]); + + $I->wantTo('check behavior on passing invalid client_secret'); + $this->route->issueToken($this->buildParams( + 'ely', + 'invalid-secret', + ['invalid-scope'] + )); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'error' => 'invalid_client', + ]); + } + + public function testIssueTokenWithPublicScopes(OauthSteps $I) { + // TODO: у нас пока нет публичных скоупов, поэтому тест прогоняется с пустым набором + $this->route->issueToken($this->buildParams( + 'ely', + 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + [] + )); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'token_type' => 'Bearer', + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.access_token'); + $I->canSeeResponseJsonMatchesJsonPath('$.expires_in'); + } + + public function testIssueTokenWithInternalScopes(OauthSteps $I) { + $this->route->issueToken($this->buildParams( + 'ely', + 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + [S::ACCOUNT_BLOCK] + )); + $I->canSeeResponseCodeIs(400); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'error' => 'invalid_scope', + ]); + + $this->route->issueToken($this->buildParams( + 'trusted-client', + 'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9', + [S::ACCOUNT_BLOCK] + )); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'token_type' => 'Bearer', + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.access_token'); + $I->canSeeResponseJsonMatchesJsonPath('$.expires_in'); + } + + private function buildParams($clientId = null, $clientSecret = null, array $scopes = null) { + $params = ['grant_type' => 'client_credentials']; + if ($clientId !== null) { + $params['client_id'] = $clientId; + } + + if ($clientSecret !== null) { + $params['client_secret'] = $clientSecret; + } + + if ($scopes !== null) { + $params['scope'] = implode(',', $scopes); + } + + return $params; + } + +} From 4bccf94299923bf752713039933fbe6983d3c890 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sun, 8 Jan 2017 14:55:28 +0300 Subject: [PATCH 10/23] =?UTF-8?q?=D0=9D=D0=B5=D0=BC=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D1=83=D1=81=D0=BA=D0=BE=D1=80=D0=B5=D0=BD=D0=BE=20?= =?UTF-8?q?=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=20=D0=BF=D1=80=D0=BE=D1=85=D0=BE?= =?UTF-8?q?=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20backend=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B2=20=D0=B7=D0=B0=20=D1=81=D1=87=D1=91?= =?UTF-8?q?=D1=82=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BF=D1=80=D1=8F=D0=BC=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=BE=D0=B1=D1=80=D0=B0=D1=89=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=BA=20=D1=84=D0=BE=D1=80=D0=BC=D0=B5=20=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0,=20=D0=B0=20=D0=BD=D0=B5=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/_support/FunctionalTester.php | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/codeception/api/_support/FunctionalTester.php b/tests/codeception/api/_support/FunctionalTester.php index cc985c6..a06b732 100644 --- a/tests/codeception/api/_support/FunctionalTester.php +++ b/tests/codeception/api/_support/FunctionalTester.php @@ -1,9 +1,10 @@ login('Admin', 'password_0'); + $form = new LoginForm(); + if ($login === null && $password === null) { + $form->login = 'Admin'; + $form->password = 'password_0'; } elseif ($login !== null && $password !== null) { - $route->login($login, $password); + $form->login = $login; + $form->password = $password; } else { throw new InvalidArgumentException('login and password should be presented both.'); } - $this->canSeeResponseIsJson(); - $this->canSeeAuthCredentials(false); - $jwt = $this->grabDataFromResponseByJsonPath('$.access_token')[0]; - $this->amBearerAuthenticated($jwt); + $result = $form->login(); + $this->assertInstanceOf(LoginResult::class, $result); + if ($result !== false) { + $this->amBearerAuthenticated($result->getJwt()); + } } public function notLoggedIn() { From bb1fd1a9602f7f01ef57c82197445e6df0d8cb8c Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Thu, 19 Jan 2017 01:46:54 +0300 Subject: [PATCH 11/23] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B0=20QR-=D0=BA=D0=BE=D0=B4=D0=BE=D0=B2=20?= =?UTF-8?q?=D1=81=20=D0=BB=D0=BE=D0=B3=D0=BE=D1=82=D0=B8=D0=BF=D0=BE=D0=BC?= =?UTF-8?q?=20Ely.by?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- Dockerfile-dev | 2 +- common/components/Qr/ElyDecorator.php | 116 ++++++++++++++++++++++++ common/components/Qr/resources/logo.svg | 6 ++ composer.json | 4 +- 5 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 common/components/Qr/ElyDecorator.php create mode 100644 common/components/Qr/resources/logo.svg diff --git a/Dockerfile b/Dockerfile index d76207d..bc906e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM registry.ely.by/elyby/accounts-php:1.2.0 +FROM registry.ely.by/elyby/accounts-php:1.3.0 # Вносим конфигурации для крона и воркеров COPY docker/cron/* /etc/cron.d/ diff --git a/Dockerfile-dev b/Dockerfile-dev index ef963fb..2f82da8 100644 --- a/Dockerfile-dev +++ b/Dockerfile-dev @@ -1,4 +1,4 @@ -FROM registry.ely.by/elyby/accounts-php:1.2.0-dev +FROM registry.ely.by/elyby/accounts-php:1.3.0-dev # Вносим конфигурации для крона и воркеров COPY docker/cron/* /etc/cron.d/ diff --git a/common/components/Qr/ElyDecorator.php b/common/components/Qr/ElyDecorator.php new file mode 100644 index 0000000..6ce2f47 --- /dev/null +++ b/common/components/Qr/ElyDecorator.php @@ -0,0 +1,116 @@ + 7, + ErrorCorrectionLevel::M => 15, + ErrorCorrectionLevel::Q => 25, + ErrorCorrectionLevel::H => 30, + ]; + + public function preProcess( + QrCode $qrCode, + RendererInterface $renderer, + $outputWidth, + $outputHeight, + $leftPadding, + $topPadding, + $multiple + ) { + if (!$renderer instanceof Svg) { + throw new InvalidArgumentException('$renderer must by instance of ' . Svg::class); + } + + $correctionLevel = self::CORRECTION_MAP[$qrCode->getErrorCorrectionLevel()->get()]; + $sizeMultiplier = $correctionLevel + floor($correctionLevel / 3); + $count = $qrCode->getMatrix()->getWidth(); + + $countToRemoveX = floor($count * $sizeMultiplier / 100); + $countToRemoveY = floor($count * $sizeMultiplier / 100); + + $startX = $leftPadding + round(($count - $countToRemoveX) / 2 * $multiple); + $startY = $topPadding + round(($count - $countToRemoveY) / 2 * $multiple); + $width = $countToRemoveX * $multiple; + $height = $countToRemoveY * $multiple; + + $reflection = new ReflectionClass($renderer); + $property = $reflection->getProperty('svg'); + $property->setAccessible(true); + /** @var \SimpleXMLElement $svg */ + $svg = $property->getValue($renderer); + + $image = $svg->addChild('image'); + $image->addAttribute('x', $startX); + $image->addAttribute('y', $startY); + $image->addAttribute('width', $width); + $image->addAttribute('height', $height); + $image->addAttribute('xlink:href', $this->encodeSvgToBase64(self::LOGO)); + + $logo = new Imagick(); + $logo->readImage(self::LOGO); + $logo->scaleImage($width, $height); + + $foundedPixels = []; + foreach ($logo->getPixelIterator() as $row => $pixels) { + /** @var \ImagickPixel[] $pixels */ + foreach ($pixels as $column => $pixel) { + $color = $pixel->getColorAsString(); + if ($color !== 'srgb(255,255,255)') { + $foundedPixels[] = [(int)($startX + $column), (int)($startY + $row)]; + } + } + } + + $logo->clear(); + $logo->destroy(); + + $padding = $multiple - 2; + if ($padding < 0) { + $padding = 1; + } + + foreach ($foundedPixels as $coordinates) { + [$x, $y] = $coordinates; + $x -= $leftPadding; + $y -= $topPadding; + + for ($i = $x - $padding; $i <= $x + $padding; $i++) { + for ($j = $y - $padding; $j <= $y + $padding; $j++) { + $matrixX = floor($i / $multiple); + $matrixY = floor($j / $multiple); + $qrCode->getMatrix()->set($matrixX, $matrixY, 0); + } + } + } + } + + public function postProcess( + QrCode $qrCode, + RendererInterface $renderer, + $outputWidth, + $outputHeight, + $leftPadding, + $topPadding, + $multiple + ) { + + } + + private function encodeSvgToBase64(string $filePath): string { + return 'data:image/svg+xml;base64,' . base64_encode(file_get_contents($filePath)); + } + +} diff --git a/common/components/Qr/resources/logo.svg b/common/components/Qr/resources/logo.svg new file mode 100644 index 0000000..9251067 --- /dev/null +++ b/common/components/Qr/resources/logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/composer.json b/composer.json index 2734f1e..759a408 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,9 @@ "ely/email-renderer": "dev-master#ef1cb3f7a13196524b97ca5aa0a2d5867f2d9207", "predis/predis": "^1.0", "mito/yii2-sentry": "dev-fix_init#27f00805cb906f73b2c6f8181c1c655decb9be70", - "minime/annotations": "~3.0" + "minime/annotations": "~3.0", + "spomky-labs/otphp": "^8.3", + "bacon/bacon-qr-code": "^1.0" }, "require-dev": { "yiisoft/yii2-codeception": "*", From 3b9ef7ea703c20c81d205f74c0f5d7c8cf06143a Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sat, 21 Jan 2017 01:54:30 +0300 Subject: [PATCH 12/23] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=B0?= =?UTF-8?q?=20=D0=B8=D0=BD=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=86=D0=B8=D0=B8?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=B4=D0=B2=D1=83=D1=85=D1=84=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=BD=D0=BE=D0=B9=20=D0=B0=D1=83=D1=82=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=B2=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B4=D0=B0=D1=82=D0=BE=D1=80=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20TOTP=20=D0=BA=D0=BE=D0=B4=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/controllers/TwoFactorAuthController.php | 37 ++++++++ api/models/profile/TwoFactorAuthForm.php | 95 +++++++++++++++++++ api/validators/TotpValidator.php | 51 ++++++++++ common/helpers/Error.php | 3 + common/models/Account.php | 2 + .../m170118_224937_account_otp_secrets.php | 17 ++++ .../api/_pages/TwoFactorAuthRoute.php | 16 ++++ .../TwoFactorAuthCredentialsCest.php | 28 ++++++ .../models/profile/TwoFactorAuthFormTest.php | 67 +++++++++++++ .../api/unit/validators/TotpValidatorTest.php | 35 +++++++ 10 files changed, 351 insertions(+) create mode 100644 api/controllers/TwoFactorAuthController.php create mode 100644 api/models/profile/TwoFactorAuthForm.php create mode 100644 api/validators/TotpValidator.php create mode 100644 console/migrations/m170118_224937_account_otp_secrets.php create mode 100644 tests/codeception/api/_pages/TwoFactorAuthRoute.php create mode 100644 tests/codeception/api/functional/TwoFactorAuthCredentialsCest.php create mode 100644 tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php create mode 100644 tests/codeception/api/unit/validators/TotpValidatorTest.php diff --git a/api/controllers/TwoFactorAuthController.php b/api/controllers/TwoFactorAuthController.php new file mode 100644 index 0000000..514b8cc --- /dev/null +++ b/api/controllers/TwoFactorAuthController.php @@ -0,0 +1,37 @@ + [ + 'class' => AccessControl::class, + 'rules' => [ + [ + 'class' => ActiveUserRule::class, + 'actions' => [ + 'credentials', + ], + ], + ], + ], + ]); + } + + public function actionCredentials() { + $account = Yii::$app->user->identity; + $model = new TwoFactorAuthForm($account); + + return $model->getCredentials(); + } + +} diff --git a/api/models/profile/TwoFactorAuthForm.php b/api/models/profile/TwoFactorAuthForm.php new file mode 100644 index 0000000..9c432e4 --- /dev/null +++ b/api/models/profile/TwoFactorAuthForm.php @@ -0,0 +1,95 @@ +account = $account; + parent::__construct($config); + } + + public function rules() { + $on = [self::SCENARIO_ENABLE, self::SCENARIO_DISABLE]; + return [ + ['token', 'required', 'message' => E::OTP_TOKEN_REQUIRED, 'on' => $on], + ['token', TotpValidator::class, 'account' => $this->account, 'window' => 30, 'on' => $on], + ['password', PasswordRequiredValidator::class, 'account' => $this->account, 'on' => $on], + ]; + } + + public function getCredentials(): array { + if (empty($this->account->otp_secret)) { + $this->setOtpSecret(); + } + + $provisioningUri = $this->getTotp()->getProvisioningUri(); + + return [ + 'qr' => base64_encode($this->drawQrCode($provisioningUri)), + 'uri' => $provisioningUri, + 'secret' => $this->account->otp_secret, + ]; + } + + public function getAccount(): Account { + return $this->account; + } + + /** + * @return TOTP + */ + public function getTotp(): TOTP { + $totp = new TOTP($this->account->email, $this->account->otp_secret); + $totp->setIssuer('Ely.by'); + + return $totp; + } + + public function drawQrCode(string $content): string { + $renderer = new Svg(); + $renderer->setHeight(256); + $renderer->setWidth(256); + $renderer->setForegroundColor(new Rgb(32, 126, 92)); + $renderer->setMargin(0); + $renderer->addDecorator(new ElyDecorator()); + + $writer = new Writer($renderer); + + return $writer->writeString($content, Encoder::DEFAULT_BYTE_MODE_ECODING, ErrorCorrectionLevel::H); + } + + protected function setOtpSecret(): void { + $this->account->otp_secret = trim(Base32::encode(random_bytes(32)), '='); + if (!$this->account->save()) { + throw new ErrorException('Cannot set account otp_secret'); + } + } + +} diff --git a/api/validators/TotpValidator.php b/api/validators/TotpValidator.php new file mode 100644 index 0000000..dac0c17 --- /dev/null +++ b/api/validators/TotpValidator.php @@ -0,0 +1,51 @@ +account === null) { + $this->account = Yii::$app->user->identity; + } + + if (!$this->account instanceof Account) { + throw new InvalidConfigException('account should be instance of ' . Account::class); + } + + if (empty($this->account->otp_secret)) { + throw new InvalidConfigException('account should have not empty otp_secret'); + } + } + + protected function validateValue($value) { + $totp = new TOTP(null, $this->account->otp_secret); + if (!$totp->verify((string)$value, null, $this->window)) { + return [E::OTP_TOKEN_INCORRECT, []]; + } + + return null; + } + +} diff --git a/common/helpers/Error.php b/common/helpers/Error.php index 664daa7..6d2347c 100644 --- a/common/helpers/Error.php +++ b/common/helpers/Error.php @@ -54,4 +54,7 @@ final class Error { const SUBJECT_REQUIRED = 'error.subject_required'; const MESSAGE_REQUIRED = 'error.message_required'; + const OTP_TOKEN_REQUIRED = 'error.otp_token_required'; + const OTP_TOKEN_INCORRECT = 'error.otp_token_incorrect'; + } diff --git a/common/models/Account.php b/common/models/Account.php index ba346da..4d67f1c 100644 --- a/common/models/Account.php +++ b/common/models/Account.php @@ -20,6 +20,8 @@ use const common\LATEST_RULES_VERSION; * @property integer $status * @property integer $rules_agreement_version * @property string $registration_ip + * @property string $otp_secret + * @property integer $is_otp_enabled * @property integer $created_at * @property integer $updated_at * @property integer $password_changed_at diff --git a/console/migrations/m170118_224937_account_otp_secrets.php b/console/migrations/m170118_224937_account_otp_secrets.php new file mode 100644 index 0000000..4efa139 --- /dev/null +++ b/console/migrations/m170118_224937_account_otp_secrets.php @@ -0,0 +1,17 @@ +addColumn('{{%accounts}}', 'otp_secret', $this->string()->after('registration_ip')); + $this->addColumn('{{%accounts}}', 'is_otp_enabled', $this->boolean()->notNull()->defaultValue(false)->after('otp_secret')); + } + + public function safeDown() { + $this->dropColumn('{{%accounts}}', 'otp_secret'); + $this->dropColumn('{{%accounts}}', 'is_otp_enabled'); + } + +} diff --git a/tests/codeception/api/_pages/TwoFactorAuthRoute.php b/tests/codeception/api/_pages/TwoFactorAuthRoute.php new file mode 100644 index 0000000..32ffeaa --- /dev/null +++ b/tests/codeception/api/_pages/TwoFactorAuthRoute.php @@ -0,0 +1,16 @@ +route = '/two-factor-auth'; + $this->actor->sendGET($this->getUrl()); + } + +} diff --git a/tests/codeception/api/functional/TwoFactorAuthCredentialsCest.php b/tests/codeception/api/functional/TwoFactorAuthCredentialsCest.php new file mode 100644 index 0000000..1084965 --- /dev/null +++ b/tests/codeception/api/functional/TwoFactorAuthCredentialsCest.php @@ -0,0 +1,28 @@ +route = new TwoFactorAuthRoute($I); + } + + public function testGetCredentials(FunctionalTester $I) { + $I->loggedInAsActiveAccount(); + $this->route->credentials(); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseJsonMatchesJsonPath('$.secret'); + $I->canSeeResponseJsonMatchesJsonPath('$.uri'); + $I->canSeeResponseJsonMatchesJsonPath('$.qr'); + } + +} diff --git a/tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php b/tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php new file mode 100644 index 0000000..37587da --- /dev/null +++ b/tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php @@ -0,0 +1,67 @@ +getMockBuilder(Account::class) + ->setMethods(['save']) + ->getMock(); + + $account->expects($this->once()) + ->method('save') + ->willReturn(true); + + $account->email = 'mock@email.com'; + $account->otp_secret = null; + + /** @var TwoFactorAuthForm|\PHPUnit_Framework_MockObject_MockObject $model */ + $model = $this->getMockBuilder(TwoFactorAuthForm::class) + ->setConstructorArgs([$account]) + ->setMethods(['drawQrCode']) + ->getMock(); + + $model->expects($this->once()) + ->method('drawQrCode') + ->willReturn('this is qr code, trust me'); + + $result = $model->getCredentials(); + $this->assertTrue(is_array($result)); + $this->assertArrayHasKey('qr', $result); + $this->assertArrayHasKey('uri', $result); + $this->assertArrayHasKey('secret', $result); + $this->assertNotNull($account->otp_secret); + $this->assertEquals($account->otp_secret, $result['secret']); + $this->assertEquals(base64_encode('this is qr code, trust me'), $result['qr']); + + /** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */ + $account = $this->getMockBuilder(Account::class) + ->setMethods(['save']) + ->getMock(); + + $account->expects($this->never()) + ->method('save'); + + $account->email = 'mock@email.com'; + $account->otp_secret = 'some valid totp secret value'; + + /** @var TwoFactorAuthForm|\PHPUnit_Framework_MockObject_MockObject $model */ + $model = $this->getMockBuilder(TwoFactorAuthForm::class) + ->setConstructorArgs([$account]) + ->setMethods(['drawQrCode']) + ->getMock(); + + $model->expects($this->once()) + ->method('drawQrCode') + ->willReturn('this is qr code, trust me'); + + $result = $model->getCredentials(); + $this->assertEquals('some valid totp secret value', $result['secret']); + } + +} diff --git a/tests/codeception/api/unit/validators/TotpValidatorTest.php b/tests/codeception/api/unit/validators/TotpValidatorTest.php new file mode 100644 index 0000000..fc23f9f --- /dev/null +++ b/tests/codeception/api/unit/validators/TotpValidatorTest.php @@ -0,0 +1,35 @@ +otp_secret = 'some secret'; + $controlTotp = new TOTP(null, 'some secret'); + + $validator = new TotpValidator(['account' => $account]); + + $result = $this->callProtected($validator, 'validateValue', 123456); + $this->assertEquals([E::OTP_TOKEN_INCORRECT, []], $result); + + $result = $this->callProtected($validator, 'validateValue', $controlTotp->now()); + $this->assertNull($result); + + $result = $this->callProtected($validator, 'validateValue', $controlTotp->at(time() - 31)); + $this->assertEquals([E::OTP_TOKEN_INCORRECT, []], $result); + + $validator->window = 60; + $result = $this->callProtected($validator, 'validateValue', $controlTotp->at(time() - 31)); + $this->assertNull($result); + } + +} From be4c7908b22a6026cb4dff8cd79a3914832c4574 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sat, 21 Jan 2017 02:28:26 +0300 Subject: [PATCH 13/23] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D1=8B=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4?= =?UTF-8?q?=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F\=D0=BE=D1=82=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=B2=D1=83=D1=85=D1=84=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D1=80=D0=BD=D0=BE=D0=B9=20=D0=B0=D1=83=D1=82?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/config/routes.php | 3 ++ api/controllers/TwoFactorAuthController.php | 30 ++++++++++++ api/models/profile/TwoFactorAuthForm.php | 53 +++++++++++++++++++-- common/helpers/Error.php | 2 + 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/api/config/routes.php b/api/config/routes.php index ca7ede9..c973f56 100644 --- a/api/config/routes.php +++ b/api/config/routes.php @@ -7,6 +7,9 @@ return [ '/accounts/change-email/submit-new-email' => 'accounts/change-email-submit-new-email', '/accounts/change-email/confirm-new-email' => 'accounts/change-email-confirm-new-email', + 'POST /two-factor-auth' => 'two-factor-auth/activate', + 'DELETE /two-factor-auth' => 'two-factor-auth/disable', + '/oauth2/v1/' => 'oauth/', '/account/v1/info' => 'identity-info/index', diff --git a/api/controllers/TwoFactorAuthController.php b/api/controllers/TwoFactorAuthController.php index 514b8cc..b6a50ee 100644 --- a/api/controllers/TwoFactorAuthController.php +++ b/api/controllers/TwoFactorAuthController.php @@ -34,4 +34,34 @@ class TwoFactorAuthController extends Controller { return $model->getCredentials(); } + public function actionActivate() { + $account = Yii::$app->user->identity; + $model = new TwoFactorAuthForm($account, ['scenario' => TwoFactorAuthForm::SCENARIO_ACTIVATE]); + if (!$model->activate()) { + return [ + 'success' => false, + 'errors' => $model->getFirstErrors(), + ]; + } + + return [ + 'success' => true, + ]; + } + + public function actionDisable() { + $account = Yii::$app->user->identity; + $model = new TwoFactorAuthForm($account, ['scenario' => TwoFactorAuthForm::SCENARIO_DISABLE]); + if (!$model->disable()) { + return [ + 'success' => false, + 'errors' => $model->getFirstErrors(), + ]; + } + + return [ + 'success' => true, + ]; + } + } diff --git a/api/models/profile/TwoFactorAuthForm.php b/api/models/profile/TwoFactorAuthForm.php index 9c432e4..398cf33 100644 --- a/api/models/profile/TwoFactorAuthForm.php +++ b/api/models/profile/TwoFactorAuthForm.php @@ -18,7 +18,7 @@ use yii\base\ErrorException; class TwoFactorAuthForm extends ApiForm { - const SCENARIO_ENABLE = 'enable'; + const SCENARIO_ACTIVATE = 'enable'; const SCENARIO_DISABLE = 'disable'; public $token; @@ -36,11 +36,13 @@ class TwoFactorAuthForm extends ApiForm { } public function rules() { - $on = [self::SCENARIO_ENABLE, self::SCENARIO_DISABLE]; + $bothScenarios = [self::SCENARIO_ACTIVATE, self::SCENARIO_DISABLE]; return [ - ['token', 'required', 'message' => E::OTP_TOKEN_REQUIRED, 'on' => $on], - ['token', TotpValidator::class, 'account' => $this->account, 'window' => 30, 'on' => $on], - ['password', PasswordRequiredValidator::class, 'account' => $this->account, 'on' => $on], + ['account', 'validateOtpDisabled', 'on' => self::SCENARIO_ACTIVATE], + ['account', 'validateOtpEnabled', 'on' => self::SCENARIO_DISABLE], + ['token', 'required', 'message' => E::OTP_TOKEN_REQUIRED, 'on' => $bothScenarios], + ['token', TotpValidator::class, 'account' => $this->account, 'window' => 30, 'on' => $bothScenarios], + ['password', PasswordRequiredValidator::class, 'account' => $this->account, 'on' => $bothScenarios], ]; } @@ -58,6 +60,47 @@ class TwoFactorAuthForm extends ApiForm { ]; } + public function activate(): bool { + if (!$this->validate()) { + return false; + } + + $account = $this->account; + $account->is_otp_enabled = true; + if (!$account->save()) { + throw new ErrorException('Cannot enable otp for account'); + } + + return true; + } + + public function disable(): bool { + if (!$this->validate()) { + return false; + } + + $account = $this->account; + $account->is_otp_enabled = false; + $account->otp_secret = null; + if (!$account->save()) { + throw new ErrorException('Cannot disable otp for account'); + } + + return true; + } + + public function validateOtpDisabled($attribute) { + if ($this->account->is_otp_enabled) { + $this->addError($attribute, E::OTP_ALREADY_ENABLED); + } + } + + public function validateOtpEnabled($attribute) { + if (!$this->account->is_otp_enabled) { + $this->addError($attribute, E::OTP_NOT_ENABLED); + } + } + public function getAccount(): Account { return $this->account; } diff --git a/common/helpers/Error.php b/common/helpers/Error.php index 6d2347c..8425256 100644 --- a/common/helpers/Error.php +++ b/common/helpers/Error.php @@ -56,5 +56,7 @@ final class Error { const OTP_TOKEN_REQUIRED = 'error.otp_token_required'; const OTP_TOKEN_INCORRECT = 'error.otp_token_incorrect'; + const OTP_ALREADY_ENABLED = 'error.otp_already_enabled'; + const OTP_NOT_ENABLED = 'error.otp_not_enabled'; } From 6aab2592b477bc6c9b31ee3c7807abdc5a3d817f Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 23 Jan 2017 02:07:29 +0300 Subject: [PATCH 14/23] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B2=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F/=D0=BE=D1=82=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20OTP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/controllers/TwoFactorAuthController.php | 14 ++- api/models/profile/TwoFactorAuthForm.php | 4 +- common/helpers/Error.php | 4 +- .../api/_pages/TwoFactorAuthRoute.php | 17 +++- .../functional/TwoFactorAuthDisableCest.php | 61 ++++++++++++ .../functional/TwoFactorAuthEnableCest.php | 61 ++++++++++++ .../models/profile/TwoFactorAuthFormTest.php | 98 +++++++++++++++++++ .../common/fixtures/data/accounts.php | 30 ++++++ 8 files changed, 281 insertions(+), 8 deletions(-) create mode 100644 tests/codeception/api/functional/TwoFactorAuthDisableCest.php create mode 100644 tests/codeception/api/functional/TwoFactorAuthEnableCest.php diff --git a/api/controllers/TwoFactorAuthController.php b/api/controllers/TwoFactorAuthController.php index b6a50ee..6b3b925 100644 --- a/api/controllers/TwoFactorAuthController.php +++ b/api/controllers/TwoFactorAuthController.php @@ -17,16 +17,22 @@ class TwoFactorAuthController extends Controller { 'class' => AccessControl::class, 'rules' => [ [ + 'allow' => true, 'class' => ActiveUserRule::class, - 'actions' => [ - 'credentials', - ], ], ], ], ]); } + public function verbs() { + return [ + 'credentials' => ['GET'], + 'activate' => ['POST'], + 'disable' => ['DELETE'], + ]; + } + public function actionCredentials() { $account = Yii::$app->user->identity; $model = new TwoFactorAuthForm($account); @@ -37,6 +43,7 @@ class TwoFactorAuthController extends Controller { public function actionActivate() { $account = Yii::$app->user->identity; $model = new TwoFactorAuthForm($account, ['scenario' => TwoFactorAuthForm::SCENARIO_ACTIVATE]); + $model->load(Yii::$app->request->post()); if (!$model->activate()) { return [ 'success' => false, @@ -52,6 +59,7 @@ class TwoFactorAuthController extends Controller { public function actionDisable() { $account = Yii::$app->user->identity; $model = new TwoFactorAuthForm($account, ['scenario' => TwoFactorAuthForm::SCENARIO_DISABLE]); + $model->load(Yii::$app->request->getBodyParams()); if (!$model->disable()) { return [ 'success' => false, diff --git a/api/models/profile/TwoFactorAuthForm.php b/api/models/profile/TwoFactorAuthForm.php index 398cf33..907e686 100644 --- a/api/models/profile/TwoFactorAuthForm.php +++ b/api/models/profile/TwoFactorAuthForm.php @@ -61,7 +61,7 @@ class TwoFactorAuthForm extends ApiForm { } public function activate(): bool { - if (!$this->validate()) { + if ($this->scenario !== self::SCENARIO_ACTIVATE || !$this->validate()) { return false; } @@ -75,7 +75,7 @@ class TwoFactorAuthForm extends ApiForm { } public function disable(): bool { - if (!$this->validate()) { + if ($this->scenario !== self::SCENARIO_DISABLE || !$this->validate()) { return false; } diff --git a/common/helpers/Error.php b/common/helpers/Error.php index 8425256..3baa1b8 100644 --- a/common/helpers/Error.php +++ b/common/helpers/Error.php @@ -54,8 +54,8 @@ final class Error { const SUBJECT_REQUIRED = 'error.subject_required'; const MESSAGE_REQUIRED = 'error.message_required'; - const OTP_TOKEN_REQUIRED = 'error.otp_token_required'; - const OTP_TOKEN_INCORRECT = 'error.otp_token_incorrect'; + const OTP_TOKEN_REQUIRED = 'error.token_required'; + const OTP_TOKEN_INCORRECT = 'error.token_incorrect'; const OTP_ALREADY_ENABLED = 'error.otp_already_enabled'; const OTP_NOT_ENABLED = 'error.otp_not_enabled'; diff --git a/tests/codeception/api/_pages/TwoFactorAuthRoute.php b/tests/codeception/api/_pages/TwoFactorAuthRoute.php index 32ffeaa..ed677bb 100644 --- a/tests/codeception/api/_pages/TwoFactorAuthRoute.php +++ b/tests/codeception/api/_pages/TwoFactorAuthRoute.php @@ -8,9 +8,24 @@ use yii\codeception\BasePage; */ class TwoFactorAuthRoute extends BasePage { + public $route = '/two-factor-auth'; + public function credentials() { - $this->route = '/two-factor-auth'; $this->actor->sendGET($this->getUrl()); } + public function enable($token = null, $password = null) { + $this->actor->sendPOST($this->getUrl(), [ + 'token' => $token, + 'password' => $password, + ]); + } + + public function disable($token = null, $password = null) { + $this->actor->sendDELETE($this->getUrl(), [ + 'token' => $token, + 'password' => $password, + ]); + } + } diff --git a/tests/codeception/api/functional/TwoFactorAuthDisableCest.php b/tests/codeception/api/functional/TwoFactorAuthDisableCest.php new file mode 100644 index 0000000..974857e --- /dev/null +++ b/tests/codeception/api/functional/TwoFactorAuthDisableCest.php @@ -0,0 +1,61 @@ +route = new TwoFactorAuthRoute($I); + } + + public function testFails(FunctionalTester $I) { + $I->loggedInAsActiveAccount('AccountWithEnabledOtp', 'password_0'); + + $this->route->disable(); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'token' => 'error.token_required', + 'password' => 'error.password_required', + ], + ]); + + $this->route->disable('123456', 'invalid_password'); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'token' => 'error.token_incorrect', + 'password' => 'error.password_incorrect', + ], + ]); + + $I->loggedInAsActiveAccount('AccountWithOtpSecret', 'password_0'); + $this->route->disable('123456', 'invalid_password'); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'account' => 'error.otp_not_enabled', + ], + ]); + } + + public function testSuccessEnable(FunctionalTester $I) { + $I->loggedInAsActiveAccount('AccountWithEnabledOtp', 'password_0'); + $totp = new TOTP(null, 'secret-secret-secret'); + $this->route->disable($totp->now(), 'password_0'); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + } + +} diff --git a/tests/codeception/api/functional/TwoFactorAuthEnableCest.php b/tests/codeception/api/functional/TwoFactorAuthEnableCest.php new file mode 100644 index 0000000..efec01f --- /dev/null +++ b/tests/codeception/api/functional/TwoFactorAuthEnableCest.php @@ -0,0 +1,61 @@ +route = new TwoFactorAuthRoute($I); + } + + public function testFails(FunctionalTester $I) { + $I->loggedInAsActiveAccount('AccountWithOtpSecret', 'password_0'); + + $this->route->enable(); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'token' => 'error.token_required', + 'password' => 'error.password_required', + ], + ]); + + $this->route->enable('123456', 'invalid_password'); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'token' => 'error.token_incorrect', + 'password' => 'error.password_incorrect', + ], + ]); + + $I->loggedInAsActiveAccount('AccountWithEnabledOtp', 'password_0'); + $this->route->enable('123456', 'invalid_password'); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'account' => 'error.otp_already_enabled', + ], + ]); + } + + public function testSuccessEnable(FunctionalTester $I) { + $I->loggedInAsActiveAccount('AccountWithOtpSecret', 'password_0'); + $totp = new TOTP(null, 'some otp secret value'); + $this->route->enable($totp->now(), 'password_0'); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + } + +} diff --git a/tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php b/tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php index 37587da..a160805 100644 --- a/tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php +++ b/tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php @@ -2,7 +2,9 @@ namespace tests\codeception\api\unit\models\profile; use api\models\profile\TwoFactorAuthForm; +use common\helpers\Error as E; use common\models\Account; +use OTPHP\TOTP; use tests\codeception\api\unit\TestCase; class TwoFactorAuthFormTest extends TestCase { @@ -64,4 +66,100 @@ class TwoFactorAuthFormTest extends TestCase { $this->assertEquals('some valid totp secret value', $result['secret']); } + public function testActivate() { + /** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */ + $account = $this->getMockBuilder(Account::class) + ->setMethods(['save']) + ->getMock(); + + $account->expects($this->once()) + ->method('save') + ->willReturn(true); + + $account->is_otp_enabled = false; + $account->otp_secret = 'mock secret'; + + /** @var TwoFactorAuthForm|\PHPUnit_Framework_MockObject_MockObject $model */ + $model = $this->getMockBuilder(TwoFactorAuthForm::class) + ->setMethods(['validate']) + ->setConstructorArgs([$account, ['scenario' => TwoFactorAuthForm::SCENARIO_ACTIVATE]]) + ->getMock(); + + $model->expects($this->once()) + ->method('validate') + ->willReturn(true); + + $this->assertTrue($model->activate()); + $this->assertTrue($account->is_otp_enabled); + } + + public function testDisable() { + /** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */ + $account = $this->getMockBuilder(Account::class) + ->setMethods(['save']) + ->getMock(); + + $account->expects($this->once()) + ->method('save') + ->willReturn(true); + + $account->is_otp_enabled = true; + $account->otp_secret = 'mock secret'; + + /** @var TwoFactorAuthForm|\PHPUnit_Framework_MockObject_MockObject $model */ + $model = $this->getMockBuilder(TwoFactorAuthForm::class) + ->setMethods(['validate']) + ->setConstructorArgs([$account, ['scenario' => TwoFactorAuthForm::SCENARIO_DISABLE]]) + ->getMock(); + + $model->expects($this->once()) + ->method('validate') + ->willReturn(true); + + $this->assertTrue($model->disable()); + $this->assertNull($account->otp_secret); + $this->assertFalse($account->is_otp_enabled); + } + + public function testValidateOtpDisabled() { + $account = new Account(); + $account->is_otp_enabled = true; + $model = new TwoFactorAuthForm($account); + $model->validateOtpDisabled('account'); + $this->assertEquals([E::OTP_ALREADY_ENABLED], $model->getErrors('account')); + + $account = new Account(); + $account->is_otp_enabled = false; + $model = new TwoFactorAuthForm($account); + $model->validateOtpDisabled('account'); + $this->assertEmpty($model->getErrors('account')); + } + + public function testValidateOtpEnabled() { + $account = new Account(); + $account->is_otp_enabled = false; + $model = new TwoFactorAuthForm($account); + $model->validateOtpEnabled('account'); + $this->assertEquals([E::OTP_NOT_ENABLED], $model->getErrors('account')); + + $account = new Account(); + $account->is_otp_enabled = true; + $model = new TwoFactorAuthForm($account); + $model->validateOtpEnabled('account'); + $this->assertEmpty($model->getErrors('account')); + } + + public function testGetTotp() { + $account = new Account(); + $account->otp_secret = 'mock secret'; + $account->email = 'check@this.email'; + + $model = new TwoFactorAuthForm($account); + $totp = $model->getTotp(); + $this->assertInstanceOf(TOTP::class, $totp); + $this->assertEquals('check@this.email', $totp->getLabel()); + $this->assertEquals('mock secret', $totp->getSecret()); + $this->assertEquals('Ely.by', $totp->getIssuer()); + } + } diff --git a/tests/codeception/common/fixtures/data/accounts.php b/tests/codeception/common/fixtures/data/accounts.php index 33db104..c8c79d3 100644 --- a/tests/codeception/common/fixtures/data/accounts.php +++ b/tests/codeception/common/fixtures/data/accounts.php @@ -146,4 +146,34 @@ return [ 'created_at' => 1474404139, 'updated_at' => 1474404149, ], + 'account-with-otp-secret' => [ + 'id' => 12, + 'uuid' => '9e9dcd11-2322-46dc-a992-e822a422726e', + 'username' => 'AccountWithOtpSecret', + 'email' => 'sava-galkin@mail.ru', + 'password_hash' => '$2y$13$2rYkap5T6jG8z/mMK8a3Ou6aZxJcmAaTha6FEuujvHEmybSHRzW5e', # password_0 + 'password_hash_strategy' => \common\models\Account::PASS_HASH_STRATEGY_YII2, + 'lang' => 'ru', + 'status' => \common\models\Account::STATUS_ACTIVE, + 'rules_agreement_version' => \common\LATEST_RULES_VERSION, + 'otp_secret' => 'some otp secret value', + 'is_otp_enabled' => false, + 'created_at' => 1485124615, + 'updated_at' => 1485124615, + ], + 'account-with-enabled-otp' => [ + 'id' => 13, + 'uuid' => '15d0afa7-a2bb-44d3-9f31-964cbccc6043', + 'username' => 'AccountWithEnabledOtp', + 'email' => 'otp@gmail.com', + 'password_hash' => '$2y$13$2rYkap5T6jG8z/mMK8a3Ou6aZxJcmAaTha6FEuujvHEmybSHRzW5e', # password_0 + 'password_hash_strategy' => \common\models\Account::PASS_HASH_STRATEGY_YII2, + 'lang' => 'ru', + 'status' => \common\models\Account::STATUS_ACTIVE, + 'rules_agreement_version' => \common\LATEST_RULES_VERSION, + 'otp_secret' => 'secret-secret-secret', + 'is_otp_enabled' => true, + 'created_at' => 1485124685, + 'updated_at' => 1485124685, + ], ]; From a2e1e9a805420ee795f855d38baae9734944c926 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 23 Jan 2017 14:22:20 +0300 Subject: [PATCH 15/23] =?UTF-8?q?=D0=92=20=D1=84=D0=BE=D1=80=D0=BC=D1=83?= =?UTF-8?q?=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0=20=D0=B2=D0=BD=D0=B5=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BD=D0=B0=20=D0=BD=D0=B0=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B2=D0=BA=D0=BB=D1=8E=D1=87=D1=91=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9=20OTP=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/models/authentication/LoginForm.php | 23 +++++ .../api/_pages/AuthenticationRoute.php | 12 ++- .../codeception/api/functional/LoginCest.php | 63 ++++++++++++++ .../models/authentication/LoginFormTest.php | 85 +++++++++++++------ 4 files changed, 153 insertions(+), 30 deletions(-) diff --git a/api/models/authentication/LoginForm.php b/api/models/authentication/LoginForm.php index c1ad5e6..004dc79 100644 --- a/api/models/authentication/LoginForm.php +++ b/api/models/authentication/LoginForm.php @@ -3,6 +3,7 @@ namespace api\models\authentication; use api\models\AccountIdentity; use api\models\base\ApiForm; +use api\validators\TotpValidator; use common\helpers\Error as E; use api\traits\AccountFinder; use common\models\Account; @@ -16,6 +17,7 @@ class LoginForm extends ApiForm { public $login; public $password; + public $token; public $rememberMe = false; public function rules() { @@ -28,6 +30,11 @@ class LoginForm extends ApiForm { }, 'message' => E::PASSWORD_REQUIRED], ['password', 'validatePassword'], + ['token', 'required', 'when' => function(self $model) { + return !$model->hasErrors() && $model->getAccount()->is_otp_enabled; + }, 'message' => E::OTP_TOKEN_REQUIRED], + ['token', 'validateTotpToken'], + ['login', 'validateActivity'], ['rememberMe', 'boolean'], @@ -51,6 +58,22 @@ class LoginForm extends ApiForm { } } + public function validateTotpToken($attribute) { + if ($this->hasErrors()) { + return; + } + + $account = $this->getAccount(); + if (!$account->is_otp_enabled) { + return; + } + + $validator = new TotpValidator(['account' => $account]); + if (!$validator->validate($this->token, $error)) { + $this->addError($attribute, $error); + } + } + public function validateActivity($attribute) { if (!$this->hasErrors()) { $account = $this->getAccount(); diff --git a/tests/codeception/api/_pages/AuthenticationRoute.php b/tests/codeception/api/_pages/AuthenticationRoute.php index 7ed0042..087bb55 100644 --- a/tests/codeception/api/_pages/AuthenticationRoute.php +++ b/tests/codeception/api/_pages/AuthenticationRoute.php @@ -8,15 +8,23 @@ use yii\codeception\BasePage; */ class AuthenticationRoute extends BasePage { - public function login($login = '', $password = '', $rememberMe = false) { + /** + * @param string $login + * @param string $password + * @param string|bool|null $rememberMeOrToken + * @param bool $rememberMe + */ + public function login($login = '', $password = '', $rememberMeOrToken = null, $rememberMe = false) { $this->route = ['authentication/login']; $params = [ 'login' => $login, 'password' => $password, ]; - if ($rememberMe) { + if ((is_bool($rememberMeOrToken) && $rememberMeOrToken) || $rememberMe) { $params['rememberMe'] = 1; + } elseif ($rememberMeOrToken !== null) { + $params['token'] = $rememberMeOrToken; } $this->actor->sendPOST($this->getUrl(), $params); diff --git a/tests/codeception/api/functional/LoginCest.php b/tests/codeception/api/functional/LoginCest.php index 4c39e63..ab11f50 100644 --- a/tests/codeception/api/functional/LoginCest.php +++ b/tests/codeception/api/functional/LoginCest.php @@ -1,6 +1,7 @@ wantTo('see token don\'t have errors if email, username or token not set'); + $route->login(); + $I->canSeeResponseContainsJson([ + 'success' => false, + ]); + $I->cantSeeResponseJsonMatchesJsonPath('$.errors.token'); + + $I->wantTo('see token don\'t have errors if username not exists in database'); + $route->login('non-exist-username', 'random-password'); + $I->canSeeResponseContainsJson([ + 'success' => false, + ]); + $I->cantSeeResponseJsonMatchesJsonPath('$.errors.token'); + + $I->wantTo('see token don\'t has errors if email not exists in database'); + $route->login('not-exist@user.com', 'random-password'); + $I->canSeeResponseContainsJson([ + 'success' => false, + ]); + $I->cantSeeResponseJsonMatchesJsonPath('$.errors.token'); + + $I->wantTo('see token don\'t has errors if email correct, but password wrong'); + $route->login('not-exist@user.com', 'random-password'); + $I->canSeeResponseContainsJson([ + 'success' => false, + ]); + $I->cantSeeResponseJsonMatchesJsonPath('$.errors.token'); + + $I->wantTo('see error.token_required if username and password correct, but account have enable otp'); + $route->login('AccountWithEnabledOtp', 'password_0'); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'token' => 'error.token_required', + ], + ]); + + $I->wantTo('see error.token_incorrect if username and password correct, but token wrong'); + $route->login('AccountWithEnabledOtp', 'password_0', '123456'); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'token' => 'error.token_incorrect', + ], + ]); + } + public function testLoginByUsernameCorrect(FunctionalTester $I) { $route = new AuthenticationRoute($I); @@ -151,4 +202,16 @@ class LoginCest { $I->canSeeAuthCredentials(true); } + public function testLoginByAccountWithOtp(FunctionalTester $I) { + $route = new AuthenticationRoute($I); + + $I->wantTo('login into account with enabled otp'); + $route->login('AccountWithEnabledOtp', 'password_0', (new TOTP(null, 'secret-secret-secret'))->now()); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + $I->cantSeeResponseJsonMatchesJsonPath('$.errors'); + $I->canSeeAuthCredentials(false); + } + } diff --git a/tests/codeception/api/unit/models/authentication/LoginFormTest.php b/tests/codeception/api/unit/models/authentication/LoginFormTest.php index e32f243..0cf524e 100644 --- a/tests/codeception/api/unit/models/authentication/LoginFormTest.php +++ b/tests/codeception/api/unit/models/authentication/LoginFormTest.php @@ -6,6 +6,7 @@ use api\models\AccountIdentity; use api\models\authentication\LoginForm; use Codeception\Specify; use common\models\Account; +use OTPHP\TOTP; use tests\codeception\api\unit\TestCase; use tests\codeception\common\fixtures\AccountFixture; @@ -38,7 +39,7 @@ class LoginFormTest extends TestCase { 'account' => null, ]); $model->validateLogin('login'); - expect($model->getErrors('login'))->equals(['error.login_not_exist']); + $this->assertEquals(['error.login_not_exist'], $model->getErrors('login')); }); $this->specify('no errors if login exists', function () { @@ -47,7 +48,7 @@ class LoginFormTest extends TestCase { 'account' => new AccountIdentity(), ]); $model->validateLogin('login'); - expect($model->getErrors('login'))->isEmpty(); + $this->assertEmpty($model->getErrors('login')); }); } @@ -58,7 +59,7 @@ class LoginFormTest extends TestCase { 'account' => new AccountIdentity(['password' => '12345678']), ]); $model->validatePassword('password'); - expect($model->getErrors('password'))->equals(['error.password_incorrect']); + $this->assertEquals(['error.password_incorrect'], $model->getErrors('password')); }); $this->specify('no errors if password valid', function () { @@ -67,7 +68,35 @@ class LoginFormTest extends TestCase { 'account' => new AccountIdentity(['password' => '12345678']), ]); $model->validatePassword('password'); - expect($model->getErrors('password'))->isEmpty(); + $this->assertEmpty($model->getErrors('password')); + }); + } + + public function testValidateTotpToken() { + $account = new AccountIdentity(['password' => '12345678']); + $account->password = '12345678'; + $account->is_otp_enabled = true; + $account->otp_secret = 'mock secret'; + + $this->specify('error.token_incorrect if totp invalid', function() use ($account) { + $model = $this->createModel([ + 'password' => '12345678', + 'token' => '321123', + 'account' => $account, + ]); + $model->validateTotpToken('token'); + $this->assertEquals(['error.token_incorrect'], $model->getErrors('token')); + }); + + $totp = new TOTP(null, 'mock secret'); + $this->specify('no errors if password valid', function() use ($account, $totp) { + $model = $this->createModel([ + 'password' => '12345678', + 'token' => $totp->now(), + 'account' => $account, + ]); + $model->validateTotpToken('token'); + $this->assertEmpty($model->getErrors('token')); }); } @@ -77,7 +106,7 @@ class LoginFormTest extends TestCase { 'account' => new AccountIdentity(['status' => Account::STATUS_REGISTERED]), ]); $model->validateActivity('login'); - expect($model->getErrors('login'))->equals(['error.account_not_activated']); + $this->assertEquals(['error.account_not_activated'], $model->getErrors('login')); }); $this->specify('error.account_banned if account has banned status', function () { @@ -85,7 +114,7 @@ class LoginFormTest extends TestCase { 'account' => new AccountIdentity(['status' => Account::STATUS_BANNED]), ]); $model->validateActivity('login'); - expect($model->getErrors('login'))->equals(['error.account_banned']); + $this->assertEquals(['error.account_banned'], $model->getErrors('login')); }); $this->specify('no errors if account active', function () { @@ -93,36 +122,36 @@ class LoginFormTest extends TestCase { 'account' => new AccountIdentity(['status' => Account::STATUS_ACTIVE]), ]); $model->validateActivity('login'); - expect($model->getErrors('login'))->isEmpty(); + $this->assertEmpty($model->getErrors('login')); }); } public function testLogin() { - $this->specify('user should be able to login with correct username and password', function () { - $model = $this->createModel([ - 'login' => 'erickskrauch', + $model = $this->createModel([ + 'login' => 'erickskrauch', + 'password' => '12345678', + 'account' => new AccountIdentity([ + 'username' => 'erickskrauch', 'password' => '12345678', - 'account' => new AccountIdentity([ - 'username' => 'erickskrauch', - 'password' => '12345678', - 'status' => Account::STATUS_ACTIVE, - ]), - ]); - expect('model should login user', $model->login())->isInstanceOf(LoginResult::class); - expect('error message should not be set', $model->errors)->isEmpty(); - }); + 'status' => Account::STATUS_ACTIVE, + ]), + ]); + $this->assertInstanceOf(LoginResult::class, $model->login(), 'model should login user'); + $this->assertEmpty($model->getErrors(), 'error message should not be set'); } public function testLoginWithRehashing() { - $this->specify('user, that login using account with old pass hash strategy should update it automatically', function () { - $model = new LoginForm([ - 'login' => $this->tester->grabFixture('accounts', 'user-with-old-password-type')['username'], - 'password' => '12345678', - ]); - expect($model->login())->isInstanceOf(LoginResult::class); - expect($model->errors)->isEmpty(); - expect($model->getAccount()->password_hash_strategy)->equals(Account::PASS_HASH_STRATEGY_YII2); - }); + $model = new LoginForm([ + 'login' => $this->tester->grabFixture('accounts', 'user-with-old-password-type')['username'], + 'password' => '12345678', + ]); + $this->assertInstanceOf(LoginResult::class, $model->login()); + $this->assertEmpty($model->getErrors()); + $this->assertEquals( + Account::PASS_HASH_STRATEGY_YII2, + $model->getAccount()->password_hash_strategy, + 'user, that login using account with old pass hash strategy should update it automatically' + ); } /** From e82b8aa8cfb3d2d8604ba56445c7cb204becd823 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 23 Jan 2017 14:38:21 +0300 Subject: [PATCH 16/23] =?UTF-8?q?=D0=92=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=20/currect=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=20=D1=81=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F=20=D0=B2=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D1=91=D0=BD=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20OTP=20=D0=B0?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/controllers/AccountsController.php | 1 + tests/codeception/api/functional/AccountsCurrentCest.php | 1 + 2 files changed, 2 insertions(+) diff --git a/api/controllers/AccountsController.php b/api/controllers/AccountsController.php index cec7384..ec3fdba 100644 --- a/api/controllers/AccountsController.php +++ b/api/controllers/AccountsController.php @@ -69,6 +69,7 @@ class AccountsController extends Controller { 'passwordChangedAt' => $account->password_changed_at, 'hasMojangUsernameCollision' => $account->hasMojangUsernameCollision(), 'shouldAcceptRules' => !$account->isAgreedWithActualRules(), + 'isOtpEnabled' => (bool)$account->is_otp_enabled, ]; } diff --git a/tests/codeception/api/functional/AccountsCurrentCest.php b/tests/codeception/api/functional/AccountsCurrentCest.php index cc4daa2..ef4e4df 100644 --- a/tests/codeception/api/functional/AccountsCurrentCest.php +++ b/tests/codeception/api/functional/AccountsCurrentCest.php @@ -29,6 +29,7 @@ class AccountsCurrentCest { 'isActive' => true, 'hasMojangUsernameCollision' => false, 'shouldAcceptRules' => false, + 'isOtpEnabled' => false, ]); $I->canSeeResponseJsonMatchesJsonPath('$.passwordChangedAt'); } From 4695b6e72444fabf590707c3c807cadcfb916a55 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 23 Jan 2017 23:50:13 +0300 Subject: [PATCH 17/23] =?UTF-8?q?=D0=92=D0=BD=D0=B5=D0=B4=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20OTP=20=D0=B2=20=D0=BF=D1=80=D0=BE=D1=86=D0=B5=D1=81?= =?UTF-8?q?=D1=81=20=D0=B2=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../authentication/ForgotPasswordForm.php | 20 ++++ api/models/authentication/LoginForm.php | 4 +- .../api/_pages/AuthenticationRoute.php | 3 +- .../api/functional/ForgotPasswordCest.php | 98 +++++++++++++++---- .../authentication/ForgotPasswordFormTest.php | 18 +++- 5 files changed, 119 insertions(+), 24 deletions(-) diff --git a/api/models/authentication/ForgotPasswordForm.php b/api/models/authentication/ForgotPasswordForm.php index 23b302a..a947f82 100644 --- a/api/models/authentication/ForgotPasswordForm.php +++ b/api/models/authentication/ForgotPasswordForm.php @@ -2,6 +2,7 @@ namespace api\models\authentication; use api\models\base\ApiForm; +use api\validators\TotpValidator; use common\helpers\Error as E; use api\traits\AccountFinder; use common\components\UserFriendlyRandomKey; @@ -16,11 +17,16 @@ class ForgotPasswordForm extends ApiForm { use AccountFinder; public $login; + public $token; public function rules() { return [ ['login', 'required', 'message' => E::LOGIN_REQUIRED], ['login', 'validateLogin'], + ['token', 'required', 'when' => function(self $model) { + return !$this->hasErrors() && $model->getAccount()->is_otp_enabled; + }, 'message' => E::OTP_TOKEN_REQUIRED], + ['token', 'validateTotpToken'], ['login', 'validateActivity'], ['login', 'validateFrequency'], ]; @@ -34,6 +40,20 @@ class ForgotPasswordForm extends ApiForm { } } + public function validateTotpToken($attribute) { + if ($this->hasErrors()) { + return; + } + + $account = $this->getAccount(); + if (!$account->is_otp_enabled) { + return; + } + + $validator = new TotpValidator(['account' => $account]); + $validator->validateAttribute($this, $attribute); + } + public function validateActivity($attribute) { if (!$this->hasErrors()) { $account = $this->getAccount(); diff --git a/api/models/authentication/LoginForm.php b/api/models/authentication/LoginForm.php index 004dc79..044c4ee 100644 --- a/api/models/authentication/LoginForm.php +++ b/api/models/authentication/LoginForm.php @@ -69,9 +69,7 @@ class LoginForm extends ApiForm { } $validator = new TotpValidator(['account' => $account]); - if (!$validator->validate($this->token, $error)) { - $this->addError($attribute, $error); - } + $validator->validateAttribute($this, $attribute); } public function validateActivity($attribute) { diff --git a/tests/codeception/api/_pages/AuthenticationRoute.php b/tests/codeception/api/_pages/AuthenticationRoute.php index 087bb55..bfe5784 100644 --- a/tests/codeception/api/_pages/AuthenticationRoute.php +++ b/tests/codeception/api/_pages/AuthenticationRoute.php @@ -35,10 +35,11 @@ class AuthenticationRoute extends BasePage { $this->actor->sendPOST($this->getUrl()); } - public function forgotPassword($login = '') { + public function forgotPassword($login = null, $token = null) { $this->route = ['authentication/forgot-password']; $this->actor->sendPOST($this->getUrl(), [ 'login' => $login, + 'token' => $token, ]); } diff --git a/tests/codeception/api/functional/ForgotPasswordCest.php b/tests/codeception/api/functional/ForgotPasswordCest.php index 5f36a93..0444838 100644 --- a/tests/codeception/api/functional/ForgotPasswordCest.php +++ b/tests/codeception/api/functional/ForgotPasswordCest.php @@ -1,41 +1,87 @@ wantTo('create new password recover request by passing email'); - $route->forgotPassword('admin@ely.by'); + public function _before(FunctionalTester $I) { + $this->route = new AuthenticationRoute($I); + } + + public function testWrongInput(FunctionalTester $I) { + $I->wantTo('see reaction on invalid input'); + + $this->route->forgotPassword(); $I->canSeeResponseContainsJson([ - 'success' => true, + 'success' => false, + 'errors' => [ + 'login' => 'error.login_required', + ], ]); - $I->canSeeResponseJsonMatchesJsonPath('$.data.canRepeatIn'); - $I->canSeeResponseJsonMatchesJsonPath('$.data.repeatFrequency'); + + $this->route->forgotPassword('becauseimbatman!'); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'login' => 'error.login_not_exist', + ], + ]); + + $this->route->forgotPassword('AccountWithEnabledOtp'); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'token' => 'error.token_required', + ], + ]); + + $this->route->forgotPassword('AccountWithEnabledOtp'); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'token' => 'error.token_required', + ], + ]); + + $this->route->forgotPassword('AccountWithEnabledOtp', '123456'); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'token' => 'error.token_incorrect', + ], + ]); + } + + public function testForgotPasswordByEmail(FunctionalTester $I) { + $I->wantTo('create new password recover request by passing email'); + $this->route->forgotPassword('admin@ely.by'); + $this->assertSuccessResponse($I, false); } public function testForgotPasswordByUsername(FunctionalTester $I) { - $route = new AuthenticationRoute($I); - $I->wantTo('create new password recover request by passing username'); - $route->forgotPassword('Admin'); - $I->canSeeResponseContainsJson([ - 'success' => true, - ]); - $I->canSeeResponseJsonMatchesJsonPath('$.data.canRepeatIn'); - $I->canSeeResponseJsonMatchesJsonPath('$.data.repeatFrequency'); - $I->canSeeResponseJsonMatchesJsonPath('$.data.emailMask'); + $this->route->forgotPassword('Admin'); + $this->assertSuccessResponse($I, true); + } + + public function testForgotPasswordByAccountWithOtp(FunctionalTester $I) { + $I->wantTo('create new password recover request by passing username and otp token'); + $totp = new TOTP(null, 'secret-secret-secret'); + $this->route->forgotPassword('AccountWithEnabledOtp', $totp->now()); + $this->assertSuccessResponse($I, true); } public function testDataForFrequencyError(FunctionalTester $I) { - $route = new AuthenticationRoute($I); - $I->wantTo('get info about time to repeat recover password request'); - $route->forgotPassword('Notch'); + $this->route->forgotPassword('Notch'); $I->canSeeResponseContainsJson([ 'success' => false, 'errors' => [ @@ -46,4 +92,18 @@ class ForgotPasswordCest { $I->canSeeResponseJsonMatchesJsonPath('$.data.repeatFrequency'); } + /** + * @param FunctionalTester $I + */ + private function assertSuccessResponse(FunctionalTester $I, bool $expectEmailMask = false): void { + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.data.canRepeatIn'); + $I->canSeeResponseJsonMatchesJsonPath('$.data.repeatFrequency'); + if ($expectEmailMask) { + $I->canSeeResponseJsonMatchesJsonPath('$.data.emailMask'); + } + } + } diff --git a/tests/codeception/api/unit/models/authentication/ForgotPasswordFormTest.php b/tests/codeception/api/unit/models/authentication/ForgotPasswordFormTest.php index dc68fb5..c97723f 100644 --- a/tests/codeception/api/unit/models/authentication/ForgotPasswordFormTest.php +++ b/tests/codeception/api/unit/models/authentication/ForgotPasswordFormTest.php @@ -4,6 +4,7 @@ namespace codeception\api\unit\models\authentication; use api\models\authentication\ForgotPasswordForm; use Codeception\Specify; use common\models\EmailActivation; +use OTPHP\TOTP; use tests\codeception\api\unit\TestCase; use tests\codeception\common\fixtures\AccountFixture; use tests\codeception\common\fixtures\EmailActivationFixture; @@ -18,7 +19,7 @@ class ForgotPasswordFormTest extends TestCase { ]; } - public function testValidateAccount() { + public function testValidateLogin() { $this->specify('error.login_not_exist if login is invalid', function() { $model = new ForgotPasswordForm(['login' => 'unexist']); $model->validateLogin('login'); @@ -32,6 +33,21 @@ class ForgotPasswordFormTest extends TestCase { }); } + public function testValidateTotpToken() { + $model = new ForgotPasswordForm(); + $model->login = 'AccountWithEnabledOtp'; + $model->token = '123456'; + $model->validateTotpToken('token'); + $this->assertEquals(['error.token_incorrect'], $model->getErrors('token')); + + $totp = new TOTP(null, 'secret-secret-secret'); + $model = new ForgotPasswordForm(); + $model->login = 'AccountWithEnabledOtp'; + $model->token = $totp->now(); + $model->validateTotpToken('token'); + $this->assertEmpty($model->getErrors('token')); + } + public function testValidateActivity() { $this->specify('error.account_not_activated if account is not confirmed', function() { $model = new ForgotPasswordForm([ From fbaf48591fa2165b6cecd0163f93d6132a272a5a Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Tue, 24 Jan 2017 02:00:08 +0300 Subject: [PATCH 18/23] =?UTF-8?q?=D0=A0=D0=B5=D0=BE=D1=80=D0=B3=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=86=D0=B5=D1=81=D1=81=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D1=84?= =?UTF-8?q?=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/_support/FunctionalTester.php | 26 +++++++------------ .../functional/AccountsAcceptRulesCest.php | 2 +- ...AccountsChangeEmailConfirmNewEmailCest.php | 2 +- .../AccountsChangeEmailInitializeCest.php | 4 +-- .../AccountsChangeEmailSubmitNewEmailCest.php | 2 +- .../api/functional/AccountsChangeLangCest.php | 2 +- .../functional/AccountsChangePasswordCest.php | 2 +- .../functional/AccountsChangeUsernameCest.php | 4 +-- .../api/functional/AccountsCurrentCest.php | 2 +- .../codeception/api/functional/LogoutCest.php | 2 +- .../api/functional/OauthAuthCodeCest.php | 12 ++++----- .../TwoFactorAuthCredentialsCest.php | 2 +- .../functional/TwoFactorAuthDisableCest.php | 6 ++--- .../functional/TwoFactorAuthEnableCest.php | 6 ++--- .../api/functional/_steps/AuthserverSteps.php | 6 ++--- .../api/functional/_steps/OauthSteps.php | 2 +- .../functional/authserver/InvalidateCest.php | 2 +- .../api/functional/authserver/RefreshCest.php | 2 +- .../functional/authserver/ValidateCest.php | 2 +- .../api/functional/sessionserver/JoinCest.php | 4 +-- .../sessionserver/JoinLegacyCest.php | 4 +-- 21 files changed, 44 insertions(+), 52 deletions(-) diff --git a/tests/codeception/api/_support/FunctionalTester.php b/tests/codeception/api/_support/FunctionalTester.php index a06b732..e8b42d1 100644 --- a/tests/codeception/api/_support/FunctionalTester.php +++ b/tests/codeception/api/_support/FunctionalTester.php @@ -1,10 +1,10 @@ login = 'Admin'; - $form->password = 'password_0'; - } elseif ($login !== null && $password !== null) { - $form->login = $login; - $form->password = $password; - } else { - throw new InvalidArgumentException('login and password should be presented both.'); + public function amAuthenticated(string $asUsername = 'admin') { + /** @var AccountIdentity $account */ + $account = AccountIdentity::findOne(['username' => $asUsername]); + if ($account === null) { + throw new InvalidArgumentException("Cannot find account for username \"$asUsername\""); } - $result = $form->login(); - $this->assertInstanceOf(LoginResult::class, $result); - if ($result !== false) { - $this->amBearerAuthenticated($result->getJwt()); - } + $result = Yii::$app->user->login($account); + $this->amBearerAuthenticated($result->getJwt()); } public function notLoggedIn() { diff --git a/tests/codeception/api/functional/AccountsAcceptRulesCest.php b/tests/codeception/api/functional/AccountsAcceptRulesCest.php index 4f6fee7..ae8269d 100644 --- a/tests/codeception/api/functional/AccountsAcceptRulesCest.php +++ b/tests/codeception/api/functional/AccountsAcceptRulesCest.php @@ -16,7 +16,7 @@ class AccountsAcceptRulesCest { } public function testCurrent(FunctionalTester $I) { - $I->loggedInAsActiveAccount('Veleyaba', 'password_0'); + $I->amAuthenticated('Veleyaba'); $this->route->acceptRules(); $I->canSeeResponseCodeIs(200); $I->canSeeResponseIsJson(); diff --git a/tests/codeception/api/functional/AccountsChangeEmailConfirmNewEmailCest.php b/tests/codeception/api/functional/AccountsChangeEmailConfirmNewEmailCest.php index 0b8016d..53b31d8 100644 --- a/tests/codeception/api/functional/AccountsChangeEmailConfirmNewEmailCest.php +++ b/tests/codeception/api/functional/AccountsChangeEmailConfirmNewEmailCest.php @@ -17,7 +17,7 @@ class AccountsChangeEmailConfirmNewEmailCest { public function testConfirmNewEmail(FunctionalTester $I) { $I->wantTo('change my email and get changed value'); - $I->loggedInAsActiveAccount('CrafterGameplays', 'password_0'); + $I->amAuthenticated('CrafterGameplays'); $this->route->changeEmailConfirmNewEmail('H28HBDCHHAG2HGHGHS'); $I->canSeeResponseCodeIs(200); diff --git a/tests/codeception/api/functional/AccountsChangeEmailInitializeCest.php b/tests/codeception/api/functional/AccountsChangeEmailInitializeCest.php index 5e1f6b9..0dfa82e 100644 --- a/tests/codeception/api/functional/AccountsChangeEmailInitializeCest.php +++ b/tests/codeception/api/functional/AccountsChangeEmailInitializeCest.php @@ -17,7 +17,7 @@ class AccountsChangeEmailInitializeCest { public function testChangeEmailInitialize(FunctionalTester $I) { $I->wantTo('send current email confirmation'); - $I->loggedInAsActiveAccount(); + $I->amAuthenticated(); $this->route->changeEmailInitialize('password_0'); $I->canSeeResponseCodeIs(200); @@ -29,7 +29,7 @@ class AccountsChangeEmailInitializeCest { public function testChangeEmailInitializeFrequencyError(FunctionalTester $I) { $I->wantTo('see change email request frequency error'); - $I->loggedInAsActiveAccount('ILLIMUNATI', 'password_0'); + $I->amAuthenticated('ILLIMUNATI'); $this->route->changeEmailInitialize('password_0'); $I->canSeeResponseContainsJson([ diff --git a/tests/codeception/api/functional/AccountsChangeEmailSubmitNewEmailCest.php b/tests/codeception/api/functional/AccountsChangeEmailSubmitNewEmailCest.php index f70ac36..d09383b 100644 --- a/tests/codeception/api/functional/AccountsChangeEmailSubmitNewEmailCest.php +++ b/tests/codeception/api/functional/AccountsChangeEmailSubmitNewEmailCest.php @@ -18,7 +18,7 @@ class AccountsChangeEmailSubmitNewEmailCest { public function testSubmitNewEmail(FunctionalTester $I) { $I->wantTo('submit new email'); - $I->loggedInAsActiveAccount('ILLIMUNATI', 'password_0'); + $I->amAuthenticated('ILLIMUNATI'); $this->route->changeEmailSubmitNewEmail('H27HBDCHHAG2HGHGHS', 'my-new-email@ely.by'); $I->canSeeResponseCodeIs(200); diff --git a/tests/codeception/api/functional/AccountsChangeLangCest.php b/tests/codeception/api/functional/AccountsChangeLangCest.php index 4fc16fb..ffe229f 100644 --- a/tests/codeception/api/functional/AccountsChangeLangCest.php +++ b/tests/codeception/api/functional/AccountsChangeLangCest.php @@ -18,7 +18,7 @@ class AccountsChangeLangCest { public function testSubmitNewEmail(FunctionalTester $I) { $I->wantTo('change my account language'); - $I->loggedInAsActiveAccount(); + $I->amAuthenticated(); $this->route->changeLang('ru'); $I->canSeeResponseCodeIs(200); diff --git a/tests/codeception/api/functional/AccountsChangePasswordCest.php b/tests/codeception/api/functional/AccountsChangePasswordCest.php index 099d809..056a340 100644 --- a/tests/codeception/api/functional/AccountsChangePasswordCest.php +++ b/tests/codeception/api/functional/AccountsChangePasswordCest.php @@ -27,7 +27,7 @@ class AccountsChangePasswordCest { public function testChangePassword(FunctionalTester $I) { $I->wantTo('change my password'); - $I->loggedInAsActiveAccount(); + $I->amAuthenticated(); $this->route->changePassword('password_0', 'new-password', 'new-password'); $I->canSeeResponseCodeIs(200); diff --git a/tests/codeception/api/functional/AccountsChangeUsernameCest.php b/tests/codeception/api/functional/AccountsChangeUsernameCest.php index 73c54e6..b4a696f 100644 --- a/tests/codeception/api/functional/AccountsChangeUsernameCest.php +++ b/tests/codeception/api/functional/AccountsChangeUsernameCest.php @@ -26,7 +26,7 @@ class AccountsChangeUsernameCest { public function testChangeUsername(FunctionalTester $I) { $I->wantTo('change my nickname'); - $I->loggedInAsActiveAccount(); + $I->amAuthenticated(); $this->route->changeUsername('password_0', 'bruce_wayne'); $I->canSeeResponseCodeIs(200); @@ -38,7 +38,7 @@ class AccountsChangeUsernameCest { public function testChangeUsernameNotAvailable(FunctionalTester $I) { $I->wantTo('see, that nickname "in use" is not available'); - $I->loggedInAsActiveAccount(); + $I->amAuthenticated(); $this->route->changeUsername('password_0', 'Jon'); $I->canSeeResponseCodeIs(200); diff --git a/tests/codeception/api/functional/AccountsCurrentCest.php b/tests/codeception/api/functional/AccountsCurrentCest.php index ef4e4df..c53be87 100644 --- a/tests/codeception/api/functional/AccountsCurrentCest.php +++ b/tests/codeception/api/functional/AccountsCurrentCest.php @@ -16,7 +16,7 @@ class AccountsCurrentCest { } public function testCurrent(FunctionalTester $I) { - $I->loggedInAsActiveAccount(); + $I->amAuthenticated(); $this->route->current(); $I->canSeeResponseCodeIs(200); diff --git a/tests/codeception/api/functional/LogoutCest.php b/tests/codeception/api/functional/LogoutCest.php index 5acaa92..f0235ae 100644 --- a/tests/codeception/api/functional/LogoutCest.php +++ b/tests/codeception/api/functional/LogoutCest.php @@ -8,7 +8,7 @@ class LogoutCest { public function testLoginEmailOrUsername(FunctionalTester $I) { $route = new AuthenticationRoute($I); - $I->loggedInAsActiveAccount(); + $I->amAuthenticated(); $route->logout(); $I->canSeeResponseContainsJson([ 'success' => true, diff --git a/tests/codeception/api/functional/OauthAuthCodeCest.php b/tests/codeception/api/functional/OauthAuthCodeCest.php index be61f3e..55ac48b 100644 --- a/tests/codeception/api/functional/OauthAuthCodeCest.php +++ b/tests/codeception/api/functional/OauthAuthCodeCest.php @@ -51,7 +51,7 @@ class OauthAuthCodeCest { } public function testValidateWithDescriptionReplaceRequest(FunctionalTester $I) { - $I->loggedInAsActiveAccount(); + $I->amAuthenticated(); $I->wantTo('validate and get information with description replacement'); $this->route->validate($this->buildQueryParams( 'ely', @@ -73,13 +73,13 @@ class OauthAuthCodeCest { } public function testCompleteValidationAction(FunctionalTester $I) { - $I->loggedInAsActiveAccount(); + $I->amAuthenticated(); $I->wantTo('validate all oAuth params on complete request'); $this->testOauthParamsValidation($I, 'complete'); } public function testCompleteActionOnWrongConditions(FunctionalTester $I) { - $I->loggedInAsActiveAccount(); + $I->amAuthenticated(); $I->wantTo('get accept_required if I don\'t require any scope, but this is first time request'); $this->route->complete($this->buildQueryParams( @@ -112,7 +112,7 @@ class OauthAuthCodeCest { } public function testCompleteActionSuccess(FunctionalTester $I) { - $I->loggedInAsActiveAccount(); + $I->amAuthenticated(); $I->wantTo('get auth code if I require some scope and pass accept field'); $this->route->complete($this->buildQueryParams( 'ely', @@ -155,7 +155,7 @@ class OauthAuthCodeCest { } public function testAcceptRequiredOnNewScope(FunctionalTester $I) { - $I->loggedInAsActiveAccount(); + $I->amAuthenticated(); $I->wantTo('get accept_required if I have previous successful request, but now require some new scope'); $this->route->complete($this->buildQueryParams( 'ely', @@ -179,7 +179,7 @@ class OauthAuthCodeCest { } public function testCompleteActionWithDismissState(FunctionalTester $I) { - $I->loggedInAsActiveAccount(); + $I->amAuthenticated(); $I->wantTo('get access_denied error if I pass accept in false state'); $this->route->complete($this->buildQueryParams( 'ely', diff --git a/tests/codeception/api/functional/TwoFactorAuthCredentialsCest.php b/tests/codeception/api/functional/TwoFactorAuthCredentialsCest.php index 1084965..ce95923 100644 --- a/tests/codeception/api/functional/TwoFactorAuthCredentialsCest.php +++ b/tests/codeception/api/functional/TwoFactorAuthCredentialsCest.php @@ -16,7 +16,7 @@ class TwoFactorAuthCredentialsCest { } public function testGetCredentials(FunctionalTester $I) { - $I->loggedInAsActiveAccount(); + $I->amAuthenticated(); $this->route->credentials(); $I->canSeeResponseCodeIs(200); $I->canSeeResponseIsJson(); diff --git a/tests/codeception/api/functional/TwoFactorAuthDisableCest.php b/tests/codeception/api/functional/TwoFactorAuthDisableCest.php index 974857e..5e41f83 100644 --- a/tests/codeception/api/functional/TwoFactorAuthDisableCest.php +++ b/tests/codeception/api/functional/TwoFactorAuthDisableCest.php @@ -17,7 +17,7 @@ class TwoFactorAuthDisableCest { } public function testFails(FunctionalTester $I) { - $I->loggedInAsActiveAccount('AccountWithEnabledOtp', 'password_0'); + $I->amAuthenticated('AccountWithEnabledOtp'); $this->route->disable(); $I->canSeeResponseContainsJson([ @@ -37,7 +37,7 @@ class TwoFactorAuthDisableCest { ], ]); - $I->loggedInAsActiveAccount('AccountWithOtpSecret', 'password_0'); + $I->amAuthenticated('AccountWithOtpSecret'); $this->route->disable('123456', 'invalid_password'); $I->canSeeResponseContainsJson([ 'success' => false, @@ -48,7 +48,7 @@ class TwoFactorAuthDisableCest { } public function testSuccessEnable(FunctionalTester $I) { - $I->loggedInAsActiveAccount('AccountWithEnabledOtp', 'password_0'); + $I->amAuthenticated('AccountWithEnabledOtp'); $totp = new TOTP(null, 'secret-secret-secret'); $this->route->disable($totp->now(), 'password_0'); $I->canSeeResponseCodeIs(200); diff --git a/tests/codeception/api/functional/TwoFactorAuthEnableCest.php b/tests/codeception/api/functional/TwoFactorAuthEnableCest.php index efec01f..aee002e 100644 --- a/tests/codeception/api/functional/TwoFactorAuthEnableCest.php +++ b/tests/codeception/api/functional/TwoFactorAuthEnableCest.php @@ -17,7 +17,7 @@ class TwoFactorAuthEnableCest { } public function testFails(FunctionalTester $I) { - $I->loggedInAsActiveAccount('AccountWithOtpSecret', 'password_0'); + $I->amAuthenticated('AccountWithOtpSecret'); $this->route->enable(); $I->canSeeResponseContainsJson([ @@ -37,7 +37,7 @@ class TwoFactorAuthEnableCest { ], ]); - $I->loggedInAsActiveAccount('AccountWithEnabledOtp', 'password_0'); + $I->amAuthenticated('AccountWithEnabledOtp'); $this->route->enable('123456', 'invalid_password'); $I->canSeeResponseContainsJson([ 'success' => false, @@ -48,7 +48,7 @@ class TwoFactorAuthEnableCest { } public function testSuccessEnable(FunctionalTester $I) { - $I->loggedInAsActiveAccount('AccountWithOtpSecret', 'password_0'); + $I->amAuthenticated('AccountWithOtpSecret'); $totp = new TOTP(null, 'some otp secret value'); $this->route->enable($totp->now(), 'password_0'); $I->canSeeResponseCodeIs(200); diff --git a/tests/codeception/api/functional/_steps/AuthserverSteps.php b/tests/codeception/api/functional/_steps/AuthserverSteps.php index e065d36..9648ec2 100644 --- a/tests/codeception/api/functional/_steps/AuthserverSteps.php +++ b/tests/codeception/api/functional/_steps/AuthserverSteps.php @@ -7,12 +7,12 @@ use tests\codeception\api\FunctionalTester; class AuthserverSteps extends FunctionalTester { - public function amAuthenticated() { + public function amAuthenticated(string $asUsername = 'admin', string $password = 'password_0') { $route = new AuthserverRoute($this); $clientToken = Uuid::uuid4()->toString(); $route->authenticate([ - 'username' => 'admin', - 'password' => 'password_0', + 'username' => $asUsername, + 'password' => $password, 'clientToken' => $clientToken, ]); diff --git a/tests/codeception/api/functional/_steps/OauthSteps.php b/tests/codeception/api/functional/_steps/OauthSteps.php index 3e0c467..c8327e7 100644 --- a/tests/codeception/api/functional/_steps/OauthSteps.php +++ b/tests/codeception/api/functional/_steps/OauthSteps.php @@ -9,7 +9,7 @@ class OauthSteps extends FunctionalTester { public function getAuthCode(array $permissions = []) { // TODO: по идее можно напрямую сделать запись в базу, что ускорит процесс тестирования - $this->loggedInAsActiveAccount(); + $this->amAuthenticated(); $route = new OauthRoute($this); $route->complete([ 'client_id' => 'ely', diff --git a/tests/codeception/api/functional/authserver/InvalidateCest.php b/tests/codeception/api/functional/authserver/InvalidateCest.php index 7cab088..de2bcb0 100644 --- a/tests/codeception/api/functional/authserver/InvalidateCest.php +++ b/tests/codeception/api/functional/authserver/InvalidateCest.php @@ -18,7 +18,7 @@ class InvalidateCest { public function invalidate(AuthserverSteps $I) { $I->wantTo('invalidate my token'); - list($accessToken, $clientToken) = $I->amAuthenticated(); + [$accessToken, $clientToken] = $I->amAuthenticated(); $this->route->invalidate([ 'accessToken' => $accessToken, 'clientToken' => $clientToken, diff --git a/tests/codeception/api/functional/authserver/RefreshCest.php b/tests/codeception/api/functional/authserver/RefreshCest.php index e26ccf7..e18751b 100644 --- a/tests/codeception/api/functional/authserver/RefreshCest.php +++ b/tests/codeception/api/functional/authserver/RefreshCest.php @@ -18,7 +18,7 @@ class RefreshCest { public function refresh(AuthserverSteps $I) { $I->wantTo('refresh my accessToken'); - list($accessToken, $clientToken) = $I->amAuthenticated(); + [$accessToken, $clientToken] = $I->amAuthenticated(); $this->route->refresh([ 'accessToken' => $accessToken, 'clientToken' => $clientToken, diff --git a/tests/codeception/api/functional/authserver/ValidateCest.php b/tests/codeception/api/functional/authserver/ValidateCest.php index 1cd5b9b..704b02a 100644 --- a/tests/codeception/api/functional/authserver/ValidateCest.php +++ b/tests/codeception/api/functional/authserver/ValidateCest.php @@ -18,7 +18,7 @@ class ValidateCest { public function validate(AuthserverSteps $I) { $I->wantTo('validate my accessToken'); - list($accessToken) = $I->amAuthenticated(); + [$accessToken] = $I->amAuthenticated(); $this->route->validate([ 'accessToken' => $accessToken, ]); diff --git a/tests/codeception/api/functional/sessionserver/JoinCest.php b/tests/codeception/api/functional/sessionserver/JoinCest.php index 986521e..ed34400 100644 --- a/tests/codeception/api/functional/sessionserver/JoinCest.php +++ b/tests/codeception/api/functional/sessionserver/JoinCest.php @@ -21,7 +21,7 @@ class JoinCest { public function joinByLegacyAuthserver(AuthserverSteps $I) { $I->wantTo('join to server, using legacy authserver access token'); - list($accessToken) = $I->amAuthenticated(); + [$accessToken] = $I->amAuthenticated(); $this->route->join([ 'accessToken' => $accessToken, 'selectedProfile' => 'df936908-b2e1-544d-96f8-2977ec213022', @@ -32,7 +32,7 @@ class JoinCest { public function joinByPassJsonInPost(AuthserverSteps $I) { $I->wantTo('join to server, passing data in body as encoded json'); - list($accessToken) = $I->amAuthenticated(); + [$accessToken] = $I->amAuthenticated(); $this->route->join(json_encode([ 'accessToken' => $accessToken, 'selectedProfile' => 'df936908-b2e1-544d-96f8-2977ec213022', diff --git a/tests/codeception/api/functional/sessionserver/JoinLegacyCest.php b/tests/codeception/api/functional/sessionserver/JoinLegacyCest.php index e82695a..d691bce 100644 --- a/tests/codeception/api/functional/sessionserver/JoinLegacyCest.php +++ b/tests/codeception/api/functional/sessionserver/JoinLegacyCest.php @@ -21,7 +21,7 @@ class JoinLegacyCest { public function joinByLegacyAuthserver(AuthserverSteps $I) { $I->wantTo('join to server by legacy protocol, using legacy authserver access token'); - list($accessToken) = $I->amAuthenticated(); + [$accessToken] = $I->amAuthenticated(); $this->route->joinLegacy([ 'sessionId' => $accessToken, 'user' => 'Admin', @@ -32,7 +32,7 @@ class JoinLegacyCest { public function joinByNewSessionFormat(AuthserverSteps $I) { $I->wantTo('join to server by legacy protocol with new launcher session format, using legacy authserver'); - list($accessToken) = $I->amAuthenticated(); + [$accessToken] = $I->amAuthenticated(); $this->route->joinLegacy([ 'sessionId' => 'token:' . $accessToken . ':' . 'df936908-b2e1-544d-96f8-2977ec213022', 'user' => 'Admin', From 206e518da68dbec032d9e4cc86ed429efc6ef10b Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sat, 28 Jan 2017 22:58:41 +0300 Subject: [PATCH 19/23] =?UTF-8?q?=D0=9D=D0=B0=D0=BA=D0=BE=D0=BD=D0=B5?= =?UTF-8?q?=D1=86-=D1=82=D0=BE=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C=D1=8E=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D1=81=D1=8C=20=D0=BD=D0=B0=20=D0=BD=D0=B0=D1=88=20?= =?UTF-8?q?gitlab.ely.by?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 759a408..ab3f529 100644 --- a/composer.json +++ b/composer.json @@ -52,11 +52,11 @@ }, { "type": "git", - "url": "git@gitlab.com:elyby/amqp-controller.git" + "url": "git@gitlab.ely.by:elyby/amqp-controller.git" }, { "type": "git", - "url": "git@gitlab.com:elyby/email-renderer.git" + "url": "git@gitlab.ely.by:elyby/email-renderer.git" }, { "type": "git", From b069ec630d64355c9776e14c1b8ad0ae7f08feea Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sun, 5 Feb 2017 15:26:44 +0300 Subject: [PATCH 20/23] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=BE=D0=BF=D1=8B=D1=82=D0=BA?= =?UTF-8?q?=D0=B5=20=D1=80=D0=B5=D1=84=D1=80=D0=B5=D1=88=D0=BD=D1=83=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BD=D0=B5=D1=81=D1=83=D1=89=D0=B5=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D1=83=D1=8E=D1=89=D0=B8=D0=B9=20oauth=20refresh=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OAuth2/Storage/RefreshTokenStorage.php | 3 +++ .../api/functional/OauthRefreshTokenCest.php | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/api/components/OAuth2/Storage/RefreshTokenStorage.php b/api/components/OAuth2/Storage/RefreshTokenStorage.php index 2321e76..c1acfe6 100644 --- a/api/components/OAuth2/Storage/RefreshTokenStorage.php +++ b/api/components/OAuth2/Storage/RefreshTokenStorage.php @@ -18,6 +18,9 @@ class RefreshTokenStorage extends AbstractStorage implements RefreshTokenInterfa public function get($token) { $result = Json::decode((new Key($this->dataTable, $token))->getValue()); + if ($result === null) { + return null; + } $entity = new RefreshTokenEntity($this->server); $entity->setId($result['id']); diff --git a/tests/codeception/api/functional/OauthRefreshTokenCest.php b/tests/codeception/api/functional/OauthRefreshTokenCest.php index 47e5e47..7e13680 100644 --- a/tests/codeception/api/functional/OauthRefreshTokenCest.php +++ b/tests/codeception/api/functional/OauthRefreshTokenCest.php @@ -16,6 +16,18 @@ class OauthRefreshTokenCest { $this->route = new OauthRoute($I); } + public function testInvalidRefreshToken(OauthSteps $I) { + $this->route->issueToken($this->buildParams( + 'some-invalid-refresh-token', + 'ely', + 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM' + )); + $I->canSeeResponseContainsJson([ + 'error' => 'invalid_request', + 'message' => 'The refresh token is invalid.', + ]); + } + public function testRefreshToken(OauthSteps $I) { $refreshToken = $I->getRefreshToken(); $this->route->issueToken($this->buildParams( From 014380c82b77b03d2c7a96d7508034e4ac400ca8 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sun, 5 Feb 2017 15:35:15 +0300 Subject: [PATCH 21/23] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=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=BF=D1=80=D0=BE=D1=86=D0=B5=D1=81=D1=81=D0=B0=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BC=D0=B5=D0=BD=D0=B0=20auth=5Ftoken=20=D0=BD?= =?UTF-8?q?=D0=B0=20access=5Ftoken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/functional/OauthAccessTokenCest.php | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/codeception/api/functional/OauthAccessTokenCest.php b/tests/codeception/api/functional/OauthAccessTokenCest.php index 1ee1e4c..797bf1f 100644 --- a/tests/codeception/api/functional/OauthAccessTokenCest.php +++ b/tests/codeception/api/functional/OauthAccessTokenCest.php @@ -16,12 +16,13 @@ class OauthAccessTokenCest { $this->route = new OauthRoute($I); } - public function testIssueTokenWithWrongArgs(FunctionalTester $I) { + public function testIssueTokenWithWrongArgs(OauthSteps $I) { $I->wantTo('check behavior on on request without any credentials'); $this->route->issueToken(); $I->canSeeResponseCodeIs(400); $I->canSeeResponseContainsJson([ 'error' => 'invalid_request', + 'message' => 'The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. Check the "grant_type" parameter.', ]); $I->wantTo('check behavior on passing invalid auth code'); @@ -34,6 +35,21 @@ class OauthAccessTokenCest { $I->canSeeResponseCodeIs(400); $I->canSeeResponseContainsJson([ 'error' => 'invalid_request', + 'message' => 'The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. Check the "code" parameter.', + ]); + + $authCode = $I->getAuthCode(); + $I->wantTo('check behavior on passing invalid redirect_uri'); + $this->route->issueToken($this->buildParams( + $authCode, + 'ely', + 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + 'http://some-other.domain' + )); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'error' => 'invalid_client', + 'message' => 'Client authentication failed.', ]); } From d262e577a6fb494d1e4f58aff2cab7478e3170d7 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 13 Feb 2017 13:44:14 +0300 Subject: [PATCH 22/23] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=BF=D0=BE=D0=B2=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20hasJoined=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=B4=D0=BB=D1=8F=20legacy=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/modules/session/controllers/SessionController.php | 9 +++------ .../api/functional/sessionserver/HasJoinedLegacyCest.php | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/api/modules/session/controllers/SessionController.php b/api/modules/session/controllers/SessionController.php index f120ba8..384d2f6 100644 --- a/api/modules/session/controllers/SessionController.php +++ b/api/modules/session/controllers/SessionController.php @@ -89,15 +89,12 @@ class SessionController extends ApiController { $hasJoinedForm = new HasJoinedForm($protocol); try { $hasJoinedForm->hasJoined(); + } catch (ForbiddenOperationException $e) { + return 'NO'; } catch (SessionServerException $e) { Yii::$app->response->statusCode = $e->statusCode; - if ($e instanceof ForbiddenOperationException) { - $message = 'NO'; - } else { - $message = $e->getMessage(); - } - return $message; + return $e->getMessage(); } return 'YES'; diff --git a/tests/codeception/api/functional/sessionserver/HasJoinedLegacyCest.php b/tests/codeception/api/functional/sessionserver/HasJoinedLegacyCest.php index b0133ac..8c2e07f 100644 --- a/tests/codeception/api/functional/sessionserver/HasJoinedLegacyCest.php +++ b/tests/codeception/api/functional/sessionserver/HasJoinedLegacyCest.php @@ -44,7 +44,7 @@ class HasJoinedLegacyCest { 'user' => 'random-username', 'serverId' => Uuid::uuid(), ]); - $I->seeResponseCodeIs(401); + $I->seeResponseCodeIs(200); $I->canSeeResponseEquals('NO'); } From 34c43683d9a245c48802b03392ae5722a45d91de Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Thu, 16 Feb 2017 14:41:13 +0300 Subject: [PATCH 23/23] 1.1.6 [skip ci] --- common/config/config.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/config/config.php b/common/config/config.php index 10eb75a..441c300 100644 --- a/common/config/config.php +++ b/common/config/config.php @@ -1,6 +1,6 @@ '1.1.6-dev', + 'version' => '1.1.6', 'vendorPath' => dirname(dirname(__DIR__)) . '/vendor', 'components' => [ 'cache' => [