Merge branch 'ban_user_api' into develop

This commit is contained in:
ErickSkrauch 2017-01-05 02:05:58 +03:00
commit 83f32a0f11
37 changed files with 1071 additions and 52 deletions

View File

@ -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 {

View File

@ -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();
}

View File

@ -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;
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace api\components\OAuth2\Grants;
use api\components\OAuth2\Entities;
class ClientCredentialsGrant extends \League\OAuth2\Server\Grant\ClientCredentialsGrant {
protected function createAccessTokenEntity() {
return new Entities\AccessTokenEntity($this->server);
}
protected function createRefreshTokenEntity() {
return new Entities\RefreshTokenEntity($this->server);
}
protected function createSessionEntity() {
return new Entities\SessionEntity($this->server);
}
}

View File

@ -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;

View File

@ -1,10 +1,12 @@
<?php
namespace api\components\OAuth2\Storage;
use api\components\OAuth2\Entities\ClientEntity;
use api\components\OAuth2\Entities\ScopeEntity;
use common\models\OauthScope;
use League\OAuth2\Server\Storage\AbstractStorage;
use League\OAuth2\Server\Storage\ScopeInterface;
use yii\base\ErrorException;
class ScopeStorage extends AbstractStorage implements ScopeInterface {
@ -12,7 +14,28 @@ class ScopeStorage extends AbstractStorage implements ScopeInterface {
* @inheritdoc
*/
public function get($scope, $grantType = null, $clientId = null) {
if (!in_array($scope, OauthScope::getScopes(), true)) {
$query = OauthScope::find();
if ($grantType === 'authorization_code') {
$query->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;
}

View File

@ -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,
],
],
];

View File

@ -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');
}

View File

