diff --git a/api/components/ApiUser/AccessControl.php b/api/components/ApiUser/AccessControl.php new file mode 100644 index 0000000..b145828 --- /dev/null +++ b/api/components/ApiUser/AccessControl.php @@ -0,0 +1,8 @@ +getScopes()->exists($permissionName); + } + +} diff --git a/api/components/ApiUser/Component.php b/api/components/ApiUser/Component.php new file mode 100644 index 0000000..2c9e0c3 --- /dev/null +++ b/api/components/ApiUser/Component.php @@ -0,0 +1,23 @@ +isExpired()) { + throw new UnauthorizedHttpException('Token expired'); + } + + return new static($model); + } + + private function __construct(OauthAccessToken $accessToken) { + $this->_accessToken = $accessToken; + } + + public function getAccount() : Account { + return $this->getSession()->account; + } + + public function getClient() : OauthClient { + return $this->getSession()->client; + } + + public function getSession() : OauthSession { + return $this->_accessToken->session; + } + + public function getAccessToken() : OauthAccessToken { + return $this->_accessToken; + } + + /** + * Этот метод используется для получения пользователя, к которому привязаны права. + * У нас права привязываются к токенам, так что возвращаем именно его id. + * @inheritdoc + */ + public function getId() { + return $this->_accessToken->access_token; + } + + public function getAuthKey() { + throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth'); + } + + public function validateAuthKey($authKey) { + throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth'); + } + + public static function findIdentity($id) { + throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth'); + } + +} diff --git a/api/components/User/Component.php b/api/components/User/Component.php index ab8ea39..bf22613 100644 --- a/api/components/User/Component.php +++ b/api/components/User/Component.php @@ -26,6 +26,12 @@ use yii\web\User as YiiUserComponent; */ class Component extends YiiUserComponent { + public $enableSession = false; + + public $loginUrl = null; + + public $identityClass = AccountIdentity::class; + public $secret; public $expirationTimeout = 3600; // 1h diff --git a/api/config/main.php b/api/config/main.php index e82ba86..8066d9c 100644 --- a/api/config/main.php +++ b/api/config/main.php @@ -15,11 +15,11 @@ return [ 'components' => [ 'user' => [ 'class' => \api\components\User\Component::class, - 'identityClass' => \api\models\AccountIdentity::class, - 'enableSession' => false, - 'loginUrl' => null, 'secret' => $params['userSecret'], ], + 'apiUser' => [ + 'class' => \api\components\ApiUser\Component::class, + ], 'log' => [ 'traceLevel' => YII_DEBUG ? 3 : 0, 'targets' => [ diff --git a/api/config/routes.php b/api/config/routes.php index 491daee..31cc2e3 100644 --- a/api/config/routes.php +++ b/api/config/routes.php @@ -5,4 +5,6 @@ return [ '/accounts/change-email/confirm-new-email' => 'accounts/change-email-confirm-new-email', '/oauth2/v1/' => 'oauth/', + + '/account/v1/info' => 'identity-info/index', ]; diff --git a/api/controllers/ApiController.php b/api/controllers/ApiController.php new file mode 100644 index 0000000..81a0455 --- /dev/null +++ b/api/controllers/ApiController.php @@ -0,0 +1,31 @@ + HttpBearerAuth::class, + 'user' => Yii::$app->apiUser, + ]; + + // xml нам не понадобится + unset($parentBehaviors['contentNegotiator']['formats']['application/xml']); + // rate limiter здесь не применяется + unset($parentBehaviors['rateLimiter']); + + return $parentBehaviors; + } + +} diff --git a/api/controllers/Controller.php b/api/controllers/Controller.php index 8f22f8e..8c655d6 100644 --- a/api/controllers/Controller.php +++ b/api/controllers/Controller.php @@ -2,6 +2,7 @@ namespace api\controllers; use api\traits\ApiNormalize; +use Yii; use yii\filters\auth\HttpBearerAuth; /** @@ -18,6 +19,7 @@ class Controller extends \yii\rest\Controller { // Добавляем авторизатор для входа по jwt токенам $parentBehaviors['authenticator'] = [ 'class' => HttpBearerAuth::class, + 'user' => Yii::$app->getUser(), ]; // xml нам не понадобится diff --git a/api/controllers/IdentityInfoController.php b/api/controllers/IdentityInfoController.php new file mode 100644 index 0000000..8dbf3ab --- /dev/null +++ b/api/controllers/IdentityInfoController.php @@ -0,0 +1,44 @@ + [ + 'class' => AccessControl::class, + 'rules' => [ + [ + 'actions' => ['index'], + 'allow' => true, + 'roles' => [S::ACCOUNT_INFO], + ], + ], + ], + ]); + } + + public function actionIndex() { + $account = Yii::$app->apiUser->getIdentity()->getAccount(); + $response = [ + 'id' => $account->id, + 'uuid' => $account->uuid, + 'username' => $account->username, + 'registeredAt' => $account->created_at, + 'profileLink' => $account->getProfileLink(), + 'preferredLanguage' => $account->lang, + ]; + + if (Yii::$app->apiUser->can(S::ACCOUNT_EMAIL)) { + $response['email'] = $account->email; + } + + return $response; + } + +} diff --git a/autocompletion.php b/autocompletion.php index a0e847c..c3fcedf 100644 --- a/autocompletion.php +++ b/autocompletion.php @@ -28,6 +28,7 @@ abstract class BaseApplication extends yii\base\Application { * Include only Web application related components here * * @property \api\components\User\Component $user User component. + * @property \api\components\ApiUser\Component $apiUser Api User component. * @property \api\components\ReCaptcha\Component $reCaptcha * @property \common\components\oauth\Component $oauth * diff --git a/common/helpers/StringHelper.php b/common/helpers/StringHelper.php index d81186a..5e4c300 100644 --- a/common/helpers/StringHelper.php +++ b/common/helpers/StringHelper.php @@ -3,7 +3,7 @@ namespace common\helpers; class StringHelper { - public static function getEmailMask($email) { + public static function getEmailMask(string $email) : string { $username = explode('@', $email)[0]; $usernameLength = mb_strlen($username); $maskChars = '**'; @@ -21,4 +21,16 @@ class StringHelper { return $mask . mb_substr($email, $usernameLength); } + /** + * Проверяет на то, что переданная строка является валидным UUID + * Regex найдено на просторах интернета: http://stackoverflow.com/a/6223221 + * + * @param string $uuid + * @return bool + */ + public static function isUuid(string $uuid) : bool { + $re = '/[a-f0-9]{8}\-[a-f0-9]{4}\-4[a-f0-9]{3}\-(8|9|a|b)[a-f0-9]{3}\-[a-f0-9]{12}/'; + return preg_match($re, $uuid, $matches) === 1; + } + } diff --git a/common/models/Account.php b/common/models/Account.php index 98b61c4..8a90596 100644 --- a/common/models/Account.php +++ b/common/models/Account.php @@ -25,7 +25,8 @@ use yii\db\ActiveRecord; * @property integer $password_changed_at * * Геттеры-сеттеры: - * @property string $password пароль пользователя (только для записи) + * @property string $password пароль пользователя (только для записи) + * @property string $profileLink ссылка на профиль на Ely без поддержки static url (только для записи) * * Отношения: * @property EmailActivation[] $emailActivations @@ -144,7 +145,7 @@ class Account extends ActiveRecord { * * @return bool */ - public function canAutoApprove(OauthClient $client, array $scopes = []) { + public function canAutoApprove(OauthClient $client, array $scopes = []) : bool { if ($client->is_trusted) { return true; } @@ -165,10 +166,14 @@ class Account extends ActiveRecord { * Выполняет проверку, принадлежит ли этому нику аккаунт у Mojang * @return bool */ - public function hasMojangUsernameCollision() { + public function hasMojangUsernameCollision() : bool { return MojangUsername::find() ->andWhere(['username' => $this->username]) ->exists(); } + public function getProfileLink() : string { + return 'http://ely.by/u' . $this->id; + } + } diff --git a/common/models/OauthAccessToken.php b/common/models/OauthAccessToken.php index 7053cb8..9c60bed 100644 --- a/common/models/OauthAccessToken.php +++ b/common/models/OauthAccessToken.php @@ -2,7 +2,6 @@ namespace common\models; use common\components\redis\Set; -use Yii; use yii\db\ActiveRecord; /** @@ -13,6 +12,8 @@ use yii\db\ActiveRecord; * @property integer $expire_time * * @property Set $scopes + * + * @property OauthSession $session */ class OauthAccessToken extends ActiveRecord { @@ -38,4 +39,8 @@ class OauthAccessToken extends ActiveRecord { return true; } + public function isExpired() { + return time() > $this->expire_time; + } + } diff --git a/common/models/OauthScope.php b/common/models/OauthScope.php index 74d3901..ab392bf 100644 --- a/common/models/OauthScope.php +++ b/common/models/OauthScope.php @@ -1,7 +1,6 @@ batchInsert('{{%oauth_scopes}}', ['id'], [ + ['account_info'], + ['account_email'], + ]); + } + + public function safeDown() { + $this->delete('{{%oauth_scopes}}', ['id' => ['account_info', 'account_email']]); + } + +} diff --git a/tests/codeception/api/_pages/IdentityInfoRoute.php b/tests/codeception/api/_pages/IdentityInfoRoute.php new file mode 100644 index 0000000..75cd984 --- /dev/null +++ b/tests/codeception/api/_pages/IdentityInfoRoute.php @@ -0,0 +1,16 @@ +route = ['identity-info/index']; + $this->actor->sendGET($this->getUrl()); + } + +} diff --git a/tests/codeception/api/functional/IdentityInfoCest.php b/tests/codeception/api/functional/IdentityInfoCest.php new file mode 100644 index 0000000..0050a32 --- /dev/null +++ b/tests/codeception/api/functional/IdentityInfoCest.php @@ -0,0 +1,66 @@ +route = new IdentityInfoRoute($I); + } + + public function testGetErrorIfNotEnoughPerms(OauthSteps $I) { + $accessToken = $I->getAccessToken(); + $I->amBearerAuthenticated($accessToken); + $this->route->info(); + $I->canSeeResponseCodeIs(403); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'name' => 'Forbidden', + 'status' => 403, + ]); + } + + public function testGetInfo(OauthSteps $I) { + $accessToken = $I->getAccessToken([S::ACCOUNT_INFO]); + $I->amBearerAuthenticated($accessToken); + $this->route->info(); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'id' => 1, + 'uuid' => 'df936908-b2e1-544d-96f8-2977ec213022', + 'username' => 'Admin', + 'registeredAt' => 1451775316, + 'profileLink' => 'http://ely.by/u1', + 'preferredLanguage' => 'en', + ]); + $I->cantSeeResponseJsonMatchesJsonPath('$.email'); + } + + public function testGetInfoWithEmail(OauthSteps $I) { + $accessToken = $I->getAccessToken([S::ACCOUNT_INFO, S::ACCOUNT_EMAIL]); + $I->amBearerAuthenticated($accessToken); + $this->route->info(); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'id' => 1, + 'uuid' => 'df936908-b2e1-544d-96f8-2977ec213022', + 'username' => 'Admin', + 'registeredAt' => 1451775316, + 'profileLink' => 'http://ely.by/u1', + 'preferredLanguage' => 'en', + 'email' => 'admin@ely.by', + ]); + } + +} diff --git a/tests/codeception/api/functional/OauthAccessTokenCest.php b/tests/codeception/api/functional/OauthAccessTokenCest.php index a2c1275..1ee1e4c 100644 --- a/tests/codeception/api/functional/OauthAccessTokenCest.php +++ b/tests/codeception/api/functional/OauthAccessTokenCest.php @@ -1,9 +1,9 @@ getAuthCode(false); + $authCode = $I->getAuthCode([S::OFFLINE_ACCESS]); $this->route->issueToken($this->buildParams( $authCode, 'ely', diff --git a/tests/codeception/api/functional/OauthRefreshTokenCest.php b/tests/codeception/api/functional/OauthRefreshTokenCest.php index 4c29bad..dc6307a 100644 --- a/tests/codeception/api/functional/OauthRefreshTokenCest.php +++ b/tests/codeception/api/functional/OauthRefreshTokenCest.php @@ -1,10 +1,9 @@ getRefreshToken(); + $refreshToken = $I->getRefreshToken([S::MINECRAFT_SERVER_SESSION]); $this->route->issueToken($this->buildParams( $refreshToken, 'ely', 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', - [OauthScope::MINECRAFT_SERVER_SESSION, OauthScope::OFFLINE_ACCESS] + [S::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS] )); $I->canSeeResponseCodeIs(200); $I->canSeeResponseIsJson(); @@ -53,12 +52,12 @@ class OauthRefreshTokenCest { } public function testRefreshTokenWithNewScopes(OauthSteps $I) { - $refreshToken = $I->getRefreshToken(); + $refreshToken = $I->getRefreshToken([S::MINECRAFT_SERVER_SESSION]); $this->route->issueToken($this->buildParams( $refreshToken, 'ely', 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', - [OauthScope::MINECRAFT_SERVER_SESSION, OauthScope::OFFLINE_ACCESS, 'change_skin'] + [S::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS, S::ACCOUNT_EMAIL] )); $I->canSeeResponseCodeIs(400); $I->canSeeResponseIsJson(); diff --git a/tests/codeception/api/functional/_steps/OauthSteps.php b/tests/codeception/api/functional/_steps/OauthSteps.php index 000bc88..c16aaf6 100644 --- a/tests/codeception/api/functional/_steps/OauthSteps.php +++ b/tests/codeception/api/functional/_steps/OauthSteps.php @@ -1,11 +1,12 @@ loggedInAsActiveAccount(); $route = new OauthRoute($this); @@ -13,7 +14,7 @@ class OauthSteps extends \tests\codeception\api\FunctionalTester { 'client_id' => 'ely', 'redirect_uri' => 'http://ely.by', 'response_type' => 'code', - 'scope' => 'minecraft_server_session' . ($online ? '' : ',offline_access'), + 'scope' => implode(',', $permissions), ], ['accept' => true]); $this->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); $response = json_decode($this->grabResponse(), true); @@ -22,9 +23,22 @@ class OauthSteps extends \tests\codeception\api\FunctionalTester { return $matches[1]; } - public function getRefreshToken() { + public function getAccessToken(array $permissions = []) { + $authCode = $this->getAuthCode($permissions); + $response = $this->issueToken($authCode); + + return $response['access_token']; + } + + public function getRefreshToken(array $permissions = []) { // TODO: по идее можно напрямую сделать зпись в базу, что ускорит процесс тестирования - $authCode = $this->getAuthCode(false); + $authCode = $this->getAuthCode(array_merge([S::OFFLINE_ACCESS], $permissions)); + $response = $this->issueToken($authCode); + + return $response['refresh_token']; + } + + public function issueToken($authCode) { $route = new OauthRoute($this); $route->issueToken([ 'code' => $authCode, @@ -34,9 +48,7 @@ class OauthSteps extends \tests\codeception\api\FunctionalTester { 'grant_type' => 'authorization_code', ]); - $response = json_decode($this->grabResponse(), true); - - return $response['refresh_token']; + return json_decode($this->grabResponse(), true); } } diff --git a/tests/codeception/common/_support/FixtureHelper.php b/tests/codeception/common/_support/FixtureHelper.php index 4ec7f8e..c96fcc4 100644 --- a/tests/codeception/common/_support/FixtureHelper.php +++ b/tests/codeception/common/_support/FixtureHelper.php @@ -7,7 +7,6 @@ use tests\codeception\common\fixtures\AccountFixture; use tests\codeception\common\fixtures\AccountSessionFixture; use tests\codeception\common\fixtures\EmailActivationFixture; use tests\codeception\common\fixtures\OauthClientFixture; -use tests\codeception\common\fixtures\OauthScopeFixture; use tests\codeception\common\fixtures\OauthSessionFixture; use tests\codeception\common\fixtures\UsernameHistoryFixture; use yii\test\FixtureTrait; @@ -56,10 +55,6 @@ class FixtureHelper extends Module { 'class' => OauthClientFixture::class, 'dataFile' => '@tests/codeception/common/fixtures/data/oauth-clients.php', ], - 'oauthScopes' => [ - 'class' => OauthScopeFixture::class, - 'dataFile' => '@tests/codeception/common/fixtures/data/oauth-scopes.php', - ], 'oauthSessions' => [ 'class' => OauthSessionFixture::class, 'dataFile' => '@tests/codeception/common/fixtures/data/oauth-sessions.php', diff --git a/tests/codeception/common/fixtures/OauthScopeFixture.php b/tests/codeception/common/fixtures/OauthScopeFixture.php deleted file mode 100644 index 2ce4ef2..0000000 --- a/tests/codeception/common/fixtures/OauthScopeFixture.php +++ /dev/null @@ -1,11 +0,0 @@ - [ - 'id' => 'minecraft_server_session', - ], - 'change_skin' => [ - 'id' => 'change_skin', - ], - 'offline_access' => [ - 'id' => 'offline_access', - ], -]; diff --git a/tests/codeception/common/unit/helpers/StringHelperTest.php b/tests/codeception/common/unit/helpers/StringHelperTest.php index 872331d..50c7297 100644 --- a/tests/codeception/common/unit/helpers/StringHelperTest.php +++ b/tests/codeception/common/unit/helpers/StringHelperTest.php @@ -13,4 +13,10 @@ class StringHelperTest extends \PHPUnit_Framework_TestCase { $this->assertEquals('эр**уч@елу.бел', StringHelper::getEmailMask('эрикскрауч@елу.бел')); } + public function testIsUuid() { + $this->assertTrue(StringHelper::isUuid('a80b4487-a5c6-45a5-9829-373b4a494135')); + $this->assertFalse(StringHelper::isUuid('12345678')); + $this->assertFalse(StringHelper::isUuid('12345678-1234-1234-1234-123456789123')); + } + }