From 3b9ef7ea703c20c81d205f74c0f5d7c8cf06143a Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sat, 21 Jan 2017 01:54:30 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=B0=20?= =?UTF-8?q?=D0=B8=D0=BD=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B4=D0=B2=D1=83=D1=85=D1=84=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=BD=D0=BE=D0=B9=20=D0=B0=D1=83=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D0=B8=20=D0=94?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=B2=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B4=D0=B0=D1=82=D0=BE=D1=80=20=D0=B4=D0=BB=D1=8F=20TOT?= =?UTF-8?q?P=20=D0=BA=D0=BE=D0=B4=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/controllers/TwoFactorAuthController.php | 37 ++++++++ api/models/profile/TwoFactorAuthForm.php | 95 +++++++++++++++++++ api/validators/TotpValidator.php | 51 ++++++++++ common/helpers/Error.php | 3 + common/models/Account.php | 2 + .../m170118_224937_account_otp_secrets.php | 17 ++++ .../api/_pages/TwoFactorAuthRoute.php | 16 ++++ .../TwoFactorAuthCredentialsCest.php | 28 ++++++ .../models/profile/TwoFactorAuthFormTest.php | 67 +++++++++++++ .../api/unit/validators/TotpValidatorTest.php | 35 +++++++ 10 files changed, 351 insertions(+) create mode 100644 api/controllers/TwoFactorAuthController.php create mode 100644 api/models/profile/TwoFactorAuthForm.php create mode 100644 api/validators/TotpValidator.php create mode 100644 console/migrations/m170118_224937_account_otp_secrets.php create mode 100644 tests/codeception/api/_pages/TwoFactorAuthRoute.php create mode 100644 tests/codeception/api/functional/TwoFactorAuthCredentialsCest.php create mode 100644 tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php create mode 100644 tests/codeception/api/unit/validators/TotpValidatorTest.php diff --git a/api/controllers/TwoFactorAuthController.php b/api/controllers/TwoFactorAuthController.php new file mode 100644 index 0000000..514b8cc --- /dev/null +++ b/api/controllers/TwoFactorAuthController.php @@ -0,0 +1,37 @@ + [ + 'class' => AccessControl::class, + 'rules' => [ + [ + 'class' => ActiveUserRule::class, + 'actions' => [ + 'credentials', + ], + ], + ], + ], + ]); + } + + public function actionCredentials() { + $account = Yii::$app->user->identity; + $model = new TwoFactorAuthForm($account); + + return $model->getCredentials(); + } + +} diff --git a/api/models/profile/TwoFactorAuthForm.php b/api/models/profile/TwoFactorAuthForm.php new file mode 100644 index 0000000..9c432e4 --- /dev/null +++ b/api/models/profile/TwoFactorAuthForm.php @@ -0,0 +1,95 @@ +account = $account; + parent::__construct($config); + } + + public function rules() { + $on = [self::SCENARIO_ENABLE, self::SCENARIO_DISABLE]; + return [ + ['token', 'required', 'message' => E::OTP_TOKEN_REQUIRED, 'on' => $on], + ['token', TotpValidator::class, 'account' => $this->account, 'window' => 30, 'on' => $on], + ['password', PasswordRequiredValidator::class, 'account' => $this->account, 'on' => $on], + ]; + } + + public function getCredentials(): array { + if (empty($this->account->otp_secret)) { + $this->setOtpSecret(); + } + + $provisioningUri = $this->getTotp()->getProvisioningUri(); + + return [ + 'qr' => base64_encode($this->drawQrCode($provisioningUri)), + 'uri' => $provisioningUri, + 'secret' => $this->account->otp_secret, + ]; + } + + public function getAccount(): Account { + return $this->account; + } + + /** + * @return TOTP + */ + public function getTotp(): TOTP { + $totp = new TOTP($this->account->email, $this->account->otp_secret); + $totp->setIssuer('Ely.by'); + + return $totp; + } + + public function drawQrCode(string $content): string { + $renderer = new Svg(); + $renderer->setHeight(256); + $renderer->setWidth(256); + $renderer->setForegroundColor(new Rgb(32, 126, 92)); + $renderer->setMargin(0); + $renderer->addDecorator(new ElyDecorator()); + + $writer = new Writer($renderer); + + return $writer->writeString($content, Encoder::DEFAULT_BYTE_MODE_ECODING, ErrorCorrectionLevel::H); + } + + protected function setOtpSecret(): void { + $this->account->otp_secret = trim(Base32::encode(random_bytes(32)), '='); + if (!$this->account->save()) { + throw new ErrorException('Cannot set account otp_secret'); + } + } + +} diff --git a/api/validators/TotpValidator.php b/api/validators/TotpValidator.php new file mode 100644 index 0000000..dac0c17 --- /dev/null +++ b/api/validators/TotpValidator.php @@ -0,0 +1,51 @@ +account === null) { + $this->account = Yii::$app->user->identity; + } + + if (!$this->account instanceof Account) { + throw new InvalidConfigException('account should be instance of ' . Account::class); + } + + if (empty($this->account->otp_secret)) { + throw new InvalidConfigException('account should have not empty otp_secret'); + } + } + + protected function validateValue($value) { + $totp = new TOTP(null, $this->account->otp_secret); + if (!$totp->verify((string)$value, null, $this->window)) { + return [E::OTP_TOKEN_INCORRECT, []]; + } + + return null; + } + +} diff --git a/common/helpers/Error.php b/common/helpers/Error.php index 664daa7..6d2347c 100644 --- a/common/helpers/Error.php +++ b/common/helpers/Error.php @@ -54,4 +54,7 @@ final class Error { const SUBJECT_REQUIRED = 'error.subject_required'; const MESSAGE_REQUIRED = 'error.message_required'; + const OTP_TOKEN_REQUIRED = 'error.otp_token_required'; + const OTP_TOKEN_INCORRECT = 'error.otp_token_incorrect'; + } diff --git a/common/models/Account.php b/common/models/Account.php index ba346da..4d67f1c 100644 --- a/common/models/Account.php +++ b/common/models/Account.php @@ -20,6 +20,8 @@ use const common\LATEST_RULES_VERSION; * @property integer $status * @property integer $rules_agreement_version * @property string $registration_ip + * @property string $otp_secret + * @property integer $is_otp_enabled * @property integer $created_at * @property integer $updated_at * @property integer $password_changed_at diff --git a/console/migrations/m170118_224937_account_otp_secrets.php b/console/migrations/m170118_224937_account_otp_secrets.php new file mode 100644 index 0000000..4efa139 --- /dev/null +++ b/console/migrations/m170118_224937_account_otp_secrets.php @@ -0,0 +1,17 @@ +addColumn('{{%accounts}}', 'otp_secret', $this->string()->after('registration_ip')); + $this->addColumn('{{%accounts}}', 'is_otp_enabled', $this->boolean()->notNull()->defaultValue(false)->after('otp_secret')); + } + + public function safeDown() { + $this->dropColumn('{{%accounts}}', 'otp_secret'); + $this->dropColumn('{{%accounts}}', 'is_otp_enabled'); + } + +} diff --git a/tests/codeception/api/_pages/TwoFactorAuthRoute.php b/tests/codeception/api/_pages/TwoFactorAuthRoute.php new file mode 100644 index 0000000..32ffeaa --- /dev/null +++ b/tests/codeception/api/_pages/TwoFactorAuthRoute.php @@ -0,0 +1,16 @@ +route = '/two-factor-auth'; + $this->actor->sendGET($this->getUrl()); + } + +} diff --git a/tests/codeception/api/functional/TwoFactorAuthCredentialsCest.php b/tests/codeception/api/functional/TwoFactorAuthCredentialsCest.php new file mode 100644 index 0000000..1084965 --- /dev/null +++ b/tests/codeception/api/functional/TwoFactorAuthCredentialsCest.php @@ -0,0 +1,28 @@ +route = new TwoFactorAuthRoute($I); + } + + public function testGetCredentials(FunctionalTester $I) { + $I->loggedInAsActiveAccount(); + $this->route->credentials(); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseJsonMatchesJsonPath('$.secret'); + $I->canSeeResponseJsonMatchesJsonPath('$.uri'); + $I->canSeeResponseJsonMatchesJsonPath('$.qr'); + } + +} diff --git a/tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php b/tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php new file mode 100644 index 0000000..37587da --- /dev/null +++ b/tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php @@ -0,0 +1,67 @@ +getMockBuilder(Account::class) + ->setMethods(['save']) + ->getMock(); + + $account->expects($this->once()) + ->method('save') + ->willReturn(true); + + $account->email = 'mock@email.com'; + $account->otp_secret = null; + + /** @var TwoFactorAuthForm|\PHPUnit_Framework_MockObject_MockObject $model */ + $model = $this->getMockBuilder(TwoFactorAuthForm::class) + ->setConstructorArgs([$account]) + ->setMethods(['drawQrCode']) + ->getMock(); + + $model->expects($this->once()) + ->method('drawQrCode') + ->willReturn('this is qr code, trust me'); + + $result = $model->getCredentials(); + $this->assertTrue(is_array($result)); + $this->assertArrayHasKey('qr', $result); + $this->assertArrayHasKey('uri', $result); + $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']); + + /** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */ + $account = $this->getMockBuilder(Account::class) + ->setMethods(['save']) + ->getMock(); + + $account->expects($this->never()) + ->method('save'); + + $account->email = 'mock@email.com'; + $account->otp_secret = 'some valid totp secret value'; + + /** @var TwoFactorAuthForm|\PHPUnit_Framework_MockObject_MockObject $model */ + $model = $this->getMockBuilder(TwoFactorAuthForm::class) + ->setConstructorArgs([$account]) + ->setMethods(['drawQrCode']) + ->getMock(); + + $model->expects($this->once()) + ->method('drawQrCode') + ->willReturn('this is qr code, trust me'); + + $result = $model->getCredentials(); + $this->assertEquals('some valid totp secret value', $result['secret']); + } + +} diff --git a/tests/codeception/api/unit/validators/TotpValidatorTest.php b/tests/codeception/api/unit/validators/TotpValidatorTest.php new file mode 100644 index 0000000..fc23f9f --- /dev/null +++ b/tests/codeception/api/unit/validators/TotpValidatorTest.php @@ -0,0 +1,35 @@ +otp_secret = 'some secret'; + $controlTotp = new TOTP(null, 'some secret'); + + $validator = new TotpValidator(['account' => $account]); + + $result = $this->callProtected($validator, 'validateValue', 123456); + $this->assertEquals([E::OTP_TOKEN_INCORRECT, []], $result); + + $result = $this->callProtected($validator, 'validateValue', $controlTotp->now()); + $this->assertNull($result); + + $result = $this->callProtected($validator, 'validateValue', $controlTotp->at(time() - 31)); + $this->assertEquals([E::OTP_TOKEN_INCORRECT, []], $result); + + $validator->window = 60; + $result = $this->callProtected($validator, 'validateValue', $controlTotp->at(time() - 31)); + $this->assertNull($result); + } + +}