mirror of
				https://github.com/elyby/accounts.git
				synced 2025-05-31 14:11:46 +05:30 
			
		
		
		
	Merge branch 'develop'
This commit is contained in:
		| @@ -1,4 +1,4 @@ | |||||||
| FROM registry.ely.by/elyby/accounts-php:1.2.0 | FROM registry.ely.by/elyby/accounts-php:1.3.0 | ||||||
|  |  | ||||||
| # Вносим конфигурации для крона и воркеров | # Вносим конфигурации для крона и воркеров | ||||||
| COPY docker/cron/* /etc/cron.d/ | COPY docker/cron/* /etc/cron.d/ | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| FROM registry.ely.by/elyby/accounts-php:1.2.0-dev | FROM registry.ely.by/elyby/accounts-php:1.3.0-dev | ||||||
|  |  | ||||||
| # Вносим конфигурации для крона и воркеров | # Вносим конфигурации для крона и воркеров | ||||||
| COPY docker/cron/* /etc/cron.d/ | COPY docker/cron/* /etc/cron.d/ | ||||||
|   | |||||||
| @@ -6,8 +6,8 @@ use yii\web\User as YiiUserComponent; | |||||||
| /** | /** | ||||||
|  * @property Identity|null $identity |  * @property Identity|null $identity | ||||||
|  * |  * | ||||||
|  * @method Identity|null getIdentity() |  * @method Identity|null getIdentity($autoRenew = true) | ||||||
|  * @method Identity|null loginByAccessToken(string $token, $type = null) |  * @method Identity|null loginByAccessToken($token, $type = null) | ||||||
|  */ |  */ | ||||||
| class Component extends YiiUserComponent { | class Component extends YiiUserComponent { | ||||||
|  |  | ||||||
|   | |||||||
| @@ -26,7 +26,8 @@ class Identity implements IdentityInterface { | |||||||
|     /** |     /** | ||||||
|      * @inheritdoc |      * @inheritdoc | ||||||
|      */ |      */ | ||||||
|     public static function findIdentityByAccessToken($token, $type = null) { |     public static function findIdentityByAccessToken($token, $type = null): self { | ||||||
|  |         /** @var AccessTokenEntity|null $model */ | ||||||
|         $model = Yii::$app->oauth->getAuthServer()->getAccessTokenStorage()->get($token); |         $model = Yii::$app->oauth->getAuthServer()->getAccessTokenStorage()->get($token); | ||||||
|         if ($model === null) { |         if ($model === null) { | ||||||
|             throw new UnauthorizedHttpException('Incorrect token'); |             throw new UnauthorizedHttpException('Incorrect token'); | ||||||
| @@ -41,19 +42,19 @@ class Identity implements IdentityInterface { | |||||||
|         $this->_accessToken = $accessToken; |         $this->_accessToken = $accessToken; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function getAccount() : Account { |     public function getAccount(): Account { | ||||||
|         return $this->getSession()->account; |         return $this->getSession()->account; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function getClient() : OauthClient { |     public function getClient(): OauthClient { | ||||||
|         return $this->getSession()->client; |         return $this->getSession()->client; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function getSession() : OauthSession { |     public function getSession(): OauthSession { | ||||||
|         return OauthSession::findOne($this->_accessToken->getSessionId()); |         return OauthSession::findOne($this->_accessToken->getSessionId()); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function getAccessToken() : AccessTokenEntity { |     public function getAccessToken(): AccessTokenEntity { | ||||||
|         return $this->_accessToken; |         return $this->_accessToken; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -62,7 +63,7 @@ class Identity implements IdentityInterface { | |||||||
|      * У нас права привязываются к токенам, так что возвращаем именно его id. |      * У нас права привязываются к токенам, так что возвращаем именно его id. | ||||||
|      * @inheritdoc |      * @inheritdoc | ||||||
|      */ |      */ | ||||||
|     public function getId() { |     public function getId(): string { | ||||||
|         return $this->_accessToken->getId(); |         return $this->_accessToken->getId(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,6 +3,8 @@ namespace api\components\OAuth2\Entities; | |||||||
|  |  | ||||||
| class ClientEntity extends \League\OAuth2\Server\Entity\ClientEntity { | class ClientEntity extends \League\OAuth2\Server\Entity\ClientEntity { | ||||||
|  |  | ||||||
|  |     private $isTrusted; | ||||||
|  |  | ||||||
|     public function setId(string $id) { |     public function setId(string $id) { | ||||||
|         $this->id = $id; |         $this->id = $id; | ||||||
|     } |     } | ||||||
| @@ -19,4 +21,12 @@ class ClientEntity extends \League\OAuth2\Server\Entity\ClientEntity { | |||||||
|         $this->redirectUri = $redirectUri; |         $this->redirectUri = $redirectUri; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public function setIsTrusted(bool $isTrusted) { | ||||||
|  |         $this->isTrusted = $isTrusted; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function isTrusted(): bool { | ||||||
|  |         return $this->isTrusted; | ||||||
|  |     } | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										20
									
								
								api/components/OAuth2/Grants/ClientCredentialsGrant.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								api/components/OAuth2/Grants/ClientCredentialsGrant.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | <?php | ||||||
|  | namespace api\components\OAuth2\Grants; | ||||||
|  |  | ||||||
|  | use api\components\OAuth2\Entities; | ||||||
|  |  | ||||||
|  | class ClientCredentialsGrant extends \League\OAuth2\Server\Grant\ClientCredentialsGrant { | ||||||
|  |  | ||||||
|  |     protected function createAccessTokenEntity() { | ||||||
|  |         return new Entities\AccessTokenEntity($this->server); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     protected function createRefreshTokenEntity() { | ||||||
|  |         return new Entities\RefreshTokenEntity($this->server); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     protected function createSessionEntity() { | ||||||
|  |         return new Entities\SessionEntity($this->server); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -74,6 +74,7 @@ class ClientStorage extends AbstractStorage implements ClientInterface { | |||||||
|         $entity->setId($model->id); |         $entity->setId($model->id); | ||||||
|         $entity->setName($model->name); |         $entity->setName($model->name); | ||||||
|         $entity->setSecret($model->secret); |         $entity->setSecret($model->secret); | ||||||
|  |         $entity->setIsTrusted($model->is_trusted); | ||||||
|         $entity->setRedirectUri($model->redirect_uri); |         $entity->setRedirectUri($model->redirect_uri); | ||||||
|  |  | ||||||
|         return $entity; |         return $entity; | ||||||
|   | |||||||
| @@ -18,6 +18,9 @@ class RefreshTokenStorage extends AbstractStorage implements RefreshTokenInterfa | |||||||
|  |  | ||||||
|     public function get($token) { |     public function get($token) { | ||||||
|         $result = Json::decode((new Key($this->dataTable, $token))->getValue()); |         $result = Json::decode((new Key($this->dataTable, $token))->getValue()); | ||||||
|  |         if ($result === null) { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         $entity = new RefreshTokenEntity($this->server); |         $entity = new RefreshTokenEntity($this->server); | ||||||
|         $entity->setId($result['id']); |         $entity->setId($result['id']); | ||||||
|   | |||||||
| @@ -1,10 +1,12 @@ | |||||||
| <?php | <?php | ||||||
| namespace api\components\OAuth2\Storage; | namespace api\components\OAuth2\Storage; | ||||||
|  |  | ||||||
|  | use api\components\OAuth2\Entities\ClientEntity; | ||||||
| use api\components\OAuth2\Entities\ScopeEntity; | use api\components\OAuth2\Entities\ScopeEntity; | ||||||
| use common\models\OauthScope; | use common\models\OauthScope; | ||||||
| use League\OAuth2\Server\Storage\AbstractStorage; | use League\OAuth2\Server\Storage\AbstractStorage; | ||||||
| use League\OAuth2\Server\Storage\ScopeInterface; | use League\OAuth2\Server\Storage\ScopeInterface; | ||||||
|  | use yii\base\ErrorException; | ||||||
|  |  | ||||||
| class ScopeStorage extends AbstractStorage implements ScopeInterface { | class ScopeStorage extends AbstractStorage implements ScopeInterface { | ||||||
|  |  | ||||||
| @@ -12,7 +14,28 @@ class ScopeStorage extends AbstractStorage implements ScopeInterface { | |||||||
|      * @inheritdoc |      * @inheritdoc | ||||||
|      */ |      */ | ||||||
|     public function get($scope, $grantType = null, $clientId = null) { |     public function get($scope, $grantType = null, $clientId = null) { | ||||||
|         if (!in_array($scope, OauthScope::getScopes(), true)) { |         $query = OauthScope::find(); | ||||||
|  |         if ($grantType === 'authorization_code') { | ||||||
|  |             $query->onlyPublic()->usersScopes(); | ||||||
|  |         } elseif ($grantType === 'client_credentials') { | ||||||
|  |             $query->machineScopes(); | ||||||
|  |             $isTrusted = false; | ||||||
|  |             if ($clientId !== null) { | ||||||
|  |                 $client = $this->server->getClientStorage()->get($clientId); | ||||||
|  |                 if (!$client instanceof ClientEntity) { | ||||||
|  |                     throw new ErrorException('client storage must return instance of ' . ClientEntity::class); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 $isTrusted = $client->isTrusted(); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!$isTrusted) { | ||||||
|  |                 $query->onlyPublic(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         $scopes = $query->all(); | ||||||
|  |         if (!in_array($scope, $scopes, true)) { | ||||||
|             return null; |             return null; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -7,7 +7,7 @@ $params = array_merge( | |||||||
| return [ | return [ | ||||||
|     'id' => 'accounts-site-api', |     'id' => 'accounts-site-api', | ||||||
|     'basePath' => dirname(__DIR__), |     'basePath' => dirname(__DIR__), | ||||||
|     'bootstrap' => ['log', 'authserver'], |     'bootstrap' => ['log', 'authserver', 'internal'], | ||||||
|     'controllerNamespace' => 'api\controllers', |     'controllerNamespace' => 'api\controllers', | ||||||
|     'params' => $params, |     'params' => $params, | ||||||
|     'components' => [ |     'components' => [ | ||||||
| @@ -73,14 +73,6 @@ return [ | |||||||
|         'response' => [ |         'response' => [ | ||||||
|             'format' => yii\web\Response::FORMAT_JSON, |             'format' => yii\web\Response::FORMAT_JSON, | ||||||
|         ], |         ], | ||||||
|         'oauth' => [ |  | ||||||
|             'class' => api\components\OAuth2\Component::class, |  | ||||||
|             'grantTypes' => ['authorization_code'], |  | ||||||
|             'grantMap' => [ |  | ||||||
|                 'authorization_code' => api\components\OAuth2\Grants\AuthCodeGrant::class, |  | ||||||
|                 'refresh_token' => api\components\OAuth2\Grants\RefreshTokenGrant::class, |  | ||||||
|             ], |  | ||||||
|         ], |  | ||||||
|         'errorHandler' => [ |         'errorHandler' => [ | ||||||
|             'class' => api\components\ErrorHandler::class, |             'class' => api\components\ErrorHandler::class, | ||||||
|         ], |         ], | ||||||
| @@ -96,5 +88,8 @@ return [ | |||||||
|         'mojang' => [ |         'mojang' => [ | ||||||
|             'class' => api\modules\mojang\Module::class, |             'class' => api\modules\mojang\Module::class, | ||||||
|         ], |         ], | ||||||
|  |         'internal' => [ | ||||||
|  |             'class' => api\modules\internal\Module::class, | ||||||
|  |         ], | ||||||
|     ], |     ], | ||||||
| ]; | ]; | ||||||
|   | |||||||
| @@ -7,6 +7,9 @@ return [ | |||||||
|     '/accounts/change-email/submit-new-email' => 'accounts/change-email-submit-new-email', |     '/accounts/change-email/submit-new-email' => 'accounts/change-email-submit-new-email', | ||||||
|     '/accounts/change-email/confirm-new-email' => 'accounts/change-email-confirm-new-email', |     '/accounts/change-email/confirm-new-email' => 'accounts/change-email-confirm-new-email', | ||||||
|  |  | ||||||
|  |     'POST /two-factor-auth' => 'two-factor-auth/activate', | ||||||
|  |     'DELETE /two-factor-auth' => 'two-factor-auth/disable', | ||||||
|  |  | ||||||
|     '/oauth2/v1/<action>' => 'oauth/<action>', |     '/oauth2/v1/<action>' => 'oauth/<action>', | ||||||
|  |  | ||||||
|     '/account/v1/info' => 'identity-info/index', |     '/account/v1/info' => 'identity-info/index', | ||||||
|   | |||||||
| @@ -69,6 +69,7 @@ class AccountsController extends Controller { | |||||||
|             'passwordChangedAt' => $account->password_changed_at, |             'passwordChangedAt' => $account->password_changed_at, | ||||||
|             'hasMojangUsernameCollision' => $account->hasMojangUsernameCollision(), |             'hasMojangUsernameCollision' => $account->hasMojangUsernameCollision(), | ||||||
|             'shouldAcceptRules' => !$account->isAgreedWithActualRules(), |             'shouldAcceptRules' => !$account->isAgreedWithActualRules(), | ||||||
|  |             'isOtpEnabled' => (bool)$account->is_otp_enabled, | ||||||
|         ]; |         ]; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -7,7 +7,9 @@ use api\components\OAuth2\Exception\AccessDeniedException; | |||||||
| use common\models\Account; | use common\models\Account; | ||||||
| use common\models\OauthClient; | use common\models\OauthClient; | ||||||
| use common\models\OauthScope; | use common\models\OauthScope; | ||||||
|  | use League\OAuth2\Server\AuthorizationServer; | ||||||
| use League\OAuth2\Server\Exception\OAuthException; | use League\OAuth2\Server\Exception\OAuthException; | ||||||
|  | use League\OAuth2\Server\Grant\AuthCodeGrant; | ||||||
| use Yii; | use Yii; | ||||||
| use yii\filters\AccessControl; | use yii\filters\AccessControl; | ||||||
| use yii\helpers\ArrayHelper; | use yii\helpers\ArrayHelper; | ||||||
| @@ -274,17 +276,12 @@ class OauthController extends Controller { | |||||||
|         return $response; |         return $response; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     private function getServer(): AuthorizationServer { | ||||||
|      * @return \League\OAuth2\Server\AuthorizationServer |  | ||||||
|      */ |  | ||||||
|     private function getServer() { |  | ||||||
|         return Yii::$app->oauth->authServer; |         return Yii::$app->oauth->authServer; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     private function getGrantType(): AuthCodeGrant { | ||||||
|      * @return \League\OAuth2\Server\Grant\AuthCodeGrant |         /** @noinspection PhpIncompatibleReturnTypeInspection */ | ||||||
|      */ |  | ||||||
|     private function getGrantType() { |  | ||||||
|         return $this->getServer()->getGrantType('authorization_code'); |         return $this->getServer()->getGrantType('authorization_code'); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										75
									
								
								api/controllers/TwoFactorAuthController.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								api/controllers/TwoFactorAuthController.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | |||||||
|  | <?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' => [ | ||||||
|  |                     [ | ||||||
|  |                         'allow' => true, | ||||||
|  |                         'class' => ActiveUserRule::class, | ||||||
|  |                     ], | ||||||
|  |                 ], | ||||||
|  |             ], | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function verbs() { | ||||||
|  |         return [ | ||||||
|  |             'credentials' => ['GET'], | ||||||
|  |             'activate' => ['POST'], | ||||||
|  |             'disable' => ['DELETE'], | ||||||
|  |         ]; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function actionCredentials() { | ||||||
|  |         $account = Yii::$app->user->identity; | ||||||
|  |         $model = new TwoFactorAuthForm($account); | ||||||
|  |  | ||||||
|  |         return $model->getCredentials(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function actionActivate() { | ||||||
|  |         $account = Yii::$app->user->identity; | ||||||
|  |         $model = new TwoFactorAuthForm($account, ['scenario' => TwoFactorAuthForm::SCENARIO_ACTIVATE]); | ||||||
|  |         $model->load(Yii::$app->request->post()); | ||||||
|  |         if (!$model->activate()) { | ||||||
|  |             return [ | ||||||
|  |                 'success' => false, | ||||||
|  |                 'errors' => $model->getFirstErrors(), | ||||||
|  |             ]; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return [ | ||||||
|  |             'success' => true, | ||||||
|  |         ]; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function actionDisable() { | ||||||
|  |         $account = Yii::$app->user->identity; | ||||||
|  |         $model = new TwoFactorAuthForm($account, ['scenario' => TwoFactorAuthForm::SCENARIO_DISABLE]); | ||||||
|  |         $model->load(Yii::$app->request->getBodyParams()); | ||||||
|  |         if (!$model->disable()) { | ||||||
|  |             return [ | ||||||
|  |                 'success' => false, | ||||||
|  |                 'errors' => $model->getFirstErrors(), | ||||||
|  |             ]; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return [ | ||||||
|  |             'success' => true, | ||||||
|  |         ]; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -2,6 +2,7 @@ | |||||||
| namespace api\models\authentication; | namespace api\models\authentication; | ||||||
|  |  | ||||||
| use api\models\base\ApiForm; | use api\models\base\ApiForm; | ||||||
|  | use api\validators\TotpValidator; | ||||||
| use common\helpers\Error as E; | use common\helpers\Error as E; | ||||||
| use api\traits\AccountFinder; | use api\traits\AccountFinder; | ||||||
| use common\components\UserFriendlyRandomKey; | use common\components\UserFriendlyRandomKey; | ||||||
| @@ -16,11 +17,16 @@ class ForgotPasswordForm extends ApiForm { | |||||||
|     use AccountFinder; |     use AccountFinder; | ||||||
|  |  | ||||||
|     public $login; |     public $login; | ||||||
|  |     public $token; | ||||||
|  |  | ||||||
|     public function rules() { |     public function rules() { | ||||||
|         return [ |         return [ | ||||||
|             ['login', 'required', 'message' => E::LOGIN_REQUIRED], |             ['login', 'required', 'message' => E::LOGIN_REQUIRED], | ||||||
|             ['login', 'validateLogin'], |             ['login', 'validateLogin'], | ||||||
|  |             ['token', 'required', 'when' => function(self $model) { | ||||||
|  |                 return !$this->hasErrors() && $model->getAccount()->is_otp_enabled; | ||||||
|  |             }, 'message' => E::OTP_TOKEN_REQUIRED], | ||||||
|  |             ['token', 'validateTotpToken'], | ||||||
|             ['login', 'validateActivity'], |             ['login', 'validateActivity'], | ||||||
|             ['login', 'validateFrequency'], |             ['login', 'validateFrequency'], | ||||||
|         ]; |         ]; | ||||||
| @@ -34,6 +40,20 @@ class ForgotPasswordForm extends ApiForm { | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public function validateTotpToken($attribute) { | ||||||
|  |         if ($this->hasErrors()) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         $account = $this->getAccount(); | ||||||
|  |         if (!$account->is_otp_enabled) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         $validator = new TotpValidator(['account' => $account]); | ||||||
|  |         $validator->validateAttribute($this, $attribute); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     public function validateActivity($attribute) { |     public function validateActivity($attribute) { | ||||||
|         if (!$this->hasErrors()) { |         if (!$this->hasErrors()) { | ||||||
|             $account = $this->getAccount(); |             $account = $this->getAccount(); | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ namespace api\models\authentication; | |||||||
|  |  | ||||||
| use api\models\AccountIdentity; | use api\models\AccountIdentity; | ||||||
| use api\models\base\ApiForm; | use api\models\base\ApiForm; | ||||||
|  | use api\validators\TotpValidator; | ||||||
| use common\helpers\Error as E; | use common\helpers\Error as E; | ||||||
| use api\traits\AccountFinder; | use api\traits\AccountFinder; | ||||||
| use common\models\Account; | use common\models\Account; | ||||||
| @@ -16,6 +17,7 @@ class LoginForm extends ApiForm { | |||||||
|  |  | ||||||
|     public $login; |     public $login; | ||||||
|     public $password; |     public $password; | ||||||
|  |     public $token; | ||||||
|     public $rememberMe = false; |     public $rememberMe = false; | ||||||
|  |  | ||||||
|     public function rules() { |     public function rules() { | ||||||
| @@ -28,6 +30,11 @@ class LoginForm extends ApiForm { | |||||||
|             }, 'message' => E::PASSWORD_REQUIRED], |             }, 'message' => E::PASSWORD_REQUIRED], | ||||||
|             ['password', 'validatePassword'], |             ['password', 'validatePassword'], | ||||||
|  |  | ||||||
|  |             ['token', 'required', 'when' => function(self $model) { | ||||||
|  |                 return !$model->hasErrors() && $model->getAccount()->is_otp_enabled; | ||||||
|  |             }, 'message' => E::OTP_TOKEN_REQUIRED], | ||||||
|  |             ['token', 'validateTotpToken'], | ||||||
|  |  | ||||||
|             ['login', 'validateActivity'], |             ['login', 'validateActivity'], | ||||||
|  |  | ||||||
|             ['rememberMe', 'boolean'], |             ['rememberMe', 'boolean'], | ||||||
| @@ -51,6 +58,20 @@ class LoginForm extends ApiForm { | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public function validateTotpToken($attribute) { | ||||||
|  |         if ($this->hasErrors()) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         $account = $this->getAccount(); | ||||||
|  |         if (!$account->is_otp_enabled) { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         $validator = new TotpValidator(['account' => $account]); | ||||||
|  |         $validator->validateAttribute($this, $attribute); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     public function validateActivity($attribute) { |     public function validateActivity($attribute) { | ||||||
|         if (!$this->hasErrors()) { |         if (!$this->hasErrors()) { | ||||||
|             $account = $this->getAccount(); |             $account = $this->getAccount(); | ||||||
|   | |||||||
							
								
								
									
										138
									
								
								api/models/profile/TwoFactorAuthForm.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								api/models/profile/TwoFactorAuthForm.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,138 @@ | |||||||
|  | <?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_ACTIVATE = '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() { | ||||||
|  |         $bothScenarios = [self::SCENARIO_ACTIVATE, self::SCENARIO_DISABLE]; | ||||||
|  |         return [ | ||||||
|  |             ['account', 'validateOtpDisabled', 'on' => self::SCENARIO_ACTIVATE], | ||||||
|  |             ['account', 'validateOtpEnabled', 'on' => self::SCENARIO_DISABLE], | ||||||
|  |             ['token', 'required', 'message' => E::OTP_TOKEN_REQUIRED, 'on' => $bothScenarios], | ||||||
|  |             ['token', TotpValidator::class, 'account' => $this->account, 'window' => 30, 'on' => $bothScenarios], | ||||||
|  |             ['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' => base64_encode($this->drawQrCode($provisioningUri)), | ||||||
|  |             'uri' => $provisioningUri, | ||||||
|  |             'secret' => $this->account->otp_secret, | ||||||
|  |         ]; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function activate(): bool { | ||||||
|  |         if ($this->scenario !== self::SCENARIO_ACTIVATE || !$this->validate()) { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         $account = $this->account; | ||||||
|  |         $account->is_otp_enabled = true; | ||||||
|  |         if (!$account->save()) { | ||||||
|  |             throw new ErrorException('Cannot enable otp for account'); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         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 = 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'); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										19
									
								
								api/modules/internal/Module.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								api/modules/internal/Module.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | |||||||
|  | <?php | ||||||
|  | namespace api\modules\internal; | ||||||
|  |  | ||||||
|  | use yii\base\BootstrapInterface; | ||||||
|  |  | ||||||
|  | class Module extends \yii\base\Module implements BootstrapInterface { | ||||||
|  |  | ||||||
|  |     public $id = 'internal'; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @param \yii\base\Application $app the application currently running | ||||||
|  |      */ | ||||||
|  |     public function bootstrap($app) { | ||||||
|  |         $app->getUrlManager()->addRules([ | ||||||
|  |             '/internal/<controller>/<accountId>/<action>' => "{$this->id}/<controller>/<action>", | ||||||
|  |         ], false); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										88
									
								
								api/modules/internal/controllers/AccountsController.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								api/modules/internal/controllers/AccountsController.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,88 @@ | |||||||
|  | <?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 yii\helpers\ArrayHelper; | ||||||
|  | use yii\web\NotFoundHttpException; | ||||||
|  |  | ||||||
|  | class AccountsController extends Controller { | ||||||
|  |  | ||||||
|  |     public function behaviors() { | ||||||
|  |         return ArrayHelper::merge(parent::behaviors(), [ | ||||||
|  |             'authenticator' => [ | ||||||
|  |                 'user' => Yii::$app->apiUser, | ||||||
|  |             ], | ||||||
|  |             'access' => [ | ||||||
|  |                 'class' => AccessControl::class, | ||||||
|  |                 'rules' => [ | ||||||
|  |                     [ | ||||||
|  |                         'actions' => ['ban'], | ||||||
|  |                         'allow' => true, | ||||||
|  |                         'roles' => [S::ACCOUNT_BLOCK], | ||||||
|  |                     ], | ||||||
|  |                 ], | ||||||
|  |             ], | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function verbs() { | ||||||
|  |         return [ | ||||||
|  |             'ban' => ['POST', 'DELETE'], | ||||||
|  |         ]; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function actionBan(int $accountId) { | ||||||
|  |         $account = $this->findAccount($accountId); | ||||||
|  |         if (Yii::$app->request->isPost) { | ||||||
|  |             return $this->banAccount($account); | ||||||
|  |         } else { | ||||||
|  |             return $this->pardonAccount($account); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     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; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										9
									
								
								api/modules/internal/helpers/Error.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								api/modules/internal/helpers/Error.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | <?php | ||||||
|  | namespace api\modules\internal\helpers; | ||||||
|  |  | ||||||
|  | final class Error { | ||||||
|  |  | ||||||
|  |     public const ACCOUNT_ALREADY_BANNED = 'error.account_already_banned'; | ||||||
|  |     public const ACCOUNT_NOT_BANNED = 'error.account_not_banned'; | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										95
									
								
								api/modules/internal/models/BanForm.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								api/modules/internal/models/BanForm.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,95 @@ | |||||||
|  | <?php | ||||||
|  | namespace api\modules\internal\models; | ||||||
|  |  | ||||||
|  | use api\models\base\ApiForm; | ||||||
|  | use api\modules\internal\helpers\Error as E; | ||||||
|  | use common\helpers\Amqp; | ||||||
|  | use common\models\Account; | ||||||
|  | use common\models\amqp\AccountBanned; | ||||||
|  | use PhpAmqpLib\Message\AMQPMessage; | ||||||
|  | use Yii; | ||||||
|  | use yii\base\ErrorException; | ||||||
|  |  | ||||||
|  | class BanForm extends ApiForm { | ||||||
|  |  | ||||||
|  |     public const DURATION_FOREVER = -1; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Нереализованный функционал блокировки аккаунта на определённый период времени. | ||||||
|  |      * Сейчас установка этого параметра ничего не даст, аккаунт будет заблокирован навечно, | ||||||
|  |      * но, по задумке, здесь можно передать количество секунд, на которое будет | ||||||
|  |      * заблокирован аккаунт пользователя. | ||||||
|  |      * | ||||||
|  |      * @var int | ||||||
|  |      */ | ||||||
|  |     public $duration = self::DURATION_FOREVER; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Нереализованный функционал указания причины блокировки аккаунта. | ||||||
|  |      * | ||||||
|  |      * @var string | ||||||
|  |      */ | ||||||
|  |     public $message = ''; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @var Account | ||||||
|  |      */ | ||||||
|  |     private $account; | ||||||
|  |  | ||||||
|  |     public function rules(): array { | ||||||
|  |         return [ | ||||||
|  |             [['duration'], 'integer', 'min' => self::DURATION_FOREVER], | ||||||
|  |             [['message'], 'string'], | ||||||
|  |             [['account'], 'validateAccountActivity'], | ||||||
|  |         ]; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function getAccount(): Account { | ||||||
|  |         return $this->account; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function validateAccountActivity() { | ||||||
|  |         if ($this->account->status === Account::STATUS_BANNED) { | ||||||
|  |             $this->addError('account', E::ACCOUNT_ALREADY_BANNED); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function ban(): bool { | ||||||
|  |         if (!$this->validate()) { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         $transaction = Yii::$app->db->beginTransaction(); | ||||||
|  |  | ||||||
|  |         $account = $this->account; | ||||||
|  |         $account->status = Account::STATUS_BANNED; | ||||||
|  |         if (!$account->save()) { | ||||||
|  |             throw new ErrorException('Cannot ban account'); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         $this->createTask(); | ||||||
|  |  | ||||||
|  |         $transaction->commit(); | ||||||
|  |  | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function createTask(): void { | ||||||
|  |         $model = new AccountBanned(); | ||||||
|  |         $model->accountId = $this->account->id; | ||||||
|  |         $model->duration = $this->duration; | ||||||
|  |         $model->message = $this->message; | ||||||
|  |  | ||||||
|  |         $message = Amqp::getInstance()->prepareMessage($model, [ | ||||||
|  |             'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT, | ||||||
|  |         ]); | ||||||
|  |  | ||||||
|  |         Amqp::sendToEventsExchange('accounts.account-banned', $message); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function __construct(Account $account, array $config = []) { | ||||||
|  |         $this->account = $account; | ||||||
|  |         parent::__construct($config); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										72
									
								
								api/modules/internal/models/PardonForm.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								api/modules/internal/models/PardonForm.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | |||||||
|  | <?php | ||||||
|  | namespace api\modules\internal\models; | ||||||
|  |  | ||||||
|  | use api\models\base\ApiForm; | ||||||
|  | use api\modules\internal\helpers\Error as E; | ||||||
|  | use common\helpers\Amqp; | ||||||
|  | use common\models\Account; | ||||||
|  | use common\models\amqp\AccountPardoned; | ||||||
|  | use PhpAmqpLib\Message\AMQPMessage; | ||||||
|  | use Yii; | ||||||
|  | use yii\base\ErrorException; | ||||||
|  |  | ||||||
|  | class PardonForm extends ApiForm { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @var Account | ||||||
|  |      */ | ||||||
|  |     private $account; | ||||||
|  |  | ||||||
|  |     public function rules(): array { | ||||||
|  |         return [ | ||||||
|  |             [['account'], 'validateAccountBanned'], | ||||||
|  |         ]; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function getAccount(): Account { | ||||||
|  |         return $this->account; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function validateAccountBanned(): void { | ||||||
|  |         if ($this->account->status !== Account::STATUS_BANNED) { | ||||||
|  |             $this->addError('account', E::ACCOUNT_NOT_BANNED); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function pardon(): bool { | ||||||
|  |         if (!$this->validate()) { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         $transaction = Yii::$app->db->beginTransaction(); | ||||||
|  |  | ||||||
|  |         $account = $this->account; | ||||||
|  |         $account->status = Account::STATUS_ACTIVE; | ||||||
|  |         if (!$account->save()) { | ||||||
|  |             throw new ErrorException('Cannot pardon account'); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         $this->createTask(); | ||||||
|  |  | ||||||
|  |         $transaction->commit(); | ||||||
|  |  | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function createTask(): void { | ||||||
|  |         $model = new AccountPardoned(); | ||||||
|  |         $model->accountId = $this->account->id; | ||||||
|  |  | ||||||
|  |         $message = Amqp::getInstance()->prepareMessage($model, [ | ||||||
|  |             'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT, | ||||||
|  |         ]); | ||||||
|  |  | ||||||
|  |         Amqp::sendToEventsExchange('accounts.account-pardoned', $message); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function __construct(Account $account, array $config = []) { | ||||||
|  |         $this->account = $account; | ||||||
|  |         parent::__construct($config); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -89,15 +89,12 @@ class SessionController extends ApiController { | |||||||
|         $hasJoinedForm = new HasJoinedForm($protocol); |         $hasJoinedForm = new HasJoinedForm($protocol); | ||||||
|         try { |         try { | ||||||
|             $hasJoinedForm->hasJoined(); |             $hasJoinedForm->hasJoined(); | ||||||
|  |         } catch (ForbiddenOperationException $e) { | ||||||
|  |             return 'NO'; | ||||||
|         } catch (SessionServerException $e) { |         } catch (SessionServerException $e) { | ||||||
|             Yii::$app->response->statusCode = $e->statusCode; |             Yii::$app->response->statusCode = $e->statusCode; | ||||||
|             if ($e instanceof ForbiddenOperationException) { |  | ||||||
|                 $message = 'NO'; |  | ||||||
|             } else { |  | ||||||
|                 $message = $e->getMessage(); |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             return $message; |             return $e->getMessage(); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         return 'YES'; |         return 'YES'; | ||||||
|   | |||||||
							
								
								
									
										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; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -16,12 +16,13 @@ class Yii extends \yii\BaseYii { | |||||||
|  * Class BaseApplication |  * Class BaseApplication | ||||||
|  * Used for properties that are identical for both WebApplication and ConsoleApplication |  * Used for properties that are identical for both WebApplication and ConsoleApplication | ||||||
|  * |  * | ||||||
|  * @property \yii\swiftmailer\Mailer $mailer |  * @property \yii\swiftmailer\Mailer               $mailer | ||||||
|  * @property \common\components\Redis\Connection $redis |  * @property \common\components\Redis\Connection   $redis | ||||||
|  * @property \common\components\RabbitMQ\Component $amqp |  * @property \common\components\RabbitMQ\Component $amqp | ||||||
|  * @property \GuzzleHttp\Client $guzzle |  * @property \GuzzleHttp\Client                    $guzzle | ||||||
|  * @property \common\components\EmailRenderer $emailRenderer |  * @property \common\components\EmailRenderer      $emailRenderer | ||||||
|  * @property \mito\sentry\Component $sentry |  * @property \mito\sentry\Component                $sentry | ||||||
|  |  * @property \api\components\OAuth2\Component      $oauth | ||||||
|  */ |  */ | ||||||
| abstract class BaseApplication extends yii\base\Application { | abstract class BaseApplication extends yii\base\Application { | ||||||
| } | } | ||||||
| @@ -33,7 +34,6 @@ abstract class BaseApplication extends yii\base\Application { | |||||||
|  * @property \api\components\User\Component      $user User component. |  * @property \api\components\User\Component      $user User component. | ||||||
|  * @property \api\components\ApiUser\Component   $apiUser Api User component. |  * @property \api\components\ApiUser\Component   $apiUser Api User component. | ||||||
|  * @property \api\components\ReCaptcha\Component $reCaptcha |  * @property \api\components\ReCaptcha\Component $reCaptcha | ||||||
|  * @property \api\components\OAuth2\Component    $oauth |  | ||||||
|  * |  * | ||||||
|  * @method \api\components\User\Component getUser() |  * @method \api\components\User\Component getUser() | ||||||
|  */ |  */ | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								common/components/Annotations/Reader.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								common/components/Annotations/Reader.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | <?php | ||||||
|  | namespace common\components\Annotations; | ||||||
|  |  | ||||||
|  | class Reader extends \Minime\Annotations\Reader { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Поначаду я думал кэшировать эту штуку, но потом забил, т.к. всё всё равно завернул | ||||||
|  |      * в Yii::$app->cache и как-то надобность в отдельном кэше отпала, так что пока забьём | ||||||
|  |      * и оставим как заготовку на будущее | ||||||
|  |      * | ||||||
|  |      * @return \Minime\Annotations\Interfaces\ReaderInterface | ||||||
|  |      */ | ||||||
|  |     public static function createFromDefaults() { | ||||||
|  |         return parent::createFromDefaults(); | ||||||
|  |         //return new self(new \Minime\Annotations\Parser(), new RedisCache()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										65
									
								
								common/components/Annotations/RedisCache.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								common/components/Annotations/RedisCache.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | |||||||
|  | <?php | ||||||
|  | namespace common\components\Annotations; | ||||||
|  |  | ||||||
|  | use common\components\Redis\Key; | ||||||
|  | use common\components\Redis\Set; | ||||||
|  | use Minime\Annotations\Interfaces\CacheInterface; | ||||||
|  | use yii\helpers\Json; | ||||||
|  |  | ||||||
|  | class RedisCache implements CacheInterface { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Generates uuid for a given docblock string | ||||||
|  |      * @param  string $docblock docblock string | ||||||
|  |      * @return string uuid that maps to the given docblock | ||||||
|  |      */ | ||||||
|  |     public function getKey($docblock) { | ||||||
|  |         return md5($docblock); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Adds an annotation AST to cache | ||||||
|  |      * | ||||||
|  |      * @param string $key cache entry uuid | ||||||
|  |      * @param array  $annotations annotation AST | ||||||
|  |      */ | ||||||
|  |     public function set($key, array $annotations) { | ||||||
|  |         $this->getRedisKey($key)->setValue(Json::encode($annotations))->expire(3600); | ||||||
|  |         $this->getRedisKeysSet()->add($key); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Retrieves cached annotations from docblock uuid | ||||||
|  |      * | ||||||
|  |      * @param  string $key cache entry uuid | ||||||
|  |      * @return array  cached annotation AST | ||||||
|  |      */ | ||||||
|  |     public function get($key) { | ||||||
|  |         $result = $this->getRedisKey($key)->getValue(); | ||||||
|  |         if ($result === null) { | ||||||
|  |             return []; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return Json::decode($result); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Resets cache | ||||||
|  |      */ | ||||||
|  |     public function clear() { | ||||||
|  |         /** @var array $keys */ | ||||||
|  |         $keys = $this->getRedisKeysSet()->getValue(); | ||||||
|  |         foreach ($keys as $key) { | ||||||
|  |             $this->getRedisKey($key)->delete(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private function getRedisKey(string $key): Key { | ||||||
|  |         return new Key('annotations', 'cache', $key); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private function getRedisKeysSet(): Set { | ||||||
|  |         return new Set('annotations', 'cache', 'keys'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										116
									
								
								common/components/Qr/ElyDecorator.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								common/components/Qr/ElyDecorator.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,116 @@ | |||||||
|  | <?php | ||||||
|  | namespace common\components\Qr; | ||||||
|  |  | ||||||
|  | use BaconQrCode\Common\ErrorCorrectionLevel; | ||||||
|  | use BaconQrCode\Encoder\QrCode; | ||||||
|  | use BaconQrCode\Renderer\Image\Decorator\DecoratorInterface; | ||||||
|  | use BaconQrCode\Renderer\Image\RendererInterface; | ||||||
|  | use BaconQrCode\Renderer\Image\Svg; | ||||||
|  | use Imagick; | ||||||
|  | use InvalidArgumentException; | ||||||
|  | use ReflectionClass; | ||||||
|  |  | ||||||
|  | class ElyDecorator implements DecoratorInterface { | ||||||
|  |  | ||||||
|  |     private const LOGO = __DIR__ . '/resources/logo.svg'; | ||||||
|  |  | ||||||
|  |     private const CORRECTION_MAP = [ | ||||||
|  |         ErrorCorrectionLevel::L => 7, | ||||||
|  |         ErrorCorrectionLevel::M => 15, | ||||||
|  |         ErrorCorrectionLevel::Q => 25, | ||||||
|  |         ErrorCorrectionLevel::H => 30, | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|  |     public function preProcess( | ||||||
|  |         QrCode $qrCode, | ||||||
|  |         RendererInterface $renderer, | ||||||
|  |         $outputWidth, | ||||||
|  |         $outputHeight, | ||||||
|  |         $leftPadding, | ||||||
|  |         $topPadding, | ||||||
|  |         $multiple | ||||||
|  |     ) { | ||||||
|  |         if (!$renderer instanceof Svg) { | ||||||
|  |             throw new InvalidArgumentException('$renderer must by instance of ' . Svg::class); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         $correctionLevel = self::CORRECTION_MAP[$qrCode->getErrorCorrectionLevel()->get()]; | ||||||
|  |         $sizeMultiplier = $correctionLevel + floor($correctionLevel / 3); | ||||||
|  |         $count = $qrCode->getMatrix()->getWidth(); | ||||||
|  |  | ||||||
|  |         $countToRemoveX = floor($count * $sizeMultiplier / 100); | ||||||
|  |         $countToRemoveY = floor($count * $sizeMultiplier / 100); | ||||||
|  |  | ||||||
|  |         $startX = $leftPadding + round(($count - $countToRemoveX) / 2 * $multiple); | ||||||
|  |         $startY = $topPadding + round(($count - $countToRemoveY) / 2 * $multiple); | ||||||
|  |         $width = $countToRemoveX * $multiple; | ||||||
|  |         $height = $countToRemoveY * $multiple; | ||||||
|  |  | ||||||
|  |         $reflection = new ReflectionClass($renderer); | ||||||
|  |         $property = $reflection->getProperty('svg'); | ||||||
|  |         $property->setAccessible(true); | ||||||
|  |         /** @var \SimpleXMLElement $svg */ | ||||||
|  |         $svg = $property->getValue($renderer); | ||||||
|  |  | ||||||
|  |         $image = $svg->addChild('image'); | ||||||
|  |         $image->addAttribute('x', $startX); | ||||||
|  |         $image->addAttribute('y', $startY); | ||||||
|  |         $image->addAttribute('width', $width); | ||||||
|  |         $image->addAttribute('height', $height); | ||||||
|  |         $image->addAttribute('xlink:href', $this->encodeSvgToBase64(self::LOGO)); | ||||||
|  |  | ||||||
|  |         $logo = new Imagick(); | ||||||
|  |         $logo->readImage(self::LOGO); | ||||||
|  |         $logo->scaleImage($width, $height); | ||||||
|  |  | ||||||
|  |         $foundedPixels = []; | ||||||
|  |         foreach ($logo->getPixelIterator() as $row => $pixels) { | ||||||
|  |             /** @var \ImagickPixel[] $pixels */ | ||||||
|  |             foreach ($pixels as $column => $pixel) { | ||||||
|  |                 $color = $pixel->getColorAsString(); | ||||||
|  |                 if ($color !== 'srgb(255,255,255)') { | ||||||
|  |                     $foundedPixels[] = [(int)($startX + $column), (int)($startY + $row)]; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         $logo->clear(); | ||||||
|  |         $logo->destroy(); | ||||||
|  |  | ||||||
|  |         $padding = $multiple - 2; | ||||||
|  |         if ($padding < 0) { | ||||||
|  |             $padding = 1; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         foreach ($foundedPixels as $coordinates) { | ||||||
|  |             [$x, $y] = $coordinates; | ||||||
|  |             $x -= $leftPadding; | ||||||
|  |             $y -= $topPadding; | ||||||
|  |  | ||||||
|  |             for ($i = $x - $padding; $i <= $x + $padding; $i++) { | ||||||
|  |                 for ($j = $y - $padding; $j <= $y + $padding; $j++) { | ||||||
|  |                     $matrixX = floor($i / $multiple); | ||||||
|  |                     $matrixY = floor($j / $multiple); | ||||||
|  |                     $qrCode->getMatrix()->set($matrixX, $matrixY, 0); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function postProcess( | ||||||
|  |         QrCode $qrCode, | ||||||
|  |         RendererInterface $renderer, | ||||||
|  |         $outputWidth, | ||||||
|  |         $outputHeight, | ||||||
|  |         $leftPadding, | ||||||
|  |         $topPadding, | ||||||
|  |         $multiple | ||||||
|  |     ) { | ||||||
|  |  | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private function encodeSvgToBase64(string $filePath): string { | ||||||
|  |         return 'data:image/svg+xml;base64,' . base64_encode(file_get_contents($filePath)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										6
									
								
								common/components/Qr/resources/logo.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								common/components/Qr/resources/logo.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 389 365.16" style="fill:#207e5c"> | ||||||
|  |     <polygon points="130 161 37 161 37 257 145 257 145 285 0 285 0 19 144 19 144 47 37 47 37 133 130 133 130 161 130 161" /> | ||||||
|  |     <polygon points="213 285 177 285 177 0 213 0 213 285 213 285" /> | ||||||
|  |     <path d="M319,246.42l34-142h42l-66,227c-5.07,17.07-10.54,30.61-20,39.49S291.1,382.58,279,382.58c-4.67,0-13.89-1.65-21-3.16l4.11-27s18.89,1.78,22.69-4.37c4.54-4.26,8.71-13.46,12.21-24.48l6-23.16-58-196h41l33,142h0Z" transform="translate(-6 -17.42)" /> | ||||||
|  | </svg> | ||||||
| After Width: | Height: | Size: 582 B | 
| @@ -1,6 +1,6 @@ | |||||||
| <?php | <?php | ||||||
| return [ | return [ | ||||||
|     'version' => '1.1.5', |     'version' => '1.1.6', | ||||||
|     'vendorPath' => dirname(dirname(__DIR__)) . '/vendor', |     'vendorPath' => dirname(dirname(__DIR__)) . '/vendor', | ||||||
|     'components' => [ |     'components' => [ | ||||||
|         'cache' => [ |         'cache' => [ | ||||||
| @@ -69,6 +69,15 @@ return [ | |||||||
|             'class' => common\components\EmailRenderer::class, |             'class' => common\components\EmailRenderer::class, | ||||||
|             'basePath' => '/images/emails', |             'basePath' => '/images/emails', | ||||||
|         ], |         ], | ||||||
|  |         'oauth' => [ | ||||||
|  |             'class' => api\components\OAuth2\Component::class, | ||||||
|  |             'grantTypes' => ['authorization_code', 'client_credentials'], | ||||||
|  |             'grantMap' => [ | ||||||
|  |                 'authorization_code' => api\components\OAuth2\Grants\AuthCodeGrant::class, | ||||||
|  |                 'refresh_token' => api\components\OAuth2\Grants\RefreshTokenGrant::class, | ||||||
|  |                 'client_credentials' => api\components\OAuth2\Grants\ClientCredentialsGrant::class, | ||||||
|  |             ], | ||||||
|  |         ], | ||||||
|     ], |     ], | ||||||
|     'aliases' => [ |     'aliases' => [ | ||||||
|         '@bower' => '@vendor/bower-asset', |         '@bower' => '@vendor/bower-asset', | ||||||
|   | |||||||
| @@ -54,4 +54,9 @@ final class Error { | |||||||
|     const SUBJECT_REQUIRED = 'error.subject_required'; |     const SUBJECT_REQUIRED = 'error.subject_required'; | ||||||
|     const MESSAGE_REQUIRED = 'error.message_required'; |     const MESSAGE_REQUIRED = 'error.message_required'; | ||||||
|  |  | ||||||
|  |     const OTP_TOKEN_REQUIRED = 'error.token_required'; | ||||||
|  |     const OTP_TOKEN_INCORRECT = 'error.token_incorrect'; | ||||||
|  |     const OTP_ALREADY_ENABLED = 'error.otp_already_enabled'; | ||||||
|  |     const OTP_NOT_ENABLED = 'error.otp_not_enabled'; | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -20,6 +20,8 @@ use const common\LATEST_RULES_VERSION; | |||||||
|  * @property integer $status |  * @property integer $status | ||||||
|  * @property integer $rules_agreement_version |  * @property integer $rules_agreement_version | ||||||
|  * @property string  $registration_ip |  * @property string  $registration_ip | ||||||
|  |  * @property string  $otp_secret | ||||||
|  |  * @property integer $is_otp_enabled | ||||||
|  * @property integer $created_at |  * @property integer $created_at | ||||||
|  * @property integer $updated_at |  * @property integer $updated_at | ||||||
|  * @property integer $password_changed_at |  * @property integer $password_changed_at | ||||||
| @@ -29,10 +31,11 @@ use const common\LATEST_RULES_VERSION; | |||||||
|  * @property string  $profileLink ссылка на профиль на Ely без поддержки static url (только для записи) |  * @property string  $profileLink ссылка на профиль на Ely без поддержки static url (только для записи) | ||||||
|  * |  * | ||||||
|  * Отношения: |  * Отношения: | ||||||
|  * @property EmailActivation[] $emailActivations |  * @property EmailActivation[]    $emailActivations | ||||||
|  * @property OauthSession[]    $oauthSessions |  * @property OauthSession[]       $oauthSessions | ||||||
|  * @property UsernameHistory[] $usernameHistory |  * @property UsernameHistory[]    $usernameHistory | ||||||
|  * @property AccountSession[]  $sessions |  * @property AccountSession[]     $sessions | ||||||
|  |  * @property MinecraftAccessKey[] $minecraftAccessKeys | ||||||
|  * |  * | ||||||
|  * Поведения: |  * Поведения: | ||||||
|  * @mixin TimestampBehavior |  * @mixin TimestampBehavior | ||||||
| @@ -99,7 +102,7 @@ class Account extends ActiveRecord { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function getOauthSessions() { |     public function getOauthSessions() { | ||||||
|         return $this->hasMany(OauthSession::class, ['owner_id' => 'id']); |         return $this->hasMany(OauthSession::class, ['owner_id' => 'id'])->andWhere(['owner_type' => 'user']); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function getUsernameHistory() { |     public function getUsernameHistory() { | ||||||
| @@ -110,6 +113,10 @@ class Account extends ActiveRecord { | |||||||
|         return $this->hasMany(AccountSession::class, ['account_id' => 'id']); |         return $this->hasMany(AccountSession::class, ['account_id' => 'id']); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public function getMinecraftAccessKeys() { | ||||||
|  |         return $this->hasMany(MinecraftAccessKey::class, ['account_id' => 'id']); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Выполняет проверку, принадлежит ли этому нику аккаунт у Mojang |      * Выполняет проверку, принадлежит ли этому нику аккаунт у Mojang | ||||||
|      * |      * | ||||||
|   | |||||||
| @@ -1,20 +1,62 @@ | |||||||
| <?php | <?php | ||||||
| namespace common\models; | namespace common\models; | ||||||
|  |  | ||||||
|  | use common\components\Annotations\Reader; | ||||||
|  | use ReflectionClass; | ||||||
|  | use Yii; | ||||||
|  |  | ||||||
| class OauthScope { | class OauthScope { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @owner user | ||||||
|  |      */ | ||||||
|     const OFFLINE_ACCESS = 'offline_access'; |     const OFFLINE_ACCESS = 'offline_access'; | ||||||
|  |     /** | ||||||
|  |      * @owner user | ||||||
|  |      */ | ||||||
|     const MINECRAFT_SERVER_SESSION = 'minecraft_server_session'; |     const MINECRAFT_SERVER_SESSION = 'minecraft_server_session'; | ||||||
|  |     /** | ||||||
|  |      * @owner user | ||||||
|  |      */ | ||||||
|     const ACCOUNT_INFO = 'account_info'; |     const ACCOUNT_INFO = 'account_info'; | ||||||
|  |     /** | ||||||
|  |      * @owner user | ||||||
|  |      */ | ||||||
|     const ACCOUNT_EMAIL = 'account_email'; |     const ACCOUNT_EMAIL = 'account_email'; | ||||||
|  |     /** | ||||||
|  |      * @internal | ||||||
|  |      * @owner machine | ||||||
|  |      */ | ||||||
|  |     const ACCOUNT_BLOCK = 'account_block'; | ||||||
|  |  | ||||||
|     public static function getScopes() : array { |     public static function find(): OauthScopeQuery { | ||||||
|         return [ |         return new OauthScopeQuery(static::queryScopes()); | ||||||
|             self::OFFLINE_ACCESS, |     } | ||||||
|             self::MINECRAFT_SERVER_SESSION, |  | ||||||
|             self::ACCOUNT_INFO, |     private static function queryScopes(): array { | ||||||
|             self::ACCOUNT_EMAIL, |         $cacheKey = 'oauth-scopes-list'; | ||||||
|         ]; |         $scopes = false; | ||||||
|  |         if ($scopes === false) { | ||||||
|  |             $scopes = []; | ||||||
|  |             $reflection = new ReflectionClass(static::class); | ||||||
|  |             $constants = $reflection->getConstants(); | ||||||
|  |             $reader = Reader::createFromDefaults(); | ||||||
|  |             foreach ($constants as $constName => $value) { | ||||||
|  |                 $annotations = $reader->getConstantAnnotations(static::class, $constName); | ||||||
|  |                 $isInternal = $annotations->get('internal', false); | ||||||
|  |                 $owner = $annotations->get('owner', 'user'); | ||||||
|  |                 $keyValue = [ | ||||||
|  |                     'value' => $value, | ||||||
|  |                     'internal' => $isInternal, | ||||||
|  |                     'owner' => $owner, | ||||||
|  |                 ]; | ||||||
|  |                 $scopes[$constName] = $keyValue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             Yii::$app->cache->set($cacheKey, $scopes, 3600); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return $scopes; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										50
									
								
								common/models/OauthScopeQuery.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								common/models/OauthScopeQuery.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | |||||||
|  | <?php | ||||||
|  | namespace common\models; | ||||||
|  |  | ||||||
|  | use yii\helpers\ArrayHelper; | ||||||
|  |  | ||||||
|  | class OauthScopeQuery { | ||||||
|  |  | ||||||
|  |     private $scopes; | ||||||
|  |  | ||||||
|  |     private $internal; | ||||||
|  |  | ||||||
|  |     private $owner; | ||||||
|  |  | ||||||
|  |     public function onlyPublic(): self { | ||||||
|  |         $this->internal = false; | ||||||
|  |         return $this; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function onlyInternal(): self { | ||||||
|  |         $this->internal = true; | ||||||
|  |         return $this; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function usersScopes(): self { | ||||||
|  |         $this->owner = 'user'; | ||||||
|  |         return $this; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function machineScopes(): self { | ||||||
|  |         $this->owner = 'machine'; | ||||||
|  |         return $this; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function all(): array { | ||||||
|  |         return ArrayHelper::getColumn(array_filter($this->scopes, function($value) { | ||||||
|  |             $shouldCheckInternal = $this->internal !== null; | ||||||
|  |             $isInternalMatch = $value['internal'] === $this->internal; | ||||||
|  |             $shouldCheckOwner = $this->owner !== null; | ||||||
|  |             $isOwnerMatch = $value['owner'] === $this->owner; | ||||||
|  |  | ||||||
|  |             return (!$shouldCheckInternal || $isInternalMatch) | ||||||
|  |                 && (!$shouldCheckOwner || $isOwnerMatch); | ||||||
|  |         }), 'value'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function __construct(array $scopes) { | ||||||
|  |         $this->scopes = $scopes; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										14
									
								
								common/models/amqp/AccountBanned.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								common/models/amqp/AccountBanned.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | |||||||
|  | <?php | ||||||
|  | namespace common\models\amqp; | ||||||
|  |  | ||||||
|  | use yii\base\Object; | ||||||
|  |  | ||||||
|  | class AccountBanned extends Object { | ||||||
|  |  | ||||||
|  |     public $accountId; | ||||||
|  |  | ||||||
|  |     public $duration = -1; | ||||||
|  |  | ||||||
|  |     public $message = ''; | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										10
									
								
								common/models/amqp/AccountPardoned.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								common/models/amqp/AccountPardoned.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | |||||||
|  | <?php | ||||||
|  | namespace common\models\amqp; | ||||||
|  |  | ||||||
|  | use yii\base\Object; | ||||||
|  |  | ||||||
|  | class AccountPardoned extends Object { | ||||||
|  |  | ||||||
|  |     public $accountId; | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -18,7 +18,7 @@ | |||||||
|         "yiisoft/yii2": "2.0.10", |         "yiisoft/yii2": "2.0.10", | ||||||
|         "yiisoft/yii2-swiftmailer": "*", |         "yiisoft/yii2-swiftmailer": "*", | ||||||
|         "ramsey/uuid": "^3.5.0", |         "ramsey/uuid": "^3.5.0", | ||||||
|         "league/oauth2-server": "dev-improvements#b9277ccd664dcb80a766b73674d21de686cb9dda", |         "league/oauth2-server": "dev-improvements#fbaa9b0bd3d8050235ba7dde90f731764122bc20", | ||||||
|         "yiisoft/yii2-redis": "~2.0.0", |         "yiisoft/yii2-redis": "~2.0.0", | ||||||
|         "guzzlehttp/guzzle": "^6.0.0", |         "guzzlehttp/guzzle": "^6.0.0", | ||||||
|         "php-amqplib/php-amqplib": "^2.6.2", |         "php-amqplib/php-amqplib": "^2.6.2", | ||||||
| @@ -27,7 +27,10 @@ | |||||||
|         "ely/amqp-controller": "dev-master#d7f8cdbc66c45e477c9c7d5d509bc0c1b11fd3ec", |         "ely/amqp-controller": "dev-master#d7f8cdbc66c45e477c9c7d5d509bc0c1b11fd3ec", | ||||||
|         "ely/email-renderer": "dev-master#ef1cb3f7a13196524b97ca5aa0a2d5867f2d9207", |         "ely/email-renderer": "dev-master#ef1cb3f7a13196524b97ca5aa0a2d5867f2d9207", | ||||||
|         "predis/predis": "^1.0", |         "predis/predis": "^1.0", | ||||||
|         "mito/yii2-sentry": "dev-fix_init#27f00805cb906f73b2c6f8181c1c655decb9be70" |         "mito/yii2-sentry": "dev-fix_init#27f00805cb906f73b2c6f8181c1c655decb9be70", | ||||||
|  |         "minime/annotations": "~3.0", | ||||||
|  |         "spomky-labs/otphp": "^8.3", | ||||||
|  |         "bacon/bacon-qr-code": "^1.0" | ||||||
|     }, |     }, | ||||||
|     "require-dev": { |     "require-dev": { | ||||||
|         "yiisoft/yii2-codeception": "*", |         "yiisoft/yii2-codeception": "*", | ||||||
| @@ -49,11 +52,11 @@ | |||||||
|         }, |         }, | ||||||
|         { |         { | ||||||
|             "type": "git", |             "type": "git", | ||||||
|             "url": "git@gitlab.com:elyby/amqp-controller.git" |             "url": "git@gitlab.ely.by:elyby/amqp-controller.git" | ||||||
|         }, |         }, | ||||||
|         { |         { | ||||||
|             "type": "git", |             "type": "git", | ||||||
|             "url": "git@gitlab.com:elyby/email-renderer.git" |             "url": "git@gitlab.ely.by:elyby/email-renderer.git" | ||||||
|         }, |         }, | ||||||
|         { |         { | ||||||
|             "type": "git", |             "type": "git", | ||||||
|   | |||||||
| @@ -3,10 +3,13 @@ namespace console\controllers; | |||||||
|  |  | ||||||
| use common\components\Mojang\Api as MojangApi; | use common\components\Mojang\Api as MojangApi; | ||||||
| use common\components\Mojang\exceptions\NoContentException; | use common\components\Mojang\exceptions\NoContentException; | ||||||
|  | use common\models\Account; | ||||||
|  | use common\models\amqp\AccountBanned; | ||||||
| use common\models\amqp\UsernameChanged; | use common\models\amqp\UsernameChanged; | ||||||
| use common\models\MojangUsername; | use common\models\MojangUsername; | ||||||
| use Ely\Amqp\Builder\Configurator; | use Ely\Amqp\Builder\Configurator; | ||||||
| use GuzzleHttp\Exception\RequestException; | use GuzzleHttp\Exception\RequestException; | ||||||
|  | use Yii; | ||||||
|  |  | ||||||
| class AccountQueueController extends AmqpController { | class AccountQueueController extends AmqpController { | ||||||
|  |  | ||||||
| @@ -17,16 +20,18 @@ class AccountQueueController extends AmqpController { | |||||||
|     public function configure(Configurator $configurator) { |     public function configure(Configurator $configurator) { | ||||||
|         $configurator->exchange->topic()->durable(); |         $configurator->exchange->topic()->durable(); | ||||||
|         $configurator->queue->name('accounts-accounts-events')->durable(); |         $configurator->queue->name('accounts-accounts-events')->durable(); | ||||||
|         $configurator->bind->routingKey('accounts.username-changed'); |         $configurator->bind->routingKey('accounts.username-changed') | ||||||
|  |             ->add()->routingKey('account.account-banned'); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function getRoutesMap() { |     public function getRoutesMap() { | ||||||
|         return [ |         return [ | ||||||
|             'accounts.username-changed' => 'routeUsernameChanged', |             'accounts.username-changed' => 'routeUsernameChanged', | ||||||
|  |             'accounts.account-banned' => 'routeAccountBanned', | ||||||
|         ]; |         ]; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function routeUsernameChanged(UsernameChanged $body) { |     public function routeUsernameChanged(UsernameChanged $body): bool { | ||||||
|         $mojangApi = $this->createMojangApi(); |         $mojangApi = $this->createMojangApi(); | ||||||
|         try { |         try { | ||||||
|             $response = $mojangApi->usernameToUUID($body->newUsername); |             $response = $mojangApi->usernameToUUID($body->newUsername); | ||||||
| @@ -58,10 +63,32 @@ class AccountQueueController extends AmqpController { | |||||||
|         return true; |         return true; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public function routeAccountBanned(AccountBanned $body): bool { | ||||||
|  |         $account = Account::findOne($body->accountId); | ||||||
|  |         if ($account === null) { | ||||||
|  |             Yii::warning('Cannot find banned account ' . $body->accountId . '. Skipping.'); | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         foreach ($account->sessions as $authSession) { | ||||||
|  |             $authSession->delete(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         foreach ($account->minecraftAccessKeys as $key) { | ||||||
|  |             $key->delete(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         foreach ($account->oauthSessions as $oauthSession) { | ||||||
|  |             $oauthSession->delete(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * @return MojangApi |      * @return MojangApi | ||||||
|      */ |      */ | ||||||
|     protected function createMojangApi() : MojangApi { |     protected function createMojangApi(): MojangApi { | ||||||
|         return new MojangApi(); |         return new MojangApi(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -0,0 +1,15 @@ | |||||||
|  | <?php | ||||||
|  |  | ||||||
|  | use console\db\Migration; | ||||||
|  |  | ||||||
|  | class m161228_101022_oauth_clients_allow_null_redirect_uri extends Migration { | ||||||
|  |  | ||||||
|  |     public function safeUp() { | ||||||
|  |         $this->alterColumn('{{%oauth_clients}}', 'redirect_uri', $this->string()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function safeDown() { | ||||||
|  |         $this->alterColumn('{{%oauth_clients}}', 'redirect_uri', $this->string()->notNull()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										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'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -8,15 +8,23 @@ use yii\codeception\BasePage; | |||||||
|  */ |  */ | ||||||
| class AuthenticationRoute extends BasePage { | class AuthenticationRoute extends BasePage { | ||||||
|  |  | ||||||
|     public function login($login = '', $password = '', $rememberMe = false) { |     /** | ||||||
|  |      * @param string           $login | ||||||
|  |      * @param string           $password | ||||||
|  |      * @param string|bool|null $rememberMeOrToken | ||||||
|  |      * @param bool             $rememberMe | ||||||
|  |      */ | ||||||
|  |     public function login($login = '', $password = '', $rememberMeOrToken = null, $rememberMe = false) { | ||||||
|         $this->route = ['authentication/login']; |         $this->route = ['authentication/login']; | ||||||
|         $params = [ |         $params = [ | ||||||
|             'login' => $login, |             'login' => $login, | ||||||
|             'password' => $password, |             'password' => $password, | ||||||
|         ]; |         ]; | ||||||
|  |  | ||||||
|         if ($rememberMe) { |         if ((is_bool($rememberMeOrToken) && $rememberMeOrToken) || $rememberMe) { | ||||||
|             $params['rememberMe'] = 1; |             $params['rememberMe'] = 1; | ||||||
|  |         } elseif ($rememberMeOrToken !== null) { | ||||||
|  |             $params['token'] = $rememberMeOrToken; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         $this->actor->sendPOST($this->getUrl(), $params); |         $this->actor->sendPOST($this->getUrl(), $params); | ||||||
| @@ -27,10 +35,11 @@ class AuthenticationRoute extends BasePage { | |||||||
|         $this->actor->sendPOST($this->getUrl()); |         $this->actor->sendPOST($this->getUrl()); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function forgotPassword($login = '') { |     public function forgotPassword($login = null, $token = null) { | ||||||
|         $this->route = ['authentication/forgot-password']; |         $this->route = ['authentication/forgot-password']; | ||||||
|         $this->actor->sendPOST($this->getUrl(), [ |         $this->actor->sendPOST($this->getUrl(), [ | ||||||
|             'login' => $login, |             'login' => $login, | ||||||
|  |             'token' => $token, | ||||||
|         ]); |         ]); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										21
									
								
								tests/codeception/api/_pages/InternalRoute.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								tests/codeception/api/_pages/InternalRoute.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | |||||||
|  | <?php | ||||||
|  | namespace tests\codeception\api\_pages; | ||||||
|  |  | ||||||
|  | use yii\codeception\BasePage; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * @property \tests\codeception\api\FunctionalTester $actor | ||||||
|  |  */ | ||||||
|  | class InternalRoute extends BasePage { | ||||||
|  |  | ||||||
|  |     public function ban($accountId) { | ||||||
|  |         $this->route = '/internal/accounts/' . $accountId . '/ban'; | ||||||
|  |         $this->actor->sendPOST($this->getUrl()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function pardon($accountId) { | ||||||
|  |         $this->route = '/internal/accounts/' . $accountId . '/ban'; | ||||||
|  |         $this->actor->sendDELETE($this->getUrl()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										31
									
								
								tests/codeception/api/_pages/TwoFactorAuthRoute.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								tests/codeception/api/_pages/TwoFactorAuthRoute.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | |||||||
|  | <?php | ||||||
|  | namespace tests\codeception\api\_pages; | ||||||
|  |  | ||||||
|  | use yii\codeception\BasePage; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * @property \tests\codeception\api\FunctionalTester $actor | ||||||
|  |  */ | ||||||
|  | class TwoFactorAuthRoute extends BasePage { | ||||||
|  |  | ||||||
|  |     public $route = '/two-factor-auth'; | ||||||
|  |  | ||||||
|  |     public function credentials() { | ||||||
|  |         $this->actor->sendGET($this->getUrl()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function enable($token = null, $password = null) { | ||||||
|  |         $this->actor->sendPOST($this->getUrl(), [ | ||||||
|  |             'token' => $token, | ||||||
|  |             'password' => $password, | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function disable($token = null, $password = null) { | ||||||
|  |         $this->actor->sendDELETE($this->getUrl(), [ | ||||||
|  |             'token' => $token, | ||||||
|  |             'password' => $password, | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -1,9 +1,10 @@ | |||||||
| <?php | <?php | ||||||
| namespace tests\codeception\api; | namespace tests\codeception\api; | ||||||
|  |  | ||||||
|  | use api\models\AccountIdentity; | ||||||
| use Codeception\Actor; | use Codeception\Actor; | ||||||
| use InvalidArgumentException; | use InvalidArgumentException; | ||||||
| use tests\codeception\api\_pages\AuthenticationRoute; | use Yii; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Inherited Methods |  * Inherited Methods | ||||||
| @@ -23,20 +24,15 @@ use tests\codeception\api\_pages\AuthenticationRoute; | |||||||
| class FunctionalTester extends Actor { | class FunctionalTester extends Actor { | ||||||
|     use _generated\FunctionalTesterActions; |     use _generated\FunctionalTesterActions; | ||||||
|  |  | ||||||
|     public function loggedInAsActiveAccount($login = null, $password = null) { |     public function amAuthenticated(string $asUsername = 'admin') { | ||||||
|         $route = new AuthenticationRoute($this); |         /** @var AccountIdentity $account */ | ||||||
|         if ($login === null) { |         $account = AccountIdentity::findOne(['username' => $asUsername]); | ||||||
|             $route->login('Admin', 'password_0'); |         if ($account === null) { | ||||||
|         } elseif ($login !== null && $password !== null) { |             throw new InvalidArgumentException("Cannot find account for username \"$asUsername\""); | ||||||
|             $route->login($login, $password); |  | ||||||
|         } else { |  | ||||||
|             throw new InvalidArgumentException('login and password should be presented both.'); |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         $this->canSeeResponseIsJson(); |         $result = Yii::$app->user->login($account); | ||||||
|         $this->canSeeAuthCredentials(false); |         $this->amBearerAuthenticated($result->getJwt()); | ||||||
|         $jwt = $this->grabDataFromResponseByJsonPath('$.access_token')[0]; |  | ||||||
|         $this->amBearerAuthenticated($jwt); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function notLoggedIn() { |     public function notLoggedIn() { | ||||||
|   | |||||||
| @@ -16,7 +16,7 @@ class AccountsAcceptRulesCest { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function testCurrent(FunctionalTester $I) { |     public function testCurrent(FunctionalTester $I) { | ||||||
|         $I->loggedInAsActiveAccount('Veleyaba', 'password_0'); |         $I->amAuthenticated('Veleyaba'); | ||||||
|         $this->route->acceptRules(); |         $this->route->acceptRules(); | ||||||
|         $I->canSeeResponseCodeIs(200); |         $I->canSeeResponseCodeIs(200); | ||||||
|         $I->canSeeResponseIsJson(); |         $I->canSeeResponseIsJson(); | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ class AccountsChangeEmailConfirmNewEmailCest { | |||||||
|  |  | ||||||
|     public function testConfirmNewEmail(FunctionalTester $I) { |     public function testConfirmNewEmail(FunctionalTester $I) { | ||||||
|         $I->wantTo('change my email and get changed value'); |         $I->wantTo('change my email and get changed value'); | ||||||
|         $I->loggedInAsActiveAccount('CrafterGameplays', 'password_0'); |         $I->amAuthenticated('CrafterGameplays'); | ||||||
|  |  | ||||||
|         $this->route->changeEmailConfirmNewEmail('H28HBDCHHAG2HGHGHS'); |         $this->route->changeEmailConfirmNewEmail('H28HBDCHHAG2HGHGHS'); | ||||||
|         $I->canSeeResponseCodeIs(200); |         $I->canSeeResponseCodeIs(200); | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ class AccountsChangeEmailInitializeCest { | |||||||
|  |  | ||||||
|     public function testChangeEmailInitialize(FunctionalTester $I) { |     public function testChangeEmailInitialize(FunctionalTester $I) { | ||||||
|         $I->wantTo('send current email confirmation'); |         $I->wantTo('send current email confirmation'); | ||||||
|         $I->loggedInAsActiveAccount(); |         $I->amAuthenticated(); | ||||||
|  |  | ||||||
|         $this->route->changeEmailInitialize('password_0'); |         $this->route->changeEmailInitialize('password_0'); | ||||||
|         $I->canSeeResponseCodeIs(200); |         $I->canSeeResponseCodeIs(200); | ||||||
| @@ -29,7 +29,7 @@ class AccountsChangeEmailInitializeCest { | |||||||
|  |  | ||||||
|     public function testChangeEmailInitializeFrequencyError(FunctionalTester $I) { |     public function testChangeEmailInitializeFrequencyError(FunctionalTester $I) { | ||||||
|         $I->wantTo('see change email request frequency error'); |         $I->wantTo('see change email request frequency error'); | ||||||
|         $I->loggedInAsActiveAccount('ILLIMUNATI', 'password_0'); |         $I->amAuthenticated('ILLIMUNATI'); | ||||||
|  |  | ||||||
|         $this->route->changeEmailInitialize('password_0'); |         $this->route->changeEmailInitialize('password_0'); | ||||||
|         $I->canSeeResponseContainsJson([ |         $I->canSeeResponseContainsJson([ | ||||||
|   | |||||||
| @@ -18,7 +18,7 @@ class AccountsChangeEmailSubmitNewEmailCest { | |||||||
|  |  | ||||||
|     public function testSubmitNewEmail(FunctionalTester $I) { |     public function testSubmitNewEmail(FunctionalTester $I) { | ||||||
|         $I->wantTo('submit new email'); |         $I->wantTo('submit new email'); | ||||||
|         $I->loggedInAsActiveAccount('ILLIMUNATI', 'password_0'); |         $I->amAuthenticated('ILLIMUNATI'); | ||||||
|  |  | ||||||
|         $this->route->changeEmailSubmitNewEmail('H27HBDCHHAG2HGHGHS', 'my-new-email@ely.by'); |         $this->route->changeEmailSubmitNewEmail('H27HBDCHHAG2HGHGHS', 'my-new-email@ely.by'); | ||||||
|         $I->canSeeResponseCodeIs(200); |         $I->canSeeResponseCodeIs(200); | ||||||
|   | |||||||
| @@ -18,7 +18,7 @@ class AccountsChangeLangCest { | |||||||
|  |  | ||||||
|     public function testSubmitNewEmail(FunctionalTester $I) { |     public function testSubmitNewEmail(FunctionalTester $I) { | ||||||
|         $I->wantTo('change my account language'); |         $I->wantTo('change my account language'); | ||||||
|         $I->loggedInAsActiveAccount(); |         $I->amAuthenticated(); | ||||||
|  |  | ||||||
|         $this->route->changeLang('ru'); |         $this->route->changeLang('ru'); | ||||||
|         $I->canSeeResponseCodeIs(200); |         $I->canSeeResponseCodeIs(200); | ||||||
|   | |||||||
| @@ -27,7 +27,7 @@ class AccountsChangePasswordCest { | |||||||
|  |  | ||||||
|     public function testChangePassword(FunctionalTester $I) { |     public function testChangePassword(FunctionalTester $I) { | ||||||
|         $I->wantTo('change my password'); |         $I->wantTo('change my password'); | ||||||
|         $I->loggedInAsActiveAccount(); |         $I->amAuthenticated(); | ||||||
|  |  | ||||||
|         $this->route->changePassword('password_0', 'new-password', 'new-password'); |         $this->route->changePassword('password_0', 'new-password', 'new-password'); | ||||||
|         $I->canSeeResponseCodeIs(200); |         $I->canSeeResponseCodeIs(200); | ||||||
|   | |||||||
| @@ -26,7 +26,7 @@ class AccountsChangeUsernameCest { | |||||||
|  |  | ||||||
|     public function testChangeUsername(FunctionalTester $I) { |     public function testChangeUsername(FunctionalTester $I) { | ||||||
|         $I->wantTo('change my nickname'); |         $I->wantTo('change my nickname'); | ||||||
|         $I->loggedInAsActiveAccount(); |         $I->amAuthenticated(); | ||||||
|  |  | ||||||
|         $this->route->changeUsername('password_0', 'bruce_wayne'); |         $this->route->changeUsername('password_0', 'bruce_wayne'); | ||||||
|         $I->canSeeResponseCodeIs(200); |         $I->canSeeResponseCodeIs(200); | ||||||
| @@ -38,7 +38,7 @@ class AccountsChangeUsernameCest { | |||||||
|  |  | ||||||
|     public function testChangeUsernameNotAvailable(FunctionalTester $I) { |     public function testChangeUsernameNotAvailable(FunctionalTester $I) { | ||||||
|         $I->wantTo('see, that nickname "in use" is not available'); |         $I->wantTo('see, that nickname "in use" is not available'); | ||||||
|         $I->loggedInAsActiveAccount(); |         $I->amAuthenticated(); | ||||||
|  |  | ||||||
|         $this->route->changeUsername('password_0', 'Jon'); |         $this->route->changeUsername('password_0', 'Jon'); | ||||||
|         $I->canSeeResponseCodeIs(200); |         $I->canSeeResponseCodeIs(200); | ||||||
|   | |||||||
| @@ -16,7 +16,7 @@ class AccountsCurrentCest { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function testCurrent(FunctionalTester $I) { |     public function testCurrent(FunctionalTester $I) { | ||||||
|         $I->loggedInAsActiveAccount(); |         $I->amAuthenticated(); | ||||||
|  |  | ||||||
|         $this->route->current(); |         $this->route->current(); | ||||||
|         $I->canSeeResponseCodeIs(200); |         $I->canSeeResponseCodeIs(200); | ||||||
| @@ -29,6 +29,7 @@ class AccountsCurrentCest { | |||||||
|             'isActive' => true, |             'isActive' => true, | ||||||
|             'hasMojangUsernameCollision' => false, |             'hasMojangUsernameCollision' => false, | ||||||
|             'shouldAcceptRules' => false, |             'shouldAcceptRules' => false, | ||||||
|  |             'isOtpEnabled' => false, | ||||||
|         ]); |         ]); | ||||||
|         $I->canSeeResponseJsonMatchesJsonPath('$.passwordChangedAt'); |         $I->canSeeResponseJsonMatchesJsonPath('$.passwordChangedAt'); | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,41 +1,87 @@ | |||||||
| <?php | <?php | ||||||
| namespace codeception\api\functional; | namespace codeception\api\functional; | ||||||
|  |  | ||||||
|  | use OTPHP\TOTP; | ||||||
| use tests\codeception\api\_pages\AuthenticationRoute; | use tests\codeception\api\_pages\AuthenticationRoute; | ||||||
| use tests\codeception\api\FunctionalTester; | use tests\codeception\api\FunctionalTester; | ||||||
|  |  | ||||||
| class ForgotPasswordCest { | class ForgotPasswordCest { | ||||||
|  |  | ||||||
|     public function testForgotPasswordByEmail(FunctionalTester $I) { |     /** | ||||||
|         $route = new AuthenticationRoute($I); |      * @var AuthenticationRoute | ||||||
|  |      */ | ||||||
|  |     private $route; | ||||||
|  |  | ||||||
|         $I->wantTo('create new password recover request by passing email'); |     public function _before(FunctionalTester $I) { | ||||||
|         $route->forgotPassword('admin@ely.by'); |         $this->route = new AuthenticationRoute($I); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testWrongInput(FunctionalTester $I) { | ||||||
|  |         $I->wantTo('see reaction on invalid input'); | ||||||
|  |  | ||||||
|  |         $this->route->forgotPassword(); | ||||||
|         $I->canSeeResponseContainsJson([ |         $I->canSeeResponseContainsJson([ | ||||||
|             'success' => true, |             'success' => false, | ||||||
|  |             'errors' => [ | ||||||
|  |                 'login' => 'error.login_required', | ||||||
|  |             ], | ||||||
|         ]); |         ]); | ||||||
|         $I->canSeeResponseJsonMatchesJsonPath('$.data.canRepeatIn'); |  | ||||||
|         $I->canSeeResponseJsonMatchesJsonPath('$.data.repeatFrequency'); |         $this->route->forgotPassword('becauseimbatman!'); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => false, | ||||||
|  |             'errors' => [ | ||||||
|  |                 'login' => 'error.login_not_exist', | ||||||
|  |             ], | ||||||
|  |         ]); | ||||||
|  |  | ||||||
|  |         $this->route->forgotPassword('AccountWithEnabledOtp'); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => false, | ||||||
|  |             'errors' => [ | ||||||
|  |                 'token' => 'error.token_required', | ||||||
|  |             ], | ||||||
|  |         ]); | ||||||
|  |  | ||||||
|  |         $this->route->forgotPassword('AccountWithEnabledOtp'); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => false, | ||||||
|  |             'errors' => [ | ||||||
|  |                 'token' => 'error.token_required', | ||||||
|  |             ], | ||||||
|  |         ]); | ||||||
|  |  | ||||||
|  |         $this->route->forgotPassword('AccountWithEnabledOtp', '123456'); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => false, | ||||||
|  |             'errors' => [ | ||||||
|  |                 'token' => 'error.token_incorrect', | ||||||
|  |             ], | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testForgotPasswordByEmail(FunctionalTester $I) { | ||||||
|  |         $I->wantTo('create new password recover request by passing email'); | ||||||
|  |         $this->route->forgotPassword('admin@ely.by'); | ||||||
|  |         $this->assertSuccessResponse($I, false); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function testForgotPasswordByUsername(FunctionalTester $I) { |     public function testForgotPasswordByUsername(FunctionalTester $I) { | ||||||
|         $route = new AuthenticationRoute($I); |  | ||||||
|  |  | ||||||
|         $I->wantTo('create new password recover request by passing username'); |         $I->wantTo('create new password recover request by passing username'); | ||||||
|         $route->forgotPassword('Admin'); |         $this->route->forgotPassword('Admin'); | ||||||
|         $I->canSeeResponseContainsJson([ |         $this->assertSuccessResponse($I, true); | ||||||
|             'success' => true, |     } | ||||||
|         ]); |  | ||||||
|         $I->canSeeResponseJsonMatchesJsonPath('$.data.canRepeatIn'); |     public function testForgotPasswordByAccountWithOtp(FunctionalTester $I) { | ||||||
|         $I->canSeeResponseJsonMatchesJsonPath('$.data.repeatFrequency'); |         $I->wantTo('create new password recover request by passing username and otp token'); | ||||||
|         $I->canSeeResponseJsonMatchesJsonPath('$.data.emailMask'); |         $totp = new TOTP(null, 'secret-secret-secret'); | ||||||
|  |         $this->route->forgotPassword('AccountWithEnabledOtp', $totp->now()); | ||||||
|  |         $this->assertSuccessResponse($I, true); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function testDataForFrequencyError(FunctionalTester $I) { |     public function testDataForFrequencyError(FunctionalTester $I) { | ||||||
|         $route = new AuthenticationRoute($I); |  | ||||||
|  |  | ||||||
|         $I->wantTo('get info about time to repeat recover password request'); |         $I->wantTo('get info about time to repeat recover password request'); | ||||||
|         $route->forgotPassword('Notch'); |         $this->route->forgotPassword('Notch'); | ||||||
|         $I->canSeeResponseContainsJson([ |         $I->canSeeResponseContainsJson([ | ||||||
|             'success' => false, |             'success' => false, | ||||||
|             'errors' => [ |             'errors' => [ | ||||||
| @@ -46,4 +92,18 @@ class ForgotPasswordCest { | |||||||
|         $I->canSeeResponseJsonMatchesJsonPath('$.data.repeatFrequency'); |         $I->canSeeResponseJsonMatchesJsonPath('$.data.repeatFrequency'); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @param FunctionalTester $I | ||||||
|  |      */ | ||||||
|  |     private function assertSuccessResponse(FunctionalTester $I, bool $expectEmailMask = false): void { | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => true, | ||||||
|  |         ]); | ||||||
|  |         $I->canSeeResponseJsonMatchesJsonPath('$.data.canRepeatIn'); | ||||||
|  |         $I->canSeeResponseJsonMatchesJsonPath('$.data.repeatFrequency'); | ||||||
|  |         if ($expectEmailMask) { | ||||||
|  |             $I->canSeeResponseJsonMatchesJsonPath('$.data.emailMask'); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| <?php | <?php | ||||||
| namespace tests\codeception\api; | namespace tests\codeception\api; | ||||||
|  |  | ||||||
|  | use OTPHP\TOTP; | ||||||
| use tests\codeception\api\_pages\AuthenticationRoute; | use tests\codeception\api\_pages\AuthenticationRoute; | ||||||
|  |  | ||||||
| class LoginCest { | class LoginCest { | ||||||
| @@ -103,6 +104,56 @@ class LoginCest { | |||||||
|         ]); |         ]); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public function testLoginToken(FunctionalTester $I) { | ||||||
|  |         $route = new AuthenticationRoute($I); | ||||||
|  |  | ||||||
|  |         $I->wantTo('see token don\'t have errors if email, username or token not set'); | ||||||
|  |         $route->login(); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => false, | ||||||
|  |         ]); | ||||||
|  |         $I->cantSeeResponseJsonMatchesJsonPath('$.errors.token'); | ||||||
|  |  | ||||||
|  |         $I->wantTo('see token don\'t have errors if username not exists in database'); | ||||||
|  |         $route->login('non-exist-username', 'random-password'); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => false, | ||||||
|  |         ]); | ||||||
|  |         $I->cantSeeResponseJsonMatchesJsonPath('$.errors.token'); | ||||||
|  |  | ||||||
|  |         $I->wantTo('see token don\'t has errors if email not exists in database'); | ||||||
|  |         $route->login('not-exist@user.com', 'random-password'); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => false, | ||||||
|  |         ]); | ||||||
|  |         $I->cantSeeResponseJsonMatchesJsonPath('$.errors.token'); | ||||||
|  |  | ||||||
|  |         $I->wantTo('see token don\'t has errors if email correct, but password wrong'); | ||||||
|  |         $route->login('not-exist@user.com', 'random-password'); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => false, | ||||||
|  |         ]); | ||||||
|  |         $I->cantSeeResponseJsonMatchesJsonPath('$.errors.token'); | ||||||
|  |  | ||||||
|  |         $I->wantTo('see error.token_required if username and password correct, but account have enable otp'); | ||||||
|  |         $route->login('AccountWithEnabledOtp', 'password_0'); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => false, | ||||||
|  |             'errors' => [ | ||||||
|  |                 'token' => 'error.token_required', | ||||||
|  |             ], | ||||||
|  |         ]); | ||||||
|  |  | ||||||
|  |         $I->wantTo('see error.token_incorrect if username and password correct, but token wrong'); | ||||||
|  |         $route->login('AccountWithEnabledOtp', 'password_0', '123456'); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => false, | ||||||
|  |             'errors' => [ | ||||||
|  |                 'token' => 'error.token_incorrect', | ||||||
|  |             ], | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     public function testLoginByUsernameCorrect(FunctionalTester $I) { |     public function testLoginByUsernameCorrect(FunctionalTester $I) { | ||||||
|         $route = new AuthenticationRoute($I); |         $route = new AuthenticationRoute($I); | ||||||
|  |  | ||||||
| @@ -151,4 +202,16 @@ class LoginCest { | |||||||
|         $I->canSeeAuthCredentials(true); |         $I->canSeeAuthCredentials(true); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public function testLoginByAccountWithOtp(FunctionalTester $I) { | ||||||
|  |         $route = new AuthenticationRoute($I); | ||||||
|  |  | ||||||
|  |         $I->wantTo('login into account with enabled otp'); | ||||||
|  |         $route->login('AccountWithEnabledOtp', 'password_0', (new TOTP(null, 'secret-secret-secret'))->now()); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => true, | ||||||
|  |         ]); | ||||||
|  |         $I->cantSeeResponseJsonMatchesJsonPath('$.errors'); | ||||||
|  |         $I->canSeeAuthCredentials(false); | ||||||
|  |     } | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -8,7 +8,7 @@ class LogoutCest { | |||||||
|     public function testLoginEmailOrUsername(FunctionalTester $I) { |     public function testLoginEmailOrUsername(FunctionalTester $I) { | ||||||
|         $route = new AuthenticationRoute($I); |         $route = new AuthenticationRoute($I); | ||||||
|  |  | ||||||
|         $I->loggedInAsActiveAccount(); |         $I->amAuthenticated(); | ||||||
|         $route->logout(); |         $route->logout(); | ||||||
|         $I->canSeeResponseContainsJson([ |         $I->canSeeResponseContainsJson([ | ||||||
|             'success' => true, |             'success' => true, | ||||||
|   | |||||||
| @@ -16,12 +16,13 @@ class OauthAccessTokenCest { | |||||||
|         $this->route = new OauthRoute($I); |         $this->route = new OauthRoute($I); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function testIssueTokenWithWrongArgs(FunctionalTester $I) { |     public function testIssueTokenWithWrongArgs(OauthSteps $I) { | ||||||
|         $I->wantTo('check behavior on on request without any credentials'); |         $I->wantTo('check behavior on on request without any credentials'); | ||||||
|         $this->route->issueToken(); |         $this->route->issueToken(); | ||||||
|         $I->canSeeResponseCodeIs(400); |         $I->canSeeResponseCodeIs(400); | ||||||
|         $I->canSeeResponseContainsJson([ |         $I->canSeeResponseContainsJson([ | ||||||
|             'error' => 'invalid_request', |             'error' => 'invalid_request', | ||||||
|  |             'message' => 'The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. Check the "grant_type" parameter.', | ||||||
|         ]); |         ]); | ||||||
|  |  | ||||||
|         $I->wantTo('check behavior on passing invalid auth code'); |         $I->wantTo('check behavior on passing invalid auth code'); | ||||||
| @@ -34,6 +35,21 @@ class OauthAccessTokenCest { | |||||||
|         $I->canSeeResponseCodeIs(400); |         $I->canSeeResponseCodeIs(400); | ||||||
|         $I->canSeeResponseContainsJson([ |         $I->canSeeResponseContainsJson([ | ||||||
|             'error' => 'invalid_request', |             'error' => 'invalid_request', | ||||||
|  |             'message' => 'The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. Check the "code" parameter.', | ||||||
|  |         ]); | ||||||
|  |  | ||||||
|  |         $authCode = $I->getAuthCode(); | ||||||
|  |         $I->wantTo('check behavior on passing invalid redirect_uri'); | ||||||
|  |         $this->route->issueToken($this->buildParams( | ||||||
|  |             $authCode, | ||||||
|  |             'ely', | ||||||
|  |             'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', | ||||||
|  |             'http://some-other.domain' | ||||||
|  |         )); | ||||||
|  |         $I->canSeeResponseCodeIs(401); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'error' => 'invalid_client', | ||||||
|  |             'message' => 'Client authentication failed.', | ||||||
|         ]); |         ]); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -51,7 +51,7 @@ class OauthAuthCodeCest { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function testValidateWithDescriptionReplaceRequest(FunctionalTester $I) { |     public function testValidateWithDescriptionReplaceRequest(FunctionalTester $I) { | ||||||
|         $I->loggedInAsActiveAccount(); |         $I->amAuthenticated(); | ||||||
|         $I->wantTo('validate and get information with description replacement'); |         $I->wantTo('validate and get information with description replacement'); | ||||||
|         $this->route->validate($this->buildQueryParams( |         $this->route->validate($this->buildQueryParams( | ||||||
|             'ely', |             'ely', | ||||||
| @@ -73,13 +73,13 @@ class OauthAuthCodeCest { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function testCompleteValidationAction(FunctionalTester $I) { |     public function testCompleteValidationAction(FunctionalTester $I) { | ||||||
|         $I->loggedInAsActiveAccount(); |         $I->amAuthenticated(); | ||||||
|         $I->wantTo('validate all oAuth params on complete request'); |         $I->wantTo('validate all oAuth params on complete request'); | ||||||
|         $this->testOauthParamsValidation($I, 'complete'); |         $this->testOauthParamsValidation($I, 'complete'); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function testCompleteActionOnWrongConditions(FunctionalTester $I) { |     public function testCompleteActionOnWrongConditions(FunctionalTester $I) { | ||||||
|         $I->loggedInAsActiveAccount(); |         $I->amAuthenticated(); | ||||||
|  |  | ||||||
|         $I->wantTo('get accept_required if I don\'t require any scope, but this is first time request'); |         $I->wantTo('get accept_required if I don\'t require any scope, but this is first time request'); | ||||||
|         $this->route->complete($this->buildQueryParams( |         $this->route->complete($this->buildQueryParams( | ||||||
| @@ -112,7 +112,7 @@ class OauthAuthCodeCest { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function testCompleteActionSuccess(FunctionalTester $I) { |     public function testCompleteActionSuccess(FunctionalTester $I) { | ||||||
|         $I->loggedInAsActiveAccount(); |         $I->amAuthenticated(); | ||||||
|         $I->wantTo('get auth code if I require some scope and pass accept field'); |         $I->wantTo('get auth code if I require some scope and pass accept field'); | ||||||
|         $this->route->complete($this->buildQueryParams( |         $this->route->complete($this->buildQueryParams( | ||||||
|             'ely', |             'ely', | ||||||
| @@ -155,7 +155,7 @@ class OauthAuthCodeCest { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function testAcceptRequiredOnNewScope(FunctionalTester $I) { |     public function testAcceptRequiredOnNewScope(FunctionalTester $I) { | ||||||
|         $I->loggedInAsActiveAccount(); |         $I->amAuthenticated(); | ||||||
|         $I->wantTo('get accept_required if I have previous successful request, but now require some new scope'); |         $I->wantTo('get accept_required if I have previous successful request, but now require some new scope'); | ||||||
|         $this->route->complete($this->buildQueryParams( |         $this->route->complete($this->buildQueryParams( | ||||||
|             'ely', |             'ely', | ||||||
| @@ -179,7 +179,7 @@ class OauthAuthCodeCest { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function testCompleteActionWithDismissState(FunctionalTester $I) { |     public function testCompleteActionWithDismissState(FunctionalTester $I) { | ||||||
|         $I->loggedInAsActiveAccount(); |         $I->amAuthenticated(); | ||||||
|         $I->wantTo('get access_denied error if I pass accept in false state'); |         $I->wantTo('get access_denied error if I pass accept in false state'); | ||||||
|         $this->route->complete($this->buildQueryParams( |         $this->route->complete($this->buildQueryParams( | ||||||
|             'ely', |             'ely', | ||||||
| @@ -281,6 +281,21 @@ class OauthAuthCodeCest { | |||||||
|             'statusCode' => 400, |             'statusCode' => 400, | ||||||
|         ]); |         ]); | ||||||
|         $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); |         $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); | ||||||
|  |  | ||||||
|  |         $I->wantTo('check behavior on request internal scope'); | ||||||
|  |         $this->route->$action($this->buildQueryParams('ely', 'http://ely.by', 'code', [ | ||||||
|  |             S::MINECRAFT_SERVER_SESSION, | ||||||
|  |             S::ACCOUNT_BLOCK, | ||||||
|  |         ])); | ||||||
|  |         $I->canSeeResponseCodeIs(400); | ||||||
|  |         $I->canSeeResponseIsJson(); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => false, | ||||||
|  |             'error' => 'invalid_scope', | ||||||
|  |             'parameter' => S::ACCOUNT_BLOCK, | ||||||
|  |             'statusCode' => 400, | ||||||
|  |         ]); | ||||||
|  |         $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -0,0 +1,120 @@ | |||||||
|  | <?php | ||||||
|  | namespace tests\codeception\api; | ||||||
|  |  | ||||||
|  | use common\models\OauthScope as S; | ||||||
|  | use tests\codeception\api\_pages\OauthRoute; | ||||||
|  | use tests\codeception\api\functional\_steps\OauthSteps; | ||||||
|  |  | ||||||
|  | class OauthClientCredentialsGrantCest { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @var OauthRoute | ||||||
|  |      */ | ||||||
|  |     private $route; | ||||||
|  |  | ||||||
|  |     public function _before(FunctionalTester $I) { | ||||||
|  |         $this->route = new OauthRoute($I); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testIssueTokenWithWrongArgs(FunctionalTester $I) { | ||||||
|  |         $I->wantTo('check behavior on on request without any credentials'); | ||||||
|  |         $this->route->issueToken($this->buildParams()); | ||||||
|  |         $I->canSeeResponseCodeIs(400); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'error' => 'invalid_request', | ||||||
|  |         ]); | ||||||
|  |  | ||||||
|  |         $I->wantTo('check behavior on passing invalid client_id'); | ||||||
|  |         $this->route->issueToken($this->buildParams( | ||||||
|  |             'invalid-client', | ||||||
|  |             'invalid-secret', | ||||||
|  |             ['invalid-scope'] | ||||||
|  |         )); | ||||||
|  |         $I->canSeeResponseCodeIs(401); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'error' => 'invalid_client', | ||||||
|  |         ]); | ||||||
|  |  | ||||||
|  |         $I->wantTo('check behavior on passing invalid client_secret'); | ||||||
|  |         $this->route->issueToken($this->buildParams( | ||||||
|  |             'ely', | ||||||
|  |             'invalid-secret', | ||||||
|  |             ['invalid-scope'] | ||||||
|  |         )); | ||||||
|  |         $I->canSeeResponseCodeIs(401); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'error' => 'invalid_client', | ||||||
|  |         ]); | ||||||
|  |  | ||||||
|  |         $I->wantTo('check behavior on passing invalid client_secret'); | ||||||
|  |         $this->route->issueToken($this->buildParams( | ||||||
|  |             'ely', | ||||||
|  |             'invalid-secret', | ||||||
|  |             ['invalid-scope'] | ||||||
|  |         )); | ||||||
|  |         $I->canSeeResponseCodeIs(401); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'error' => 'invalid_client', | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testIssueTokenWithPublicScopes(OauthSteps $I) { | ||||||
|  |         // TODO: у нас пока нет публичных скоупов, поэтому тест прогоняется с пустым набором | ||||||
|  |         $this->route->issueToken($this->buildParams( | ||||||
|  |             'ely', | ||||||
|  |             'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', | ||||||
|  |             [] | ||||||
|  |         )); | ||||||
|  |         $I->canSeeResponseCodeIs(200); | ||||||
|  |         $I->canSeeResponseIsJson(); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'token_type' => 'Bearer', | ||||||
|  |         ]); | ||||||
|  |         $I->canSeeResponseJsonMatchesJsonPath('$.access_token'); | ||||||
|  |         $I->canSeeResponseJsonMatchesJsonPath('$.expires_in'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testIssueTokenWithInternalScopes(OauthSteps $I) { | ||||||
|  |         $this->route->issueToken($this->buildParams( | ||||||
|  |             'ely', | ||||||
|  |             'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', | ||||||
|  |             [S::ACCOUNT_BLOCK] | ||||||
|  |         )); | ||||||
|  |         $I->canSeeResponseCodeIs(400); | ||||||
|  |         $I->canSeeResponseIsJson(); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'error' => 'invalid_scope', | ||||||
|  |         ]); | ||||||
|  |  | ||||||
|  |         $this->route->issueToken($this->buildParams( | ||||||
|  |             'trusted-client', | ||||||
|  |             'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9', | ||||||
|  |             [S::ACCOUNT_BLOCK] | ||||||
|  |         )); | ||||||
|  |         $I->canSeeResponseCodeIs(200); | ||||||
|  |         $I->canSeeResponseIsJson(); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'token_type' => 'Bearer', | ||||||
|  |         ]); | ||||||
|  |         $I->canSeeResponseJsonMatchesJsonPath('$.access_token'); | ||||||
|  |         $I->canSeeResponseJsonMatchesJsonPath('$.expires_in'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private function buildParams($clientId = null, $clientSecret = null, array $scopes = null) { | ||||||
|  |         $params = ['grant_type' => 'client_credentials']; | ||||||
|  |         if ($clientId !== null) { | ||||||
|  |             $params['client_id'] = $clientId; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if ($clientSecret !== null) { | ||||||
|  |             $params['client_secret'] = $clientSecret; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if ($scopes !== null) { | ||||||
|  |             $params['scope'] = implode(',', $scopes); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return $params; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -16,6 +16,18 @@ class OauthRefreshTokenCest { | |||||||
|         $this->route = new OauthRoute($I); |         $this->route = new OauthRoute($I); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public function testInvalidRefreshToken(OauthSteps $I) { | ||||||
|  |         $this->route->issueToken($this->buildParams( | ||||||
|  |             'some-invalid-refresh-token', | ||||||
|  |             'ely', | ||||||
|  |             'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM' | ||||||
|  |         )); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'error' => 'invalid_request', | ||||||
|  |             'message' => 'The refresh token is invalid.', | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     public function testRefreshToken(OauthSteps $I) { |     public function testRefreshToken(OauthSteps $I) { | ||||||
|         $refreshToken = $I->getRefreshToken(); |         $refreshToken = $I->getRefreshToken(); | ||||||
|         $this->route->issueToken($this->buildParams( |         $this->route->issueToken($this->buildParams( | ||||||
|   | |||||||
| @@ -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->amAuthenticated(); | ||||||
|  |         $this->route->credentials(); | ||||||
|  |         $I->canSeeResponseCodeIs(200); | ||||||
|  |         $I->canSeeResponseIsJson(); | ||||||
|  |         $I->canSeeResponseJsonMatchesJsonPath('$.secret'); | ||||||
|  |         $I->canSeeResponseJsonMatchesJsonPath('$.uri'); | ||||||
|  |         $I->canSeeResponseJsonMatchesJsonPath('$.qr'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -0,0 +1,61 @@ | |||||||
|  | <?php | ||||||
|  | namespace tests\codeception\api\functional; | ||||||
|  |  | ||||||
|  | use OTPHP\TOTP; | ||||||
|  | use tests\codeception\api\_pages\TwoFactorAuthRoute; | ||||||
|  | use tests\codeception\api\FunctionalTester; | ||||||
|  |  | ||||||
|  | class TwoFactorAuthDisableCest { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @var TwoFactorAuthRoute | ||||||
|  |      */ | ||||||
|  |     private $route; | ||||||
|  |  | ||||||
|  |     public function _before(FunctionalTester $I) { | ||||||
|  |         $this->route = new TwoFactorAuthRoute($I); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testFails(FunctionalTester $I) { | ||||||
|  |         $I->amAuthenticated('AccountWithEnabledOtp'); | ||||||
|  |  | ||||||
|  |         $this->route->disable(); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => false, | ||||||
|  |             'errors' => [ | ||||||
|  |                 'token' => 'error.token_required', | ||||||
|  |                 'password' => 'error.password_required', | ||||||
|  |             ], | ||||||
|  |         ]); | ||||||
|  |  | ||||||
|  |         $this->route->disable('123456', 'invalid_password'); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => false, | ||||||
|  |             'errors' => [ | ||||||
|  |                 'token' => 'error.token_incorrect', | ||||||
|  |                 'password' => 'error.password_incorrect', | ||||||
|  |             ], | ||||||
|  |         ]); | ||||||
|  |  | ||||||
|  |         $I->amAuthenticated('AccountWithOtpSecret'); | ||||||
|  |         $this->route->disable('123456', 'invalid_password'); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => false, | ||||||
|  |             'errors' => [ | ||||||
|  |                 'account' => 'error.otp_not_enabled', | ||||||
|  |             ], | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testSuccessEnable(FunctionalTester $I) { | ||||||
|  |         $I->amAuthenticated('AccountWithEnabledOtp'); | ||||||
|  |         $totp = new TOTP(null, 'secret-secret-secret'); | ||||||
|  |         $this->route->disable($totp->now(), 'password_0'); | ||||||
|  |         $I->canSeeResponseCodeIs(200); | ||||||
|  |         $I->canSeeResponseIsJson(); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => true, | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										61
									
								
								tests/codeception/api/functional/TwoFactorAuthEnableCest.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								tests/codeception/api/functional/TwoFactorAuthEnableCest.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | |||||||
|  | <?php | ||||||
|  | namespace tests\codeception\api\functional; | ||||||
|  |  | ||||||
|  | use OTPHP\TOTP; | ||||||
|  | use tests\codeception\api\_pages\TwoFactorAuthRoute; | ||||||
|  | use tests\codeception\api\FunctionalTester; | ||||||
|  |  | ||||||
|  | class TwoFactorAuthEnableCest { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @var TwoFactorAuthRoute | ||||||
|  |      */ | ||||||
|  |     private $route; | ||||||
|  |  | ||||||
|  |     public function _before(FunctionalTester $I) { | ||||||
|  |         $this->route = new TwoFactorAuthRoute($I); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testFails(FunctionalTester $I) { | ||||||
|  |         $I->amAuthenticated('AccountWithOtpSecret'); | ||||||
|  |  | ||||||
|  |         $this->route->enable(); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => false, | ||||||
|  |             'errors' => [ | ||||||
|  |                 'token' => 'error.token_required', | ||||||
|  |                 'password' => 'error.password_required', | ||||||
|  |             ], | ||||||
|  |         ]); | ||||||
|  |  | ||||||
|  |         $this->route->enable('123456', 'invalid_password'); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => false, | ||||||
|  |             'errors' => [ | ||||||
|  |                 'token' => 'error.token_incorrect', | ||||||
|  |                 'password' => 'error.password_incorrect', | ||||||
|  |             ], | ||||||
|  |         ]); | ||||||
|  |  | ||||||
|  |         $I->amAuthenticated('AccountWithEnabledOtp'); | ||||||
|  |         $this->route->enable('123456', 'invalid_password'); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => false, | ||||||
|  |             'errors' => [ | ||||||
|  |                 'account' => 'error.otp_already_enabled', | ||||||
|  |             ], | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testSuccessEnable(FunctionalTester $I) { | ||||||
|  |         $I->amAuthenticated('AccountWithOtpSecret'); | ||||||
|  |         $totp = new TOTP(null, 'some otp secret value'); | ||||||
|  |         $this->route->enable($totp->now(), 'password_0'); | ||||||
|  |         $I->canSeeResponseCodeIs(200); | ||||||
|  |         $I->canSeeResponseIsJson(); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => true, | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -7,12 +7,12 @@ use tests\codeception\api\FunctionalTester; | |||||||
|  |  | ||||||
| class AuthserverSteps extends FunctionalTester { | class AuthserverSteps extends FunctionalTester { | ||||||
|  |  | ||||||
|     public function amAuthenticated() { |     public function amAuthenticated(string $asUsername = 'admin', string $password = 'password_0') { | ||||||
|         $route = new AuthserverRoute($this); |         $route = new AuthserverRoute($this); | ||||||
|         $clientToken = Uuid::uuid4()->toString(); |         $clientToken = Uuid::uuid4()->toString(); | ||||||
|         $route->authenticate([ |         $route->authenticate([ | ||||||
|             'username' => 'admin', |             'username' => $asUsername, | ||||||
|             'password' => 'password_0', |             'password' => $password, | ||||||
|             'clientToken' => $clientToken, |             'clientToken' => $clientToken, | ||||||
|         ]); |         ]); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,12 +3,13 @@ namespace tests\codeception\api\functional\_steps; | |||||||
|  |  | ||||||
| use common\models\OauthScope as S; | use common\models\OauthScope as S; | ||||||
| use tests\codeception\api\_pages\OauthRoute; | use tests\codeception\api\_pages\OauthRoute; | ||||||
|  | use tests\codeception\api\FunctionalTester; | ||||||
|  |  | ||||||
| class OauthSteps extends \tests\codeception\api\FunctionalTester { | class OauthSteps extends FunctionalTester { | ||||||
|  |  | ||||||
|     public function getAuthCode(array $permissions = []) { |     public function getAuthCode(array $permissions = []) { | ||||||
|         // TODO: по идее можно напрямую сделать зпись в базу, что ускорит процесс тестирования |         // TODO: по идее можно напрямую сделать запись в базу, что ускорит процесс тестирования | ||||||
|         $this->loggedInAsActiveAccount(); |         $this->amAuthenticated(); | ||||||
|         $route = new OauthRoute($this); |         $route = new OauthRoute($this); | ||||||
|         $route->complete([ |         $route->complete([ | ||||||
|             'client_id' => 'ely', |             'client_id' => 'ely', | ||||||
| @@ -31,7 +32,7 @@ class OauthSteps extends \tests\codeception\api\FunctionalTester { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function getRefreshToken(array $permissions = []) { |     public function getRefreshToken(array $permissions = []) { | ||||||
|         // TODO: по идее можно напрямую сделать зпись в базу, что ускорит процесс тестирования |         // TODO: по идее можно напрямую сделать запись в базу, что ускорит процесс тестирования | ||||||
|         $authCode = $this->getAuthCode(array_merge([S::OFFLINE_ACCESS], $permissions)); |         $authCode = $this->getAuthCode(array_merge([S::OFFLINE_ACCESS], $permissions)); | ||||||
|         $response = $this->issueToken($authCode); |         $response = $this->issueToken($authCode); | ||||||
|  |  | ||||||
| @@ -51,4 +52,18 @@ class OauthSteps extends \tests\codeception\api\FunctionalTester { | |||||||
|         return json_decode($this->grabResponse(), true); |         return json_decode($this->grabResponse(), true); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public function getAccessTokenByClientCredentialsGrant(array $permissions = [], $useTrusted = true) { | ||||||
|  |         $route = new OauthRoute($this); | ||||||
|  |         $route->issueToken([ | ||||||
|  |             'client_id' => $useTrusted ? 'trusted-client' : 'default-client', | ||||||
|  |             'client_secret' => $useTrusted ? 'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9' : 'AzWRy7ZjS1yRQUk2vRBDic8fprOKDB1W', | ||||||
|  |             'grant_type' => 'client_credentials', | ||||||
|  |             'scope' => implode(',', $permissions), | ||||||
|  |         ]); | ||||||
|  |  | ||||||
|  |         $response = json_decode($this->grabResponse(), true); | ||||||
|  |  | ||||||
|  |         return $response['access_token']; | ||||||
|  |     } | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -18,7 +18,7 @@ class InvalidateCest { | |||||||
|  |  | ||||||
|     public function invalidate(AuthserverSteps $I) { |     public function invalidate(AuthserverSteps $I) { | ||||||
|         $I->wantTo('invalidate my token'); |         $I->wantTo('invalidate my token'); | ||||||
|         list($accessToken, $clientToken) = $I->amAuthenticated(); |         [$accessToken, $clientToken] = $I->amAuthenticated(); | ||||||
|         $this->route->invalidate([ |         $this->route->invalidate([ | ||||||
|             'accessToken' => $accessToken, |             'accessToken' => $accessToken, | ||||||
|             'clientToken' => $clientToken, |             'clientToken' => $clientToken, | ||||||
|   | |||||||
| @@ -18,7 +18,7 @@ class RefreshCest { | |||||||
|  |  | ||||||
|     public function refresh(AuthserverSteps $I) { |     public function refresh(AuthserverSteps $I) { | ||||||
|         $I->wantTo('refresh my accessToken'); |         $I->wantTo('refresh my accessToken'); | ||||||
|         list($accessToken, $clientToken) = $I->amAuthenticated(); |         [$accessToken, $clientToken] = $I->amAuthenticated(); | ||||||
|         $this->route->refresh([ |         $this->route->refresh([ | ||||||
|             'accessToken' => $accessToken, |             'accessToken' => $accessToken, | ||||||
|             'clientToken' => $clientToken, |             'clientToken' => $clientToken, | ||||||
|   | |||||||
| @@ -18,7 +18,7 @@ class ValidateCest { | |||||||
|  |  | ||||||
|     public function validate(AuthserverSteps $I) { |     public function validate(AuthserverSteps $I) { | ||||||
|         $I->wantTo('validate my accessToken'); |         $I->wantTo('validate my accessToken'); | ||||||
|         list($accessToken) = $I->amAuthenticated(); |         [$accessToken] = $I->amAuthenticated(); | ||||||
|         $this->route->validate([ |         $this->route->validate([ | ||||||
|             'accessToken' => $accessToken, |             'accessToken' => $accessToken, | ||||||
|         ]); |         ]); | ||||||
|   | |||||||
							
								
								
									
										47
									
								
								tests/codeception/api/functional/internal/BanCest.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								tests/codeception/api/functional/internal/BanCest.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | |||||||
|  | <?php | ||||||
|  | namespace tests\codeception\api\functional\internal; | ||||||
|  |  | ||||||
|  | use common\models\OauthScope as S; | ||||||
|  | use tests\codeception\api\_pages\InternalRoute; | ||||||
|  | use tests\codeception\api\functional\_steps\OauthSteps; | ||||||
|  | use tests\codeception\api\FunctionalTester; | ||||||
|  |  | ||||||
|  | class BanCest { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @var InternalRoute | ||||||
|  |      */ | ||||||
|  |     private $route; | ||||||
|  |  | ||||||
|  |     public function _before(FunctionalTester $I) { | ||||||
|  |         $this->route = new InternalRoute($I); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testBanAccount(OauthSteps $I) { | ||||||
|  |         $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::ACCOUNT_BLOCK]); | ||||||
|  |         $I->amBearerAuthenticated($accessToken); | ||||||
|  |  | ||||||
|  |         $this->route->ban(1); | ||||||
|  |         $I->canSeeResponseCodeIs(200); | ||||||
|  |         $I->canSeeResponseIsJson(); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => true, | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testBanBannedAccount(OauthSteps $I) { | ||||||
|  |         $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::ACCOUNT_BLOCK]); | ||||||
|  |         $I->amBearerAuthenticated($accessToken); | ||||||
|  |  | ||||||
|  |         $this->route->ban(10); | ||||||
|  |         $I->canSeeResponseCodeIs(200); | ||||||
|  |         $I->canSeeResponseIsJson(); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => false, | ||||||
|  |             'errors' => [ | ||||||
|  |                 'account' => 'error.account_already_banned', | ||||||
|  |             ], | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										47
									
								
								tests/codeception/api/functional/internal/PardonCest.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								tests/codeception/api/functional/internal/PardonCest.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | |||||||
|  | <?php | ||||||
|  | namespace tests\codeception\api\functional\internal; | ||||||
|  |  | ||||||
|  | use common\models\OauthScope as S; | ||||||
|  | use tests\codeception\api\_pages\InternalRoute; | ||||||
|  | use tests\codeception\api\functional\_steps\OauthSteps; | ||||||
|  | use tests\codeception\api\FunctionalTester; | ||||||
|  |  | ||||||
|  | class PardonCest { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * @var InternalRoute | ||||||
|  |      */ | ||||||
|  |     private $route; | ||||||
|  |  | ||||||
|  |     public function _before(FunctionalTester $I) { | ||||||
|  |         $this->route = new InternalRoute($I); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testPardonAccount(OauthSteps $I) { | ||||||
|  |         $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::ACCOUNT_BLOCK]); | ||||||
|  |         $I->amBearerAuthenticated($accessToken); | ||||||
|  |  | ||||||
|  |         $this->route->pardon(10); | ||||||
|  |         $I->canSeeResponseCodeIs(200); | ||||||
|  |         $I->canSeeResponseIsJson(); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => true, | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testPardonNotBannedAccount(OauthSteps $I) { | ||||||
|  |         $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::ACCOUNT_BLOCK]); | ||||||
|  |         $I->amBearerAuthenticated($accessToken); | ||||||
|  |  | ||||||
|  |         $this->route->pardon(1); | ||||||
|  |         $I->canSeeResponseCodeIs(200); | ||||||
|  |         $I->canSeeResponseIsJson(); | ||||||
|  |         $I->canSeeResponseContainsJson([ | ||||||
|  |             'success' => false, | ||||||
|  |             'errors' => [ | ||||||
|  |                 'account' => 'error.account_not_banned', | ||||||
|  |             ], | ||||||
|  |         ]); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -44,7 +44,7 @@ class HasJoinedLegacyCest { | |||||||
|             'user' => 'random-username', |             'user' => 'random-username', | ||||||
|             'serverId' => Uuid::uuid(), |             'serverId' => Uuid::uuid(), | ||||||
|         ]); |         ]); | ||||||
|         $I->seeResponseCodeIs(401); |         $I->seeResponseCodeIs(200); | ||||||
|         $I->canSeeResponseEquals('NO'); |         $I->canSeeResponseEquals('NO'); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -21,7 +21,7 @@ class JoinCest { | |||||||
|  |  | ||||||
|     public function joinByLegacyAuthserver(AuthserverSteps $I) { |     public function joinByLegacyAuthserver(AuthserverSteps $I) { | ||||||
|         $I->wantTo('join to server, using legacy authserver access token'); |         $I->wantTo('join to server, using legacy authserver access token'); | ||||||
|         list($accessToken) = $I->amAuthenticated(); |         [$accessToken] = $I->amAuthenticated(); | ||||||
|         $this->route->join([ |         $this->route->join([ | ||||||
|             'accessToken' => $accessToken, |             'accessToken' => $accessToken, | ||||||
|             'selectedProfile' => 'df936908-b2e1-544d-96f8-2977ec213022', |             'selectedProfile' => 'df936908-b2e1-544d-96f8-2977ec213022', | ||||||
| @@ -32,7 +32,7 @@ class JoinCest { | |||||||
|  |  | ||||||
|     public function joinByPassJsonInPost(AuthserverSteps $I) { |     public function joinByPassJsonInPost(AuthserverSteps $I) { | ||||||
|         $I->wantTo('join to server, passing data in body as encoded json'); |         $I->wantTo('join to server, passing data in body as encoded json'); | ||||||
|         list($accessToken) = $I->amAuthenticated(); |         [$accessToken] = $I->amAuthenticated(); | ||||||
|         $this->route->join(json_encode([ |         $this->route->join(json_encode([ | ||||||
|             'accessToken' => $accessToken, |             'accessToken' => $accessToken, | ||||||
|             'selectedProfile' => 'df936908-b2e1-544d-96f8-2977ec213022', |             'selectedProfile' => 'df936908-b2e1-544d-96f8-2977ec213022', | ||||||
|   | |||||||
| @@ -21,7 +21,7 @@ class JoinLegacyCest { | |||||||
|  |  | ||||||
|     public function joinByLegacyAuthserver(AuthserverSteps $I) { |     public function joinByLegacyAuthserver(AuthserverSteps $I) { | ||||||
|         $I->wantTo('join to server by legacy protocol, using legacy authserver access token'); |         $I->wantTo('join to server by legacy protocol, using legacy authserver access token'); | ||||||
|         list($accessToken) = $I->amAuthenticated(); |         [$accessToken] = $I->amAuthenticated(); | ||||||
|         $this->route->joinLegacy([ |         $this->route->joinLegacy([ | ||||||
|             'sessionId' => $accessToken, |             'sessionId' => $accessToken, | ||||||
|             'user' => 'Admin', |             'user' => 'Admin', | ||||||
| @@ -32,7 +32,7 @@ class JoinLegacyCest { | |||||||
|  |  | ||||||
|     public function joinByNewSessionFormat(AuthserverSteps $I) { |     public function joinByNewSessionFormat(AuthserverSteps $I) { | ||||||
|         $I->wantTo('join to server by legacy protocol with new launcher session format, using legacy authserver'); |         $I->wantTo('join to server by legacy protocol with new launcher session format, using legacy authserver'); | ||||||
|         list($accessToken) = $I->amAuthenticated(); |         [$accessToken] = $I->amAuthenticated(); | ||||||
|         $this->route->joinLegacy([ |         $this->route->joinLegacy([ | ||||||
|             'sessionId' => 'token:' . $accessToken . ':' . 'df936908-b2e1-544d-96f8-2977ec213022', |             'sessionId' => 'token:' . $accessToken . ':' . 'df936908-b2e1-544d-96f8-2977ec213022', | ||||||
|             'user' => 'Admin', |             'user' => 'Admin', | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ namespace codeception\api\unit\models\authentication; | |||||||
| use api\models\authentication\ForgotPasswordForm; | use api\models\authentication\ForgotPasswordForm; | ||||||
| use Codeception\Specify; | use Codeception\Specify; | ||||||
| use common\models\EmailActivation; | use common\models\EmailActivation; | ||||||
|  | use OTPHP\TOTP; | ||||||
| use tests\codeception\api\unit\TestCase; | use tests\codeception\api\unit\TestCase; | ||||||
| use tests\codeception\common\fixtures\AccountFixture; | use tests\codeception\common\fixtures\AccountFixture; | ||||||
| use tests\codeception\common\fixtures\EmailActivationFixture; | use tests\codeception\common\fixtures\EmailActivationFixture; | ||||||
| @@ -18,7 +19,7 @@ class ForgotPasswordFormTest extends TestCase { | |||||||
|         ]; |         ]; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function testValidateAccount() { |     public function testValidateLogin() { | ||||||
|         $this->specify('error.login_not_exist if login is invalid', function() { |         $this->specify('error.login_not_exist if login is invalid', function() { | ||||||
|             $model = new ForgotPasswordForm(['login' => 'unexist']); |             $model = new ForgotPasswordForm(['login' => 'unexist']); | ||||||
|             $model->validateLogin('login'); |             $model->validateLogin('login'); | ||||||
| @@ -32,6 +33,21 @@ class ForgotPasswordFormTest extends TestCase { | |||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public function testValidateTotpToken() { | ||||||
|  |         $model = new ForgotPasswordForm(); | ||||||
|  |         $model->login = 'AccountWithEnabledOtp'; | ||||||
|  |         $model->token = '123456'; | ||||||
|  |         $model->validateTotpToken('token'); | ||||||
|  |         $this->assertEquals(['error.token_incorrect'], $model->getErrors('token')); | ||||||
|  |  | ||||||
|  |         $totp = new TOTP(null, 'secret-secret-secret'); | ||||||
|  |         $model = new ForgotPasswordForm(); | ||||||
|  |         $model->login = 'AccountWithEnabledOtp'; | ||||||
|  |         $model->token = $totp->now(); | ||||||
|  |         $model->validateTotpToken('token'); | ||||||
|  |         $this->assertEmpty($model->getErrors('token')); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     public function testValidateActivity() { |     public function testValidateActivity() { | ||||||
|         $this->specify('error.account_not_activated if account is not confirmed', function() { |         $this->specify('error.account_not_activated if account is not confirmed', function() { | ||||||
|             $model = new ForgotPasswordForm([ |             $model = new ForgotPasswordForm([ | ||||||
|   | |||||||
| @@ -6,6 +6,7 @@ use api\models\AccountIdentity; | |||||||
| use api\models\authentication\LoginForm; | use api\models\authentication\LoginForm; | ||||||
| use Codeception\Specify; | use Codeception\Specify; | ||||||
| use common\models\Account; | use common\models\Account; | ||||||
|  | use OTPHP\TOTP; | ||||||
| use tests\codeception\api\unit\TestCase; | use tests\codeception\api\unit\TestCase; | ||||||
| use tests\codeception\common\fixtures\AccountFixture; | use tests\codeception\common\fixtures\AccountFixture; | ||||||
|  |  | ||||||
| @@ -38,7 +39,7 @@ class LoginFormTest extends TestCase { | |||||||
|                 'account' => null, |                 'account' => null, | ||||||
|             ]); |             ]); | ||||||
|             $model->validateLogin('login'); |             $model->validateLogin('login'); | ||||||
|             expect($model->getErrors('login'))->equals(['error.login_not_exist']); |             $this->assertEquals(['error.login_not_exist'], $model->getErrors('login')); | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         $this->specify('no errors if login exists', function () { |         $this->specify('no errors if login exists', function () { | ||||||
| @@ -47,7 +48,7 @@ class LoginFormTest extends TestCase { | |||||||
|                 'account' => new AccountIdentity(), |                 'account' => new AccountIdentity(), | ||||||
|             ]); |             ]); | ||||||
|             $model->validateLogin('login'); |             $model->validateLogin('login'); | ||||||
|             expect($model->getErrors('login'))->isEmpty(); |             $this->assertEmpty($model->getErrors('login')); | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -58,7 +59,7 @@ class LoginFormTest extends TestCase { | |||||||
|                 'account' => new AccountIdentity(['password' => '12345678']), |                 'account' => new AccountIdentity(['password' => '12345678']), | ||||||
|             ]); |             ]); | ||||||
|             $model->validatePassword('password'); |             $model->validatePassword('password'); | ||||||
|             expect($model->getErrors('password'))->equals(['error.password_incorrect']); |             $this->assertEquals(['error.password_incorrect'], $model->getErrors('password')); | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         $this->specify('no errors if password valid', function () { |         $this->specify('no errors if password valid', function () { | ||||||
| @@ -67,7 +68,35 @@ class LoginFormTest extends TestCase { | |||||||
|                 'account' => new AccountIdentity(['password' => '12345678']), |                 'account' => new AccountIdentity(['password' => '12345678']), | ||||||
|             ]); |             ]); | ||||||
|             $model->validatePassword('password'); |             $model->validatePassword('password'); | ||||||
|             expect($model->getErrors('password'))->isEmpty(); |             $this->assertEmpty($model->getErrors('password')); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testValidateTotpToken() { | ||||||
|  |         $account = new AccountIdentity(['password' => '12345678']); | ||||||
|  |         $account->password = '12345678'; | ||||||
|  |         $account->is_otp_enabled = true; | ||||||
|  |         $account->otp_secret = 'mock secret'; | ||||||
|  |  | ||||||
|  |         $this->specify('error.token_incorrect if totp invalid', function() use ($account) { | ||||||
|  |             $model = $this->createModel([ | ||||||
|  |                 'password' => '12345678', | ||||||
|  |                 'token' => '321123', | ||||||
|  |                 'account' => $account, | ||||||
|  |             ]); | ||||||
|  |             $model->validateTotpToken('token'); | ||||||
|  |             $this->assertEquals(['error.token_incorrect'], $model->getErrors('token')); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         $totp = new TOTP(null, 'mock secret'); | ||||||
|  |         $this->specify('no errors if password valid', function() use ($account, $totp) { | ||||||
|  |             $model = $this->createModel([ | ||||||
|  |                 'password' => '12345678', | ||||||
|  |                 'token' => $totp->now(), | ||||||
|  |                 'account' => $account, | ||||||
|  |             ]); | ||||||
|  |             $model->validateTotpToken('token'); | ||||||
|  |             $this->assertEmpty($model->getErrors('token')); | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -77,7 +106,7 @@ class LoginFormTest extends TestCase { | |||||||
|                 'account' => new AccountIdentity(['status' => Account::STATUS_REGISTERED]), |                 'account' => new AccountIdentity(['status' => Account::STATUS_REGISTERED]), | ||||||
|             ]); |             ]); | ||||||
|             $model->validateActivity('login'); |             $model->validateActivity('login'); | ||||||
|             expect($model->getErrors('login'))->equals(['error.account_not_activated']); |             $this->assertEquals(['error.account_not_activated'], $model->getErrors('login')); | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         $this->specify('error.account_banned if account has banned status', function () { |         $this->specify('error.account_banned if account has banned status', function () { | ||||||
| @@ -85,7 +114,7 @@ class LoginFormTest extends TestCase { | |||||||
|                 'account' => new AccountIdentity(['status' => Account::STATUS_BANNED]), |                 'account' => new AccountIdentity(['status' => Account::STATUS_BANNED]), | ||||||
|             ]); |             ]); | ||||||
|             $model->validateActivity('login'); |             $model->validateActivity('login'); | ||||||
|             expect($model->getErrors('login'))->equals(['error.account_banned']); |             $this->assertEquals(['error.account_banned'], $model->getErrors('login')); | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         $this->specify('no errors if account active', function () { |         $this->specify('no errors if account active', function () { | ||||||
| @@ -93,36 +122,36 @@ class LoginFormTest extends TestCase { | |||||||
|                 'account' => new AccountIdentity(['status' => Account::STATUS_ACTIVE]), |                 'account' => new AccountIdentity(['status' => Account::STATUS_ACTIVE]), | ||||||
|             ]); |             ]); | ||||||
|             $model->validateActivity('login'); |             $model->validateActivity('login'); | ||||||
|             expect($model->getErrors('login'))->isEmpty(); |             $this->assertEmpty($model->getErrors('login')); | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function testLogin() { |     public function testLogin() { | ||||||
|         $this->specify('user should be able to login with correct username and password', function () { |         $model = $this->createModel([ | ||||||
|             $model = $this->createModel([ |             'login' => 'erickskrauch', | ||||||
|                 'login' => 'erickskrauch', |             'password' => '12345678', | ||||||
|  |             'account' => new AccountIdentity([ | ||||||
|  |                 'username' => 'erickskrauch', | ||||||
|                 'password' => '12345678', |                 'password' => '12345678', | ||||||
|                 'account' => new AccountIdentity([ |                 'status' => Account::STATUS_ACTIVE, | ||||||
|                     'username' => 'erickskrauch', |             ]), | ||||||
|                     'password' => '12345678', |         ]); | ||||||
|                     'status' => Account::STATUS_ACTIVE, |         $this->assertInstanceOf(LoginResult::class, $model->login(), 'model should login user'); | ||||||
|                 ]), |         $this->assertEmpty($model->getErrors(), 'error message should not be set'); | ||||||
|             ]); |  | ||||||
|             expect('model should login user', $model->login())->isInstanceOf(LoginResult::class); |  | ||||||
|             expect('error message should not be set', $model->errors)->isEmpty(); |  | ||||||
|         }); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public function testLoginWithRehashing() { |     public function testLoginWithRehashing() { | ||||||
|         $this->specify('user, that login using account with old pass hash strategy should update it automatically', function () { |         $model = new LoginForm([ | ||||||
|             $model = new LoginForm([ |             'login' => $this->tester->grabFixture('accounts', 'user-with-old-password-type')['username'], | ||||||
|                 'login' => $this->tester->grabFixture('accounts', 'user-with-old-password-type')['username'], |             'password' => '12345678', | ||||||
|                 'password' => '12345678', |         ]); | ||||||
|             ]); |         $this->assertInstanceOf(LoginResult::class, $model->login()); | ||||||
|             expect($model->login())->isInstanceOf(LoginResult::class); |         $this->assertEmpty($model->getErrors()); | ||||||
|             expect($model->errors)->isEmpty(); |         $this->assertEquals( | ||||||
|             expect($model->getAccount()->password_hash_strategy)->equals(Account::PASS_HASH_STRATEGY_YII2); |             Account::PASS_HASH_STRATEGY_YII2, | ||||||
|         }); |             $model->getAccount()->password_hash_strategy, | ||||||
|  |             'user, that login using account with old pass hash strategy should update it automatically' | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|   | |||||||
| @@ -0,0 +1,165 @@ | |||||||
|  | <?php | ||||||
|  | namespace tests\codeception\api\unit\models\profile; | ||||||
|  |  | ||||||
|  | use api\models\profile\TwoFactorAuthForm; | ||||||
|  | use common\helpers\Error as E; | ||||||
|  | use common\models\Account; | ||||||
|  | use OTPHP\TOTP; | ||||||
|  | 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']); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testActivate() { | ||||||
|  |         /** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */ | ||||||
|  |         $account = $this->getMockBuilder(Account::class) | ||||||
|  |             ->setMethods(['save']) | ||||||
|  |             ->getMock(); | ||||||
|  |  | ||||||
|  |         $account->expects($this->once()) | ||||||
|  |             ->method('save') | ||||||
|  |             ->willReturn(true); | ||||||
|  |  | ||||||
|  |         $account->is_otp_enabled = false; | ||||||
|  |         $account->otp_secret = 'mock secret'; | ||||||
|  |  | ||||||
|  |         /** @var TwoFactorAuthForm|\PHPUnit_Framework_MockObject_MockObject $model */ | ||||||
|  |         $model = $this->getMockBuilder(TwoFactorAuthForm::class) | ||||||
|  |             ->setMethods(['validate']) | ||||||
|  |             ->setConstructorArgs([$account, ['scenario' => TwoFactorAuthForm::SCENARIO_ACTIVATE]]) | ||||||
|  |             ->getMock(); | ||||||
|  |  | ||||||
|  |         $model->expects($this->once()) | ||||||
|  |             ->method('validate') | ||||||
|  |             ->willReturn(true); | ||||||
|  |  | ||||||
|  |         $this->assertTrue($model->activate()); | ||||||
|  |         $this->assertTrue($account->is_otp_enabled); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testDisable() { | ||||||
|  |         /** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */ | ||||||
|  |         $account = $this->getMockBuilder(Account::class) | ||||||
|  |             ->setMethods(['save']) | ||||||
|  |             ->getMock(); | ||||||
|  |  | ||||||
|  |         $account->expects($this->once()) | ||||||
|  |             ->method('save') | ||||||
|  |             ->willReturn(true); | ||||||
|  |  | ||||||
|  |         $account->is_otp_enabled = true; | ||||||
|  |         $account->otp_secret = 'mock secret'; | ||||||
|  |  | ||||||
|  |         /** @var TwoFactorAuthForm|\PHPUnit_Framework_MockObject_MockObject $model */ | ||||||
|  |         $model = $this->getMockBuilder(TwoFactorAuthForm::class) | ||||||
|  |             ->setMethods(['validate']) | ||||||
|  |             ->setConstructorArgs([$account, ['scenario' => TwoFactorAuthForm::SCENARIO_DISABLE]]) | ||||||
|  |             ->getMock(); | ||||||
|  |  | ||||||
|  |         $model->expects($this->once()) | ||||||
|  |             ->method('validate') | ||||||
|  |             ->willReturn(true); | ||||||
|  |  | ||||||
|  |         $this->assertTrue($model->disable()); | ||||||
|  |         $this->assertNull($account->otp_secret); | ||||||
|  |         $this->assertFalse($account->is_otp_enabled); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testValidateOtpDisabled() { | ||||||
|  |         $account = new Account(); | ||||||
|  |         $account->is_otp_enabled = true; | ||||||
|  |         $model = new TwoFactorAuthForm($account); | ||||||
|  |         $model->validateOtpDisabled('account'); | ||||||
|  |         $this->assertEquals([E::OTP_ALREADY_ENABLED], $model->getErrors('account')); | ||||||
|  |  | ||||||
|  |         $account = new Account(); | ||||||
|  |         $account->is_otp_enabled = false; | ||||||
|  |         $model = new TwoFactorAuthForm($account); | ||||||
|  |         $model->validateOtpDisabled('account'); | ||||||
|  |         $this->assertEmpty($model->getErrors('account')); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testValidateOtpEnabled() { | ||||||
|  |         $account = new Account(); | ||||||
|  |         $account->is_otp_enabled = false; | ||||||
|  |         $model = new TwoFactorAuthForm($account); | ||||||
|  |         $model->validateOtpEnabled('account'); | ||||||
|  |         $this->assertEquals([E::OTP_NOT_ENABLED], $model->getErrors('account')); | ||||||
|  |  | ||||||
|  |         $account = new Account(); | ||||||
|  |         $account->is_otp_enabled = true; | ||||||
|  |         $model = new TwoFactorAuthForm($account); | ||||||
|  |         $model->validateOtpEnabled('account'); | ||||||
|  |         $this->assertEmpty($model->getErrors('account')); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testGetTotp() { | ||||||
|  |         $account = new Account(); | ||||||
|  |         $account->otp_secret = 'mock secret'; | ||||||
|  |         $account->email = 'check@this.email'; | ||||||
|  |  | ||||||
|  |         $model = new TwoFactorAuthForm($account); | ||||||
|  |         $totp = $model->getTotp(); | ||||||
|  |         $this->assertInstanceOf(TOTP::class, $totp); | ||||||
|  |         $this->assertEquals('check@this.email', $totp->getLabel()); | ||||||
|  |         $this->assertEquals('mock secret', $totp->getSecret()); | ||||||
|  |         $this->assertEquals('Ely.by', $totp->getIssuer()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -0,0 +1,62 @@ | |||||||
|  | <?php | ||||||
|  | namespace tests\codeception\api\unit\modules\internal\models; | ||||||
|  |  | ||||||
|  | use api\modules\internal\helpers\Error as E; | ||||||
|  | use api\modules\internal\models\BanForm; | ||||||
|  | use common\models\Account; | ||||||
|  | use tests\codeception\api\unit\TestCase; | ||||||
|  |  | ||||||
|  | class BanFormTest extends TestCase { | ||||||
|  |  | ||||||
|  |     public function testValidateAccountActivity() { | ||||||
|  |         $account = new Account(); | ||||||
|  |         $account->status = Account::STATUS_ACTIVE; | ||||||
|  |         $form = new BanForm($account); | ||||||
|  |         $form->validateAccountActivity(); | ||||||
|  |         $this->assertEmpty($form->getErrors('account')); | ||||||
|  |  | ||||||
|  |         $account = new Account(); | ||||||
|  |         $account->status = Account::STATUS_BANNED; | ||||||
|  |         $form = new BanForm($account); | ||||||
|  |         $form->validateAccountActivity(); | ||||||
|  |         $this->assertEquals([E::ACCOUNT_ALREADY_BANNED], $form->getErrors('account')); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testBan() { | ||||||
|  |         /** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */ | ||||||
|  |         $account = $this->getMockBuilder(Account::class) | ||||||
|  |             ->setMethods(['save']) | ||||||
|  |             ->getMock(); | ||||||
|  |  | ||||||
|  |         $account->expects($this->once()) | ||||||
|  |             ->method('save') | ||||||
|  |             ->willReturn(true); | ||||||
|  |  | ||||||
|  |         $model = new BanForm($account); | ||||||
|  |         $this->assertTrue($model->ban()); | ||||||
|  |         $this->assertEquals(Account::STATUS_BANNED, $account->status); | ||||||
|  |         $this->tester->canSeeAmqpMessageIsCreated('events'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testCreateTask() { | ||||||
|  |         $account = new Account(); | ||||||
|  |         $account->id = 3; | ||||||
|  |  | ||||||
|  |         $model = new BanForm($account); | ||||||
|  |         $model->createTask(); | ||||||
|  |         $message = json_decode($this->tester->grabLastSentAmqpMessage('events')->body, true); | ||||||
|  |         $this->assertSame(3, $message['accountId']); | ||||||
|  |         $this->assertSame(-1, $message['duration']); | ||||||
|  |         $this->assertSame('', $message['message']); | ||||||
|  |  | ||||||
|  |         $model = new BanForm($account); | ||||||
|  |         $model->duration = 123; | ||||||
|  |         $model->message = 'test'; | ||||||
|  |         $model->createTask(); | ||||||
|  |         $message = json_decode($this->tester->grabLastSentAmqpMessage('events')->body, true); | ||||||
|  |         $this->assertSame(3, $message['accountId']); | ||||||
|  |         $this->assertSame(123, $message['duration']); | ||||||
|  |         $this->assertSame('test', $message['message']); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -0,0 +1,52 @@ | |||||||
|  | <?php | ||||||
|  | namespace tests\codeception\api\unit\modules\internal\models; | ||||||
|  |  | ||||||
|  | use api\modules\internal\helpers\Error as E; | ||||||
|  | use api\modules\internal\models\PardonForm; | ||||||
|  | use common\models\Account; | ||||||
|  | use tests\codeception\api\unit\TestCase; | ||||||
|  |  | ||||||
|  | class PardonFormTest extends TestCase { | ||||||
|  |  | ||||||
|  |     public function testValidateAccountBanned() { | ||||||
|  |         $account = new Account(); | ||||||
|  |         $account->status = Account::STATUS_BANNED; | ||||||
|  |         $form = new PardonForm($account); | ||||||
|  |         $form->validateAccountBanned(); | ||||||
|  |         $this->assertEmpty($form->getErrors('account')); | ||||||
|  |  | ||||||
|  |         $account = new Account(); | ||||||
|  |         $account->status = Account::STATUS_ACTIVE; | ||||||
|  |         $form = new PardonForm($account); | ||||||
|  |         $form->validateAccountBanned(); | ||||||
|  |         $this->assertEquals([E::ACCOUNT_NOT_BANNED], $form->getErrors('account')); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testPardon() { | ||||||
|  |         /** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */ | ||||||
|  |         $account = $this->getMockBuilder(Account::class) | ||||||
|  |             ->setMethods(['save']) | ||||||
|  |             ->getMock(); | ||||||
|  |  | ||||||
|  |         $account->expects($this->once()) | ||||||
|  |             ->method('save') | ||||||
|  |             ->willReturn(true); | ||||||
|  |  | ||||||
|  |         $account->status = Account::STATUS_BANNED; | ||||||
|  |         $model = new PardonForm($account); | ||||||
|  |         $this->assertTrue($model->pardon()); | ||||||
|  |         $this->assertEquals(Account::STATUS_ACTIVE, $account->status); | ||||||
|  |         $this->tester->canSeeAmqpMessageIsCreated('events'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public function testCreateTask() { | ||||||
|  |         $account = new Account(); | ||||||
|  |         $account->id = 3; | ||||||
|  |  | ||||||
|  |         $model = new PardonForm($account); | ||||||
|  |         $model->createTask(); | ||||||
|  |         $message = json_decode($this->tester->grabLastSentAmqpMessage('events')->body, true); | ||||||
|  |         $this->assertSame(3, $message['accountId']); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										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); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -16,4 +16,12 @@ return [ | |||||||
|         'created_at' => time(), |         'created_at' => time(), | ||||||
|         'last_refreshed_at' => time(), |         'last_refreshed_at' => time(), | ||||||
|     ], |     ], | ||||||
|  |     'banned-user-session' => [ | ||||||
|  |         'id' => 3, | ||||||
|  |         'account_id' => 10, | ||||||
|  |         'refresh_token' => 'Af7fIuV6eL61tRUHn40yhmDRXN1OQxKR', | ||||||
|  |         'last_used_ip' => ip2long('182.123.234.123'), | ||||||
|  |         'created_at' => time(), | ||||||
|  |         'last_refreshed_at' => time(), | ||||||
|  |     ], | ||||||
| ]; | ]; | ||||||
|   | |||||||
| @@ -146,4 +146,34 @@ return [ | |||||||
|         'created_at' => 1474404139, |         'created_at' => 1474404139, | ||||||
|         'updated_at' => 1474404149, |         'updated_at' => 1474404149, | ||||||
|     ], |     ], | ||||||
|  |     'account-with-otp-secret' => [ | ||||||
|  |         'id' => 12, | ||||||
|  |         'uuid' => '9e9dcd11-2322-46dc-a992-e822a422726e', | ||||||
|  |         'username' => 'AccountWithOtpSecret', | ||||||
|  |         'email' => 'sava-galkin@mail.ru', | ||||||
|  |         'password_hash' => '$2y$13$2rYkap5T6jG8z/mMK8a3Ou6aZxJcmAaTha6FEuujvHEmybSHRzW5e', # password_0 | ||||||
|  |         'password_hash_strategy' => \common\models\Account::PASS_HASH_STRATEGY_YII2, | ||||||
|  |         'lang' => 'ru', | ||||||
|  |         'status' => \common\models\Account::STATUS_ACTIVE, | ||||||
|  |         'rules_agreement_version' => \common\LATEST_RULES_VERSION, | ||||||
|  |         'otp_secret' => 'some otp secret value', | ||||||
|  |         'is_otp_enabled' => false, | ||||||
|  |         'created_at' => 1485124615, | ||||||
|  |         'updated_at' => 1485124615, | ||||||
|  |     ], | ||||||
|  |     'account-with-enabled-otp' => [ | ||||||
|  |         'id' => 13, | ||||||
|  |         'uuid' => '15d0afa7-a2bb-44d3-9f31-964cbccc6043', | ||||||
|  |         'username' => 'AccountWithEnabledOtp', | ||||||
|  |         'email' => 'otp@gmail.com', | ||||||
|  |         'password_hash' => '$2y$13$2rYkap5T6jG8z/mMK8a3Ou6aZxJcmAaTha6FEuujvHEmybSHRzW5e', # password_0 | ||||||
|  |         'password_hash_strategy' => \common\models\Account::PASS_HASH_STRATEGY_YII2, | ||||||
|  |         'lang' => 'ru', | ||||||
|  |         'status' => \common\models\Account::STATUS_ACTIVE, | ||||||
|  |         'rules_agreement_version' => \common\LATEST_RULES_VERSION, | ||||||
|  |         'otp_secret' => 'secret-secret-secret', | ||||||
|  |         'is_otp_enabled' => true, | ||||||
|  |         'created_at' => 1485124685, | ||||||
|  |         'updated_at' => 1485124685, | ||||||
|  |     ], | ||||||
| ]; | ]; | ||||||
|   | |||||||
| @@ -30,4 +30,24 @@ return [ | |||||||
|         'is_trusted' => 0, |         'is_trusted' => 0, | ||||||
|         'created_at' => 1479937982, |         'created_at' => 1479937982, | ||||||
|     ], |     ], | ||||||
|  |     'trustedClient' => [ | ||||||
|  |         'id' => 'trusted-client', | ||||||
|  |         'secret' => 'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9', | ||||||
|  |         'name' => 'Trusted client', | ||||||
|  |         'description' => 'Это клиент, которому мы доверяем', | ||||||
|  |         'redirect_uri' => null, | ||||||
|  |         'account_id' => null, | ||||||
|  |         'is_trusted' => 1, | ||||||
|  |         'created_at' => 1482922663, | ||||||
|  |     ], | ||||||
|  |     'defaultClient' => [ | ||||||
|  |         'id' => 'default-client', | ||||||
|  |         'secret' => 'AzWRy7ZjS1yRQUk2vRBDic8fprOKDB1W', | ||||||
|  |         'name' => 'Default client', | ||||||
|  |         'description' => 'Это обычный клиент, каких может быть много', | ||||||
|  |         'redirect_uri' => null, | ||||||
|  |         'account_id' => null, | ||||||
|  |         'is_trusted' => 0, | ||||||
|  |         'created_at' => 1482922711, | ||||||
|  |     ], | ||||||
| ]; | ]; | ||||||
|   | |||||||
| @@ -7,4 +7,11 @@ return [ | |||||||
|         'client_id' => 'test1', |         'client_id' => 'test1', | ||||||
|         'client_redirect_uri' => 'http://test1.net/oauth', |         'client_redirect_uri' => 'http://test1.net/oauth', | ||||||
|     ], |     ], | ||||||
|  |     'banned-account-session' => [ | ||||||
|  |         'id' => 2, | ||||||
|  |         'owner_type' => 'user', | ||||||
|  |         'owner_id' => 10, | ||||||
|  |         'client_id' => 'test1', | ||||||
|  |         'client_redirect_uri' => 'http://test1.net/oauth', | ||||||
|  |     ], | ||||||
| ]; | ]; | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ namespace codeception\console\unit\controllers; | |||||||
| use common\components\Mojang\Api; | use common\components\Mojang\Api; | ||||||
| use common\components\Mojang\exceptions\NoContentException; | use common\components\Mojang\exceptions\NoContentException; | ||||||
| use common\components\Mojang\response\UsernameToUUIDResponse; | use common\components\Mojang\response\UsernameToUUIDResponse; | ||||||
|  | use common\models\amqp\AccountBanned; | ||||||
| use common\models\amqp\UsernameChanged; | use common\models\amqp\UsernameChanged; | ||||||
| use common\models\MojangUsername; | use common\models\MojangUsername; | ||||||
| use console\controllers\AccountQueueController; | use console\controllers\AccountQueueController; | ||||||
| @@ -143,4 +144,22 @@ class AccountQueueControllerTest extends TestCase { | |||||||
|         $this->assertNotEquals($mojangInfo->uuid, $mojangUsername->uuid); |         $this->assertNotEquals($mojangInfo->uuid, $mojangUsername->uuid); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public function testRouteAccountBanned() { | ||||||
|  |         /** @var \common\models\Account $bannedAccount */ | ||||||
|  |         $bannedAccount = $this->tester->grabFixture('accounts', 'banned-account'); | ||||||
|  |         $this->tester->haveFixtures([ | ||||||
|  |             'oauthSessions' => \tests\codeception\common\fixtures\OauthSessionFixture::class, | ||||||
|  |             'minecraftAccessKeys' => \tests\codeception\common\fixtures\MinecraftAccessKeyFixture::class, | ||||||
|  |             'authSessions' => \tests\codeception\common\fixtures\AccountSessionFixture::class, | ||||||
|  |         ]); | ||||||
|  |  | ||||||
|  |         $body = new AccountBanned(); | ||||||
|  |         $body->accountId = $bannedAccount->id; | ||||||
|  |  | ||||||
|  |         $this->controller->routeAccountBanned($body); | ||||||
|  |         $this->assertEmpty($bannedAccount->sessions); | ||||||
|  |         $this->assertEmpty($bannedAccount->minecraftAccessKeys); | ||||||
|  |         $this->assertEmpty($bannedAccount->oauthSessions); | ||||||
|  |     } | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user