mirror of
https://github.com/elyby/accounts.git
synced 2024-12-27 23:50:19 +05:30
Реализован метод для запроса информации для активации двухфакторной аутентификации
Добавлен валидатор для TOTP кодов
This commit is contained in:
parent
bb1fd1a960
commit
3b9ef7ea70
37
api/controllers/TwoFactorAuthController.php
Normal file
37
api/controllers/TwoFactorAuthController.php
Normal 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();
|
||||
}
|
||||
|
||||
}
|
95
api/models/profile/TwoFactorAuthForm.php
Normal file
95
api/models/profile/TwoFactorAuthForm.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
51
api/validators/TotpValidator.php
Normal file
51
api/validators/TotpValidator.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
@ -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';
|
||||
|
||||
}
|
||||
|
@ -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
|
||||
|
17
console/migrations/m170118_224937_account_otp_secrets.php
Normal file
17
console/migrations/m170118_224937_account_otp_secrets.php
Normal 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');
|
||||
}
|
||||
|
||||
}
|
16
tests/codeception/api/_pages/TwoFactorAuthRoute.php
Normal file
16
tests/codeception/api/_pages/TwoFactorAuthRoute.php
Normal 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());
|
||||
}
|
||||
|
||||
}
|
@ -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');
|
||||
}
|
||||
|
||||
}
|
@ -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']);
|
||||
}
|
||||
|
||||
}
|
35
tests/codeception/api/unit/validators/TotpValidatorTest.php
Normal file
35
tests/codeception/api/unit/validators/TotpValidatorTest.php
Normal 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);
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue
Block a user