2017-01-21 04:24:30 +05:30
|
|
|
|
<?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 common\components\Qr\ElyDecorator;
|
|
|
|
|
use common\helpers\Error as E;
|
|
|
|
|
use common\models\Account;
|
|
|
|
|
use OTPHP\TOTP;
|
2017-08-08 22:48:44 +05:30
|
|
|
|
use ParagonIE\ConstantTime\Encoding;
|
2017-02-23 04:31:32 +05:30
|
|
|
|
use Yii;
|
2017-01-21 04:24:30 +05:30
|
|
|
|
use yii\base\ErrorException;
|
|
|
|
|
|
|
|
|
|
class TwoFactorAuthForm extends ApiForm {
|
|
|
|
|
|
2017-01-21 04:58:26 +05:30
|
|
|
|
const SCENARIO_ACTIVATE = 'enable';
|
2017-01-21 04:24:30 +05:30
|
|
|
|
const SCENARIO_DISABLE = 'disable';
|
|
|
|
|
|
|
|
|
|
public $token;
|
|
|
|
|
|
2017-02-22 04:19:24 +05:30
|
|
|
|
public $timestamp;
|
|
|
|
|
|
2017-01-21 04:24:30 +05:30
|
|
|
|
public $password;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @var Account
|
|
|
|
|
*/
|
|
|
|
|
private $account;
|
|
|
|
|
|
|
|
|
|
public function __construct(Account $account, array $config = []) {
|
|
|
|
|
$this->account = $account;
|
|
|
|
|
parent::__construct($config);
|
|
|
|
|
}
|
|
|
|
|
|
2017-08-08 03:26:24 +05:30
|
|
|
|
public function rules(): array {
|
2017-01-21 04:58:26 +05:30
|
|
|
|
$bothScenarios = [self::SCENARIO_ACTIVATE, self::SCENARIO_DISABLE];
|
2017-01-21 04:24:30 +05:30
|
|
|
|
return [
|
2017-02-22 04:19:24 +05:30
|
|
|
|
['timestamp', 'integer', 'on' => [self::SCENARIO_ACTIVATE]],
|
2017-01-21 04:58:26 +05:30
|
|
|
|
['account', 'validateOtpDisabled', 'on' => self::SCENARIO_ACTIVATE],
|
|
|
|
|
['account', 'validateOtpEnabled', 'on' => self::SCENARIO_DISABLE],
|
|
|
|
|
['token', 'required', 'message' => E::OTP_TOKEN_REQUIRED, 'on' => $bothScenarios],
|
2017-02-22 04:19:24 +05:30
|
|
|
|
['token', TotpValidator::class, 'on' => $bothScenarios,
|
|
|
|
|
'account' => $this->account,
|
|
|
|
|
'timestamp' => function() {
|
|
|
|
|
return $this->timestamp;
|
|
|
|
|
},
|
|
|
|
|
],
|
2017-01-21 04:58:26 +05:30
|
|
|
|
['password', PasswordRequiredValidator::class, 'account' => $this->account, 'on' => $bothScenarios],
|
2017-01-21 04:24:30 +05:30
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getCredentials(): array {
|
|
|
|
|
if (empty($this->account->otp_secret)) {
|
|
|
|
|
$this->setOtpSecret();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$provisioningUri = $this->getTotp()->getProvisioningUri();
|
|
|
|
|
|
|
|
|
|
return [
|
2017-08-03 17:20:48 +05:30
|
|
|
|
'qr' => 'data:image/svg+xml,' . trim($this->drawQrCode($provisioningUri)),
|
2017-01-21 04:24:30 +05:30
|
|
|
|
'uri' => $provisioningUri,
|
|
|
|
|
'secret' => $this->account->otp_secret,
|
|
|
|
|
];
|
|
|
|
|
}
|
2017-01-21 04:58:26 +05:30
|
|
|
|
|
|
|
|
|
public function activate(): bool {
|
2017-01-23 04:37:29 +05:30
|
|
|
|
if ($this->scenario !== self::SCENARIO_ACTIVATE || !$this->validate()) {
|
2017-01-21 04:58:26 +05:30
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2017-02-23 04:31:32 +05:30
|
|
|
|
$transaction = Yii::$app->db->beginTransaction();
|
|
|
|
|
|
2017-01-21 04:58:26 +05:30
|
|
|
|
$account = $this->account;
|
|
|
|
|
$account->is_otp_enabled = true;
|
|
|
|
|
if (!$account->save()) {
|
|
|
|
|
throw new ErrorException('Cannot enable otp for account');
|
|
|
|
|
}
|
|
|
|
|
|
2017-02-23 04:31:32 +05:30
|
|
|
|
Yii::$app->user->terminateSessions();
|
|
|
|
|
|
|
|
|
|
$transaction->commit();
|
|
|
|
|
|
2017-01-21 04:58:26 +05:30
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function disable(): bool {
|
2017-01-23 04:37:29 +05:30
|
|
|
|
if ($this->scenario !== self::SCENARIO_DISABLE || !$this->validate()) {
|
2017-01-21 04:58:26 +05:30
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$account = $this->account;
|
|
|
|
|
$account->is_otp_enabled = false;
|
|
|
|
|
$account->otp_secret = null;
|
|
|
|
|
if (!$account->save()) {
|
|
|
|
|
throw new ErrorException('Cannot disable otp for account');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function validateOtpDisabled($attribute) {
|
|
|
|
|
if ($this->account->is_otp_enabled) {
|
|
|
|
|
$this->addError($attribute, E::OTP_ALREADY_ENABLED);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function validateOtpEnabled($attribute) {
|
|
|
|
|
if (!$this->account->is_otp_enabled) {
|
|
|
|
|
$this->addError($attribute, E::OTP_NOT_ENABLED);
|
|
|
|
|
}
|
|
|
|
|
}
|
2017-01-21 04:24:30 +05:30
|
|
|
|
|
|
|
|
|
public function getAccount(): Account {
|
|
|
|
|
return $this->account;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return TOTP
|
|
|
|
|
*/
|
|
|
|
|
public function getTotp(): TOTP {
|
2017-08-08 22:48:44 +05:30
|
|
|
|
$totp = TOTP::create($this->account->otp_secret);
|
|
|
|
|
$totp->setLabel($this->account->email);
|
2017-01-21 04:24:30 +05:30
|
|
|
|
$totp->setIssuer('Ely.by');
|
|
|
|
|
|
|
|
|
|
return $totp;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function drawQrCode(string $content): string {
|
2017-08-08 04:38:34 +05:30
|
|
|
|
$content = $this->forceMinimalQrContentLength($content);
|
|
|
|
|
|
2017-01-21 04:24:30 +05:30
|
|
|
|
$renderer = new Svg();
|
|
|
|
|
$renderer->setMargin(0);
|
2017-08-08 03:26:24 +05:30
|
|
|
|
$renderer->setForegroundColor(new Rgb(32, 126, 92));
|
2017-01-21 04:24:30 +05:30
|
|
|
|
$renderer->addDecorator(new ElyDecorator());
|
|
|
|
|
|
|
|
|
|
$writer = new Writer($renderer);
|
|
|
|
|
|
|
|
|
|
return $writer->writeString($content, Encoder::DEFAULT_BYTE_MODE_ECODING, ErrorCorrectionLevel::H);
|
|
|
|
|
}
|
|
|
|
|
|
2017-02-21 22:33:48 +05:30
|
|
|
|
/**
|
|
|
|
|
* otp_secret кодируется в Base32, т.к. после кодирования в результурющей строке нет символов,
|
|
|
|
|
* которые можно перепутать (1 и l, O и 0, и т.д.). Отрицательной стороной является то, что итоговая
|
|
|
|
|
* строка составляет 160% от исходной. Поэтому, генерируя исходный приватный ключ, мы должны обеспечить
|
|
|
|
|
* ему такую длину, чтобы 160% его длины было равно запрошенному значению
|
|
|
|
|
*
|
2017-02-23 04:31:32 +05:30
|
|
|
|
* @param int $length
|
2017-02-21 22:33:48 +05:30
|
|
|
|
* @throws ErrorException
|
|
|
|
|
*/
|
|
|
|
|
protected function setOtpSecret(int $length = 24): void {
|
|
|
|
|
$randomBytesLength = ceil($length / 1.6);
|
2017-08-08 22:48:44 +05:30
|
|
|
|
$randomBase32 = trim(Encoding::base32EncodeUpper(random_bytes($randomBytesLength)), '=');
|
|
|
|
|
$this->account->otp_secret = substr($randomBase32, 0, $length);
|
2017-01-21 04:24:30 +05:30
|
|
|
|
if (!$this->account->save()) {
|
|
|
|
|
throw new ErrorException('Cannot set account otp_secret');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2017-08-08 04:38:34 +05:30
|
|
|
|
/**
|
|
|
|
|
* В используемой либе для рендеринга 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, '#');
|
|
|
|
|
}
|
|
|
|
|
|
2017-01-21 04:24:30 +05:30
|
|
|
|
}
|