diff --git a/.env-dist b/.env-dist index 683a034..acc5b32 100644 --- a/.env-dist +++ b/.env-dist @@ -1,9 +1,44 @@ -# Основные параметры +# Параметры приложения +## Env приложения YII_DEBUG=true YII_ENV=dev + +## Параметры, отвечающие за безопасность JWT_USER_SECRET= + +## Внешние сервисы RECAPTCHA_PUBLIC= RECAPTCHA_SECRET= +SENTRY_DSN= + +## SMTP параметры +SMTP_USER= +SMTP_PASS= +SMTP_PORT= + +## Параметры подключения к базе данных +DB_HOST=db +DB_DATABASE=ely_accounts +DB_USER=ely_accounts_user +DB_PASSWORD=ely_accounts_password + +## Параметры подключения к redis +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_DATABASE=0 +REDIS_PASSWORD= + +## Параметры подключения к rabbitmq +RABBITMQ_HOST=rabbitmq +RABBITMQ_PORT=5672 +RABBITMQ_USER=ely-accounts-app +RABBITMQ_PASS=ely-accounts-app-password +RABBITMQ_VHOST=/ely.by + +## Конфигурация для Dev. +XDEBUG_CONFIG=remote_host=10.254.254.254 +PHP_IDE_CONFIG=serverName=docker + # Web VIRTUAL_HOST=account.ely.by,authserver.ely.by @@ -11,10 +46,6 @@ AUTHSERVER_HOST=authserver.ely.by # LETSENCRYPT_HOST=account.ely.by # LETSENCRYPT_EMAIL=erickskrauch@ely.by -# SMTP (только для production) -SMTP_USER= -SMTP_PASS= - # MySQL MYSQL_ALLOW_EMPTY_PASSWORD=yes MYSQL_ROOT_PASSWORD= @@ -26,7 +57,3 @@ MYSQL_PASSWORD=ely_accounts_password RABBITMQ_DEFAULT_USER=ely-accounts-app RABBITMQ_DEFAULT_PASS=ely-accounts-app-password RABBITMQ_DEFAULT_VHOST=/ely.by - -# Конфигурация для Dev. -XDEBUG_CONFIG=remote_host=10.254.254.254 -PHP_IDE_CONFIG=serverName=docker diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8f832ae..f6b5827 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -4,21 +4,40 @@ stages: - release variables: - CONTAINER_IMAGE: registry.ely.by/elyby/accounts + DOCKER_DRIVER: aufs + CONTAINER_IMAGE: "registry.ely.by/elyby/accounts" test:backend: - image: jonaskello/docker-and-compose:1.12.1-1.8.0 + image: docker:latest services: - - docker:1.12.1-dind + - mariadb:10.0 + - redis:3.0-alpine + variables: + # mariadb config + MYSQL_RANDOM_ROOT_PASSWORD: "true" + MYSQL_DATABASE: "ely_accounts_test" + MYSQL_USER: "ely_accounts_tester" + MYSQL_PASSWORD: "ely_accounts_tester_password" stage: test before_script: - docker login -u gitlab-ci -p $CI_BUILD_TOKEN registry.ely.by - echo "$SSH_PRIVATE_KEY" > id_rsa - - docker-compose -f tests/docker-compose.yml build --pull testphp - after_script: - - docker-compose -f tests/docker-compose.yml down -v script: - - docker-compose -f tests/docker-compose.yml run --rm testphp ./vendor/bin/codecept run -c tests + - export TEMP_DEV_IMAGE="${CONTAINER_IMAGE}:ci-${CI_BUILD_ID}" + - docker build --pull -f Dockerfile-dev -t $TEMP_DEV_IMAGE . + - > + docker run --rm + --add-host=mariadb:`getent hosts mariadb | awk '{ print $1 ; exit }'` + --add-host=redis:`getent hosts redis | awk '{ print $1 ; exit }'` + -e YII_DEBUG="true" + -e YII_ENV="test" + -e DB_HOST="mariadb" + -e DB_DATABASE="ely_accounts_test" + -e DB_USER="ely_accounts_tester" + -e DB_PASSWORD="ely_accounts_tester_password" + -e REDIS_HOST="redis" + $TEMP_DEV_IMAGE + php vendor/bin/codecept run -c tests test:frontend: image: node:5.12 @@ -28,8 +47,8 @@ test:frontend: - frontend/node_modules script: - cd frontend - - npm i --silent - - npm run test + - npm i --silent > /dev/null + - npm run test --silent build:production: image: docker:latest diff --git a/Dockerfile b/Dockerfile index ef80df9..d76207d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,8 @@ -FROM registry.ely.by/elyby/accounts-php:1.0.0 +FROM registry.ely.by/elyby/accounts-php:1.2.0 + +# Вносим конфигурации для крона и воркеров +COPY docker/cron/* /etc/cron.d/ +COPY docker/supervisor/* /etc/supervisor/conf.d/ COPY id_rsa /root/.ssh/id_rsa @@ -7,7 +11,7 @@ RUN chmod 400 ~/.ssh/id_rsa \ && eval $(ssh-agent -s) \ && ssh-add /root/.ssh/id_rsa \ && touch /root/.ssh/known_hosts \ - && ssh-keyscan gitlab.com >> /root/.ssh/known_hosts + && ssh-keyscan gitlab.com gitlab.ely.by >> /root/.ssh/known_hosts # Копируем composer.json в родительскую директорию, которая не будет синкаться с хостом через # volume на dev окружении. В entrypoint эта папка будет скопирована обратно. @@ -28,7 +32,7 @@ COPY ./frontend/scripts /var/www/frontend/scripts COPY ./frontend/webpack-utils /var/www/frontend/webpack-utils RUN cd ../frontend \ - && npm install \ + && npm install --quiet --depth -1 \ && cd - # Удаляем ключи из production контейнера на всякий случай @@ -42,7 +46,7 @@ RUN mkdir -p api/runtime api/web/assets console/runtime \ # Билдим фронт && cd frontend \ && ln -s /var/www/frontend/node_modules $PWD/node_modules \ - && npm run build \ + && npm run build:quite --quiet \ && rm node_modules \ # Копируем билд наружу, чтобы его не затёрло volume в dev режиме && cp -r ./dist /var/www/dist \ diff --git a/Dockerfile-dev b/Dockerfile-dev index e3e5ebb..ef963fb 100644 --- a/Dockerfile-dev +++ b/Dockerfile-dev @@ -1,4 +1,8 @@ -FROM registry.ely.by/elyby/accounts-php:1.0.0-dev +FROM registry.ely.by/elyby/accounts-php:1.2.0-dev + +# Вносим конфигурации для крона и воркеров +COPY docker/cron/* /etc/cron.d/ +COPY docker/supervisor/* /etc/supervisor/conf.d/ COPY id_rsa /root/.ssh/id_rsa @@ -7,7 +11,7 @@ RUN chmod 400 ~/.ssh/id_rsa \ && eval $(ssh-agent -s) \ && ssh-add /root/.ssh/id_rsa \ && touch /root/.ssh/known_hosts \ - && ssh-keyscan gitlab.com >> /root/.ssh/known_hosts + && ssh-keyscan gitlab.com gitlab.ely.by >> /root/.ssh/known_hosts # Копируем composer.json в родительскую директорию, которая не будет синкаться с хостом через # volume на dev окружении. В entrypoint эта папка будет скопирована обратно. @@ -28,7 +32,7 @@ COPY ./frontend/scripts /var/www/frontend/scripts COPY ./frontend/webpack-utils /var/www/frontend/webpack-utils RUN cd ../frontend \ - && npm install \ + && npm install --quiet --depth -1 \ && cd - # Наконец переносим все сорцы внутрь контейнера @@ -39,7 +43,7 @@ RUN mkdir -p api/runtime api/web/assets console/runtime \ # Билдим фронт && cd frontend \ && ln -s /var/www/frontend/node_modules $PWD/node_modules \ - && npm run build \ + && npm run build:quite --quiet \ && rm node_modules \ # Копируем билд наружу, чтобы его не затёрло volume в dev режиме && cp -r ./dist /var/www/dist \ diff --git a/README.md b/README.md index f96d4ad..1f0aeab 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Accounts Ely.by -## Развёртывание dev +## Развёртывание dev [backend] Предварительно нужно установить [git](https://git-scm.com/downloads), [docker](https://docs.docker.com/engine/installation/) и его @@ -15,8 +15,8 @@ За тем сливаем репозиторий: ```sh -git clone git@gitlab.com:elyby/account.git account.ely.by -cd account.ely.by.local +git clone git@gitlab.ely.by:elyby/accounts.git account.ely.by +cd account.ely.by ``` Далее нужно создать `.env`, `docker-compose.yml` и `id_rsa` файлы: @@ -27,12 +27,12 @@ cp docker-compose.dev.yml docker-compose.yml cp ~/.ssh/id_rsa id_rsa # Использовать ссылку нельзя ``` -Касательно файла id_rsa: часть зависимостей находятся в наших приватных репозиториях, получить +**Касательно файла id_rsa**: часть зависимостей находятся в наших приватных репозиториях, получить доступ куда можно только в том случае, если в контейнере окажется ключ, который имеет доступ к этим репозиториям. -Все вышеперечисленные файла находятся под gitignore, так что с полученными файлами можно произвести -все необходимые манипуляции под конкретный кейс использования. **В файле `.env` обязательно следует +Все вышеперечисленные файлы находятся под gitignore, так что с конечными файлами можно произвести +все необходимые манипуляции под конкретную задачу разработки. **В файле `.env` обязательно следует задать `JWT_USER_SECRET`, иначе авторизация на бекенде не заработает.** После этого просто выполняем старт всех контейнеров: @@ -41,10 +41,50 @@ cp ~/.ssh/id_rsa id_rsa # Использовать ссылку нельзя docker-compose up -d ``` -Они автоматически сбилдятся и начнут свою работу. +Контейнеры автоматически сбилдятся и начнут свою работу. + +## Развёртывание dev [frontend] + +Чтобы поднять сборку frontend приложения, необходимо иметь установленный в системе [Node.js](https://nodejs.org) +версии 5.x или 6.x, а так же npm 3-ей версии (`npm i -g npm` для обновления). + +За тем переходим в папку `frontend` и устанавливаем зависимости: + +```sh +cd frontend +npm i +``` + +После того, как все зависимости будут установлены, можно поднять dev-сервер. Здесь есть 2 пути: можно, следуя +инструкции выше, поднять backend на своей машине через Docker. Если же разработка не привязывается к специфичной +версии backend, то более быстрым и удобным способ будет использовать наш dev-сервер, расположенный под адресу +https://dev.account.ely.by. + +В любом из случаев необходимо в папке `frontend/config` скопировать файл `template.env.js` в `env.js` (находится +под .gitignore) и указать в параметре `apiHost` или свой локальный сервер (тот хост, что был указан в .env +как `VIRTUAL_HOST`), или указав просто `https://dev.account.ely.by`. + +После того, как это будет сделано, запускаем dev-сервер (находясь в папке frontend): + +``` +npm start +``` + +dev-сервер поднимется на 8080 порту и будет доступен по адресу http://localhost:8080. ### Как влезть в работающий контейнер +Начиная с версии docker-compose 1.9.0, появилась команда `docker-compose exec`, которая позволяет выполнить +на работающем контейнере произвольную команду, основываясь на имени сервиса в compose файле. + +``` +docker-compose exec app bash +``` + +------------------------ + +_// Старый вариант_ + Сперва, с помощью команды `docker ps` мы увидим все запущенные контейнеры. Нас интересуют значения из первой колонки CONTAINER ID или NAMES. Узнать, чему они соответствуют можно прочитав название IMAGE из 2 колонки. Чтобы выполнить команду внутри работабщего контейнера, нужно выполнить: diff --git a/api/components/ApiUser/AuthChecker.php b/api/components/ApiUser/AuthChecker.php index ef3dae5..59f6fa9 100644 --- a/api/components/ApiUser/AuthChecker.php +++ b/api/components/ApiUser/AuthChecker.php @@ -1,7 +1,7 @@ oauth->getAuthServer()->getAccessTokenStorage()->get($token); if ($accessToken === null) { return false; } - return $accessToken->getScopes()->exists($permissionName); + return $accessToken->hasScope($permissionName); } } diff --git a/api/components/ApiUser/Identity.php b/api/components/ApiUser/Identity.php index 3000b93..fb3510d 100644 --- a/api/components/ApiUser/Identity.php +++ b/api/components/ApiUser/Identity.php @@ -1,24 +1,25 @@ oauth->getAuthServer()->getAccessTokenStorage()->get($token); if ($model === null) { throw new UnauthorizedHttpException('Incorrect token'); } elseif ($model->isExpired()) { @@ -37,7 +37,7 @@ class Identity implements IdentityInterface { return new static($model); } - private function __construct(OauthAccessToken $accessToken) { + private function __construct(AccessTokenEntity $accessToken) { $this->_accessToken = $accessToken; } @@ -50,20 +50,20 @@ class Identity implements IdentityInterface { } public function getSession() : OauthSession { - return $this->_accessToken->session; + return OauthSession::findOne($this->_accessToken->getSessionId()); } - public function getAccessToken() : OauthAccessToken { + public function getAccessToken() : AccessTokenEntity { return $this->_accessToken; } /** - * Этот метод используется для получения пользователя, к которому привязаны права. + * Этот метод используется для получения токена, к которому привязаны права. * У нас права привязываются к токенам, так что возвращаем именно его id. * @inheritdoc */ public function getId() { - return $this->_accessToken->access_token; + return $this->_accessToken->getId(); } public function getAuthKey() { diff --git a/common/components/oauth/Component.php b/api/components/OAuth2/Component.php similarity index 57% rename from common/components/oauth/Component.php rename to api/components/OAuth2/Component.php index 6bd3091..940e29b 100644 --- a/common/components/oauth/Component.php +++ b/api/components/OAuth2/Component.php @@ -1,13 +1,13 @@ _authServer === null) { $authServer = new AuthorizationServer(); - $authServer - ->setAccessTokenStorage(new AccessTokenStorage()) - ->setClientStorage(new ClientStorage()) - ->setScopeStorage(new ScopeStorage()) - ->setSessionStorage(new SessionStorage()) - ->setAuthCodeStorage(new AuthCodeStorage()) - ->setRefreshTokenStorage(new RefreshTokenStorage()) - ->setScopeDelimiter(','); + $authServer->setAccessTokenStorage(new AccessTokenStorage()); + $authServer->setClientStorage(new ClientStorage()); + $authServer->setScopeStorage(new ScopeStorage()); + $authServer->setSessionStorage(new SessionStorage()); + $authServer->setAuthCodeStorage(new AuthCodeStorage()); + $authServer->setRefreshTokenStorage(new RefreshTokenStorage()); + $authServer->setScopeDelimiter(','); $this->_authServer = $authServer; foreach ($this->grantTypes as $grantType) { - if (!array_key_exists($grantType, $this->grantMap)) { + if (!isset($this->grantMap[$grantType])) { throw new InvalidConfigException('Invalid grant type'); } + /** @var Grant\GrantTypeInterface $grant */ $grant = new $this->grantMap[$grantType](); $this->_authServer->addGrantType($grant); } diff --git a/api/components/OAuth2/Entities/AccessTokenEntity.php b/api/components/OAuth2/Entities/AccessTokenEntity.php new file mode 100644 index 0000000..183704d --- /dev/null +++ b/api/components/OAuth2/Entities/AccessTokenEntity.php @@ -0,0 +1,44 @@ +sessionId; + } + + public function setSessionId($sessionId) { + $this->sessionId = $sessionId; + } + + /** + * @inheritdoc + * @return static + */ + public function setSession(OriginalSessionEntity $session) { + parent::setSession($session); + $this->sessionId = $session->getId(); + + return $this; + } + + public function getSession() { + if ($this->session instanceof OriginalSessionEntity) { + return $this->session; + } + + $sessionStorage = $this->server->getSessionStorage(); + if (!$sessionStorage instanceof SessionStorage) { + throw new ErrorException('SessionStorage must be instance of ' . SessionStorage::class); + } + + return $sessionStorage->getById($this->sessionId); + } + +} diff --git a/common/components/oauth/Entity/AccessTokenEntity.php b/api/components/OAuth2/Entities/AuthCodeEntity.php similarity index 66% rename from common/components/oauth/Entity/AccessTokenEntity.php rename to api/components/OAuth2/Entities/AuthCodeEntity.php index bd70930..28bfc2b 100644 --- a/common/components/oauth/Entity/AccessTokenEntity.php +++ b/api/components/OAuth2/Entities/AuthCodeEntity.php @@ -1,11 +1,9 @@ sessionId = $sessionId; + } + } diff --git a/api/components/OAuth2/Entities/ClientEntity.php b/api/components/OAuth2/Entities/ClientEntity.php new file mode 100644 index 0000000..8636cf1 --- /dev/null +++ b/api/components/OAuth2/Entities/ClientEntity.php @@ -0,0 +1,22 @@ +id = $id; + } + + public function setName(string $name) { + $this->name = $name; + } + + public function setSecret(string $secret) { + $this->secret = $secret; + } + + public function setRedirectUri($redirectUri) { + $this->redirectUri = $redirectUri; + } + +} diff --git a/api/components/OAuth2/Entities/RefreshTokenEntity.php b/api/components/OAuth2/Entities/RefreshTokenEntity.php new file mode 100644 index 0000000..2404fa7 --- /dev/null +++ b/api/components/OAuth2/Entities/RefreshTokenEntity.php @@ -0,0 +1,44 @@ +session instanceof SessionEntity) { + return $this->session; + } + + $sessionStorage = $this->server->getSessionStorage(); + if (!$sessionStorage instanceof SessionStorage) { + throw new ErrorException('SessionStorage must be instance of ' . SessionStorage::class); + } + + return $sessionStorage->getById($this->sessionId); + } + + public function getSessionId() : int { + return $this->sessionId; + } + + public function setSession(OriginalSessionEntity $session) { + parent::setSession($session); + $this->setSessionId($session->getId()); + + return $this; + } + + public function setSessionId(int $sessionId) { + $this->sessionId = $sessionId; + } + +} diff --git a/api/components/OAuth2/Entities/ScopeEntity.php b/api/components/OAuth2/Entities/ScopeEntity.php new file mode 100644 index 0000000..7b9f3c0 --- /dev/null +++ b/api/components/OAuth2/Entities/ScopeEntity.php @@ -0,0 +1,10 @@ +id = $id; + } + +} diff --git a/common/components/oauth/Entity/SessionEntity.php b/api/components/OAuth2/Entities/SessionEntity.php similarity index 57% rename from common/components/oauth/Entity/SessionEntity.php rename to api/components/OAuth2/Entities/SessionEntity.php index 28fafb5..eea6fb3 100644 --- a/common/components/oauth/Entity/SessionEntity.php +++ b/api/components/OAuth2/Entities/SessionEntity.php @@ -1,7 +1,7 @@ clientId; } - /** - * @inheritdoc - * @return static - */ - public function associateClient(ClientEntity $client) { + public function associateClient(OriginalClientEntity $client) { parent::associateClient($client); $this->clientId = $client->getId(); return $this; } + public function setClientId(string $clientId) { + $this->clientId = $clientId; + } + } diff --git a/common/components/oauth/Exception/AcceptRequiredException.php b/api/components/OAuth2/Exception/AcceptRequiredException.php similarity index 89% rename from common/components/oauth/Exception/AcceptRequiredException.php rename to api/components/OAuth2/Exception/AcceptRequiredException.php index 36c5bf0..540650c 100644 --- a/common/components/oauth/Exception/AcceptRequiredException.php +++ b/api/components/OAuth2/Exception/AcceptRequiredException.php @@ -1,5 +1,5 @@ server); + } + + protected function createRefreshTokenEntity() { + return new Entities\RefreshTokenEntity($this->server); + } + + protected function createSessionEntity() { + return new Entities\SessionEntity($this->server); + } + +} diff --git a/api/components/OAuth2/Grants/RefreshTokenGrant.php b/api/components/OAuth2/Grants/RefreshTokenGrant.php new file mode 100644 index 0000000..d98b3d6 --- /dev/null +++ b/api/components/OAuth2/Grants/RefreshTokenGrant.php @@ -0,0 +1,150 @@ +server); + } + + protected function createRefreshTokenEntity() { + return new Entities\RefreshTokenEntity($this->server); + } + + protected function createSessionEntity() { + return new Entities\SessionEntity($this->server); + } + + /** + * Метод таки пришлось переписать по той причине, что нынче мы храним access_token в redis с expire значением, + * так что он может банально несуществовать на тот момент, когда к нему через refresh_token попытаются обратиться. + * Поэтому мы расширили логику RefreshTokenEntity и она теперь знает о сессии, в рамках которой была создана + * + * @inheritdoc + */ + public function completeFlow() { + $clientId = $this->server->getRequest()->request->get('client_id', $this->server->getRequest()->getUser()); + if (is_null($clientId)) { + throw new Exception\InvalidRequestException('client_id'); + } + + $clientSecret = $this->server->getRequest()->request->get( + 'client_secret', + $this->server->getRequest()->getPassword() + ); + if ($this->shouldRequireClientSecret() && is_null($clientSecret)) { + throw new Exception\InvalidRequestException('client_secret'); + } + + // Validate client ID and client secret + $client = $this->server->getClientStorage()->get( + $clientId, + $clientSecret, + null, + $this->getIdentifier() + ); + + if (($client instanceof OriginalClientEntity) === false) { + $this->server->getEventEmitter()->emit(new Event\ClientAuthenticationFailedEvent($this->server->getRequest())); + throw new Exception\InvalidClientException(); + } + + $oldRefreshTokenParam = $this->server->getRequest()->request->get('refresh_token', null); + if ($oldRefreshTokenParam === null) { + throw new Exception\InvalidRequestException('refresh_token'); + } + + // Validate refresh token + $oldRefreshToken = $this->server->getRefreshTokenStorage()->get($oldRefreshTokenParam); + if (($oldRefreshToken instanceof OriginalRefreshTokenEntity) === false) { + throw new Exception\InvalidRefreshException(); + } + + // Ensure the old refresh token hasn't expired + if ($oldRefreshToken->isExpired()) { + throw new Exception\InvalidRefreshException(); + } + + /** @var Entities\AccessTokenEntity|null $oldAccessToken */ + $oldAccessToken = $oldRefreshToken->getAccessToken(); + if ($oldAccessToken instanceof Entities\AccessTokenEntity) { + // Get the scopes for the original session + $session = $oldAccessToken->getSession(); + } else { + if (!$oldRefreshToken instanceof Entities\RefreshTokenEntity) { + throw new ErrorException('oldRefreshToken must be instance of ' . Entities\RefreshTokenEntity::class); + } + + $session = $oldRefreshToken->getSession(); + } + + $scopes = $this->formatScopes($session->getScopes()); + + // Get and validate any requested scopes + $requestedScopesString = $this->server->getRequest()->request->get('scope', ''); + $requestedScopes = $this->validateScopes($requestedScopesString, $client); + + // If no new scopes are requested then give the access token the original session scopes + if (count($requestedScopes) === 0) { + $newScopes = $scopes; + } else { + // The OAuth spec says that a refreshed access token can have the original scopes or fewer so ensure + // the request doesn't include any new scopes + foreach ($requestedScopes as $requestedScope) { + if (!isset($scopes[$requestedScope->getId()])) { + throw new Exception\InvalidScopeException($requestedScope->getId()); + } + } + + $newScopes = $requestedScopes; + } + + // Generate a new access token and assign it the correct sessions + $newAccessToken = $this->createAccessTokenEntity(); + $newAccessToken->setId(SecureKey::generate()); + $newAccessToken->setExpireTime($this->getAccessTokenTTL() + time()); + $newAccessToken->setSession($session); + + foreach ($newScopes as $newScope) { + $newAccessToken->associateScope($newScope); + } + + // Expire the old token and save the new one + ($oldAccessToken instanceof Entities\AccessTokenEntity) && $oldAccessToken->expire(); + $newAccessToken->save(); + + $this->server->getTokenType()->setSession($session); + $this->server->getTokenType()->setParam('access_token', $newAccessToken->getId()); + $this->server->getTokenType()->setParam('expires_in', $this->getAccessTokenTTL()); + + if ($this->shouldRotateRefreshTokens()) { + // Expire the old refresh token + $oldRefreshToken->expire(); + + // Generate a new refresh token + $newRefreshToken = $this->createRefreshTokenEntity(); + $newRefreshToken->setId(SecureKey::generate()); + $newRefreshToken->setExpireTime($this->getRefreshTokenTTL() + time()); + $newRefreshToken->setAccessToken($newAccessToken); + $newRefreshToken->save(); + + $this->server->getTokenType()->setParam('refresh_token', $newRefreshToken->getId()); + } else { + $oldRefreshToken->setAccessToken($newAccessToken); + $oldRefreshToken->save(); + } + + return $this->server->getTokenType()->generateResponse(); + } + +} diff --git a/api/components/OAuth2/Storage/AccessTokenStorage.php b/api/components/OAuth2/Storage/AccessTokenStorage.php new file mode 100644 index 0000000..fdeb14c --- /dev/null +++ b/api/components/OAuth2/Storage/AccessTokenStorage.php @@ -0,0 +1,67 @@ +dataTable, $token))->getValue()); + + $token = new AccessTokenEntity($this->server); + $token->setId($result['id']); + $token->setExpireTime($result['expire_time']); + $token->setSessionId($result['session_id']); + + return $token; + } + + public function getScopes(OriginalAccessTokenEntity $token) { + $scopes = $this->scopes($token->getId()); + $entities = []; + foreach($scopes as $scope) { + if ($this->server->getScopeStorage()->get($scope) !== null) { + $entities[] = (new ScopeEntity($this->server))->hydrate(['id' => $scope]); + } + } + + return $entities; + } + + public function create($token, $expireTime, $sessionId) { + $payload = Json::encode([ + 'id' => $token, + 'expire_time' => $expireTime, + 'session_id' => $sessionId, + ]); + + $this->key($token)->setValue($payload)->expireAt($expireTime); + } + + public function associateScope(OriginalAccessTokenEntity $token, ScopeEntity $scope) { + $this->scopes($token->getId())->add($scope->getId())->expireAt($token->getExpireTime()); + } + + public function delete(OriginalAccessTokenEntity $token) { + $this->key($token->getId())->delete(); + $this->scopes($token->getId())->delete(); + } + + private function key(string $token) : Key { + return new Key($this->dataTable, $token); + } + + private function scopes(string $token) : Set { + return new Set($this->dataTable, $token, 'scopes'); + } + +} diff --git a/api/components/OAuth2/Storage/AuthCodeStorage.php b/api/components/OAuth2/Storage/AuthCodeStorage.php new file mode 100644 index 0000000..77d7f51 --- /dev/null +++ b/api/components/OAuth2/Storage/AuthCodeStorage.php @@ -0,0 +1,72 @@ +dataTable, $code))->getValue()); + if ($result === null) { + return null; + } + + $entity = new AuthCodeEntity($this->server); + $entity->setId($result['id']); + $entity->setExpireTime($result['expire_time']); + $entity->setSessionId($result['session_id']); + $entity->setRedirectUri($result['client_redirect_uri']); + + return $entity; + } + + public function create($token, $expireTime, $sessionId, $redirectUri) { + $payload = Json::encode([ + 'id' => $token, + 'expire_time' => $expireTime, + 'session_id' => $sessionId, + 'client_redirect_uri' => $redirectUri, + ]); + + $this->key($token)->setValue($payload)->expireAt($expireTime); + } + + public function getScopes(OriginalAuthCodeEntity $token) { + $scopes = $this->scopes($token->getId()); + $scopesEntities = []; + foreach ($scopes as $scope) { + if ($this->server->getScopeStorage()->get($scope) !== null) { + $scopesEntities[] = (new ScopeEntity($this->server))->hydrate(['id' => $scope]); + } + } + + return $scopesEntities; + } + + public function associateScope(OriginalAuthCodeEntity $token, ScopeEntity $scope) { + $this->scopes($token->getId())->add($scope->getId())->expireAt($token->getExpireTime()); + } + + public function delete(OriginalAuthCodeEntity $token) { + $this->key($token->getId())->delete(); + $this->scopes($token->getId())->delete(); + } + + private function key(string $token) : Key { + return new Key($this->dataTable, $token); + } + + private function scopes(string $token) : Set { + return new Set($this->dataTable, $token, 'scopes'); + } + +} diff --git a/common/components/oauth/Storage/Yii2/ClientStorage.php b/api/components/OAuth2/Storage/ClientStorage.php similarity index 67% rename from common/components/oauth/Storage/Yii2/ClientStorage.php rename to api/components/OAuth2/Storage/ClientStorage.php index 5e8808d..90d024b 100644 --- a/common/components/oauth/Storage/Yii2/ClientStorage.php +++ b/api/components/OAuth2/Storage/ClientStorage.php @@ -1,9 +1,9 @@ select(['id', 'name', 'secret', 'redirect_uri']) - ->where([OauthClient::tableName() . '.id' => $clientId]); - + $query = OauthClient::find()->andWhere(['id' => $clientId]); if ($clientSecret !== null) { $query->andWhere(['secret' => $clientSecret]); } - $model = $query->asArray()->one(); + /** @var OauthClient|null $model */ + $model = $query->one(); if ($model === null) { return null; } @@ -39,22 +37,17 @@ class ClientStorage extends AbstractStorage implements ClientInterface { * Короче это нужно учесть */ if ($redirectUri !== null) { - if ($redirectUri === self::REDIRECT_STATIC_PAGE || $redirectUri === self::REDIRECT_STATIC_PAGE_WITH_CODE) { + if (in_array($redirectUri, [self::REDIRECT_STATIC_PAGE, self::REDIRECT_STATIC_PAGE_WITH_CODE], true)) { // Тут, наверное, нужно проверить тип приложения } else { - if (!StringHelper::startsWith($redirectUri, $model['redirect_uri'], false)) { + if (!StringHelper::startsWith($redirectUri, $model->redirect_uri, false)) { return null; } } } - $entity = new ClientEntity($this->server); - $entity->hydrate([ - 'id' => $model['id'], - 'name' => $model['name'], - 'secret' => $model['secret'], - 'redirectUri' => $redirectUri, - ]); + $entity = $this->hydrate($model); + $entity->setRedirectUri($redirectUri); return $entity; } @@ -67,17 +60,23 @@ class ClientStorage extends AbstractStorage implements ClientInterface { throw new \ErrorException('This module assumes that $session typeof ' . SessionEntity::class); } - $model = OauthClient::find() - ->select(['id', 'name']) - ->andWhere(['id' => $session->getClientId()]) - ->asArray() - ->one(); - + /** @var OauthClient|null $model */ + $model = OauthClient::findOne($session->getClientId()); if ($model === null) { return null; } - return (new ClientEntity($this->server))->hydrate($model); + return $this->hydrate($model); + } + + private function hydrate(OauthClient $model) : ClientEntity { + $entity = new ClientEntity($this->server); + $entity->setId($model->id); + $entity->setName($model->name); + $entity->setSecret($model->secret); + $entity->setRedirectUri($model->redirect_uri); + + return $entity; } } diff --git a/api/components/OAuth2/Storage/RefreshTokenStorage.php b/api/components/OAuth2/Storage/RefreshTokenStorage.php new file mode 100644 index 0000000..2321e76 --- /dev/null +++ b/api/components/OAuth2/Storage/RefreshTokenStorage.php @@ -0,0 +1,60 @@ +dataTable, $token))->getValue()); + + $entity = new RefreshTokenEntity($this->server); + $entity->setId($result['id']); + $entity->setAccessTokenId($result['access_token_id']); + $entity->setSessionId($result['session_id']); + + return $entity; + } + + public function create($token, $expireTime, $accessToken) { + $sessionId = $this->server->getAccessTokenStorage()->get($accessToken)->getSession()->getId(); + $payload = Json::encode([ + 'id' => $token, + 'access_token_id' => $accessToken, + 'session_id' => $sessionId, + ]); + + $this->key($token)->setValue($payload); + $this->sessionHash($sessionId)->add($token); + } + + public function delete(OriginalRefreshTokenEntity $token) { + if (!$token instanceof RefreshTokenEntity) { + throw new ErrorException('Token must be instance of ' . RefreshTokenEntity::class); + } + + $this->key($token->getId())->delete(); + $this->sessionHash($token->getSessionId())->remove($token->getId()); + } + + public function sessionHash(string $sessionId) : Set { + $tableName = Yii::$app->db->getSchema()->getRawTableName(OauthSession::tableName()); + return new Set($tableName, $sessionId, 'refresh_tokens'); + } + + private function key(string $token) : Key { + return new Key($this->dataTable, $token); + } + +} diff --git a/common/components/oauth/Storage/Yii2/ScopeStorage.php b/api/components/OAuth2/Storage/ScopeStorage.php similarity index 64% rename from common/components/oauth/Storage/Yii2/ScopeStorage.php rename to api/components/OAuth2/Storage/ScopeStorage.php index 64fef0e..be42d1e 100644 --- a/common/components/oauth/Storage/Yii2/ScopeStorage.php +++ b/api/components/OAuth2/Storage/ScopeStorage.php @@ -1,8 +1,8 @@ andWhere(['id' => $scope])->asArray()->one(); - if ($row === null) { + if (!in_array($scope, OauthScope::getScopes(), true)) { return null; } $entity = new ScopeEntity($this->server); - $entity->hydrate($row); + $entity->setId($scope); return $entity; } diff --git a/common/components/oauth/Storage/Yii2/SessionStorage.php b/api/components/OAuth2/Storage/SessionStorage.php similarity index 55% rename from common/components/oauth/Storage/Yii2/SessionStorage.php rename to api/components/OAuth2/Storage/SessionStorage.php index 1542391..8777f96 100644 --- a/common/components/oauth/Storage/Yii2/SessionStorage.php +++ b/api/components/OAuth2/Storage/SessionStorage.php @@ -1,8 +1,8 @@ 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)); + public function getById($sessionId) { + return $this->hydrate($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); + throw new ErrorException('This method is not implemented and should not be used'); } - /** - * @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()); + return $this->getById($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]); + if ($this->server->getScopeStorage()->get($scope) !== null) { + $result[] = (new ScopeEntity($this->server))->hydrate(['id' => $scope]); + } } return $result; } - /** - * @inheritdoc - */ public function create($ownerType, $ownerId, $clientId, $clientRedirectUri = null) { $sessionId = OauthSession::find() ->select('id') @@ -116,11 +72,26 @@ class SessionStorage extends AbstractStorage implements SessionInterface { return $sessionId; } - /** - * @inheritdoc - */ public function associateScope(OriginalSessionEntity $session, ScopeEntity $scope) { $this->getSessionModel($session->getId())->getScopes()->add($scope->getId()); } + private function getSessionModel(string $sessionId) : OauthSession { + $session = OauthSession::findOne($sessionId); + if ($session === null) { + throw new ErrorException('Cannot find oauth session'); + } + + return $session; + } + + private function hydrate(OauthSession $sessionModel) { + $entity = new SessionEntity($this->server); + $entity->setId($sessionModel->id); + $entity->setClientId($sessionModel->client_id); + $entity->setOwner($sessionModel->owner_type, $sessionModel->owner_id); + + return $entity; + } + } diff --git a/api/components/OAuth2/Utils/KeyAlgorithm/UuidAlgorithm.php b/api/components/OAuth2/Utils/KeyAlgorithm/UuidAlgorithm.php new file mode 100644 index 0000000..54b4ba3 --- /dev/null +++ b/api/components/OAuth2/Utils/KeyAlgorithm/UuidAlgorithm.php @@ -0,0 +1,16 @@ +toString(); + } + +} diff --git a/api/components/User/Component.php b/api/components/User/Component.php index a0685d5..aa996e6 100644 --- a/api/components/User/Component.php +++ b/api/components/User/Component.php @@ -23,7 +23,7 @@ use yii\web\User as YiiUserComponent; * @property AccountSession|null $activeSession * @property AccountIdentity|null $identity * - * @method AccountIdentity|null getIdentity($autoRenew = true) + * @method AccountIdentity|null loginByAccessToken($token, $type = null) */ class Component extends YiiUserComponent { @@ -39,6 +39,8 @@ class Component extends YiiUserComponent { public $sessionTimeout = 'P7D'; + private $_identity; + public function init() { parent::init(); if (!$this->secret) { @@ -46,6 +48,24 @@ class Component extends YiiUserComponent { } } + /** + * @param bool $autoRenew + * @return null|AccountIdentity + */ + public function getIdentity($autoRenew = true) { + $result = parent::getIdentity($autoRenew); + if ($result === null && $this->_identity !== false) { + $bearer = $this->getBearerToken(); + if ($bearer !== null) { + $result = $this->loginByAccessToken($bearer); + } + + $this->_identity = $result ?: false; + } + + return $result; + } + /** * @param IdentityInterface $identity * @param bool $rememberMe @@ -90,7 +110,7 @@ class Component extends YiiUserComponent { return $result; } - public function renew(AccountSession $session) { + public function renew(AccountSession $session): RenewResult { $account = $session->account; $transaction = Yii::$app->db->beginTransaction(); try { @@ -149,14 +169,9 @@ class Component extends YiiUserComponent { return null; } - $authHeader = Yii::$app->request->getHeaders()->get('Authorization'); - if ($authHeader === null || !preg_match('/^Bearer\s+(.*?)$/', $authHeader, $matches)) { - return null; - } - - $token = $matches[1]; + $bearer = $this->getBearerToken(); try { - $token = $this->parseToken($token); + $token = $this->parseToken($bearer); } catch (VerificationException $e) { return null; } @@ -203,4 +218,16 @@ class Component extends YiiUserComponent { ]; } + /** + * @return ?string + */ + private function getBearerToken() { + $authHeader = Yii::$app->request->getHeaders()->get('Authorization'); + if ($authHeader === null || !preg_match('/^Bearer\s+(.*?)$/', $authHeader, $matches)) { + return null; + } + + return $matches[1]; + } + } diff --git a/api/config/config.php b/api/config/config.php index dfcd9c3..68e83c2 100644 --- a/api/config/config.php +++ b/api/config/config.php @@ -1,7 +1,7 @@ [ 'traceLevel' => YII_DEBUG ? 3 : 0, 'targets' => [ + [ + 'class' => mito\sentry\Target::class, + 'levels' => ['error', 'warning'], + 'except' => [ + 'legacy-authserver', + 'session', + 'yii\web\HttpException:*', + 'api\modules\session\exceptions\SessionServerException:*', + 'api\modules\authserver\exceptions\AuthserverException:*', + ], + ], [ 'class' => yii\log\FileTarget::class, 'levels' => ['error', 'warning'], @@ -63,8 +74,12 @@ return [ 'format' => yii\web\Response::FORMAT_JSON, ], 'oauth' => [ - 'class' => common\components\oauth\Component::class, + '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, diff --git a/api/controllers/AccountsController.php b/api/controllers/AccountsController.php index 8fca1e6..cec7384 100644 --- a/api/controllers/AccountsController.php +++ b/api/controllers/AccountsController.php @@ -79,7 +79,7 @@ class AccountsController extends Controller { if (!$model->changePassword()) { return [ 'success' => false, - 'errors' => $this->normalizeModelErrors($model->getErrors()), + 'errors' => $model->getFirstErrors(), ]; } @@ -94,7 +94,7 @@ class AccountsController extends Controller { if (!$model->change()) { return [ 'success' => false, - 'errors' => $this->normalizeModelErrors($model->getErrors()), + 'errors' => $model->getFirstErrors(), ]; } @@ -110,7 +110,7 @@ class AccountsController extends Controller { if (!$model->sendCurrentEmailConfirmation()) { $data = [ 'success' => false, - 'errors' => $this->normalizeModelErrors($model->getErrors()), + 'errors' => $model->getFirstErrors(), ]; if (ArrayHelper::getValue($data['errors'], 'email') === E::RECENTLY_SENT_MESSAGE) { @@ -136,7 +136,7 @@ class AccountsController extends Controller { if (!$model->sendNewEmailConfirmation()) { return [ 'success' => false, - 'errors' => $this->normalizeModelErrors($model->getErrors()), + 'errors' => $model->getFirstErrors(), ]; } @@ -152,7 +152,7 @@ class AccountsController extends Controller { if (!$model->changeEmail()) { return [ 'success' => false, - 'errors' => $this->normalizeModelErrors($model->getErrors()), + 'errors' => $model->getFirstErrors(), ]; } @@ -171,7 +171,7 @@ class AccountsController extends Controller { if (!$model->applyLanguage()) { return [ 'success' => false, - 'errors' => $this->normalizeModelErrors($model->getErrors()), + 'errors' => $model->getFirstErrors(), ]; } @@ -187,7 +187,7 @@ class AccountsController extends Controller { if (!$model->agreeWithLatestRules()) { return [ 'success' => false, - 'errors' => $this->normalizeModelErrors($model->getErrors()), + 'errors' => $model->getFirstErrors(), ]; } diff --git a/api/controllers/AuthenticationController.php b/api/controllers/AuthenticationController.php index 051e30c..0046f7c 100644 --- a/api/controllers/AuthenticationController.php +++ b/api/controllers/AuthenticationController.php @@ -17,13 +17,14 @@ class AuthenticationController extends Controller { public function behaviors() { return ArrayHelper::merge(parent::behaviors(), [ 'authenticator' => [ - 'except' => ['login', 'forgot-password', 'recover-password', 'refresh-token'], + 'only' => ['logout'], ], 'access' => [ 'class' => AccessControl::class, + 'except' => ['refresh-token'], 'rules' => [ [ - 'actions' => ['login', 'forgot-password', 'recover-password', 'refresh-token'], + 'actions' => ['login', 'forgot-password', 'recover-password'], 'allow' => true, 'roles' => ['?'], ], @@ -53,7 +54,7 @@ class AuthenticationController extends Controller { if (($result = $model->login()) === false) { $data = [ 'success' => false, - 'errors' => $this->normalizeModelErrors($model->getErrors()), + 'errors' => $model->getFirstErrors(), ]; if (ArrayHelper::getValue($data['errors'], 'login') === E::ACCOUNT_NOT_ACTIVATED) { @@ -83,7 +84,7 @@ class AuthenticationController extends Controller { if ($model->forgotPassword() === false) { $data = [ 'success' => false, - 'errors' => $this->normalizeModelErrors($model->getErrors()), + 'errors' => $model->getFirstErrors(), ]; if (ArrayHelper::getValue($data['errors'], 'login') === E::RECENTLY_SENT_MESSAGE) { @@ -119,7 +120,7 @@ class AuthenticationController extends Controller { if (($result = $model->recoverPassword()) === false) { return [ 'success' => false, - 'errors' => $this->normalizeModelErrors($model->getErrors()), + 'errors' => $model->getFirstErrors(), ]; } @@ -134,7 +135,7 @@ class AuthenticationController extends Controller { if (($result = $model->renew()) === false) { return [ 'success' => false, - 'errors' => $this->normalizeModelErrors($model->getErrors()), + 'errors' => $model->getFirstErrors(), ]; } diff --git a/api/controllers/Controller.php b/api/controllers/Controller.php index 8c655d6..a4a981e 100644 --- a/api/controllers/Controller.php +++ b/api/controllers/Controller.php @@ -1,7 +1,6 @@ Yii::$app->getUser(), ]; - // xml нам не понадобится - unset($parentBehaviors['contentNegotiator']['formats']['application/xml']); - // rate limiter здесь не применяется - unset($parentBehaviors['rateLimiter']); + // xml и rate limiter нам не понадобятся + unset( + $parentBehaviors['contentNegotiator']['formats']['application/xml'], + $parentBehaviors['rateLimiter'] + ); return $parentBehaviors; } diff --git a/api/controllers/FeedbackController.php b/api/controllers/FeedbackController.php index e185c6d..5d4885a 100644 --- a/api/controllers/FeedbackController.php +++ b/api/controllers/FeedbackController.php @@ -27,7 +27,7 @@ class FeedbackController extends Controller { if (!$model->sendMessage()) { return [ 'success' => false, - 'errors' => $this->normalizeModelErrors($model->getErrors()), + 'errors' => $model->getFirstErrors(), ]; } diff --git a/api/controllers/OauthController.php b/api/controllers/OauthController.php index 4f5db86..8df62a0 100644 --- a/api/controllers/OauthController.php +++ b/api/controllers/OauthController.php @@ -2,13 +2,12 @@ namespace api\controllers; use api\filters\ActiveUserRule; -use common\components\oauth\Exception\AcceptRequiredException; -use common\components\oauth\Exception\AccessDeniedException; +use api\components\OAuth2\Exception\AcceptRequiredException; +use api\components\OAuth2\Exception\AccessDeniedException; use common\models\Account; use common\models\OauthClient; use common\models\OauthScope; use League\OAuth2\Server\Exception\OAuthException; -use League\OAuth2\Server\Grant\RefreshTokenGrant; use Yii; use yii\filters\AccessControl; use yii\helpers\ArrayHelper; @@ -18,16 +17,12 @@ class OauthController extends Controller { public function behaviors() { return ArrayHelper::merge(parent::behaviors(), [ 'authenticator' => [ - 'except' => ['validate', 'token'], + 'only' => ['complete'], ], 'access' => [ 'class' => AccessControl::class, + 'only' => ['complete'], 'rules' => [ - [ - 'actions' => ['validate', 'token'], - 'allow' => true, - 'roles' => ['?'], - ], [ 'class' => ActiveUserRule::class, 'actions' => ['complete'], @@ -186,7 +181,7 @@ class OauthController extends Controller { } $scopes = $codeModel->getScopes(); - if (array_search(OauthScope::OFFLINE_ACCESS, array_keys($scopes)) === false) { + if (array_search(OauthScope::OFFLINE_ACCESS, array_keys($scopes), true) === false) { return; } } elseif ($grantType === 'refresh_token') { @@ -195,7 +190,10 @@ class OauthController extends Controller { return; } - $this->getServer()->addGrantType(new RefreshTokenGrant()); + $grantClass = Yii::$app->oauth->grantMap['refresh_token']; + $grant = new $grantClass; + + $this->getServer()->addGrantType($grant); } /** diff --git a/api/controllers/OptionsController.php b/api/controllers/OptionsController.php index 4c230f9..c96762a 100644 --- a/api/controllers/OptionsController.php +++ b/api/controllers/OptionsController.php @@ -1,6 +1,7 @@ [ 'except' => ['index'], ], + 'nginxCache' => [ + 'class' => NginxCache::class, + 'rules' => [ + 'index' => 3600, // 1h + ], + ], ]); } diff --git a/api/controllers/SignupController.php b/api/controllers/SignupController.php index 2518b5e..b1d29c0 100644 --- a/api/controllers/SignupController.php +++ b/api/controllers/SignupController.php @@ -43,7 +43,7 @@ class SignupController extends Controller { if (!$model->signup()) { return [ 'success' => false, - 'errors' => $this->normalizeModelErrors($model->getErrors()), + 'errors' => $model->getFirstErrors(), ]; } @@ -58,7 +58,7 @@ class SignupController extends Controller { if (!$model->sendRepeatMessage()) { $response = [ 'success' => false, - 'errors' => $this->normalizeModelErrors($model->getErrors()), + 'errors' => $model->getFirstErrors(), ]; if (ArrayHelper::getValue($response['errors'], 'email') === E::RECENTLY_SENT_MESSAGE) { @@ -83,7 +83,7 @@ class SignupController extends Controller { if (!($result = $model->confirm())) { return [ 'success' => false, - 'errors' => $this->normalizeModelErrors($model->getErrors()), + 'errors' => $model->getFirstErrors(), ]; } diff --git a/api/filters/NginxCache.php b/api/filters/NginxCache.php new file mode 100644 index 0000000..21669e1 --- /dev/null +++ b/api/filters/NginxCache.php @@ -0,0 +1,35 @@ + сколько кэшировать. + * + * Период можно задавать 2-умя путями: + * - если значение начинается с префикса @, оно задаёт абсолютное время в unix timestamp, + * до которого ответ может быть закэширован. + * - в ином случае значение интерпретируется как количество секунд, на которое необходимо + * закэшировать ответ + */ + public $rules; + + public function afterAction($action, $result) { + $rule = $this->rules[$action->id] ?? null; + if ($rule !== null) { + if (is_callable($rule)) { + $cacheTime = $rule($action); + } else { + $cacheTime = $rule; + } + + Yii::$app->response->headers->set('X-Accel-Expires', $cacheTime); + } + + return parent::afterAction($action, $result); + } + +} diff --git a/api/models/profile/ChangeUsernameForm.php b/api/models/profile/ChangeUsernameForm.php index fb0a931..9e7dd53 100644 --- a/api/models/profile/ChangeUsernameForm.php +++ b/api/models/profile/ChangeUsernameForm.php @@ -28,15 +28,19 @@ class ChangeUsernameForm extends ApiForm { ]; } - public function change() { + public function change() : bool { if (!$this->validate()) { return false; } - $transaction = Yii::$app->db->beginTransaction(); $account = $this->getAccount(); - $oldNickname = $account->username; + if ($this->username === $account->username) { + return true; + } + + $transaction = Yii::$app->db->beginTransaction(); try { + $oldNickname = $account->username; $account->username = $this->username; if (!$account->save()) { throw new ErrorException('Cannot save account model with new username'); diff --git a/api/modules/session/filters/RateLimiter.php b/api/modules/session/filters/RateLimiter.php index 71cbf65..12cd5b7 100644 --- a/api/modules/session/filters/RateLimiter.php +++ b/api/modules/session/filters/RateLimiter.php @@ -25,6 +25,7 @@ class RateLimiter extends \yii\filters\RateLimiter { /** * @inheritdoc + * @throws TooManyRequestsHttpException */ public function beforeAction($action) { $this->checkRateLimit( @@ -39,6 +40,7 @@ class RateLimiter extends \yii\filters\RateLimiter { /** * @inheritdoc + * @throws TooManyRequestsHttpException */ public function checkRateLimit($user, $request, $response, $action) { if (parse_url($request->getHostInfo(), PHP_URL_HOST) === $this->authserverDomain) { @@ -54,7 +56,7 @@ class RateLimiter extends \yii\filters\RateLimiter { $key = $this->buildKey($ip); $redis = $this->getRedis(); - $countRequests = intval($redis->executeCommand('INCR', [$key])); + $countRequests = (int)$redis->incr($key); if ($countRequests === 1) { $redis->executeCommand('EXPIRE', [$key, $this->limitTime]); } @@ -65,7 +67,7 @@ class RateLimiter extends \yii\filters\RateLimiter { } /** - * @return \yii\redis\Connection + * @return \common\components\Redis\Connection */ public function getRedis() { return Yii::$app->redis; diff --git a/api/modules/session/models/JoinForm.php b/api/modules/session/models/JoinForm.php index 648338e..f5e1973 100644 --- a/api/modules/session/models/JoinForm.php +++ b/api/modules/session/models/JoinForm.php @@ -128,7 +128,7 @@ class JoinForm extends Model { $account = $accessModel->account; } - /** @var MinecraftAccessKey|\common\models\OauthAccessToken $accessModel */ + /** @var MinecraftAccessKey|\api\components\OAuth2\Entities\AccessTokenEntity $accessModel */ if ($accessModel->isExpired()) { Session::error("User with access_token = '{$accessToken}' failed join by expired access_token."); throw new ForbiddenOperationException('Expired access_token.'); diff --git a/api/traits/ApiNormalize.php b/api/traits/ApiNormalize.php deleted file mode 100644 index 590680c..0000000 --- a/api/traits/ApiNormalize.php +++ /dev/null @@ -1,26 +0,0 @@ - 'first_error_of_field1', - * 'field2' => 'first_error_of_field2', - * ] - * - * @param array $errors - * @return array - */ - public function normalizeModelErrors(array $errors) { - $normalized = []; - foreach($errors as $attribute => $attrErrors) { - $normalized[$attribute] = $attrErrors[0]; - } - - return $normalized; - } - -} diff --git a/autocompletion.php b/autocompletion.php index 24c5140..f91608c 100644 --- a/autocompletion.php +++ b/autocompletion.php @@ -17,10 +17,11 @@ class Yii extends \yii\BaseYii { * Used for properties that are identical for both WebApplication and ConsoleApplication * * @property \yii\swiftmailer\Mailer $mailer - * @property \yii\redis\Connection $redis + * @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 */ abstract class BaseApplication extends yii\base\Application { } @@ -29,10 +30,10 @@ abstract class BaseApplication extends yii\base\Application { * Class WebApplication * 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\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 + * @property \api\components\OAuth2\Component $oauth * * @method \api\components\User\Component getUser() */ diff --git a/common/components/RabbitMQ/Component.php b/common/components/RabbitMQ/Component.php index 89da8f8..0ce0f91 100644 --- a/common/components/RabbitMQ/Component.php +++ b/common/components/RabbitMQ/Component.php @@ -111,8 +111,8 @@ class Component extends \yii\base\Component { public function sendToExchange($exchangeName, $routingKey, $message, $exchangeArgs = [], $publishArgs = []) { $message = $this->prepareMessage($message); $channel = $this->getChannel(); - call_user_func_array([$channel, 'exchange_declare'], $this->prepareExchangeArgs($exchangeName, $exchangeArgs)); - call_user_func_array([$channel, 'basic_publish'], $this->preparePublishArgs($message, $exchangeName, $routingKey, $publishArgs)); + $channel->exchange_declare(...$this->prepareExchangeArgs($exchangeName, $exchangeArgs)); + $channel->basic_publish(...$this->preparePublishArgs($message, $exchangeName, $routingKey, $publishArgs)); } /** diff --git a/common/components/Redis/Cache.php b/common/components/Redis/Cache.php new file mode 100644 index 0000000..6a120a1 --- /dev/null +++ b/common/components/Redis/Cache.php @@ -0,0 +1,13 @@ +redis = Instance::ensure($this->redis, ConnectionInterface::class); + } + +} diff --git a/common/components/Redis/Connection.php b/common/components/Redis/Connection.php new file mode 100644 index 0000000..5bdc47d --- /dev/null +++ b/common/components/Redis/Connection.php @@ -0,0 +1,415 @@ +_client === null) { + $this->_client = new Client($this->prepareParams(), $this->options); + } + + return $this->_client; + } + + public function __call($name, $params) { + $redisCommand = mb_strtoupper($name); + if (in_array($redisCommand, self::REDIS_COMMANDS)) { + return $this->executeCommand($name, $params); + } + + return parent::__call($name, $params); + } + + public function executeCommand(string $name, array $params = []) { + return $this->getConnection()->$name(...$params); + } + + private function prepareParams() { + if ($this->parameters !== null) { + return $this->parameters; + } + + if ($this->unixSocket) { + $parameters = [ + 'scheme' => 'unix', + 'path' => $this->unixSocket, + ]; + } else { + $parameters = [ + 'scheme' => 'tcp', + 'host' => $this->hostname, + 'port' => $this->port, + ]; + } + + return array_merge($parameters, [ + 'database' => $this->database, + ]); + } + +} diff --git a/common/components/Redis/ConnectionInterface.php b/common/components/Redis/ConnectionInterface.php new file mode 100644 index 0000000..f4195fe --- /dev/null +++ b/common/components/Redis/ConnectionInterface.php @@ -0,0 +1,19 @@ +redis; } - public function getKey() { + public function getKey() : string { return $this->key; } public function getValue() { - return json_decode($this->getRedis()->get($this->key), true); + return $this->getRedis()->get($this->key); } public function setValue($value) { - $this->getRedis()->set($this->key, json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + $this->getRedis()->set($this->key, $value); return $this; } public function delete() { - $this->getRedis()->executeCommand('DEL', [$this->key]); + $this->getRedis()->del($this->key); return $this; } - public function expire($ttl) { - $this->getRedis()->executeCommand('EXPIRE', [$this->key, $ttl]); + public function exists() : bool { + return (bool)$this->getRedis()->exists($this->key); + } + + public function expire(int $ttl) { + $this->getRedis()->expire($this->key, $ttl); return $this; } + public function expireAt(int $unixTimestamp) { + $this->getRedis()->expireat($this->key, $unixTimestamp); + return $this; + } + + public function __construct(...$key) { + if (empty($key)) { + throw new InvalidArgumentException('You must specify at least one key.'); + } + + $this->key = $this->buildKey($key); + } + private function buildKey(array $parts) { $keyParts = []; foreach($parts as $part) { @@ -47,12 +64,4 @@ class Key { 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..fe8302a --- /dev/null +++ b/common/components/Redis/Set.php @@ -0,0 +1,46 @@ +getRedis()->sadd($this->key, $value); + return $this; + } + + public function remove($value) { + $this->getRedis()->srem($this->key, $value); + return $this; + } + + public function members() { + return $this->getRedis()->smembers($this->key); + } + + public function getValue() { + return $this->members(); + } + + public function exists(string $value = null) : bool { + if ($value === null) { + return parent::exists(); + } else { + return (bool)$this->getRedis()->sismember($this->key, $value); + } + } + + public function diff(array $sets) { + return $this->getRedis()->sdiff([$this->key, implode(' ', $sets)]); + } + + /** + * @inheritdoc + */ + public function getIterator() { + return new ArrayIterator($this->members()); + } + +} diff --git a/common/components/Sentry/Component.php b/common/components/Sentry/Component.php new file mode 100644 index 0000000..ce0b1c5 --- /dev/null +++ b/common/components/Sentry/Component.php @@ -0,0 +1,18 @@ +client) && !isset($this->client['release'])) { + $this->client['release'] = Yii::$app->version; + } + + parent::init(); + } + +} diff --git a/common/components/oauth/Entity/AuthCodeEntity.php b/common/components/oauth/Entity/AuthCodeEntity.php deleted file mode 100644 index 6bd8b0c..0000000 --- a/common/components/oauth/Entity/AuthCodeEntity.php +++ /dev/null @@ -1,27 +0,0 @@ -sessionId; - } - - /** - * @inheritdoc - * @return static - */ - public function setSession(SessionEntity $session) { - parent::setSession($session); - $this->sessionId = $session->getId(); - - return $this; - } - -} diff --git a/common/components/oauth/Storage/Redis/AuthCodeStorage.php b/common/components/oauth/Storage/Redis/AuthCodeStorage.php deleted file mode 100644 index f3bdbdc..0000000 --- a/common/components/oauth/Storage/Redis/AuthCodeStorage.php +++ /dev/null @@ -1,84 +0,0 @@ -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['session_id'], - ]); - } - - /** - * @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 deleted file mode 100644 index f3ad9e0..0000000 --- a/common/components/oauth/Storage/Redis/RefreshTokenStorage.php +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 03cc89a..0000000 --- a/common/components/oauth/Storage/Yii2/AccessTokenStorage.php +++ /dev/null @@ -1,84 +0,0 @@ -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(); - $model->access_token = $token; - $model->expire_time = $expireTime; - $model->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/Util/KeyAlgorithm/UuidAlgorithm.php b/common/components/oauth/Util/KeyAlgorithm/UuidAlgorithm.php deleted file mode 100644 index e75580d..0000000 --- a/common/components/oauth/Util/KeyAlgorithm/UuidAlgorithm.php +++ /dev/null @@ -1,16 +0,0 @@ -toString(); - } - -} diff --git a/common/components/redis/Set.php b/common/components/redis/Set.php deleted file mode 100644 index 80f8d0a..0000000 --- a/common/components/redis/Set.php +++ /dev/null @@ -1,49 +0,0 @@ -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/config-prod.php b/common/config/config-prod.php index 373f6ca..0160435 100644 --- a/common/config/config-prod.php +++ b/common/config/config-prod.php @@ -6,22 +6,5 @@ return [ 'schemaCacheDuration' => 3600, 'schemaCache' => 'cache', ], - 'mailer' => [ - 'useFileTransport' => false, - 'transport' => [ - 'class' => Swift_SmtpTransport::class, - 'host' => 'ely.by', - 'username' => getenv('SMTP_USER'), - 'password' => getenv('SMTP_PASS'), - 'port' => 587, - 'encryption' => 'tls', - 'streamOptions' => [ - 'ssl' => [ - 'allow_self_signed' => true, - 'verify_peer' => false, - ], - ], - ], - ], ], ]; diff --git a/common/config/config.php b/common/config/config.php index 31b0a83..d1be897 100644 --- a/common/config/config.php +++ b/common/config/config.php @@ -1,16 +1,17 @@ '1.1.3', 'vendorPath' => dirname(dirname(__DIR__)) . '/vendor', 'components' => [ 'cache' => [ - 'class' => yii\redis\Cache::class, + 'class' => common\components\Redis\Cache::class, 'redis' => 'redis', ], 'db' => [ 'class' => yii\db\Connection::class, - 'dsn' => 'mysql:host=db;dbname=' . getenv('MYSQL_DATABASE'), - 'username' => getenv('MYSQL_USER'), - 'password' => getenv('MYSQL_PASSWORD'), + 'dsn' => 'mysql:host=' . (getenv('DB_HOST') ?: 'db') . ';dbname=' . getenv('DB_DATABASE'), + 'username' => getenv('DB_USER'), + 'password' => getenv('DB_PASSWORD'), 'charset' => 'utf8', 'schemaMap' => [ 'mysql' => common\db\mysql\Schema::class, @@ -19,24 +20,47 @@ return [ 'mailer' => [ 'class' => yii\swiftmailer\Mailer::class, 'viewPath' => '@common/mail', + 'transport' => [ + 'class' => Swift_SmtpTransport::class, + 'host' => 'ely.by', + 'username' => getenv('SMTP_USER'), + 'password' => getenv('SMTP_PASS'), + 'port' => getenv('SMTP_PORT') ?: 587, + 'encryption' => 'tls', + 'streamOptions' => [ + 'ssl' => [ + 'allow_self_signed' => true, + 'verify_peer' => false, + ], + ], + ], + ], + 'sentry' => [ + 'class' => common\components\Sentry\Component::class, + 'enabled' => !empty(getenv('SENTRY_DSN')), + 'dsn' => getenv('SENTRY_DSN'), + 'environment' => YII_ENV_DEV ? 'development' : 'production', + 'client' => [ + 'curl_method' => 'async', + ], ], 'security' => [ 'passwordHashStrategy' => 'password_hash', ], 'redis' => [ - 'class' => yii\redis\Connection::class, - 'hostname' => 'redis', - 'password' => null, - 'port' => 6379, - 'database' => 0, + 'class' => common\components\Redis\Connection::class, + 'hostname' => getenv('REDIS_HOST') ?: 'redis', + 'password' => getenv('REDIS_PASS') ?: null, + 'port' => getenv('REDIS_PORT') ?: 6379, + 'database' => getenv('REDIS_DATABASE') ?: 0, ], 'amqp' => [ 'class' => common\components\RabbitMQ\Component::class, - 'host' => 'rabbitmq', - 'port' => 5672, - 'user' => getenv('RABBITMQ_DEFAULT_USER'), - 'password' => getenv('RABBITMQ_DEFAULT_PASS'), - 'vhost' => getenv('RABBITMQ_DEFAULT_VHOST'), + 'host' => getenv('RABBITMQ_HOST') ?: 'rabbitmq', + 'port' => getenv('RABBITMQ_PORT') ?: 5672, + 'user' => getenv('RABBITMQ_USER'), + 'password' => getenv('RABBITMQ_PASS'), + 'vhost' => getenv('RABBITMQ_VHOST'), ], 'guzzle' => [ 'class' => GuzzleHttp\Client::class, diff --git a/common/models/OauthAccessToken.php b/common/models/OauthAccessToken.php index b253ef2..7364a79 100644 --- a/common/models/OauthAccessToken.php +++ b/common/models/OauthAccessToken.php @@ -1,7 +1,7 @@ hasMany(OauthAccessToken::class, ['session_id' => 'id']); + throw new ErrorException('This method is possible, but not implemented'); } public function getClient() { @@ -46,6 +47,14 @@ class OauthSession extends ActiveRecord { } $this->getScopes()->delete(); + /** @var \api\components\OAuth2\Storage\RefreshTokenStorage $refreshTokensStorage */ + $refreshTokensStorage = Yii::$app->oauth->getAuthServer()->getRefreshTokenStorage(); + $refreshTokensSet = $refreshTokensStorage->sessionHash($this->id); + foreach ($refreshTokensSet->members() as $refreshTokenId) { + $refreshTokensStorage->delete($refreshTokensStorage->get($refreshTokenId)); + } + + $refreshTokensSet->delete(); return true; } diff --git a/composer.json b/composer.json index fb6029c..09c7d33 100644 --- a/composer.json +++ b/composer.json @@ -15,17 +15,19 @@ "minimum-stability": "stable", "require": { "php": "^7.0.6", - "yiisoft/yii2": "2.0.9", + "yiisoft/yii2": "2.0.10", "yiisoft/yii2-swiftmailer": "*", "ramsey/uuid": "^3.5.0", - "league/oauth2-server": "~4.1.5", + "league/oauth2-server": "dev-improvements#b9277ccd664dcb80a766b73674d21de686cb9dda", "yiisoft/yii2-redis": "~2.0.0", "guzzlehttp/guzzle": "^6.0.0", "php-amqplib/php-amqplib": "^2.6.2", "ely/yii2-tempmail-validator": "~1.0.0", "emarref/jwt": "~1.0.3", "ely/amqp-controller": "dev-master#d7f8cdbc66c45e477c9c7d5d509bc0c1b11fd3ec", - "ely/email-renderer": "dev-master#38a148cd5081147acc31125ddc49966b149f65cf" + "ely/email-renderer": "dev-master#38a148cd5081147acc31125ddc49966b149f65cf", + "predis/predis": "^1.0", + "mito/yii2-sentry": "dev-fix_init#27f00805cb906f73b2c6f8181c1c655decb9be70" }, "require-dev": { "yiisoft/yii2-codeception": "*", @@ -35,8 +37,7 @@ "codeception/codeception": "~2.2.4", "codeception/specify": "*", "codeception/verify": "*", - "phploc/phploc": "^3.0.1", - "predis/predis": "^1.0" + "phploc/phploc": "^3.0.1" }, "config": { "process-timeout": 1800 @@ -53,6 +54,14 @@ { "type": "git", "url": "git@gitlab.com:elyby/email-renderer.git" + }, + { + "type": "git", + "url": "git@gitlab.ely.by:elyby/oauth2-server.git" + }, + { + "type": "git", + "url": "git@github.com:erickskrauch/yii2-sentry.git" } ], "scripts": { diff --git a/console/config/config.php b/console/config/config.php index f7c04cf..748acc4 100644 --- a/console/config/config.php +++ b/console/config/config.php @@ -13,6 +13,10 @@ return [ 'components' => [ 'log' => [ 'targets' => [ + [ + 'class' => mito\sentry\Target::class, + 'levels' => ['error', 'warning'], + ], [ 'class' => yii\log\FileTarget::class, 'levels' => ['error', 'warning'], diff --git a/console/controllers/CleanupController.php b/console/controllers/CleanupController.php new file mode 100644 index 0000000..f08985b --- /dev/null +++ b/console/controllers/CleanupController.php @@ -0,0 +1,22 @@ +andWhere(['<', 'expire_time', time()]) + ->each(1000); + + foreach($accessTokens as $token) { + /** @var OauthAccessToken $token */ + $token->delete(); + } + + return self::EXIT_CODE_NORMAL; + } + +} diff --git a/console/migrations/m161127_145211_remove_oauth_scopes.php b/console/migrations/m161127_145211_remove_oauth_scopes.php new file mode 100644 index 0000000..39b89ce --- /dev/null +++ b/console/migrations/m161127_145211_remove_oauth_scopes.php @@ -0,0 +1,25 @@ +dropTable('{{%oauth_scopes}}'); + } + + public function safeDown() { + $this->createTable('{{%oauth_scopes}}', [ + 'id' => $this->string(64), + $this->primary('id'), + ]); + + $this->batchInsert('{{%oauth_scopes}}', ['id'], [ + ['offline_access'], + ['minecraft_server_session'], + ['account_info'], + ['account_email'], + ]); + } + +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9dce11c..f93f1f7 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -13,7 +13,7 @@ services: env_file: .env web: - build: ./docker/nginx + image: registry.ely.by/elyby/accounts-nginx:latest volumes_from: - app links: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1d59c03..d311e0b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -9,7 +9,7 @@ services: env_file: .env web: - build: ./docker/nginx + image: registry.ely.by/elyby/accounts-nginx:1.0.2 volumes_from: - app links: diff --git a/docker/cron/.gitkeep b/docker/cron/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile deleted file mode 100644 index 3c47e59..0000000 --- a/docker/nginx/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM nginx:1.11-alpine - -COPY nginx.conf /etc/nginx/nginx.conf -COPY account.ely.by.conf.template /etc/nginx/conf.d/account.ely.by.conf.template -COPY run.sh /run.sh - -RUN rm /etc/nginx/conf.d/default.conf \ - && chmod a+x /run.sh - -ENTRYPOINT ["/run.sh"] -CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/nginx/account.ely.by.conf.template b/docker/nginx/account.ely.by.conf.template deleted file mode 100644 index d5b2fbc..0000000 --- a/docker/nginx/account.ely.by.conf.template +++ /dev/null @@ -1,78 +0,0 @@ -server { - listen 80; - - root $root_path; - charset utf-8; - index index.html; - etag on; - - # Это можно раскоментить для целей отладки - # rewrite_log on; - # error_log /var/log/nginx/error.log debug; - - set $root_path '/var/www/html'; - set $frontend_path '${root_path}/frontend/dist'; - - set $request_url $request_uri; - set $host_with_uri '${host}${request_uri}'; - - if ($host_with_uri ~ '^${AUTHSERVER_HOST}/auth') { - set $request_url '/api/authserver${request_uri}'; - rewrite ^/auth /api/authserver$uri last; - } - - if ($host_with_uri ~ '^${AUTHSERVER_HOST}/session') { - set $request_url '/api/minecraft${request_uri}'; - rewrite ^/session /api/minecraft$uri last; - } - - if ($host_with_uri ~ '^${AUTHSERVER_HOST}/api/(user|profiles)') { - set $request_url '/api/mojang${request_uri}'; - rewrite ^/api/(user|profiles) /api/mojang$uri last; - } - - location / { - alias $frontend_path; - try_files $uri /index.html =404; - } - - location /api { - try_files $uri $uri /api/web/index.php$is_args$args; - } - - location ~* \.php$ { - fastcgi_pass php:9000; - include fastcgi_params; - - fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - fastcgi_param SERVER_NAME $host; - fastcgi_param REQUEST_URI $request_url; - fastcgi_param REMOTE_ADDR $http_x_real_ip; - - try_files $uri =404; - } - - # html файлы идут отдельно, для них будет применяться E-Tag кэширование - location ~* \.html$ { - root $frontend_path; - access_log off; - } - - # Раздача статики для frontend с указанием max-кэша. Сброс будет по #hash после ребилда webpackом - location ~* ^.+\.(jpg|jpeg|gif|png|svg|js|json|css|zip|rar|eot|ttf|woff|woff2|ico)$ { - root $frontend_path; - expires max; - etag off; - access_log off; - } - - # Запросы к статике для email, их нужно запустить внутрь vendor - location ^~ /images/emails/assets { - rewrite ^/images/emails/assets/(.+)$ /vendor/ely/emails-renderer/dist/assets/$1 last; - } - - location ^~ /vendor/ely/emails-renderer/dist/assets { - alias '${root_path}/vendor/ely/email-renderer/dist/assets'; - try_files $uri =404; - } -} diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf deleted file mode 100644 index 886547e..0000000 --- a/docker/nginx/nginx.conf +++ /dev/null @@ -1,25 +0,0 @@ -user nginx; -worker_processes 1; - -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - - sendfile on; - keepalive_timeout 10; - - include /etc/nginx/conf.d/*.conf; -} diff --git a/docker/nginx/run.sh b/docker/nginx/run.sh deleted file mode 100644 index 7365afe..0000000 --- a/docker/nginx/run.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env sh - -envsubst '$AUTHSERVER_HOST' < /etc/nginx/conf.d/account.ely.by.conf.template > /etc/nginx/conf.d/default.conf - -exec "$@" diff --git a/docker/php/composer.sh b/docker/php/composer.sh deleted file mode 100644 index 517b5d0..0000000 --- a/docker/php/composer.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -if [ -n "$API_TOKEN" ] -then - php /usr/local/bin/composer.phar config -g github-oauth.github.com $API_TOKEN -fi - -exec php /usr/local/bin/composer.phar "$@" diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh deleted file mode 100644 index f6723cb..0000000 --- a/docker/php/entrypoint.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash - -cd /var/www/html - -if [ "$1" = "bash" ] || [ "$1" = "composer" ] -then - exec "$@" - exit 0 -fi - -# Переносим vendor, если его нету или он изменился (или затёрся силами volume) -if ! cmp -s ./../vendor/autoload.php ./vendor/autoload.php -then - echo "vendor have diffs..." - echo "removing exists vendor" - rm -rf ./vendor - echo "copying new one" - cp -r ./../vendor ./vendor -fi - -# Переносим dist, если его нету или он изменился (или затёрся силами volume) -if ! cmp -s ./../dist/index.html ./frontend/dist/index.html -then - echo "frontend dist have diffs..." - echo "removing exists dist" - rm -rf ./frontend/dist - echo "copying new one" - cp -r ./../dist ./frontend/dist -fi - -if [ "$YII_ENV" != "test" ] -then - wait-for-it db:3306 -s -- "php /var/www/html/yii migrate/up --interactive=0" -else - wait-for-it testdb:3306 -s -- "php /var/www/html/tests/codeception/bin/yii migrate/up --interactive=0" -fi - -exec "$@" diff --git a/docker/php/php.ini b/docker/php/php.ini deleted file mode 100644 index a9c3fab..0000000 --- a/docker/php/php.ini +++ /dev/null @@ -1,2 +0,0 @@ -error_reporting = E_ALL; -display_errors = On; diff --git a/docker/php/supervisord.conf b/docker/php/supervisord.conf deleted file mode 100644 index 22c385c..0000000 --- a/docker/php/supervisord.conf +++ /dev/null @@ -1,36 +0,0 @@ -[supervisord] -logfile=/tmp/supervisord.log ; (main log file;default $CWD/supervisord.log) -logfile_maxbytes=50MB ; (max main logfile bytes b4 rotation;default 50MB) -logfile_backups=10 ; (num of main logfile rotation backups;default 10) -loglevel=info ; (log level;default info; others: debug,warn,trace) -pidfile=/tmp/supervisord.pid ; (supervisord pidfile;default supervisord.pid) -nodaemon=false ; (start in foreground if true;default false) -minfds=1024 ; (min. avail startup file descriptors;default 1024) -minprocs=200 ; (min. avail process descriptors;default 200) -user=root - -; the below section must remain in the config file for RPC -; (supervisorctl/web interface) to work, additional interfaces may be -; added by defining them in separate rpcinterface: sections -[rpcinterface:supervisor] -supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface - -[supervisorctl] -serverurl=unix:///dev/shm/supervisor.sock ; use a unix:// URL for a unix socket - -[program:php-fpm] -command=php-fpm -autostart=true -autorestart=true -priority=5 -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 - -[program:account-queue-worker] -directory=/var/www/html -command=wait-for-it rabbitmq:5672 -- php yii account-queue -autostart=true -autorestart=true -priority=10 diff --git a/docker/supervisor/account-queue-worker.conf b/docker/supervisor/account-queue-worker.conf new file mode 100644 index 0000000..aed1af3 --- /dev/null +++ b/docker/supervisor/account-queue-worker.conf @@ -0,0 +1,6 @@ +[program:account-queue-worker] +directory=/var/www/html +command=wait-for-it rabbitmq:5672 -- php yii account-queue +autostart=true +autorestart=true +priority=10 diff --git a/tests/codeception/api/codeception.yml b/tests/codeception/api/codeception.yml index 4318152..92cfc34 100644 --- a/tests/codeception/api/codeception.yml +++ b/tests/codeception/api/codeception.yml @@ -1,5 +1,6 @@ namespace: tests\codeception\api actor: Tester +params: [env] paths: tests: . log: _output diff --git a/tests/codeception/api/functional.suite.yml b/tests/codeception/api/functional.suite.yml index 232c933..713312d 100644 --- a/tests/codeception/api/functional.suite.yml +++ b/tests/codeception/api/functional.suite.yml @@ -4,23 +4,16 @@ modules: - Filesystem - Yii2 - tests\codeception\common\_support\FixtureHelper + - tests\codeception\common\_support\amqp\Helper - Redis - - AMQP - Asserts - REST: depends: Yii2 config: Yii2: configFile: '../config/api/functional.php' - cleanup: true + cleanup: false Redis: - host: testredis + host: "%REDIS_HOST%" port: 6379 database: 0 - AMQP: - host: testrabbit - port: 5672 - username: 'ely-accounts-tester' - password: 'tester-password' - vhost: '/account.ely.by/tests' - queues: ['account-operations'] diff --git a/tests/codeception/api/functional/OauthAuthCodeCest.php b/tests/codeception/api/functional/OauthAuthCodeCest.php index 5f291db..2ed740f 100644 --- a/tests/codeception/api/functional/OauthAuthCodeCest.php +++ b/tests/codeception/api/functional/OauthAuthCodeCest.php @@ -81,7 +81,7 @@ class OauthAuthCodeCest { public function testCompleteActionOnWrongConditions(FunctionalTester $I) { $I->loggedInAsActiveAccount(); - $I->wantTo('get accept_required if I dom\'t require any scope, but this is first time request'); + $I->wantTo('get accept_required if I don\'t require any scope, but this is first time request'); $this->route->complete($this->buildQueryParams( 'ely', 'http://ely.by', diff --git a/tests/codeception/api/functional/OauthRefreshTokenCest.php b/tests/codeception/api/functional/OauthRefreshTokenCest.php index dc6307a..47e5e47 100644 --- a/tests/codeception/api/functional/OauthRefreshTokenCest.php +++ b/tests/codeception/api/functional/OauthRefreshTokenCest.php @@ -23,14 +23,7 @@ class OauthRefreshTokenCest { 'ely', 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM' )); - $I->canSeeResponseCodeIs(200); - $I->canSeeResponseIsJson(); - $I->canSeeResponseContainsJson([ - 'token_type' => 'Bearer', - ]); - $I->canSeeResponseJsonMatchesJsonPath('$.access_token'); - $I->canSeeResponseJsonMatchesJsonPath('$.refresh_token'); - $I->canSeeResponseJsonMatchesJsonPath('$.expires_in'); + $this->canSeeRefreshTokenSuccess($I); } public function testRefreshTokenWithSameScopes(OauthSteps $I) { @@ -41,14 +34,26 @@ class OauthRefreshTokenCest { 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', [S::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS] )); - $I->canSeeResponseCodeIs(200); - $I->canSeeResponseIsJson(); - $I->canSeeResponseContainsJson([ - 'token_type' => 'Bearer', - ]); - $I->canSeeResponseJsonMatchesJsonPath('$.access_token'); - $I->canSeeResponseJsonMatchesJsonPath('$.refresh_token'); - $I->canSeeResponseJsonMatchesJsonPath('$.expires_in'); + $this->canSeeRefreshTokenSuccess($I); + } + + public function testRefreshTokenTwice(OauthSteps $I) { + $refreshToken = $I->getRefreshToken([S::MINECRAFT_SERVER_SESSION]); + $this->route->issueToken($this->buildParams( + $refreshToken, + 'ely', + 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + [S::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS] + )); + $this->canSeeRefreshTokenSuccess($I); + + $this->route->issueToken($this->buildParams( + $refreshToken, + 'ely', + 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + [S::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS] + )); + $this->canSeeRefreshTokenSuccess($I); } public function testRefreshTokenWithNewScopes(OauthSteps $I) { @@ -91,4 +96,15 @@ class OauthRefreshTokenCest { return $params; } + private function canSeeRefreshTokenSuccess(OauthSteps $I) { + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'token_type' => 'Bearer', + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.access_token'); + $I->canSeeResponseJsonMatchesJsonPath('$.expires_in'); + $I->cantSeeResponseJsonMatchesJsonPath('$.refresh_token'); + } + } diff --git a/tests/codeception/api/unit.suite.yml b/tests/codeception/api/unit.suite.yml index 8ce2d31..3d31363 100644 --- a/tests/codeception/api/unit.suite.yml +++ b/tests/codeception/api/unit.suite.yml @@ -3,6 +3,8 @@ modules: enabled: - Yii2: part: [orm, email, fixtures] + - tests\codeception\common\_support\amqp\Helper config: Yii2: configFile: '../config/api/unit.php' + cleanup: false diff --git a/tests/codeception/api/unit/components/User/ComponentTest.php b/tests/codeception/api/unit/components/User/ComponentTest.php index 470a0b4..1bc2033 100644 --- a/tests/codeception/api/unit/components/User/ComponentTest.php +++ b/tests/codeception/api/unit/components/User/ComponentTest.php @@ -16,7 +16,6 @@ use tests\codeception\common\_support\ProtectedCaller; use tests\codeception\common\fixtures\AccountFixture; use tests\codeception\common\fixtures\AccountSessionFixture; use Yii; -use yii\web\HeaderCollection; use yii\web\Request; class ComponentTest extends TestCase { @@ -24,7 +23,7 @@ class ComponentTest extends TestCase { use ProtectedCaller; /** - * @var Component + * @var Component|\PHPUnit_Framework_MockObject_MockObject */ private $component; @@ -40,6 +39,46 @@ class ComponentTest extends TestCase { ]; } + public function testGetIdentity() { + $this->specify('getIdentity should return null, if not authorization header', function() { + $this->mockAuthorizationHeader(null); + $this->assertNull($this->component->getIdentity()); + }); + + $this->specify('getIdentity should return null, if passed bearer token don\'t return any account', function() { + $this->mockAuthorizationHeader('some-auth'); + /** @var Component|\PHPUnit_Framework_MockObject_MockObject $component */ + $component = $this->getMockBuilder(Component::class) + ->setMethods(['loginByAccessToken']) + ->setConstructorArgs([$this->getComponentArguments()]) + ->getMock(); + + $component + ->expects($this->once()) + ->method('loginByAccessToken') + ->willReturn(null); + + $this->assertNull($component->getIdentity()); + }); + + $this->specify('getIdentity should return identity from loginByAccessToken method', function() { + $identity = new AccountIdentity(); + $this->mockAuthorizationHeader('some-auth'); + /** @var Component|\PHPUnit_Framework_MockObject_MockObject $component */ + $component = $this->getMockBuilder(Component::class) + ->setMethods(['loginByAccessToken']) + ->setConstructorArgs([$this->getComponentArguments()]) + ->getMock(); + + $component + ->expects($this->once()) + ->method('loginByAccessToken') + ->willReturn($identity); + + $this->assertEquals($identity, $component->getIdentity()); + }); + } + public function testLogin() { $this->mockRequest(); $this->specify('success get LoginResult object without session value', function() { @@ -117,30 +156,9 @@ class ComponentTest extends TestCase { $component ->expects($this->any()) ->method('getIsGuest') - ->will($this->returnValue(false)); + ->willReturn(false); - /** @var HeaderCollection|\PHPUnit_Framework_MockObject_MockObject $headersCollection */ - $headersCollection = $this->getMockBuilder(HeaderCollection::class) - ->setMethods(['get']) - ->getMock(); - - $headersCollection - ->expects($this->any()) - ->method('get') - ->with($this->equalTo('Authorization')) - ->will($this->returnValue('Bearer ' . $result->getJwt())); - - /** @var Request|\PHPUnit_Framework_MockObject_MockObject $request */ - $request = $this->getMockBuilder(Request::class) - ->setMethods(['getHeaders']) - ->getMock(); - - $request - ->expects($this->any()) - ->method('getHeaders') - ->will($this->returnValue($headersCollection)); - - Yii::$app->set('request', $request); + $this->mockAuthorizationHeader($result->getJwt()); $session = $component->getActiveSession(); expect($session)->isInstanceOf(AccountSession::class); @@ -203,6 +221,17 @@ class ComponentTest extends TestCase { return $request; } + /** + * @param string $bearerToken + */ + private function mockAuthorizationHeader($bearerToken = null) { + if ($bearerToken !== null) { + $bearerToken = 'Bearer ' . $bearerToken; + } + + Yii::$app->request->headers->set('Authorization', $bearerToken); + } + private function getComponentArguments() { return [ 'identityClass' => AccountIdentity::class, diff --git a/tests/codeception/api/unit/filters/NginxCacheTest.php b/tests/codeception/api/unit/filters/NginxCacheTest.php new file mode 100644 index 0000000..01f982d --- /dev/null +++ b/tests/codeception/api/unit/filters/NginxCacheTest.php @@ -0,0 +1,57 @@ +testAfterActionInternal(3600, 3600); + $this->testAfterActionInternal('@' . (time() + 30), '@' . (time() + 30)); + $this->testAfterActionInternal(function() { + return 3000; + }, 3000); + } + + private function testAfterActionInternal($ruleConfig, $expected) { + /** @var HeaderCollection|\PHPUnit_Framework_MockObject_MockObject $headers */ + $headers = $this->getMockBuilder(HeaderCollection::class) + ->setMethods(['set']) + ->getMock(); + + $headers->expects($this->once()) + ->method('set') + ->with('X-Accel-Expires', $expected); + + /** @var Request|\PHPUnit_Framework_MockObject_MockObject $request */ + $request = $this->getMockBuilder(Request::class) + ->setMethods(['getHeaders']) + ->getMock(); + + $request->expects($this->any()) + ->method('getHeaders') + ->willReturn($headers); + + Yii::$app->set('response', $request); + + /** @var Controller|\PHPUnit_Framework_MockObject_MockObject $controller */ + $controller = $this->getMockBuilder(Controller::class) + ->setConstructorArgs(['mock', Yii::$app]) + ->getMock(); + + $component = new NginxCache([ + 'rules' => [ + 'index' => $ruleConfig, + ], + ]); + + $component->afterAction(new Action('index', $controller), ''); + } + +} diff --git a/tests/codeception/api/unit/models/authentication/ConfirmEmailFormTest.php b/tests/codeception/api/unit/models/authentication/ConfirmEmailFormTest.php index 1fd318b..2bbd5d7 100644 --- a/tests/codeception/api/unit/models/authentication/ConfirmEmailFormTest.php +++ b/tests/codeception/api/unit/models/authentication/ConfirmEmailFormTest.php @@ -25,9 +25,15 @@ class ConfirmEmailFormTest extends TestCase { $this->assertInstanceOf(AccountSession::class, $result->getSession(), 'session was generated'); $activationExists = EmailActivation::find()->andWhere(['key' => $fixture['key']])->exists(); $this->assertFalse($activationExists, 'email activation key is not exist'); - /** @var Account $user */ - $user = Account::findOne($fixture['account_id']); - $this->assertEquals(Account::STATUS_ACTIVE, $user->status, 'user status changed to active'); + /** @var Account $account */ + $account = Account::findOne($fixture['account_id']); + $this->assertEquals(Account::STATUS_ACTIVE, $account->status, 'user status changed to active'); + + $message = $this->tester->grabLastSentAmqpMessage('events'); + $body = json_decode($message->getBody(), true); + $this->assertEquals($account->id, $body['accountId']); + $this->assertEquals($account->username, $body['newUsername']); + $this->assertNull($body['oldUsername']); } private function createModel($key) { diff --git a/tests/codeception/api/unit/models/profile/ChangeEmail/ConfirmNewEmailFormTest.php b/tests/codeception/api/unit/models/profile/ChangeEmail/ConfirmNewEmailFormTest.php index 68ab6c4..3ac9e26 100644 --- a/tests/codeception/api/unit/models/profile/ChangeEmail/ConfirmNewEmailFormTest.php +++ b/tests/codeception/api/unit/models/profile/ChangeEmail/ConfirmNewEmailFormTest.php @@ -18,9 +18,8 @@ class ConfirmNewEmailFormTest extends TestCase { } public function testChangeEmail() { - $accountId = $this->tester->grabFixture('accounts', 'account-with-change-email-finish-state')['id']; /** @var Account $account */ - $account = Account::findOne($accountId); + $account = Account::findOne($this->getAccountId()); $newEmailConfirmationFixture = $this->tester->grabFixture('emailActivations', 'newEmailConfirmation'); $model = new ConfirmNewEmailForm($account, [ 'key' => $newEmailConfirmationFixture['key'], @@ -32,6 +31,23 @@ class ConfirmNewEmailFormTest extends TestCase { ])); $data = unserialize($newEmailConfirmationFixture['_data']); $this->assertEquals($data['newEmail'], $account->email); + $this->tester->canSeeAmqpMessageIsCreated('events'); + } + + public function testCreateTask() { + /** @var Account $account */ + $account = Account::findOne($this->getAccountId()); + $model = new ConfirmNewEmailForm($account); + $model->createTask(1, 'test1@ely.by', 'test@ely.by'); + $message = $this->tester->grabLastSentAmqpMessage('events'); + $body = json_decode($message->getBody(), true); + $this->assertEquals(1, $body['accountId']); + $this->assertEquals('test1@ely.by', $body['newEmail']); + $this->assertEquals('test@ely.by', $body['oldEmail']); + } + + private function getAccountId() { + return $this->tester->grabFixture('accounts', 'account-with-change-email-finish-state')['id']; } } diff --git a/tests/codeception/api/unit/models/profile/ChangeUsernameFormTest.php b/tests/codeception/api/unit/models/profile/ChangeUsernameFormTest.php index a41dd75..158b17e 100644 --- a/tests/codeception/api/unit/models/profile/ChangeUsernameFormTest.php +++ b/tests/codeception/api/unit/models/profile/ChangeUsernameFormTest.php @@ -35,6 +35,7 @@ class ChangeUsernameFormTest extends TestCase { $this->assertTrue($model->change()); $this->assertEquals('my_new_nickname', Account::findOne($this->getAccountId())->username); $this->assertInstanceOf(UsernameHistory::class, UsernameHistory::findOne(['username' => 'my_new_nickname'])); + $this->tester->canSeeAmqpMessageIsCreated('events'); } public function testChangeWithoutChange() { @@ -49,7 +50,8 @@ class ChangeUsernameFormTest extends TestCase { 'AND', 'username' => $username, ['>=', 'applied_in', $callTime], - ]), 'no new UsernameHistory record, if we don\'t change nickname'); + ]), 'no new UsernameHistory record, if we don\'t change username'); + $this->tester->cantSeeAmqpMessageIsCreated('events'); } public function testChangeCase() { @@ -65,13 +67,17 @@ class ChangeUsernameFormTest extends TestCase { UsernameHistory::findOne(['username' => $newUsername]), 'username should change, if we change case of some letters' ); + $this->tester->canSeeAmqpMessageIsCreated('events'); } public function testCreateTask() { $model = new ChangeUsernameForm(); - $model->createEventTask('1', 'test1', 'test'); - // TODO: у меня пока нет идей о том, чтобы это как-то успешно протестировать, увы - // но по крайней мере можно убедиться, что оно не падает где-то на этом шаге + $model->createEventTask(1, 'test1', 'test'); + $message = $this->tester->grabLastSentAmqpMessage('events'); + $body = json_decode($message->getBody(), true); + $this->assertEquals(1, $body['accountId']); + $this->assertEquals('test1', $body['newUsername']); + $this->assertEquals('test', $body['oldUsername']); } private function getAccountId() { diff --git a/tests/codeception/api/unit/traits/ApiNormalizerTest.php b/tests/codeception/api/unit/traits/ApiNormalizerTest.php deleted file mode 100644 index 5641478..0000000 --- a/tests/codeception/api/unit/traits/ApiNormalizerTest.php +++ /dev/null @@ -1,35 +0,0 @@ -normalizeModelErrors([ - 'rulesAgreement' => [ - 'error.you_must_accept_rules', - ], - 'email' => [ - 'error.email_required', - ], - 'username' => [ - 'error.username_too_short', - 'error.username_not_unique', - ], - ]); - - $this->assertEquals([ - 'rulesAgreement' => 'error.you_must_accept_rules', - 'email' => 'error.email_required', - 'username' => 'error.username_too_short', - ], $normalized); - } - -} diff --git a/tests/codeception/common/_support/amqp/Helper.php b/tests/codeception/common/_support/amqp/Helper.php new file mode 100644 index 0000000..7c6cd1b --- /dev/null +++ b/tests/codeception/common/_support/amqp/Helper.php @@ -0,0 +1,94 @@ +seeAmqpMessageIsCreated(); + * + * // check that only 3 messages were created + * $I->seeAmqpMessageIsCreated(3); + * ``` + * + * @param string|null $exchange + * @param int|null $num + */ + public function seeAmqpMessageIsCreated($exchange = null, $num = null) { + if ($num === null) { + $this->assertNotEmpty($this->grabSentAmqpMessages($exchange), 'message were created'); + return; + } + + // TODO: заменить на assertCount() после релиза Codeception 2.2.7 + // https://github.com/Codeception/Codeception/pull/3802 + /** @noinspection PhpUnitTestsInspection */ + $this->assertEquals( + $num, + count($this->grabSentAmqpMessages($exchange)), + 'number of created messages is equal to ' . $num + ); + } + + /** + * Checks that no messages was created + * + * @param string|null $exchange + */ + public function dontSeeAmqpMessageIsCreated($exchange = null) { + $this->seeAmqpMessageIsCreated($exchange, 0); + } + + /** + * Returns last sent message + * + * @param string|null $exchange + * @return \PhpAmqpLib\Message\AMQPMessage + */ + public function grabLastSentAmqpMessage($exchange = null) { + $this->seeAmqpMessageIsCreated(); + $messages = $this->grabSentAmqpMessages($exchange); + + return end($messages); + } + + /** + * Returns array of all sent amqp messages. + * Each message is `\PhpAmqpLib\Message\AMQPMessage` instance. + * Useful to perform additional checks using `Asserts` module. + * + * @param string|null $exchange + * @return \PhpAmqpLib\Message\AMQPMessage[] + * @throws ModuleException + */ + public function grabSentAmqpMessages($exchange = null) { + $amqp = $this->grabComponent('amqp'); + if (!$amqp instanceof TestComponent) { + throw new ModuleException($this, 'AMQP module is not mocked, can\'t test messages'); + } + + return $amqp->getSentMessages($exchange); + } + + private function grabComponent(string $component) { + return $this->getYii2()->grabComponent($component); + } + + private function getYii2() : Yii2 { + $yii2 = $this->getModule('Yii2'); + if (!$yii2 instanceof Yii2) { + throw new ModuleException($this, 'Yii2 module must be configured'); + } + + return $yii2; + } + +} diff --git a/tests/codeception/common/_support/amqp/TestComponent.php b/tests/codeception/common/_support/amqp/TestComponent.php new file mode 100644 index 0000000..9b8e159 --- /dev/null +++ b/tests/codeception/common/_support/amqp/TestComponent.php @@ -0,0 +1,58 @@ +sentMessages[$exchangeName][] = $this->prepareMessage($message); + } + + /** + * @param string|null $exchangeName + * @return \PhpAmqpLib\Message\AMQPMessage[] + */ + public function getSentMessages(string $exchangeName = null) : array { + if ($exchangeName !== null) { + return $this->sentMessages[$exchangeName] ?? []; + } else { + $messages = []; + foreach($this->sentMessages as $exchangeGroup) { + foreach ($exchangeGroup as $message) { + $messages[] = $message; + } + } + + return $messages; + } + } + +} diff --git a/tests/codeception/common/codeception.yml b/tests/codeception/common/codeception.yml index f81585b..ff6249c 100644 --- a/tests/codeception/common/codeception.yml +++ b/tests/codeception/common/codeception.yml @@ -1,5 +1,6 @@ namespace: tests\codeception\common actor: Tester +params: [env] paths: tests: . log: _output diff --git a/tests/codeception/common/fixtures/OauthAccessTokenFixture.php b/tests/codeception/common/fixtures/OauthAccessTokenFixture.php new file mode 100644 index 0000000..d51f14f --- /dev/null +++ b/tests/codeception/common/fixtures/OauthAccessTokenFixture.php @@ -0,0 +1,17 @@ + [ + 'access_token' => '07541285-831e-1e47-e314-b950309a6fca', + 'session_id' => 1, + 'expire_time' => time() + 3600, + ], + 'admin-test1-expired' => [ + 'access_token' => '2977ec21-3022-96f8-544db-2e1df936908', + 'session_id' => 1, + 'expire_time' => time() - 3600, + ], +]; diff --git a/tests/codeception/common/fixtures/data/oauth-clients.php b/tests/codeception/common/fixtures/data/oauth-clients.php index c7b11a9..e7ddba5 100644 --- a/tests/codeception/common/fixtures/data/oauth-clients.php +++ b/tests/codeception/common/fixtures/data/oauth-clients.php @@ -6,7 +6,7 @@ return [ 'name' => 'Ely.by', 'description' => 'Всем знакомое елуби', 'redirect_uri' => 'http://ely.by', - 'account_id' => NULL, + 'account_id' => null, 'is_trusted' => 0, 'created_at' => 1455309271, ], @@ -16,8 +16,18 @@ return [ 'name' => 'TLauncher', 'description' => 'Лучший альтернативный лаунчер для Minecraft с большим количеством версий и их модификаций, а также возмоностью входа как с лицензионным аккаунтом, так и без него.', 'redirect_uri' => '', - 'account_id' => NULL, + 'account_id' => null, 'is_trusted' => 0, 'created_at' => 1455318468, ], + 'test1' => [ + 'id' => 'test1', + 'secret' => 'eEvrKHF47sqiaX94HsX-xXzdGiz3mcsq', + 'name' => 'Test1', + 'description' => 'Some description', + 'redirect_uri' => 'http://test1.net', + 'account_id' => null, + 'is_trusted' => 0, + 'created_at' => 1479937982, + ], ]; diff --git a/tests/codeception/common/fixtures/data/oauth-sessions.php b/tests/codeception/common/fixtures/data/oauth-sessions.php index d0b9c34..ebbc2d2 100644 --- a/tests/codeception/common/fixtures/data/oauth-sessions.php +++ b/tests/codeception/common/fixtures/data/oauth-sessions.php @@ -1,3 +1,10 @@ [ + 'id' => 1, + 'owner_type' => 'user', + 'owner_id' => 1, + 'client_id' => 'test1', + 'client_redirect_uri' => 'http://test1.net/oauth', + ], ]; diff --git a/tests/codeception/common/unit.suite.yml b/tests/codeception/common/unit.suite.yml index d072b09..98fb59d 100644 --- a/tests/codeception/common/unit.suite.yml +++ b/tests/codeception/common/unit.suite.yml @@ -6,3 +6,4 @@ modules: config: Yii2: configFile: '../config/common/unit.php' + cleanup: false diff --git a/tests/codeception/config/config.php b/tests/codeception/config/config.php index 3fbe89e..edd34d4 100644 --- a/tests/codeception/config/config.php +++ b/tests/codeception/config/config.php @@ -10,29 +10,18 @@ return [ ], ], 'components' => [ - 'db' => [ - 'dsn' => 'mysql:host=testdb;dbname=ely_accounts_test', - 'username' => 'ely_accounts_tester', - 'password' => 'ely_accounts_tester_password', - ], - 'mailer' => [ - 'useFileTransport' => true, - ], 'urlManager' => [ 'showScriptName' => true, ], - 'redis' => [ - 'hostname' => 'testredis', - ], - 'amqp' => [ - 'host' => 'testrabbit', - 'user' => 'ely-accounts-tester', - 'password' => 'tester-password', - 'vhost' => '/account.ely.by/tests', - ], 'security' => [ // Для тестов нам не сильно важна безопасность, а вот время прохождения тестов значительно сокращается 'passwordHashCost' => 4, ], + 'amqp' => [ + 'class' => tests\codeception\common\_support\amqp\TestComponent::class, + ], + 'sentry' => [ + 'enabled' => false, + ], ], ]; diff --git a/tests/codeception/console/codeception.yml b/tests/codeception/console/codeception.yml index 14c972e..57e8496 100644 --- a/tests/codeception/console/codeception.yml +++ b/tests/codeception/console/codeception.yml @@ -1,5 +1,6 @@ namespace: tests\codeception\console actor: Tester +params: [env] paths: tests: . log: _output diff --git a/tests/codeception/console/unit.suite.yml b/tests/codeception/console/unit.suite.yml index 3ac6f10..bdcb10b 100644 --- a/tests/codeception/console/unit.suite.yml +++ b/tests/codeception/console/unit.suite.yml @@ -6,3 +6,4 @@ modules: config: Yii2: configFile: '../config/console/unit.php' + cleanup: false diff --git a/tests/codeception/console/unit/controllers/CleanupControllerTest.php b/tests/codeception/console/unit/controllers/CleanupControllerTest.php new file mode 100644 index 0000000..bcb0cef --- /dev/null +++ b/tests/codeception/console/unit/controllers/CleanupControllerTest.php @@ -0,0 +1,31 @@ + OauthAccessTokenFixture::class, + ]; + } + + public function testActionAccessTokens() { + /** @var OauthAccessToken $validAccessToken */ + $validAccessToken = $this->tester->grabFixture('accessTokens', 'admin-test1'); + /** @var OauthAccessToken $expiredAccessToken */ + $expiredAccessToken = $this->tester->grabFixture('accessTokens', 'admin-test1-expired'); + + $controller = new CleanupController('cleanup', Yii::$app); + $this->assertEquals(0, $controller->actionAccessTokens()); + + $this->tester->canSeeRecord(OauthAccessToken::class, ['access_token' => $validAccessToken->access_token]); + $this->tester->cantSeeRecord(OauthAccessToken::class, ['access_token' => $expiredAccessToken->access_token]); + } + +} diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index 4c2d96d..66ea17f 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -9,18 +9,21 @@ services: depends_on: - testdb - testredis - - testrabbit volumes: - - ./codeception/_output:/var/www/html/tests/codeception/_output - - ./codeception/api/_output:/var/www/html/tests/codeception/api/_output - - ./codeception/common/_output:/var/www/html/tests/codeception/common/_output - - ./codeception/console/_output:/var/www/html/tests/codeception/console/_output + - ./..:/var/www/html environment: - - YII_DEBUG=true - - YII_ENV=test + YII_DEBUG: "true" + YII_ENV: "test" + # DB config + DB_HOST: "testdb" + DB_DATABASE: "ely_accounts_test" + DB_USER: "ely_accounts_tester" + DB_PASSWORD: "ely_accounts_tester_password" + # Redis config + REDIS_HOST: "testredis" # Это я потом, когда-нибудь, уберу - - XDEBUG_CONFIG=remote_host=10.254.254.254 - - PHP_IDE_CONFIG=serverName=docker + XDEBUG_CONFIG: "remote_host=10.254.254.254" + PHP_IDE_CONFIG: "serverName=docker" testdb: container_name: accountelyby_testdb @@ -36,11 +39,3 @@ services: testredis: container_name: accountelyby_testredis image: redis:3.0-alpine - - testrabbit: - container_name: accountelyby_testrabbit - image: rabbitmq:3.6 - environment: - RABBITMQ_DEFAULT_USER: "ely-accounts-tester" - RABBITMQ_DEFAULT_PASS: "tester-password" - RABBITMQ_DEFAULT_VHOST: "/account.ely.by/tests"