Объединены сущности для авторизации посредством JWT токенов и токенов, выданных через oAuth2.

Все действия, связанные с аккаунтами, теперь вызываются через url `/api/v1/accounts/<id>/<action>`.
Добавлена вменяемая система разграничения прав на основе RBAC.
Теперь oAuth2 токены генерируются как случайная строка в 40 символов длинной, а не UUID.
Исправлен баг с неправильным временем жизни токена в ответе успешного запроса аутентификации.
Теперь все unit тесты можно успешно прогнать без наличия интернета.
This commit is contained in:
ErickSkrauch
2017-09-19 20:06:16 +03:00
parent 928b3aa7fc
commit dd2c4bc413
173 changed files with 2719 additions and 2748 deletions

View File

@@ -1,35 +0,0 @@
<?php
namespace api\models\profile;
use api\models\base\ApiForm;
use common\models\Account;
use yii\base\ErrorException;
use const \common\LATEST_RULES_VERSION;
class AcceptRulesForm extends ApiForm {
/**
* @var Account
*/
private $account;
public function __construct(Account $account, array $config = []) {
$this->account = $account;
parent::__construct($config);
}
public function agreeWithLatestRules() : bool {
$account = $this->getAccount();
$account->rules_agreement_version = LATEST_RULES_VERSION;
if (!$account->save()) {
throw new ErrorException('Cannot set user rules version');
}
return true;
}
public function getAccount() : Account {
return $this->account;
}
}

View File

@@ -1,85 +0,0 @@
<?php
namespace api\models\profile\ChangeEmail;
use api\models\base\ApiForm;
use api\validators\EmailActivationKeyValidator;
use common\helpers\Amqp;
use common\models\Account;
use common\models\amqp\EmailChanged;
use common\models\EmailActivation;
use PhpAmqpLib\Message\AMQPMessage;
use Yii;
use yii\base\ErrorException;
class ConfirmNewEmailForm extends ApiForm {
public $key;
/**
* @var Account
*/
private $account;
public function rules() {
return [
['key', EmailActivationKeyValidator::class, 'type' => EmailActivation::TYPE_NEW_EMAIL_CONFIRMATION],
];
}
/**
* @return Account
*/
public function getAccount(): Account {
return $this->account;
}
public function changeEmail(): bool {
if (!$this->validate()) {
return false;
}
$transaction = Yii::$app->db->beginTransaction();
/** @var \common\models\confirmations\NewEmailConfirmation $activation */
$activation = $this->key;
$activation->delete();
$account = $this->getAccount();
$oldEmail = $account->email;
$account->email = $activation->newEmail;
if (!$account->save()) {
throw new ErrorException('Cannot save new account email value');
}
$this->createTask($account->id, $account->email, $oldEmail);
$transaction->commit();
return true;
}
/**
* @param integer $accountId
* @param string $newEmail
* @param string $oldEmail
* @throws \PhpAmqpLib\Exception\AMQPExceptionInterface
*/
public function createTask($accountId, $newEmail, $oldEmail) {
$model = new EmailChanged;
$model->accountId = $accountId;
$model->oldEmail = $oldEmail;
$model->newEmail = $newEmail;
$message = Amqp::getInstance()->prepareMessage($model, [
'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT,
]);
Amqp::sendToEventsExchange('accounts.email-changed', $message);
}
public function __construct(Account $account, array $config = []) {
$this->account = $account;
parent::__construct($config);
}
}

View File

