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..7ec29e7 100644 --- a/api/components/ErrorHandler.php +++ b/api/components/ErrorHandler.php @@ -2,11 +2,13 @@ namespace api\components; use api\modules\authserver\exceptions\AuthserverException; +use api\modules\session\exceptions\SessionServerException; +use Yii; 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(), @@ -16,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/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/config/main.php b/api/config/main.php index 35302cb..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', ], ], ], @@ -56,5 +74,8 @@ return [ 'class' => \api\modules\authserver\Module::class, 'baseDomain' => $params['authserverDomain'], ], + 'session' => [ + 'class' => \api\modules\session\Module::class, + ], ], ]; diff --git a/api/config/routes.php b/api/config/routes.php index 31cc2e3..6dec26d 100644 --- a/api/config/routes.php +++ b/api/config/routes.php @@ -7,4 +7,10 @@ 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', + '/minecraft/session/profile/' => 'session/session/profile', ]; 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/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..63f1cad --- /dev/null +++ b/api/modules/session/Module.php @@ -0,0 +1,20 @@ + RateLimiter::class, + 'only' => ['has-joined', 'has-joined-legacy'], + ]; + + return $behaviors; + } + + public function actionJoin() { + 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'; + } + + 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'; + } + + 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/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/filters/RateLimiter.php b/api/modules/session/filters/RateLimiter.php new file mode 100644 index 0000000..cc912b8 --- /dev/null +++ b/api/modules/session/filters/RateLimiter.php @@ -0,0 +1,109 @@ +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; + } + + $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/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 new file mode 100644 index 0000000..e0ac1de --- /dev/null +++ b/api/modules/session/models/JoinForm.php @@ -0,0 +1,162 @@ +protocol = $protocol; + $this->accessToken = $protocol->getAccessToken(); + $this->selectedProfile = $protocol->getSelectedProfile(); + $this->serverId = $protocol->getServerId(); + + parent::__construct($config); + } + + public function rules() { + return [ + [['accessToken', 'serverId'], RequiredValidator::class], + [['accessToken', 'selectedProfile'], 'validateUuid'], + [['accessToken'], 'validateAccessToken'], + ]; + } + + public function join() { + $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, $serverId); + if (!$sessionModel->save()) { + throw new ErrorException('Cannot save join session model'); + } + + Session::info( + "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); + + 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.'); + } + + $selectedProfile = $this->selectedProfile; + $isUuid = StringHelper::isUuid($selectedProfile); + if ($isUuid && $account->uuid !== $selectedProfile) { + Session::error( + "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; + } + + /** + * @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..da43391 --- /dev/null +++ b/api/modules/session/models/SessionModel.php @@ -0,0 +1,66 @@ +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)]); + } + + /** + * @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/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/ModernHasJoined.php b/api/modules/session/models/protocols/ModernHasJoined.php new file mode 100644 index 0000000..8d6584f --- /dev/null +++ b/api/modules/session/models/protocols/ModernHasJoined.php @@ -0,0 +1,6 @@ +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/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 @@ +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 1303616..6ef7bfe 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, @@ -22,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/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 @@ 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/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/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/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 new file mode 100644 index 0000000..b3ac951 --- /dev/null +++ b/tests/codeception/api/_pages/SessionServerRoute.php @@ -0,0 +1,36 @@ +route = '/minecraft/session/join'; + $this->actor->sendPOST($this->getUrl(), $params); + } + + public function joinLegacy(array $params) { + $this->route = '/minecraft/session/legacy/join'; + $this->actor->sendGET($this->getUrl(), $params); + } + + public function hasJoined(array $params) { + $this->route = '/minecraft/session/hasJoined'; + $this->actor->sendGET($this->getUrl(), $params); + } + + public function hasJoinedLegacy(array $params) { + $this->route = '/minecraft/session/legacy/hasJoined'; + $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.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..b5b4d6a --- /dev/null +++ b/tests/codeception/api/functional/_steps/SessionServerSteps.php @@ -0,0 +1,66 @@ +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]; + } + + 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 new file mode 100644 index 0000000..8b90d1e --- /dev/null +++ b/tests/codeception/api/functional/sessionserver/HasJoinedCest.php @@ -0,0 +1,59 @@ +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->canSeeValidTexturesResponse($username, 'df936908b2e1544d96f82977ec213022'); + } + + 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'); + } + +} diff --git a/tests/codeception/api/functional/sessionserver/JoinCest.php b/tests/codeception/api/functional/sessionserver/JoinCest.php new file mode 100644 index 0000000..00d9cf3 --- /dev/null +++ b/tests/codeception/api/functional/sessionserver/JoinCest.php @@ -0,0 +1,122 @@ +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 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, + '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/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/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.', + ]); + } + +} 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..e99a699 --- /dev/null +++ b/tests/codeception/api/unit/modules/session/filters/RateLimiterTest.php @@ -0,0 +1,102 @@ +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); + } + + 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 + */ + 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); + } + } + +} 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')); } } 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], + ]; + } + +} 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', + ], ];