From f5f93ddef15b5d98dcf7407bd759409669a45dc5 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sun, 14 Feb 2016 20:50:10 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=20oAuth=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20Redis,=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BB=D0=B8=D1=88=D0=BD=D0=B8=D0=B5=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=8B,=20=D0=BF=D0=BE=D1=84=D0=B8=D0=BA=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=81=D1=82=D0=B0=D1=80=D1=8B=D0=B5.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/config/main.php | 7 +- api/controllers/Controller.php | 13 +- api/controllers/OauthController.php | 219 +++++++++++++ api/models/RegistrationForm.php | 2 +- api/models/SignupForm.php | 58 ---- common/components/oauth/Component.php | 63 ++++ .../oauth/Entity/AccessTokenEntity.php | 27 ++ .../oauth/Entity/AuthCodeEntity.php | 27 ++ .../components/oauth/Entity/SessionEntity.php | 27 ++ .../Exception/AcceptRequiredException.php | 22 ++ .../oauth/Exception/AccessDeniedException.php | 11 + .../oauth/Storage/Redis/AuthCodeStorage.php | 84 +++++ .../Storage/Redis/RefreshTokenStorage.php | 48 +++ .../oauth/Storage/Yii2/AccessTokenStorage.php | 85 +++++ .../oauth/Storage/Yii2/ClientStorage.php | 73 +++++ .../oauth/Storage/Yii2/ScopeStorage.php | 26 ++ .../oauth/Storage/Yii2/SessionStorage.php | 125 ++++++++ common/components/redis/Key.php | 58 ++++ common/components/redis/Set.php | 49 +++ common/config/main.php | 5 +- common/models/Account.php | 31 ++ common/models/OauthAccessToken.php | 41 +++ common/models/OauthClient.php | 49 +++ common/models/OauthScope.php | 17 + common/models/OauthSession.php | 54 ++++ composer.json | 9 +- console/db/Migration.php | 25 +- console/migrations/m160201_055928_oauth.php | 77 +++++ environments/dev/api/config/main-local.php | 3 + environments/dev/common/config/main-local.php | 6 + environments/prod/api/config/main-local.php | 12 + environments/prod/api/config/params-local.php | 3 + environments/prod/api/web/index.php | 18 ++ .../prod/common/config/main-local.php | 6 + tests/codeception/api/_bootstrap.php | 3 + tests/codeception/api/_pages/OauthRoute.php | 21 ++ tests/codeception/api/functional.suite.yml | 5 + .../api/functional/ContactCept.php | 47 --- .../codeception/api/functional/OauthCest.php | 294 ++++++++++++++++++ .../codeception/api/functional/_bootstrap.php | 2 + .../api/functional/_steps/AccountSteps.php | 16 + .../api/unit/models/ContactFormTest.php | 59 ---- .../models/PasswordResetRequestFormTest.php | 87 ------ .../api/unit/models/ResetPasswordFormTest.php | 43 --- .../common/_support/FixtureHelper.php | 31 +- .../common/fixtures/OauthClientFixture.php | 15 + .../common/fixtures/OauthScopeFixture.php | 11 + .../common/fixtures/OauthSessionFixture.php | 17 + .../common/fixtures/data/oauth-clients.php | 23 ++ .../common/fixtures/data/oauth-scopes.php | 9 + .../common/fixtures/data/oauth-sessions.php | 3 + tests/codeception/config/config.php | 3 + 52 files changed, 1752 insertions(+), 317 deletions(-) create mode 100644 api/controllers/OauthController.php delete mode 100644 api/models/SignupForm.php create mode 100644 common/components/oauth/Component.php create mode 100644 common/components/oauth/Entity/AccessTokenEntity.php create mode 100644 common/components/oauth/Entity/AuthCodeEntity.php create mode 100644 common/components/oauth/Entity/SessionEntity.php create mode 100644 common/components/oauth/Exception/AcceptRequiredException.php create mode 100644 common/components/oauth/Exception/AccessDeniedException.php create mode 100644 common/components/oauth/Storage/Redis/AuthCodeStorage.php create mode 100644 common/components/oauth/Storage/Redis/RefreshTokenStorage.php create mode 100644 common/components/oauth/Storage/Yii2/AccessTokenStorage.php create mode 100644 common/components/oauth/Storage/Yii2/ClientStorage.php create mode 100644 common/components/oauth/Storage/Yii2/ScopeStorage.php create mode 100644 common/components/oauth/Storage/Yii2/SessionStorage.php create mode 100644 common/components/redis/Key.php create mode 100644 common/components/redis/Set.php create mode 100644 common/models/OauthAccessToken.php create mode 100644 common/models/OauthClient.php create mode 100644 common/models/OauthScope.php create mode 100644 common/models/OauthSession.php create mode 100644 console/migrations/m160201_055928_oauth.php create mode 100644 environments/prod/api/config/main-local.php create mode 100644 environments/prod/api/config/params-local.php create mode 100644 environments/prod/api/web/index.php create mode 100644 tests/codeception/api/_pages/OauthRoute.php delete mode 100644 tests/codeception/api/functional/ContactCept.php create mode 100644 tests/codeception/api/functional/OauthCest.php create mode 100644 tests/codeception/api/functional/_steps/AccountSteps.php delete mode 100644 tests/codeception/api/unit/models/ContactFormTest.php delete mode 100644 tests/codeception/api/unit/models/PasswordResetRequestFormTest.php delete mode 100644 tests/codeception/api/unit/models/ResetPasswordFormTest.php create mode 100644 tests/codeception/common/fixtures/OauthClientFixture.php create mode 100644 tests/codeception/common/fixtures/OauthScopeFixture.php create mode 100644 tests/codeception/common/fixtures/OauthSessionFixture.php create mode 100644 tests/codeception/common/fixtures/data/oauth-clients.php create mode 100644 tests/codeception/common/fixtures/data/oauth-scopes.php create mode 100644 tests/codeception/common/fixtures/data/oauth-sessions.php diff --git a/api/config/main.php b/api/config/main.php index 45ed653..d952cc3 100644 --- a/api/config/main.php +++ b/api/config/main.php @@ -13,8 +13,9 @@ return [ 'controllerNamespace' => 'api\controllers', 'components' => [ 'user' => [ - 'identityClass' => 'common\models\Account', + 'identityClass' => \common\models\Account::class, 'enableAutoLogin' => true, + 'loginUrl' => null, ], 'log' => [ 'traceLevel' => YII_DEBUG ? 3 : 0, @@ -42,6 +43,10 @@ return [ 'response' => [ 'format' => \yii\web\Response::FORMAT_JSON, ], + 'oauth' => [ + 'class' => \common\components\oauth\Component::class, + 'grantTypes' => ['authorization_code'], + ], ], 'params' => $params, ]; diff --git a/api/controllers/Controller.php b/api/controllers/Controller.php index 1e02157..1932f13 100644 --- a/api/controllers/Controller.php +++ b/api/controllers/Controller.php @@ -2,12 +2,14 @@ namespace api\controllers; use api\traits\ApiNormalize; +use Yii; +/** + * @property \common\models\Account|null $account + */ class Controller extends \yii\rest\Controller { use ApiNormalize; - public $enableCsrfValidation = true; - public function behaviors() { $parentBehaviors = parent::behaviors(); // xml нам не понадобится @@ -16,4 +18,11 @@ class Controller extends \yii\rest\Controller { return $parentBehaviors; } + /** + * @return \common\models\Account|null + */ + public function getAccount() { + return Yii::$app->getUser()->getIdentity(); + } + } diff --git a/api/controllers/OauthController.php b/api/controllers/OauthController.php new file mode 100644 index 0000000..3aa937c --- /dev/null +++ b/api/controllers/OauthController.php @@ -0,0 +1,219 @@ + [ + 'class' => AccessControl::class, + 'rules' => [ + [ + 'actions' => ['validate'], + 'allow' => true, + ], + [ + 'actions' => ['complete'], + 'allow' => true, + 'roles' => ['@'], + ], + ], + ], + ]); + } + + public function verbs() { + return [ + 'validate' => ['GET'], + 'complete' => ['POST'], + ]; + } + + /** + * @return \League\OAuth2\Server\AuthorizationServer + */ + protected function getServer() { + /** @var \common\components\oauth\Component $oauth */ + $oauth = Yii::$app->get('oauth'); + return $oauth->authServer; + } + + /** + * @return \League\OAuth2\Server\Grant\AuthCodeGrant + */ + protected function getGrantType() { + return $this->getServer()->getGrantType('authorization_code'); + } + + /** + * Запрос, который должен проверить переданные параметры oAuth авторизации + * и сформировать ответ для нашего приложения на фронте + * + * Входными данными является стандартный список GET параметров по стандарту oAuth: + * $_GET = [ + * client_id, + * redirect_uri, + * response_type, + * scope, + * state, + * ] + * + * Кроме того можно передать значения description для переопределения описания приложения. + * + * @return array|\yii\web\Response + */ + public function actionValidate() { + try { + $authParams = $this->getGrantType()->checkAuthorizeParams(); + /** @var \League\OAuth2\Server\Entity\ClientEntity $client */ + $client = $authParams['client']; + /** @var \common\models\OauthClient $clientModel */ + $clientModel = OauthClient::findOne($client->getId()); + $response = $this->buildSuccessResponse( + Yii::$app->request->getQueryParams(), + $clientModel, + $authParams['scopes'] + ); + } catch (OAuthException $e) { + $response = $this->buildErrorResponse($e); + } + + return $response; + } + + /** + * Метод выполняется генерацию авторизационного кода (auth_code) и формирование ссылки + * для дальнейшнешл редиректа пользователя назад на сайт клиента + * + * Входными данными является всё те же параметры, что были необходимы для валидации: + * $_GET = [ + * client_id, + * redirect_uri, + * response_type, + * scope, + * state, + * ]; + * А также поле accept, которое показывает, что пользователь нажал на кнопку "Принять". Если поле присутствует, + * то оно будет интерпретироваться как любое приводимое к false значение. В ином случае, значение будет + * интерпретировано, как положительный исход. + * + * @return array|\yii\web\Response + */ + public function actionComplete() { + $grant = $this->getGrantType(); + try { + $authParams = $grant->checkAuthorizeParams(); + $account = $this->getAccount(); + /** @var \League\OAuth2\Server\Entity\ClientEntity $client */ + $client = $authParams['client']; + /** @var \common\models\OauthClient $clientModel */ + $clientModel = OauthClient::findOne($client->getId()); + + if (!$account->canAutoApprove($clientModel, $authParams['scopes'])) { + $isAccept = Yii::$app->request->post('accept'); + if ($isAccept === null) { + throw new AcceptRequiredException(); + } + + if (!$isAccept) { + throw new AccessDeniedException($authParams['redirect_uri']); + } + } + + $redirectUri = $grant->newAuthorizeRequest('user', $account->id, $authParams); + $response = [ + 'success' => true, + 'redirectUri' => $redirectUri, + ]; + } catch (OAuthException $e) { + $response = $this->buildErrorResponse($e); + } + + return $response; + } + + /** + * Метод выполняется сервером приложения, которому был выдан auth_token. + * + * Входными данными является стандартный список GET параметров по стандарту oAuth: + * $_GET = [ + * client_id, + * client_secret, + * redirect_uri, + * code|refresh_token, + * grant_type, + * ] + * + * @return array + */ + public function actionIssueToken() { + try { + $response = $this->getServer()->issueAccessToken(); + } catch (OAuthException $e) { + Yii::$app->response->statusCode = $e->httpStatusCode; + $response = [ + 'error' => $e->errorType, + 'message' => $e->getMessage(), + ]; + } + + return $response; + } + + /** + * @param array $queryParams + * @param OauthClient $clientModel + * @param \League\OAuth2\Server\Entity\ScopeEntity[] $scopes + * + * @return array + */ + private function buildSuccessResponse($queryParams, OauthClient $clientModel, $scopes) { + return [ + 'success' => true, + // Возвращаем только те ключи, которые имеют реальное отношение к oAuth параметрам + 'oAuth' => array_intersect_key($queryParams, array_flip([ + 'client_id', + 'redirect_uri', + 'response_type', + 'scope', + 'state', + ])), + 'client' => [ + 'id' => $clientModel->id, + 'name' => $clientModel->name, + 'description' => ArrayHelper::getValue($queryParams, 'description', $clientModel->description), + ], + 'session' => [ + 'scopes' => array_keys($scopes), + ], + ]; + } + + private function buildErrorResponse(OAuthException $e) { + $response = [ + 'success' => false, + 'error' => $e->errorType, + 'parameter' => $e->parameter, + 'statusCode' => $e->httpStatusCode, + ]; + + if ($e->shouldRedirect()) { + $response['redirectUri'] = $e->getRedirectUri(); + } + + if ($e->httpStatusCode !== 200) { + Yii::$app->response->setStatusCode($e->httpStatusCode); + } + + return $response; + } + +} diff --git a/api/models/RegistrationForm.php b/api/models/RegistrationForm.php index 0878d4d..0926189 100644 --- a/api/models/RegistrationForm.php +++ b/api/models/RegistrationForm.php @@ -18,7 +18,7 @@ class RegistrationForm extends BaseApiForm { public function rules() { return [ - ['rulesAgreement', 'boolean', 'message' => 'error.you_must_accept_rules'], + ['rulesAgreement', 'required', 'message' => 'error.you_must_accept_rules'], [[], ReCaptchaValidator::class, 'message' => 'error.captcha_invalid', 'when' => !YII_ENV_TEST], ['username', 'filter', 'filter' => 'trim'], diff --git a/api/models/SignupForm.php b/api/models/SignupForm.php deleted file mode 100644 index ab419e1..0000000 --- a/api/models/SignupForm.php +++ /dev/null @@ -1,58 +0,0 @@ - 'trim'], - ['username', 'required'], - ['username', 'unique', 'targetClass' => '\common\models\User', 'message' => 'This username has already been taken.'], - ['username', 'string', 'min' => 2, 'max' => 255], - - ['email', 'filter', 'filter' => 'trim'], - ['email', 'required'], - ['email', 'email'], - ['email', 'string', 'max' => 255], - ['email', 'unique', 'targetClass' => '\common\models\User', 'message' => 'This email address has already been taken.'], - - ['password', 'required'], - ['password', 'string', 'min' => 6], - ]; - } - - /** - * Signs user up. - * - * @return Account|null the saved model or null if saving fails - */ - public function signup() - { - if ($this->validate()) { - $user = new Account(); - $user->email = $this->email; - $user->setPassword($this->password); - $user->generateAuthKey(); - if ($user->save()) { - return $user; - } - } - - return null; - } -} diff --git a/common/components/oauth/Component.php b/common/components/oauth/Component.php new file mode 100644 index 0000000..9fe54a3 --- /dev/null +++ b/common/components/oauth/Component.php @@ -0,0 +1,63 @@ + class + */ + public $grantMap = [ + 'authorization_code' => 'League\OAuth2\Server\Grant\AuthCodeGrant', + 'client_credentials' => 'League\OAuth2\Server\Grant\ClientCredentialsGrant', + 'password' => 'League\OAuth2\Server\Grant\PasswordGrant', + 'refresh_token' => 'League\OAuth2\Server\Grant\RefreshTokenGrant' + ]; + + public function getAuthServer() { + if ($this->_authServer === null) { + $authServer = new AuthorizationServer(); + $authServer + ->setAccessTokenStorage(new AccessTokenStorage()) + ->setClientStorage(new ClientStorage()) + ->setScopeStorage(new ScopeStorage()) + ->setSessionStorage(new SessionStorage()) + ->setAuthCodeStorage(new AuthCodeStorage()) + ->setScopeDelimiter(','); + + $this->_authServer = $authServer; + + foreach ($this->grantTypes as $grantType) { + if (!array_key_exists($grantType, $this->grantMap)) { + throw new InvalidConfigException('Invalid grant type'); + } + + $grant = new $this->grantMap[$grantType](); + $this->_authServer->addGrantType($grant); + } + } + + return $this->_authServer; + } + +} diff --git a/common/components/oauth/Entity/AccessTokenEntity.php b/common/components/oauth/Entity/AccessTokenEntity.php new file mode 100644 index 0000000..3501d82 --- /dev/null +++ b/common/components/oauth/Entity/AccessTokenEntity.php @@ -0,0 +1,27 @@ +sessionId; + } + + /** + * @inheritdoc + * @return static + */ + public function setSession(SessionEntity $session) { + parent::setSession($session); + $this->sessionId = $session->getId(); + + return $this; + } + +} diff --git a/common/components/oauth/Entity/AuthCodeEntity.php b/common/components/oauth/Entity/AuthCodeEntity.php new file mode 100644 index 0000000..6bd8b0c --- /dev/null +++ b/common/components/oauth/Entity/AuthCodeEntity.php @@ -0,0 +1,27 @@ +sessionId; + } + + /** + * @inheritdoc + * @return static + */ + public function setSession(SessionEntity $session) { + parent::setSession($session); + $this->sessionId = $session->getId(); + + return $this; + } + +} diff --git a/common/components/oauth/Entity/SessionEntity.php b/common/components/oauth/Entity/SessionEntity.php new file mode 100644 index 0000000..28fafb5 --- /dev/null +++ b/common/components/oauth/Entity/SessionEntity.php @@ -0,0 +1,27 @@ +clientId; + } + + /** + * @inheritdoc + * @return static + */ + public function associateClient(ClientEntity $client) { + parent::associateClient($client); + $this->clientId = $client->getId(); + + return $this; + } + +} diff --git a/common/components/oauth/Exception/AcceptRequiredException.php b/common/components/oauth/Exception/AcceptRequiredException.php new file mode 100644 index 0000000..36c5bf0 --- /dev/null +++ b/common/components/oauth/Exception/AcceptRequiredException.php @@ -0,0 +1,22 @@ +redirectUri = $redirectUri; + } + +} diff --git a/common/components/oauth/Storage/Redis/AuthCodeStorage.php b/common/components/oauth/Storage/Redis/AuthCodeStorage.php new file mode 100644 index 0000000..8e75e7f --- /dev/null +++ b/common/components/oauth/Storage/Redis/AuthCodeStorage.php @@ -0,0 +1,84 @@ +dataTable, $code))->getValue(); + if (!$result) { + return null; + } + + if ($result['expire_time'] < time()) { + return null; + } + + return (new AuthCodeEntity($this->server))->hydrate([ + 'id' => $result['id'], + 'redirectUri' => $result['client_redirect_uri'], + 'expireTime' => $result['expire_time'], + 'sessionId' => $result['sessionId'], + ]); + } + + /** + * @inheritdoc + */ + public function create($token, $expireTime, $sessionId, $redirectUri) { + $payload = [ + 'id' => $token, + 'expire_time' => $expireTime, + 'session_id' => $sessionId, + 'client_redirect_uri' => $redirectUri, + ]; + + (new Key($this->dataTable, $token))->setValue($payload)->expire($this->ttl); + } + + /** + * @inheritdoc + */ + public function getScopes(OriginalAuthCodeEntity $token) { + $result = (new Set($this->dataTable, $token->getId(), 'scopes')); + $response = []; + foreach ($result as $scope) { + // TODO: нужно проверить все выданные скоупы на их существование + $response[] = (new ScopeEntity($this->server))->hydrate(['id' => $scope]); + } + + return $response; + } + + /** + * @inheritdoc + */ + public function associateScope(OriginalAuthCodeEntity $token, ScopeEntity $scope) { + (new Set($this->dataTable, $token->getId(), 'scopes'))->add($scope->getId())->expire($this->ttl); + } + + /** + * @inheritdoc + */ + public function delete(OriginalAuthCodeEntity $token) { + // Удаляем ключ + (new Set($this->dataTable, $token->getId()))->delete(); + // Удаляем список скоупов для ключа + (new Set($this->dataTable, $token->getId(), 'scopes'))->delete(); + } + +} diff --git a/common/components/oauth/Storage/Redis/RefreshTokenStorage.php b/common/components/oauth/Storage/Redis/RefreshTokenStorage.php new file mode 100644 index 0000000..2736f78 --- /dev/null +++ b/common/components/oauth/Storage/Redis/RefreshTokenStorage.php @@ -0,0 +1,48 @@ +dataTable, $token))->getValue(); + if (!$result) { + return null; + } + + return (new RefreshTokenEntity($this->server)) + ->setId($result['id']) + ->setExpireTime($result['expire_time']) + ->setAccessTokenId($result['access_token_id']); + } + + /** + * @inheritdoc + */ + public function create($token, $expireTime, $accessToken) { + $payload = [ + 'id' => $token, + 'expire_time' => $expireTime, + 'access_token_id' => $accessToken, + ]; + + (new Key($this->dataTable, $token))->setValue($payload); + } + + /** + * @inheritdoc + */ + public function delete(RefreshTokenEntity $token) { + (new Key($this->dataTable, $token->getId()))->delete(); + } + +} diff --git a/common/components/oauth/Storage/Yii2/AccessTokenStorage.php b/common/components/oauth/Storage/Yii2/AccessTokenStorage.php new file mode 100644 index 0000000..311d81c --- /dev/null +++ b/common/components/oauth/Storage/Yii2/AccessTokenStorage.php @@ -0,0 +1,85 @@ +cache[$token])) { + $this->cache[$token] = OauthAccessToken::findOne($token); + } + + return $this->cache[$token]; + } + + /** + * @inheritdoc + */ + public function get($token) { + $model = $this->getTokenModel($token); + if ($model === null) { + return null; + } + + return (new AccessTokenEntity($this->server))->hydrate([ + 'id' => $model->access_token, + 'expireTime' => $model->expire_time, + 'sessionId' => $model->session_id, + ]); + } + + /** + * @inheritdoc + */ + public function getScopes(OriginalAccessTokenEntity $token) { + $entities = []; + foreach($this->getTokenModel($token->getId())->getScopes() as $scope) { + $entities[] = (new ScopeEntity($this->server))->hydrate(['id' => $scope]); + } + + return $entities; + } + + /** + * @inheritdoc + */ + public function create($token, $expireTime, $sessionId) { + $model = new OauthAccessToken([ + 'access_token' => $token, + 'expire_time' => $expireTime, + 'session_id' => $sessionId, + ]); + + if (!$model->save()) { + throw new Exception('Cannot save ' . OauthAccessToken::class . ' model.'); + } + } + + /** + * @inheritdoc + */ + public function associateScope(OriginalAccessTokenEntity $token, ScopeEntity $scope) { + $this->getTokenModel($token->getId())->getScopes()->add($scope->getId()); + } + + /** + * @inheritdoc + */ + public function delete(OriginalAccessTokenEntity $token) { + $this->getTokenModel($token->getId())->delete(); + } + +} diff --git a/common/components/oauth/Storage/Yii2/ClientStorage.php b/common/components/oauth/Storage/Yii2/ClientStorage.php new file mode 100644 index 0000000..51aa1a3 --- /dev/null +++ b/common/components/oauth/Storage/Yii2/ClientStorage.php @@ -0,0 +1,73 @@ +select(['id', 'name', 'secret']) + ->where([OauthClient::tableName() . '.id' => $clientId]); + + if ($clientSecret !== null) { + $query->andWhere(['secret' => $clientSecret]); + } + + if ($redirectUri !== null) { + $query + ->addSelect(['redirect_uri']) + ->andWhere(['redirect_uri' => $redirectUri]); + } + + $model = $query->asArray()->one(); + if ($model === null) { + return null; + } + + $entity = new ClientEntity($this->server); + $entity->hydrate([ + 'id' => $model['id'], + 'name' => $model['name'], + 'secret' => $model['secret'], + ]); + + if (isset($model['redirect_uri'])) { + $entity->hydrate([ + 'redirectUri' => $model['redirect_uri'], + ]); + } + + return $entity; + } + + /** + * @inheritdoc + */ + public function getBySession(OriginalSessionEntity $session) { + if (!$session instanceof SessionEntity) { + throw new \ErrorException('This module assumes that $session typeof ' . SessionEntity::class); + } + + $model = OauthClient::find() + ->select(['id', 'name']) + ->andWhere(['id' => $session->getClientId()]) + ->asArray() + ->one(); + + if ($model === null) { + return null; + } + + return (new ClientEntity($this->server))->hydrate($model); + } + +} diff --git a/common/components/oauth/Storage/Yii2/ScopeStorage.php b/common/components/oauth/Storage/Yii2/ScopeStorage.php new file mode 100644 index 0000000..64fef0e --- /dev/null +++ b/common/components/oauth/Storage/Yii2/ScopeStorage.php @@ -0,0 +1,26 @@ +andWhere(['id' => $scope])->asArray()->one(); + if ($row === null) { + return null; + } + + $entity = new ScopeEntity($this->server); + $entity->hydrate($row); + + return $entity; + } + +} diff --git a/common/components/oauth/Storage/Yii2/SessionStorage.php b/common/components/oauth/Storage/Yii2/SessionStorage.php new file mode 100644 index 0000000..0686977 --- /dev/null +++ b/common/components/oauth/Storage/Yii2/SessionStorage.php @@ -0,0 +1,125 @@ +cache[$sessionId])) { + $this->cache[$sessionId] = OauthSession::findOne($sessionId); + } + + return $this->cache[$sessionId]; + } + + private function hydrateEntity($sessionModel) { + if (!$sessionModel instanceof OauthSession) { + return null; + } + + return (new SessionEntity($this->server))->hydrate([ + 'id' => $sessionModel->id, + 'client_id' => $sessionModel->client_id, + ])->setOwner($sessionModel->owner_type, $sessionModel->owner_id); + } + + /** + * @param string $sessionId + * @return SessionEntity|null + */ + public function getSession($sessionId) { + return $this->hydrateEntity($this->getSessionModel($sessionId)); + } + + /** + * @inheritdoc + */ + public function getByAccessToken(OriginalAccessTokenEntity $accessToken) { + /** @var OauthSession|null $model */ + $model = OauthSession::find()->innerJoinWith([ + 'accessTokens' => function(ActiveQuery $query) use ($accessToken) { + $query->andWhere(['access_token' => $accessToken->getId()]); + }, + ])->one(); + + return $this->hydrateEntity($model); + } + + /** + * @inheritdoc + */ + public function getByAuthCode(OriginalAuthCodeEntity $authCode) { + if (!$authCode instanceof AuthCodeEntity) { + throw new \ErrorException('This module assumes that $authCode typeof ' . AuthCodeEntity::class); + } + + return $this->getSession($authCode->getSessionId()); + } + + /** + * {@inheritdoc} + */ + public function getScopes(OriginalSessionEntity $session) { + $result = []; + foreach ($this->getSessionModel($session->getId())->getScopes() as $scope) { + // TODO: нужно проверить все выданные скоупы на их существование + $result[] = (new ScopeEntity($this->server))->hydrate(['id' => $scope]); + } + + return $result; + } + + /** + * @inheritdoc + */ + public function create($ownerType, $ownerId, $clientId, $clientRedirectUri = null) { + $sessionId = OauthSession::find() + ->select('id') + ->andWhere([ + 'client_id' => $clientId, + 'owner_type' => $ownerType, + 'owner_id' => $ownerId, + ])->scalar(); + + if ($sessionId === false) { + $model = new OauthSession([ + 'client_id' => $clientId, + 'owner_type' => $ownerType, + 'owner_id' => $ownerId, + ]); + + if (!$model->save()) { + throw new Exception('Cannot save ' . OauthSession::class . ' model.'); + } + + $sessionId = $model->id; + } + + return $sessionId; + } + + /** + * @inheritdoc + */ + public function associateScope(OriginalSessionEntity $session, ScopeEntity $scope) { + $this->getSessionModel($session->getId())->getScopes()->add($scope->getId()); + } + +} diff --git a/common/components/redis/Key.php b/common/components/redis/Key.php new file mode 100644 index 0000000..1b1f951 --- /dev/null +++ b/common/components/redis/Key.php @@ -0,0 +1,58 @@ +get('redis'); + } + + public function getKey() { + return $this->key; + } + + public function getValue() { + return $this->getRedis()->get(json_decode($this->key)); + } + + public function setValue($value) { + $this->getRedis()->set($this->key, json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + return $this; + } + + public function delete() { + $this->getRedis()->executeCommand('DEL', [$this->key]); + return $this; + } + + public function expire($ttl) { + $this->getRedis()->executeCommand('EXPIRE', [$this->key, $ttl]); + return $this; + } + + private function buildKey(array $parts) { + $keyParts = []; + foreach($parts as $part) { + $keyParts[] = str_replace('_', ':', $part); + } + + return implode(':', $keyParts); + } + + public function __construct(...$key) { + if (empty($key)) { + throw new InvalidArgumentException('You must specify at least one key.'); + } + + $this->key = $this->buildKey($key); + } + +} diff --git a/common/components/redis/Set.php b/common/components/redis/Set.php new file mode 100644 index 0000000..9dde932 --- /dev/null +++ b/common/components/redis/Set.php @@ -0,0 +1,49 @@ +get('redis'); + } + + public function add($value) { + $this->getDb()->executeCommand('SADD', [$this->key, $value]); + return $this; + } + + public function remove($value) { + $this->getDb()->executeCommand('SREM', [$this->key, $value]); + return $this; + } + + public function members() { + return $this->getDb()->executeCommand('SMEMBERS', [$this->key]); + } + + public function getValue() { + return $this->members(); + } + + public function exists($value) { + return !!$this->getDb()->executeCommand('SISMEMBER', [$this->key, $value]); + } + + public function diff(array $sets) { + return $this->getDb()->executeCommand('SDIFF', [$this->key, implode(' ', $sets)]); + } + + /** + * @inheritdoc + */ + public function getIterator() { + return new \ArrayIterator($this->members()); + } + +} diff --git a/common/config/main.php b/common/config/main.php index c920d13..e568d4a 100644 --- a/common/config/main.php +++ b/common/config/main.php @@ -15,6 +15,9 @@ return [ ], 'security' => [ 'passwordHashStrategy' => 'password_hash', - ] + ], + 'redis' => [ + 'class' => 'yii\redis\Connection', + ], ], ]; diff --git a/common/models/Account.php b/common/models/Account.php index d6d2725..f3b0bc4 100644 --- a/common/models/Account.php +++ b/common/models/Account.php @@ -28,6 +28,7 @@ use yii\web\IdentityInterface; * * Отношения: * @property EmailActivation[] $emailActivations + * @property OauthSession[] $sessions * * Поведения: * @mixin TimestampBehavior @@ -216,4 +217,34 @@ class Account extends ActiveRecord implements IdentityInterface { return $this->hasMany(EmailActivation::class, ['id' => 'account_id']); } + public function getSessions() { + return $this->hasMany(OauthSession::class, ['owner_id' => 'id']); + } + + /** + * Метод проверяет, может ли текщий пользователь быть автоматически авторизован + * для указанного клиента без запроса доступа к необходимому списку прав + * + * @param OauthClient $client + * @param \League\OAuth2\Server\Entity\ScopeEntity[] $scopes + * + * @return bool + */ + public function canAutoApprove(OauthClient $client, array $scopes = []) { + if ($client->is_trusted) { + return true; + } + + /** @var OauthSession|null $session */ + $session = $this->getSessions()->andWhere(['client_id' => $client->id])->one(); + if ($session !== null) { + $existScopes = $session->getScopes()->members(); + if (empty(array_diff(array_keys($scopes), $existScopes))) { + return true; + } + } + + return false; + } + } diff --git a/common/models/OauthAccessToken.php b/common/models/OauthAccessToken.php new file mode 100644 index 0000000..7053cb8 --- /dev/null +++ b/common/models/OauthAccessToken.php @@ -0,0 +1,41 @@ +hasOne(OauthSession::class, ['id' => 'session_id']); + } + + public function getScopes() { + return new Set($this->getDb()->getSchema()->getRawTableName($this->tableName()), $this->access_token, 'scopes'); + } + + public function beforeDelete() { + if (!$result = parent::beforeDelete()) { + return $result; + } + + $this->getScopes()->delete(); + + return true; + } + +} diff --git a/common/models/OauthClient.php b/common/models/OauthClient.php new file mode 100644 index 0000000..8bec648 --- /dev/null +++ b/common/models/OauthClient.php @@ -0,0 +1,49 @@ + function(self $model) { + return $model->isNewRecord; + }], + [['id'], 'unique', 'when' => function(self $model) { + return $model->isNewRecord; + }], + [['name', 'description'], 'required'], + [['name', 'description'], 'string', 'max' => 255], + ]; + } + + public function getAccount() { + return $this->hasOne(Account::class, ['id' => 'account_id']); + } + + public function getSessions() { + return $this->hasMany(OauthSession::class, ['client_id' => 'id']); + } + +} diff --git a/common/models/OauthScope.php b/common/models/OauthScope.php new file mode 100644 index 0000000..cfa3acc --- /dev/null +++ b/common/models/OauthScope.php @@ -0,0 +1,17 @@ +hasMany(OauthAccessToken::class, ['session_id' => 'id']); + } + + public function getClient() { + return $this->hasOne(OauthClient::class, ['id' => 'client_id']); + } + + public function getAccount() { + return $this->hasOne(Account::class, ['id' => 'owner_id']); + } + + public function getScopes() { + return new Set($this->getDb()->getSchema()->getRawTableName($this->tableName()), $this->id, 'scopes'); + } + + public function beforeDelete() { + if (!$result = parent::beforeDelete()) { + return $result; + } + + $this->getScopes()->delete(); + + return true; + } + +} diff --git a/composer.json b/composer.json index 7fd250b..e0d8971 100644 --- a/composer.json +++ b/composer.json @@ -18,14 +18,19 @@ "yiisoft/yii2": ">=2.0.6", "yiisoft/yii2-bootstrap": "*", "yiisoft/yii2-swiftmailer": "*", - "ramsey/uuid": "^3.1" + "ramsey/uuid": "^3.1", + "league/oauth2-server": "~4.1.5", + "yiisoft/yii2-redis": "~2.0.0" }, "require-dev": { "yiisoft/yii2-codeception": "*", "yiisoft/yii2-debug": "*", "yiisoft/yii2-gii": "*", "yiisoft/yii2-faker": "*", - "flow/jsonpath": "^0.3.1" + "flow/jsonpath": "^0.3.1", + "codeception/codeception": "2.0.*", + "codeception/specify": "*", + "codeception/verify": "*" }, "config": { "process-timeout": 1800 diff --git a/console/db/Migration.php b/console/db/Migration.php index c2effcb..d784a36 100644 --- a/console/db/Migration.php +++ b/console/db/Migration.php @@ -11,11 +11,34 @@ class Migration extends YiiMigration { public function getTableOptions($engine = 'InnoDB') { $tableOptions = null; if ($this->db->driverName === 'mysql') { - // http://stackoverflow.com/questions/766809/whats-the-difference-between-utf8-general-ci-and-utf8-unicode-ci $tableOptions = 'CHARACTER SET utf8 COLLATE utf8_general_ci ENGINE=' . $engine; } return $tableOptions; } + protected function primary(...$columns) { + switch (count($columns)) { + case 0: + $key = ''; + break; + case 1: + $key = $columns[0]; + break; + default: + $key = $this->buildKey($columns); + } + + return " PRIMARY KEY ($key) "; + } + + private function buildKey(array $columns) { + $key = ''; + foreach ($columns as $i => $column) { + $key .= $i == count($columns) ? $column : "$column,"; + } + + return $key; + } + } diff --git a/console/migrations/m160201_055928_oauth.php b/console/migrations/m160201_055928_oauth.php new file mode 100644 index 0000000..bfc71c7 --- /dev/null +++ b/console/migrations/m160201_055928_oauth.php @@ -0,0 +1,77 @@ +createTable('{{%oauth_clients}}', [ + 'id' => $this->string(64), + 'secret' => $this->string()->notNull(), + 'name' => $this->string()->notNull(), + 'description' => $this->string(), + 'redirect_uri' => $this->string()->notNull(), + 'account_id' => $this->getDb()->getTableSchema('{{%accounts}}')->getColumn('id')->dbType, + 'is_trusted' => $this->boolean()->defaultValue(false)->notNull(), + 'created_at' => $this->integer()->notNull(), + $this->primary('id'), + ], $this->tableOptions); + + $this->createTable('{{%oauth_scopes}}', [ + 'id' => $this->string(64), + $this->primary('id'), + ], $this->tableOptions); + + $this->createTable('{{%oauth_sessions}}', [ + 'id' => $this->primaryKey(), + 'owner_type' => $this->string()->notNull(), + 'owner_id' => $this->string()->notNull(), + 'client_id' => $this->getDb()->getTableSchema('{{%oauth_clients}}')->getColumn('id')->dbType, + 'client_redirect_uri' => $this->string(), + ], $this->tableOptions); + + $this->createTable('{{%oauth_access_tokens}}', [ + 'access_token' => $this->string(64), + 'session_id' => $this->getDb()->getTableSchema('{{%oauth_sessions}}')->getColumn('id')->dbType, + 'expire_time' => $this->integer()->notNull(), + $this->primary('access_token'), + ], $this->tableOptions); + + $this->addForeignKey( + 'FK_oauth_client_to_accounts', + '{{%oauth_clients}}', + 'account_id', + '{{%accounts}}', + 'id', + 'CASCADE' + ); + + $this->addForeignKey( + 'FK_oauth_session_to_client', + '{{%oauth_sessions}}', + 'client_id', + '{{%oauth_clients}}', + 'id', + 'CASCADE', + 'CASCADE' + ); + + $this->addForeignKey( + 'FK_oauth_access_toke_to_oauth_session', + '{{%oauth_access_tokens}}', + 'session_id', + '{{%oauth_sessions}}', + 'id', + 'CASCADE', + 'SET NULL' + ); + } + + public function safeDown() { + $this->dropTable('{{%oauth_access_tokens}}'); + $this->dropTable('{{%oauth_sessions}}'); + $this->dropTable('{{%oauth_scopes}}'); + $this->dropTable('{{%oauth_clients}}'); + } + +} diff --git a/environments/dev/api/config/main-local.php b/environments/dev/api/config/main-local.php index d9e3809..4c7c9bc 100644 --- a/environments/dev/api/config/main-local.php +++ b/environments/dev/api/config/main-local.php @@ -6,6 +6,9 @@ $config = [ // !!! insert a secret key in the following (if it is empty) - this is required by cookie validation 'cookieValidationKey' => '', ], + 'reCaptcha' => [ + 'secret' => '', + ], ], ]; diff --git a/environments/dev/common/config/main-local.php b/environments/dev/common/config/main-local.php index 8f3311c..d5bc008 100644 --- a/environments/dev/common/config/main-local.php +++ b/environments/dev/common/config/main-local.php @@ -12,5 +12,11 @@ return [ // for the mailer to send real emails. 'useFileTransport' => true, ], + 'redis' => [ + 'hostname' => 'localhost', + 'password' => null, + 'port' => 6379, + 'database' => 0, + ], ], ]; diff --git a/environments/prod/api/config/main-local.php b/environments/prod/api/config/main-local.php new file mode 100644 index 0000000..9bf259c --- /dev/null +++ b/environments/prod/api/config/main-local.php @@ -0,0 +1,12 @@ + [ + 'request' => [ + // !!! insert a secret key in the following (if it is empty) - this is required by cookie validation + 'cookieValidationKey' => '', + ], + 'reCaptcha' => [ + 'secret' => '', + ], + ], +]; diff --git a/environments/prod/api/config/params-local.php b/environments/prod/api/config/params-local.php new file mode 100644 index 0000000..d0b9c34 --- /dev/null +++ b/environments/prod/api/config/params-local.php @@ -0,0 +1,3 @@ +run(); diff --git a/environments/prod/common/config/main-local.php b/environments/prod/common/config/main-local.php index e4e6d99..6fb5f85 100644 --- a/environments/prod/common/config/main-local.php +++ b/environments/prod/common/config/main-local.php @@ -6,5 +6,11 @@ return [ 'username' => 'root', 'password' => '', ], + 'redis' => [ + 'hostname' => 'localhost', + 'password' => null, + 'port' => 6379, + 'database' => 0, + ], ], ]; diff --git a/tests/codeception/api/_bootstrap.php b/tests/codeception/api/_bootstrap.php index 7a8b56d..2344a50 100644 --- a/tests/codeception/api/_bootstrap.php +++ b/tests/codeception/api/_bootstrap.php @@ -21,3 +21,6 @@ $_SERVER['SERVER_NAME'] = parse_url(\Codeception\Configuration::config()['confi $_SERVER['SERVER_PORT'] = parse_url(\Codeception\Configuration::config()['config']['test_entry_url'], PHP_URL_PORT) ?: '80'; Yii::setAlias('@tests', dirname(dirname(__DIR__))); + +// disable deep cloning of properties inside specify block +\Codeception\Specify\Config::setDeepClone(false); diff --git a/tests/codeception/api/_pages/OauthRoute.php b/tests/codeception/api/_pages/OauthRoute.php new file mode 100644 index 0000000..4356928 --- /dev/null +++ b/tests/codeception/api/_pages/OauthRoute.php @@ -0,0 +1,21 @@ +route = ['oauth/validate']; + $this->actor->sendGET($this->getUrl($queryParams)); + } + + public function complete($queryParams = [], $postParams = []) { + $this->route = ['oauth/complete']; + $this->actor->sendPOST($this->getUrl($queryParams), $postParams); + } + +} diff --git a/tests/codeception/api/functional.suite.yml b/tests/codeception/api/functional.suite.yml index b2bbce8..37e2ef1 100644 --- a/tests/codeception/api/functional.suite.yml +++ b/tests/codeception/api/functional.suite.yml @@ -13,6 +13,11 @@ modules: - Yii2 - tests\codeception\common\_support\FixtureHelper - REST + - Redis config: Yii2: configFile: '../config/api/functional.php' + Redis: + host: localhost + port: 6379 + database: 1 diff --git a/tests/codeception/api/functional/ContactCept.php b/tests/codeception/api/functional/ContactCept.php deleted file mode 100644 index 1558ea4..0000000 --- a/tests/codeception/api/functional/ContactCept.php +++ /dev/null @@ -1,47 +0,0 @@ -wantTo('ensure that contact works'); - -$contactPage = ContactPage::openBy($I); - -$I->see('Contact', 'h1'); - -$I->amGoingTo('submit contact form with no data'); -$contactPage->submit([]); -$I->expectTo('see validations errors'); -$I->see('Contact', 'h1'); -$I->see('Name cannot be blank', '.help-block'); -$I->see('Email cannot be blank', '.help-block'); -$I->see('Subject cannot be blank', '.help-block'); -$I->see('Body cannot be blank', '.help-block'); -$I->see('The verification code is incorrect', '.help-block'); - -$I->amGoingTo('submit contact form with not correct email'); -$contactPage->submit([ - 'name' => 'tester', - 'email' => 'tester.email', - 'subject' => 'test subject', - 'body' => 'test content', - 'verifyCode' => 'testme', -]); -$I->expectTo('see that email adress is wrong'); -$I->dontSee('Name cannot be blank', '.help-block'); -$I->see('Email is not a valid email address.', '.help-block'); -$I->dontSee('Subject cannot be blank', '.help-block'); -$I->dontSee('Body cannot be blank', '.help-block'); -$I->dontSee('The verification code is incorrect', '.help-block'); - -$I->amGoingTo('submit contact form with correct data'); -$contactPage->submit([ - 'name' => 'tester', - 'email' => 'tester@example.com', - 'subject' => 'test subject', - 'body' => 'test content', - 'verifyCode' => 'testme', -]); -$I->see('Thank you for contacting us. We will respond to you as soon as possible.'); diff --git a/tests/codeception/api/functional/OauthCest.php b/tests/codeception/api/functional/OauthCest.php new file mode 100644 index 0000000..bd84ef1 --- /dev/null +++ b/tests/codeception/api/functional/OauthCest.php @@ -0,0 +1,294 @@ +route = new OauthRoute($I); + } + + public function testValidateRequest(FunctionalTester $I) { + $this->testOauthParamsValidation($I, 'validate'); + + $I->wantTo('validate and obtain information about new auth request'); + $this->route->validate($this->buildQueryParams( + 'ely', + 'http://ely.by', + 'code', + [ + 'minecraft_server_session' + ], + 'test-state' + )); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => true, + 'oAuth' => [ + 'client_id' => 'ely', + 'redirect_uri' => 'http://ely.by', + 'response_type' => 'code', + 'scope' => 'minecraft_server_session', + 'state' => 'test-state', + ], + 'client' => [ + 'id' => 'ely', + 'name' => 'Ely.by', + 'description' => 'Всем знакомое елуби', + ], + 'session' => [ + 'scopes' => [ + 'minecraft_server_session', + ], + ], + ]); + } + + public function testValidateWithDescriptionReplaceRequest(FunctionalTester $I) { + $I->wantTo('validate and get information with description replacement'); + $this->route->validate($this->buildQueryParams( + 'ely', + 'http://ely.by', + 'code', + null, + null, + [ + 'description' => 'all familiar eliby', + ] + )); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'client' => [ + 'description' => 'all familiar eliby', + ], + ]); + } + + public function testCompleteValidationAction($I, $scenario) { + $I = new AccountSteps($scenario); + $I->loggedInAsActiveAccount(); + $I->wantTo('validate all oAuth params on complete request'); + $this->testOauthParamsValidation($I, 'complete'); + } + + public function testCompleteActionOnWrongConditions($I, $scenario) { + $I = new AccountSteps($scenario); + $I->loggedInAsActiveAccount(); + + $I->wantTo('get accept_required if I dom\'t require any scope, but this is first time request'); + $I->cleanupRedis(); + $this->route->complete($this->buildQueryParams( + 'ely', + 'http://ely.by', + 'code' + )); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'error' => 'accept_required', + 'parameter' => '', + 'statusCode' => 401, + ]); + + $I->wantTo('get accept_required if I require some scopes on first time'); + $this->route->complete($this->buildQueryParams( + 'ely', + 'http://ely.by', + 'code', + ['minecraft_server_session'] + )); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'error' => 'accept_required', + 'parameter' => '', + 'statusCode' => 401, + ]); + } + + public function testCompleteActionSuccess($I, $scenario) { + $I = new AccountSteps($scenario); + $I->loggedInAsActiveAccount(); + + $I->wantTo('get auth code if I require some scope and pass accept field'); + $this->route->complete($this->buildQueryParams( + 'ely', + 'http://ely.by', + 'code', + ['minecraft_server_session'] + ), ['accept' => true]); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); + + $I->wantTo('get auth code if I don\'t require any scope and don\'t pass accept field, but previously have ' . + 'successful request'); + $this->route->complete($this->buildQueryParams( + 'ely', + 'http://ely.by', + 'code' + )); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); + + $I->wantTo('get auth code if I require some scopes and don\'t pass accept field, but previously have successful ' . + 'request with same scopes'); + $this->route->complete($this->buildQueryParams( + 'ely', + 'http://ely.by', + 'code', + ['minecraft_server_session'] + )); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); + } + + public function testAcceptRequiredOnNewScope($I, $scenario) { + $I = new AccountSteps($scenario); + $I->loggedInAsActiveAccount(); + $I->wantTo('get accept_required if I have previous successful request, but now require some new scope'); + $this->route->complete($this->buildQueryParams( + 'ely', + 'http://ely.by', + 'code', + ['minecraft_server_session'] + ), ['accept' => true]); + $this->route->complete($this->buildQueryParams( + 'ely', + 'http://ely.by', + 'code', + ['minecraft_server_session', 'change_skin'] + )); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'error' => 'accept_required', + 'parameter' => '', + 'statusCode' => 401, + ]); + } + + public function testCompleteActionWithDismissState($I, $scenario) { + $I = new AccountSteps($scenario); + $I->loggedInAsActiveAccount(); + $I->wantTo('get access_denied error if I pass accept in false state'); + $this->route->complete($this->buildQueryParams( + 'ely', + 'http://ely.by', + 'code', + ['minecraft_server_session'] + ), ['accept' => false]); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'error' => 'access_denied', + 'parameter' => '', + 'statusCode' => 401, + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); + } + + private function buildQueryParams( + $clientId = null, + $redirectUri = null, + $responseType = null, + $scopes = [], + $state = null, + $customData = [] + ) { + $params = $customData; + if ($clientId !== null) { + $params['client_id'] = $clientId; + } + + if ($redirectUri !== null) { + $params['redirect_uri'] = $redirectUri; + } + + if ($responseType !== null) { + $params['response_type'] = $responseType; + } + + if ($state !== null) { + $params['state'] = $state; + } + + if (!empty($scopes)) { + if (is_array($scopes)) { + $scopes = implode(',', $scopes); + } + + $params['scope'] = $scopes; + } + + return $params; + } + + private function testOauthParamsValidation(FunctionalTester $I, $action) { + $I->wantTo('check behavior on invalid request without one or few params'); + $this->route->$action($this->buildQueryParams()); + $I->canSeeResponseCodeIs(400); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'error' => 'invalid_request', + 'parameter' => 'client_id', + 'statusCode' => 400, + ]); + + $I->wantTo('check behavior on invalid client id'); + $this->route->$action($this->buildQueryParams('non-exists-client', 'http://some-resource.by', 'code')); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'error' => 'invalid_client', + 'statusCode' => 401, + ]); + + $I->wantTo('check behavior on invalid response type'); + $this->route->$action($this->buildQueryParams('ely', 'http://ely.by', 'kitty')); + $I->canSeeResponseCodeIs(400); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'error' => 'unsupported_response_type', + 'parameter' => 'kitty', + 'statusCode' => 400, + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); + + $I->wantTo('check behavior on some invalid scopes'); + $this->route->$action($this->buildQueryParams('ely', 'http://ely.by', 'code', [ + 'minecraft_server_session', + 'some_wrong_scope', + ])); + $I->canSeeResponseCodeIs(400); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'error' => 'invalid_scope', + 'parameter' => 'some_wrong_scope', + 'statusCode' => 400, + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); + } + +} diff --git a/tests/codeception/api/functional/_bootstrap.php b/tests/codeception/api/functional/_bootstrap.php index b89a993..aed4dc6 100644 --- a/tests/codeception/api/functional/_bootstrap.php +++ b/tests/codeception/api/functional/_bootstrap.php @@ -1,3 +1,5 @@ login('Admin', 'password_0'); + $I->canSeeResponseIsJson(); + } + +} diff --git a/tests/codeception/api/unit/models/ContactFormTest.php b/tests/codeception/api/unit/models/ContactFormTest.php deleted file mode 100644 index 775a553..0000000 --- a/tests/codeception/api/unit/models/ContactFormTest.php +++ /dev/null @@ -1,59 +0,0 @@ -mailer->fileTransportCallback = function ($mailer, $message) { - return 'testing_message.eml'; - }; - } - - protected function tearDown() - { - unlink($this->getMessageFile()); - parent::tearDown(); - } - - public function testContact() - { - $model = new ContactForm(); - - $model->attributes = [ - 'name' => 'Tester', - 'email' => 'tester@example.com', - 'subject' => 'very important letter subject', - 'body' => 'body of current message', - ]; - - $model->sendEmail('admin@example.com'); - - $this->specify('email should be send', function () { - expect('email file should exist', file_exists($this->getMessageFile()))->true(); - }); - - $this->specify('message should contain correct data', function () use ($model) { - $emailMessage = file_get_contents($this->getMessageFile()); - - expect('email should contain user name', $emailMessage)->contains($model->name); - expect('email should contain sender email', $emailMessage)->contains($model->email); - expect('email should contain subject', $emailMessage)->contains($model->subject); - expect('email should contain body', $emailMessage)->contains($model->body); - }); - } - - private function getMessageFile() - { - return Yii::getAlias(Yii::$app->mailer->fileTransportPath) . '/testing_message.eml'; - } -} diff --git a/tests/codeception/api/unit/models/PasswordResetRequestFormTest.php b/tests/codeception/api/unit/models/PasswordResetRequestFormTest.php deleted file mode 100644 index dabdd9c..0000000 --- a/tests/codeception/api/unit/models/PasswordResetRequestFormTest.php +++ /dev/null @@ -1,87 +0,0 @@ -mailer->fileTransportCallback = function ($mailer, $message) { - return 'testing_message.eml'; - }; - } - - protected function tearDown() - { - @unlink($this->getMessageFile()); - - parent::tearDown(); - } - - public function testSendEmailWrongUser() - { - $this->specify('no user with such email, message should not be sent', function () { - - $model = new PasswordResetRequestForm(); - $model->email = 'not-existing-email@example.com'; - - expect('email not sent', $model->sendEmail())->false(); - - }); - - $this->specify('user is not active, message should not be sent', function () { - - $model = new PasswordResetRequestForm(); - $model->email = $this->user[1]['email']; - - expect('email not sent', $model->sendEmail())->false(); - - }); - } - - public function testSendEmailCorrectUser() - { - $model = new PasswordResetRequestForm(); - $model->email = $this->user[0]['email']; - $user = Account::findOne(['password_reset_token' => $this->user[0]['password_reset_token']]); - - expect('email sent', $model->sendEmail())->true(); - expect('user has valid token', $user->password_reset_token)->notNull(); - - $this->specify('message has correct format', function () use ($model) { - - expect('message file exists', file_exists($this->getMessageFile()))->true(); - - $message = file_get_contents($this->getMessageFile()); - expect('message "from" is correct', $message)->contains(Yii::$app->params['supportEmail']); - expect('message "to" is correct', $message)->contains($model->email); - - }); - } - - public function fixtures() - { - return [ - 'user' => [ - 'class' => UserFixture::className(), - 'dataFile' => '@tests/codeception/api/unit/fixtures/data/models/user.php' - ], - ]; - } - - private function getMessageFile() - { - return Yii::getAlias(Yii::$app->mailer->fileTransportPath) . '/testing_message.eml'; - } -} diff --git a/tests/codeception/api/unit/models/ResetPasswordFormTest.php b/tests/codeception/api/unit/models/ResetPasswordFormTest.php deleted file mode 100644 index b3ab142..0000000 --- a/tests/codeception/api/unit/models/ResetPasswordFormTest.php +++ /dev/null @@ -1,43 +0,0 @@ -user[0]['password_reset_token']); - expect('password should be resetted', $form->resetPassword())->true(); - } - - public function fixtures() - { - return [ - 'user' => [ - 'class' => UserFixture::className(), - 'dataFile' => '@tests/codeception/api/unit/fixtures/data/models/user.php' - ], - ]; - } -} diff --git a/tests/codeception/common/_support/FixtureHelper.php b/tests/codeception/common/_support/FixtureHelper.php index 7b4bdda..c413a84 100644 --- a/tests/codeception/common/_support/FixtureHelper.php +++ b/tests/codeception/common/_support/FixtureHelper.php @@ -4,6 +4,9 @@ namespace tests\codeception\common\_support; use Codeception\Module; use tests\codeception\common\fixtures\AccountFixture; 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 yii\test\FixtureTrait; use yii\test\InitDbFixture; @@ -26,35 +29,20 @@ class FixtureHelper extends Module { getFixture as protected; } - /** - * Method called before any suite tests run. Loads User fixture login user - * to use in functional tests. - * - * @param array $settings - */ public function _beforeSuite($settings = []) { $this->loadFixtures(); } - /** - * Method is called after all suite tests run - */ public function _afterSuite() { $this->unloadFixtures(); } - /** - * @inheritdoc - */ public function globalFixtures() { return [ InitDbFixture::className(), ]; } - /** - * @inheritdoc - */ public function fixtures() { return [ 'accounts' => [ @@ -65,6 +53,19 @@ class FixtureHelper extends Module { 'class' => EmailActivationFixture::class, 'dataFile' => '@tests/codeception/common/fixtures/data/email-activations.php', ], + 'oauthClients' => [ + '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/OauthClientFixture.php b/tests/codeception/common/fixtures/OauthClientFixture.php new file mode 100644 index 0000000..e6dee18 --- /dev/null +++ b/tests/codeception/common/fixtures/OauthClientFixture.php @@ -0,0 +1,15 @@ + [ + 'id' => 'ely', + 'secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + 'name' => 'Ely.by', + 'description' => 'Всем знакомое елуби', + 'redirect_uri' => 'http://ely.by', + 'account_id' => NULL, + 'is_trusted' => 0, + 'created_at' => 1455309271, + ], + 'tlauncher' => [ + 'id' => 'tlauncher', + 'secret' => 'HsX-xXzdGiz3mcsqeEvrKHF47sqiaX94', + 'name' => 'TLauncher', + 'description' => 'Лучший альтернативный лаунчер для Minecraft с большим количеством версий и их модификаций, а также возмоностью входа как с лицензионным аккаунтом, так и без него.', + 'redirect_uri' => '', + 'account_id' => NULL, + 'is_trusted' => 0, + 'created_at' => 1455318468, + ], +]; diff --git a/tests/codeception/common/fixtures/data/oauth-scopes.php b/tests/codeception/common/fixtures/data/oauth-scopes.php new file mode 100644 index 0000000..dc2ef34 --- /dev/null +++ b/tests/codeception/common/fixtures/data/oauth-scopes.php @@ -0,0 +1,9 @@ + [ + 'id' => 'minecraft_server_session', + ], + 'change_skin' => [ + 'id' => 'change_skin', + ], +]; diff --git a/tests/codeception/common/fixtures/data/oauth-sessions.php b/tests/codeception/common/fixtures/data/oauth-sessions.php new file mode 100644 index 0000000..d0b9c34 --- /dev/null +++ b/tests/codeception/common/fixtures/data/oauth-sessions.php @@ -0,0 +1,3 @@ + [ 'showScriptName' => true, ], + 'redis' => [ + 'database' => 1, + ], ], ];