@@ -1,117 +0,0 @@
<?php
namespace api\models\profile\ChangeEmail;
use common\emails\EmailHelper;
use api\models\base\ApiForm;
use api\validators\PasswordRequiredValidator;
use common\helpers\Error as E;
use common\models\Account;
use common\models\confirmations\CurrentEmailConfirmation;
use common\models\EmailActivation;
use Yii;
use yii\base\ErrorException;
use yii\base\Exception;
class InitStateForm extends ApiForm {
public $email;
public $password;
private $account;
public function __construct(Account $account, array $config = []) {
$this->account = $account;
$this->email = $account->email;
parent::__construct($config);
}
public function getAccount() : Account {
return $this->account;
}
public function rules() {
return [
['email', 'validateFrequency'],
['password', PasswordRequiredValidator::class, 'account' => $this->account],
];
}
public function validateFrequency($attribute) {
if (!$this->hasErrors()) {
$emailConfirmation = $this->getEmailActivation();
if ($emailConfirmation !== null && !$emailConfirmation->canRepeat()) {
$this->addError($attribute, E::RECENTLY_SENT_MESSAGE);
}
}
}
public function sendCurrentEmailConfirmation() : bool {
if (!$this->validate()) {
return false;
}
$transaction = Yii::$app->db->beginTransaction();
try {
$this->removeOldCode();
$activation = $this->createCode();
EmailHelper::changeEmailConfirmCurrent($activation);
$transaction->commit();
} catch (Exception $e) {
$transaction->rollBack();
throw $e;
}
return true;
}
/**
* @return CurrentEmailConfirmation
* @throws ErrorException
*/
public function createCode() : CurrentEmailConfirmation {
$account = $this->getAccount();
$emailActivation = new CurrentEmailConfirmation();
$emailActivation->account_id = $account->id;
if (!$emailActivation->save()) {
throw new ErrorException('Cannot save email activation model');
}
return $emailActivation;
}
/**
* Удаляет старый ключ активации, если он существует
*/
public function removeOldCode() {
$emailActivation = $this->getEmailActivation();
if ($emailActivation === null) {
return;
}
$emailActivation->delete();
}
/**
* Возвращает E-mail активацию, которая использовалась внутри процесса для перехода на следующий шаг.
* Метод предназначен для проверки, не слишком ли часто отправляются письма о смене E-mail.
* Проверяем тип подтверждения нового E-mail, поскольку при переходе на этот этап, активация предыдущего
* шага удаляется.
* @return EmailActivation|null
* @throws ErrorException
*/
public function getEmailActivation() {
return $this->getAccount()
->getEmailActivations()
->andWhere([
'type' => [
EmailActivation::TYPE_CURRENT_EMAIL_CONFIRMATION,
EmailActivation::TYPE_NEW_EMAIL_CONFIRMATION,
],
])
->one();
}
}

View File

@@ -1,76 +0,0 @@
<?php
namespace api\models\profile\ChangeEmail;
use common\emails\EmailHelper;
use api\models\base\ApiForm;
use api\validators\EmailActivationKeyValidator;
use common\models\Account;
use common\models\confirmations\NewEmailConfirmation;
use common\models\EmailActivation;
use common\validators\EmailValidator;
use Yii;
use yii\base\ErrorException;
class NewEmailForm extends ApiForm {
public $key;
public $email;
/**
* @var Account
*/
private $account;
public function rules() {
return [
['key', EmailActivationKeyValidator::class, 'type' => EmailActivation::TYPE_CURRENT_EMAIL_CONFIRMATION],
['email', EmailValidator::class],
];
}
public function getAccount(): Account {
return $this->account;
}
public function sendNewEmailConfirmation(): bool {
if (!$this->validate()) {
return false;
}
$transaction = Yii::$app->db->beginTransaction();
/** @var \common\models\confirmations\CurrentEmailConfirmation $previousActivation */
$previousActivation = $this->key;
$previousActivation->delete();
$activation = $this->createCode();
EmailHelper::changeEmailConfirmNew($activation);
$transaction->commit();
return true;
}
/**
* @return NewEmailConfirmation
* @throws ErrorException
*/
public function createCode() {
$emailActivation = new NewEmailConfirmation();
$emailActivation->account_id = $this->getAccount()->id;
$emailActivation->newEmail = $this->email;
if (!$emailActivation->save()) {
throw new ErrorException('Cannot save email activation model');
}
return $emailActivation;
}
public function __construct(Account $account, array $config = []) {
$this->account = $account;
parent::__construct($config);
}
}

View File

