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], + ]; + } + +}