@ -0,0 +1,19 @@
<?php
namespace api\modules\internal;
use yii\base\BootstrapInterface;
class Module extends \yii\base\Module implements BootstrapInterface {
public $id = 'internal';
/**
* @param \yii\base\Application $app the application currently running
*/
public function bootstrap($app) {
$app->getUrlManager()->addRules([
'/internal/<controller>/<accountId>/<action>' => "{$this->id}/<controller>/<action>",
], false);
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace api\modules\internal\controllers;
use api\components\ApiUser\AccessControl;
use api\controllers\Controller;
use api\modules\internal\models\BanForm;
use api\modules\internal\models\PardonForm;
use common\models\Account;
use common\models\OauthScope as S;
use Yii;
use yii\helpers\ArrayHelper;
use yii\web\NotFoundHttpException;
class AccountsController extends Controller {
public function behaviors() {
return ArrayHelper::merge(parent::behaviors(), [
'authenticator' => [
'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;
}
}

View File

@ -0,0 +1,9 @@
<?php
namespace api\modules\internal\helpers;
final class Error {
public const ACCOUNT_ALREADY_BANNED = 'error.account_already_banned';
public const ACCOUNT_NOT_BANNED = 'error.account_not_banned';
}

View File

@ -0,0 +1,95 @@
<?php
namespace api\modules\internal\models;
use api\models\base\ApiForm;
use api\modules\internal\helpers\Error as E;
use common\helpers\Amqp;
use common\models\Account;
use common\models\amqp\AccountBanned;
use PhpAmqpLib\Message\AMQPMessage;
use Yii;
use yii\base\ErrorException;
class BanForm extends ApiForm {
public const DURATION_FOREVER = -1;
/**
* Нереализованный функционал блокировки аккаунта на определённый период времени.
* Сейчас установка этого параметра ничего не даст, аккаунт будет заблокирован навечно,
* но, по задумке, здесь можно передать количество секунд, на которое будет
* заблокирован аккаунт пользователя.
*
* @var int
*/
public $duration = self::DURATION_FOREVER;
/**
* Нереализованный функционал указания причины блокировки аккаунта.
*
* @var string
*/
public $message = '';
/**
* @var Account
*/
private $account;
public function rules(): array {
return [
[['duration'], 'integer', 'min' => 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);
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace api\modules\internal\models;
use api\models\base\ApiForm;
use api\modules\internal\helpers\Error as E;
use common\helpers\Amqp;
use common\models\Account;
use common\models\amqp\AccountPardoned;
use PhpAmqpLib\Message\AMQPMessage;
use Yii;
use yii\base\ErrorException;
class PardonForm extends ApiForm {
/**
* @var Account
*/
private $account;
public function rules(): array {
return [
[['account'], 'validateAccountBanned'],
];
}
public function getAccount(): Account {
return $this->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);
}
}

View File

@ -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()
*/

View File

@ -0,0 +1,18 @@
<?php
namespace common\components\Annotations;
class Reader extends \Minime\Annotations\Reader {
/**
* Поначаду я думал кэшировать эту штуку, но потом забил, т.к. всё всё равно завернул
* в Yii::$app->cache и как-то надобность в отдельном кэше отпала, так что пока забьём
* и оставим как заготовку на будущее
*
* @return \Minime\Annotations\Interfaces\ReaderInterface
*/
public static function createFromDefaults() {
return parent::createFromDefaults();
//return new self(new \Minime\Annotations\Parser(), new RedisCache());
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace common\components\Annotations;
use common\components\Redis\Key;
use common\components\Redis\Set;
use Minime\Annotations\Interfaces\CacheInterface;
use yii\helpers\Json;
class RedisCache implements CacheInterface {
/**
* Generates uuid for a given docblock string
* @param string $docblock docblock string
* @return string uuid that maps to the given docblock
*/
public function getKey($docblock) {
return md5($docblock);
}
/**
* Adds an annotation AST to cache
*
* @param string $key cache entry uuid
* @param array $annotations annotation AST
*/
public function set($key, array $annotations) {
$this->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');
}
}

View File

@ -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',

View File

@ -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
*

View File

@ -1,20 +1,62 @@
<?php
namespace common\models;
use common\components\Annotations\Reader;
use ReflectionClass;
use Yii;
class OauthScope {
/**
* @owner user
*/
const OFFLINE_ACCESS = 'offline_access';
/**
* @owner user
*/
const MINECRAFT_SERVER_SESSION = 'minecraft_server_session';
/**
* @owner user
*/
const ACCOUNT_INFO = 'account_info';
/**
* @owner user
*/
const ACCOUNT_EMAIL = 'account_email';
/**
* @internal
* @owner machine
*/
const ACCOUNT_BLOCK = 'account_block';
public static function getScopes() : array {
return [
self::OFFLINE_ACCESS,
self::MINECRAFT_SERVER_SESSION,
self::ACCOUNT_INFO,
self::ACCOUNT_EMAIL,
];
public static function find(): OauthScopeQuery {
return new OauthScopeQuery(static::queryScopes());
}
private static function queryScopes(): array {
$cacheKey = 'oauth-scopes-list';
$scopes = false;
if ($scopes === false) {
$scopes = [];
$reflection = new ReflectionClass(static::class);
$constants = $reflection->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;
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace common\models;
use yii\helpers\ArrayHelper;
class OauthScopeQuery {
private $scopes;
private $internal;
private $owner;
public function onlyPublic(): self {
$this->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;
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace common\models\amqp;
use yii\base\Object;
class AccountBanned extends Object {
public $accountId;
public $duration = -1;
public $message = '';
}

View File

@ -0,0 +1,10 @@
<?php
namespace common\models\amqp;
use yii\base\Object;
class AccountPardoned extends Object {
public $accountId;
}

View File

@ -18,7 +18,7 @@
"yiisoft/yii2": "2.0.10",
"yiisoft/yii2-swiftmailer": "*",
"ramsey/uuid": "^3.5.0",
"league/oauth2-server": "dev-improvements#b9277ccd664dcb80a766b73674d21de686cb9dda",
"league/oauth2-server": "dev-improvements#fbaa9b0bd3d8050235ba7dde90f731764122bc20",
"yiisoft/yii2-redis": "~2.0.0",
"guzzlehttp/guzzle": "^6.0.0",
"php-amqplib/php-amqplib": "^2.6.2",
@ -27,7 +27,8 @@
"ely/amqp-controller": "dev-master#d7f8cdbc66c45e477c9c7d5d509bc0c1b11fd3ec",
"ely/email-renderer": "dev-master#ef1cb3f7a13196524b97ca5aa0a2d5867f2d9207",
"predis/predis": "^1.0",
"mito/yii2-sentry": "dev-fix_init#27f00805cb906f73b2c6f8181c1c655decb9be70"
"mito/yii2-sentry": "dev-fix_init#27f00805cb906f73b2c6f8181c1c655decb9be70",
"minime/annotations": "~3.0"
},
"require-dev": {
"yiisoft/yii2-codeception": "*",

View File

@ -3,10 +3,13 @@ namespace console\controllers;
use common\components\Mojang\Api as MojangApi;
use common\components\Mojang\exceptions\NoContentException;
use common\models\Account;
use common\models\amqp\AccountBanned;
use common\models\amqp\UsernameChanged;
use common\models\MojangUsername;
use Ely\Amqp\Builder\Configurator;
use GuzzleHttp\Exception\RequestException;
use Yii;
class AccountQueueController extends AmqpController {
@ -17,16 +20,18 @@ class AccountQueueController extends AmqpController {
public function configure(Configurator $configurator) {
$configurator->exchange->topic()->durable();
$configurator->queue->name('accounts-accounts-events')->durable();
$configurator->bind->routingKey('accounts.username-changed');
$configurator->bind->routingKey('accounts.username-changed')
->add()->routingKey('account.account-banned');
}
public function getRoutesMap() {
return [
'accounts.username-changed' => 'routeUsernameChanged',
'accounts.account-banned' => 'routeAccountBanned',
];
}
public function routeUsernameChanged(UsernameChanged $body) {
public function routeUsernameChanged(UsernameChanged $body): bool {
$mojangApi = $this->createMojangApi();
try {
$response = $mojangApi->usernameToUUID($body->newUsername);
@ -58,10 +63,32 @@ class AccountQueueController extends AmqpController {
return true;
}
public function routeAccountBanned(AccountBanned $body): bool {
$account = Account::findOne($body->accountId);
if ($account === null) {
Yii::warning('Cannot find banned account ' . $body->accountId . '. Skipping.');
return true;
}
foreach ($account->sessions as $authSession) {
$authSession->delete();
}
foreach ($account->minecraftAccessKeys as $key) {
$key->delete();
}
foreach ($account->oauthSessions as $oauthSession) {
$oauthSession->delete();
}
return true;
}
/**
* @return MojangApi
*/
protected function createMojangApi() : MojangApi {
protected function createMojangApi(): MojangApi {
return new MojangApi();
}

View File

@ -0,0 +1,15 @@
<?php
use console\db\Migration;
class m161228_101022_oauth_clients_allow_null_redirect_uri extends Migration {
public function safeUp() {
$this->alterColumn('{{%oauth_clients}}', 'redirect_uri', $this->string());
}
public function safeDown() {
$this->alterColumn('{{%oauth_clients}}', 'redirect_uri', $this->string()->notNull());
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace tests\codeception\api\_pages;
use yii\codeception\BasePage;
/**
* @property \tests\codeception\api\FunctionalTester $actor
*/
class InternalRoute extends BasePage {
public function ban($accountId) {
$this->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());
}
}

View File

@ -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');
}
}

View File

@ -0,0 +1,120 @@
<?php
namespace tests\codeception\api;
use common\models\OauthScope as S;
use tests\codeception\api\_pages\OauthRoute;
use tests\codeception\api\functional\_steps\OauthSteps;
class OauthClientCredentialsGrantCest {
/**
* @var OauthRoute
*/
private $route;
public function _before(FunctionalTester $I) {
$this->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;
}
}

View File

@ -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'];
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace tests\codeception\api\functional\internal;
use common\models\OauthScope as S;
use tests\codeception\api\_pages\InternalRoute;
use tests\codeception\api\functional\_steps\OauthSteps;
use tests\codeception\api\FunctionalTester;
class BanCest {
/**
* @var InternalRoute
*/
private $route;
public function _before(FunctionalTester $I) {
$this->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',
],
]);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace tests\codeception\api\functional\internal;
use common\models\OauthScope as S;
use tests\codeception\api\_pages\InternalRoute;
use tests\codeception\api\functional\_steps\OauthSteps;
use tests\codeception\api\FunctionalTester;
class PardonCest {
/**
* @var InternalRoute
*/
private $route;
public function _before(FunctionalTester $I) {
$this->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',
],
]);
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace tests\codeception\api\unit\modules\internal\models;
use api\modules\internal\helpers\Error as E;
use api\modules\internal\models\BanForm;
use common\models\Account;
use tests\codeception\api\unit\TestCase;
class BanFormTest extends TestCase {
public function testValidateAccountActivity() {
$account = new Account();
$account->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']);
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace tests\codeception\api\unit\modules\internal\models;
use api\modules\internal\helpers\Error as E;
use api\modules\internal\models\PardonForm;
use common\models\Account;
use tests\codeception\api\unit\TestCase;
class PardonFormTest extends TestCase {
public function testValidateAccountBanned() {
$account = new Account();
$account->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']);
}
}

View File

@ -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(),
],
];

View File

@ -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,
],
];

View File

@ -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',
],
];

View File

@ -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);
}
}