@@ -1,45 +0,0 @@
<?php
namespace api\models\profile;
use api\models\base\ApiForm;
use common\models\Account;
use common\validators\LanguageValidator;
use yii\base\ErrorException;
class ChangeLanguageForm extends ApiForm {
public $lang;
private $account;
public function __construct(Account $account, array $config = []) {
$this->account = $account;
parent::__construct($config);
}
public function rules() {
return [
['lang', 'required'],
['lang', LanguageValidator::class],
];
}
public function applyLanguage() : bool {
if (!$this->validate()) {
return false;
}
$account = $this->getAccount();
$account->lang = $this->lang;
if (!$account->save()) {
throw new ErrorException('Cannot change user language');
}
return true;
}
public function getAccount() : Account {
return $this->account;
}
}

View File

@@ -1,85 +0,0 @@
<?php
namespace api\models\profile;
use api\models\base\ApiForm;
use api\validators\PasswordRequiredValidator;
use common\helpers\Error as E;
use common\models\Account;
use common\validators\PasswordValidator;
use Yii;
use yii\base\ErrorException;
use yii\helpers\ArrayHelper;
class ChangePasswordForm extends ApiForm {
public $newPassword;
public $newRePassword;
public $logoutAll;
public $password;
/**
* @var \common\models\Account
*/
private $_account;
public function __construct(Account $account, array $config = []) {
$this->_account = $account;
parent::__construct($config);
}
/**
* @inheritdoc
*/
public function rules() {
return ArrayHelper::merge(parent::rules(), [
['newPassword', 'required', 'message' => E::NEW_PASSWORD_REQUIRED],
['newRePassword', 'required', 'message' => E::NEW_RE_PASSWORD_REQUIRED],
['newPassword', PasswordValidator::class],
['newRePassword', 'validatePasswordAndRePasswordMatch'],
['logoutAll', 'boolean'],
['password', PasswordRequiredValidator::class, 'account' => $this->_account],
]);
}
public function validatePasswordAndRePasswordMatch($attribute) {
if (!$this->hasErrors($attribute)) {
if ($this->newPassword !== $this->newRePassword) {
$this->addError($attribute, E::NEW_RE_PASSWORD_DOES_NOT_MATCH);
}
}
}
/**
* @return bool
* @throws ErrorException
*/
public function changePassword() : bool {
if (!$this->validate()) {
return false;
}
$transaction = Yii::$app->db->beginTransaction();
$account = $this->_account;
$account->setPassword($this->newPassword);
if ($this->logoutAll) {
Yii::$app->user->terminateSessions();
}
if (!$account->save()) {
throw new ErrorException('Cannot save user model');
}
$transaction->commit();
return true;
}
protected function getAccount() : Account {
return $this->_account;
}
}

View File

@@ -1,98 +0,0 @@
<?php
namespace api\models\profile;
use api\models\base\ApiForm;
use api\validators\PasswordRequiredValidator;
use common\helpers\Amqp;
use common\models\Account;
use common\models\amqp\UsernameChanged;
use common\models\UsernameHistory;
use common\validators\UsernameValidator;
use Exception;
use PhpAmqpLib\Message\AMQPMessage;
use Yii;
use yii\base\ErrorException;
class ChangeUsernameForm extends ApiForm {
public $username;
public $password;
/**
* @var Account
*/
private $account;
public function __construct(Account $account, array $config = []) {
parent::__construct($config);
$this->account = $account;
}
public function rules(): array {
return [
['username', UsernameValidator::class, 'accountCallback' => function() {
return $this->account->id;
}],
['password', PasswordRequiredValidator::class],
];
}
public function change(): bool {
if (!$this->validate()) {
return false;
}
$account = $this->account;
if ($this->username === $account->username) {
return true;
}
$transaction = Yii::$app->db->beginTransaction();
try {
$oldNickname = $account->username;
$account->username = $this->username;
if (!$account->save()) {
throw new ErrorException('Cannot save account model with new username');
}
$usernamesHistory = new UsernameHistory();
$usernamesHistory->account_id = $account->id;
$usernamesHistory->username = $account->username;
if (!$usernamesHistory->save()) {
throw new ErrorException('Cannot save username history record');
}
$this->createEventTask($account->id, $account->username, $oldNickname);
$transaction->commit();
} catch (Exception $e) {
$transaction->rollBack();
throw $e;
}
return true;
}
/**
* TODO: вынести это в отдельную сущность, т.к. эта команда используется внутри формы регистрации
*
* @param integer $accountId
* @param string $newNickname
* @param string $oldNickname
* @throws \PhpAmqpLib\Exception\AMQPExceptionInterface
*/
public function createEventTask($accountId, $newNickname, $oldNickname) {
$model = new UsernameChanged;
$model->accountId = $accountId;
$model->oldUsername = $oldNickname;
$model->newUsername = $newNickname;
$message = Amqp::getInstance()->prepareMessage($model, [
'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT,
]);
Amqp::sendToEventsExchange('accounts.username-changed', $message);
}
}

