diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c7e4460..ce9d083 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -46,7 +46,7 @@ test:frontend: - frontend/node_modules script: - cd frontend - - npm i --silent > /dev/null + - npm run build:install --silent - npm run lint --silent # - npm run flow --silent # disabled due to missing libelf.so.1 in docker container - npm run test --silent diff --git a/Dockerfile b/Dockerfile index 6c7b92b..b15dad6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ COPY ./composer.json /var/www/composer.json # Устанавливаем зависимости PHP RUN cd .. \ - && composer install --no-interaction --no-suggest --no-dev --optimize-autoloader \ + && composer install --no-interaction --no-suggest --no-dev --classmap-authoritative \ && cd - # Устанавливаем зависимости для Node.js @@ -34,7 +34,7 @@ COPY ./frontend/scripts /var/www/frontend/scripts COPY ./frontend/webpack-utils /var/www/frontend/webpack-utils RUN cd ../frontend \ - && npm install --quiet --depth -1 \ + && npm run build:install \ && cd - # Удаляем ключи из production контейнера на всякий случай diff --git a/Dockerfile-dev b/Dockerfile-dev index 0f3e618..b2ea2a5 100644 --- a/Dockerfile-dev +++ b/Dockerfile-dev @@ -34,7 +34,7 @@ COPY ./frontend/scripts /var/www/frontend/scripts COPY ./frontend/webpack-utils /var/www/frontend/webpack-utils RUN cd ../frontend \ - && npm install --quiet --depth -1 \ + && npm run build:install \ && cd - # Наконец переносим все сорцы внутрь контейнера diff --git a/api/components/TestData.php b/api/components/TestData.php new file mode 100644 index 0000000..ac4b417 --- /dev/null +++ b/api/components/TestData.php @@ -0,0 +1,174 @@ + 'beforeSignup', + 'signup/repeat-message' => 'beforeRepeatMessage', + 'signup/confirm' => 'beforeSignupConfirm', + 'authentication/forgot-password' => 'beforeForgotPassword', + 'authentication/recover-password' => 'beforeRecoverPassword', + 'default/get' => 'beforeAccountGet', + 'default/email-verification' => 'beforeAccountEmailVerification', + 'default/new-email-verification' => 'beforeAccountNewEmailVerification', + 'default/email' => 'beforeAccountChangeEmail', + ]; + + public static function getInstance(): callable { + return Closure::fromCallable([new static(), 'beforeAction']); + } + + public function beforeAction(ActionEvent $event): void { + $id = $event->action->controller->id . '/' . $event->action->id; + if (!isset(self::MAP[$id])) { + return; + } + + $handler = self::MAP[$id]; + $request = Yii::$app->request; + $response = Yii::$app->response; + $result = $this->$handler($request, $response); + if ($result === null) { + return; + } + + $response->content = Json::encode($result); + + // Prevent request execution + $event->isValid = false; + $event->handled = true; + } + + public function beforeSignup(Request $request): ?array { + $email = $request->post('email'); + if ($email === 'let-me-register@ely.by') { + return ['success' => true]; + } + + return null; + } + + public function beforeRepeatMessage(Request $request): ?array { + $email = $request->post('email'); + if ($email === 'let-me-register@ely.by' || $email === 'let-me-repeat@ely.by') { + return ['success' => true]; + } + + return null; + } + + public function beforeSignupConfirm(Request $request): ?array { + $email = $request->post('key'); + if ($email === 'LETMEIN') { + return [ + 'success' => true, + 'access_token' => 'dummy_token', + 'expires_in' => time() + 60, + ]; + } + + return null; + } + + public function beforeForgotPassword(Request $request): ?array { + $login = $request->post('login'); + if ($login === 'let-me-recover@ely.by') { + return [ + 'success' => true, + 'data' => [ + 'canRepeatIn' => time() + 60, + 'repeatFrequency' => 60, + ], + ]; + } + + return null; + } + + public function beforeRecoverPassword(Request $request): ?array { + $key = $request->post('key'); + if ($key === 'LETMEIN') { + return [ + 'success' => true, + 'access_token' => 'dummy_token', + 'expires_in' => time() + 60, + ]; + } + + return null; + } + + public function beforeAccountGet(Request $request): ?array { + $httpAuth = $request->getHeaders()->get('authorization'); + if ($httpAuth === 'Bearer dummy_token') { + return [ + 'id' => 1, + 'uuid' => 'f63cd5e1-680f-4c2d-baa2-cc7bb174b71a', + 'username' => 'dummy', + 'isOtpEnabled' => false, + 'registeredAt' => time(), + 'lang' => 'en', + 'elyProfileLink' => 'http://ely.by/u1', + 'email' => 'let-me-register@ely.by', + 'isActive' => true, + 'passwordChangedAt' => time(), + 'hasMojangUsernameCollision' => false, + 'shouldAcceptRules' => false, + ]; + } + + return null; + } + + public function beforeAccountEmailVerification(Request $request): ?array { + $httpAuth = $request->getHeaders()->get('authorization'); + if ($httpAuth === 'Bearer dummy_token') { + $password = $request->post('password'); + if (empty($password)) { + return [ + 'success' => false, + 'errors' => [ + 'password' => 'error.password_required', + ], + ]; + } + + return [ + 'success' => true, + ]; + } + + return null; + } + + public function beforeAccountNewEmailVerification(Request $request): ?array { + $key = $request->post('key'); + if ($key === 'LETMEIN') { + return [ + 'success' => true, + ]; + } + + return null; + } + + public function beforeAccountChangeEmail(Request $request): ?array { + $key = $request->post('key'); + if ($key === 'LETMEIN') { + return [ + 'success' => true, + 'email' => 'brand-new-email@ely.by', + ]; + } + + return null; + } + +} diff --git a/api/components/User/JwtIdentity.php b/api/components/User/JwtIdentity.php index 78a8491..a168013 100644 --- a/api/components/User/JwtIdentity.php +++ b/api/components/User/JwtIdentity.php @@ -4,6 +4,7 @@ namespace api\components\User; use common\models\Account; use Emarref\Jwt\Claim\Subject; use Emarref\Jwt\Exception\ExpiredException; +use Emarref\Jwt\Exception\InvalidSubjectException; use Emarref\Jwt\Token; use Exception; use Yii; @@ -28,7 +29,8 @@ class JwtIdentity implements IdentityInterface { $component = Yii::$app->user; try { $token = $component->parseToken($rawToken); - } catch (ExpiredException $e) { + } catch (ExpiredException | InvalidSubjectException $e) { + // InvalidSubjectException is temporary solution and should be removed in the next release throw new UnauthorizedHttpException('Token expired'); } catch (Exception $e) { Yii::error($e); diff --git a/api/config/config.php b/api/config/config.php index 8969ef5..c38ea0c 100644 --- a/api/config/config.php +++ b/api/config/config.php @@ -87,4 +87,5 @@ return [ 'internal' => api\modules\internal\Module::class, 'accounts' => api\modules\accounts\Module::class, ], + 'on beforeAction' => api\components\TestData::getInstance(), ]; diff --git a/api/models/OauthProcess.php b/api/models/OauthProcess.php index 40c601e..c666922 100644 --- a/api/models/OauthProcess.php +++ b/api/models/OauthProcess.php @@ -7,6 +7,7 @@ use api\components\OAuth2\Grants\AuthCodeGrant; use api\components\OAuth2\Grants\AuthorizeParams; use common\models\Account; use common\models\OauthClient; +use common\rbac\Permissions as P; use League\OAuth2\Server\AuthorizationServer; use League\OAuth2\Server\Exception\InvalidGrantException; use League\OAuth2\Server\Exception\OAuthException; @@ -16,6 +17,11 @@ use yii\helpers\ArrayHelper; class OauthProcess { + private const INTERNAL_PERMISSIONS_TO_PUBLIC_SCOPES = [ + P::OBTAIN_OWN_ACCOUNT_INFO => 'account_info', + P::OBTAIN_ACCOUNT_EMAIL => 'account_email', + ]; + /** * @var AuthorizationServer */ @@ -196,11 +202,21 @@ class OauthProcess { 'description' => ArrayHelper::getValue($queryParams, 'description', $client->description), ], 'session' => [ - 'scopes' => array_keys($scopes), + 'scopes' => $this->fixScopesNames(array_keys($scopes)), ], ]; } + private function fixScopesNames(array $scopes): array { + foreach ($scopes as &$scope) { + if (isset(self::INTERNAL_PERMISSIONS_TO_PUBLIC_SCOPES[$scope])) { + $scope = self::INTERNAL_PERMISSIONS_TO_PUBLIC_SCOPES[$scope]; + } + } + + return $scopes; + } + private function buildErrorResponse(OAuthException $e): array { $response = [ 'success' => false, diff --git a/api/models/authentication/ForgotPasswordForm.php b/api/models/authentication/ForgotPasswordForm.php index 5ff3b7f..4c92256 100644 --- a/api/models/authentication/ForgotPasswordForm.php +++ b/api/models/authentication/ForgotPasswordForm.php @@ -3,7 +3,6 @@ namespace api\models\authentication; use api\components\ReCaptcha\Validator as ReCaptchaValidator; use api\models\base\ApiForm; -use api\validators\TotpValidator; use common\emails\EmailHelper; use common\helpers\Error as E; use api\traits\AccountFinder; @@ -20,17 +19,11 @@ class ForgotPasswordForm extends ApiForm { public $login; - public $totp; - public function rules() { return [ ['captcha', ReCaptchaValidator::class], ['login', 'required', 'message' => E::LOGIN_REQUIRED], ['login', 'validateLogin'], - ['totp', 'required', 'when' => function(self $model) { - return !$this->hasErrors() && $model->getAccount()->is_otp_enabled; - }, 'message' => E::TOTP_REQUIRED], - ['totp', 'validateTotp'], ['login', 'validateActivity'], ['login', 'validateFrequency'], ]; @@ -44,21 +37,6 @@ class ForgotPasswordForm extends ApiForm { } } - public function validateTotp($attribute) { - if ($this->hasErrors()) { - return; - } - - $account = $this->getAccount(); - if (!$account->is_otp_enabled) { - return; - } - - $validator = new TotpValidator(['account' => $account]); - $validator->window = 1; - $validator->validateAttribute($this, $attribute); - } - public function validateActivity($attribute) { if (!$this->hasErrors()) { $account = $this->getAccount(); diff --git a/common/config/config.php b/common/config/config.php index 44f09f4..99e5160 100644 --- a/common/config/config.php +++ b/common/config/config.php @@ -1,6 +1,6 @@ '1.1.18', + 'version' => '1.1.19', 'vendorPath' => dirname(__DIR__, 2) . '/vendor', 'components' => [ 'cache' => [ diff --git a/composer.json b/composer.json index eac23b1..fb033f1 100644 --- a/composer.json +++ b/composer.json @@ -33,9 +33,8 @@ "codeception/codeception": "2.3.6", "codeception/specify": "*", "codeception/verify": "*", - "phploc/phploc": "^3.0.1", - "mockery/mockery": "dev-master#87fc5f641657833f7c96f14ed3067effe94a0824", - "php-mock/php-mock-mockery": "dev-mockery-1.0.0#03956ed4b34ae25bc20a0677500f4f4b416f976c" + "mockery/mockery": "^1.0.0", + "php-mock/php-mock-mockery": "^1.2.0" }, "repositories": [ { @@ -49,15 +48,8 @@ { "type": "git", "url": "git@gitlab.ely.by:elyby/email-renderer.git" - }, - { - "type": "git", - "url": "git@github.com:erickskrauch/php-mock-mockery.git" } ], - "scripts": { - "phploc" : "phploc ./api ./common ./console" - }, "autoload": { "files": [ "common/consts.php" diff --git a/tests/codeception/api/functional/ForgotPasswordCest.php b/tests/codeception/api/functional/ForgotPasswordCest.php index 823015d..72a7ef8 100644 --- a/tests/codeception/api/functional/ForgotPasswordCest.php +++ b/tests/codeception/api/functional/ForgotPasswordCest.php @@ -1,7 +1,6 @@ 'error.login_not_exist', ], ]); - - $this->route->forgotPassword('AccountWithEnabledOtp'); - $I->canSeeResponseContainsJson([ - 'success' => false, - 'errors' => [ - 'totp' => 'error.totp_required', - ], - ]); - - $this->route->forgotPassword('AccountWithEnabledOtp'); - $I->canSeeResponseContainsJson([ - 'success' => false, - 'errors' => [ - 'totp' => 'error.totp_required', - ], - ]); - - $this->route->forgotPassword('AccountWithEnabledOtp', '123456'); - $I->canSeeResponseContainsJson([ - 'success' => false, - 'errors' => [ - 'totp' => 'error.totp_incorrect', - ], - ]); } public function testForgotPasswordByEmail(FunctionalTester $I) { @@ -72,13 +47,6 @@ class ForgotPasswordCest { $this->assertSuccessResponse($I, true); } - public function testForgotPasswordByAccountWithOtp(FunctionalTester $I) { - $I->wantTo('create new password recover request by passing username and otp totp'); - $totp = TOTP::create('BBBB'); - $this->route->forgotPassword('AccountWithEnabledOtp', $totp->now()); - $this->assertSuccessResponse($I, true); - } - public function testDataForFrequencyError(FunctionalTester $I) { $I->wantTo('get info about time to repeat recover password request'); $this->route->forgotPassword('Notch'); diff --git a/tests/codeception/api/functional/oauth/AuthCodeCest.php b/tests/codeception/api/functional/oauth/AuthCodeCest.php index b2829e3..daccabb 100644 --- a/tests/codeception/api/functional/oauth/AuthCodeCest.php +++ b/tests/codeception/api/functional/oauth/AuthCodeCest.php @@ -24,7 +24,7 @@ class AuthCodeCest { 'ely', 'http://ely.by', 'code', - [P::MINECRAFT_SERVER_SESSION], + [P::MINECRAFT_SERVER_SESSION, 'account_info', 'account_email'], 'test-state' )); $I->canSeeResponseCodeIs(200); @@ -35,7 +35,7 @@ class AuthCodeCest { 'client_id' => 'ely', 'redirect_uri' => 'http://ely.by', 'response_type' => 'code', - 'scope' => 'minecraft_server_session', + 'scope' => 'minecraft_server_session,account_info,account_email', 'state' => 'test-state', ], 'client' => [ @@ -46,6 +46,8 @@ class AuthCodeCest { 'session' => [ 'scopes' => [ 'minecraft_server_session', + 'account_info', + 'account_email', ], ], ]); diff --git a/tests/codeception/api/unit/models/authentication/ForgotPasswordFormTest.php b/tests/codeception/api/unit/models/authentication/ForgotPasswordFormTest.php index ad73d27..6436911 100644 --- a/tests/codeception/api/unit/models/authentication/ForgotPasswordFormTest.php +++ b/tests/codeception/api/unit/models/authentication/ForgotPasswordFormTest.php @@ -6,7 +6,6 @@ use api\models\authentication\ForgotPasswordForm; use Codeception\Specify; use common\models\EmailActivation; use GuzzleHttp\ClientInterface; -use OTPHP\TOTP; use tests\codeception\api\unit\TestCase; use tests\codeception\common\fixtures\AccountFixture; use tests\codeception\common\fixtures\EmailActivationFixture; @@ -41,21 +40,6 @@ class ForgotPasswordFormTest extends TestCase { $this->assertEmpty($model->getErrors('login'), 'empty errors if login is exists'); } - public function testValidateTotp() { - $model = new ForgotPasswordForm(); - $model->login = 'AccountWithEnabledOtp'; - $model->totp = '123456'; - $model->validateTotp('totp'); - $this->assertEquals(['error.totp_incorrect'], $model->getErrors('totp')); - - $totp = TOTP::create('BBBB'); - $model = new ForgotPasswordForm(); - $model->login = 'AccountWithEnabledOtp'; - $model->totp = $totp->now(); - $model->validateTotp('totp'); - $this->assertEmpty($model->getErrors('totp')); - } - public function testValidateActivity() { $model = new ForgotPasswordForm([ 'login' => $this->tester->grabFixture('accounts', 'not-activated-account')['username'],