Объединены сущности для авторизации посредством 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

@ -0,0 +1,10 @@
<?php
namespace api\modules\accounts;
use yii\base\Module as BaseModule;
class Module extends BaseModule {
public $id = 'accounts';
}

View File

@ -0,0 +1,12 @@
<?php
namespace api\modules\accounts\actions;
use api\modules\accounts\models\AcceptRulesForm;
class AcceptRulesAction extends BaseAccountAction {
protected function getFormClassName(): string {
return AcceptRulesForm::class;
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace api\modules\accounts\actions;
use api\modules\accounts\models\BanAccountForm;
class BanAccountAction extends BaseAccountAction {
protected function getFormClassName(): string {
return BanAccountForm::class;
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace api\modules\accounts\actions;
use api\modules\accounts\models\AccountActionForm;
use common\models\Account;
use Yii;
use yii\base\Action;
use yii\web\NotFoundHttpException;
abstract class BaseAccountAction extends Action {
final public function run(int $id): array {
$className = $this->getFormClassName();
/** @var AccountActionForm $model */
$model = new $className($this->findAccount($id));
$model->load($this->getRequestData());
if (!$model->performAction()) {
return $this->formatFailedResult($model);
}
return $this->formatSuccessResult($model);
}
abstract protected function getFormClassName(): string;
public function getRequestData(): array {
return Yii::$app->request->post();
}
public function getSuccessResultData(AccountActionForm $model): array {
return [];
}
public function getFailedResultData(AccountActionForm $model): array {
return [];
}
private function formatFailedResult(AccountActionForm $model): array {
$response = [
'success' => false,
'errors' => $model->getFirstErrors(),
];
$data = $this->getFailedResultData($model);
if (!empty($data)) {
$response['data'] = $data;
}
return $response;
}
private function formatSuccessResult(AccountActionForm $model): array {
$response = [
'success' => true,
];
$data = $this->getSuccessResultData($model);
if (!empty($data)) {
$response['data'] = $data;
}
return $response;
}
private function findAccount(int $id): Account {
$account = Account::findOne($id);
if ($account === null) {
throw new NotFoundHttpException();
}
return $account;
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace api\modules\accounts\actions;
use api\modules\accounts\models\AccountActionForm;
use api\modules\accounts\models\ChangeEmailForm;
class ChangeEmailAction extends BaseAccountAction {
protected function getFormClassName(): string {
return ChangeEmailForm::class;
}
/**
* @param ChangeEmailForm|AccountActionForm $model
* @return array
*/
public function getSuccessResultData(AccountActionForm $model): array {
return [
'email' => $model->getAccount()->email,
];
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace api\modules\accounts\actions;
use api\modules\accounts\models\ChangeLanguageForm;
class ChangeLanguageAction extends BaseAccountAction {
protected function getFormClassName(): string {
return ChangeLanguageForm::class;
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace api\modules\accounts\actions;
use api\modules\accounts\models\ChangePasswordForm;
class ChangePasswordAction extends BaseAccountAction {
protected function getFormClassName(): string {
return ChangePasswordForm::class;
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace api\modules\accounts\actions;
use api\modules\accounts\models\ChangeUsernameForm;
class ChangeUsernameAction extends BaseAccountAction {
protected function getFormClassName(): string {
return ChangeUsernameForm::class;
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace api\modules\accounts\actions;
use api\modules\accounts\models\DisableTwoFactorAuthForm;
class DisableTwoFactorAuthAction extends BaseAccountAction {
protected function getFormClassName(): string {
return DisableTwoFactorAuthForm::class;
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace api\modules\accounts\actions;
use api\modules\accounts\models\AccountActionForm;
use api\modules\accounts\models\SendEmailVerificationForm;
use common\helpers\Error as E;
class EmailVerificationAction extends BaseAccountAction {
protected function getFormClassName(): string {
return SendEmailVerificationForm::class;
}
/**
* @param SendEmailVerificationForm|AccountActionForm $model
* @return array
*/
public function getFailedResultData(AccountActionForm $model): array {
$emailError = $model->getFirstError('email');
if ($emailError !== E::RECENTLY_SENT_MESSAGE) {
return [];
}
$emailActivation = $model->getEmailActivation();
return [
'canRepeatIn' => $emailActivation->canRepeatIn(),
'repeatFrequency' => $emailActivation->repeatTimeout,
];
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace api\modules\accounts\actions;
use api\modules\accounts\models\EnableTwoFactorAuthForm;
class EnableTwoFactorAuthAction extends BaseAccountAction {
protected function getFormClassName(): string {
return EnableTwoFactorAuthForm::class;
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace api\modules\accounts\actions;
use api\modules\accounts\models\SendNewEmailVerificationForm;
class NewEmailVerificationAction extends BaseAccountAction {
protected function getFormClassName(): string {
return SendNewEmailVerificationForm::class;
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace api\modules\accounts\actions;
use api\modules\accounts\models\PardonAccountForm;
class PardonAccountAction extends BaseAccountAction {
protected function getFormClassName(): string {
return PardonAccountForm::class;
}
}

View File

@ -0,0 +1,133 @@
<?php
namespace api\modules\accounts\controllers;
use api\controllers\Controller;
use api\modules\accounts\actions;
use api\modules\accounts\models\AccountInfo;
use api\modules\accounts\models\TwoFactorAuthInfo;
use common\models\Account;
use common\rbac\Permissions as P;
use Yii;
use yii\filters\AccessControl;
use yii\helpers\ArrayHelper;
use yii\web\NotFoundHttpException;
class DefaultController extends Controller {
public function behaviors(): array {
$paramsCallback = function() {
return [
'accountId' => Yii::$app->request->get('id'),
];
};
return ArrayHelper::merge(Controller::behaviors(), [
'access' => [
'class' => AccessControl::class,
'rules' => [
[
'allow' => true,
'actions' => ['get'],
'roles' => [P::OBTAIN_ACCOUNT_INFO],
'roleParams' => function() use ($paramsCallback) {
return array_merge($paramsCallback(), [
'optionalRules' => true,
]);
},
],
[
'allow' => true,
'actions' => ['username'],
'roles' => [P::CHANGE_ACCOUNT_USERNAME],
'roleParams' => $paramsCallback,
],
[
'allow' => true,
'actions' => ['password'],
'roles' => [P::CHANGE_ACCOUNT_PASSWORD],
'roleParams' => $paramsCallback,
],
[
'allow' => true,
'actions' => ['language'],
'roles' => [P::CHANGE_ACCOUNT_LANGUAGE],
'roleParams' => $paramsCallback,
],
[
'allow' => true,
'actions' => [
'email',
'email-verification',
'new-email-verification',
],
'roles' => [P::CHANGE_ACCOUNT_EMAIL],
'roleParams' => $paramsCallback,
],
[
'allow' => true,
'actions' => ['rules'],
'roles' => [P::ACCEPT_NEW_PROJECT_RULES],
'roleParams' => function() use ($paramsCallback) {
return array_merge($paramsCallback(), [
'optionalRules' => true,
]);
},
],
[
'allow' => true,
'actions' => [
'get-two-factor-auth-credentials',
'enable-two-factor-auth',
'disable-two-factor-auth',
],
'roles' => [P::MANAGE_TWO_FACTOR_AUTH],
'roleParams' => $paramsCallback,
],
[
'allow' => true,
'actions' => [
'ban',
'pardon',
],
'roles' => [P::BLOCK_ACCOUNT],
'roleParams' => $paramsCallback,
],
],
],
]);
}
public function actions(): array {
return [
'username' => actions\ChangeUsernameAction::class,
'password' => actions\ChangePasswordAction::class,
'language' => actions\ChangeLanguageAction::class,
'email' => actions\ChangeEmailAction::class,
'email-verification' => actions\EmailVerificationAction::class,
'new-email-verification' => actions\NewEmailVerificationAction::class,
'rules' => actions\AcceptRulesAction::class,
'enable-two-factor-auth' => actions\EnableTwoFactorAuthAction::class,
'disable-two-factor-auth' => actions\DisableTwoFactorAuthAction::class,
'ban' => actions\BanAccountAction::class,
'pardon' => actions\PardonAccountAction::class,
];
}
public function actionGet(int $id): array {
return (new AccountInfo($this->findAccount($id)))->info();
}
public function actionGetTwoFactorAuthCredentials(int $id): array {
return (new TwoFactorAuthInfo($this->findAccount($id)))->getCredentials();
}
private function findAccount(int $id): Account {
$account = Account::findOne($id);
if ($account === null) {
throw new NotFoundHttpException();
}
return $account;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace api\modules\accounts\models;
use yii\base\ErrorException;
use const \common\LATEST_RULES_VERSION;
class AcceptRulesForm extends AccountActionForm {
public function performAction(): bool {
$account = $this->getAccount();
$account->rules_agreement_version = LATEST_RULES_VERSION;
if (!$account->save()) {
throw new ErrorException('Cannot set user rules version');
}
return true;
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace api\modules\accounts\models;
use api\models\base\BaseAccountForm;
abstract class AccountActionForm extends BaseAccountForm {
abstract public function performAction(): bool;
}

View File

@ -0,0 +1,54 @@
<?php
namespace api\modules\accounts\models;
use api\models\base\BaseAccountForm;
use common\models\Account;
use common\rbac\Permissions as P;
use yii\di\Instance;
use yii\web\User;
class AccountInfo extends BaseAccountForm {
/**
* @var User|string
*/
public $user = 'user';
public function init() {
parent::init();
$this->user = Instance::ensure($this->user, User::class);
}
public function info(): array {
$account = $this->getAccount();
$response = [
'id' => $account->id,
'uuid' => $account->uuid,
'username' => $account->username,
'isOtpEnabled' => (bool)$account->is_otp_enabled,
'registeredAt' => $account->created_at,
'lang' => $account->lang,
'elyProfileLink' => $account->getProfileLink(),
];
$authManagerParams = [
'accountId' => $account->id,
'optionalRules' => true,
];
if ($this->user->can(P::OBTAIN_ACCOUNT_EMAIL, $authManagerParams)) {
$response['email'] = $account->email;
}
if ($this->user->can(P::OBTAIN_EXTENDED_ACCOUNT_INFO, $authManagerParams)) {
$response['isActive'] = $account->status === Account::STATUS_ACTIVE;
$response['passwordChangedAt'] = $account->password_changed_at;
$response['hasMojangUsernameCollision'] = $account->hasMojangUsernameCollision();
$response['shouldAcceptRules'] = !$account->isAgreedWithActualRules();
}
return $response;
}
}

View File

@ -1,7 +1,6 @@
<?php
namespace api\modules\internal\models;
namespace api\modules\accounts\models;
use api\models\base\ApiForm;
use api\modules\internal\helpers\Error as E;
use common\helpers\Amqp;
use common\models\Account;
@ -10,7 +9,7 @@ use PhpAmqpLib\Message\AMQPMessage;
use Yii;
use yii\base\ErrorException;
class BanForm extends ApiForm {
class BanAccountForm extends AccountActionForm {
public const DURATION_FOREVER = -1;
@ -31,11 +30,6 @@ class BanForm extends ApiForm {
*/
public $message = '';
/**
* @var Account
*/
private $account;
public function rules(): array {
return [
[['duration'], 'integer', 'min' => self::DURATION_FOREVER],
@ -44,24 +38,20 @@ class BanForm extends ApiForm {
];
}
public function getAccount(): Account {
return $this->account;
}
public function validateAccountActivity() {
if ($this->account->status === Account::STATUS_BANNED) {
if ($this->getAccount()->status === Account::STATUS_BANNED) {
$this->addError('account', E::ACCOUNT_ALREADY_BANNED);
}
}
public function ban(): bool {
public function performAction(): bool {
if (!$this->validate()) {
return false;
}
$transaction = Yii::$app->db->beginTransaction();
$account = $this->account;
$account = $this->getAccount();
$account->status = Account::STATUS_BANNED;
if (!$account->save()) {
throw new ErrorException('Cannot ban account');
@ -76,7 +66,7 @@ class BanForm extends ApiForm {
public function createTask(): void {
$model = new AccountBanned();
$model->accountId = $this->account->id;
$model->accountId = $this->getAccount()->id;
$model->duration = $this->duration;
$model->message = $this->message;
@ -87,9 +77,4 @@ class BanForm extends ApiForm {
Amqp::sendToEventsExchange('accounts.account-banned', $message);
}
public function __construct(Account $account, array $config = []) {
$this->account = $account;
parent::__construct($config);
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace api\modules\accounts\models;
use api\validators\EmailActivationKeyValidator;
use common\helpers\Amqp;
use common\models\amqp\EmailChanged;
use common\models\EmailActivation;
use PhpAmqpLib\Message\AMQPMessage;
use Yii;
use yii\base\ErrorException;
class ChangeEmailForm extends AccountActionForm {
public $key;
public function rules(): array {
return [
['key', EmailActivationKeyValidator::class, 'type' => EmailActivation::TYPE_NEW_EMAIL_CONFIRMATION],
];
}
public function performAction(): 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;
}
public function createTask(int $accountId, string $newEmail, string $oldEmail): void {
$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);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace api\modules\accounts\models;
use api\exceptions\ThisShouldNotHappenException;
use common\validators\LanguageValidator;
class ChangeLanguageForm extends AccountActionForm {
public $lang;
public function rules(): array {
return [
['lang', 'required'],
['lang', LanguageValidator::class],
];
}
public function performAction(): bool {
if (!$this->validate()) {
return false;
}
$account = $this->getAccount();
$account->lang = $this->lang;
if (!$account->save()) {
throw new ThisShouldNotHappenException('Cannot change user language');
}
return true;
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace api\modules\accounts\models;
use api\components\User\Component;
use api\exceptions\ThisShouldNotHappenException;
use api\validators\PasswordRequiredValidator;
use common\helpers\Error as E;
use common\validators\PasswordValidator;
use Yii;
use yii\helpers\ArrayHelper;
class ChangePasswordForm extends AccountActionForm {
public $newPassword;
public $newRePassword;
public $logoutAll;
public $password;
/**
* @inheritdoc
*/
public function rules(): array {
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->getAccount(), 'when' => function() {
return !$this->hasErrors();
}],
]);
}
public function validatePasswordAndRePasswordMatch($attribute): void {
if (!$this->hasErrors($attribute)) {
if ($this->newPassword !== $this->newRePassword) {
$this->addError($attribute, E::NEW_RE_PASSWORD_DOES_NOT_MATCH);
}
}
}
public function performAction(): bool {
if (!$this->validate()) {
return false;
}
$transaction = Yii::$app->db->beginTransaction();
$account = $this->getAccount();
$account->setPassword($this->newPassword);
if ($this->logoutAll) {
Yii::$app->user->terminateSessions($account, Component::KEEP_CURRENT_SESSION);
}
if (!$account->save()) {
throw new ThisShouldNotHappenException('Cannot save user model');
}
$transaction->commit();
return true;
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace api\modules\accounts\models;
use api\exceptions\ThisShouldNotHappenException;
use api\validators\PasswordRequiredValidator;
use common\helpers\Amqp;
use common\models\amqp\UsernameChanged;
use common\models\UsernameHistory;
use common\validators\UsernameValidator;
use PhpAmqpLib\Message\AMQPMessage;
use Yii;
use yii\base\ErrorException;
class ChangeUsernameForm extends AccountActionForm {
public $username;
public $password;
public function rules(): array {
return [
['username', UsernameValidator::class, 'accountCallback' => function() {
return $this->getAccount()->id;
}],
['password', PasswordRequiredValidator::class, 'account' => $this->getAccount()],
];
}
public function performAction(): bool {
if (!$this->validate()) {
return false;
}
$account = $this->getAccount();
if ($this->username === $account->username) {
return true;
}
$transaction = Yii::$app->db->beginTransaction();
$oldNickname = $account->username;
$account->username = $this->username;
if (!$account->save()) {
throw new ThisShouldNotHappenException('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();
return true;
}
/**
* TODO: вынести это в отдельную сущность, т.к. эта команда используется внутри формы регистрации
*
* @param integer $accountId
* @param string $newNickname
* @param string $oldNickname
*
* @throws \PhpAmqpLib\Exception\AMQPExceptionInterface|\yii\base\Exception
*/
public function createEventTask($accountId, $newNickname, $oldNickname): void {
$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

@ -0,0 +1,45 @@
<?php
namespace api\modules\accounts\models;
use api\exceptions\ThisShouldNotHappenException;
use api\validators\PasswordRequiredValidator;
use api\validators\TotpValidator;
use common\helpers\Error as E;
class DisableTwoFactorAuthForm extends AccountActionForm {
public $totp;
public $password;
public function rules(): array {
return [
['account', 'validateOtpEnabled'],
['totp', 'required', 'message' => E::TOTP_REQUIRED],
['totp', TotpValidator::class, 'account' => $this->getAccount()],
['password', PasswordRequiredValidator::class, 'account' => $this->getAccount()],
];
}
public function performAction(): bool {
if (!$this->validate()) {
return false;
}
$account = $this->getAccount();
$account->is_otp_enabled = false;
$account->otp_secret = null;
if (!$account->save()) {
throw new ThisShouldNotHappenException('Cannot disable otp for account');
}
return true;
}
public function validateOtpEnabled($attribute): void {
if (!$this->getAccount()->is_otp_enabled) {
$this->addError($attribute, E::OTP_NOT_ENABLED);
}
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace api\modules\accounts\models;
use api\components\User\Component;
use api\exceptions\ThisShouldNotHappenException;
use api\validators\PasswordRequiredValidator;
use api\validators\TotpValidator;
use common\helpers\Error as E;
use Yii;
class EnableTwoFactorAuthForm extends AccountActionForm {
public $totp;
public $password;
public function rules(): array {
return [
['account', 'validateOtpDisabled'],
['totp', 'required', 'message' => E::TOTP_REQUIRED],
['totp', TotpValidator::class, 'account' => $this->getAccount()],
['password', PasswordRequiredValidator::class, 'account' => $this->getAccount()],
];
}
public function performAction(): bool {
if (!$this->validate()) {
return false;
}
$transaction = Yii::$app->db->beginTransaction();
$account = $this->getAccount();
$account->is_otp_enabled = true;
if (!$account->save()) {
throw new ThisShouldNotHappenException('Cannot enable otp for account');
}
Yii::$app->user->terminateSessions($account, Component::KEEP_CURRENT_SESSION);
$transaction->commit();
return true;
}
public function validateOtpDisabled($attribute): void {
if ($this->getAccount()->is_otp_enabled) {
$this->addError($attribute, E::OTP_ALREADY_ENABLED);
}
}
}

View File

@ -1,7 +1,6 @@
<?php
namespace api\modules\internal\models;
namespace api\modules\accounts\models;
use api\models\base\ApiForm;
use api\modules\internal\helpers\Error as E;
use common\helpers\Amqp;
use common\models\Account;
@ -10,12 +9,7 @@ use PhpAmqpLib\Message\AMQPMessage;
use Yii;
use yii\base\ErrorException;
class PardonForm extends ApiForm {
/**
* @var Account
*/
private $account;
class PardonAccountForm extends AccountActionForm {
public function rules(): array {
return [
@ -23,24 +17,20 @@ class PardonForm extends ApiForm {
];
}
public function getAccount(): Account {
return $this->account;
}
public function validateAccountBanned(): void {
if ($this->account->status !== Account::STATUS_BANNED) {
if ($this->getAccount()->status !== Account::STATUS_BANNED) {
$this->addError('account', E::ACCOUNT_NOT_BANNED);
}
}
public function pardon(): bool {
public function performAction(): bool {
if (!$this->validate()) {
return false;
}
$transaction = Yii::$app->db->beginTransaction();
$account = $this->account;
$account = $this->getAccount();
$account->status = Account::STATUS_ACTIVE;
if (!$account->save()) {
throw new ErrorException('Cannot pardon account');
@ -55,7 +45,7 @@ class PardonForm extends ApiForm {
public function createTask(): void {
$model = new AccountPardoned();
$model->accountId = $this->account->id;
$model->accountId = $this->getAccount()->id;
$message = Amqp::getInstance()->prepareMessage($model, [
'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT,
@ -64,9 +54,4 @@ class PardonForm extends ApiForm {
Amqp::sendToEventsExchange('accounts.account-pardoned', $message);
}
public function __construct(Account $account, array $config = []) {
$this->account = $account;
parent::__construct($config);
}
}

View File

@ -0,0 +1,92 @@
<?php
namespace api\modules\accounts\models;
use api\exceptions\ThisShouldNotHappenException;
use common\emails\EmailHelper;
use api\validators\PasswordRequiredValidator;
use common\helpers\Error as E;
use common\models\confirmations\CurrentEmailConfirmation;
use common\models\EmailActivation;
use Yii;
class SendEmailVerificationForm extends AccountActionForm {
public $password;
/**
* @var null meta-поле, чтобы заставить yii валидировать и публиковать ошибки, связанные с отправленными email
*/
public $email;
public function rules(): array {
return [
['email', 'validateFrequency', 'skipOnEmpty' => false],
['password', PasswordRequiredValidator::class, 'account' => $this->getAccount()],
];
}
public function validateFrequency($attribute): void {
if (!$this->hasErrors()) {
$emailConfirmation = $this->getEmailActivation();
if ($emailConfirmation !== null && !$emailConfirmation->canRepeat()) {
$this->addError($attribute, E::RECENTLY_SENT_MESSAGE);
}
}
}
public function performAction(): bool {
if (!$this->validate()) {
return false;
}
$transaction = Yii::$app->db->beginTransaction();
$this->removeOldCode();
$activation = $this->createCode();
EmailHelper::changeEmailConfirmCurrent($activation);
$transaction->commit();
return true;
}
public function createCode(): CurrentEmailConfirmation {
$account = $this->getAccount();
$emailActivation = new CurrentEmailConfirmation();
$emailActivation->account_id = $account->id;
if (!$emailActivation->save()) {
throw new ThisShouldNotHappenException('Cannot save email activation model');
}
return $emailActivation;
}
public function removeOldCode(): void {
$emailActivation = $this->getEmailActivation();
if ($emailActivation === null) {
return;
}
$emailActivation->delete();
}
/**
* Возвращает E-mail активацию, которая использовалась внутри процесса для перехода на следующий шаг.
* Метод предназначен для проверки, не слишком ли часто отправляются письма о смене E-mail.
* Проверяем тип подтверждения нового E-mail, поскольку при переходе на этот этап, активация предыдущего
* шага удаляется.
*/
public function getEmailActivation(): ?EmailActivation {
return $this->getAccount()
->getEmailActivations()
->andWhere([
'type' => [
EmailActivation::TYPE_CURRENT_EMAIL_CONFIRMATION,
EmailActivation::TYPE_NEW_EMAIL_CONFIRMATION,
],
])
->one();
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace api\modules\accounts\models;
use api\exceptions\ThisShouldNotHappenException;
use common\emails\EmailHelper;
use api\validators\EmailActivationKeyValidator;
use common\models\confirmations\NewEmailConfirmation;
use common\models\EmailActivation;
use common\validators\EmailValidator;
use Yii;
class SendNewEmailVerificationForm extends AccountActionForm {
public $key;
public $email;
public function rules(): array {
return [
['key', EmailActivationKeyValidator::class, 'type' => EmailActivation::TYPE_CURRENT_EMAIL_CONFIRMATION],
['email', EmailValidator::class],
];
}
public function performAction(): 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;
}
public function createCode(): NewEmailConfirmation {
$emailActivation = new NewEmailConfirmation();
$emailActivation->account_id = $this->getAccount()->id;
$emailActivation->newEmail = $this->email;
if (!$emailActivation->save()) {
throw new ThisShouldNotHappenException('Cannot save email activation model');
}
return $emailActivation;
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace api\modules\accounts\models;
use common\models\Account;
use OTPHP\TOTP;
trait TotpHelper {
protected function getTotp(): TOTP {
$account = $this->getAccount();
$totp = TOTP::create($account->otp_secret);
$totp->setLabel($account->email);
$totp->setIssuer('Ely.by');
return $totp;
}
abstract public function getAccount(): Account;
}

View File

@ -0,0 +1,80 @@
<?php
namespace api\modules\accounts\models;
use api\exceptions\ThisShouldNotHappenException;
use api\models\base\BaseAccountForm;
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 ParagonIE\ConstantTime\Base32;
class TwoFactorAuthInfo extends BaseAccountForm {
use TotpHelper;
public function getCredentials(): array {
if (empty($this->getAccount()->otp_secret)) {
$this->setOtpSecret();
}
$provisioningUri = $this->getTotp()->getProvisioningUri();
return [
'qr' => 'data:image/svg+xml,' . trim($this->drawQrCode($provisioningUri)),
'uri' => $provisioningUri,
'secret' => $this->getAccount()->otp_secret,
];
}
private function drawQrCode(string $content): string {
$content = $this->forceMinimalQrContentLength($content);
$renderer = new Svg();
$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);
}
/**
* otp_secret кодируется в Base32, т.к. после кодирования в результурющей строке нет символов,
* которые можно перепутать (1 и l, O и 0, и т.д.). Отрицательной стороной является то, что итоговая
* строка составляет 160% от исходной. Поэтому, генерируя исходный приватный ключ, мы должны обеспечить
* ему такую длину, чтобы 160% его длины было равно запрошенному значению
*
* @param int $length
*
* @throws ThisShouldNotHappenException
*/
private function setOtpSecret(int $length = 24): void {
$account = $this->getAccount();
$randomBytesLength = ceil($length / 1.6);
$randomBase32 = trim(Base32::encodeUpper(random_bytes($randomBytesLength)), '=');
$account->otp_secret = substr($randomBase32, 0, $length);
if (!$account->save()) {
throw new ThisShouldNotHappenException('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, '#');
}
}

View File

@ -7,7 +7,7 @@ use Yii;
class AuthenticationController extends Controller {
public function behaviors() {
public function behaviors(): array {
$behaviors = parent::behaviors();
unset($behaviors['authenticator']);

View File

@ -1,58 +1,42 @@
<?php
namespace api\modules\internal\controllers;
use api\components\ApiUser\AccessControl;
use api\controllers\Controller;
use api\modules\internal\models\BanForm;
use api\modules\internal\models\PardonForm;
use common\models\Account;
use common\models\OauthScope as S;
use Yii;
use common\rbac\Permissions as P;
use yii\filters\AccessControl;
use yii\helpers\ArrayHelper;
use yii\web\BadRequestHttpException;
use yii\web\NotFoundHttpException;
class AccountsController extends Controller {
public function behaviors() {
public function behaviors(): array {
return ArrayHelper::merge(parent::behaviors(), [
'authenticator' => [
'user' => Yii::$app->apiUser,
],
'access' => [
'class' => AccessControl::class,
'rules' => [
[
'actions' => ['ban'],
'allow' => true,
'roles' => [S::ACCOUNT_BLOCK],
],
[
'actions' => ['info'],
'allow' => true,
'roles' => [S::INTERNAL_ACCOUNT_INFO],
'roles' => [P::OBTAIN_EXTENDED_ACCOUNT_INFO],
'roleParams' => function() {
return [
'accountId' => 0,
];
},
],
],
],
]);
}
public function verbs() {
public function verbs(): array {
return [
'ban' => ['POST', 'DELETE'],
'info' => ['GET'],
];
}
public function actionBan(int $accountId) {
$account = $this->findAccount($accountId);
if (Yii::$app->request->isPost) {
return $this->banAccount($account);
} else {
return $this->pardonAccount($account);
}
}
public function actionInfo(int $id = null, string $username = null, string $uuid = null) {
if ($id !== null) {
$account = Account::findOne($id);
@ -76,43 +60,4 @@ class AccountsController extends Controller {
];
}
private function banAccount(Account $account) {
$model = new BanForm($account);
$model->load(Yii::$app->request->post());
if (!$model->ban()) {
return [
'success' => false,
'errors' => $model->getFirstErrors(),
];
}
return [
'success' => true,
];
}
private function pardonAccount(Account $account) {
$model = new PardonForm($account);
$model->load(Yii::$app->request->post());
if (!$model->pardon()) {
return [
'success' => false,
'errors' => $model->getFirstErrors(),
];
}
return [
'success' => true,
];
}
private function findAccount(int $accountId): Account {
$account = Account::findOne($accountId);
if ($account === null) {
throw new NotFoundHttpException();
}
return $account;
}
}

View File

@ -10,7 +10,7 @@ use yii\web\Response;
class ApiController extends Controller {
public function behaviors() {
public function behaviors(): array {
$behaviors = parent::behaviors();
unset($behaviors['authenticator']);

View File

@ -1,7 +1,7 @@
<?php
namespace api\modules\session\controllers;
use api\controllers\ApiController;
use api\controllers\Controller;
use api\modules\session\exceptions\ForbiddenOperationException;
use api\modules\session\exceptions\IllegalArgumentException;
use api\modules\session\exceptions\SessionServerException;
@ -17,9 +17,9 @@ use Ramsey\Uuid\Uuid;
use Yii;
use yii\web\Response;
class SessionController extends ApiController {
class SessionController extends Controller {
public function behaviors() {
public function behaviors(): array {
$behaviors = parent::behaviors();
unset($behaviors['authenticator']);
$behaviors['rateLimiting'] = [

View File

@ -18,7 +18,7 @@ class HasJoinedForm extends Model {
parent::__construct($config);
}
public function hasJoined() : Account {
public function hasJoined(): Account {
if (!$this->protocol->validate()) {
throw new IllegalArgumentException();
}

View File

@ -7,10 +7,10 @@ use api\modules\session\models\protocols\JoinInterface;
use api\modules\session\Module as Session;
use api\modules\session\validators\RequiredValidator;
use common\helpers\StringHelper;
use common\models\OauthScope as S;
use common\validators\UuidValidator;
use common\rbac\Permissions as P;
use common\models\Account;
use common\models\MinecraftAccessKey;
use Ramsey\Uuid\Uuid;
use Yii;
use yii\base\ErrorException;
use yii\base\Model;
@ -84,16 +84,7 @@ class JoinForm extends Model {
return;
}
if ($attribute === 'selectedProfile' && !StringHelper::isUuid($this->selectedProfile)) {
// Это нормально. Там может быть ник игрока, если это Legacy авторизация
return;
}
$validator = new UuidValidator();
$validator->allowNil = false;
$validator->validateAttribute($this, $attribute);
if ($this->hasErrors($attribute)) {
if ($this->$attribute === Uuid::NIL) {
throw new IllegalArgumentException();
}
}
@ -105,9 +96,17 @@ class JoinForm extends Model {
$accessToken = $this->accessToken;
/** @var MinecraftAccessKey|null $accessModel */
$accessModel = MinecraftAccessKey::findOne($accessToken);
if ($accessModel === null) {
if ($accessModel !== null) {
/** @var MinecraftAccessKey|\api\components\OAuth2\Entities\AccessTokenEntity $accessModel */
if ($accessModel->isExpired()) {
Session::error("User with access_token = '{$accessToken}' failed join by expired access_token.");
throw new ForbiddenOperationException('Expired access_token.');
}
$account = $accessModel->account;
} else {
try {
$identity = Yii::$app->apiUser->loginByAccessToken($accessToken);
$identity = Yii::$app->user->loginByAccessToken($accessToken);
} catch (UnauthorizedHttpException $e) {
$identity = null;
}
@ -117,21 +116,12 @@ class JoinForm extends Model {
throw new ForbiddenOperationException('Invalid access_token.');
}
if (!Yii::$app->apiUser->can(S::MINECRAFT_SERVER_SESSION)) {
if (!Yii::$app->user->can(P::MINECRAFT_SERVER_SESSION)) {
Session::error("User with access_token = '{$accessToken}' doesn't have enough scopes to make join.");
throw new ForbiddenOperationException('The token does not have required scope.');
}
$accessModel = $identity->getAccessToken();
$account = $identity->getAccount();
} else {
$account = $accessModel->account;
}
/** @var MinecraftAccessKey|\api\components\OAuth2\Entities\AccessTokenEntity $accessModel */
if ($accessModel->isExpired()) {
Session::error("User with access_token = '{$accessToken}' failed join by expired access_token.");
throw new ForbiddenOperationException('Expired access_token.');
}
$selectedProfile = $this->selectedProfile;
@ -142,7 +132,9 @@ class JoinForm extends Model {
" but access_token issued to account with id = '{$account->uuid}'."
);
throw new ForbiddenOperationException('Wrong selected_profile.');
} elseif (!$isUuid && $account->username !== $selectedProfile) {
}
if (!$isUuid && $account->username !== $selectedProfile) {
Session::error(
"User with access_token = '{$accessToken}' trying to join with identity = '{$selectedProfile}'," .
" but access_token issued to account with username = '{$account->username}'."
@ -153,10 +145,7 @@ class JoinForm extends Model {
$this->account = $account;
}
/**
* @return Account|null
*/
protected function getAccount() {
protected function getAccount(): Account {
return $this->account;
}

View File

@ -17,24 +17,16 @@ class SessionModel {
$this->serverId = $serverId;
}
/**
* @param $username
* @param $serverId
*
* @return static|null
*/
public static function find($username, $serverId) {
public static function find(string $username, string $serverId): ?self {
$key = static::buildKey($username, $serverId);
$result = Yii::$app->redis->executeCommand('GET', [$key]);
if (!$result) {
/** @noinspection PhpIncompatibleReturnTypeInspection шторм что-то сума сходит, когда видит static */
return null;
}
$data = json_decode($result, true);
$model = new static($data['username'], $data['serverId']);
return $model;
return new static($data['username'], $data['serverId']);
}
public function save() {
@ -51,15 +43,11 @@ class SessionModel {
return Yii::$app->redis->executeCommand('DEL', [static::buildKey($this->username, $this->serverId)]);
}
/**
* @return Account|null
* TODO: после перехода на PHP 7.1 установить тип как ?Account
*/
public function getAccount() {
public function getAccount(): ?Account {
return Account::findOne(['username' => $this->username]);
}
protected static function buildKey($username, $serverId) : string {
protected static function buildKey($username, $serverId): string {
return md5('minecraft:join-server:' . mb_strtolower($username) . ':' . $serverId);
}

View File

@ -1,31 +1,30 @@
<?php
namespace api\modules\session\models\protocols;
use yii\validators\RequiredValidator;
abstract class BaseHasJoined implements HasJoinedInterface {
private $username;
private $serverId;
public function __construct(string $username, string $serverId) {
$this->username = $username;
$this->serverId = $serverId;
$this->username = trim($username);
$this->serverId = trim($serverId);
}
public function getUsername() : string {
public function getUsername(): string {
return $this->username;
}
public function getServerId() : string {
public function getServerId(): string {
return $this->serverId;
}
public function validate() : bool {
$validator = new RequiredValidator();
public function validate(): bool {
return !$this->isEmpty($this->username) && !$this->isEmpty($this->serverId);
}
return $validator->validate($this->username)
&& $validator->validate($this->serverId);
private function isEmpty($value): bool {
return $value === null || $value === '';
}
}

View File

@ -3,12 +3,8 @@ namespace api\modules\session\models\protocols;
abstract class BaseJoin implements JoinInterface {
abstract public function getAccessToken() : string;
abstract public function getSelectedProfile() : string;
abstract public function getServerId() : string;
abstract public function validate() : bool;
protected function isEmpty($value): bool {
return $value === null || $value === '';
}
}

View File

@ -3,10 +3,10 @@ namespace api\modules\session\models\protocols;
interface HasJoinedInterface {
public function getUsername() : string;
public function getUsername(): string;
public function getServerId() : string;
public function getServerId(): string;
public function validate() : bool;
public function validate(): bool;
}

View File

@ -3,13 +3,12 @@ namespace api\modules\session\models\protocols;
interface JoinInterface {
public function getAccessToken() : string;
public function getAccessToken(): string;
// TODO: после перехода на PHP 7.1 сменить тип на ?string и возвращать null, если параметр не передан
public function getSelectedProfile() : string;
public function getSelectedProfile(): string;
public function getServerId() : string;
public function getServerId(): string;
public function validate() : bool;
public function validate(): bool;
}

View File

@ -1,8 +1,6 @@
<?php
namespace api\modules\session\models\protocols;
use yii\validators\RequiredValidator;
class LegacyJoin extends BaseJoin {
private $user;
@ -13,9 +11,9 @@ class LegacyJoin extends BaseJoin {
private $uuid;
public function __construct(string $user, string $sessionId, string $serverId) {
$this->user = $user;
$this->sessionId = $sessionId;
$this->serverId = $serverId;
$this->user = trim($user);
$this->sessionId = trim($sessionId);
$this->serverId = trim($serverId);
$this->parseSessionId($this->sessionId);
}
@ -24,23 +22,19 @@ class LegacyJoin extends BaseJoin {
return $this->accessToken;
}
public function getSelectedProfile() : string {
public function getSelectedProfile(): string {
return $this->uuid ?: $this->user;
}
public function getServerId() : string {
public function getServerId(): string {
return $this->serverId;
}
/**
* @return bool
*/
public function validate() : bool {
$validator = new RequiredValidator();
return $validator->validate($this->accessToken)
&& $validator->validate($this->user)
&& $validator->validate($this->serverId);
public function validate(): bool {
return !$this->isEmpty($this->accessToken) && !$this->isEmpty($this->user) && !$this->isEmpty($this->serverId);
}
/**
@ -50,7 +44,7 @@ class LegacyJoin extends BaseJoin {
* Бьём по ':' для учёта авторизации в современных лаунчерах и входе на более старую
* версию игры. Там sessionId передаётся как "token:{accessToken}:{uuid}", так что это нужно обработать
*/
protected function parseSessionId(string $sessionId) {
private function parseSessionId(string $sessionId) {
$parts = explode(':', $sessionId);
if (count($parts) === 3) {
$this->accessToken = $parts[1];

View File

@ -1,8 +1,6 @@
<?php
namespace api\modules\session\models\protocols;
use yii\validators\RequiredValidator;
class ModernJoin extends BaseJoin {
private $accessToken;
@ -10,29 +8,25 @@ class ModernJoin extends BaseJoin {
private $serverId;
public function __construct(string $accessToken, string $selectedProfile, string $serverId) {
$this->accessToken = $accessToken;
$this->selectedProfile = $selectedProfile;
$this->serverId = $serverId;
$this->accessToken = trim($accessToken);
$this->selectedProfile = trim($selectedProfile);
$this->serverId = trim($serverId);
}
public function getAccessToken() : string {
public function getAccessToken(): string {
return $this->accessToken;
}
public function getSelectedProfile() : string {
public function getSelectedProfile(): string {
return $this->selectedProfile;
}
public function getServerId() : string {
public function getServerId(): string {
return $this->serverId;
}
public function validate() : bool {
$validator = new RequiredValidator();
return $validator->validate($this->accessToken)
&& $validator->validate($this->selectedProfile)
&& $validator->validate($this->serverId);
public function validate(): bool {
return !$this->isEmpty($this->accessToken) && !$this->isEmpty($this->selectedProfile) && !$this->isEmpty($this->serverId);
}
}