View File

@@ -1,181 +0,0 @@
<?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;
use ParagonIE\ConstantTime\Encoding;
use Yii;
use yii\base\ErrorException;
class TwoFactorAuthForm extends ApiForm {
const SCENARIO_ACTIVATE = 'enable';
const SCENARIO_DISABLE = 'disable';
public $totp;
public $timestamp;
public $password;
/**
* @var Account
*/
private $account;
public function __construct(Account $account, array $config = []) {
$this->account = $account;
parent::__construct($config);
}
public function rules(): array {
$bothScenarios = [self::SCENARIO_ACTIVATE, self::SCENARIO_DISABLE];
return [
['timestamp', 'integer', 'on' => [self::SCENARIO_ACTIVATE]],
['account', 'validateOtpDisabled', 'on' => self::SCENARIO_ACTIVATE],
['account', 'validateOtpEnabled', 'on' => self::SCENARIO_DISABLE],
['totp', 'required', 'message' => E::TOTP_REQUIRED, 'on' => $bothScenarios],
['totp', TotpValidator::class, 'on' => $bothScenarios,
'account' => $this->account,
'timestamp' => function() {
return $this->timestamp;
},
],
['password', PasswordRequiredValidator::class, 'account' => $this->account, 'on' => $bothScenarios],
];
}
public function getCredentials(): array {
if (empty($this->account->otp_secret)) {
$this->setOtpSecret();
}
$provisioningUri = $this->getTotp()->getProvisioningUri();
return [
'qr' => 'data:image/svg+xml,' . trim($this->drawQrCode($provisioningUri)),
'uri' => $provisioningUri,
'secret' => $this->account->otp_secret,
];
}
public function activate(): bool {
if ($this->scenario !== self::SCENARIO_ACTIVATE || !$this->validate()) {
return false;
}
$transaction = Yii::$app->db->beginTransaction();
$account = $this->account;
$account->is_otp_enabled = true;
if (!$account->save()) {
throw new ErrorException('Cannot enable otp for account');
}
Yii::$app->user->terminateSessions();
$transaction->commit();
return true;
}
public function disable(): bool {
if ($this->scenario !== self::SCENARIO_DISABLE || !$this->validate()) {
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);
}
}
public function getAccount(): Account {
return $this->account;
}
/**
* @return TOTP
*/
public function getTotp(): TOTP {
$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->setMargin(0);
$renderer->setForegroundColor(new Rgb(32, 126, 92));
$renderer->addDecorator(new ElyDecorator());
$writer = new Writer($renderer);
return $writer->writeString($content, Encoder::DEFAULT_BYTE_MODE_ECODING, ErrorCorrectionLevel::H);
}
/**
* otp_secret кодируется в Base32, т.к. после кодирования в результурющей строке нет символов,
* которые можно перепутать (1 и l, O и 0, и т.д.). Отрицательной стороной является то, что итоговая
* строка составляет 160% от исходной. Поэтому, генерируя исходный приватный ключ, мы должны обеспечить
* ему такую длину, чтобы 160% его длины было равно запрошенному значению
*
* @param int $length
* @throws ErrorException
*/
protected function setOtpSecret(int $length = 24): void {
$randomBytesLength = ceil($length / 1.6);
$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, '#');
}
}