diff --git a/api/components/ApiUser/Component.php b/api/components/ApiUser/Component.php index 46acfc1..d6f4655 100644 --- a/api/components/ApiUser/Component.php +++ b/api/components/ApiUser/Component.php @@ -6,8 +6,8 @@ use yii\web\User as YiiUserComponent; /** * @property Identity|null $identity * - * @method Identity|null getIdentity() - * @method Identity|null loginByAccessToken(string $token, $type = null) + * @method Identity|null getIdentity($autoRenew = true) + * @method Identity|null loginByAccessToken($token, $type = null) */ class Component extends YiiUserComponent { diff --git a/api/components/ApiUser/Identity.php b/api/components/ApiUser/Identity.php index fb3510d..953b5a7 100644 --- a/api/components/ApiUser/Identity.php +++ b/api/components/ApiUser/Identity.php @@ -26,7 +26,8 @@ class Identity implements IdentityInterface { /** * @inheritdoc */ - public static function findIdentityByAccessToken($token, $type = null) { + public static function findIdentityByAccessToken($token, $type = null): self { + /** @var AccessTokenEntity|null $model */ $model = Yii::$app->oauth->getAuthServer()->getAccessTokenStorage()->get($token); if ($model === null) { throw new UnauthorizedHttpException('Incorrect token'); @@ -41,19 +42,19 @@ class Identity implements IdentityInterface { $this->_accessToken = $accessToken; } - public function getAccount() : Account { + public function getAccount(): Account { return $this->getSession()->account; } - public function getClient() : OauthClient { + public function getClient(): OauthClient { return $this->getSession()->client; } - public function getSession() : OauthSession { + public function getSession(): OauthSession { return OauthSession::findOne($this->_accessToken->getSessionId()); } - public function getAccessToken() : AccessTokenEntity { + public function getAccessToken(): AccessTokenEntity { return $this->_accessToken; } @@ -62,7 +63,7 @@ class Identity implements IdentityInterface { * У нас права привязываются к токенам, так что возвращаем именно его id. * @inheritdoc */ - public function getId() { + public function getId(): string { return $this->_accessToken->getId(); } diff --git a/api/components/OAuth2/Entities/ClientEntity.php b/api/components/OAuth2/Entities/ClientEntity.php index 8636cf1..e88f424 100644 --- a/api/components/OAuth2/Entities/ClientEntity.php +++ b/api/components/OAuth2/Entities/ClientEntity.php @@ -3,6 +3,8 @@ namespace api\components\OAuth2\Entities; class ClientEntity extends \League\OAuth2\Server\Entity\ClientEntity { + private $isTrusted; + public function setId(string $id) { $this->id = $id; } @@ -19,4 +21,12 @@ class ClientEntity extends \League\OAuth2\Server\Entity\ClientEntity { $this->redirectUri = $redirectUri; } + public function setIsTrusted(bool $isTrusted) { + $this->isTrusted = $isTrusted; + } + + public function isTrusted(): bool { + return $this->isTrusted; + } + } diff --git a/api/components/OAuth2/Grants/ClientCredentialsGrant.php b/api/components/OAuth2/Grants/ClientCredentialsGrant.php new file mode 100644 index 0000000..4e7b467 --- /dev/null +++ b/api/components/OAuth2/Grants/ClientCredentialsGrant.php @@ -0,0 +1,20 @@ +server); + } + + protected function createRefreshTokenEntity() { + return new Entities\RefreshTokenEntity($this->server); + } + + protected function createSessionEntity() { + return new Entities\SessionEntity($this->server); + } + +} diff --git a/api/components/OAuth2/Storage/ClientStorage.php b/api/components/OAuth2/Storage/ClientStorage.php index 90d024b..9a339f0 100644 --- a/api/components/OAuth2/Storage/ClientStorage.php +++ b/api/components/OAuth2/Storage/ClientStorage.php @@ -74,6 +74,7 @@ class ClientStorage extends AbstractStorage implements ClientInterface { $entity->setId($model->id); $entity->setName($model->name); $entity->setSecret($model->secret); + $entity->setIsTrusted($model->is_trusted); $entity->setRedirectUri($model->redirect_uri); return $entity; diff --git a/api/components/OAuth2/Storage/ScopeStorage.php b/api/components/OAuth2/Storage/ScopeStorage.php index be42d1e..d5223e5 100644 --- a/api/components/OAuth2/Storage/ScopeStorage.php +++ b/api/components/OAuth2/Storage/ScopeStorage.php @@ -1,10 +1,12 @@ onlyPublic()->usersScopes(); + } elseif ($grantType === 'client_credentials') { + $query->machineScopes(); + $isTrusted = false; + if ($clientId !== null) { + $client = $this->server->getClientStorage()->get($clientId); + if (!$client instanceof ClientEntity) { + throw new ErrorException('client storage must return instance of ' . ClientEntity::class); + } + + $isTrusted = $client->isTrusted(); + } + + if (!$isTrusted) { + $query->onlyPublic(); + } + } + + $scopes = $query->all(); + if (!in_array($scope, $scopes, true)) { return null; } diff --git a/api/config/config.php b/api/config/config.php index 68e83c2..c00e318 100644 --- a/api/config/config.php +++ b/api/config/config.php @@ -7,7 +7,7 @@ $params = array_merge( return [ 'id' => 'accounts-site-api', 'basePath' => dirname(__DIR__), - 'bootstrap' => ['log', 'authserver'], + 'bootstrap' => ['log', 'authserver', 'internal'], 'controllerNamespace' => 'api\controllers', 'params' => $params, 'components' => [ @@ -73,14 +73,6 @@ return [ 'response' => [ 'format' => yii\web\Response::FORMAT_JSON, ], - 'oauth' => [ - 'class' => api\components\OAuth2\Component::class, - 'grantTypes' => ['authorization_code'], - 'grantMap' => [ - 'authorization_code' => api\components\OAuth2\Grants\AuthCodeGrant::class, - 'refresh_token' => api\components\OAuth2\Grants\RefreshTokenGrant::class, - ], - ], 'errorHandler' => [ 'class' => api\components\ErrorHandler::class, ], @@ -96,5 +88,8 @@ return [ 'mojang' => [ 'class' => api\modules\mojang\Module::class, ], + 'internal' => [ + 'class' => api\modules\internal\Module::class, + ], ], ]; diff --git a/api/controllers/OauthController.php b/api/controllers/OauthController.php index 8df62a0..9428287 100644 --- a/api/controllers/OauthController.php +++ b/api/controllers/OauthController.php @@ -7,7 +7,9 @@ use api\components\OAuth2\Exception\AccessDeniedException; use common\models\Account; use common\models\OauthClient; use common\models\OauthScope; +use League\OAuth2\Server\AuthorizationServer; use League\OAuth2\Server\Exception\OAuthException; +use League\OAuth2\Server\Grant\AuthCodeGrant; use Yii; use yii\filters\AccessControl; use yii\helpers\ArrayHelper; @@ -274,17 +276,12 @@ class OauthController extends Controller { return $response; } - /** - * @return \League\OAuth2\Server\AuthorizationServer - */ - private function getServer() { + private function getServer(): AuthorizationServer { return Yii::$app->oauth->authServer; } - /** - * @return \League\OAuth2\Server\Grant\AuthCodeGrant - */ - private function getGrantType() { + private function getGrantType(): AuthCodeGrant { + /** @noinspection PhpIncompatibleReturnTypeInspection */ return $this->getServer()->getGrantType('authorization_code'); } diff --git a/api/modules/internal/Module.php b/api/modules/internal/Module.php new file mode 100644 index 0000000..5213e72 --- /dev/null +++ b/api/modules/internal/Module.php @@ -0,0 +1,19 @@ +getUrlManager()->addRules([ + '/internal///' => "{$this->id}//", + ], false); + } + +} diff --git a/api/modules/internal/controllers/AccountsController.php b/api/modules/internal/controllers/AccountsController.php new file mode 100644 index 0000000..34f4dbe --- /dev/null +++ b/api/modules/internal/controllers/AccountsController.php @@ -0,0 +1,88 @@ + [ + 'user' => Yii::$app->apiUser, + ], + 'access' => [ + 'class' => AccessControl::class, + 'rules' => [ + [ + 'actions' => ['ban'], + 'allow' => true, + 'roles' => [S::ACCOUNT_BLOCK], + ], + ], + ], + ]); + } + + public function verbs() { + return [ + 'ban' => ['POST', 'DELETE'], + ]; + } + + public function actionBan(int $accountId) { + $account = $this->findAccount($accountId); + if (Yii::$app->request->isPost) { + return $this->banAccount($account); + } else { + return $this->pardonAccount($account); + } + } + + private function banAccount(Account $account) { + $model = new BanForm($account); + $model->load(Yii::$app->request->post()); + if (!$model->ban()) { + return [ + 'success' => false, + 'errors' => $model->getFirstErrors(), + ]; + } + + return [ + 'success' => true, + ]; + } + + private function pardonAccount(Account $account) { + $model = new PardonForm($account); + $model->load(Yii::$app->request->post()); + if (!$model->pardon()) { + return [ + 'success' => false, + 'errors' => $model->getFirstErrors(), + ]; + } + + return [ + 'success' => true, + ]; + } + + private function findAccount(int $accountId): Account { + $account = Account::findOne($accountId); + if ($account === null) { + throw new NotFoundHttpException(); + } + + return $account; + } + +} diff --git a/api/modules/internal/helpers/Error.php b/api/modules/internal/helpers/Error.php new file mode 100644 index 0000000..7a7dec6 --- /dev/null +++ b/api/modules/internal/helpers/Error.php @@ -0,0 +1,9 @@ + self::DURATION_FOREVER], + [['message'], 'string'], + [['account'], 'validateAccountActivity'], + ]; + } + + public function getAccount(): Account { + return $this->account; + } + + public function validateAccountActivity() { + if ($this->account->status === Account::STATUS_BANNED) { + $this->addError('account', E::ACCOUNT_ALREADY_BANNED); + } + } + + public function ban(): bool { + if (!$this->validate()) { + return false; + } + + $transaction = Yii::$app->db->beginTransaction(); + + $account = $this->account; + $account->status = Account::STATUS_BANNED; + if (!$account->save()) { + throw new ErrorException('Cannot ban account'); + } + + $this->createTask(); + + $transaction->commit(); + + return true; + } + + public function createTask(): void { + $model = new AccountBanned(); + $model->accountId = $this->account->id; + $model->duration = $this->duration; + $model->message = $this->message; + + $message = Amqp::getInstance()->prepareMessage($model, [ + 'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT, + ]); + + Amqp::sendToEventsExchange('accounts.account-banned', $message); + } + + public function __construct(Account $account, array $config = []) { + $this->account = $account; + parent::__construct($config); + } + +} diff --git a/api/modules/internal/models/PardonForm.php b/api/modules/internal/models/PardonForm.php new file mode 100644 index 0000000..60c10e5 --- /dev/null +++ b/api/modules/internal/models/PardonForm.php @@ -0,0 +1,72 @@ +account; + } + + public function validateAccountBanned(): void { + if ($this->account->status !== Account::STATUS_BANNED) { + $this->addError('account', E::ACCOUNT_NOT_BANNED); + } + } + + public function pardon(): bool { + if (!$this->validate()) { + return false; + } + + $transaction = Yii::$app->db->beginTransaction(); + + $account = $this->account; + $account->status = Account::STATUS_ACTIVE; + if (!$account->save()) { + throw new ErrorException('Cannot pardon account'); + } + + $this->createTask(); + + $transaction->commit(); + + return true; + } + + public function createTask(): void { + $model = new AccountPardoned(); + $model->accountId = $this->account->id; + + $message = Amqp::getInstance()->prepareMessage($model, [ + 'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT, + ]); + + Amqp::sendToEventsExchange('accounts.account-pardoned', $message); + } + + public function __construct(Account $account, array $config = []) { + $this->account = $account; + parent::__construct($config); + } + +} diff --git a/autocompletion.php b/autocompletion.php index f91608c..d01c0d8 100644 --- a/autocompletion.php +++ b/autocompletion.php @@ -16,12 +16,13 @@ class Yii extends \yii\BaseYii { * Class BaseApplication * Used for properties that are identical for both WebApplication and ConsoleApplication * - * @property \yii\swiftmailer\Mailer $mailer - * @property \common\components\Redis\Connection $redis + * @property \yii\swiftmailer\Mailer $mailer + * @property \common\components\Redis\Connection $redis * @property \common\components\RabbitMQ\Component $amqp - * @property \GuzzleHttp\Client $guzzle - * @property \common\components\EmailRenderer $emailRenderer - * @property \mito\sentry\Component $sentry + * @property \GuzzleHttp\Client $guzzle + * @property \common\components\EmailRenderer $emailRenderer + * @property \mito\sentry\Component $sentry + * @property \api\components\OAuth2\Component $oauth */ abstract class BaseApplication extends yii\base\Application { } @@ -33,7 +34,6 @@ abstract class BaseApplication extends yii\base\Application { * @property \api\components\User\Component $user User component. * @property \api\components\ApiUser\Component $apiUser Api User component. * @property \api\components\ReCaptcha\Component $reCaptcha - * @property \api\components\OAuth2\Component $oauth * * @method \api\components\User\Component getUser() */ diff --git a/common/components/Annotations/Reader.php b/common/components/Annotations/Reader.php new file mode 100644 index 0000000..3397fa4 --- /dev/null +++ b/common/components/Annotations/Reader.php @@ -0,0 +1,18 @@ +cache и как-то надобность в отдельном кэше отпала, так что пока забьём + * и оставим как заготовку на будущее + * + * @return \Minime\Annotations\Interfaces\ReaderInterface + */ + public static function createFromDefaults() { + return parent::createFromDefaults(); + //return new self(new \Minime\Annotations\Parser(), new RedisCache()); + } + +} diff --git a/common/components/Annotations/RedisCache.php b/common/components/Annotations/RedisCache.php new file mode 100644 index 0000000..4556347 --- /dev/null +++ b/common/components/Annotations/RedisCache.php @@ -0,0 +1,65 @@ +getRedisKey($key)->setValue(Json::encode($annotations))->expire(3600); + $this->getRedisKeysSet()->add($key); + } + + /** + * Retrieves cached annotations from docblock uuid + * + * @param string $key cache entry uuid + * @return array cached annotation AST + */ + public function get($key) { + $result = $this->getRedisKey($key)->getValue(); + if ($result === null) { + return []; + } + + return Json::decode($result); + } + + /** + * Resets cache + */ + public function clear() { + /** @var array $keys */ + $keys = $this->getRedisKeysSet()->getValue(); + foreach ($keys as $key) { + $this->getRedisKey($key)->delete(); + } + } + + private function getRedisKey(string $key): Key { + return new Key('annotations', 'cache', $key); + } + + private function getRedisKeysSet(): Set { + return new Set('annotations', 'cache', 'keys'); + } + +} diff --git a/common/config/config.php b/common/config/config.php index 39ad823..10eb75a 100644 --- a/common/config/config.php +++ b/common/config/config.php @@ -69,6 +69,15 @@ return [ 'class' => common\components\EmailRenderer::class, 'basePath' => '/images/emails', ], + 'oauth' => [ + 'class' => api\components\OAuth2\Component::class, + 'grantTypes' => ['authorization_code', 'client_credentials'], + 'grantMap' => [ + 'authorization_code' => api\components\OAuth2\Grants\AuthCodeGrant::class, + 'refresh_token' => api\components\OAuth2\Grants\RefreshTokenGrant::class, + 'client_credentials' => api\components\OAuth2\Grants\ClientCredentialsGrant::class, + ], + ], ], 'aliases' => [ '@bower' => '@vendor/bower-asset', diff --git a/common/models/Account.php b/common/models/Account.php index 2bc1e93..ba346da 100644 --- a/common/models/Account.php +++ b/common/models/Account.php @@ -29,10 +29,11 @@ use const common\LATEST_RULES_VERSION; * @property string $profileLink ссылка на профиль на Ely без поддержки static url (только для записи) * * Отношения: - * @property EmailActivation[] $emailActivations - * @property OauthSession[] $oauthSessions - * @property UsernameHistory[] $usernameHistory - * @property AccountSession[] $sessions + * @property EmailActivation[] $emailActivations + * @property OauthSession[] $oauthSessions + * @property UsernameHistory[] $usernameHistory + * @property AccountSession[] $sessions + * @property MinecraftAccessKey[] $minecraftAccessKeys * * Поведения: * @mixin TimestampBehavior @@ -99,7 +100,7 @@ class Account extends ActiveRecord { } public function getOauthSessions() { - return $this->hasMany(OauthSession::class, ['owner_id' => 'id']); + return $this->hasMany(OauthSession::class, ['owner_id' => 'id'])->andWhere(['owner_type' => 'user']); } public function getUsernameHistory() { @@ -110,6 +111,10 @@ class Account extends ActiveRecord { return $this->hasMany(AccountSession::class, ['account_id' => 'id']); } + public function getMinecraftAccessKeys() { + return $this->hasMany(MinecraftAccessKey::class, ['account_id' => 'id']); + } + /** * Выполняет проверку, принадлежит ли этому нику аккаунт у Mojang * diff --git a/common/models/OauthScope.php b/common/models/OauthScope.php index 9fbf035..bb4da54 100644 --- a/common/models/OauthScope.php +++ b/common/models/OauthScope.php @@ -1,20 +1,62 @@ getConstants(); + $reader = Reader::createFromDefaults(); + foreach ($constants as $constName => $value) { + $annotations = $reader->getConstantAnnotations(static::class, $constName); + $isInternal = $annotations->get('internal', false); + $owner = $annotations->get('owner', 'user'); + $keyValue = [ + 'value' => $value, + 'internal' => $isInternal, + 'owner' => $owner, + ]; + $scopes[$constName] = $keyValue; + } + + Yii::$app->cache->set($cacheKey, $scopes, 3600); + } + + return $scopes; } } diff --git a/common/models/OauthScopeQuery.php b/common/models/OauthScopeQuery.php new file mode 100644 index 0000000..27577c5 --- /dev/null +++ b/common/models/OauthScopeQuery.php @@ -0,0 +1,50 @@ +internal = false; + return $this; + } + + public function onlyInternal(): self { + $this->internal = true; + return $this; + } + + public function usersScopes(): self { + $this->owner = 'user'; + return $this; + } + + public function machineScopes(): self { + $this->owner = 'machine'; + return $this; + } + + public function all(): array { + return ArrayHelper::getColumn(array_filter($this->scopes, function($value) { + $shouldCheckInternal = $this->internal !== null; + $isInternalMatch = $value['internal'] === $this->internal; + $shouldCheckOwner = $this->owner !== null; + $isOwnerMatch = $value['owner'] === $this->owner; + + return (!$shouldCheckInternal || $isInternalMatch) + && (!$shouldCheckOwner || $isOwnerMatch); + }), 'value'); + } + + public function __construct(array $scopes) { + $this->scopes = $scopes; + } + +} diff --git a/common/models/amqp/AccountBanned.php b/common/models/amqp/AccountBanned.php new file mode 100644 index 0000000..1b1198e --- /dev/null +++ b/common/models/amqp/AccountBanned.php @@ -0,0 +1,14 @@ +exchange->topic()->durable(); $configurator->queue->name('accounts-accounts-events')->durable(); - $configurator->bind->routingKey('accounts.username-changed'); + $configurator->bind->routingKey('accounts.username-changed') + ->add()->routingKey('account.account-banned'); } public function getRoutesMap() { return [ 'accounts.username-changed' => 'routeUsernameChanged', + 'accounts.account-banned' => 'routeAccountBanned', ]; } - public function routeUsernameChanged(UsernameChanged $body) { + public function routeUsernameChanged(UsernameChanged $body): bool { $mojangApi = $this->createMojangApi(); try { $response = $mojangApi->usernameToUUID($body->newUsername); @@ -58,10 +63,32 @@ class AccountQueueController extends AmqpController { return true; } + public function routeAccountBanned(AccountBanned $body): bool { + $account = Account::findOne($body->accountId); + if ($account === null) { + Yii::warning('Cannot find banned account ' . $body->accountId . '. Skipping.'); + return true; + } + + foreach ($account->sessions as $authSession) { + $authSession->delete(); + } + + foreach ($account->minecraftAccessKeys as $key) { + $key->delete(); + } + + foreach ($account->oauthSessions as $oauthSession) { + $oauthSession->delete(); + } + + return true; + } + /** * @return MojangApi */ - protected function createMojangApi() : MojangApi { + protected function createMojangApi(): MojangApi { return new MojangApi(); } diff --git a/console/migrations/m161228_101022_oauth_clients_allow_null_redirect_uri.php b/console/migrations/m161228_101022_oauth_clients_allow_null_redirect_uri.php new file mode 100644 index 0000000..1734f95 --- /dev/null +++ b/console/migrations/m161228_101022_oauth_clients_allow_null_redirect_uri.php @@ -0,0 +1,15 @@ +alterColumn('{{%oauth_clients}}', 'redirect_uri', $this->string()); + } + + public function safeDown() { + $this->alterColumn('{{%oauth_clients}}', 'redirect_uri', $this->string()->notNull()); + } + +} diff --git a/tests/codeception/api/_pages/InternalRoute.php b/tests/codeception/api/_pages/InternalRoute.php new file mode 100644 index 0000000..ad4fbe2 --- /dev/null +++ b/tests/codeception/api/_pages/InternalRoute.php @@ -0,0 +1,21 @@ +route = '/internal/accounts/' . $accountId . '/ban'; + $this->actor->sendPOST($this->getUrl()); + } + + public function pardon($accountId) { + $this->route = '/internal/accounts/' . $accountId . '/ban'; + $this->actor->sendDELETE($this->getUrl()); + } + +} diff --git a/tests/codeception/api/functional/OauthAuthCodeCest.php b/tests/codeception/api/functional/OauthAuthCodeCest.php index 2ed740f..be61f3e 100644 --- a/tests/codeception/api/functional/OauthAuthCodeCest.php +++ b/tests/codeception/api/functional/OauthAuthCodeCest.php @@ -281,6 +281,21 @@ class OauthAuthCodeCest { 'statusCode' => 400, ]); $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); + + $I->wantTo('check behavior on request internal scope'); + $this->route->$action($this->buildQueryParams('ely', 'http://ely.by', 'code', [ + S::MINECRAFT_SERVER_SESSION, + S::ACCOUNT_BLOCK, + ])); + $I->canSeeResponseCodeIs(400); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'error' => 'invalid_scope', + 'parameter' => S::ACCOUNT_BLOCK, + 'statusCode' => 400, + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); } } diff --git a/tests/codeception/api/functional/OauthClientCredentialsGrantCest.php b/tests/codeception/api/functional/OauthClientCredentialsGrantCest.php new file mode 100644 index 0000000..e27ccbb --- /dev/null +++ b/tests/codeception/api/functional/OauthClientCredentialsGrantCest.php @@ -0,0 +1,120 @@ +route = new OauthRoute($I); + } + + public function testIssueTokenWithWrongArgs(FunctionalTester $I) { + $I->wantTo('check behavior on on request without any credentials'); + $this->route->issueToken($this->buildParams()); + $I->canSeeResponseCodeIs(400); + $I->canSeeResponseContainsJson([ + 'error' => 'invalid_request', + ]); + + $I->wantTo('check behavior on passing invalid client_id'); + $this->route->issueToken($this->buildParams( + 'invalid-client', + 'invalid-secret', + ['invalid-scope'] + )); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'error' => 'invalid_client', + ]); + + $I->wantTo('check behavior on passing invalid client_secret'); + $this->route->issueToken($this->buildParams( + 'ely', + 'invalid-secret', + ['invalid-scope'] + )); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'error' => 'invalid_client', + ]); + + $I->wantTo('check behavior on passing invalid client_secret'); + $this->route->issueToken($this->buildParams( + 'ely', + 'invalid-secret', + ['invalid-scope'] + )); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'error' => 'invalid_client', + ]); + } + + public function testIssueTokenWithPublicScopes(OauthSteps $I) { + // TODO: у нас пока нет публичных скоупов, поэтому тест прогоняется с пустым набором + $this->route->issueToken($this->buildParams( + 'ely', + 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + [] + )); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'token_type' => 'Bearer', + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.access_token'); + $I->canSeeResponseJsonMatchesJsonPath('$.expires_in'); + } + + public function testIssueTokenWithInternalScopes(OauthSteps $I) { + $this->route->issueToken($this->buildParams( + 'ely', + 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + [S::ACCOUNT_BLOCK] + )); + $I->canSeeResponseCodeIs(400); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'error' => 'invalid_scope', + ]); + + $this->route->issueToken($this->buildParams( + 'trusted-client', + 'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9', + [S::ACCOUNT_BLOCK] + )); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'token_type' => 'Bearer', + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.access_token'); + $I->canSeeResponseJsonMatchesJsonPath('$.expires_in'); + } + + private function buildParams($clientId = null, $clientSecret = null, array $scopes = null) { + $params = ['grant_type' => 'client_credentials']; + if ($clientId !== null) { + $params['client_id'] = $clientId; + } + + if ($clientSecret !== null) { + $params['client_secret'] = $clientSecret; + } + + if ($scopes !== null) { + $params['scope'] = implode(',', $scopes); + } + + return $params; + } + +} diff --git a/tests/codeception/api/functional/_steps/OauthSteps.php b/tests/codeception/api/functional/_steps/OauthSteps.php index c16aaf6..3e0c467 100644 --- a/tests/codeception/api/functional/_steps/OauthSteps.php +++ b/tests/codeception/api/functional/_steps/OauthSteps.php @@ -3,11 +3,12 @@ namespace tests\codeception\api\functional\_steps; use common\models\OauthScope as S; use tests\codeception\api\_pages\OauthRoute; +use tests\codeception\api\FunctionalTester; -class OauthSteps extends \tests\codeception\api\FunctionalTester { +class OauthSteps extends FunctionalTester { public function getAuthCode(array $permissions = []) { - // TODO: по идее можно напрямую сделать зпись в базу, что ускорит процесс тестирования + // TODO: по идее можно напрямую сделать запись в базу, что ускорит процесс тестирования $this->loggedInAsActiveAccount(); $route = new OauthRoute($this); $route->complete([ @@ -31,7 +32,7 @@ class OauthSteps extends \tests\codeception\api\FunctionalTester { } public function getRefreshToken(array $permissions = []) { - // TODO: по идее можно напрямую сделать зпись в базу, что ускорит процесс тестирования + // TODO: по идее можно напрямую сделать запись в базу, что ускорит процесс тестирования $authCode = $this->getAuthCode(array_merge([S::OFFLINE_ACCESS], $permissions)); $response = $this->issueToken($authCode); @@ -51,4 +52,18 @@ class OauthSteps extends \tests\codeception\api\FunctionalTester { return json_decode($this->grabResponse(), true); } + public function getAccessTokenByClientCredentialsGrant(array $permissions = [], $useTrusted = true) { + $route = new OauthRoute($this); + $route->issueToken([ + 'client_id' => $useTrusted ? 'trusted-client' : 'default-client', + 'client_secret' => $useTrusted ? 'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9' : 'AzWRy7ZjS1yRQUk2vRBDic8fprOKDB1W', + 'grant_type' => 'client_credentials', + 'scope' => implode(',', $permissions), + ]); + + $response = json_decode($this->grabResponse(), true); + + return $response['access_token']; + } + } diff --git a/tests/codeception/api/functional/internal/BanCest.php b/tests/codeception/api/functional/internal/BanCest.php new file mode 100644 index 0000000..9f46806 --- /dev/null +++ b/tests/codeception/api/functional/internal/BanCest.php @@ -0,0 +1,47 @@ +route = new InternalRoute($I); + } + + public function testBanAccount(OauthSteps $I) { + $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::ACCOUNT_BLOCK]); + $I->amBearerAuthenticated($accessToken); + + $this->route->ban(1); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + } + + public function testBanBannedAccount(OauthSteps $I) { + $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::ACCOUNT_BLOCK]); + $I->amBearerAuthenticated($accessToken); + + $this->route->ban(10); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'account' => 'error.account_already_banned', + ], + ]); + } + +} diff --git a/tests/codeception/api/functional/internal/PardonCest.php b/tests/codeception/api/functional/internal/PardonCest.php new file mode 100644 index 0000000..c7aea10 --- /dev/null +++ b/tests/codeception/api/functional/internal/PardonCest.php @@ -0,0 +1,47 @@ +route = new InternalRoute($I); + } + + public function testPardonAccount(OauthSteps $I) { + $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::ACCOUNT_BLOCK]); + $I->amBearerAuthenticated($accessToken); + + $this->route->pardon(10); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + } + + public function testPardonNotBannedAccount(OauthSteps $I) { + $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::ACCOUNT_BLOCK]); + $I->amBearerAuthenticated($accessToken); + + $this->route->pardon(1); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'account' => 'error.account_not_banned', + ], + ]); + } + +} diff --git a/tests/codeception/api/unit/modules/internal/models/BanFormTest.php b/tests/codeception/api/unit/modules/internal/models/BanFormTest.php new file mode 100644 index 0000000..b63c15a --- /dev/null +++ b/tests/codeception/api/unit/modules/internal/models/BanFormTest.php @@ -0,0 +1,62 @@ +status = Account::STATUS_ACTIVE; + $form = new BanForm($account); + $form->validateAccountActivity(); + $this->assertEmpty($form->getErrors('account')); + + $account = new Account(); + $account->status = Account::STATUS_BANNED; + $form = new BanForm($account); + $form->validateAccountActivity(); + $this->assertEquals([E::ACCOUNT_ALREADY_BANNED], $form->getErrors('account')); + } + + public function testBan() { + /** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */ + $account = $this->getMockBuilder(Account::class) + ->setMethods(['save']) + ->getMock(); + + $account->expects($this->once()) + ->method('save') + ->willReturn(true); + + $model = new BanForm($account); + $this->assertTrue($model->ban()); + $this->assertEquals(Account::STATUS_BANNED, $account->status); + $this->tester->canSeeAmqpMessageIsCreated('events'); + } + + public function testCreateTask() { + $account = new Account(); + $account->id = 3; + + $model = new BanForm($account); + $model->createTask(); + $message = json_decode($this->tester->grabLastSentAmqpMessage('events')->body, true); + $this->assertSame(3, $message['accountId']); + $this->assertSame(-1, $message['duration']); + $this->assertSame('', $message['message']); + + $model = new BanForm($account); + $model->duration = 123; + $model->message = 'test'; + $model->createTask(); + $message = json_decode($this->tester->grabLastSentAmqpMessage('events')->body, true); + $this->assertSame(3, $message['accountId']); + $this->assertSame(123, $message['duration']); + $this->assertSame('test', $message['message']); + } + +} diff --git a/tests/codeception/api/unit/modules/internal/models/PardonFormTest.php b/tests/codeception/api/unit/modules/internal/models/PardonFormTest.php new file mode 100644 index 0000000..c05d9a0 --- /dev/null +++ b/tests/codeception/api/unit/modules/internal/models/PardonFormTest.php @@ -0,0 +1,52 @@ +status = Account::STATUS_BANNED; + $form = new PardonForm($account); + $form->validateAccountBanned(); + $this->assertEmpty($form->getErrors('account')); + + $account = new Account(); + $account->status = Account::STATUS_ACTIVE; + $form = new PardonForm($account); + $form->validateAccountBanned(); + $this->assertEquals([E::ACCOUNT_NOT_BANNED], $form->getErrors('account')); + } + + public function testPardon() { + /** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */ + $account = $this->getMockBuilder(Account::class) + ->setMethods(['save']) + ->getMock(); + + $account->expects($this->once()) + ->method('save') + ->willReturn(true); + + $account->status = Account::STATUS_BANNED; + $model = new PardonForm($account); + $this->assertTrue($model->pardon()); + $this->assertEquals(Account::STATUS_ACTIVE, $account->status); + $this->tester->canSeeAmqpMessageIsCreated('events'); + } + + public function testCreateTask() { + $account = new Account(); + $account->id = 3; + + $model = new PardonForm($account); + $model->createTask(); + $message = json_decode($this->tester->grabLastSentAmqpMessage('events')->body, true); + $this->assertSame(3, $message['accountId']); + } + +} diff --git a/tests/codeception/common/fixtures/data/account-sessions.php b/tests/codeception/common/fixtures/data/account-sessions.php index fb9581b..1a68919 100644 --- a/tests/codeception/common/fixtures/data/account-sessions.php +++ b/tests/codeception/common/fixtures/data/account-sessions.php @@ -16,4 +16,12 @@ return [ 'created_at' => time(), 'last_refreshed_at' => time(), ], + 'banned-user-session' => [ + 'id' => 3, + 'account_id' => 10, + 'refresh_token' => 'Af7fIuV6eL61tRUHn40yhmDRXN1OQxKR', + 'last_used_ip' => ip2long('182.123.234.123'), + 'created_at' => time(), + 'last_refreshed_at' => time(), + ], ]; diff --git a/tests/codeception/common/fixtures/data/oauth-clients.php b/tests/codeception/common/fixtures/data/oauth-clients.php index e7ddba5..e8c2dfc 100644 --- a/tests/codeception/common/fixtures/data/oauth-clients.php +++ b/tests/codeception/common/fixtures/data/oauth-clients.php @@ -30,4 +30,24 @@ return [ 'is_trusted' => 0, 'created_at' => 1479937982, ], + 'trustedClient' => [ + 'id' => 'trusted-client', + 'secret' => 'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9', + 'name' => 'Trusted client', + 'description' => 'Это клиент, которому мы доверяем', + 'redirect_uri' => null, + 'account_id' => null, + 'is_trusted' => 1, + 'created_at' => 1482922663, + ], + 'defaultClient' => [ + 'id' => 'default-client', + 'secret' => 'AzWRy7ZjS1yRQUk2vRBDic8fprOKDB1W', + 'name' => 'Default client', + 'description' => 'Это обычный клиент, каких может быть много', + 'redirect_uri' => null, + 'account_id' => null, + 'is_trusted' => 0, + 'created_at' => 1482922711, + ], ]; diff --git a/tests/codeception/common/fixtures/data/oauth-sessions.php b/tests/codeception/common/fixtures/data/oauth-sessions.php index ebbc2d2..69e0536 100644 --- a/tests/codeception/common/fixtures/data/oauth-sessions.php +++ b/tests/codeception/common/fixtures/data/oauth-sessions.php @@ -7,4 +7,11 @@ return [ 'client_id' => 'test1', 'client_redirect_uri' => 'http://test1.net/oauth', ], + 'banned-account-session' => [ + 'id' => 2, + 'owner_type' => 'user', + 'owner_id' => 10, + 'client_id' => 'test1', + 'client_redirect_uri' => 'http://test1.net/oauth', + ], ]; diff --git a/tests/codeception/console/unit/controllers/AccountQueueControllerTest.php b/tests/codeception/console/unit/controllers/AccountQueueControllerTest.php index 4aaa57f..4c9e236 100644 --- a/tests/codeception/console/unit/controllers/AccountQueueControllerTest.php +++ b/tests/codeception/console/unit/controllers/AccountQueueControllerTest.php @@ -4,6 +4,7 @@ namespace codeception\console\unit\controllers; use common\components\Mojang\Api; use common\components\Mojang\exceptions\NoContentException; use common\components\Mojang\response\UsernameToUUIDResponse; +use common\models\amqp\AccountBanned; use common\models\amqp\UsernameChanged; use common\models\MojangUsername; use console\controllers\AccountQueueController; @@ -143,4 +144,22 @@ class AccountQueueControllerTest extends TestCase { $this->assertNotEquals($mojangInfo->uuid, $mojangUsername->uuid); } + public function testRouteAccountBanned() { + /** @var \common\models\Account $bannedAccount */ + $bannedAccount = $this->tester->grabFixture('accounts', 'banned-account'); + $this->tester->haveFixtures([ + 'oauthSessions' => \tests\codeception\common\fixtures\OauthSessionFixture::class, + 'minecraftAccessKeys' => \tests\codeception\common\fixtures\MinecraftAccessKeyFixture::class, + 'authSessions' => \tests\codeception\common\fixtures\AccountSessionFixture::class, + ]); + + $body = new AccountBanned(); + $body->accountId = $bannedAccount->id; + + $this->controller->routeAccountBanned($body); + $this->assertEmpty($bannedAccount->sessions); + $this->assertEmpty($bannedAccount->minecraftAccessKeys); + $this->assertEmpty($bannedAccount->oauthSessions); + } + }