diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 04f976d..22c33d0 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -40,7 +40,7 @@ test:backend: php vendor/bin/codecept run -c tests test:frontend: - image: node:5.12 + image: node:8.2.1 stage: test cache: paths: diff --git a/Dockerfile b/Dockerfile index bc906e3..6aefae6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM registry.ely.by/elyby/accounts-php:1.3.0 +FROM registry.ely.by/elyby/accounts-php:1.4.0 # Вносим конфигурации для крона и воркеров COPY docker/cron/* /etc/cron.d/ @@ -46,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:quite --quiet \ + && npm run build:quiet \ && rm node_modules \ # Копируем билд наружу, чтобы его не затёрло volume в dev режиме && cp -r ./dist /var/www/dist \ diff --git a/Dockerfile-dev b/Dockerfile-dev index 2f82da8..3000374 100644 --- a/Dockerfile-dev +++ b/Dockerfile-dev @@ -1,4 +1,4 @@ -FROM registry.ely.by/elyby/accounts-php:1.3.0-dev +FROM registry.ely.by/elyby/accounts-php:1.4.0-dev # Вносим конфигурации для крона и воркеров COPY docker/cron/* /etc/cron.d/ @@ -43,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:quite --quiet \ + && npm run build:quiet \ && rm node_modules \ # Копируем билд наружу, чтобы его не затёрло volume в dev режиме && cp -r ./dist /var/www/dist \ diff --git a/api/components/OAuth2/Storage/AccessTokenStorage.php b/api/components/OAuth2/Storage/AccessTokenStorage.php index fdeb14c..c31742b 100644 --- a/api/components/OAuth2/Storage/AccessTokenStorage.php +++ b/api/components/OAuth2/Storage/AccessTokenStorage.php @@ -16,6 +16,9 @@ class AccessTokenStorage extends AbstractStorage implements AccessTokenInterface public function get($token) { $result = Json::decode((new Key($this->dataTable, $token))->getValue()); + if ($result === null) { + return null; + } $token = new AccessTokenEntity($this->server); $token->setId($result['id']); diff --git a/api/models/profile/TwoFactorAuthForm.php b/api/models/profile/TwoFactorAuthForm.php index 275ec97..8f6c31f 100644 --- a/api/models/profile/TwoFactorAuthForm.php +++ b/api/models/profile/TwoFactorAuthForm.php @@ -9,11 +9,11 @@ use BaconQrCode\Encoder\Encoder; use BaconQrCode\Renderer\Color\Rgb; use BaconQrCode\Renderer\Image\Svg; use BaconQrCode\Writer; -use Base32\Base32; use common\components\Qr\ElyDecorator; use common\helpers\Error as E; use common\models\Account; use OTPHP\TOTP; +use ParagonIE\ConstantTime\Encoding; use Yii; use yii\base\ErrorException; @@ -38,7 +38,7 @@ class TwoFactorAuthForm extends ApiForm { parent::__construct($config); } - public function rules() { + public function rules(): array { $bothScenarios = [self::SCENARIO_ACTIVATE, self::SCENARIO_DISABLE]; return [ ['timestamp', 'integer', 'on' => [self::SCENARIO_ACTIVATE]], @@ -63,7 +63,7 @@ class TwoFactorAuthForm extends ApiForm { $provisioningUri = $this->getTotp()->getProvisioningUri(); return [ - 'qr' => base64_encode($this->drawQrCode($provisioningUri)), + 'qr' => 'data:image/svg+xml,' . trim($this->drawQrCode($provisioningUri)), 'uri' => $provisioningUri, 'secret' => $this->account->otp_secret, ]; @@ -124,18 +124,19 @@ class TwoFactorAuthForm extends ApiForm { * @return TOTP */ public function getTotp(): TOTP { - $totp = new TOTP($this->account->email, $this->account->otp_secret); + $totp = TOTP::create($this->account->otp_secret); + $totp->setLabel($this->account->email); $totp->setIssuer('Ely.by'); return $totp; } public function drawQrCode(string $content): string { + $content = $this->forceMinimalQrContentLength($content); + $renderer = new Svg(); - $renderer->setHeight(256); - $renderer->setWidth(256); - $renderer->setForegroundColor(new Rgb(32, 126, 92)); $renderer->setMargin(0); + $renderer->setForegroundColor(new Rgb(32, 126, 92)); $renderer->addDecorator(new ElyDecorator()); $writer = new Writer($renderer); @@ -154,10 +155,27 @@ class TwoFactorAuthForm extends ApiForm { */ protected function setOtpSecret(int $length = 24): void { $randomBytesLength = ceil($length / 1.6); - $this->account->otp_secret = substr(trim(Base32::encode(random_bytes($randomBytesLength)), '='), 0, $length); + $randomBase32 = trim(Encoding::base32EncodeUpper(random_bytes($randomBytesLength)), '='); + $this->account->otp_secret = substr($randomBase32, 0, $length); if (!$this->account->save()) { throw new ErrorException('Cannot set account otp_secret'); } } + /** + * В используемой либе для рендеринга QR кода нет возможности указать QR code version. + * http://www.qrcode.com/en/about/version.html + * По какой-то причине 7 и 8 версии не читаются вовсе, с логотипом или без. + * Поэтому нужно иначально привести строку к длинне 9 версии (91), добавляя к концу + * строки необходимое количество символов "#". Этот символ используется, т.к. нашим + * контентом является ссылка и чтобы не вводить лишние параметры мы помечаем добавочную + * часть как хеш часть и все программы для чтения QR кодов продолжают свою работу. + * + * @param string $content + * @return string + */ + private function forceMinimalQrContentLength(string $content): string { + return str_pad($content, 91, '#'); + } + } diff --git a/api/validators/TotpValidator.php b/api/validators/TotpValidator.php index e436d0c..68bbc5e 100644 --- a/api/validators/TotpValidator.php +++ b/api/validators/TotpValidator.php @@ -4,6 +4,7 @@ namespace api\validators; use common\helpers\Error as E; use common\models\Account; use OTPHP\TOTP; +use RangeException; use Yii; use yii\base\InvalidConfigException; use yii\validators\Validator; @@ -48,8 +49,12 @@ class TotpValidator extends Validator { } protected function validateValue($value) { - $totp = new TOTP(null, $this->account->otp_secret); - if (!$totp->verify((string)$value, $this->getTimestamp(), $this->window)) { + try { + $totp = TOTP::create($this->account->otp_secret); + if (!$totp->verify((string)$value, $this->getTimestamp(), $this->window)) { + return [E::OTP_TOKEN_INCORRECT, []]; + } + } catch (RangeException $e) { return [E::OTP_TOKEN_INCORRECT, []]; } diff --git a/common/config/config.php b/common/config/config.php index 74c5481..3d332f6 100644 --- a/common/config/config.php +++ b/common/config/config.php @@ -1,6 +1,6 @@ '1.1.16', + 'version' => '1.1.17', 'vendorPath' => dirname(dirname(__DIR__)) . '/vendor', 'components' => [ 'cache' => [ diff --git a/composer.json b/composer.json index 9624f06..6dc66a0 100644 --- a/composer.json +++ b/composer.json @@ -1,21 +1,12 @@ { - "name": "yiisoft/yii2-app-advanced", - "description": "Yii 2 Advanced Project Template", - "keywords": ["yii2", "framework", "advanced", "project template"], - "homepage": "http://www.yiiframework.com/", + "name": "elyby/accounts", + "description": "Authentication service for Ely.by", + "homepage": "https://account.ely.by", "type": "project", - "license": "BSD-3-Clause", - "support": { - "issues": "https://github.com/yiisoft/yii2/issues?state=open", - "forum": "http://www.yiiframework.com/forum/", - "wiki": "http://www.yiiframework.com/wiki/", - "irc": "irc://irc.freenode.net/yii", - "source": "https://github.com/yiisoft/yii2" - }, "minimum-stability": "stable", "require": { "php": "^7.1", - "yiisoft/yii2": "2.0.11.2", + "yiisoft/yii2": "2.0.12", "yiisoft/yii2-swiftmailer": "*", "ramsey/uuid": "^3.5.0", "league/oauth2-server": "dev-improvements#fbaa9b0bd3d8050235ba7dde90f731764122bc20", @@ -29,8 +20,10 @@ "predis/predis": "^1.0", "mito/yii2-sentry": "^1.0", "minime/annotations": "~3.0", - "spomky-labs/otphp": "8.3.1", - "bacon/bacon-qr-code": "^1.0" + "spomky-labs/otphp": "^9.0.2", + "bacon/bacon-qr-code": "^1.0", + "roave/security-advisories": "dev-master", + "paragonie/constant_time_encoding": "^2.0" }, "require-dev": { "yiisoft/yii2-codeception": "*", @@ -38,16 +31,13 @@ "yiisoft/yii2-faker": "*", "flow/jsonpath": "^0.3.1", "phpunit/phpunit": "^5.7", - "codeception/codeception": "~2.3", + "codeception/codeception": "2.3.4", "codeception/specify": "*", "codeception/verify": "*", "phploc/phploc": "^3.0.1", "mockery/mockery": "1.0.0-alpha1", "php-mock/php-mock-mockery": "dev-mockery-1.0.0#03956ed4b34ae25bc20a0677500f4f4b416f976c" }, - "config": { - "process-timeout": 1800 - }, "repositories": [ { "type": "composer", diff --git a/tests/codeception/api/functional.suite.yml b/tests/codeception/api/functional.suite.yml index 3dd11ab..2c726d3 100644 --- a/tests/codeception/api/functional.suite.yml +++ b/tests/codeception/api/functional.suite.yml @@ -13,6 +13,7 @@ modules: Yii2: configFile: '../config/api/functional.php' cleanup: true + transaction: false Redis: host: "%REDIS_HOST%" port: 6379 diff --git a/tests/codeception/api/functional/ForgotPasswordCest.php b/tests/codeception/api/functional/ForgotPasswordCest.php index 0444838..36023bd 100644 --- a/tests/codeception/api/functional/ForgotPasswordCest.php +++ b/tests/codeception/api/functional/ForgotPasswordCest.php @@ -74,7 +74,7 @@ class ForgotPasswordCest { public function testForgotPasswordByAccountWithOtp(FunctionalTester $I) { $I->wantTo('create new password recover request by passing username and otp token'); - $totp = new TOTP(null, 'secret-secret-secret'); + $totp = TOTP::create('BBBB'); $this->route->forgotPassword('AccountWithEnabledOtp', $totp->now()); $this->assertSuccessResponse($I, true); } diff --git a/tests/codeception/api/functional/LoginCest.php b/tests/codeception/api/functional/LoginCest.php index ab11f50..6f2adaf 100644 --- a/tests/codeception/api/functional/LoginCest.php +++ b/tests/codeception/api/functional/LoginCest.php @@ -206,7 +206,7 @@ class LoginCest { $route = new AuthenticationRoute($I); $I->wantTo('login into account with enabled otp'); - $route->login('AccountWithEnabledOtp', 'password_0', (new TOTP(null, 'secret-secret-secret'))->now()); + $route->login('AccountWithEnabledOtp', 'password_0', (TOTP::create('BBBB'))->now()); $I->canSeeResponseContainsJson([ 'success' => true, ]); diff --git a/tests/codeception/api/functional/TwoFactorAuthDisableCest.php b/tests/codeception/api/functional/TwoFactorAuthDisableCest.php index 5e41f83..1e288d1 100644 --- a/tests/codeception/api/functional/TwoFactorAuthDisableCest.php +++ b/tests/codeception/api/functional/TwoFactorAuthDisableCest.php @@ -49,7 +49,7 @@ class TwoFactorAuthDisableCest { public function testSuccessEnable(FunctionalTester $I) { $I->amAuthenticated('AccountWithEnabledOtp'); - $totp = new TOTP(null, 'secret-secret-secret'); + $totp = TOTP::create('BBBB'); $this->route->disable($totp->now(), 'password_0'); $I->canSeeResponseCodeIs(200); $I->canSeeResponseIsJson(); diff --git a/tests/codeception/api/functional/TwoFactorAuthEnableCest.php b/tests/codeception/api/functional/TwoFactorAuthEnableCest.php index aee002e..5fea216 100644 --- a/tests/codeception/api/functional/TwoFactorAuthEnableCest.php +++ b/tests/codeception/api/functional/TwoFactorAuthEnableCest.php @@ -49,7 +49,7 @@ class TwoFactorAuthEnableCest { public function testSuccessEnable(FunctionalTester $I) { $I->amAuthenticated('AccountWithOtpSecret'); - $totp = new TOTP(null, 'some otp secret value'); + $totp = TOTP::create('AAAA'); $this->route->enable($totp->now(), 'password_0'); $I->canSeeResponseCodeIs(200); $I->canSeeResponseIsJson(); diff --git a/tests/codeception/api/unit.suite.yml b/tests/codeception/api/unit.suite.yml index ddf9713..ccb1862 100644 --- a/tests/codeception/api/unit.suite.yml +++ b/tests/codeception/api/unit.suite.yml @@ -9,3 +9,4 @@ modules: Yii2: configFile: '../config/api/unit.php' cleanup: true + transaction: false diff --git a/tests/codeception/api/unit/models/authentication/ForgotPasswordFormTest.php b/tests/codeception/api/unit/models/authentication/ForgotPasswordFormTest.php index 4ae4cc9..e6d82c9 100644 --- a/tests/codeception/api/unit/models/authentication/ForgotPasswordFormTest.php +++ b/tests/codeception/api/unit/models/authentication/ForgotPasswordFormTest.php @@ -48,7 +48,7 @@ class ForgotPasswordFormTest extends TestCase { $model->validateTotpToken('token'); $this->assertEquals(['error.token_incorrect'], $model->getErrors('token')); - $totp = new TOTP(null, 'secret-secret-secret'); + $totp = TOTP::create('BBBB'); $model = new ForgotPasswordForm(); $model->login = 'AccountWithEnabledOtp'; $model->token = $totp->now(); diff --git a/tests/codeception/api/unit/models/authentication/LoginFormTest.php b/tests/codeception/api/unit/models/authentication/LoginFormTest.php index 0cf524e..1307eca 100644 --- a/tests/codeception/api/unit/models/authentication/LoginFormTest.php +++ b/tests/codeception/api/unit/models/authentication/LoginFormTest.php @@ -76,7 +76,7 @@ class LoginFormTest extends TestCase { $account = new AccountIdentity(['password' => '12345678']); $account->password = '12345678'; $account->is_otp_enabled = true; - $account->otp_secret = 'mock secret'; + $account->otp_secret = 'AAAA'; $this->specify('error.token_incorrect if totp invalid', function() use ($account) { $model = $this->createModel([ @@ -88,7 +88,7 @@ class LoginFormTest extends TestCase { $this->assertEquals(['error.token_incorrect'], $model->getErrors('token')); }); - $totp = new TOTP(null, 'mock secret'); + $totp = TOTP::create($account->otp_secret); $this->specify('no errors if password valid', function() use ($account, $totp) { $model = $this->createModel([ 'password' => '12345678', diff --git a/tests/codeception/api/unit/models/authentication/RegistrationFormTest.php b/tests/codeception/api/unit/models/authentication/RegistrationFormTest.php index d3e7c57..2db914f 100644 --- a/tests/codeception/api/unit/models/authentication/RegistrationFormTest.php +++ b/tests/codeception/api/unit/models/authentication/RegistrationFormTest.php @@ -10,6 +10,8 @@ use common\models\UsernameHistory; use GuzzleHttp\ClientInterface; use tests\codeception\api\unit\TestCase; use tests\codeception\common\fixtures\AccountFixture; +use tests\codeception\common\fixtures\EmailActivationFixture; +use tests\codeception\common\fixtures\UsernameHistoryFixture; use Yii; use yii\web\Request; use const common\LATEST_RULES_VERSION; @@ -30,6 +32,8 @@ class RegistrationFormTest extends TestCase { public function _fixtures() { return [ 'accounts' => AccountFixture::class, + 'emailActivations' => EmailActivationFixture::class, + 'usernameHistory' => UsernameHistoryFixture::class, ]; } diff --git a/tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php b/tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php index 780652c..5157b05 100644 --- a/tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php +++ b/tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php @@ -35,7 +35,7 @@ class TwoFactorAuthFormTest extends TestCase { $model->expects($this->once()) ->method('drawQrCode') - ->willReturn('this is qr code, trust me'); + ->willReturn('<_/>'); $result = $model->getCredentials(); $this->assertTrue(is_array($result)); @@ -44,7 +44,7 @@ class TwoFactorAuthFormTest extends TestCase { $this->assertArrayHasKey('secret', $result); $this->assertNotNull($account->otp_secret); $this->assertEquals($account->otp_secret, $result['secret']); - $this->assertEquals(base64_encode('this is qr code, trust me'), $result['qr']); + $this->assertEquals('data:image/svg+xml,<_/>', $result['qr']); /** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */ $account = $this->getMockBuilder(Account::class) @@ -197,10 +197,12 @@ class TwoFactorAuthFormTest extends TestCase { $model = new TwoFactorAuthForm($account); $this->callProtected($model, 'setOtpSecret'); $this->assertEquals(24, strlen($model->getAccount()->otp_secret)); + $this->assertSame(strtoupper($model->getAccount()->otp_secret), $model->getAccount()->otp_secret); $model = new TwoFactorAuthForm($account); $this->callProtected($model, 'setOtpSecret', 25); $this->assertEquals(25, strlen($model->getAccount()->otp_secret)); + $this->assertSame(strtoupper($model->getAccount()->otp_secret), $model->getAccount()->otp_secret); } } diff --git a/tests/codeception/api/unit/validators/TotpValidatorTest.php b/tests/codeception/api/unit/validators/TotpValidatorTest.php index dfc0bd3..1954009 100644 --- a/tests/codeception/api/unit/validators/TotpValidatorTest.php +++ b/tests/codeception/api/unit/validators/TotpValidatorTest.php @@ -13,8 +13,8 @@ class TotpValidatorTest extends TestCase { public function testValidateValue() { $account = new Account(); - $account->otp_secret = 'some secret'; - $controlTotp = new TOTP(null, $account->otp_secret); + $account->otp_secret = 'AAAA'; + $controlTotp = TOTP::create($account->otp_secret); $validator = new TotpValidator(['account' => $account]); diff --git a/tests/codeception/common/fixtures/data/accounts.php b/tests/codeception/common/fixtures/data/accounts.php index c8c79d3..fe15830 100644 --- a/tests/codeception/common/fixtures/data/accounts.php +++ b/tests/codeception/common/fixtures/data/accounts.php @@ -156,7 +156,7 @@ return [ 'lang' => 'ru', 'status' => \common\models\Account::STATUS_ACTIVE, 'rules_agreement_version' => \common\LATEST_RULES_VERSION, - 'otp_secret' => 'some otp secret value', + 'otp_secret' => 'AAAA', 'is_otp_enabled' => false, 'created_at' => 1485124615, 'updated_at' => 1485124615, @@ -171,7 +171,7 @@ return [ 'lang' => 'ru', 'status' => \common\models\Account::STATUS_ACTIVE, 'rules_agreement_version' => \common\LATEST_RULES_VERSION, - 'otp_secret' => 'secret-secret-secret', + 'otp_secret' => 'BBBB', 'is_otp_enabled' => true, 'created_at' => 1485124685, 'updated_at' => 1485124685, diff --git a/tests/codeception/common/unit.suite.yml b/tests/codeception/common/unit.suite.yml index 74f9d6a..11d4bb4 100644 --- a/tests/codeception/common/unit.suite.yml +++ b/tests/codeception/common/unit.suite.yml @@ -8,3 +8,4 @@ modules: Yii2: configFile: '../config/common/unit.php' cleanup: true + transaction: false diff --git a/tests/codeception/console/unit.suite.yml b/tests/codeception/console/unit.suite.yml index 110e551..baa5a80 100644 --- a/tests/codeception/console/unit.suite.yml +++ b/tests/codeception/console/unit.suite.yml @@ -8,3 +8,4 @@ modules: Yii2: configFile: '../config/console/unit.php' cleanup: true + transaction: false