Объединены сущности для авторизации посредством 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,67 +0,0 @@
<?php
namespace api\models;
use common\models\Account;
use Emarref\Jwt\Claim\JwtId;
use Emarref\Jwt\Exception\ExpiredException;
use Yii;
use yii\base\NotSupportedException;
use yii\web\IdentityInterface;
use yii\web\UnauthorizedHttpException;
class AccountIdentity extends Account implements IdentityInterface {
/**
* @inheritdoc
*/
public static function findIdentityByAccessToken($token, $type = null) {
/** @var \api\components\User\Component $component */
$component = Yii::$app->user;
try {
$token = $component->parseToken($token);
} catch (ExpiredException $e) {
throw new UnauthorizedHttpException('Token expired');
} catch (\Exception $e) {
throw new UnauthorizedHttpException('Incorrect token');
}
// Если исключение выше не случилось, то значит всё оке
/** @var JwtId $jti */
$jti = $token->getPayload()->findClaimByName(JwtId::NAME);
$account = static::findOne($jti->getValue());
if ($account === null) {
throw new UnauthorizedHttpException('Invalid token');
}
return $account;
}
/**
* @inheritdoc
*/
public function getId() {
return $this->id;
}
/**
* @inheritdoc
*/
public static function findIdentity($id) {
return static::findOne($id);
}
/**
* @inheritdoc
*/
public function getAuthKey() {
throw new NotSupportedException('This method used for cookie auth, except we using JWT tokens');
}
/**
* @inheritdoc
*/
public function validateAuthKey($authKey) {
throw new NotSupportedException('This method used for cookie auth, except we using JWT tokens');
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace api\models;
use api\models\base\BaseAccountForm;
use api\modules\accounts\models\AccountInfo;
use common\models\Account;
class OauthAccountInfo extends BaseAccountForm {
private $model;
public function __construct(Account $account, array $config = []) {
parent::__construct($account, $config);
$this->model = new AccountInfo($account);
}
public function info(): array {
$response = $this->model->info();
$response['profileLink'] = $response['elyProfileLink'];
unset($response['elyProfileLink']);
$response['preferredLanguage'] = $response['lang'];
unset($response['lang']);
return $response;
}
}

View File

@@ -83,7 +83,7 @@ class OauthProcess {
try {
$grant = $this->getAuthorizationCodeGrant();
$authParams = $grant->checkAuthorizeParams();
$account = Yii::$app->user->identity;
$account = Yii::$app->user->identity->getAccount();
/** @var \common\models\OauthClient $clientModel */
$clientModel = OauthClient::findOne($authParams->getClient()->getId());

View File

@@ -1,9 +1,8 @@
<?php
namespace api\models\authentication;
use api\models\AccountIdentity;
use api\models\base\ApiForm;
use api\models\profile\ChangeUsernameForm;
use api\modules\accounts\models\ChangeUsernameForm;
use api\validators\EmailActivationKeyValidator;
use common\models\Account;
use common\models\EmailActivation;
@@ -14,14 +13,14 @@ class ConfirmEmailForm extends ApiForm {
public $key;
public function rules() {
public function rules(): array {
return [
['key', EmailActivationKeyValidator::class, 'type' => EmailActivation::TYPE_REGISTRATION_EMAIL_CONFIRMATION],
];
}
/**
* @return \api\components\User\LoginResult|bool
* @return \api\components\User\AuthenticationResult|bool
* @throws ErrorException
*/
public function confirm() {
@@ -48,7 +47,7 @@ class ConfirmEmailForm extends ApiForm {
$transaction->commit();
return Yii::$app->user->login(new AccountIdentity($account->attributes), true);
return Yii::$app->user->createJwtAuthenticationToken($account, true);
}
}

View File

@@ -101,7 +101,7 @@ class ForgotPasswordForm extends ApiForm {
return true;
}
public function getLogin() {
public function getLogin(): string {
return $this->login;
}

View File

@@ -1,7 +1,6 @@
<?php
namespace api\models\authentication;
use api\models\AccountIdentity;
use api\models\base\ApiForm;
use api\validators\TotpValidator;
use common\helpers\Error as E;
@@ -9,9 +8,6 @@ use api\traits\AccountFinder;
use common\models\Account;
use Yii;
/**
* @method AccountIdentity|null getAccount()
*/
class LoginForm extends ApiForm {
use AccountFinder;
@@ -86,12 +82,12 @@ class LoginForm extends ApiForm {
}
}
public function getLogin() {
public function getLogin(): string {
return $this->login;
}
/**
* @return \api\components\User\LoginResult|bool
* @return \api\components\User\AuthenticationResult|bool
*/
public function login() {
if (!$this->validate()) {
@@ -104,11 +100,7 @@ class LoginForm extends ApiForm {
$account->save();
}
return Yii::$app->user->login($account, $this->rememberMe);
}
protected function getAccountClassName() {
return AccountIdentity::class;
return Yii::$app->user->createJwtAuthenticationToken($account, $this->rememberMe);
}
}

View File

@@ -1,7 +1,6 @@
<?php
namespace api\models\authentication;
use api\models\AccountIdentity;
use api\models\base\ApiForm;
use api\validators\EmailActivationKeyValidator;
use common\helpers\Error as E;
@@ -37,7 +36,7 @@ class RecoverPasswordForm extends ApiForm {
}
/**
* @return \api\components\User\LoginResult|bool
* @return \api\components\User\AuthenticationResult|bool
* @throws ErrorException
*/
public function recoverPassword() {
@@ -61,7 +60,7 @@ class RecoverPasswordForm extends ApiForm {
$transaction->commit();
return Yii::$app->user->login(new AccountIdentity($account->attributes), false);
return Yii::$app->user->createJwtAuthenticationToken($account, false);
}
}

View File

@@ -32,7 +32,7 @@ class RefreshTokenForm extends ApiForm {
}
/**
* @return \api\components\User\RenewResult|bool
* @return \api\components\User\AuthenticationResult|bool
*/
public function renew() {
if (!$this->validate()) {
@@ -42,7 +42,7 @@ class RefreshTokenForm extends ApiForm {
/** @var \api\components\User\Component $component */
$component = Yii::$app->user;
return $component->renew($this->getSession());
return $component->renewJwtAuthenticationToken($this->getSession());
}
/**

View File

@@ -0,0 +1,22 @@
<?php
namespace api\models\base;
use common\models\Account;
class BaseAccountForm extends ApiForm {
/**
* @var Account
*/
private $account;
public function __construct(Account $account, array $config = []) {
parent::__construct($config);
$this->account = $account;
}
public function getAccount(): Account {
return $this->account;
}
}

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, '#');
}
}