Реализован метод для запроса информации для активации двухфакторной аутентификации

Добавлен валидатор для TOTP кодов
This commit is contained in:
ErickSkrauch 2017-01-21 01:54:30 +03:00
parent bb1fd1a960
commit 3b9ef7ea70
10 changed files with 351 additions and 0 deletions

View File

@ -0,0 +1,37 @@
<?php
namespace api\controllers;
use api\filters\ActiveUserRule;
use api\models\profile\TwoFactorAuthForm;
use Yii;
use yii\filters\AccessControl;
use yii\helpers\ArrayHelper;
class TwoFactorAuthController extends Controller {
public $defaultAction = 'credentials';
public function behaviors() {
return ArrayHelper::merge(parent::behaviors(), [
'access' => [
'class' => AccessControl::class,
'rules' => [
[
'class' => ActiveUserRule::class,
'actions' => [
'credentials',
],
],
],
],
]);
}
public function actionCredentials() {
$account = Yii::$app->user->identity;
$model = new TwoFactorAuthForm($account);
return $model->getCredentials();
}
}

View File

@ -0,0 +1,95 @@
<?php
namespace api\models\profile;
use api\models\base\ApiForm;
use api\validators\TotpValidator;
use api\validators\PasswordRequiredValidator;
use BaconQrCode\Common\ErrorCorrectionLevel;
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 yii\base\ErrorException;
class TwoFactorAuthForm extends ApiForm {
const SCENARIO_ENABLE = 'enable';
const SCENARIO_DISABLE = 'disable';
public $token;
public $password;
/**
* @var Account
*/
private $account;
public function __construct(Account $account, array $config = []) {
$this->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');
}
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace api\validators;
use common\helpers\Error as E;
use common\models\Account;
use OTPHP\TOTP;
use Yii;
use yii\base\InvalidConfigException;
use yii\validators\Validator;
class TotpValidator extends Validator {
/**
* @var Account
*/
public $account;
/**
* @var int|null Задаёт окно, в промежуток которого будет проверяться код.
* Позволяет избежать ситуации, когда пользователь ввёл код в последнюю секунду
* его существования и пока шёл запрос, тот протух.
*/
public $window;
public $skipOnEmpty = false;
public function init() {
parent::init();
if ($this->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;
}
}

View File

@ -54,4 +54,7 @@ final class Error {
const SUBJECT_REQUIRED = 'error.subject_required'; const SUBJECT_REQUIRED = 'error.subject_required';
const MESSAGE_REQUIRED = 'error.message_required'; const MESSAGE_REQUIRED = 'error.message_required';
const OTP_TOKEN_REQUIRED = 'error.otp_token_required';
const OTP_TOKEN_INCORRECT = 'error.otp_token_incorrect';
} }

View File

@ -20,6 +20,8 @@ use const common\LATEST_RULES_VERSION;
* @property integer $status * @property integer $status
* @property integer $rules_agreement_version * @property integer $rules_agreement_version
* @property string $registration_ip * @property string $registration_ip
* @property string $otp_secret
* @property integer $is_otp_enabled
* @property integer $created_at * @property integer $created_at
* @property integer $updated_at * @property integer $updated_at
* @property integer $password_changed_at * @property integer $password_changed_at

View File

@ -0,0 +1,17 @@
<?php
use console\db\Migration;
class m170118_224937_account_otp_secrets extends Migration {
public function safeUp() {
$this->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');
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace tests\codeception\api\_pages;
use yii\codeception\BasePage;
/**
* @property \tests\codeception\api\FunctionalTester $actor
*/
class TwoFactorAuthRoute extends BasePage {
public function credentials() {
$this->route = '/two-factor-auth';
$this->actor->sendGET($this->getUrl());
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace tests\codeception\api\functional;
use tests\codeception\api\_pages\TwoFactorAuthRoute;
use tests\codeception\api\FunctionalTester;
class TwoFactorAuthCredentialsCest {
/**
* @var TwoFactorAuthRoute
*/
private $route;
public function _before(FunctionalTester $I) {
$this->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');
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace tests\codeception\api\unit\models\profile;
use api\models\profile\TwoFactorAuthForm;
use common\models\Account;
use tests\codeception\api\unit\TestCase;
class TwoFactorAuthFormTest extends TestCase {
public function testGetCredentials() {
/** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */
$account = $this->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']);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace tests\codeception\api\unit\validators;
use api\validators\TotpValidator;
use common\helpers\Error as E;
use common\models\Account;
use OTPHP\TOTP;
use tests\codeception\api\unit\TestCase;
use tests\codeception\common\_support\ProtectedCaller;
class TotpValidatorTest extends TestCase {
use ProtectedCaller;
public function testValidateValue() {
$account = new Account();
$account->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);
}
}