From 34d725abe2486477bc4cf8bd00da1015f740ffe1 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sat, 3 Sep 2016 01:54:22 +0300 Subject: [PATCH 1/9] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=BD=D0=B5=D1=81?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20joi?= =?UTF-8?q?n=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=81=D0=BE=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BE?= =?UTF-8?q?=D0=B2.=20=D0=9D=D1=83=D0=B6=D0=BD=D0=BE=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=B7=D0=BD=D0=B0=D1=82=D1=8C,=20=D1=87=D1=82=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=D0=B0=20=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=82=D0=B0=D0=BA=20=D1=81=D0=B5=D0=B1=D0=B5,?= =?UTF-8?q?=20=D0=BD=D0=BE=20=D0=B2=20=D0=B1=D1=83=D0=B4=D1=83=D1=89=D0=B5?= =?UTF-8?q?=D0=BC=20=D1=8F=20=D0=BE=D0=B1=D1=8F=D0=B7=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=20=D1=8D=D1=82=D0=BE=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=BF=D0=B8=D1=88=D1=83.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/components/ApiUser/Component.php | 1 + api/components/ErrorHandler.php | 3 +- api/config/main.php | 5 +- .../authserver/models/ValidateForm.php | 2 +- api/modules/session/Module.php | 31 +++++ .../session/controllers/SessionController.php | 28 ++++ .../ForbiddenOperationException.php | 10 ++ .../exceptions/IllegalArgumentException.php | 10 ++ .../exceptions/SessionServerException.php | 19 +++ api/modules/session/models/Form.php | 27 ++++ api/modules/session/models/JoinForm.php | 122 ++++++++++++++++++ api/modules/session/models/SessionModel.php | 57 ++++++++ .../session/validators/RequiredValidator.php | 25 ++++ common/models/MinecraftAccessKey.php | 4 +- common/models/OauthAccessToken.php | 2 +- common/validators/UuidValidator.php | 23 ++++ .../api/_pages/SessionServerRoute.php | 16 +++ .../api/functional/sessionserver/JoinCest.php | 111 ++++++++++++++++ .../unit/validators/UuidValidatorTest.php | 53 ++++++++ 19 files changed, 543 insertions(+), 6 deletions(-) create mode 100644 api/modules/session/Module.php create mode 100644 api/modules/session/controllers/SessionController.php create mode 100644 api/modules/session/exceptions/ForbiddenOperationException.php create mode 100644 api/modules/session/exceptions/IllegalArgumentException.php create mode 100644 api/modules/session/exceptions/SessionServerException.php create mode 100644 api/modules/session/models/Form.php create mode 100644 api/modules/session/models/JoinForm.php create mode 100644 api/modules/session/models/SessionModel.php create mode 100644 api/modules/session/validators/RequiredValidator.php create mode 100644 common/validators/UuidValidator.php create mode 100644 tests/codeception/api/_pages/SessionServerRoute.php create mode 100644 tests/codeception/api/functional/sessionserver/JoinCest.php create mode 100644 tests/codeception/common/unit/validators/UuidValidatorTest.php diff --git a/api/components/ApiUser/Component.php b/api/components/ApiUser/Component.php index 2c9e0c3..46acfc1 100644 --- a/api/components/ApiUser/Component.php +++ b/api/components/ApiUser/Component.php @@ -7,6 +7,7 @@ use yii\web\User as YiiUserComponent; * @property Identity|null $identity * * @method Identity|null getIdentity() + * @method Identity|null loginByAccessToken(string $token, $type = null) */ class Component extends YiiUserComponent { diff --git a/api/components/ErrorHandler.php b/api/components/ErrorHandler.php index f8a1ac9..54e8531 100644 --- a/api/components/ErrorHandler.php +++ b/api/components/ErrorHandler.php @@ -2,11 +2,12 @@ namespace api\components; use api\modules\authserver\exceptions\AuthserverException; +use api\modules\session\exceptions\SessionServerException; class ErrorHandler extends \yii\web\ErrorHandler { public function convertExceptionToArray($exception) { - if ($exception instanceof AuthserverException) { + if ($exception instanceof AuthserverException || $exception instanceof SessionServerException) { return [ 'error' => $exception->getName(), 'errorMessage' => $exception->getMessage(), diff --git a/api/config/main.php b/api/config/main.php index 35302cb..1bd152b 100644 --- a/api/config/main.php +++ b/api/config/main.php @@ -9,7 +9,7 @@ $params = array_merge( return [ 'id' => 'accounts-site-api', 'basePath' => dirname(__DIR__), - 'bootstrap' => ['log', 'authserver'], + 'bootstrap' => ['log', 'authserver', 'sessionserver'], 'controllerNamespace' => 'api\controllers', 'params' => $params, 'components' => [ @@ -56,5 +56,8 @@ return [ 'class' => \api\modules\authserver\Module::class, 'baseDomain' => $params['authserverDomain'], ], + 'sessionserver' => [ + 'class' => \api\modules\session\Module::class, + ], ], ]; diff --git a/api/modules/authserver/models/ValidateForm.php b/api/modules/authserver/models/ValidateForm.php index ec279eb..e56ea48 100644 --- a/api/modules/authserver/models/ValidateForm.php +++ b/api/modules/authserver/models/ValidateForm.php @@ -24,7 +24,7 @@ class ValidateForm extends Form { throw new ForbiddenOperationException('Invalid token.'); } - if (!$result->isActual()) { + if ($result->isExpired()) { $result->delete(); throw new ForbiddenOperationException('Token expired.'); } diff --git a/api/modules/session/Module.php b/api/modules/session/Module.php new file mode 100644 index 0000000..365ffb0 --- /dev/null +++ b/api/modules/session/Module.php @@ -0,0 +1,31 @@ +getUrlManager()->addRules([ + // TODO: define normal routes + //$this->baseDomain . '/' . $this->id . '/auth/' => $this->id . '/authentication/', + ], false); + } + + public static function info($message) { + Yii::info($message, 'session'); + } + + public static function error($message) { + Yii::info($message, 'session'); + } + +} diff --git a/api/modules/session/controllers/SessionController.php b/api/modules/session/controllers/SessionController.php new file mode 100644 index 0000000..466a051 --- /dev/null +++ b/api/modules/session/controllers/SessionController.php @@ -0,0 +1,28 @@ +loadByPost(); + $joinForm->join(); + + return ['id' => 'OK']; + } + + public function actionJoinLegacy() { + + } + +} diff --git a/api/modules/session/exceptions/ForbiddenOperationException.php b/api/modules/session/exceptions/ForbiddenOperationException.php new file mode 100644 index 0000000..420aaec --- /dev/null +++ b/api/modules/session/exceptions/ForbiddenOperationException.php @@ -0,0 +1,10 @@ +getShortName(); + } + +} diff --git a/api/modules/session/models/Form.php b/api/modules/session/models/Form.php new file mode 100644 index 0000000..bd4a383 --- /dev/null +++ b/api/modules/session/models/Form.php @@ -0,0 +1,27 @@ +load(Yii::$app->request->get()); + } + + public function loadByPost() { + $data = Yii::$app->request->post(); + // TODO: проверить, парсит ли Yii2 raw body и что он делает, если там неспаршенный json + /*if (empty($data)) { + $data = $request->getJsonRawBody(true); + }*/ + + return $this->load($data); + } + +} diff --git a/api/modules/session/models/JoinForm.php b/api/modules/session/models/JoinForm.php new file mode 100644 index 0000000..dbd6d1a --- /dev/null +++ b/api/modules/session/models/JoinForm.php @@ -0,0 +1,122 @@ +accessToken}' trying join to server with server_id = " . + "'{$this->serverId}'." + ); + if (!$this->validate()) { + return false; + } + + $account = $this->getAccount(); + $sessionModel = new SessionModel($account->username, $this->serverId); + if (!$sessionModel->save()) { + throw new ErrorException('Cannot save join session model'); + } + + Session::info( + "User with access_token = '{$this->accessToken}' and nickname = '{$account->username}' successfully " . + "joined to server_id = '{$this->serverId}'." + ); + + return true; + } + + public function validateUuid($attribute) { + if ($this->hasErrors($attribute)) { + return; + } + + $validator = new UuidValidator(); + $validator->validateAttribute($this, $attribute); + + if ($this->hasErrors($attribute)) { + throw new IllegalArgumentException(); + } + } + + /** + * @throws \api\modules\session\exceptions\SessionServerException + */ + public function validateAccessToken() { + $accessToken = $this->accessToken; + /** @var MinecraftAccessKey|null $accessModel */ + $accessModel = MinecraftAccessKey::findOne($accessToken); + if ($accessModel === null) { + try { + $identity = Yii::$app->apiUser->loginByAccessToken($accessToken); + } catch (UnauthorizedHttpException $e) { + $identity = null; + } + + if ($identity === null) { + Session::error("User with access_token = '{$accessToken}' failed join by wrong access_token."); + throw new ForbiddenOperationException('Invalid access_token.'); + } + + if (!Yii::$app->apiUser->can(S::MINECRAFT_SERVER_SESSION)) { + Session::error("User with access_token = '{$accessToken}' doesn't have enough scopes to make join."); + throw new ForbiddenOperationException('The token does not have required scope.'); + } + + $accessModel = $identity->getAccessToken(); + $account = $identity->getAccount(); + } else { + $account = $accessModel->account; + } + + /** @var MinecraftAccessKey|\common\models\OauthAccessToken $accessModel */ + if ($accessModel->isExpired()) { + Session::error("User with access_token = '{$accessToken}' failed join by expired access_token."); + throw new ForbiddenOperationException('Expired access_token.'); + } + + if ($account->uuid !== $this->selectedProfile) { + Session::error( + "User with access_token = '{$accessToken}' trying to join with identity = '{$this->selectedProfile}'," . + " but access_token issued to account with id = '{$account->uuid}'." + ); + throw new ForbiddenOperationException('Wrong selected_profile.'); + } + + $this->account = $account; + } + + /** + * @return Account|null + */ + protected function getAccount() { + return $this->account; + } + +} diff --git a/api/modules/session/models/SessionModel.php b/api/modules/session/models/SessionModel.php new file mode 100644 index 0000000..1efd748 --- /dev/null +++ b/api/modules/session/models/SessionModel.php @@ -0,0 +1,57 @@ +username = $username; + $this->serverId = $serverId; + } + + /** + * @param $username + * @param $serverId + * + * @return static|null + */ + public static function find($username, $serverId) { + $key = static::buildKey($username, $serverId); + $result = Yii::$app->redis->executeCommand('GET', [$key]); + if (!$result) { + /** @noinspection PhpIncompatibleReturnTypeInspection шторм что-то сума сходит, когда видит static */ + return null; + } + + $data = json_decode($result, true); + $model = new static($data['username'], $data['serverId']); + + return $model; + } + + public function save() { + $key = static::buildKey($this->username, $this->serverId); + $data = json_encode([ + 'username' => $this->username, + 'serverId' => $this->serverId, + ]); + + return Yii::$app->redis->executeCommand('SETEX', [$key, self::KEY_TIME, $data]); + } + + public function delete() { + return Yii::$app->redis->executeCommand('DEL', [static::buildKey($this->username, $this->serverId)]); + } + + protected static function buildKey($username, $serverId) { + return md5('minecraft:join-server:' . mb_strtolower($username) . ':' . $serverId); + } + +} diff --git a/api/modules/session/validators/RequiredValidator.php b/api/modules/session/validators/RequiredValidator.php new file mode 100644 index 0000000..63cae1f --- /dev/null +++ b/api/modules/session/validators/RequiredValidator.php @@ -0,0 +1,25 @@ +hasOne(Account::class, ['id' => 'account_id']); } - public function isActual() : bool { - return $this->updated_at + self::LIFETIME >= time(); + public function isExpired() : bool { + return time() > $this->updated_at + self::LIFETIME; } } diff --git a/common/models/OauthAccessToken.php b/common/models/OauthAccessToken.php index 9c60bed..8a2dff5 100644 --- a/common/models/OauthAccessToken.php +++ b/common/models/OauthAccessToken.php @@ -39,7 +39,7 @@ class OauthAccessToken extends ActiveRecord { return true; } - public function isExpired() { + public function isExpired() : bool { return time() > $this->expire_time; } diff --git a/common/validators/UuidValidator.php b/common/validators/UuidValidator.php new file mode 100644 index 0000000..e0ccced --- /dev/null +++ b/common/validators/UuidValidator.php @@ -0,0 +1,23 @@ +$attribute)->toString(); + $model->$attribute = $uuid; + } catch (InvalidArgumentException $e) { + $this->addError($model, $attribute, $this->message, []); + } + } + +} diff --git a/tests/codeception/api/_pages/SessionServerRoute.php b/tests/codeception/api/_pages/SessionServerRoute.php new file mode 100644 index 0000000..d059c8d --- /dev/null +++ b/tests/codeception/api/_pages/SessionServerRoute.php @@ -0,0 +1,16 @@ +route = ['sessionserver/session/join']; + $this->actor->sendPOST($this->getUrl(), $params); + } + +} diff --git a/tests/codeception/api/functional/sessionserver/JoinCest.php b/tests/codeception/api/functional/sessionserver/JoinCest.php new file mode 100644 index 0000000..ce7709a --- /dev/null +++ b/tests/codeception/api/functional/sessionserver/JoinCest.php @@ -0,0 +1,111 @@ +route = new SessionServerRoute($I); + } + + public function joinByLegacyAuthserver(AuthserverSteps $I) { + $I->wantTo('join to server, using legacy authserver access token'); + list($accessToken) = $I->amAuthenticated(); + $this->route->join([ + 'accessToken' => $accessToken, + 'selectedProfile' => 'df936908-b2e1-544d-96f8-2977ec213022', + 'serverId' => Uuid::uuid(), + ]); + $this->expectSuccessResponse($I); + } + + public function joinByModernOauth2Token(OauthSteps $I) { + $I->wantTo('join to server, using moder oAuth2 generated token'); + $accessToken = $I->getAccessToken([S::MINECRAFT_SERVER_SESSION]); + $this->route->join([ + 'accessToken' => $accessToken, + 'selectedProfile' => 'df936908-b2e1-544d-96f8-2977ec213022', + 'serverId' => Uuid::uuid(), + ]); + $this->expectSuccessResponse($I); + } + + public function joinByModernOauth2TokenWithoutPermission(OauthSteps $I) { + $I->wantTo('join to server, using moder oAuth2 generated token, but without minecraft auth permission'); + $accessToken = $I->getAccessToken([S::ACCOUNT_INFO, S::ACCOUNT_EMAIL]); + $this->route->join([ + 'accessToken' => $accessToken, + 'selectedProfile' => 'df936908-b2e1-544d-96f8-2977ec213022', + 'serverId' => Uuid::uuid(), + ]); + $I->seeResponseCodeIs(401); + $I->seeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'error' => 'ForbiddenOperationException', + 'errorMessage' => 'The token does not have required scope.', + ]); + } + + public function joinWithExpiredToken(FunctionalTester $I) { + $I->wantTo('join to some server with expired accessToken'); + $this->route->join([ + 'accessToken' => '6042634a-a1e2-4aed-866c-c661fe4e63e2', + 'selectedProfile' => 'df936908-b2e1-544d-96f8-2977ec213022', + 'serverId' => Uuid::uuid(), + ]); + $I->seeResponseCodeIs(401); + $I->seeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'error' => 'ForbiddenOperationException', + 'errorMessage' => 'Expired access_token.', + ]); + } + + public function wrongArguments(FunctionalTester $I) { + $I->wantTo('get error on wrong amount of arguments'); + $this->route->join([ + 'wrong' => 'argument', + ]); + $I->canSeeResponseCodeIs(400); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'error' => 'IllegalArgumentException', + 'errorMessage' => 'credentials can not be null.', + ]); + } + + public function joinWithWrongAccessToken(FunctionalTester $I) { + $I->wantTo('join to some server with wrong accessToken'); + $this->route->join([ + 'accessToken' => Uuid::uuid(), + 'selectedProfile' => 'df936908-b2e1-544d-96f8-2977ec213022', + 'serverId' => Uuid::uuid(), + ]); + $I->seeResponseCodeIs(401); + $I->seeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'error' => 'ForbiddenOperationException', + 'errorMessage' => 'Invalid access_token.', + ]); + } + + private function expectSuccessResponse(FunctionalTester $I) { + $I->seeResponseCodeIs(200); + $I->seeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'id' => 'OK', + ]); + } + +} diff --git a/tests/codeception/common/unit/validators/UuidValidatorTest.php b/tests/codeception/common/unit/validators/UuidValidatorTest.php new file mode 100644 index 0000000..ef90735 --- /dev/null +++ b/tests/codeception/common/unit/validators/UuidValidatorTest.php @@ -0,0 +1,53 @@ +specify('expected error if passed empty value', function() { + $model = new UuidTestModel(); + expect($model->validate())->false(); + expect($model->getErrors('attribute'))->equals(['Attribute must be valid uuid']); + }); + + $this->specify('expected error if passed invalid string', function() { + $model = new UuidTestModel(); + $model->attribute = '123456789'; + expect($model->validate())->false(); + expect($model->getErrors('attribute'))->equals(['Attribute must be valid uuid']); + }); + + $this->specify('no errors if passed valid uuid', function() { + $model = new UuidTestModel(); + $model->attribute = Uuid::uuid(); + expect($model->validate())->true(); + }); + + $this->specify('no errors if passed uuid string without dashes and converted to standart value', function() { + $model = new UuidTestModel(); + $originalUuid = Uuid::uuid(); + $model->attribute = str_replace('-', '', $originalUuid); + expect($model->validate())->true(); + expect($model->attribute)->equals($originalUuid); + }); + } + +} + +class UuidTestModel extends Model { + public $attribute; + + public function rules() { + return [ + ['attribute', UuidValidator::class], + ]; + } + +} From e8b5e90a9152bd9c053c625f1478c157de8533da Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 5 Sep 2016 17:55:38 +0300 Subject: [PATCH 2/9] =?UTF-8?q?=D0=9D=D0=B5=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D1=80=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=D0=B0=20Join=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=83=D1=87=D1=91=D1=82=D0=B0=20Legacy=20API=20?= =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B0=20=D1=87=D1=82?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D0=B8=D0=B7=20POST=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D0=B0,=20=D0=B5=D1=81=D0=BB=D0=B8=20=D0=BE=D0=BD=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B0=D0=BD=D1=8B=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BA=20RAW=20json=20=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20StringHelper::isUuid()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/controllers/SessionController.php | 36 +++++++- api/modules/session/models/Form.php | 27 ------ api/modules/session/models/JoinForm.php | 68 +++++++++++--- .../session/models/protocols/BaseJoin.php | 14 +++ .../models/protocols/JoinInterface.php | 15 +++ .../session/models/protocols/LegacyJoin.php | 63 +++++++++++++ .../session/models/protocols/ModernJoin.php | 38 ++++++++ common/helpers/StringHelper.php | 12 ++- .../api/_pages/SessionServerRoute.php | 5 + .../api/functional/sessionserver/JoinCest.php | 15 ++- .../sessionserver/JoinLegacyCest.php | 92 +++++++++++++++++++ .../common/unit/helpers/StringHelperTest.php | 2 +- 12 files changed, 338 insertions(+), 49 deletions(-) delete mode 100644 api/modules/session/models/Form.php create mode 100644 api/modules/session/models/protocols/BaseJoin.php create mode 100644 api/modules/session/models/protocols/JoinInterface.php create mode 100644 api/modules/session/models/protocols/LegacyJoin.php create mode 100644 api/modules/session/models/protocols/ModernJoin.php create mode 100644 tests/codeception/api/functional/sessionserver/JoinLegacyCest.php diff --git a/api/modules/session/controllers/SessionController.php b/api/modules/session/controllers/SessionController.php index 466a051..1784917 100644 --- a/api/modules/session/controllers/SessionController.php +++ b/api/modules/session/controllers/SessionController.php @@ -2,7 +2,13 @@ namespace api\modules\session\controllers; use api\controllers\ApiController; +use api\modules\session\exceptions\ForbiddenOperationException; +use api\modules\session\exceptions\SessionServerException; use api\modules\session\models\JoinForm; +use api\modules\session\models\protocols\LegacyJoin; +use api\modules\session\models\protocols\ModernJoin; +use Yii; +use yii\web\Response; class SessionController extends ApiController { @@ -14,15 +20,41 @@ class SessionController extends ApiController { } public function actionJoin() { - $joinForm = new JoinForm(); - $joinForm->loadByPost(); + Yii::$app->response->format = Response::FORMAT_JSON; + + $data = Yii::$app->request->post(); + if (empty($data)) { + // TODO: помнится у Yii2 есть механизм парсинга данных входящего запроса. Лучше будет сделать это там + $data = json_decode(Yii::$app->request->getRawBody(), true); + } + + $protocol = new ModernJoin($data['accessToken'] ?? '', $data['selectedProfile'] ?? '', $data['serverId'] ?? ''); + $joinForm = new JoinForm($protocol); $joinForm->join(); return ['id' => 'OK']; } public function actionJoinLegacy() { + Yii::$app->response->format = Response::FORMAT_RAW; + $data = Yii::$app->request->get(); + $protocol = new LegacyJoin($data['user'] ?? '', $data['sessionId'] ?? '', $data['serverId'] ?? ''); + $joinForm = new JoinForm($protocol); + try { + $joinForm->join(); + } catch (SessionServerException $e) { + Yii::$app->response->statusCode = $e->statusCode; + if ($e instanceof ForbiddenOperationException) { + $message = 'Ely.by authorization required'; + } else { + $message = $e->getMessage(); + } + + return $message; + } + + return 'OK'; } } diff --git a/api/modules/session/models/Form.php b/api/modules/session/models/Form.php deleted file mode 100644 index bd4a383..0000000 --- a/api/modules/session/models/Form.php +++ /dev/null @@ -1,27 +0,0 @@ -load(Yii::$app->request->get()); - } - - public function loadByPost() { - $data = Yii::$app->request->post(); - // TODO: проверить, парсит ли Yii2 raw body и что он делает, если там неспаршенный json - /*if (empty($data)) { - $data = $request->getJsonRawBody(true); - }*/ - - return $this->load($data); - } - -} diff --git a/api/modules/session/models/JoinForm.php b/api/modules/session/models/JoinForm.php index dbd6d1a..d7e9e17 100644 --- a/api/modules/session/models/JoinForm.php +++ b/api/modules/session/models/JoinForm.php @@ -3,60 +3,92 @@ namespace api\modules\session\models; use api\modules\session\exceptions\ForbiddenOperationException; use api\modules\session\exceptions\IllegalArgumentException; +use api\modules\session\models\protocols\JoinInterface; use api\modules\session\Module as Session; use api\modules\session\validators\RequiredValidator; +use common\helpers\StringHelper; use common\models\OauthScope as S; use common\validators\UuidValidator; use common\models\Account; use common\models\MinecraftAccessKey; use Yii; use yii\base\ErrorException; +use yii\base\Model; use yii\web\UnauthorizedHttpException; -class JoinForm extends Form { +class JoinForm extends Model { - public $accessToken; - public $selectedProfile; - public $serverId; + private $accessToken; + private $selectedProfile; + private $serverId; + /** + * @var Account|null + */ private $account; + /** + * @var JoinInterface + */ + private $protocol; + + public function __construct(JoinInterface $protocol, array $config = []) { + $this->protocol = $protocol; + $this->accessToken = $protocol->getAccessToken(); + $this->selectedProfile = $protocol->getSelectedProfile(); + $this->serverId = $protocol->getServerId(); + + parent::__construct($config); + } + public function rules() { return [ - [['accessToken', 'selectedProfile', 'serverId'], RequiredValidator::class], + [['accessToken', 'serverId'], RequiredValidator::class], [['accessToken', 'selectedProfile'], 'validateUuid'], [['accessToken'], 'validateAccessToken'], ]; } public function join() { - Session::info( - "User with access_token = '{$this->accessToken}' trying join to server with server_id = " . - "'{$this->serverId}'." - ); + $serverId = $this->serverId; + $accessToken = $this->accessToken; + Session::info("User with access_token = '{$accessToken}' trying join to server with server_id = '{$serverId}'."); if (!$this->validate()) { return false; } $account = $this->getAccount(); - $sessionModel = new SessionModel($account->username, $this->serverId); + $sessionModel = new SessionModel($account->username, $serverId); if (!$sessionModel->save()) { throw new ErrorException('Cannot save join session model'); } Session::info( - "User with access_token = '{$this->accessToken}' and nickname = '{$account->username}' successfully " . - "joined to server_id = '{$this->serverId}'." + "User with access_token = '{$accessToken}' and nickname = '{$account->username}' successfully joined to " . + "server_id = '{$serverId}'." ); return true; } + public function validate($attributeNames = null, $clearErrors = true) { + if (!$this->protocol->validate()) { + throw new IllegalArgumentException(); + } + + return parent::validate($attributeNames, $clearErrors); + } + public function validateUuid($attribute) { if ($this->hasErrors($attribute)) { return; } + if ($attribute === 'selectedProfile' && !StringHelper::isUuid($this->selectedProfile)) { + // Это нормально. Там может быть ник игрока, если это Legacy авторизация + return; + } + $validator = new UuidValidator(); $validator->validateAttribute($this, $attribute); @@ -101,12 +133,20 @@ class JoinForm extends Form { throw new ForbiddenOperationException('Expired access_token.'); } - if ($account->uuid !== $this->selectedProfile) { + $selectedProfile = $this->selectedProfile; + $isUuid = StringHelper::isUuid($selectedProfile); + if ($isUuid && $account->uuid !== $selectedProfile) { Session::error( - "User with access_token = '{$accessToken}' trying to join with identity = '{$this->selectedProfile}'," . + "User with access_token = '{$accessToken}' trying to join with identity = '{$selectedProfile}'," . " but access_token issued to account with id = '{$account->uuid}'." ); throw new ForbiddenOperationException('Wrong selected_profile.'); + } elseif (!$isUuid && $account->username !== $selectedProfile) { + Session::error( + "User with access_token = '{$accessToken}' trying to join with identity = '{$selectedProfile}'," . + " but access_token issued to account with username = '{$account->username}'." + ); + throw new ForbiddenOperationException('Invalid credentials'); } $this->account = $account; diff --git a/api/modules/session/models/protocols/BaseJoin.php b/api/modules/session/models/protocols/BaseJoin.php new file mode 100644 index 0000000..d025e83 --- /dev/null +++ b/api/modules/session/models/protocols/BaseJoin.php @@ -0,0 +1,14 @@ +user = $user; + $this->sessionId = $sessionId; + $this->serverId = $serverId; + + $this->parseSessionId($this->sessionId); + } + + public function getAccessToken() : string { + return $this->accessToken; + } + + public function getSelectedProfile() : string { + return $this->uuid ?: $this->user; + } + + public function getServerId() : string { + return $this->serverId; + } + + /** + * @return bool + */ + public function validate() : bool { + $validator = new RequiredValidator(); + + return $validator->validate($this->accessToken) + && $validator->validate($this->user) + && $validator->validate($this->serverId); + } + + /** + * Метод проводит инициализацию значений полей для соотвествия общим канонам + * именования в проекте + * + * Бьём по ':' для учёта авторизации в современных лаунчерах и входе на более старую + * версию игры. Там sessionId передаётся как "token:{accessToken}:{uuid}", так что это нужно обработать + */ + protected function parseSessionId(string $sessionId) { + $parts = explode(':', $sessionId); + if (count($parts) === 3) { + $this->accessToken = $parts[1]; + $this->uuid = $parts[2]; + } else { + $this->accessToken = $this->sessionId; + } + } + +} diff --git a/api/modules/session/models/protocols/ModernJoin.php b/api/modules/session/models/protocols/ModernJoin.php new file mode 100644 index 0000000..f535ce5 --- /dev/null +++ b/api/modules/session/models/protocols/ModernJoin.php @@ -0,0 +1,38 @@ +accessToken = $accessToken; + $this->selectedProfile = $selectedProfile; + $this->serverId = $serverId; + } + + public function getAccessToken() : string { + return $this->accessToken; + } + + public function getSelectedProfile() : string { + return $this->selectedProfile; + } + + public function getServerId() : string { + return $this->serverId; + } + + public function validate() : bool { + $validator = new RequiredValidator(); + + return $validator->validate($this->accessToken) + && $validator->validate($this->selectedProfile) + && $validator->validate($this->serverId); + } + +} diff --git a/common/helpers/StringHelper.php b/common/helpers/StringHelper.php index 5e4c300..efa49d9 100644 --- a/common/helpers/StringHelper.php +++ b/common/helpers/StringHelper.php @@ -1,6 +1,8 @@ actor->sendPOST($this->getUrl(), $params); } + public function joinLegacy(array $params) { + $this->route = ['sessionserver/session/join-legacy']; + $this->actor->sendGET($this->getUrl(), $params); + } + } diff --git a/tests/codeception/api/functional/sessionserver/JoinCest.php b/tests/codeception/api/functional/sessionserver/JoinCest.php index ce7709a..00d9cf3 100644 --- a/tests/codeception/api/functional/sessionserver/JoinCest.php +++ b/tests/codeception/api/functional/sessionserver/JoinCest.php @@ -30,8 +30,19 @@ class JoinCest { $this->expectSuccessResponse($I); } - public function joinByModernOauth2Token(OauthSteps $I) { - $I->wantTo('join to server, using moder oAuth2 generated token'); + public function joinByPassJsonInPost(AuthserverSteps $I) { + $I->wantTo('join to server, passing data in body as encoded json'); + list($accessToken) = $I->amAuthenticated(); + $this->route->join(json_encode([ + 'accessToken' => $accessToken, + 'selectedProfile' => 'df936908-b2e1-544d-96f8-2977ec213022', + 'serverId' => Uuid::uuid(), + ])); + $this->expectSuccessResponse($I); + } + + public function joinByOauth2Token(OauthSteps $I) { + $I->wantTo('join to server, using modern oAuth2 generated token'); $accessToken = $I->getAccessToken([S::MINECRAFT_SERVER_SESSION]); $this->route->join([ 'accessToken' => $accessToken, diff --git a/tests/codeception/api/functional/sessionserver/JoinLegacyCest.php b/tests/codeception/api/functional/sessionserver/JoinLegacyCest.php new file mode 100644 index 0000000..7818870 --- /dev/null +++ b/tests/codeception/api/functional/sessionserver/JoinLegacyCest.php @@ -0,0 +1,92 @@ +route = new SessionServerRoute($I); + } + + public function joinByLegacyAuthserver(AuthserverSteps $I) { + $I->wantTo('join to server by legacy protocol, using legacy authserver access token'); + list($accessToken) = $I->amAuthenticated(); + $this->route->joinLegacy([ + 'sessionId' => $accessToken, + 'user' => 'Admin', + 'serverId' => Uuid::uuid(), + ]); + $this->expectSuccessResponse($I); + } + + 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(); + $this->route->joinLegacy([ + 'sessionId' => 'token:' . $accessToken . ':' . 'df936908-b2e1-544d-96f8-2977ec213022', + 'user' => 'Admin', + 'serverId' => Uuid::uuid(), + ]); + $this->expectSuccessResponse($I); + } + + public function joinByOauth2Token(OauthSteps $I) { + $I->wantTo('join to server using modern oAuth2 generated token with new launcher session format'); + $accessToken = $I->getAccessToken([S::MINECRAFT_SERVER_SESSION]); + $this->route->joinLegacy([ + 'sessionId' => 'token:' . $accessToken . ':' . 'df936908-b2e1-544d-96f8-2977ec213022', + 'user' => 'Admin', + 'serverId' => Uuid::uuid(), + ]); + $this->expectSuccessResponse($I); + } + + public function wrongArguments(FunctionalTester $I) { + $I->wantTo('get error on wrong amount of arguments'); + $this->route->joinLegacy([ + 'wrong' => 'argument', + ]); + $I->canSeeResponseCodeIs(400); + $I->canSeeResponseContains('credentials can not be null.'); + } + + public function joinWithWrongAccessToken(FunctionalTester $I) { + $I->wantTo('join to some server with wrong accessToken'); + $this->route->joinLegacy([ + 'sessionId' => 'token:' . Uuid::uuid() . ':' . Uuid::uuid(), + 'user' => 'random-username', + 'serverId' => Uuid::uuid(), + ]); + $I->seeResponseCodeIs(401); + $I->canSeeResponseContains('Ely.by authorization required'); + } + + public function joinWithAccessTokenWithoutMinecraftPermission(OauthSteps $I) { + $I->wantTo('join to some server with wrong accessToken'); + $accessToken = $I->getAccessToken([S::ACCOUNT_INFO]); + $this->route->joinLegacy([ + 'sessionId' => 'token:' . $accessToken . ':' . 'df936908-b2e1-544d-96f8-2977ec213022', + 'user' => 'Admin', + 'serverId' => Uuid::uuid(), + ]); + $I->seeResponseCodeIs(401); + $I->canSeeResponseContains('Ely.by authorization required'); + } + + private function expectSuccessResponse(FunctionalTester $I) { + $I->seeResponseCodeIs(200); + $I->canSeeResponseEquals('OK'); + } + +} diff --git a/tests/codeception/common/unit/helpers/StringHelperTest.php b/tests/codeception/common/unit/helpers/StringHelperTest.php index 50c7297..b1f1a21 100644 --- a/tests/codeception/common/unit/helpers/StringHelperTest.php +++ b/tests/codeception/common/unit/helpers/StringHelperTest.php @@ -15,8 +15,8 @@ class StringHelperTest extends \PHPUnit_Framework_TestCase { public function testIsUuid() { $this->assertTrue(StringHelper::isUuid('a80b4487-a5c6-45a5-9829-373b4a494135')); + $this->assertTrue(StringHelper::isUuid('a80b4487a5c645a59829373b4a494135')); $this->assertFalse(StringHelper::isUuid('12345678')); - $this->assertFalse(StringHelper::isUuid('12345678-1234-1234-1234-123456789123')); } } From 198e440b8d0ae98f357c2ce86b5760a369591abb Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 5 Sep 2016 17:56:03 +0300 Subject: [PATCH 3/9] =?UTF-8?q?=D0=92=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=BE=20=D0=BA=D1=8D=D1=88=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B2=20redis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/config/main.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/config/main.php b/common/config/main.php index 1303616..92eb468 100644 --- a/common/config/main.php +++ b/common/config/main.php @@ -3,7 +3,8 @@ return [ 'vendorPath' => dirname(dirname(__DIR__)) . '/vendor', 'components' => [ 'cache' => [ - 'class' => yii\caching\FileCache::class, + 'class' => yii\redis\Cache::class, + 'redis' => 'redis', ], 'db' => [ 'class' => yii\db\Connection::class, From 68ce8b3fb628f74388483b22600d49d0361983e0 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Tue, 6 Sep 2016 12:56:39 +0300 Subject: [PATCH 4/9] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20HasJoined?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20Minecraft=20=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=B2=20JoinForm=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D0=BE=D0=B5?= =?UTF-8?q?=20API=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D0=B1=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=81=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BE=D0=BC=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D1=8B=20?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=BD=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/controllers/SessionController.php | 37 +++++++++ api/modules/session/models/HasJoinedForm.php | 52 ++++++++++++ api/modules/session/models/JoinForm.php | 6 +- api/modules/session/models/SessionModel.php | 11 ++- .../models/protocols/BaseHasJoined.php | 31 +++++++ .../models/protocols/HasJoinedInterface.php | 12 +++ .../models/protocols/LegacyHasJoined.php | 6 ++ .../models/protocols/ModernHasJoined.php | 6 ++ autocompletion.php | 1 + common/components/SkinSystem/Api.php | 29 +++++++ common/config/main.php | 3 + common/models/Textures.php | 71 ++++++++++++++++ .../api/_pages/SessionServerRoute.php | 10 +++ tests/codeception/api/functional.suite.yml | 1 + .../functional/_steps/SessionServerSteps.php | 38 +++++++++ .../sessionserver/HasJoinedCest.php | 83 +++++++++++++++++++ .../sessionserver/HasJoinedLegacyCest.php | 51 ++++++++++++ 17 files changed, 444 insertions(+), 4 deletions(-) create mode 100644 api/modules/session/models/HasJoinedForm.php create mode 100644 api/modules/session/models/protocols/BaseHasJoined.php create mode 100644 api/modules/session/models/protocols/HasJoinedInterface.php create mode 100644 api/modules/session/models/protocols/LegacyHasJoined.php create mode 100644 api/modules/session/models/protocols/ModernHasJoined.php create mode 100644 common/components/SkinSystem/Api.php create mode 100644 common/models/Textures.php create mode 100644 tests/codeception/api/functional/_steps/SessionServerSteps.php create mode 100644 tests/codeception/api/functional/sessionserver/HasJoinedCest.php create mode 100644 tests/codeception/api/functional/sessionserver/HasJoinedLegacyCest.php diff --git a/api/modules/session/controllers/SessionController.php b/api/modules/session/controllers/SessionController.php index 1784917..583b5a0 100644 --- a/api/modules/session/controllers/SessionController.php +++ b/api/modules/session/controllers/SessionController.php @@ -4,9 +4,12 @@ namespace api\modules\session\controllers; use api\controllers\ApiController; use api\modules\session\exceptions\ForbiddenOperationException; use api\modules\session\exceptions\SessionServerException; +use api\modules\session\models\HasJoinedForm; use api\modules\session\models\JoinForm; use api\modules\session\models\protocols\LegacyJoin; +use api\modules\session\models\protocols\ModernHasJoined; use api\modules\session\models\protocols\ModernJoin; +use common\models\Textures; use Yii; use yii\web\Response; @@ -57,4 +60,38 @@ class SessionController extends ApiController { return 'OK'; } + public function actionHasJoined() { + Yii::$app->response->format = Response::FORMAT_JSON; + + $data = Yii::$app->request->get(); + $protocol = new ModernHasJoined($data['username'] ?? '', $data['serverId'] ?? ''); + $hasJoinedForm = new HasJoinedForm($protocol); + $account = $hasJoinedForm->hasJoined(); + $textures = new Textures($account); + + return $textures->getMinecraftResponse(); + } + + public function actionHasJoinedLegacy() { + Yii::$app->response->format = Response::FORMAT_RAW; + + $data = Yii::$app->request->get(); + $protocol = new ModernHasJoined($data['user'] ?? '', $data['serverId'] ?? ''); + $hasJoinedForm = new HasJoinedForm($protocol); + try { + $hasJoinedForm->hasJoined(); + } catch (SessionServerException $e) { + Yii::$app->response->statusCode = $e->statusCode; + if ($e instanceof ForbiddenOperationException) { + $message = 'NO'; + } else { + $message = $e->getMessage(); + } + + return $message; + } + + return 'YES'; + } + } diff --git a/api/modules/session/models/HasJoinedForm.php b/api/modules/session/models/HasJoinedForm.php new file mode 100644 index 0000000..622ce71 --- /dev/null +++ b/api/modules/session/models/HasJoinedForm.php @@ -0,0 +1,52 @@ +protocol = $protocol; + parent::__construct($config); + } + + public function hasJoined() : Account { + if (!$this->protocol->validate()) { + throw new IllegalArgumentException(); + } + + $serverId = $this->protocol->getServerId(); + $username = $this->protocol->getUsername(); + + Session::info( + "Server with server_id = '{$serverId}' trying to verify has joined user with username = '{$username}'." + ); + + $joinModel = SessionModel::find($username, $serverId); + if ($joinModel === null) { + Session::error("Not found join operation for username = '{$username}'."); + throw new ForbiddenOperationException('Invalid token.'); + } + + $joinModel->delete(); + $account = $joinModel->getAccount(); + if ($account === null) { + throw new ErrorException('Account must exists'); + } + + Session::info( + "User with username = '{$username}' successfully verified by server with server_id = '{$serverId}'." + ); + + return $account; + } + +} diff --git a/api/modules/session/models/JoinForm.php b/api/modules/session/models/JoinForm.php index d7e9e17..e0ac1de 100644 --- a/api/modules/session/models/JoinForm.php +++ b/api/modules/session/models/JoinForm.php @@ -18,9 +18,9 @@ use yii\web\UnauthorizedHttpException; class JoinForm extends Model { - private $accessToken; - private $selectedProfile; - private $serverId; + public $accessToken; + public $selectedProfile; + public $serverId; /** * @var Account|null diff --git a/api/modules/session/models/SessionModel.php b/api/modules/session/models/SessionModel.php index 1efd748..da43391 100644 --- a/api/modules/session/models/SessionModel.php +++ b/api/modules/session/models/SessionModel.php @@ -1,6 +1,7 @@ redis->executeCommand('DEL', [static::buildKey($this->username, $this->serverId)]); } - protected static function buildKey($username, $serverId) { + /** + * @return Account|null + * TODO: после перехода на PHP 7.1 установить тип как ?Account + */ + public function getAccount() { + return Account::findOne(['username' => $this->username]); + } + + protected static function buildKey($username, $serverId) : string { return md5('minecraft:join-server:' . mb_strtolower($username) . ':' . $serverId); } diff --git a/api/modules/session/models/protocols/BaseHasJoined.php b/api/modules/session/models/protocols/BaseHasJoined.php new file mode 100644 index 0000000..b10ce49 --- /dev/null +++ b/api/modules/session/models/protocols/BaseHasJoined.php @@ -0,0 +1,31 @@ +username = $username; + $this->serverId = $serverId; + } + + public function getUsername() : string { + return $this->username; + } + + public function getServerId() : string { + return $this->serverId; + } + + public function validate() : bool { + $validator = new RequiredValidator(); + + return $validator->validate($this->username) + && $validator->validate($this->serverId); + } + +} diff --git a/api/modules/session/models/protocols/HasJoinedInterface.php b/api/modules/session/models/protocols/HasJoinedInterface.php new file mode 100644 index 0000000..96a051a --- /dev/null +++ b/api/modules/session/models/protocols/HasJoinedInterface.php @@ -0,0 +1,12 @@ +getClient()->get($this->getBuildUrl('/textures/' . $username)); + $textures = json_decode($response->getBody(), true); + + return $textures; + } + + protected function getBuildUrl(string $url) : string { + return self::BASE_DOMAIN . $url; + } + + /** + * @return GuzzleClient + */ + protected function getClient() : GuzzleClient { + return Yii::$app->guzzle; + } + +} diff --git a/common/config/main.php b/common/config/main.php index 92eb468..6ef7bfe 100644 --- a/common/config/main.php +++ b/common/config/main.php @@ -23,6 +23,9 @@ return [ 'amqp' => [ 'class' => \common\components\RabbitMQ\Component::class, ], + 'guzzle' => [ + 'class' => \GuzzleHttp\Client::class, + ], ], 'aliases' => [ '@bower' => '@vendor/bower-asset', diff --git a/common/models/Textures.php b/common/models/Textures.php new file mode 100644 index 0000000..761e227 --- /dev/null +++ b/common/models/Textures.php @@ -0,0 +1,71 @@ +account = $account; + } + + public function getMinecraftResponse() { + $response = [ + 'name' => $this->account->username, + 'id' => str_replace('-', '', $this->account->uuid), + 'properties' => [ + [ + 'name' => 'textures', + 'signature' => 'Cg==', + 'value' => $this->getTexturesValue(), + ], + ], + ]; + + if ($this->displayElyMark) { + $response['ely'] = true; + } + + return $response; + } + + public function getTexturesValue($encrypted = true) { + $array = [ + 'timestamp' => time() + 60 * 60 * 24 * 2, + 'profileId' => str_replace('-', '', $this->account->uuid), + 'profileName' => $this->account->username, + 'textures' => $this->getTextures(), + ]; + + if ($this->displayElyMark) { + $array['ely'] = true; + } + + if (!$encrypted) { + return $array; + } else { + return $this->encrypt($array); + } + } + + public function getTextures() { + $api = new SkinSystemApi(); + return $api->textures($this->account->username); + } + + public static function encrypt(array $data) { + return base64_encode(stripcslashes(json_encode($data))); + } + + public static function decrypt($string, $assoc = true) { + return json_decode(base64_decode($string), $assoc); + } + +} diff --git a/tests/codeception/api/_pages/SessionServerRoute.php b/tests/codeception/api/_pages/SessionServerRoute.php index e40c3fd..d136aac 100644 --- a/tests/codeception/api/_pages/SessionServerRoute.php +++ b/tests/codeception/api/_pages/SessionServerRoute.php @@ -18,4 +18,14 @@ class SessionServerRoute extends BasePage { $this->actor->sendGET($this->getUrl(), $params); } + public function hasJoined(array $params) { + $this->route = ['sessionserver/session/has-joined']; + $this->actor->sendGET($this->getUrl(), $params); + } + + public function hasJoinedLegacy(array $params) { + $this->route = ['sessionserver/session/has-joined-legacy']; + $this->actor->sendGET($this->getUrl(), $params); + } + } diff --git a/tests/codeception/api/functional.suite.yml b/tests/codeception/api/functional.suite.yml index 49ff5d6..4669215 100644 --- a/tests/codeception/api/functional.suite.yml +++ b/tests/codeception/api/functional.suite.yml @@ -6,6 +6,7 @@ modules: - tests\codeception\common\_support\FixtureHelper - Redis - AMQP + - Asserts - REST: depends: Yii2 config: diff --git a/tests/codeception/api/functional/_steps/SessionServerSteps.php b/tests/codeception/api/functional/_steps/SessionServerSteps.php new file mode 100644 index 0000000..6bcb3fe --- /dev/null +++ b/tests/codeception/api/functional/_steps/SessionServerSteps.php @@ -0,0 +1,38 @@ +scenario); + $accessToken = $oauthSteps->getAccessToken([S::MINECRAFT_SERVER_SESSION]); + $route = new SessionServerRoute($this); + $serverId = Uuid::uuid(); + $username = 'Admin'; + + if ($byLegacy) { + $route->joinLegacy([ + 'sessionId' => 'token:' . $accessToken . ':' . 'df936908-b2e1-544d-96f8-2977ec213022', + 'user' => $username, + 'serverId' => $serverId, + ]); + + $this->canSeeResponseEquals('OK'); + } else { + $route->join([ + 'accessToken' => $accessToken, + 'selectedProfile' => 'df936908-b2e1-544d-96f8-2977ec213022', + 'serverId' => $serverId, + ]); + + $this->canSeeResponseContainsJson(['id' => 'OK']); + } + + return [$username, $serverId]; + } + +} diff --git a/tests/codeception/api/functional/sessionserver/HasJoinedCest.php b/tests/codeception/api/functional/sessionserver/HasJoinedCest.php new file mode 100644 index 0000000..682099a --- /dev/null +++ b/tests/codeception/api/functional/sessionserver/HasJoinedCest.php @@ -0,0 +1,83 @@ +route = new SessionServerRoute($I); + } + + public function hasJoined(SessionServerSteps $I) { + $I->wantTo('check hasJoined user to some server'); + list($username, $serverId) = $I->amJoined(); + + $this->route->hasJoined([ + 'username' => $username, + 'serverId' => $serverId, + ]); + $I->seeResponseCodeIs(200); + $I->seeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'name' => $username, + 'id' => 'df936908b2e1544d96f82977ec213022', + 'ely' => true, + 'properties' => [ + [ + 'name' => 'textures', + 'signature' => 'Cg==', + ], + ], + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.properties[0].value'); + $value = json_decode($I->grabResponse(), true)['properties'][0]['value']; + $decoded = json_decode(base64_decode($value), true); + $I->assertArrayHasKey('timestamp', $decoded); + $I->assertArrayHasKey('textures', $decoded); + $I->assertEquals('df936908b2e1544d96f82977ec213022', $decoded['profileId']); + $I->assertEquals('Admin', $decoded['profileName']); + $I->assertTrue($decoded['ely']); + $textures = $decoded['textures']; + $I->assertArrayHasKey('SKIN', $textures); + $skinTextures = $textures['SKIN']; + $I->assertArrayHasKey('url', $skinTextures); + $I->assertArrayHasKey('hash', $skinTextures); + } + + public function wrongArguments(FunctionalTester $I) { + $I->wantTo('get error on wrong amount of arguments'); + $this->route->hasJoined([ + 'wrong' => 'argument', + ]); + $I->canSeeResponseCodeIs(400); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'error' => 'IllegalArgumentException', + 'errorMessage' => 'credentials can not be null.', + ]); + } + + public function hasJoinedWithNoJoinOperation(FunctionalTester $I) { + $I->wantTo('hasJoined to some server without join call'); + $this->route->hasJoined([ + 'username' => 'some-username', + 'serverId' => Uuid::uuid(), + ]); + $I->seeResponseCodeIs(401); + $I->seeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'error' => 'ForbiddenOperationException', + 'errorMessage' => 'Invalid token.', + ]); + } + +} diff --git a/tests/codeception/api/functional/sessionserver/HasJoinedLegacyCest.php b/tests/codeception/api/functional/sessionserver/HasJoinedLegacyCest.php new file mode 100644 index 0000000..b0133ac --- /dev/null +++ b/tests/codeception/api/functional/sessionserver/HasJoinedLegacyCest.php @@ -0,0 +1,51 @@ +route = new SessionServerRoute($I); + } + + public function hasJoined(SessionServerSteps $I) { + $I->wantTo('test hasJoined user to some server by legacy version'); + list($username, $serverId) = $I->amJoined(true); + + $this->route->hasJoinedLegacy([ + 'user' => $username, + 'serverId' => $serverId, + ]); + $I->seeResponseCodeIs(200); + $I->canSeeResponseEquals('YES'); + } + + public function wrongArguments(FunctionalTester $I) { + $I->wantTo('get error on wrong amount of arguments'); + $this->route->hasJoinedLegacy([ + 'wrong' => 'argument', + ]); + $I->canSeeResponseCodeIs(400); + $I->canSeeResponseEquals('credentials can not be null.'); + } + + public function hasJoinedWithNoJoinOperation(FunctionalTester $I) { + $I->wantTo('hasJoined by legacy version to some server without join call'); + $this->route->hasJoinedLegacy([ + 'user' => 'random-username', + 'serverId' => Uuid::uuid(), + ]); + $I->seeResponseCodeIs(401); + $I->canSeeResponseEquals('NO'); + } + +} From 6e15522140d2e23bd5ce997532806c9550f5bae0 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Tue, 6 Sep 2016 20:10:42 +0300 Subject: [PATCH 5/9] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D1=80=D0=B5=D0=B9=D1=82-=D0=BB=D0=B8=D0=BC=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D1=80=20=D0=B4=D0=BB=D1=8F=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D0=BE=D0=B2=20=D0=BA=20hasJoined=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BD=D0=B5=D0=B7=D0=B0=D1=80=D0=B5=D0=B3=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/components/User/Component.php | 2 +- .../session/controllers/SessionController.php | 5 ++ api/modules/session/filters/RateLimiter.php | 77 ++++++++++++++++++ .../session/filters/RateLimiterTest.php | 78 +++++++++++++++++++ 4 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 api/modules/session/filters/RateLimiter.php create mode 100644 tests/codeception/api/unit/modules/session/filters/RateLimiterTest.php diff --git a/api/components/User/Component.php b/api/components/User/Component.php index bf22613..7407a55 100644 --- a/api/components/User/Component.php +++ b/api/components/User/Component.php @@ -22,7 +22,7 @@ use yii\web\User as YiiUserComponent; * @property AccountSession|null $activeSession * @property AccountIdentity|null $identity * - * @method AccountIdentity|null getIdentity() + * @method AccountIdentity|null getIdentity($autoRenew = true) */ class Component extends YiiUserComponent { diff --git a/api/modules/session/controllers/SessionController.php b/api/modules/session/controllers/SessionController.php index 583b5a0..8e0835b 100644 --- a/api/modules/session/controllers/SessionController.php +++ b/api/modules/session/controllers/SessionController.php @@ -4,6 +4,7 @@ namespace api\modules\session\controllers; use api\controllers\ApiController; use api\modules\session\exceptions\ForbiddenOperationException; use api\modules\session\exceptions\SessionServerException; +use api\modules\session\filters\RateLimiter; use api\modules\session\models\HasJoinedForm; use api\modules\session\models\JoinForm; use api\modules\session\models\protocols\LegacyJoin; @@ -18,6 +19,10 @@ class SessionController extends ApiController { public function behaviors() { $behaviors = parent::behaviors(); unset($behaviors['authenticator']); + $behaviors['rateLimiting'] = [ + 'class' => RateLimiter::class, + 'only' => ['has-joined', 'has-joined-legacy'], + ]; return $behaviors; } diff --git a/api/modules/session/filters/RateLimiter.php b/api/modules/session/filters/RateLimiter.php new file mode 100644 index 0000000..8e2f239 --- /dev/null +++ b/api/modules/session/filters/RateLimiter.php @@ -0,0 +1,77 @@ +getServer($request); + if ($server !== null) { + return; + } + + $ip = $request->getUserIP(); + $key = $this->buildKey($ip); + + $redis = $this->getRedis(); + $countRequests = intval($redis->executeCommand('INCR', [$key])); + if ($countRequests === 1) { + $redis->executeCommand('EXPIRE', [$key, $this->limitTime]); + } + + if ($countRequests > $this->limit) { + throw new TooManyRequestsHttpException($this->errorMessage); + } + } + + /** + * @return \yii\redis\Connection + */ + public function getRedis() { + return Yii::$app->redis; + } + + /** + * @param Request $request + * @return OauthClient|null + */ + protected function getServer(Request $request) { + $serverId = $request->get('server_id'); + if ($serverId === null) { + $this->server = false; + return null; + } + + if ($this->server === null) { + /** @var OauthClient $server */ + $this->server = OauthClient::findOne($serverId); + // TODO: убедится, что это сервер + if ($this->server === null) { + $this->server = false; + } + } + + if ($this->server === false) { + return null; + } + + return $this->server; + } + + protected function buildKey($ip) : string { + return 'sessionserver:ratelimit:' . $ip; + } + +} diff --git a/tests/codeception/api/unit/modules/session/filters/RateLimiterTest.php b/tests/codeception/api/unit/modules/session/filters/RateLimiterTest.php new file mode 100644 index 0000000..10fa675 --- /dev/null +++ b/tests/codeception/api/unit/modules/session/filters/RateLimiterTest.php @@ -0,0 +1,78 @@ +getMockBuilder(Connection::class) + ->setMethods(['executeCommand']) + ->getMock(); + + $redis->expects($this->never()) + ->method('executeCommand'); + + Yii::$app->set('redis', $redis); + + /** @var RateLimiter|\PHPUnit_Framework_MockObject_MockObject $filter */ + $filter = $this->getMockBuilder(RateLimiter::class) + ->setMethods(['getServer']) + ->getMock(); + + $filter->expects($this->any()) + ->method('getServer') + ->will($this->returnValue(new OauthClient())); + + $filter->checkRateLimit(null, new Request(), null, null); + } + + /** + * @expectedException \yii\web\TooManyRequestsHttpException + */ + public function testCheckRateLimiter() { + /** @var Connection|\PHPUnit_Framework_MockObject_MockObject $redis */ + $redis = $this->getMockBuilder(Connection::class) + ->setMethods(['executeCommand']) + ->getMock(); + + $redis->expects($this->exactly(5)) + ->method('executeCommand') + ->will($this->onConsecutiveCalls('1', '1', '2', '3', '4')); + + Yii::$app->set('redis', $redis); + + /** @var Request|\PHPUnit_Framework_MockObject_MockObject $request */ + $request = $this->getMockBuilder(Request::class) + ->setMethods(['getUserIP']) + ->getMock(); + + $request->expects($this->any()) + ->method('getUserIp') + ->will($this->returnValue(Internet::localIpv4())); + + /** @var RateLimiter|\PHPUnit_Framework_MockObject_MockObject $filter */ + $filter = $this->getMockBuilder(RateLimiter::class) + ->setConstructorArgs([[ + 'limit' => 3, + ]]) + ->setMethods(['getServer']) + ->getMock(); + + $filter->expects($this->any()) + ->method('getServer') + ->will($this->returnValue(null)); + + for ($i = 0; $i < 5; $i++) { + $filter->checkRateLimit(null, $request, null, null); + } + } + +} From 8eb6a595c0093f25070519e58bc4a55274257596 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Wed, 7 Sep 2016 17:56:30 +0300 Subject: [PATCH 6/9] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=80=D0=BE=D1=83=D1=82=D1=8B=20=D0=B2=20=D1=81?= =?UTF-8?q?=D0=BE=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=81=D1=82=D0=B2=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=81=20=D1=82=D0=B5=D0=BC,=20=D0=BA=D0=B0=D0=BA=20?= =?UTF-8?q?=D0=BE=D0=BD=D0=B8=20=D0=B2=20=D0=B8=D1=82=D0=BE=D0=B3=D0=B5=20?= =?UTF-8?q?=D0=B1=D1=83=D0=B4=D1=83=D1=82=20=D0=BF=D1=80=D0=BE=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B0=D0=BD=D1=8B=20=D0=B2=20=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D0=B8=20=D0=92=20ngi?= =?UTF-8?q?nx=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F,=20=D0=BE=D1=82=D0=B2=D0=B5=D1=87=D0=B0=D1=8E?= =?UTF-8?q?=D1=89=D0=B0=D1=8F=20=D0=B7=D0=B0=20=D0=BF=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=81?= =?UTF-8?q?=D0=B5=D1=81=D1=81=D0=B8=D0=BE=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=BE=D0=B2=20=D1=81=D0=BE=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=80=D0=BE=D0=B3=D0=BE=20=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=92=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20ngi?= =?UTF-8?q?nx=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=81=201.9=20=D0=B4=D0=BE=201.11=20=D0=92=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=85=20=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=BE?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D1=83=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20url?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/config/main.php | 4 ++-- api/config/routes.php | 5 ++++ api/modules/authserver/Module.php | 24 +++++++++++++++++++ api/modules/session/Module.php | 13 +--------- docker/nginx/Dockerfile | 2 +- docker/nginx/account.ely.by.conf.template | 15 +++++++----- .../api/_pages/SessionServerRoute.php | 8 +++---- 7 files changed, 46 insertions(+), 25 deletions(-) diff --git a/api/config/main.php b/api/config/main.php index 1bd152b..96d093f 100644 --- a/api/config/main.php +++ b/api/config/main.php @@ -9,7 +9,7 @@ $params = array_merge( return [ 'id' => 'accounts-site-api', 'basePath' => dirname(__DIR__), - 'bootstrap' => ['log', 'authserver', 'sessionserver'], + 'bootstrap' => ['log', 'authserver'], 'controllerNamespace' => 'api\controllers', 'params' => $params, 'components' => [ @@ -56,7 +56,7 @@ return [ 'class' => \api\modules\authserver\Module::class, 'baseDomain' => $params['authserverDomain'], ], - 'sessionserver' => [ + 'session' => [ 'class' => \api\modules\session\Module::class, ], ], diff --git a/api/config/routes.php b/api/config/routes.php index 31cc2e3..2a0f5a0 100644 --- a/api/config/routes.php +++ b/api/config/routes.php @@ -7,4 +7,9 @@ return [ '/oauth2/v1/' => 'oauth/', '/account/v1/info' => 'identity-info/index', + + '/minecraft/session/join' => 'session/session/join', + '/minecraft/session/legacy/join' => 'session/session/join-legacy', + '/minecraft/session/hasJoined' => 'session/session/has-joined', + '/minecraft/session/legacy/hasJoined' => 'session/session/has-joined-legacy', ]; diff --git a/api/modules/authserver/Module.php b/api/modules/authserver/Module.php index 2e1e041..d8bcc11 100644 --- a/api/modules/authserver/Module.php +++ b/api/modules/authserver/Module.php @@ -4,6 +4,7 @@ namespace api\modules\authserver; use Yii; use yii\base\BootstrapInterface; use yii\base\InvalidConfigException; +use yii\web\NotFoundHttpException; class Module extends \yii\base\Module implements BootstrapInterface { @@ -23,6 +24,16 @@ class Module extends \yii\base\Module implements BootstrapInterface { } } + public function beforeAction($action) { + if (!parent::beforeAction($action)) { + return false; + } + + $this->checkHost(); + + return true; + } + /** * @param \yii\base\Application $app the application currently running */ @@ -40,4 +51,17 @@ class Module extends \yii\base\Module implements BootstrapInterface { Yii::info($message, 'legacy-authserver'); } + /** + * Поскольку это legacy метод и документации в новой среде для него не будет, + * нет смысла выставлять на показ внутренние url, так что ограничиваем доступ + * только для заходов по старому домену + * + * @throws NotFoundHttpException + */ + protected function checkHost() { + if (Yii::$app->request->getHostInfo() !== $this->baseDomain) { + throw new NotFoundHttpException(); + } + } + } diff --git a/api/modules/session/Module.php b/api/modules/session/Module.php index 365ffb0..63f1cad 100644 --- a/api/modules/session/Module.php +++ b/api/modules/session/Module.php @@ -2,24 +2,13 @@ namespace api\modules\session; use Yii; -use yii\base\BootstrapInterface; -class Module extends \yii\base\Module implements BootstrapInterface { +class Module extends \yii\base\Module { public $id = 'session'; public $defaultRoute = 'session'; - /** - * @param \yii\base\Application $app the application currently running - */ - public function bootstrap($app) { - $app->getUrlManager()->addRules([ - // TODO: define normal routes - //$this->baseDomain . '/' . $this->id . '/auth/' => $this->id . '/authentication/', - ], false); - } - public static function info($message) { Yii::info($message, 'session'); } diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile index e7f976e..ed15437 100644 --- a/docker/nginx/Dockerfile +++ b/docker/nginx/Dockerfile @@ -1,4 +1,4 @@ -FROM nginx:1.9 +FROM nginx:1.11 COPY nginx.conf /etc/nginx/nginx.conf COPY account.ely.by.conf.template /etc/nginx/conf.d/account.ely.by.conf.template diff --git a/docker/nginx/account.ely.by.conf.template b/docker/nginx/account.ely.by.conf.template index 7430d72..67410fd 100644 --- a/docker/nginx/account.ely.by.conf.template +++ b/docker/nginx/account.ely.by.conf.template @@ -4,7 +4,6 @@ server { set $root_path '/var/www/html'; set $api_path '${root_path}/api/web'; set $frontend_path '${root_path}/frontend/dist'; - set $authserver_host '${AUTHSERVER_HOST}'; root $root_path; charset utf-8; @@ -12,15 +11,19 @@ server { etag on; set $request_url $request_uri; - if ($host = $authserver_host) { + set $host_with_uri '${host}${request_uri}'; + + if ($host_with_uri ~* '^${AUTHSERVER_HOST}/auth') { set $request_url '/api/authserver${request_uri}'; + rewrite ^/auth /api/authserver$uri last; + } + + if ($host_with_uri ~* '^${AUTHSERVER_HOST}/session') { + set $request_url '/api/minecraft${request_uri}'; + rewrite ^/session /api/minecraft$uri last; } location / { - if ($host = $authserver_host) { - rewrite ^ /api/authserver$uri last; - } - alias $frontend_path; index index.html; try_files $uri /index.html =404; diff --git a/tests/codeception/api/_pages/SessionServerRoute.php b/tests/codeception/api/_pages/SessionServerRoute.php index d136aac..7e102ff 100644 --- a/tests/codeception/api/_pages/SessionServerRoute.php +++ b/tests/codeception/api/_pages/SessionServerRoute.php @@ -9,22 +9,22 @@ use yii\codeception\BasePage; class SessionServerRoute extends BasePage { public function join($params) { - $this->route = ['sessionserver/session/join']; + $this->route = '/minecraft/session/join'; $this->actor->sendPOST($this->getUrl(), $params); } public function joinLegacy(array $params) { - $this->route = ['sessionserver/session/join-legacy']; + $this->route = '/minecraft/session/legacy/join'; $this->actor->sendGET($this->getUrl(), $params); } public function hasJoined(array $params) { - $this->route = ['sessionserver/session/has-joined']; + $this->route = '/minecraft/session/hasJoined'; $this->actor->sendGET($this->getUrl(), $params); } public function hasJoinedLegacy(array $params) { - $this->route = ['sessionserver/session/has-joined-legacy']; + $this->route = '/minecraft/session/legacy/hasJoined'; $this->actor->sendGET($this->getUrl(), $params); } From c2eee9b67d65e506cc2a38d7214594564d7b9e2a Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Thu, 8 Sep 2016 13:07:43 +0300 Subject: [PATCH 7/9] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=20/session/profil?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/config/routes.php | 1 + .../session/controllers/SessionController.php | 18 ++++++ .../exceptions/IllegalArgumentException.php | 4 +- .../api/_pages/SessionServerRoute.php | 5 ++ .../functional/_steps/SessionServerSteps.php | 28 +++++++++ .../sessionserver/HasJoinedCest.php | 26 +------- .../functional/sessionserver/ProfileCest.php | 61 +++++++++++++++++++ 7 files changed, 116 insertions(+), 27 deletions(-) create mode 100644 tests/codeception/api/functional/sessionserver/ProfileCest.php diff --git a/api/config/routes.php b/api/config/routes.php index 2a0f5a0..6dec26d 100644 --- a/api/config/routes.php +++ b/api/config/routes.php @@ -12,4 +12,5 @@ return [ '/minecraft/session/legacy/join' => 'session/session/join-legacy', '/minecraft/session/hasJoined' => 'session/session/has-joined', '/minecraft/session/legacy/hasJoined' => 'session/session/has-joined-legacy', + '/minecraft/session/profile/' => 'session/session/profile', ]; diff --git a/api/modules/session/controllers/SessionController.php b/api/modules/session/controllers/SessionController.php index 8e0835b..56475c4 100644 --- a/api/modules/session/controllers/SessionController.php +++ b/api/modules/session/controllers/SessionController.php @@ -3,6 +3,7 @@ namespace api\modules\session\controllers; use api\controllers\ApiController; use api\modules\session\exceptions\ForbiddenOperationException; +use api\modules\session\exceptions\IllegalArgumentException; use api\modules\session\exceptions\SessionServerException; use api\modules\session\filters\RateLimiter; use api\modules\session\models\HasJoinedForm; @@ -10,7 +11,9 @@ use api\modules\session\models\JoinForm; use api\modules\session\models\protocols\LegacyJoin; use api\modules\session\models\protocols\ModernHasJoined; use api\modules\session\models\protocols\ModernJoin; +use common\models\Account; use common\models\Textures; +use Ramsey\Uuid\Uuid; use Yii; use yii\web\Response; @@ -99,4 +102,19 @@ class SessionController extends ApiController { return 'YES'; } + public function actionProfile($uuid) { + try { + $uuid = Uuid::fromString($uuid)->toString(); + } catch(\InvalidArgumentException $e) { + throw new IllegalArgumentException('Invalid uuid format.'); + } + + $account = Account::findOne(['uuid' => $uuid]); + if ($account === null) { + throw new ForbiddenOperationException('Invalid uuid.'); + } + + return (new Textures($account))->getMinecraftResponse(); + } + } diff --git a/api/modules/session/exceptions/IllegalArgumentException.php b/api/modules/session/exceptions/IllegalArgumentException.php index 2250a22..ac904db 100644 --- a/api/modules/session/exceptions/IllegalArgumentException.php +++ b/api/modules/session/exceptions/IllegalArgumentException.php @@ -3,8 +3,8 @@ namespace api\modules\session\exceptions; class IllegalArgumentException extends SessionServerException { - public function __construct($status = null, $message = null, $code = 0, \Exception $previous = null) { - parent::__construct(400, 'credentials can not be null.', $code, $previous); + public function __construct($message = 'credentials can not be null.', $code = 0, \Exception $previous = null) { + parent::__construct(400, $message, $code, $previous); } } diff --git a/tests/codeception/api/_pages/SessionServerRoute.php b/tests/codeception/api/_pages/SessionServerRoute.php index 7e102ff..b3ac951 100644 --- a/tests/codeception/api/_pages/SessionServerRoute.php +++ b/tests/codeception/api/_pages/SessionServerRoute.php @@ -28,4 +28,9 @@ class SessionServerRoute extends BasePage { $this->actor->sendGET($this->getUrl(), $params); } + public function profile($profileUuid) { + $this->route = '/minecraft/session/profile/' . $profileUuid; + $this->actor->sendGET($this->getUrl()); + } + } diff --git a/tests/codeception/api/functional/_steps/SessionServerSteps.php b/tests/codeception/api/functional/_steps/SessionServerSteps.php index 6bcb3fe..b5b4d6a 100644 --- a/tests/codeception/api/functional/_steps/SessionServerSteps.php +++ b/tests/codeception/api/functional/_steps/SessionServerSteps.php @@ -34,5 +34,33 @@ class SessionServerSteps extends \tests\codeception\api\FunctionalTester { return [$username, $serverId]; } + + public function canSeeValidTexturesResponse($expectedUsername, $expectedUuid) { + $this->seeResponseIsJson(); + $this->canSeeResponseContainsJson([ + 'name' => $expectedUsername, + 'id' => $expectedUuid, + 'ely' => true, + 'properties' => [ + [ + 'name' => 'textures', + 'signature' => 'Cg==', + ], + ], + ]); + $this->canSeeResponseJsonMatchesJsonPath('$.properties[0].value'); + $value = json_decode($this->grabResponse(), true)['properties'][0]['value']; + $decoded = json_decode(base64_decode($value), true); + $this->assertArrayHasKey('timestamp', $decoded); + $this->assertArrayHasKey('textures', $decoded); + $this->assertEquals($expectedUuid, $decoded['profileId']); + $this->assertEquals($expectedUsername, $decoded['profileName']); + $this->assertTrue($decoded['ely']); + $textures = $decoded['textures']; + $this->assertArrayHasKey('SKIN', $textures); + $skinTextures = $textures['SKIN']; + $this->assertArrayHasKey('url', $skinTextures); + $this->assertArrayHasKey('hash', $skinTextures); + } } diff --git a/tests/codeception/api/functional/sessionserver/HasJoinedCest.php b/tests/codeception/api/functional/sessionserver/HasJoinedCest.php index 682099a..8b90d1e 100644 --- a/tests/codeception/api/functional/sessionserver/HasJoinedCest.php +++ b/tests/codeception/api/functional/sessionserver/HasJoinedCest.php @@ -26,31 +26,7 @@ class HasJoinedCest { 'serverId' => $serverId, ]); $I->seeResponseCodeIs(200); - $I->seeResponseIsJson(); - $I->canSeeResponseContainsJson([ - 'name' => $username, - 'id' => 'df936908b2e1544d96f82977ec213022', - 'ely' => true, - 'properties' => [ - [ - 'name' => 'textures', - 'signature' => 'Cg==', - ], - ], - ]); - $I->canSeeResponseJsonMatchesJsonPath('$.properties[0].value'); - $value = json_decode($I->grabResponse(), true)['properties'][0]['value']; - $decoded = json_decode(base64_decode($value), true); - $I->assertArrayHasKey('timestamp', $decoded); - $I->assertArrayHasKey('textures', $decoded); - $I->assertEquals('df936908b2e1544d96f82977ec213022', $decoded['profileId']); - $I->assertEquals('Admin', $decoded['profileName']); - $I->assertTrue($decoded['ely']); - $textures = $decoded['textures']; - $I->assertArrayHasKey('SKIN', $textures); - $skinTextures = $textures['SKIN']; - $I->assertArrayHasKey('url', $skinTextures); - $I->assertArrayHasKey('hash', $skinTextures); + $I->canSeeValidTexturesResponse($username, 'df936908b2e1544d96f82977ec213022'); } public function wrongArguments(FunctionalTester $I) { diff --git a/tests/codeception/api/functional/sessionserver/ProfileCest.php b/tests/codeception/api/functional/sessionserver/ProfileCest.php new file mode 100644 index 0000000..29e83c1 --- /dev/null +++ b/tests/codeception/api/functional/sessionserver/ProfileCest.php @@ -0,0 +1,61 @@ +route = new SessionServerRoute($I); + } + + public function getProfile(SessionServerSteps $I) { + $I->wantTo('get info about player textures by uuid'); + $this->route->profile('df936908-b2e1-544d-96f8-2977ec213022'); + $I->canSeeValidTexturesResponse('Admin', 'df936908b2e1544d96f82977ec213022'); + } + + public function getProfileByUuidWithoutDashes(SessionServerSteps $I) { + $I->wantTo('get info about player textures by uuid without dashes'); + $this->route->profile('df936908b2e1544d96f82977ec213022'); + $I->canSeeValidTexturesResponse('Admin', 'df936908b2e1544d96f82977ec213022'); + } + + public function directCallWithoutUuidPart(FunctionalTester $I) { + $I->wantTo('call profile route without passing uuid'); + $this->route->profile(''); + $I->canSeeResponseCodeIs(404); + } + + public function callWithInvalidUuid(FunctionalTester $I) { + $I->wantTo('call profile route with invalid uuid string'); + $this->route->profile('bla-bla-bla'); + $I->canSeeResponseCodeIs(400); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'error' => 'IllegalArgumentException', + 'errorMessage' => 'Invalid uuid format.', + ]); + } + + public function getProfileWithNonexistentUuid(FunctionalTester $I) { + $I->wantTo('get info about nonexistent uuid'); + $this->route->profile(Uuid::uuid()); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseIsJson(); + $I->seeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'error' => 'ForbiddenOperationException', + 'errorMessage' => 'Invalid uuid.', + ]); + } + +} From d2fd803b0d6287ce0d0f9ff1615d8226f8d7f5b9 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Thu, 8 Sep 2016 19:06:44 +0300 Subject: [PATCH 8/9] =?UTF-8?q?=D0=9E=D1=82=D0=BA=D0=BB=D1=8E=D1=87=D0=B0?= =?UTF-8?q?=D0=B5=D0=BC=20RateLimiter=20=D0=B4=D0=BB=D1=8F=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=BE=D0=B2,=20=D1=87=D1=82=D0=BE=20?= =?UTF-8?q?=D0=B8=D0=B4=D1=83=D1=82=20=D1=81=20=D1=85=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=20=D1=81=D1=82=D0=B0=D1=80=D0=BE=D0=B3=D0=BE=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8,=20=D0=BD=D0=BE=20?= =?UTF-8?q?=D0=B2=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D0=B5=D0=BC=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=BE=D1=81=D1=82=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20(=D1=84=D0=B8=D0=BA=D1=81=20=D0=B4=D0=BB=D1=8F=20beforeActio?= =?UTF-8?q?n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/modules/session/filters/RateLimiter.php | 32 +++++++++++++++++++ .../session/filters/RateLimiterTest.php | 26 ++++++++++++++- tests/codeception/config/api/config.php | 3 ++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/api/modules/session/filters/RateLimiter.php b/api/modules/session/filters/RateLimiter.php index 8e2f239..cc912b8 100644 --- a/api/modules/session/filters/RateLimiter.php +++ b/api/modules/session/filters/RateLimiter.php @@ -3,6 +3,7 @@ namespace api\modules\session\filters; use common\models\OauthClient; use Yii; +use yii\base\InvalidConfigException; use yii\web\Request; use yii\web\TooManyRequestsHttpException; @@ -11,12 +12,43 @@ class RateLimiter extends \yii\filters\RateLimiter { public $limit = 180; public $limitTime = 3600; // 1h + public $authserverDomain; + private $server; + public function init() { + parent::init(); + if ($this->authserverDomain === null) { + $this->authserverDomain = Yii::$app->params['authserverDomain'] ?? null; + } + + if ($this->authserverDomain === null) { + throw new InvalidConfigException('authserverDomain param is required'); + } + } + + /** + * @inheritdoc + */ + public function beforeAction($action) { + $this->checkRateLimit( + null, + $this->request ?: Yii::$app->getRequest(), + $this->response ?: Yii::$app->getResponse(), + $action + ); + + return true; + } + /** * @inheritdoc */ public function checkRateLimit($user, $request, $response, $action) { + if ($request->getHostInfo() === $this->authserverDomain) { + return; + } + $server = $this->getServer($request); if ($server !== null) { return; diff --git a/tests/codeception/api/unit/modules/session/filters/RateLimiterTest.php b/tests/codeception/api/unit/modules/session/filters/RateLimiterTest.php index 10fa675..e99a699 100644 --- a/tests/codeception/api/unit/modules/session/filters/RateLimiterTest.php +++ b/tests/codeception/api/unit/modules/session/filters/RateLimiterTest.php @@ -11,7 +11,7 @@ use yii\web\Request; class RateLimiterTest extends TestCase { - public function testCheckRateLimiterWithValidServerId() { + public function testCheckRateLimiterWithOldAuthserver() { /** @var Connection|\PHPUnit_Framework_MockObject_MockObject $redis */ $redis = $this->getMockBuilder(Connection::class) ->setMethods(['executeCommand']) @@ -34,6 +34,30 @@ class RateLimiterTest extends TestCase { $filter->checkRateLimit(null, new Request(), null, null); } + public function testCheckRateLimiterWithValidServerId() { + /** @var Connection|\PHPUnit_Framework_MockObject_MockObject $redis */ + $redis = $this->getMockBuilder(Connection::class) + ->setMethods(['executeCommand']) + ->getMock(); + + $redis->expects($this->never()) + ->method('executeCommand'); + + Yii::$app->set('redis', $redis); + + /** @var Request|\PHPUnit_Framework_MockObject_MockObject $request */ + $request = $this->getMockBuilder(Request::class) + ->setMethods(['getHostInfo']) + ->getMock(); + + $request->expects($this->any()) + ->method('getHostInfo') + ->will($this->returnValue('http://authserver.ely.by')); + + $filter = new RateLimiter(); + $filter->checkRateLimit(null, $request, null, null); + } + /** * @expectedException \yii\web\TooManyRequestsHttpException */ diff --git a/tests/codeception/config/api/config.php b/tests/codeception/config/api/config.php index 86797ca..7c3a3d0 100644 --- a/tests/codeception/config/api/config.php +++ b/tests/codeception/config/api/config.php @@ -9,4 +9,7 @@ return [ 'secret' => 'private-key', ], ], + 'params' => [ + 'authserverDomain' => 'http://authserver.ely.by', + ], ]; From dd2407e347d651b3495d87ae1ee241fc00ead2eb Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Fri, 9 Sep 2016 00:39:00 +0300 Subject: [PATCH 9/9] =?UTF-8?q?=D0=A1=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3?= =?UTF-8?q?=D1=83=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BE=20=D0=BB?= =?UTF-8?q?=D0=BE=D0=B3=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/components/ErrorHandler.php | 11 +++++++++++ api/config/main.php | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/api/components/ErrorHandler.php b/api/components/ErrorHandler.php index 54e8531..7ec29e7 100644 --- a/api/components/ErrorHandler.php +++ b/api/components/ErrorHandler.php @@ -3,6 +3,7 @@ namespace api\components; use api\modules\authserver\exceptions\AuthserverException; use api\modules\session\exceptions\SessionServerException; +use Yii; class ErrorHandler extends \yii\web\ErrorHandler { @@ -17,4 +18,14 @@ class ErrorHandler extends \yii\web\ErrorHandler { return parent::convertExceptionToArray($exception); } + public function logException($exception) { + if ($exception instanceof AuthserverException) { + Yii::error($exception, AuthserverException::class . ':' . $exception->getName()); + } elseif ($exception instanceof SessionServerException) { + Yii::error($exception, SessionServerException::class . ':' . $exception->getName()); + } else { + parent::logException($exception); + } + } + } diff --git a/api/config/main.php b/api/config/main.php index 96d093f..ef82de5 100644 --- a/api/config/main.php +++ b/api/config/main.php @@ -26,6 +26,24 @@ return [ [ 'class' => \yii\log\FileTarget::class, 'levels' => ['error', 'warning'], + 'except' => [ + 'legacy-authserver', + 'session', + 'api\modules\session\exceptions\SessionServerException:*', + 'api\modules\authserver\exceptions\AuthserverException:*', + ], + ], + [ + 'class' => \yii\log\FileTarget::class, + 'levels' => ['error', 'info'], + 'categories' => ['legacy-authserver'], + 'logFile' => '@runtime/logs/authserver.log', + ], + [ + 'class' => \yii\log\FileTarget::class, + 'levels' => ['error', 'info'], + 'categories' => ['session'], + 'logFile' => '@runtime/logs/session.log', ], ], ],