diff --git a/api/components/ApiUser/AccessControl.php b/api/components/ApiUser/AccessControl.php deleted file mode 100644 index b145828..0000000 --- a/api/components/ApiUser/AccessControl.php +++ /dev/null @@ -1,8 +0,0 @@ -oauth->getAuthServer()->getAccessTokenStorage()->get($token); - if ($accessToken === null) { - return false; - } - - return $accessToken->hasScope($permissionName); - } - -} diff --git a/api/components/ApiUser/Component.php b/api/components/ApiUser/Component.php deleted file mode 100644 index d6f4655..0000000 --- a/api/components/ApiUser/Component.php +++ /dev/null @@ -1,24 +0,0 @@ -addGrantType(new Grants\ClientCredentialsGrant()); $this->_authServer = $authServer; - - SecureKey::setAlgorithm(new UuidAlgorithm()); } return $this->_authServer; } + public function getAccessTokenStorage(): AccessTokenInterface { + return $this->getAuthServer()->getAccessTokenStorage(); + } + + public function getRefreshTokenStorage(): RefreshTokenInterface { + return $this->getAuthServer()->getRefreshTokenStorage(); + } + + public function getSessionStorage(): SessionInterface { + return $this->getAuthServer()->getSessionStorage(); + } + } diff --git a/api/components/OAuth2/Grants/AuthCodeGrant.php b/api/components/OAuth2/Grants/AuthCodeGrant.php index f8f17e0..aa500ef 100644 --- a/api/components/OAuth2/Grants/AuthCodeGrant.php +++ b/api/components/OAuth2/Grants/AuthCodeGrant.php @@ -6,7 +6,7 @@ use api\components\OAuth2\Entities\AuthCodeEntity; use api\components\OAuth2\Entities\ClientEntity; use api\components\OAuth2\Entities\RefreshTokenEntity; use api\components\OAuth2\Entities\SessionEntity; -use common\models\OauthScope; +use api\components\OAuth2\Storage\ScopeStorage; use League\OAuth2\Server\Entity\AuthCodeEntity as BaseAuthCodeEntity; use League\OAuth2\Server\Entity\ClientEntity as BaseClientEntity; use League\OAuth2\Server\Event\ClientAuthenticationFailedEvent; @@ -178,7 +178,7 @@ class AuthCodeGrant extends AbstractGrant { // Generate the access token $accessToken = new AccessTokenEntity($this->server); - $accessToken->setId(SecureKey::generate()); // TODO: generate code based on permissions + $accessToken->setId(SecureKey::generate()); $accessToken->setExpireTime($this->getAccessTokenTTL() + time()); foreach ($authCodeScopes as $authCodeScope) { @@ -194,7 +194,7 @@ class AuthCodeGrant extends AbstractGrant { $this->server->getTokenType()->setParam('expires_in', $this->getAccessTokenTTL()); // Выдаём refresh_token, если запрошен offline_access - if (isset($accessToken->getScopes()[OauthScope::OFFLINE_ACCESS])) { + if (isset($accessToken->getScopes()[ScopeStorage::OFFLINE_ACCESS])) { /** @var RefreshTokenGrant $refreshTokenGrant */ $refreshTokenGrant = $this->server->getGrantType('refresh_token'); $refreshToken = new RefreshTokenEntity($this->server); @@ -223,12 +223,12 @@ class AuthCodeGrant extends AbstractGrant { * Так что оборачиваем функцию разбора скоупов, заменяя пробелы на запятые. * * @param string $scopeParam - * @param ClientEntity $client + * @param BaseClientEntity $client * @param string $redirectUri * * @return \League\OAuth2\Server\Entity\ScopeEntity[] */ - public function validateScopes($scopeParam = '', ClientEntity $client, $redirectUri = null) { + public function validateScopes($scopeParam = '', BaseClientEntity $client, $redirectUri = null) { $scopes = str_replace(' ', $this->server->getScopeDelimiter(), $scopeParam); return parent::validateScopes($scopes, $client, $redirectUri); } diff --git a/api/components/OAuth2/Grants/ClientCredentialsGrant.php b/api/components/OAuth2/Grants/ClientCredentialsGrant.php index e0303bd..a3dae02 100644 --- a/api/components/OAuth2/Grants/ClientCredentialsGrant.php +++ b/api/components/OAuth2/Grants/ClientCredentialsGrant.php @@ -18,14 +18,12 @@ class ClientCredentialsGrant extends AbstractGrant { * @throws \League\OAuth2\Server\Exception\OAuthException */ public function completeFlow(): array { - // Get the required params $clientId = $this->server->getRequest()->request->get('client_id', $this->server->getRequest()->getUser()); if ($clientId === null) { throw new Exception\InvalidRequestException('client_id'); } - $clientSecret = $this->server->getRequest()->request->get('client_secret', - $this->server->getRequest()->getPassword()); + $clientSecret = $this->server->getRequest()->request->get('client_secret'); if ($clientSecret === null) { throw new Exception\InvalidRequestException('client_secret'); } @@ -74,12 +72,12 @@ class ClientCredentialsGrant extends AbstractGrant { * Так что оборачиваем функцию разбора скоупов, заменяя пробелы на запятые. * * @param string $scopeParam - * @param ClientEntity $client + * @param BaseClientEntity $client * @param string $redirectUri * * @return \League\OAuth2\Server\Entity\ScopeEntity[] */ - public function validateScopes($scopeParam = '', ClientEntity $client, $redirectUri = null) { + public function validateScopes($scopeParam = '', BaseClientEntity $client, $redirectUri = null) { $scopes = str_replace(' ', $this->server->getScopeDelimiter(), $scopeParam); return parent::validateScopes($scopes, $client, $redirectUri); } diff --git a/api/components/OAuth2/Grants/RefreshTokenGrant.php b/api/components/OAuth2/Grants/RefreshTokenGrant.php index 9062aa7..8283631 100644 --- a/api/components/OAuth2/Grants/RefreshTokenGrant.php +++ b/api/components/OAuth2/Grants/RefreshTokenGrant.php @@ -51,12 +51,12 @@ class RefreshTokenGrant extends AbstractGrant { * Так что оборачиваем функцию разбора скоупов, заменяя пробелы на запятые. * * @param string $scopeParam - * @param ClientEntity $client + * @param BaseClientEntity $client * @param string $redirectUri * * @return \League\OAuth2\Server\Entity\ScopeEntity[] */ - public function validateScopes($scopeParam = '', ClientEntity $client, $redirectUri = null) { + public function validateScopes($scopeParam = '', BaseClientEntity $client, $redirectUri = null) { $scopes = str_replace(' ', $this->server->getScopeDelimiter(), $scopeParam); return parent::validateScopes($scopes, $client, $redirectUri); } @@ -143,7 +143,7 @@ class RefreshTokenGrant extends AbstractGrant { // Generate a new access token and assign it the correct sessions $newAccessToken = new AccessTokenEntity($this->server); - $newAccessToken->setId(SecureKey::generate()); // TODO: generate based on permissions + $newAccessToken->setId(SecureKey::generate()); $newAccessToken->setExpireTime($this->getAccessTokenTTL() + time()); $newAccessToken->setSession($session); diff --git a/api/components/OAuth2/Storage/ScopeStorage.php b/api/components/OAuth2/Storage/ScopeStorage.php index d5223e5..3e08a07 100644 --- a/api/components/OAuth2/Storage/ScopeStorage.php +++ b/api/components/OAuth2/Storage/ScopeStorage.php @@ -3,46 +3,84 @@ namespace api\components\OAuth2\Storage; use api\components\OAuth2\Entities\ClientEntity; use api\components\OAuth2\Entities\ScopeEntity; -use common\models\OauthScope; +use Assert\Assert; +use common\rbac\Permissions as P; use League\OAuth2\Server\Storage\AbstractStorage; use League\OAuth2\Server\Storage\ScopeInterface; -use yii\base\ErrorException; class ScopeStorage extends AbstractStorage implements ScopeInterface { + public const OFFLINE_ACCESS = 'offline_access'; + + private const PUBLIC_SCOPES_TO_INTERNAL_PERMISSIONS = [ + 'account_info' => P::OBTAIN_OWN_ACCOUNT_INFO, + 'account_email' => P::OBTAIN_ACCOUNT_EMAIL, + 'account_block' => P::BLOCK_ACCOUNT, + 'internal_account_info' => P::OBTAIN_EXTENDED_ACCOUNT_INFO, + ]; + + private const AUTHORIZATION_CODE_PERMISSIONS = [ + P::OBTAIN_OWN_ACCOUNT_INFO, + P::OBTAIN_ACCOUNT_EMAIL, + P::MINECRAFT_SERVER_SESSION, + self::OFFLINE_ACCESS, + ]; + + private const CLIENT_CREDENTIALS_PERMISSIONS = [ + ]; + + private const CLIENT_CREDENTIALS_PERMISSIONS_INTERNAL = [ + P::BLOCK_ACCOUNT, + P::OBTAIN_EXTENDED_ACCOUNT_INFO, + ]; + /** - * @inheritdoc + * @param string $scope + * @param string $grantType передаётся, если запрос поступает из grant. В этом случае нужно отфильтровать + * только те права, которые можно получить на этом grant. + * @param string $clientId + * + * @return ScopeEntity|null */ - public function get($scope, $grantType = null, $clientId = null) { - $query = OauthScope::find(); + public function get($scope, $grantType = null, $clientId = null): ?ScopeEntity { + $permission = $this->convertToInternalPermission($scope); + if ($grantType === 'authorization_code') { - $query->onlyPublic()->usersScopes(); + $permissions = self::AUTHORIZATION_CODE_PERMISSIONS; } elseif ($grantType === 'client_credentials') { - $query->machineScopes(); + $permissions = self::CLIENT_CREDENTIALS_PERMISSIONS; $isTrusted = false; if ($clientId !== null) { + /** @var ClientEntity $client */ $client = $this->server->getClientStorage()->get($clientId); - if (!$client instanceof ClientEntity) { - throw new ErrorException('client storage must return instance of ' . ClientEntity::class); - } + Assert::that($client)->isInstanceOf(ClientEntity::class); $isTrusted = $client->isTrusted(); } - if (!$isTrusted) { - $query->onlyPublic(); + if ($isTrusted) { + $permissions = array_merge($permissions, self::CLIENT_CREDENTIALS_PERMISSIONS_INTERNAL); } + } else { + $permissions = array_merge( + self::AUTHORIZATION_CODE_PERMISSIONS, + self::CLIENT_CREDENTIALS_PERMISSIONS, + self::CLIENT_CREDENTIALS_PERMISSIONS_INTERNAL + ); } - $scopes = $query->all(); - if (!in_array($scope, $scopes, true)) { + if (!in_array($permission, $permissions, true)) { return null; } $entity = new ScopeEntity($this->server); - $entity->setId($scope); + $entity->setId($permission); return $entity; } + private function convertToInternalPermission(string $publicScope): string { + return self::PUBLIC_SCOPES_TO_INTERNAL_PERMISSIONS[$publicScope] ?? $publicScope; + } + } diff --git a/api/components/OAuth2/Utils/KeyAlgorithm/UuidAlgorithm.php b/api/components/OAuth2/Utils/KeyAlgorithm/UuidAlgorithm.php deleted file mode 100644 index 54b4ba3..0000000 --- a/api/components/OAuth2/Utils/KeyAlgorithm/UuidAlgorithm.php +++ /dev/null @@ -1,16 +0,0 @@ -toString(); - } - -} diff --git a/api/components/User/AuthenticationResult.php b/api/components/User/AuthenticationResult.php new file mode 100644 index 0000000..3cc0010 --- /dev/null +++ b/api/components/User/AuthenticationResult.php @@ -0,0 +1,60 @@ +account = $account; + $this->jwt = $jwt; + $this->session = $session; + } + + public function getAccount(): Account { + return $this->account; + } + + public function getJwt(): string { + return $this->jwt; + } + + public function getSession(): ?AccountSession { + return $this->session; + } + + public function getAsResponse() { + $token = (new Jwt())->deserialize($this->getJwt()); + + /** @noinspection NullPointerExceptionInspection */ + $response = [ + 'access_token' => $this->getJwt(), + 'expires_in' => $token->getPayload()->findClaimByName(Expiration::NAME)->getValue() - time(), + ]; + + $session = $this->getSession(); + if ($session !== null) { + $response['refresh_token'] = $session->refresh_token; + } + + return $response; + } + +} diff --git a/api/components/User/Component.php b/api/components/User/Component.php index 670839c..aa0e37d 100644 --- a/api/components/User/Component.php +++ b/api/components/User/Component.php @@ -1,8 +1,10 @@ secret) { @@ -52,72 +58,60 @@ class Component extends YiiUserComponent { } } - /** - * @param IdentityInterface $identity - * @param bool $rememberMe - * - * @return LoginResult|bool - * @throws ErrorException - */ - public function login(IdentityInterface $identity, $rememberMe = false) { - if (!$this->beforeLogin($identity, false, $rememberMe)) { - return false; + public function findIdentityByAccessToken(string $accessToken): ?IdentityInterface { + /** @var \api\components\User\IdentityInterface|string $identityClass */ + $identityClass = $this->identityClass; + try { + return $identityClass::findIdentityByAccessToken($accessToken); + } catch (Exception $e) { + Yii::error($e); + return null; } + } - $this->switchIdentity($identity, 0); - - $id = $identity->getId(); + public function createJwtAuthenticationToken(Account $account, bool $rememberMe): AuthenticationResult { $ip = Yii::$app->request->userIP; - $token = $this->createToken($identity); + $token = $this->createToken($account); if ($rememberMe) { $session = new AccountSession(); - $session->account_id = $id; + $session->account_id = $account->id; $session->setIp($ip); $session->generateRefreshToken(); if (!$session->save()) { - throw new ErrorException('Cannot save account session model'); + throw new ThisShouldNotHappenException('Cannot save account session model'); } - $token->addClaim(new SessionIdClaim($session->id)); + $token->addClaim(new Claim\JwtId($session->id)); } else { $session = null; - // Если мы не сохраняем сессию, то токен должен жить подольше, чтобы - // не прогорала сессия во время работы с аккаунтом + // Если мы не сохраняем сессию, то токен должен жить подольше, + // чтобы не прогорала сессия во время работы с аккаунтом $token->addClaim(new Claim\Expiration((new DateTime())->add(new DateInterval($this->sessionTimeout)))); } $jwt = $this->serializeToken($token); - Yii::info("User '{$id}' logged in from {$ip}.", __METHOD__); - - $result = new LoginResult($identity, $jwt, $session); - $this->afterLogin($identity, false, $rememberMe); - - return $result; + return new AuthenticationResult($account, $jwt, $session); } - public function renew(AccountSession $session): RenewResult { - $account = $session->account; + public function renewJwtAuthenticationToken(AccountSession $session): AuthenticationResult { $transaction = Yii::$app->db->beginTransaction(); - try { - $identity = new AccountIdentity($account->attributes); - $token = $this->createToken($identity); - $jwt = $this->serializeToken($token); - $result = new RenewResult($identity, $jwt); + $account = $session->account; + $token = $this->createToken($account); + $token->addClaim(new Claim\JwtId($session->id)); + $jwt = $this->serializeToken($token); - $session->setIp(Yii::$app->request->userIP); - $session->last_refreshed_at = time(); - if (!$session->save()) { - throw new ErrorException('Cannot update session info'); - } + $result = new AuthenticationResult($account, $jwt, $session); - $transaction->commit(); - } catch (ErrorException $e) { - $transaction->rollBack(); - throw $e; + $session->setIp(Yii::$app->request->userIP); + $session->last_refreshed_at = time(); + if (!$session->save()) { + throw new ThisShouldNotHappenException('Cannot update session info'); } + $transaction->commit(); + return $result; } @@ -126,15 +120,22 @@ class Component extends YiiUserComponent { * @return Token распаршенный токен * @throws VerificationException если один из Claims не пройдёт проверку */ - public function parseToken(string $jwtString) : Token { - $hostInfo = Yii::$app->request->hostInfo; + public function parseToken(string $jwtString): Token { + $token = &self::$parsedTokensCache[$jwtString]; + if ($token === null) { + $hostInfo = Yii::$app->request->hostInfo; - $jwt = new Jwt(); - $token = $jwt->deserialize($jwtString); - $context = new VerificationContext(EncryptionFactory::create($this->getAlgorithm())); - $context->setAudience($hostInfo); - $context->setIssuer($hostInfo); - $jwt->verify($token, $context); + $jwt = new Jwt(); + $notVerifiedToken = $jwt->deserialize($jwtString); + + $context = new VerificationContext(EncryptionFactory::create($this->getAlgorithm())); + $context->setAudience($hostInfo); + $context->setIssuer($hostInfo); + $context->setSubject(self::JWT_SUBJECT_PREFIX); + $jwt->verify($notVerifiedToken, $context); + + $token = $notVerifiedToken; + } return $token; } @@ -150,19 +151,23 @@ class Component extends YiiUserComponent { * * @return AccountSession|null */ - public function getActiveSession() { + public function getActiveSession(): ?AccountSession { if ($this->getIsGuest()) { return null; } $bearer = $this->getBearerToken(); + if ($bearer === null) { + return null; + } + try { $token = $this->parseToken($bearer); } catch (VerificationException $e) { return null; } - $sessionId = $token->getPayload()->findClaimByName(SessionIdClaim::NAME); + $sessionId = $token->getPayload()->findClaimByName(Claim\JwtId::NAME); if ($sessionId === null) { return null; } @@ -170,35 +175,38 @@ class Component extends YiiUserComponent { return AccountSession::findOne($sessionId->getValue()); } - public function terminateSessions(int $mode = self::TERMINATE_ALL | self::DO_NOT_TERMINATE_CURRENT_SESSION): void { - $identity = $this->getIdentity(); - $activeSession = ($mode & self::DO_NOT_TERMINATE_CURRENT_SESSION) ? $this->getActiveSession() : null; - if ($mode & self::TERMINATE_SITE_SESSIONS) { - foreach ($identity->sessions as $session) { - if ($activeSession === null || $activeSession->id !== $session->id) { + public function terminateSessions(Account $account, int $mode = 0): void { + $currentSession = null; + if ($mode & self::KEEP_CURRENT_SESSION) { + $currentSession = $this->getActiveSession(); + } + + if (!($mode & self::KEEP_SITE_SESSIONS)) { + foreach ($account->sessions as $session) { + if ($currentSession === null || $currentSession->id !== $session->id) { $session->delete(); } } } - if ($mode & self::TERMINATE_MINECRAFT_SESSIONS) { - foreach ($identity->minecraftAccessKeys as $minecraftAccessKey) { + if (!($mode & self::KEEP_MINECRAFT_SESSIONS)) { + foreach ($account->minecraftAccessKeys as $minecraftAccessKey) { $minecraftAccessKey->delete(); } } } - public function getAlgorithm() : AlgorithmInterface { + public function getAlgorithm(): AlgorithmInterface { return new Hs256($this->secret); } - protected function serializeToken(Token $token) : string { + protected function serializeToken(Token $token): string { return (new Jwt())->serialize($token, EncryptionFactory::create($this->getAlgorithm())); } - protected function createToken(IdentityInterface $identity) : Token { + protected function createToken(Account $account): Token { $token = new Token(); - foreach($this->getClaims($identity) as $claim) { + foreach($this->getClaims($account) as $claim) { $token->addClaim($claim); } @@ -206,25 +214,23 @@ class Component extends YiiUserComponent { } /** - * @param IdentityInterface $identity + * @param Account $account * @return Claim\AbstractClaim[] */ - protected function getClaims(IdentityInterface $identity) { + protected function getClaims(Account $account): array { $currentTime = new DateTime(); $hostInfo = Yii::$app->request->hostInfo; return [ + new ScopesClaim([R::ACCOUNTS_WEB_USER]), new Claim\Audience($hostInfo), new Claim\Issuer($hostInfo), new Claim\IssuedAt($currentTime), new Claim\Expiration($currentTime->add(new DateInterval($this->expirationTimeout))), - new Claim\JwtId($identity->getId()), + new Claim\Subject(self::JWT_SUBJECT_PREFIX . $account->id), ]; } - /** - * @return ?string - */ private function getBearerToken() { $authHeader = Yii::$app->request->getHeaders()->get('Authorization'); if ($authHeader === null || !preg_match('/^Bearer\s+(.*?)$/', $authHeader, $matches)) { diff --git a/api/components/ApiUser/Identity.php b/api/components/User/Identity.php similarity index 60% rename from api/components/ApiUser/Identity.php rename to api/components/User/Identity.php index 953b5a7..89a2286 100644 --- a/api/components/ApiUser/Identity.php +++ b/api/components/User/Identity.php @@ -1,20 +1,15 @@ oauth->getAuthServer()->getAccessTokenStorage()->get($token); + $model = Yii::$app->oauth->getAccessTokenStorage()->get($token); if ($model === null) { throw new UnauthorizedHttpException('Incorrect token'); - } elseif ($model->isExpired()) { + } + + if ($model->isExpired()) { throw new UnauthorizedHttpException('Token expired'); } return new static($model); } - private function __construct(AccessTokenEntity $accessToken) { - $this->_accessToken = $accessToken; - } - - public function getAccount(): Account { + public function getAccount(): ?Account { return $this->getSession()->account; } - public function getClient(): OauthClient { - return $this->getSession()->client; - } - - public function getSession(): OauthSession { - return OauthSession::findOne($this->_accessToken->getSessionId()); - } - - public function getAccessToken(): AccessTokenEntity { - return $this->_accessToken; - } - /** - * Этот метод используется для получения токена, к которому привязаны права. - * У нас права привязываются к токенам, так что возвращаем именно его id. - * @inheritdoc + * @return string[] */ + public function getAssignedPermissions(): array { + return array_keys($this->_accessToken->getScopes()); + } + public function getId(): string { return $this->_accessToken->getId(); } @@ -79,4 +73,12 @@ class Identity implements IdentityInterface { throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth'); } + private function __construct(AccessTokenEntity $accessToken) { + $this->_accessToken = $accessToken; + } + + private function getSession(): OauthSession { + return OauthSession::findOne($this->_accessToken->getSessionId()); + } + } diff --git a/api/components/User/IdentityInterface.php b/api/components/User/IdentityInterface.php new file mode 100644 index 0000000..723dde1 --- /dev/null +++ b/api/components/User/IdentityInterface.php @@ -0,0 +1,32 @@ + $verifier) { + if (!$verifier instanceof SubjectVerifier) { + continue; + } + + $verifiers[$i] = new SubjectPrefixVerifier($context->getSubject()); + break; + } + + return $verifiers; + } + +} diff --git a/api/components/User/JwtIdentity.php b/api/components/User/JwtIdentity.php new file mode 100644 index 0000000..78a8491 --- /dev/null +++ b/api/components/User/JwtIdentity.php @@ -0,0 +1,94 @@ +user; + try { + $token = $component->parseToken($rawToken); + } catch (ExpiredException $e) { + throw new UnauthorizedHttpException('Token expired'); + } catch (Exception $e) { + Yii::error($e); + throw new UnauthorizedHttpException('Incorrect token'); + } + + return new self($rawToken, $token); + } + + public function getAccount(): ?Account { + /** @var Subject $subject */ + $subject = $this->token->getPayload()->findClaimByName(Subject::NAME); + if ($subject === null) { + return null; + } + + $value = $subject->getValue(); + if (!StringHelper::startsWith($value, Component::JWT_SUBJECT_PREFIX)) { + Yii::warning('Unknown jwt subject: ' . $value); + return null; + } + + $accountId = (int)mb_substr($value, mb_strlen(Component::JWT_SUBJECT_PREFIX)); + $account = Account::findOne($accountId); + if ($account === null) { + return null; + } + + return $account; + } + + public function getAssignedPermissions(): array { + /** @var Subject $scopesClaim */ + $scopesClaim = $this->token->getPayload()->findClaimByName(ScopesClaim::NAME); + if ($scopesClaim === null) { + return []; + } + + return explode(',', $scopesClaim->getValue()); + } + + public function getId(): string { + return $this->rawToken; + } + + public function getAuthKey() { + throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth'); + } + + public function validateAuthKey($authKey) { + throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth'); + } + + public static function findIdentity($id) { + throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth'); + } + + private function __construct(string $rawToken, Token $token) { + $this->rawToken = $rawToken; + $this->token = $token; + } + +} diff --git a/api/components/User/LoginResult.php b/api/components/User/LoginResult.php deleted file mode 100644 index 791873e..0000000 --- a/api/components/User/LoginResult.php +++ /dev/null @@ -1,67 +0,0 @@ -identity = $identity; - $this->jwt = $jwt; - $this->session = $session; - } - - public function getIdentity() : IdentityInterface { - return $this->identity; - } - - public function getJwt() : string { - return $this->jwt; - } - - /** - * @return AccountSession|null - */ - public function getSession() { - return $this->session; - } - - public function getAsResponse() { - /** @var Component $component */ - $component = Yii::$app->user; - - $now = new DateTime(); - $expiresIn = (clone $now)->add(new DateInterval($component->expirationTimeout)); - - $response = [ - 'access_token' => $this->getJwt(), - 'expires_in' => $expiresIn->getTimestamp() - $now->getTimestamp(), - ]; - - $session = $this->getSession(); - if ($session !== null) { - $response['refresh_token'] = $session->refresh_token; - } - - return $response; - } - -} diff --git a/api/components/User/RenewResult.php b/api/components/User/RenewResult.php deleted file mode 100644 index 1e1933f..0000000 --- a/api/components/User/RenewResult.php +++ /dev/null @@ -1,47 +0,0 @@ -identity = $identity; - $this->jwt = $jwt; - } - - public function getIdentity() : IdentityInterface { - return $this->identity; - } - - public function getJwt() : string { - return $this->jwt; - } - - public function getAsResponse() { - /** @var Component $component */ - $component = Yii::$app->user; - - $now = new DateTime(); - $expiresIn = (clone $now)->add(new DateInterval($component->expirationTimeout)); - - return [ - 'access_token' => $this->getJwt(), - 'expires_in' => $expiresIn->getTimestamp() - $now->getTimestamp(), - ]; - } - -} diff --git a/api/components/User/ScopesClaim.php b/api/components/User/ScopesClaim.php new file mode 100644 index 0000000..743c87f --- /dev/null +++ b/api/components/User/ScopesClaim.php @@ -0,0 +1,30 @@ +subjectPrefix = $subjectPrefix; + } + + public function verify(Token $token): void { + /** @var Subject $subjectClaim */ + $subjectClaim = $token->getPayload()->findClaimByName(Subject::NAME); + $subject = ($subjectClaim === null) ? null : $subjectClaim->getValue(); + + if (!StringHelper::startsWith($subject, $this->subjectPrefix)) { + throw new InvalidSubjectException; + } + } + +} diff --git a/api/config/config.php b/api/config/config.php index d288abc..8969ef5 100644 --- a/api/config/config.php +++ b/api/config/config.php @@ -15,9 +15,6 @@ return [ 'class' => api\components\User\Component::class, 'secret' => getenv('JWT_USER_SECRET'), ], - 'apiUser' => [ - 'class' => api\components\ApiUser\Component::class, - ], 'log' => [ 'traceLevel' => YII_DEBUG ? 3 : 0, 'targets' => [ @@ -85,14 +82,9 @@ return [ 'class' => api\modules\authserver\Module::class, 'host' => $params['authserverHost'], ], - 'session' => [ - 'class' => api\modules\session\Module::class, - ], - 'mojang' => [ - 'class' => api\modules\mojang\Module::class, - ], - 'internal' => [ - 'class' => api\modules\internal\Module::class, - ], + 'session' => api\modules\session\Module::class, + 'mojang' => api\modules\mojang\Module::class, + 'internal' => api\modules\internal\Module::class, + 'accounts' => api\modules\accounts\Module::class, ], ]; diff --git a/api/config/routes.php b/api/config/routes.php index 26f747f..563913d 100644 --- a/api/config/routes.php +++ b/api/config/routes.php @@ -3,15 +3,16 @@ * @var array $params */ return [ - '/accounts/change-email/initialize' => 'accounts/change-email-initialize', - '/accounts/change-email/submit-new-email' => 'accounts/change-email-submit-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/' => 'oauth/', + 'GET /v1/accounts/' => 'accounts/default/get', + 'GET /v1/accounts//two-factor-auth' => 'accounts/default/get-two-factor-auth-credentials', + 'POST /v1/accounts//two-factor-auth' => 'accounts/default/enable-two-factor-auth', + 'DELETE /v1/accounts//two-factor-auth' => 'accounts/default/disable-two-factor-auth', + 'POST /v1/accounts//ban' => 'accounts/default/ban', + 'DELETE /v1/accounts//ban' => 'accounts/default/pardon', + '/v1/accounts//' => 'accounts/default/', + '/account/v1/info' => 'identity-info/index', '/minecraft/session/join' => 'session/session/join', diff --git a/api/controllers/AccountsController.php b/api/controllers/AccountsController.php deleted file mode 100644 index 36d5675..0000000 --- a/api/controllers/AccountsController.php +++ /dev/null @@ -1,201 +0,0 @@ - [ - 'class' => AccessControl::class, - 'rules' => [ - [ - 'actions' => ['current', 'accept-rules'], - 'allow' => true, - 'roles' => ['@'], - ], - [ - 'class' => ActiveUserRule::class, - 'actions' => [ - 'change-password', - 'change-username', - 'change-email-initialize', - 'change-email-submit-new-email', - 'change-email-confirm-new-email', - 'change-lang', - ], - ], - ], - ], - ]); - } - - public function verbs() { - return [ - 'current' => ['GET'], - 'change-password' => ['POST'], - 'change-username' => ['POST'], - 'change-email-initialize' => ['POST'], - 'change-email-submit-new-email' => ['POST'], - 'change-email-confirm-new-email' => ['POST'], - 'change-lang' => ['POST'], - 'accept-rules' => ['POST'], - ]; - } - - public function actionCurrent() { - $account = Yii::$app->user->identity; - - return [ - 'id' => $account->id, - 'uuid' => $account->uuid, - 'username' => $account->username, - 'email' => $account->email, - 'lang' => $account->lang, - 'isActive' => $account->status === Account::STATUS_ACTIVE, - 'passwordChangedAt' => $account->password_changed_at, - 'hasMojangUsernameCollision' => $account->hasMojangUsernameCollision(), - 'shouldAcceptRules' => !$account->isAgreedWithActualRules(), - 'isOtpEnabled' => (bool)$account->is_otp_enabled, - ]; - } - - public function actionChangePassword() { - $account = Yii::$app->user->identity; - $model = new ChangePasswordForm($account); - $model->load(Yii::$app->request->post()); - if (!$model->changePassword()) { - return [ - 'success' => false, - 'errors' => $model->getFirstErrors(), - ]; - } - - return [ - 'success' => true, - ]; - } - - public function actionChangeUsername() { - $account = Yii::$app->user->identity; - $model = new ChangeUsernameForm($account); - $model->load(Yii::$app->request->post()); - if (!$model->change()) { - return [ - 'success' => false, - 'errors' => $model->getFirstErrors(), - ]; - } - - return [ - 'success' => true, - ]; - } - - public function actionChangeEmailInitialize() { - $account = Yii::$app->user->identity; - $model = new InitStateForm($account); - $model->load(Yii::$app->request->post()); - if (!$model->sendCurrentEmailConfirmation()) { - $data = [ - 'success' => false, - 'errors' => $model->getFirstErrors(), - ]; - - if (ArrayHelper::getValue($data['errors'], 'email') === E::RECENTLY_SENT_MESSAGE) { - $emailActivation = $model->getEmailActivation(); - $data['data'] = [ - 'canRepeatIn' => $emailActivation->canRepeatIn(), - 'repeatFrequency' => $emailActivation->repeatTimeout, - ]; - } - - return $data; - } - - return [ - 'success' => true, - ]; - } - - public function actionChangeEmailSubmitNewEmail() { - $account = Yii::$app->user->identity; - $model = new NewEmailForm($account); - $model->load(Yii::$app->request->post()); - if (!$model->sendNewEmailConfirmation()) { - return [ - 'success' => false, - 'errors' => $model->getFirstErrors(), - ]; - } - - return [ - 'success' => true, - ]; - } - - public function actionChangeEmailConfirmNewEmail() { - $account = Yii::$app->user->identity; - $model = new ConfirmNewEmailForm($account); - $model->load(Yii::$app->request->post()); - if (!$model->changeEmail()) { - return [ - 'success' => false, - 'errors' => $model->getFirstErrors(), - ]; - } - - return [ - 'success' => true, - 'data' => [ - 'email' => $account->email, - ], - ]; - } - - public function actionChangeLang() { - $account = Yii::$app->user->identity; - $model = new ChangeLanguageForm($account); - $model->load(Yii::$app->request->post()); - if (!$model->applyLanguage()) { - return [ - 'success' => false, - 'errors' => $model->getFirstErrors(), - ]; - } - - return [ - 'success' => true, - ]; - } - - public function actionAcceptRules() { - $account = Yii::$app->user->identity; - $model = new AcceptRulesForm($account); - $model->load(Yii::$app->request->post()); - if (!$model->agreeWithLatestRules()) { - return [ - 'success' => false, - 'errors' => $model->getFirstErrors(), - ]; - } - - return [ - 'success' => true, - ]; - } - -} diff --git a/api/controllers/ApiController.php b/api/controllers/ApiController.php deleted file mode 100644 index 81a0455..0000000 --- a/api/controllers/ApiController.php +++ /dev/null @@ -1,31 +0,0 @@ - HttpBearerAuth::class, - 'user' => Yii::$app->apiUser, - ]; - - // xml нам не понадобится - unset($parentBehaviors['contentNegotiator']['formats']['application/xml']); - // rate limiter здесь не применяется - unset($parentBehaviors['rateLimiter']); - - return $parentBehaviors; - } - -} diff --git a/api/controllers/AuthenticationController.php b/api/controllers/AuthenticationController.php index 0046f7c..27f1995 100644 --- a/api/controllers/AuthenticationController.php +++ b/api/controllers/AuthenticationController.php @@ -14,7 +14,7 @@ use yii\helpers\ArrayHelper; class AuthenticationController extends Controller { - public function behaviors() { + public function behaviors(): array { return ArrayHelper::merge(parent::behaviors(), [ 'authenticator' => [ 'only' => ['logout'], @@ -139,9 +139,12 @@ class AuthenticationController extends Controller { ]; } + $response = $result->getAsResponse(); + unset($response['refresh_token']); + return array_merge([ 'success' => true, - ], $result->getAsResponse()); + ], $response); } } diff --git a/api/controllers/Controller.php b/api/controllers/Controller.php index a4a981e..626f247 100644 --- a/api/controllers/Controller.php +++ b/api/controllers/Controller.php @@ -12,7 +12,7 @@ use yii\filters\auth\HttpBearerAuth; */ class Controller extends \yii\rest\Controller { - public function behaviors() { + public function behaviors(): array { $parentBehaviors = parent::behaviors(); // Добавляем авторизатор для входа по jwt токенам $parentBehaviors['authenticator'] = [ diff --git a/api/controllers/FeedbackController.php b/api/controllers/FeedbackController.php index 7f50ded..259ff16 100644 --- a/api/controllers/FeedbackController.php +++ b/api/controllers/FeedbackController.php @@ -7,7 +7,7 @@ use yii\helpers\ArrayHelper; class FeedbackController extends Controller { - public function behaviors() { + public function behaviors(): array { return ArrayHelper::merge(parent::behaviors(), [ 'authenticator' => [ 'optional' => ['index'], diff --git a/api/controllers/IdentityInfoController.php b/api/controllers/IdentityInfoController.php index 8dbf3ab..bbc73e9 100644 --- a/api/controllers/IdentityInfoController.php +++ b/api/controllers/IdentityInfoController.php @@ -1,14 +1,15 @@ [ 'class' => AccessControl::class, @@ -16,29 +17,22 @@ class IdentityInfoController extends ApiController { [ 'actions' => ['index'], 'allow' => true, - 'roles' => [S::ACCOUNT_INFO], + 'roles' => [P::OBTAIN_ACCOUNT_INFO], + 'roleParams' => function() { + /** @noinspection NullPointerExceptionInspection */ + return [ + 'accountId' => Yii::$app->user->getIdentity()->getAccount()->id, + ]; + }, ], ], ], ]); } - public function actionIndex() { - $account = Yii::$app->apiUser->getIdentity()->getAccount(); - $response = [ - 'id' => $account->id, - 'uuid' => $account->uuid, - 'username' => $account->username, - 'registeredAt' => $account->created_at, - 'profileLink' => $account->getProfileLink(), - 'preferredLanguage' => $account->lang, - ]; - - if (Yii::$app->apiUser->can(S::ACCOUNT_EMAIL)) { - $response['email'] = $account->email; - } - - return $response; + public function actionIndex(): array { + /** @noinspection NullPointerExceptionInspection */ + return (new OauthAccountInfo(Yii::$app->user->getIdentity()->getAccount()))->info(); } } diff --git a/api/controllers/OauthController.php b/api/controllers/OauthController.php index a4bc8ee..bac828d 100644 --- a/api/controllers/OauthController.php +++ b/api/controllers/OauthController.php @@ -1,8 +1,8 @@ ['complete'], 'rules' => [ [ - 'class' => ActiveUserRule::class, + 'allow' => true, 'actions' => ['complete'], + 'roles' => [P::COMPLETE_OAUTH_FLOW], + 'roleParams' => function() { + return [ + 'accountId' => Yii::$app->user->identity->getAccount()->id, + ]; + }, ], ], ], diff --git a/api/controllers/OptionsController.php b/api/controllers/OptionsController.php index c96762a..e5e65e8 100644 --- a/api/controllers/OptionsController.php +++ b/api/controllers/OptionsController.php @@ -7,7 +7,7 @@ use yii\helpers\ArrayHelper; class OptionsController extends Controller { - public function behaviors() { + public function behaviors(): array { return ArrayHelper::merge(parent::behaviors(), [ 'authenticator' => [ 'except' => ['index'], diff --git a/api/controllers/SignupController.php b/api/controllers/SignupController.php index b1d29c0..04e1f86 100644 --- a/api/controllers/SignupController.php +++ b/api/controllers/SignupController.php @@ -11,7 +11,7 @@ use yii\helpers\ArrayHelper; class SignupController extends Controller { - public function behaviors() { + public function behaviors(): array { return ArrayHelper::merge(parent::behaviors(), [ 'authenticator' => [ 'except' => ['index', 'repeat-message', 'confirm'], diff --git a/api/controllers/TwoFactorAuthController.php b/api/controllers/TwoFactorAuthController.php deleted file mode 100644 index 6b3b925..0000000 --- a/api/controllers/TwoFactorAuthController.php +++ /dev/null @@ -1,75 +0,0 @@ - [ - '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, - ]; - } - -} diff --git a/api/exceptions/ThisShouldNotHaveHappenedException.php b/api/exceptions/ThisShouldNotHappenException.php similarity index 89% rename from api/exceptions/ThisShouldNotHaveHappenedException.php rename to api/exceptions/ThisShouldNotHappenException.php index 21502f9..699992b 100644 --- a/api/exceptions/ThisShouldNotHaveHappenedException.php +++ b/api/exceptions/ThisShouldNotHappenException.php @@ -6,6 +6,6 @@ namespace api\exceptions; * но теоретически может произойти. Целью является отлавливание таких участков и доработка логики, * если такие ситуации всё же будут иметь место случаться. */ -class ThisShouldNotHaveHappenedException extends Exception { +class ThisShouldNotHappenException extends Exception { } diff --git a/api/filters/ActiveUserRule.php b/api/filters/ActiveUserRule.php deleted file mode 100644 index e50150e..0000000 --- a/api/filters/ActiveUserRule.php +++ /dev/null @@ -1,28 +0,0 @@ -getIdentity(); - - return $account->status === Account::STATUS_ACTIVE - && $account->isAgreedWithActualRules(); - } - - protected function getIdentity() { - return Yii::$app->getUser()->getIdentity(); - } - -} diff --git a/api/filters/RequestFilter.php b/api/filters/RequestFilter.php deleted file mode 100644 index c722118..0000000 --- a/api/filters/RequestFilter.php +++ /dev/null @@ -1,68 +0,0 @@ -getRequest()->getUserIP(); - if ($this->checkIp($ip) || $this->checkByHost($ip)) { - return true; - } - - Yii::warning( - 'Access to ' . $action->controller->id . '::' . $action->id . - ' is denied due to IP address restriction. The requesting IP address is ' . $ip, - __METHOD__ - ); - - throw new ForbiddenHttpException('You are not allowed to access this page.'); - } - - protected function checkIp(string $ip) : bool { - foreach ($this->allowedIPs as $filter) { - if ($filter === '*' - || $filter === $ip - || (($pos = strpos($filter, '*')) !== false && !strncmp($ip, $filter, $pos)) - ) { - return true; - } - } - - return false; - } - - protected function checkByHost(string $ip) : bool { - foreach ($this->allowedHosts as $hostname) { - $filter = gethostbyname($hostname); - if ($filter === $ip) { - return true; - } - } - - return false; - } - -} diff --git a/api/models/AccountIdentity.php b/api/models/AccountIdentity.php deleted file mode 100644 index 4122fca..0000000 --- a/api/models/AccountIdentity.php +++ /dev/null @@ -1,67 +0,0 @@ -user; - try { - $token = $component->parseToken($token); - } catch (ExpiredException $e) { - throw new UnauthorizedHttpException('Token expired'); - } catch (\Exception $e) { - throw new UnauthorizedHttpException('Incorrect token'); - } - - // Если исключение выше не случилось, то значит всё оке - /** @var JwtId $jti */ - $jti = $token->getPayload()->findClaimByName(JwtId::NAME); - $account = static::findOne($jti->getValue()); - if ($account === null) { - throw new UnauthorizedHttpException('Invalid token'); - } - - return $account; - } - - /** - * @inheritdoc - */ - public function getId() { - return $this->id; - } - - /** - * @inheritdoc - */ - public static function findIdentity($id) { - return static::findOne($id); - } - - /** - * @inheritdoc - */ - public function getAuthKey() { - throw new NotSupportedException('This method used for cookie auth, except we using JWT tokens'); - } - - /** - * @inheritdoc - */ - public function validateAuthKey($authKey) { - throw new NotSupportedException('This method used for cookie auth, except we using JWT tokens'); - } - -} diff --git a/api/models/OauthAccountInfo.php b/api/models/OauthAccountInfo.php new file mode 100644 index 0000000..55a6f3b --- /dev/null +++ b/api/models/OauthAccountInfo.php @@ -0,0 +1,28 @@ +model = new AccountInfo($account); + } + + public function info(): array { + $response = $this->model->info(); + + $response['profileLink'] = $response['elyProfileLink']; + unset($response['elyProfileLink']); + $response['preferredLanguage'] = $response['lang']; + unset($response['lang']); + + return $response; + } + +} diff --git a/api/models/OauthProcess.php b/api/models/OauthProcess.php index 02a5ddb..40c601e 100644 --- a/api/models/OauthProcess.php +++ b/api/models/OauthProcess.php @@ -83,7 +83,7 @@ class OauthProcess { try { $grant = $this->getAuthorizationCodeGrant(); $authParams = $grant->checkAuthorizeParams(); - $account = Yii::$app->user->identity; + $account = Yii::$app->user->identity->getAccount(); /** @var \common\models\OauthClient $clientModel */ $clientModel = OauthClient::findOne($authParams->getClient()->getId()); diff --git a/api/models/authentication/ConfirmEmailForm.php b/api/models/authentication/ConfirmEmailForm.php index 72344da..ac5f78f 100644 --- a/api/models/authentication/ConfirmEmailForm.php +++ b/api/models/authentication/ConfirmEmailForm.php @@ -1,9 +1,8 @@ EmailActivation::TYPE_REGISTRATION_EMAIL_CONFIRMATION], ]; } /** - * @return \api\components\User\LoginResult|bool + * @return \api\components\User\AuthenticationResult|bool * @throws ErrorException */ public function confirm() { @@ -48,7 +47,7 @@ class ConfirmEmailForm extends ApiForm { $transaction->commit(); - return Yii::$app->user->login(new AccountIdentity($account->attributes), true); + return Yii::$app->user->createJwtAuthenticationToken($account, true); } } diff --git a/api/models/authentication/ForgotPasswordForm.php b/api/models/authentication/ForgotPasswordForm.php index b90ba64..5ff3b7f 100644 --- a/api/models/authentication/ForgotPasswordForm.php +++ b/api/models/authentication/ForgotPasswordForm.php @@ -101,7 +101,7 @@ class ForgotPasswordForm extends ApiForm { return true; } - public function getLogin() { + public function getLogin(): string { return $this->login; } diff --git a/api/models/authentication/LoginForm.php b/api/models/authentication/LoginForm.php index 133ccf4..1e51eed 100644 --- a/api/models/authentication/LoginForm.php +++ b/api/models/authentication/LoginForm.php @@ -1,7 +1,6 @@ login; } /** - * @return \api\components\User\LoginResult|bool + * @return \api\components\User\AuthenticationResult|bool */ public function login() { if (!$this->validate()) { @@ -104,11 +100,7 @@ class LoginForm extends ApiForm { $account->save(); } - return Yii::$app->user->login($account, $this->rememberMe); - } - - protected function getAccountClassName() { - return AccountIdentity::class; + return Yii::$app->user->createJwtAuthenticationToken($account, $this->rememberMe); } } diff --git a/api/models/authentication/RecoverPasswordForm.php b/api/models/authentication/RecoverPasswordForm.php index 661fbea..ce21498 100644 --- a/api/models/authentication/RecoverPasswordForm.php +++ b/api/models/authentication/RecoverPasswordForm.php @@ -1,7 +1,6 @@ commit(); - return Yii::$app->user->login(new AccountIdentity($account->attributes), false); + return Yii::$app->user->createJwtAuthenticationToken($account, false); } } diff --git a/api/models/authentication/RefreshTokenForm.php b/api/models/authentication/RefreshTokenForm.php index 71bc702..ece51da 100644 --- a/api/models/authentication/RefreshTokenForm.php +++ b/api/models/authentication/RefreshTokenForm.php @@ -32,7 +32,7 @@ class RefreshTokenForm extends ApiForm { } /** - * @return \api\components\User\RenewResult|bool + * @return \api\components\User\AuthenticationResult|bool */ public function renew() { if (!$this->validate()) { @@ -42,7 +42,7 @@ class RefreshTokenForm extends ApiForm { /** @var \api\components\User\Component $component */ $component = Yii::$app->user; - return $component->renew($this->getSession()); + return $component->renewJwtAuthenticationToken($this->getSession()); } /** diff --git a/api/models/base/BaseAccountForm.php b/api/models/base/BaseAccountForm.php new file mode 100644 index 0000000..6ebd055 --- /dev/null +++ b/api/models/base/BaseAccountForm.php @@ -0,0 +1,22 @@ +account = $account; + } + + public function getAccount(): Account { + return $this->account; + } + +} diff --git a/api/models/profile/AcceptRulesForm.php b/api/models/profile/AcceptRulesForm.php deleted file mode 100644 index 05785f0..0000000 --- a/api/models/profile/AcceptRulesForm.php +++ /dev/null @@ -1,35 +0,0 @@ -account = $account; - parent::__construct($config); - } - - public function agreeWithLatestRules() : bool { - $account = $this->getAccount(); - $account->rules_agreement_version = LATEST_RULES_VERSION; - if (!$account->save()) { - throw new ErrorException('Cannot set user rules version'); - } - - return true; - } - - public function getAccount() : Account { - return $this->account; - } - -} diff --git a/api/models/profile/ChangeLanguageForm.php b/api/models/profile/ChangeLanguageForm.php deleted file mode 100644 index fa44d26..0000000 --- a/api/models/profile/ChangeLanguageForm.php +++ /dev/null @@ -1,45 +0,0 @@ -account = $account; - parent::__construct($config); - } - - public function rules() { - return [ - ['lang', 'required'], - ['lang', LanguageValidator::class], - ]; - } - - public function applyLanguage() : bool { - if (!$this->validate()) { - return false; - } - - $account = $this->getAccount(); - $account->lang = $this->lang; - if (!$account->save()) { - throw new ErrorException('Cannot change user language'); - } - - return true; - } - - public function getAccount() : Account { - return $this->account; - } - -} diff --git a/api/models/profile/TwoFactorAuthForm.php b/api/models/profile/TwoFactorAuthForm.php index a388d8a..e69de29 100644 --- a/api/models/profile/TwoFactorAuthForm.php +++ b/api/models/profile/TwoFactorAuthForm.php @@ -1,181 +0,0 @@ -account = $account; - parent::__construct($config); - } - - public function rules(): array { - $bothScenarios = [self::SCENARIO_ACTIVATE, self::SCENARIO_DISABLE]; - return [ - ['timestamp', 'integer', 'on' => [self::SCENARIO_ACTIVATE]], - ['account', 'validateOtpDisabled', 'on' => self::SCENARIO_ACTIVATE], - ['account', 'validateOtpEnabled', 'on' => self::SCENARIO_DISABLE], - ['totp', 'required', 'message' => E::TOTP_REQUIRED, 'on' => $bothScenarios], - ['totp', TotpValidator::class, 'on' => $bothScenarios, - 'account' => $this->account, - 'timestamp' => function() { - return $this->timestamp; - }, - ], - ['password', PasswordRequiredValidator::class, 'account' => $this->account, 'on' => $bothScenarios], - ]; - } - - public function getCredentials(): array { - if (empty($this->account->otp_secret)) { - $this->setOtpSecret(); - } - - $provisioningUri = $this->getTotp()->getProvisioningUri(); - - return [ - 'qr' => 'data:image/svg+xml,' . trim($this->drawQrCode($provisioningUri)), - 'uri' => $provisioningUri, - 'secret' => $this->account->otp_secret, - ]; - } - - public function activate(): bool { - if ($this->scenario !== self::SCENARIO_ACTIVATE || !$this->validate()) { - return false; - } - - $transaction = Yii::$app->db->beginTransaction(); - - $account = $this->account; - $account->is_otp_enabled = true; - if (!$account->save()) { - throw new ErrorException('Cannot enable otp for account'); - } - - Yii::$app->user->terminateSessions(); - - $transaction->commit(); - - return true; - } - - public function disable(): bool { - if ($this->scenario !== self::SCENARIO_DISABLE || !$this->validate()) { - return false; - } - - $account = $this->account; - $account->is_otp_enabled = false; - $account->otp_secret = null; - if (!$account->save()) { - throw new ErrorException('Cannot disable otp for account'); - } - - return true; - } - - public function validateOtpDisabled($attribute) { - if ($this->account->is_otp_enabled) { - $this->addError($attribute, E::OTP_ALREADY_ENABLED); - } - } - - public function validateOtpEnabled($attribute) { - if (!$this->account->is_otp_enabled) { - $this->addError($attribute, E::OTP_NOT_ENABLED); - } - } - - public function getAccount(): Account { - return $this->account; - } - - /** - * @return TOTP - */ - public function getTotp(): TOTP { - $totp = TOTP::create($this->account->otp_secret); - $totp->setLabel($this->account->email); - $totp->setIssuer('Ely.by'); - - return $totp; - } - - public function drawQrCode(string $content): string { - $content = $this->forceMinimalQrContentLength($content); - - $renderer = new Svg(); - $renderer->setMargin(0); - $renderer->setForegroundColor(new Rgb(32, 126, 92)); - $renderer->addDecorator(new ElyDecorator()); - - $writer = new Writer($renderer); - - return $writer->writeString($content, Encoder::DEFAULT_BYTE_MODE_ECODING, ErrorCorrectionLevel::H); - } - - /** - * otp_secret кодируется в Base32, т.к. после кодирования в результурющей строке нет символов, - * которые можно перепутать (1 и l, O и 0, и т.д.). Отрицательной стороной является то, что итоговая - * строка составляет 160% от исходной. Поэтому, генерируя исходный приватный ключ, мы должны обеспечить - * ему такую длину, чтобы 160% его длины было равно запрошенному значению - * - * @param int $length - * @throws ErrorException - */ - protected function setOtpSecret(int $length = 24): void { - $randomBytesLength = ceil($length / 1.6); - $randomBase32 = trim(Encoding::base32EncodeUpper(random_bytes($randomBytesLength)), '='); - $this->account->otp_secret = substr($randomBase32, 0, $length); - if (!$this->account->save()) { - throw new ErrorException('Cannot set account otp_secret'); - } - } - - /** - * В используемой либе для рендеринга QR кода нет возможности указать QR code version. - * http://www.qrcode.com/en/about/version.html - * По какой-то причине 7 и 8 версии не читаются вовсе, с логотипом или без. - * Поэтому нужно иначально привести строку к длинне 9 версии (91), добавляя к концу - * строки необходимое количество символов "#". Этот символ используется, т.к. нашим - * контентом является ссылка и чтобы не вводить лишние параметры мы помечаем добавочную - * часть как хеш часть и все программы для чтения QR кодов продолжают свою работу. - * - * @param string $content - * @return string - */ - private function forceMinimalQrContentLength(string $content): string { - return str_pad($content, 91, '#'); - } - -} diff --git a/api/modules/accounts/Module.php b/api/modules/accounts/Module.php new file mode 100644 index 0000000..579ca1a --- /dev/null +++ b/api/modules/accounts/Module.php @@ -0,0 +1,10 @@ +getFormClassName(); + /** @var AccountActionForm $model */ + $model = new $className($this->findAccount($id)); + $model->load($this->getRequestData()); + if (!$model->performAction()) { + return $this->formatFailedResult($model); + } + + return $this->formatSuccessResult($model); + } + + abstract protected function getFormClassName(): string; + + public function getRequestData(): array { + return Yii::$app->request->post(); + } + + public function getSuccessResultData(AccountActionForm $model): array { + return []; + } + + public function getFailedResultData(AccountActionForm $model): array { + return []; + } + + private function formatFailedResult(AccountActionForm $model): array { + $response = [ + 'success' => false, + 'errors' => $model->getFirstErrors(), + ]; + + $data = $this->getFailedResultData($model); + if (!empty($data)) { + $response['data'] = $data; + } + + return $response; + } + + private function formatSuccessResult(AccountActionForm $model): array { + $response = [ + 'success' => true, + ]; + $data = $this->getSuccessResultData($model); + if (!empty($data)) { + $response['data'] = $data; + } + + return $response; + } + + private function findAccount(int $id): Account { + $account = Account::findOne($id); + if ($account === null) { + throw new NotFoundHttpException(); + } + + return $account; + } + +} diff --git a/api/modules/accounts/actions/ChangeEmailAction.php b/api/modules/accounts/actions/ChangeEmailAction.php new file mode 100644 index 0000000..241c7ed --- /dev/null +++ b/api/modules/accounts/actions/ChangeEmailAction.php @@ -0,0 +1,23 @@ + $model->getAccount()->email, + ]; + } + +} diff --git a/api/modules/accounts/actions/ChangeLanguageAction.php b/api/modules/accounts/actions/ChangeLanguageAction.php new file mode 100644 index 0000000..32ae522 --- /dev/null +++ b/api/modules/accounts/actions/ChangeLanguageAction.php @@ -0,0 +1,12 @@ +getFirstError('email'); + if ($emailError !== E::RECENTLY_SENT_MESSAGE) { + return []; + } + + $emailActivation = $model->getEmailActivation(); + + return [ + 'canRepeatIn' => $emailActivation->canRepeatIn(), + 'repeatFrequency' => $emailActivation->repeatTimeout, + ]; + } + +} diff --git a/api/modules/accounts/actions/EnableTwoFactorAuthAction.php b/api/modules/accounts/actions/EnableTwoFactorAuthAction.php new file mode 100644 index 0000000..da56821 --- /dev/null +++ b/api/modules/accounts/actions/EnableTwoFactorAuthAction.php @@ -0,0 +1,12 @@ + Yii::$app->request->get('id'), + ]; + }; + + return ArrayHelper::merge(Controller::behaviors(), [ + 'access' => [ + 'class' => AccessControl::class, + 'rules' => [ + [ + 'allow' => true, + 'actions' => ['get'], + 'roles' => [P::OBTAIN_ACCOUNT_INFO], + 'roleParams' => function() use ($paramsCallback) { + return array_merge($paramsCallback(), [ + 'optionalRules' => true, + ]); + }, + ], + [ + 'allow' => true, + 'actions' => ['username'], + 'roles' => [P::CHANGE_ACCOUNT_USERNAME], + 'roleParams' => $paramsCallback, + ], + [ + 'allow' => true, + 'actions' => ['password'], + 'roles' => [P::CHANGE_ACCOUNT_PASSWORD], + 'roleParams' => $paramsCallback, + ], + [ + 'allow' => true, + 'actions' => ['language'], + 'roles' => [P::CHANGE_ACCOUNT_LANGUAGE], + 'roleParams' => $paramsCallback, + ], + [ + 'allow' => true, + 'actions' => [ + 'email', + 'email-verification', + 'new-email-verification', + ], + 'roles' => [P::CHANGE_ACCOUNT_EMAIL], + 'roleParams' => $paramsCallback, + ], + [ + 'allow' => true, + 'actions' => ['rules'], + 'roles' => [P::ACCEPT_NEW_PROJECT_RULES], + 'roleParams' => function() use ($paramsCallback) { + return array_merge($paramsCallback(), [ + 'optionalRules' => true, + ]); + }, + ], + [ + 'allow' => true, + 'actions' => [ + 'get-two-factor-auth-credentials', + 'enable-two-factor-auth', + 'disable-two-factor-auth', + ], + 'roles' => [P::MANAGE_TWO_FACTOR_AUTH], + 'roleParams' => $paramsCallback, + ], + [ + 'allow' => true, + 'actions' => [ + 'ban', + 'pardon', + ], + 'roles' => [P::BLOCK_ACCOUNT], + 'roleParams' => $paramsCallback, + ], + ], + ], + ]); + } + + public function actions(): array { + return [ + 'username' => actions\ChangeUsernameAction::class, + 'password' => actions\ChangePasswordAction::class, + 'language' => actions\ChangeLanguageAction::class, + 'email' => actions\ChangeEmailAction::class, + 'email-verification' => actions\EmailVerificationAction::class, + 'new-email-verification' => actions\NewEmailVerificationAction::class, + 'rules' => actions\AcceptRulesAction::class, + 'enable-two-factor-auth' => actions\EnableTwoFactorAuthAction::class, + 'disable-two-factor-auth' => actions\DisableTwoFactorAuthAction::class, + 'ban' => actions\BanAccountAction::class, + 'pardon' => actions\PardonAccountAction::class, + ]; + } + + public function actionGet(int $id): array { + return (new AccountInfo($this->findAccount($id)))->info(); + } + + public function actionGetTwoFactorAuthCredentials(int $id): array { + return (new TwoFactorAuthInfo($this->findAccount($id)))->getCredentials(); + } + + private function findAccount(int $id): Account { + $account = Account::findOne($id); + if ($account === null) { + throw new NotFoundHttpException(); + } + + return $account; + } + +} diff --git a/api/modules/accounts/models/AcceptRulesForm.php b/api/modules/accounts/models/AcceptRulesForm.php new file mode 100644 index 0000000..83e207a --- /dev/null +++ b/api/modules/accounts/models/AcceptRulesForm.php @@ -0,0 +1,19 @@ +getAccount(); + $account->rules_agreement_version = LATEST_RULES_VERSION; + if (!$account->save()) { + throw new ErrorException('Cannot set user rules version'); + } + + return true; + } + +} diff --git a/api/modules/accounts/models/AccountActionForm.php b/api/modules/accounts/models/AccountActionForm.php new file mode 100644 index 0000000..5fbb63d --- /dev/null +++ b/api/modules/accounts/models/AccountActionForm.php @@ -0,0 +1,10 @@ +user = Instance::ensure($this->user, User::class); + } + + public function info(): array { + $account = $this->getAccount(); + + $response = [ + 'id' => $account->id, + 'uuid' => $account->uuid, + 'username' => $account->username, + 'isOtpEnabled' => (bool)$account->is_otp_enabled, + 'registeredAt' => $account->created_at, + 'lang' => $account->lang, + 'elyProfileLink' => $account->getProfileLink(), + ]; + + $authManagerParams = [ + 'accountId' => $account->id, + 'optionalRules' => true, + ]; + + if ($this->user->can(P::OBTAIN_ACCOUNT_EMAIL, $authManagerParams)) { + $response['email'] = $account->email; + } + + if ($this->user->can(P::OBTAIN_EXTENDED_ACCOUNT_INFO, $authManagerParams)) { + $response['isActive'] = $account->status === Account::STATUS_ACTIVE; + $response['passwordChangedAt'] = $account->password_changed_at; + $response['hasMojangUsernameCollision'] = $account->hasMojangUsernameCollision(); + $response['shouldAcceptRules'] = !$account->isAgreedWithActualRules(); + } + + return $response; + } + +} diff --git a/api/modules/internal/models/BanForm.php b/api/modules/accounts/models/BanAccountForm.php similarity index 79% rename from api/modules/internal/models/BanForm.php rename to api/modules/accounts/models/BanAccountForm.php index f30ee70..35f0d30 100644 --- a/api/modules/internal/models/BanForm.php +++ b/api/modules/accounts/models/BanAccountForm.php @@ -1,7 +1,6 @@ self::DURATION_FOREVER], @@ -44,24 +38,20 @@ class BanForm extends ApiForm { ]; } - public function getAccount(): Account { - return $this->account; - } - public function validateAccountActivity() { - if ($this->account->status === Account::STATUS_BANNED) { + if ($this->getAccount()->status === Account::STATUS_BANNED) { $this->addError('account', E::ACCOUNT_ALREADY_BANNED); } } - public function ban(): bool { + public function performAction(): bool { if (!$this->validate()) { return false; } $transaction = Yii::$app->db->beginTransaction(); - $account = $this->account; + $account = $this->getAccount(); $account->status = Account::STATUS_BANNED; if (!$account->save()) { throw new ErrorException('Cannot ban account'); @@ -76,7 +66,7 @@ class BanForm extends ApiForm { public function createTask(): void { $model = new AccountBanned(); - $model->accountId = $this->account->id; + $model->accountId = $this->getAccount()->id; $model->duration = $this->duration; $model->message = $this->message; @@ -87,9 +77,4 @@ class BanForm extends ApiForm { Amqp::sendToEventsExchange('accounts.account-banned', $message); } - public function __construct(Account $account, array $config = []) { - $this->account = $account; - parent::__construct($config); - } - } diff --git a/api/models/profile/ChangeEmail/ConfirmNewEmailForm.php b/api/modules/accounts/models/ChangeEmailForm.php similarity index 64% rename from api/models/profile/ChangeEmail/ConfirmNewEmailForm.php rename to api/modules/accounts/models/ChangeEmailForm.php index 2a8bfc9..d08c848 100644 --- a/api/models/profile/ChangeEmail/ConfirmNewEmailForm.php +++ b/api/modules/accounts/models/ChangeEmailForm.php @@ -1,39 +1,25 @@ EmailActivation::TYPE_NEW_EMAIL_CONFIRMATION], ]; } - /** - * @return Account - */ - public function getAccount(): Account { - return $this->account; - } - - public function changeEmail(): bool { + public function performAction(): bool { if (!$this->validate()) { return false; } @@ -58,13 +44,7 @@ class ConfirmNewEmailForm extends ApiForm { return true; } - /** - * @param integer $accountId - * @param string $newEmail - * @param string $oldEmail - * @throws \PhpAmqpLib\Exception\AMQPExceptionInterface - */ - public function createTask($accountId, $newEmail, $oldEmail) { + public function createTask(int $accountId, string $newEmail, string $oldEmail): void { $model = new EmailChanged; $model->accountId = $accountId; $model->oldEmail = $oldEmail; @@ -77,9 +57,4 @@ class ConfirmNewEmailForm extends ApiForm { Amqp::sendToEventsExchange('accounts.email-changed', $message); } - public function __construct(Account $account, array $config = []) { - $this->account = $account; - parent::__construct($config); - } - } diff --git a/api/modules/accounts/models/ChangeLanguageForm.php b/api/modules/accounts/models/ChangeLanguageForm.php new file mode 100644 index 0000000..e1061de --- /dev/null +++ b/api/modules/accounts/models/ChangeLanguageForm.php @@ -0,0 +1,32 @@ +validate()) { + return false; + } + + $account = $this->getAccount(); + $account->lang = $this->lang; + if (!$account->save()) { + throw new ThisShouldNotHappenException('Cannot change user language'); + } + + return true; + } + +} diff --git a/api/models/profile/ChangePasswordForm.php b/api/modules/accounts/models/ChangePasswordForm.php similarity index 63% rename from api/models/profile/ChangePasswordForm.php rename to api/modules/accounts/models/ChangePasswordForm.php index 77e3c45..48824ff 100644 --- a/api/models/profile/ChangePasswordForm.php +++ b/api/modules/accounts/models/ChangePasswordForm.php @@ -1,16 +1,15 @@ _account = $account; - parent::__construct($config); - } - /** * @inheritdoc */ - public function rules() { + public function rules(): array { return ArrayHelper::merge(parent::rules(), [ ['newPassword', 'required', 'message' => E::NEW_PASSWORD_REQUIRED], ['newRePassword', 'required', 'message' => E::NEW_RE_PASSWORD_REQUIRED], ['newPassword', PasswordValidator::class], ['newRePassword', 'validatePasswordAndRePasswordMatch'], ['logoutAll', 'boolean'], - ['password', PasswordRequiredValidator::class, 'account' => $this->_account], + ['password', PasswordRequiredValidator::class, 'account' => $this->getAccount(), 'when' => function() { + return !$this->hasErrors(); + }], ]); } - public function validatePasswordAndRePasswordMatch($attribute) { + public function validatePasswordAndRePasswordMatch($attribute): void { if (!$this->hasErrors($attribute)) { if ($this->newPassword !== $this->newRePassword) { $this->addError($attribute, E::NEW_RE_PASSWORD_DOES_NOT_MATCH); @@ -52,25 +43,22 @@ class ChangePasswordForm extends ApiForm { } } - /** - * @return bool - * @throws ErrorException - */ - public function changePassword() : bool { + public function performAction(): bool { if (!$this->validate()) { return false; } $transaction = Yii::$app->db->beginTransaction(); - $account = $this->_account; + + $account = $this->getAccount(); $account->setPassword($this->newPassword); if ($this->logoutAll) { - Yii::$app->user->terminateSessions(); + Yii::$app->user->terminateSessions($account, Component::KEEP_CURRENT_SESSION); } if (!$account->save()) { - throw new ErrorException('Cannot save user model'); + throw new ThisShouldNotHappenException('Cannot save user model'); } $transaction->commit(); @@ -78,8 +66,4 @@ class ChangePasswordForm extends ApiForm { return true; } - protected function getAccount() : Account { - return $this->_account; - } - } diff --git a/api/models/profile/ChangeUsernameForm.php b/api/modules/accounts/models/ChangeUsernameForm.php similarity index 50% rename from api/models/profile/ChangeUsernameForm.php rename to api/modules/accounts/models/ChangeUsernameForm.php index f09921c..193712e 100644 --- a/api/models/profile/ChangeUsernameForm.php +++ b/api/modules/accounts/models/ChangeUsernameForm.php @@ -1,76 +1,60 @@ account = $account; - } - public function rules(): array { return [ ['username', UsernameValidator::class, 'accountCallback' => function() { - return $this->account->id; + return $this->getAccount()->id; }], - ['password', PasswordRequiredValidator::class], + ['password', PasswordRequiredValidator::class, 'account' => $this->getAccount()], ]; } - public function change(): bool { + public function performAction(): bool { if (!$this->validate()) { return false; } - $account = $this->account; + $account = $this->getAccount(); if ($this->username === $account->username) { return true; } $transaction = Yii::$app->db->beginTransaction(); - try { - $oldNickname = $account->username; - $account->username = $this->username; - if (!$account->save()) { - throw new ErrorException('Cannot save account model with new username'); - } - $usernamesHistory = new UsernameHistory(); - $usernamesHistory->account_id = $account->id; - $usernamesHistory->username = $account->username; - if (!$usernamesHistory->save()) { - throw new ErrorException('Cannot save username history record'); - } - - $this->createEventTask($account->id, $account->username, $oldNickname); - - $transaction->commit(); - } catch (Exception $e) { - $transaction->rollBack(); - throw $e; + $oldNickname = $account->username; + $account->username = $this->username; + if (!$account->save()) { + throw new ThisShouldNotHappenException('Cannot save account model with new username'); } + $usernamesHistory = new UsernameHistory(); + $usernamesHistory->account_id = $account->id; + $usernamesHistory->username = $account->username; + if (!$usernamesHistory->save()) { + throw new ErrorException('Cannot save username history record'); + } + + $this->createEventTask($account->id, $account->username, $oldNickname); + + $transaction->commit(); + return true; } @@ -80,10 +64,11 @@ class ChangeUsernameForm extends ApiForm { * @param integer $accountId * @param string $newNickname * @param string $oldNickname - * @throws \PhpAmqpLib\Exception\AMQPExceptionInterface + * + * @throws \PhpAmqpLib\Exception\AMQPExceptionInterface|\yii\base\Exception */ - public function createEventTask($accountId, $newNickname, $oldNickname) { - $model = new UsernameChanged; + public function createEventTask($accountId, $newNickname, $oldNickname): void { + $model = new UsernameChanged(); $model->accountId = $accountId; $model->oldUsername = $oldNickname; $model->newUsername = $newNickname; diff --git a/api/modules/accounts/models/DisableTwoFactorAuthForm.php b/api/modules/accounts/models/DisableTwoFactorAuthForm.php new file mode 100644 index 0000000..7a98c44 --- /dev/null +++ b/api/modules/accounts/models/DisableTwoFactorAuthForm.php @@ -0,0 +1,45 @@ + E::TOTP_REQUIRED], + ['totp', TotpValidator::class, 'account' => $this->getAccount()], + ['password', PasswordRequiredValidator::class, 'account' => $this->getAccount()], + ]; + } + + public function performAction(): bool { + if (!$this->validate()) { + return false; + } + + $account = $this->getAccount(); + $account->is_otp_enabled = false; + $account->otp_secret = null; + if (!$account->save()) { + throw new ThisShouldNotHappenException('Cannot disable otp for account'); + } + + return true; + } + + public function validateOtpEnabled($attribute): void { + if (!$this->getAccount()->is_otp_enabled) { + $this->addError($attribute, E::OTP_NOT_ENABLED); + } + } + +} diff --git a/api/modules/accounts/models/EnableTwoFactorAuthForm.php b/api/modules/accounts/models/EnableTwoFactorAuthForm.php new file mode 100644 index 0000000..0216c19 --- /dev/null +++ b/api/modules/accounts/models/EnableTwoFactorAuthForm.php @@ -0,0 +1,52 @@ + E::TOTP_REQUIRED], + ['totp', TotpValidator::class, 'account' => $this->getAccount()], + ['password', PasswordRequiredValidator::class, 'account' => $this->getAccount()], + ]; + } + + public function performAction(): bool { + if (!$this->validate()) { + return false; + } + + $transaction = Yii::$app->db->beginTransaction(); + + $account = $this->getAccount(); + $account->is_otp_enabled = true; + if (!$account->save()) { + throw new ThisShouldNotHappenException('Cannot enable otp for account'); + } + + Yii::$app->user->terminateSessions($account, Component::KEEP_CURRENT_SESSION); + + $transaction->commit(); + + return true; + } + + public function validateOtpDisabled($attribute): void { + if ($this->getAccount()->is_otp_enabled) { + $this->addError($attribute, E::OTP_ALREADY_ENABLED); + } + } + +} diff --git a/api/modules/internal/models/PardonForm.php b/api/modules/accounts/models/PardonAccountForm.php similarity index 66% rename from api/modules/internal/models/PardonForm.php rename to api/modules/accounts/models/PardonAccountForm.php index 60c10e5..2c337d7 100644 --- a/api/modules/internal/models/PardonForm.php +++ b/api/modules/accounts/models/PardonAccountForm.php @@ -1,7 +1,6 @@ account; - } - public function validateAccountBanned(): void { - if ($this->account->status !== Account::STATUS_BANNED) { + if ($this->getAccount()->status !== Account::STATUS_BANNED) { $this->addError('account', E::ACCOUNT_NOT_BANNED); } } - public function pardon(): bool { + public function performAction(): bool { if (!$this->validate()) { return false; } $transaction = Yii::$app->db->beginTransaction(); - $account = $this->account; + $account = $this->getAccount(); $account->status = Account::STATUS_ACTIVE; if (!$account->save()) { throw new ErrorException('Cannot pardon account'); @@ -55,7 +45,7 @@ class PardonForm extends ApiForm { public function createTask(): void { $model = new AccountPardoned(); - $model->accountId = $this->account->id; + $model->accountId = $this->getAccount()->id; $message = Amqp::getInstance()->prepareMessage($model, [ 'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT, @@ -64,9 +54,4 @@ class PardonForm extends ApiForm { Amqp::sendToEventsExchange('accounts.account-pardoned', $message); } - public function __construct(Account $account, array $config = []) { - $this->account = $account; - parent::__construct($config); - } - } diff --git a/api/models/profile/ChangeEmail/InitStateForm.php b/api/modules/accounts/models/SendEmailVerificationForm.php similarity index 59% rename from api/models/profile/ChangeEmail/InitStateForm.php rename to api/modules/accounts/models/SendEmailVerificationForm.php index 217415f..47f596f 100644 --- a/api/models/profile/ChangeEmail/InitStateForm.php +++ b/api/modules/accounts/models/SendEmailVerificationForm.php @@ -1,43 +1,31 @@ account = $account; - $this->email = $account->email; - parent::__construct($config); - } - - public function getAccount() : Account { - return $this->account; - } - - public function rules() { + public function rules(): array { return [ - ['email', 'validateFrequency'], - ['password', PasswordRequiredValidator::class, 'account' => $this->account], + ['email', 'validateFrequency', 'skipOnEmpty' => false], + ['password', PasswordRequiredValidator::class, 'account' => $this->getAccount()], ]; } - public function validateFrequency($attribute) { + public function validateFrequency($attribute): void { if (!$this->hasErrors()) { $emailConfirmation = $this->getEmailActivation(); if ($emailConfirmation !== null && !$emailConfirmation->canRepeat()) { @@ -46,46 +34,35 @@ class InitStateForm extends ApiForm { } } - public function sendCurrentEmailConfirmation() : bool { + public function performAction(): bool { if (!$this->validate()) { return false; } $transaction = Yii::$app->db->beginTransaction(); - try { - $this->removeOldCode(); - $activation = $this->createCode(); - EmailHelper::changeEmailConfirmCurrent($activation); + $this->removeOldCode(); + $activation = $this->createCode(); - $transaction->commit(); - } catch (Exception $e) { - $transaction->rollBack(); - throw $e; - } + EmailHelper::changeEmailConfirmCurrent($activation); + + $transaction->commit(); return true; } - /** - * @return CurrentEmailConfirmation - * @throws ErrorException - */ - public function createCode() : CurrentEmailConfirmation { + public function createCode(): CurrentEmailConfirmation { $account = $this->getAccount(); $emailActivation = new CurrentEmailConfirmation(); $emailActivation->account_id = $account->id; if (!$emailActivation->save()) { - throw new ErrorException('Cannot save email activation model'); + throw new ThisShouldNotHappenException('Cannot save email activation model'); } return $emailActivation; } - /** - * Удаляет старый ключ активации, если он существует - */ - public function removeOldCode() { + public function removeOldCode(): void { $emailActivation = $this->getEmailActivation(); if ($emailActivation === null) { return; @@ -99,10 +76,8 @@ class InitStateForm extends ApiForm { * Метод предназначен для проверки, не слишком ли часто отправляются письма о смене E-mail. * Проверяем тип подтверждения нового E-mail, поскольку при переходе на этот этап, активация предыдущего * шага удаляется. - * @return EmailActivation|null - * @throws ErrorException */ - public function getEmailActivation() { + public function getEmailActivation(): ?EmailActivation { return $this->getAccount() ->getEmailActivations() ->andWhere([ diff --git a/api/models/profile/ChangeEmail/NewEmailForm.php b/api/modules/accounts/models/SendNewEmailVerificationForm.php similarity index 61% rename from api/models/profile/ChangeEmail/NewEmailForm.php rename to api/modules/accounts/models/SendNewEmailVerificationForm.php index ba67dfc..1c2823e 100644 --- a/api/models/profile/ChangeEmail/NewEmailForm.php +++ b/api/modules/accounts/models/SendNewEmailVerificationForm.php @@ -1,39 +1,28 @@ EmailActivation::TYPE_CURRENT_EMAIL_CONFIRMATION], ['email', EmailValidator::class], ]; } - public function getAccount(): Account { - return $this->account; - } - - public function sendNewEmailConfirmation(): bool { + public function performAction(): bool { if (!$this->validate()) { return false; } @@ -53,24 +42,15 @@ class NewEmailForm extends ApiForm { return true; } - /** - * @return NewEmailConfirmation - * @throws ErrorException - */ - public function createCode() { + public function createCode(): NewEmailConfirmation { $emailActivation = new NewEmailConfirmation(); $emailActivation->account_id = $this->getAccount()->id; $emailActivation->newEmail = $this->email; if (!$emailActivation->save()) { - throw new ErrorException('Cannot save email activation model'); + throw new ThisShouldNotHappenException('Cannot save email activation model'); } return $emailActivation; } - public function __construct(Account $account, array $config = []) { - $this->account = $account; - parent::__construct($config); - } - } diff --git a/api/modules/accounts/models/TotpHelper.php b/api/modules/accounts/models/TotpHelper.php new file mode 100644 index 0000000..0f3111b --- /dev/null +++ b/api/modules/accounts/models/TotpHelper.php @@ -0,0 +1,20 @@ +getAccount(); + $totp = TOTP::create($account->otp_secret); + $totp->setLabel($account->email); + $totp->setIssuer('Ely.by'); + + return $totp; + } + + abstract public function getAccount(): Account; + +} diff --git a/api/modules/accounts/models/TwoFactorAuthInfo.php b/api/modules/accounts/models/TwoFactorAuthInfo.php new file mode 100644 index 0000000..b3b0986 --- /dev/null +++ b/api/modules/accounts/models/TwoFactorAuthInfo.php @@ -0,0 +1,80 @@ +getAccount()->otp_secret)) { + $this->setOtpSecret(); + } + + $provisioningUri = $this->getTotp()->getProvisioningUri(); + + return [ + 'qr' => 'data:image/svg+xml,' . trim($this->drawQrCode($provisioningUri)), + 'uri' => $provisioningUri, + 'secret' => $this->getAccount()->otp_secret, + ]; + } + + private function drawQrCode(string $content): string { + $content = $this->forceMinimalQrContentLength($content); + + $renderer = new Svg(); + $renderer->setForegroundColor(new Rgb(32, 126, 92)); + $renderer->setMargin(0); + $renderer->addDecorator(new ElyDecorator()); + + $writer = new Writer($renderer); + + return $writer->writeString($content, Encoder::DEFAULT_BYTE_MODE_ECODING, ErrorCorrectionLevel::H); + } + + /** + * otp_secret кодируется в Base32, т.к. после кодирования в результурющей строке нет символов, + * которые можно перепутать (1 и l, O и 0, и т.д.). Отрицательной стороной является то, что итоговая + * строка составляет 160% от исходной. Поэтому, генерируя исходный приватный ключ, мы должны обеспечить + * ему такую длину, чтобы 160% его длины было равно запрошенному значению + * + * @param int $length + * + * @throws ThisShouldNotHappenException + */ + private function setOtpSecret(int $length = 24): void { + $account = $this->getAccount(); + $randomBytesLength = ceil($length / 1.6); + $randomBase32 = trim(Base32::encodeUpper(random_bytes($randomBytesLength)), '='); + $account->otp_secret = substr($randomBase32, 0, $length); + if (!$account->save()) { + throw new ThisShouldNotHappenException('Cannot set account otp_secret'); + } + } + + /** + * В используемой либе для рендеринга QR кода нет возможности указать QR code version. + * http://www.qrcode.com/en/about/version.html + * По какой-то причине 7 и 8 версии не читаются вовсе, с логотипом или без. + * Поэтому нужно иначально привести строку к длинне 9 версии (91), добавляя к концу + * строки необходимое количество символов "#". Этот символ используется, т.к. нашим + * контентом является ссылка и чтобы не вводить лишние параметры мы помечаем добавочную + * часть как хеш часть и все программы для чтения QR кодов продолжают свою работу. + * + * @param string $content + * @return string + */ + private function forceMinimalQrContentLength(string $content): string { + return str_pad($content, 91, '#'); + } + +} diff --git a/api/modules/authserver/controllers/AuthenticationController.php b/api/modules/authserver/controllers/AuthenticationController.php index 650928d..0f514a1 100644 --- a/api/modules/authserver/controllers/AuthenticationController.php +++ b/api/modules/authserver/controllers/AuthenticationController.php @@ -7,7 +7,7 @@ use Yii; class AuthenticationController extends Controller { - public function behaviors() { + public function behaviors(): array { $behaviors = parent::behaviors(); unset($behaviors['authenticator']); diff --git a/api/modules/internal/controllers/AccountsController.php b/api/modules/internal/controllers/AccountsController.php index 5b734c4..92bf23c 100644 --- a/api/modules/internal/controllers/AccountsController.php +++ b/api/modules/internal/controllers/AccountsController.php @@ -1,58 +1,42 @@ [ - 'user' => Yii::$app->apiUser, - ], 'access' => [ 'class' => AccessControl::class, 'rules' => [ - [ - 'actions' => ['ban'], - 'allow' => true, - 'roles' => [S::ACCOUNT_BLOCK], - ], [ 'actions' => ['info'], 'allow' => true, - 'roles' => [S::INTERNAL_ACCOUNT_INFO], + 'roles' => [P::OBTAIN_EXTENDED_ACCOUNT_INFO], + 'roleParams' => function() { + return [ + 'accountId' => 0, + ]; + }, ], ], ], ]); } - public function verbs() { + public function verbs(): array { return [ - 'ban' => ['POST', 'DELETE'], 'info' => ['GET'], ]; } - public function actionBan(int $accountId) { - $account = $this->findAccount($accountId); - if (Yii::$app->request->isPost) { - return $this->banAccount($account); - } else { - return $this->pardonAccount($account); - } - } - public function actionInfo(int $id = null, string $username = null, string $uuid = null) { if ($id !== null) { $account = Account::findOne($id); @@ -76,43 +60,4 @@ class AccountsController extends Controller { ]; } - private function banAccount(Account $account) { - $model = new BanForm($account); - $model->load(Yii::$app->request->post()); - if (!$model->ban()) { - return [ - 'success' => false, - 'errors' => $model->getFirstErrors(), - ]; - } - - return [ - 'success' => true, - ]; - } - - private function pardonAccount(Account $account) { - $model = new PardonForm($account); - $model->load(Yii::$app->request->post()); - if (!$model->pardon()) { - return [ - 'success' => false, - 'errors' => $model->getFirstErrors(), - ]; - } - - return [ - 'success' => true, - ]; - } - - private function findAccount(int $accountId): Account { - $account = Account::findOne($accountId); - if ($account === null) { - throw new NotFoundHttpException(); - } - - return $account; - } - } diff --git a/api/modules/mojang/controllers/ApiController.php b/api/modules/mojang/controllers/ApiController.php index f564127..680fb7f 100644 --- a/api/modules/mojang/controllers/ApiController.php +++ b/api/modules/mojang/controllers/ApiController.php @@ -10,7 +10,7 @@ use yii\web\Response; class ApiController extends Controller { - public function behaviors() { + public function behaviors(): array { $behaviors = parent::behaviors(); unset($behaviors['authenticator']); diff --git a/api/modules/session/controllers/SessionController.php b/api/modules/session/controllers/SessionController.php index 046b7ee..4c860ba 100644 --- a/api/modules/session/controllers/SessionController.php +++ b/api/modules/session/controllers/SessionController.php @@ -1,7 +1,7 @@ protocol->validate()) { throw new IllegalArgumentException(); } diff --git a/api/modules/session/models/JoinForm.php b/api/modules/session/models/JoinForm.php index f5e1973..22478f6 100644 --- a/api/modules/session/models/JoinForm.php +++ b/api/modules/session/models/JoinForm.php @@ -7,10 +7,10 @@ use api\modules\session\models\protocols\JoinInterface; use api\modules\session\Module as Session; use api\modules\session\validators\RequiredValidator; use common\helpers\StringHelper; -use common\models\OauthScope as S; -use common\validators\UuidValidator; +use common\rbac\Permissions as P; use common\models\Account; use common\models\MinecraftAccessKey; +use Ramsey\Uuid\Uuid; use Yii; use yii\base\ErrorException; use yii\base\Model; @@ -84,16 +84,7 @@ class JoinForm extends Model { return; } - if ($attribute === 'selectedProfile' && !StringHelper::isUuid($this->selectedProfile)) { - // Это нормально. Там может быть ник игрока, если это Legacy авторизация - return; - } - - $validator = new UuidValidator(); - $validator->allowNil = false; - $validator->validateAttribute($this, $attribute); - - if ($this->hasErrors($attribute)) { + if ($this->$attribute === Uuid::NIL) { throw new IllegalArgumentException(); } } @@ -105,9 +96,17 @@ class JoinForm extends Model { $accessToken = $this->accessToken; /** @var MinecraftAccessKey|null $accessModel */ $accessModel = MinecraftAccessKey::findOne($accessToken); - if ($accessModel === null) { + if ($accessModel !== null) { + /** @var MinecraftAccessKey|\api\components\OAuth2\Entities\AccessTokenEntity $accessModel */ + if ($accessModel->isExpired()) { + Session::error("User with access_token = '{$accessToken}' failed join by expired access_token."); + throw new ForbiddenOperationException('Expired access_token.'); + } + + $account = $accessModel->account; + } else { try { - $identity = Yii::$app->apiUser->loginByAccessToken($accessToken); + $identity = Yii::$app->user->loginByAccessToken($accessToken); } catch (UnauthorizedHttpException $e) { $identity = null; } @@ -117,21 +116,12 @@ class JoinForm extends Model { throw new ForbiddenOperationException('Invalid access_token.'); } - if (!Yii::$app->apiUser->can(S::MINECRAFT_SERVER_SESSION)) { + if (!Yii::$app->user->can(P::MINECRAFT_SERVER_SESSION)) { Session::error("User with access_token = '{$accessToken}' doesn't have enough scopes to make join."); throw new ForbiddenOperationException('The token does not have required scope.'); } - $accessModel = $identity->getAccessToken(); $account = $identity->getAccount(); - } else { - $account = $accessModel->account; - } - - /** @var MinecraftAccessKey|\api\components\OAuth2\Entities\AccessTokenEntity $accessModel */ - if ($accessModel->isExpired()) { - Session::error("User with access_token = '{$accessToken}' failed join by expired access_token."); - throw new ForbiddenOperationException('Expired access_token.'); } $selectedProfile = $this->selectedProfile; @@ -142,7 +132,9 @@ class JoinForm extends Model { " but access_token issued to account with id = '{$account->uuid}'." ); throw new ForbiddenOperationException('Wrong selected_profile.'); - } elseif (!$isUuid && $account->username !== $selectedProfile) { + } + + if (!$isUuid && $account->username !== $selectedProfile) { Session::error( "User with access_token = '{$accessToken}' trying to join with identity = '{$selectedProfile}'," . " but access_token issued to account with username = '{$account->username}'." @@ -153,10 +145,7 @@ class JoinForm extends Model { $this->account = $account; } - /** - * @return Account|null - */ - protected function getAccount() { + protected function getAccount(): Account { return $this->account; } diff --git a/api/modules/session/models/SessionModel.php b/api/modules/session/models/SessionModel.php index da43391..c58c65e 100644 --- a/api/modules/session/models/SessionModel.php +++ b/api/modules/session/models/SessionModel.php @@ -17,24 +17,16 @@ class SessionModel { $this->serverId = $serverId; } - /** - * @param $username - * @param $serverId - * - * @return static|null - */ - public static function find($username, $serverId) { + public static function find(string $username, string $serverId): ?self { $key = static::buildKey($username, $serverId); $result = Yii::$app->redis->executeCommand('GET', [$key]); if (!$result) { - /** @noinspection PhpIncompatibleReturnTypeInspection шторм что-то сума сходит, когда видит static */ return null; } $data = json_decode($result, true); - $model = new static($data['username'], $data['serverId']); - return $model; + return new static($data['username'], $data['serverId']); } public function save() { @@ -51,15 +43,11 @@ class SessionModel { return Yii::$app->redis->executeCommand('DEL', [static::buildKey($this->username, $this->serverId)]); } - /** - * @return Account|null - * TODO: после перехода на PHP 7.1 установить тип как ?Account - */ - public function getAccount() { + public function getAccount(): ?Account { return Account::findOne(['username' => $this->username]); } - protected static function buildKey($username, $serverId) : string { + protected static function buildKey($username, $serverId): string { return md5('minecraft:join-server:' . mb_strtolower($username) . ':' . $serverId); } diff --git a/api/modules/session/models/protocols/BaseHasJoined.php b/api/modules/session/models/protocols/BaseHasJoined.php index b10ce49..2bce25e 100644 --- a/api/modules/session/models/protocols/BaseHasJoined.php +++ b/api/modules/session/models/protocols/BaseHasJoined.php @@ -1,31 +1,30 @@ username = $username; - $this->serverId = $serverId; + $this->username = trim($username); + $this->serverId = trim($serverId); } - public function getUsername() : string { + public function getUsername(): string { return $this->username; } - public function getServerId() : string { + public function getServerId(): string { return $this->serverId; } - public function validate() : bool { - $validator = new RequiredValidator(); + public function validate(): bool { + return !$this->isEmpty($this->username) && !$this->isEmpty($this->serverId); + } - return $validator->validate($this->username) - && $validator->validate($this->serverId); + private function isEmpty($value): bool { + return $value === null || $value === ''; } } diff --git a/api/modules/session/models/protocols/BaseJoin.php b/api/modules/session/models/protocols/BaseJoin.php index d025e83..c5eff04 100644 --- a/api/modules/session/models/protocols/BaseJoin.php +++ b/api/modules/session/models/protocols/BaseJoin.php @@ -3,12 +3,8 @@ namespace api\modules\session\models\protocols; abstract class BaseJoin implements JoinInterface { - abstract public function getAccessToken() : string; - - abstract public function getSelectedProfile() : string; - - abstract public function getServerId() : string; - - abstract public function validate() : bool; + protected function isEmpty($value): bool { + return $value === null || $value === ''; + } } diff --git a/api/modules/session/models/protocols/HasJoinedInterface.php b/api/modules/session/models/protocols/HasJoinedInterface.php index 96a051a..510e980 100644 --- a/api/modules/session/models/protocols/HasJoinedInterface.php +++ b/api/modules/session/models/protocols/HasJoinedInterface.php @@ -3,10 +3,10 @@ namespace api\modules\session\models\protocols; interface HasJoinedInterface { - public function getUsername() : string; + public function getUsername(): string; - public function getServerId() : string; + public function getServerId(): string; - public function validate() : bool; + public function validate(): bool; } diff --git a/api/modules/session/models/protocols/JoinInterface.php b/api/modules/session/models/protocols/JoinInterface.php index 10ba672..a638b1a 100644 --- a/api/modules/session/models/protocols/JoinInterface.php +++ b/api/modules/session/models/protocols/JoinInterface.php @@ -3,13 +3,12 @@ namespace api\modules\session\models\protocols; interface JoinInterface { - public function getAccessToken() : string; + public function getAccessToken(): string; - // TODO: после перехода на PHP 7.1 сменить тип на ?string и возвращать null, если параметр не передан - public function getSelectedProfile() : string; + public function getSelectedProfile(): string; - public function getServerId() : string; + public function getServerId(): string; - public function validate() : bool; + public function validate(): bool; } diff --git a/api/modules/session/models/protocols/LegacyJoin.php b/api/modules/session/models/protocols/LegacyJoin.php index dc56987..16ee65e 100644 --- a/api/modules/session/models/protocols/LegacyJoin.php +++ b/api/modules/session/models/protocols/LegacyJoin.php @@ -1,8 +1,6 @@ user = $user; - $this->sessionId = $sessionId; - $this->serverId = $serverId; + $this->user = trim($user); + $this->sessionId = trim($sessionId); + $this->serverId = trim($serverId); $this->parseSessionId($this->sessionId); } @@ -24,23 +22,19 @@ class LegacyJoin extends BaseJoin { return $this->accessToken; } - public function getSelectedProfile() : string { + public function getSelectedProfile(): string { return $this->uuid ?: $this->user; } - public function getServerId() : string { + public function getServerId(): string { return $this->serverId; } /** * @return bool */ - public function validate() : bool { - $validator = new RequiredValidator(); - - return $validator->validate($this->accessToken) - && $validator->validate($this->user) - && $validator->validate($this->serverId); + public function validate(): bool { + return !$this->isEmpty($this->accessToken) && !$this->isEmpty($this->user) && !$this->isEmpty($this->serverId); } /** @@ -50,7 +44,7 @@ class LegacyJoin extends BaseJoin { * Бьём по ':' для учёта авторизации в современных лаунчерах и входе на более старую * версию игры. Там sessionId передаётся как "token:{accessToken}:{uuid}", так что это нужно обработать */ - protected function parseSessionId(string $sessionId) { + private function parseSessionId(string $sessionId) { $parts = explode(':', $sessionId); if (count($parts) === 3) { $this->accessToken = $parts[1]; diff --git a/api/modules/session/models/protocols/ModernJoin.php b/api/modules/session/models/protocols/ModernJoin.php index f535ce5..6fd0942 100644 --- a/api/modules/session/models/protocols/ModernJoin.php +++ b/api/modules/session/models/protocols/ModernJoin.php @@ -1,8 +1,6 @@ accessToken = $accessToken; - $this->selectedProfile = $selectedProfile; - $this->serverId = $serverId; + $this->accessToken = trim($accessToken); + $this->selectedProfile = trim($selectedProfile); + $this->serverId = trim($serverId); } - public function getAccessToken() : string { + public function getAccessToken(): string { return $this->accessToken; } - public function getSelectedProfile() : string { + public function getSelectedProfile(): string { return $this->selectedProfile; } - public function getServerId() : string { + public function getServerId(): string { return $this->serverId; } - public function validate() : bool { - $validator = new RequiredValidator(); - - return $validator->validate($this->accessToken) - && $validator->validate($this->selectedProfile) - && $validator->validate($this->serverId); + public function validate(): bool { + return !$this->isEmpty($this->accessToken) && !$this->isEmpty($this->selectedProfile) && !$this->isEmpty($this->serverId); } } diff --git a/api/traits/AccountFinder.php b/api/traits/AccountFinder.php index 3bc1236..782ba57 100644 --- a/api/traits/AccountFinder.php +++ b/api/traits/AccountFinder.php @@ -7,29 +7,18 @@ trait AccountFinder { private $account; - public abstract function getLogin(); + public abstract function getLogin(): string; - /** - * @return Account|null - */ - public function getAccount() { + public function getAccount(): ?Account { if ($this->account === null) { - $className = $this->getAccountClassName(); - $this->account = $className::findOne([$this->getLoginAttribute() => $this->getLogin()]); + $this->account = Account::findOne([$this->getLoginAttribute() => $this->getLogin()]); } return $this->account; } - public function getLoginAttribute() { + public function getLoginAttribute(): string { return strpos($this->getLogin(), '@') ? 'email' : 'username'; } - /** - * @return Account|string - */ - protected function getAccountClassName() { - return Account::class; - } - } diff --git a/api/validators/PasswordRequiredValidator.php b/api/validators/PasswordRequiredValidator.php index af3161d..ce10ca6 100644 --- a/api/validators/PasswordRequiredValidator.php +++ b/api/validators/PasswordRequiredValidator.php @@ -3,7 +3,6 @@ namespace api\validators; use common\helpers\Error as E; use common\models\Account; -use Yii; use yii\base\InvalidConfigException; use yii\validators\Validator; @@ -21,10 +20,6 @@ class PasswordRequiredValidator extends Validator { 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); } diff --git a/autocompletion.php b/autocompletion.php index d01c0d8..e0dce4d 100644 --- a/autocompletion.php +++ b/autocompletion.php @@ -32,7 +32,6 @@ abstract class BaseApplication extends yii\base\Application { * Include only Web application related components here * * @property \api\components\User\Component $user User component. - * @property \api\components\ApiUser\Component $apiUser Api User component. * @property \api\components\ReCaptcha\Component $reCaptcha * * @method \api\components\User\Component getUser() diff --git a/common/components/Annotations/Reader.php b/common/components/Annotations/Reader.php deleted file mode 100644 index 3397fa4..0000000 --- a/common/components/Annotations/Reader.php +++ /dev/null @@ -1,18 +0,0 @@ -cache и как-то надобность в отдельном кэше отпала, так что пока забьём - * и оставим как заготовку на будущее - * - * @return \Minime\Annotations\Interfaces\ReaderInterface - */ - public static function createFromDefaults() { - return parent::createFromDefaults(); - //return new self(new \Minime\Annotations\Parser(), new RedisCache()); - } - -} diff --git a/common/components/Annotations/RedisCache.php b/common/components/Annotations/RedisCache.php deleted file mode 100644 index 4556347..0000000 --- a/common/components/Annotations/RedisCache.php +++ /dev/null @@ -1,65 +0,0 @@ -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'); - } - -} diff --git a/common/components/Redis/Key.php b/common/components/Redis/Key.php index fb43152..2f38b95 100644 --- a/common/components/Redis/Key.php +++ b/common/components/Redis/Key.php @@ -6,46 +6,7 @@ use Yii; class Key { - protected $key; - - /** - * @return Connection - */ - public function getRedis() { - return Yii::$app->redis; - } - - public function getKey() : string { - return $this->key; - } - - public function getValue() { - return $this->getRedis()->get($this->key); - } - - public function setValue($value) { - $this->getRedis()->set($this->key, $value); - return $this; - } - - public function delete() { - $this->getRedis()->del($this->key); - return $this; - } - - public function exists() : bool { - return (bool)$this->getRedis()->exists($this->key); - } - - public function expire(int $ttl) { - $this->getRedis()->expire($this->key, $ttl); - return $this; - } - - public function expireAt(int $unixTimestamp) { - $this->getRedis()->expireat($this->key, $unixTimestamp); - return $this; - } + private $key; public function __construct(...$key) { if (empty($key)) { @@ -55,7 +16,43 @@ class Key { $this->key = $this->buildKey($key); } - private function buildKey(array $parts) { + public function getRedis(): Connection { + return Yii::$app->redis; + } + + public function getKey(): string { + return $this->key; + } + + public function getValue() { + return $this->getRedis()->get($this->key); + } + + public function setValue($value): self { + $this->getRedis()->set($this->key, $value); + return $this; + } + + public function delete(): self { + $this->getRedis()->del([$this->getKey()]); + return $this; + } + + public function exists(): bool { + return (bool)$this->getRedis()->exists($this->key); + } + + public function expire(int $ttl): self { + $this->getRedis()->expire($this->key, $ttl); + return $this; + } + + public function expireAt(int $unixTimestamp): self { + $this->getRedis()->expireat($this->key, $unixTimestamp); + return $this; + } + + private function buildKey(array $parts): string { $keyParts = []; foreach($parts as $part) { $keyParts[] = str_replace('_', ':', $part); diff --git a/common/components/Redis/Set.php b/common/components/Redis/Set.php index fe8302a..debae64 100644 --- a/common/components/Redis/Set.php +++ b/common/components/Redis/Set.php @@ -6,34 +6,34 @@ use IteratorAggregate; class Set extends Key implements IteratorAggregate { - public function add($value) { - $this->getRedis()->sadd($this->key, $value); + public function add($value): self { + $this->getRedis()->sadd($this->getKey(), $value); return $this; } - public function remove($value) { - $this->getRedis()->srem($this->key, $value); + public function remove($value): self { + $this->getRedis()->srem($this->getKey(), $value); return $this; } - public function members() { - return $this->getRedis()->smembers($this->key); + public function members(): array { + return $this->getRedis()->smembers($this->getKey()); } - public function getValue() { + public function getValue(): array { return $this->members(); } - public function exists(string $value = null) : bool { + public function exists(string $value = null): bool { if ($value === null) { return parent::exists(); - } else { - return (bool)$this->getRedis()->sismember($this->key, $value); } + + return (bool)$this->getRedis()->sismember($this->getKey(), $value); } - public function diff(array $sets) { - return $this->getRedis()->sdiff([$this->key, implode(' ', $sets)]); + public function diff(array $sets): array { + return $this->getRedis()->sdiff([$this->getKey(), implode(' ', $sets)]); } /** diff --git a/common/config/config.php b/common/config/config.php index 6b35122..c4ba2cc 100644 --- a/common/config/config.php +++ b/common/config/config.php @@ -1,7 +1,7 @@ '1.1.18-dev', - 'vendorPath' => dirname(dirname(__DIR__)) . '/vendor', + 'vendorPath' => dirname(__DIR__, 2) . '/vendor', 'components' => [ 'cache' => [ 'class' => common\components\Redis\Cache::class, @@ -72,6 +72,11 @@ return [ 'oauth' => [ 'class' => api\components\OAuth2\Component::class, ], + 'authManager' => [ + 'class' => common\rbac\Manager::class, + 'itemFile' => '@common/rbac/.generated/items.php', + 'ruleFile' => '@common/rbac/.generated/rules.php', + ], ], 'container' => [ 'definitions' => [ diff --git a/common/models/Account.php b/common/models/Account.php index f8bb298..46eddba 100644 --- a/common/models/Account.php +++ b/common/models/Account.php @@ -51,27 +51,18 @@ class Account extends ActiveRecord { const PASS_HASH_STRATEGY_OLD_ELY = 0; const PASS_HASH_STRATEGY_YII2 = 1; - public static function tableName() { + public static function tableName(): string { return '{{%accounts}}'; } - public function behaviors() { + public function behaviors(): array { return [ TimestampBehavior::class, ]; } - /** - * Validates password - * - * @param string $password password to validate - * @param integer $passwordHashStrategy - * - * @return bool if password provided is valid for current user - * @throws InvalidConfigException - */ - public function validatePassword($password, $passwordHashStrategy = NULL) : bool { - if ($passwordHashStrategy === NULL) { + public function validatePassword(string $password, int $passwordHashStrategy = null): bool { + if ($passwordHashStrategy === null) { $passwordHashStrategy = $this->password_hash_strategy; } @@ -88,17 +79,13 @@ class Account extends ActiveRecord { } } - /** - * @param string $password - * @throws InvalidConfigException - */ - public function setPassword($password) { + public function setPassword(string $password): void { $this->password_hash_strategy = self::PASS_HASH_STRATEGY_YII2; $this->password_hash = Yii::$app->security->generatePasswordHash($password); $this->password_changed_at = time(); } - public function getEmailActivations() { + public function getEmailActivations(): ActiveQuery { return $this->hasMany(EmailActivation::class, ['account_id' => 'id']); } @@ -106,15 +93,15 @@ class Account extends ActiveRecord { return $this->hasMany(OauthSession::class, ['owner_id' => 'id'])->andWhere(['owner_type' => 'user']); } - public function getUsernameHistory() { + public function getUsernameHistory(): ActiveQuery { return $this->hasMany(UsernameHistory::class, ['account_id' => 'id']); } - public function getSessions() { + public function getSessions(): ActiveQuery { return $this->hasMany(AccountSession::class, ['account_id' => 'id']); } - public function getMinecraftAccessKeys() { + public function getMinecraftAccessKeys(): ActiveQuery { return $this->hasMany(MinecraftAccessKey::class, ['account_id' => 'id']); } @@ -123,7 +110,7 @@ class Account extends ActiveRecord { * * @return bool */ - public function hasMojangUsernameCollision() : bool { + public function hasMojangUsernameCollision(): bool { return MojangUsername::find() ->andWhere(['username' => $this->username]) ->exists(); @@ -136,7 +123,7 @@ class Account extends ActiveRecord { * * @return string */ - public function getProfileLink() : string { + public function getProfileLink(): string { return 'http://ely.by/u' . $this->id; } @@ -148,15 +135,15 @@ class Account extends ActiveRecord { * * @return bool */ - public function isAgreedWithActualRules() : bool { + public function isAgreedWithActualRules(): bool { return $this->rules_agreement_version === LATEST_RULES_VERSION; } - public function setRegistrationIp($ip) { + public function setRegistrationIp($ip): void { $this->registration_ip = $ip === null ? null : inet_pton($ip); } - public function getRegistrationIp() { + public function getRegistrationIp(): ?string { return $this->registration_ip === null ? null : inet_ntop($this->registration_ip); } diff --git a/common/models/OauthOwnerType.php b/common/models/OauthOwnerType.php new file mode 100644 index 0000000..4cf529d --- /dev/null +++ b/common/models/OauthOwnerType.php @@ -0,0 +1,23 @@ +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; - } - -} diff --git a/common/models/OauthSession.php b/common/models/OauthSession.php index 981983e..acb3049 100644 --- a/common/models/OauthSession.php +++ b/common/models/OauthSession.php @@ -3,14 +3,15 @@ namespace common\models; use common\components\Redis\Set; use Yii; -use yii\base\ErrorException; +use yii\base\NotSupportedException; +use yii\db\ActiveQuery; use yii\db\ActiveRecord; /** * Поля: * @property integer $id - * @property string $owner_type - * @property string $owner_id + * @property string $owner_type содержит одну из констант OauthOwnerType + * @property string|null $owner_id * @property string $client_id * @property string $client_redirect_uri * @@ -21,42 +22,50 @@ use yii\db\ActiveRecord; */ class OauthSession extends ActiveRecord { - public static function tableName() { + public static function tableName(): string { return '{{%oauth_sessions}}'; } - public function getAccessTokens() { - throw new ErrorException('This method is possible, but not implemented'); - } - - public function getClient() { + public function getClient(): ActiveQuery { return $this->hasOne(OauthClient::class, ['id' => 'client_id']); } - public function getAccount() { + public function getAccount(): ActiveQuery { return $this->hasOne(Account::class, ['id' => 'owner_id']); } - public function getScopes() { + public function getScopes(): Set { return new Set(static::getDb()->getSchema()->getRawTableName(static::tableName()), $this->id, 'scopes'); } - public function beforeDelete() { + public function getAccessTokens() { + throw new NotSupportedException('This method is possible, but not implemented'); + } + + public function beforeDelete(): bool { if (!$result = parent::beforeDelete()) { return $result; } - $this->getScopes()->delete(); + $this->clearScopes(); + $this->removeRefreshToken(); + + return true; + } + + public function removeRefreshToken(): void { /** @var \api\components\OAuth2\Storage\RefreshTokenStorage $refreshTokensStorage */ - $refreshTokensStorage = Yii::$app->oauth->getAuthServer()->getRefreshTokenStorage(); + $refreshTokensStorage = Yii::$app->oauth->getRefreshTokenStorage(); $refreshTokensSet = $refreshTokensStorage->sessionHash($this->id); foreach ($refreshTokensSet->members() as $refreshTokenId) { $refreshTokensStorage->delete($refreshTokensStorage->get($refreshTokenId)); } $refreshTokensSet->delete(); + } - return true; + public function clearScopes(): void { + $this->getScopes()->delete(); } } diff --git a/common/rbac/.generated/.gitignore b/common/rbac/.generated/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/common/rbac/.generated/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/common/rbac/Manager.php b/common/rbac/Manager.php new file mode 100644 index 0000000..038d2d5 --- /dev/null +++ b/common/rbac/Manager.php @@ -0,0 +1,31 @@ +user->findIdentityByAccessToken($accessToken); + /** @noinspection NullPointerExceptionInspection */ + $permissions = $identity->getAssignedPermissions(); + if (empty($permissions)) { + return []; + } + + return array_flip($permissions); + } + +} diff --git a/common/rbac/Permissions.php b/common/rbac/Permissions.php new file mode 100644 index 0000000..3a49076 --- /dev/null +++ b/common/rbac/Permissions.php @@ -0,0 +1,31 @@ +user->findIdentityByAccessToken($accessToken); + /** @noinspection NullPointerExceptionInspection это исключено, т.к. уже сработал authManager */ + $account = $identity->getAccount(); + if ($account === null) { + return false; + } + + if ($account->id !== (int)$accountId) { + return false; + } + + if ($account->status !== Account::STATUS_ACTIVE) { + return false; + } + + $actualRulesOptional = $params['optionalRules'] ?? false; + if (!$actualRulesOptional && !$account->isAgreedWithActualRules()) { + return false; + } + + return true; + } + +} diff --git a/composer.json b/composer.json index 7ab0e6c..24ee8f0 100644 --- a/composer.json +++ b/composer.json @@ -19,11 +19,11 @@ "ely/email-renderer": "dev-master#9458ef44bbee0186acc8417fd2f0839789d13db4", "predis/predis": "^1.0", "mito/yii2-sentry": "^1.0", - "minime/annotations": "~3.0", "spomky-labs/otphp": "^9.0.2", "bacon/bacon-qr-code": "^1.0", "roave/security-advisories": "dev-master", - "paragonie/constant_time_encoding": "^2.0" + "paragonie/constant_time_encoding": "^2.0", + "webmozart/assert": "^1.2.0" }, "require-dev": { "yiisoft/yii2-codeception": "*", @@ -35,7 +35,7 @@ "codeception/specify": "*", "codeception/verify": "*", "phploc/phploc": "^3.0.1", - "mockery/mockery": "1.0.0-alpha1", + "mockery/mockery": "dev-master#89db0caa22276c470e4add719f3d181a78dec827", "php-mock/php-mock-mockery": "dev-mockery-1.0.0#03956ed4b34ae25bc20a0677500f4f4b416f976c" }, "repositories": [ diff --git a/console/controllers/CleanupController.php b/console/controllers/CleanupController.php index 6e47ad4..9284479 100644 --- a/console/controllers/CleanupController.php +++ b/console/controllers/CleanupController.php @@ -6,11 +6,6 @@ use yii\console\Controller; class CleanupController extends Controller { - public function actionTest() { - $validator = \Yii::createObject(\api\components\ReCaptcha\Validator::class); - var_dump($validator); - } - public function actionEmailKeys() { $query = EmailActivation::find(); $conditions = ['OR']; diff --git a/console/controllers/RbacController.php b/console/controllers/RbacController.php new file mode 100644 index 0000000..b6ee484 --- /dev/null +++ b/console/controllers/RbacController.php @@ -0,0 +1,109 @@ +getAuthManager(); + $authManager->removeAllPermissions(); + $authManager->removeAllRoles(); + $authManager->removeAllRules(); + + $permObtainAccountInfo = $this->createPermission(P::OBTAIN_ACCOUNT_INFO); + $permChangeAccountLanguage = $this->createPermission(P::CHANGE_ACCOUNT_LANGUAGE); + $permChangeAccountUsername = $this->createPermission(P::CHANGE_ACCOUNT_USERNAME); + $permChangeAccountPassword = $this->createPermission(P::CHANGE_ACCOUNT_PASSWORD); + $permChangeAccountEmail = $this->createPermission(P::CHANGE_ACCOUNT_EMAIL); + $permManageTwoFactorAuth = $this->createPermission(P::MANAGE_TWO_FACTOR_AUTH); + $permBlockAccount = $this->createPermission(P::BLOCK_ACCOUNT); + $permCompleteOauthFlow = $this->createPermission(P::COMPLETE_OAUTH_FLOW, AccountOwner::class); + + $permObtainAccountEmail = $this->createPermission(P::OBTAIN_ACCOUNT_EMAIL); + $permObtainExtendedAccountInfo = $this->createPermission(P::OBTAIN_EXTENDED_ACCOUNT_INFO); + + $permAcceptNewProjectRules = $this->createPermission(P::ACCEPT_NEW_PROJECT_RULES, AccountOwner::class); + $permObtainOwnAccountInfo = $this->createPermission(P::OBTAIN_OWN_ACCOUNT_INFO, AccountOwner::class); + $permObtainOwnExtendedAccountInfo = $this->createPermission(P::OBTAIN_OWN_EXTENDED_ACCOUNT_INFO, AccountOwner::class); + $permChangeOwnAccountLanguage = $this->createPermission(P::CHANGE_OWN_ACCOUNT_LANGUAGE, AccountOwner::class); + $permChangeOwnAccountUsername = $this->createPermission(P::CHANGE_OWN_ACCOUNT_USERNAME, AccountOwner::class); + $permChangeOwnAccountPassword = $this->createPermission(P::CHANGE_OWN_ACCOUNT_PASSWORD, AccountOwner::class); + $permChangeOwnAccountEmail = $this->createPermission(P::CHANGE_OWN_ACCOUNT_EMAIL, AccountOwner::class); + $permManageOwnTwoFactorAuth = $this->createPermission(P::MANAGE_OWN_TWO_FACTOR_AUTH, AccountOwner::class); + $permMinecraftServerSession = $this->createPermission(P::MINECRAFT_SERVER_SESSION); + + $roleAccountsWebUser = $this->createRole(R::ACCOUNTS_WEB_USER); + + $authManager->addChild($permObtainOwnAccountInfo, $permObtainAccountInfo); + $authManager->addChild($permObtainOwnExtendedAccountInfo, $permObtainExtendedAccountInfo); + $authManager->addChild($permChangeOwnAccountLanguage, $permChangeAccountLanguage); + $authManager->addChild($permChangeOwnAccountUsername, $permChangeAccountUsername); + $authManager->addChild($permChangeOwnAccountPassword, $permChangeAccountPassword); + $authManager->addChild($permChangeOwnAccountEmail, $permChangeAccountEmail); + $authManager->addChild($permManageOwnTwoFactorAuth, $permManageTwoFactorAuth); + + $authManager->addChild($permObtainExtendedAccountInfo, $permObtainAccountInfo); + $authManager->addChild($permObtainExtendedAccountInfo, $permObtainAccountEmail); + + $authManager->addChild($roleAccountsWebUser, $permAcceptNewProjectRules); + $authManager->addChild($roleAccountsWebUser, $permObtainOwnExtendedAccountInfo); + $authManager->addChild($roleAccountsWebUser, $permChangeOwnAccountLanguage); + $authManager->addChild($roleAccountsWebUser, $permChangeOwnAccountUsername); + $authManager->addChild($roleAccountsWebUser, $permChangeOwnAccountPassword); + $authManager->addChild($roleAccountsWebUser, $permChangeOwnAccountEmail); + $authManager->addChild($roleAccountsWebUser, $permManageOwnTwoFactorAuth); + $authManager->addChild($roleAccountsWebUser, $permCompleteOauthFlow); + } + + private function createRole(string $name): Role { + $authManager = $this->getAuthManager(); + $role = $authManager->createRole($name); + if (!$authManager->add($role)) { + throw new ErrorException('Cannot save role in authManager'); + } + + return $role; + } + + private function createPermission(string $name, string $ruleClassName = null): Permission { + $authManager = $this->getAuthManager(); + $permission = $authManager->createPermission($name); + if ($ruleClassName !== null) { + $rule = new $ruleClassName; + if (!$rule instanceof Rule) { + throw new InvalidArgumentException('ruleClassName must be rule class name'); + } + + $ruleFromAuthManager = $authManager->getRule($rule->name); + if ($ruleFromAuthManager === null) { + $authManager->add($rule); + } + + $permission->ruleName = $rule->name; + } + + if (!$authManager->add($permission)) { + throw new ErrorException('Cannot save permission in authManager'); + } + + return $permission; + } + + private function getAuthManager(): ManagerInterface { + return Yii::$app->authManager; + } + +} diff --git a/console/migrations/m170704_215436_allow_null_owner_id.php b/console/migrations/m170704_215436_allow_null_owner_id.php new file mode 100644 index 0000000..972dc9b --- /dev/null +++ b/console/migrations/m170704_215436_allow_null_owner_id.php @@ -0,0 +1,16 @@ +alterColumn('{{%oauth_sessions}}', 'owner_id', $this->string()->null()); + } + + public function safeDown() { + $this->delete('{{%oauth_sessions}}', ['owner_id' => null]); + $this->alterColumn('{{%oauth_sessions}}', 'owner_id', $this->string()->notNull()); + } + +} diff --git a/tests/codeception/api/_pages/AccountsRoute.php b/tests/codeception/api/_pages/AccountsRoute.php index 5ff4a3b..8c4f1cb 100644 --- a/tests/codeception/api/_pages/AccountsRoute.php +++ b/tests/codeception/api/_pages/AccountsRoute.php @@ -8,13 +8,13 @@ use yii\codeception\BasePage; */ class AccountsRoute extends BasePage { - public function current() { - $this->route = ['accounts/current']; + public function get(int $accountId) { + $this->route = "/v1/accounts/{$accountId}"; $this->actor->sendGET($this->getUrl()); } - public function changePassword($currentPassword = null, $newPassword = null, $newRePassword = null) { - $this->route = ['accounts/change-password']; + public function changePassword(int $accountId, $currentPassword = null, $newPassword = null, $newRePassword = null) { + $this->route = "/v1/accounts/{$accountId}/password"; $this->actor->sendPOST($this->getUrl(), [ 'password' => $currentPassword, 'newPassword' => $newPassword, @@ -22,46 +22,56 @@ class AccountsRoute extends BasePage { ]); } - public function changeUsername($currentPassword = null, $newUsername = null) { - $this->route = ['accounts/change-username']; + public function changeUsername(int $accountId, $currentPassword = null, $newUsername = null) { + $this->route = "/v1/accounts/{$accountId}/username"; $this->actor->sendPOST($this->getUrl(), [ 'password' => $currentPassword, 'username' => $newUsername, ]); } - public function changeEmailInitialize($password = '') { - $this->route = ['accounts/change-email-initialize']; + public function changeEmailInitialize(int $accountId, $password = '') { + $this->route = "/v1/accounts/{$accountId}/email-verification"; $this->actor->sendPOST($this->getUrl(), [ 'password' => $password, ]); } - public function changeEmailSubmitNewEmail($key = null, $email = null) { - $this->route = ['accounts/change-email-submit-new-email']; + public function changeEmailSubmitNewEmail(int $accountId, $key = null, $email = null) { + $this->route = "/v1/accounts/{$accountId}/new-email-verification"; $this->actor->sendPOST($this->getUrl(), [ 'key' => $key, 'email' => $email, ]); } - public function changeEmailConfirmNewEmail($key = null) { - $this->route = ['accounts/change-email-confirm-new-email']; + public function changeEmail(int $accountId, $key = null) { + $this->route = "/v1/accounts/{$accountId}/email"; $this->actor->sendPOST($this->getUrl(), [ 'key' => $key, ]); } - public function changeLang($lang = null) { - $this->route = ['accounts/change-lang']; + public function changeLanguage(int $accountId, $lang = null) { + $this->route = "/v1/accounts/{$accountId}/language"; $this->actor->sendPOST($this->getUrl(), [ 'lang' => $lang, ]); } - public function acceptRules() { - $this->route = ['accounts/accept-rules']; + public function acceptRules(int $accountId) { + $this->route = "/v1/accounts/{$accountId}/rules"; $this->actor->sendPOST($this->getUrl()); } + public function ban(int $accountId) { + $this->route = "/v1/accounts/{$accountId}/ban"; + $this->actor->sendPOST($this->getUrl()); + } + + public function pardon($accountId) { + $this->route = "/v1/accounts/{$accountId}/ban"; + $this->actor->sendDELETE($this->getUrl()); + } + } diff --git a/tests/codeception/api/_pages/InternalRoute.php b/tests/codeception/api/_pages/InternalRoute.php index 46c7eae..d508f96 100644 --- a/tests/codeception/api/_pages/InternalRoute.php +++ b/tests/codeception/api/_pages/InternalRoute.php @@ -8,16 +8,6 @@ use yii\codeception\BasePage; */ 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()); - } - public function info(string $param, string $value) { $this->route = '/internal/accounts/info'; $this->actor->sendGET($this->getUrl(), [$param => $value]); diff --git a/tests/codeception/api/_pages/TwoFactorAuthRoute.php b/tests/codeception/api/_pages/TwoFactorAuthRoute.php index 74978cd..3db04f3 100644 --- a/tests/codeception/api/_pages/TwoFactorAuthRoute.php +++ b/tests/codeception/api/_pages/TwoFactorAuthRoute.php @@ -8,24 +8,29 @@ use yii\codeception\BasePage; */ class TwoFactorAuthRoute extends BasePage { - public $route = '/two-factor-auth'; - - public function credentials() { + public function credentials(int $accountId) { + $this->setRoute($accountId); $this->actor->sendGET($this->getUrl()); } - public function enable($totp = null, $password = null) { + public function enable(int $accountId, $totp = null, $password = null) { + $this->setRoute($accountId); $this->actor->sendPOST($this->getUrl(), [ 'totp' => $totp, 'password' => $password, ]); } - public function disable($totp = null, $password = null) { + public function disable(int $accountId, $totp = null, $password = null) { + $this->setRoute($accountId); $this->actor->sendDELETE($this->getUrl(), [ 'totp' => $totp, 'password' => $password, ]); } + private function setRoute(int $accountId) { + $this->route = "/v1/accounts/{$accountId}/two-factor-auth"; + } + } diff --git a/tests/codeception/api/_support/FunctionalTester.php b/tests/codeception/api/_support/FunctionalTester.php index e8b42d1..3d2b489 100644 --- a/tests/codeception/api/_support/FunctionalTester.php +++ b/tests/codeception/api/_support/FunctionalTester.php @@ -1,8 +1,8 @@ $asUsername]); + /** @var Account $account */ + $account = Account::findOne(['username' => $asUsername]); if ($account === null) { throw new InvalidArgumentException("Cannot find account for username \"$asUsername\""); } - $result = Yii::$app->user->login($account); + $result = Yii::$app->user->createJwtAuthenticationToken($account, false); $this->amBearerAuthenticated($result->getJwt()); + + return $account->id; } public function notLoggedIn() { diff --git a/tests/codeception/api/functional.suite.yml b/tests/codeception/api/functional.suite.yml index 2c726d3..3fbb784 100644 --- a/tests/codeception/api/functional.suite.yml +++ b/tests/codeception/api/functional.suite.yml @@ -5,6 +5,7 @@ modules: - Yii2 - tests\codeception\common\_support\FixtureHelper - tests\codeception\common\_support\amqp\Helper + - tests\codeception\common\_support\Mockery - Redis - Asserts - REST: diff --git a/tests/codeception/api/functional/internal/BanCest.php b/tests/codeception/api/functional/AccountBanCest.php similarity index 79% rename from tests/codeception/api/functional/internal/BanCest.php rename to tests/codeception/api/functional/AccountBanCest.php index 9f46806..6100bdd 100644 --- a/tests/codeception/api/functional/internal/BanCest.php +++ b/tests/codeception/api/functional/AccountBanCest.php @@ -1,24 +1,24 @@ route = new InternalRoute($I); + $this->route = new AccountsRoute($I); } public function testBanAccount(OauthSteps $I) { - $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::ACCOUNT_BLOCK]); + $accessToken = $I->getAccessTokenByClientCredentialsGrant([P::BLOCK_ACCOUNT]); $I->amBearerAuthenticated($accessToken); $this->route->ban(1); @@ -30,7 +30,7 @@ class BanCest { } public function testBanBannedAccount(OauthSteps $I) { - $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::ACCOUNT_BLOCK]); + $accessToken = $I->getAccessTokenByClientCredentialsGrant([P::BLOCK_ACCOUNT]); $I->amBearerAuthenticated($accessToken); $this->route->ban(10); diff --git a/tests/codeception/api/functional/internal/PardonCest.php b/tests/codeception/api/functional/AccountPardonCest.php similarity index 79% rename from tests/codeception/api/functional/internal/PardonCest.php rename to tests/codeception/api/functional/AccountPardonCest.php index c7aea10..5c2781e 100644 --- a/tests/codeception/api/functional/internal/PardonCest.php +++ b/tests/codeception/api/functional/AccountPardonCest.php @@ -1,24 +1,24 @@ route = new InternalRoute($I); + $this->route = new AccountsRoute($I); } public function testPardonAccount(OauthSteps $I) { - $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::ACCOUNT_BLOCK]); + $accessToken = $I->getAccessTokenByClientCredentialsGrant([P::BLOCK_ACCOUNT]); $I->amBearerAuthenticated($accessToken); $this->route->pardon(10); @@ -30,7 +30,7 @@ class PardonCest { } public function testPardonNotBannedAccount(OauthSteps $I) { - $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::ACCOUNT_BLOCK]); + $accessToken = $I->getAccessTokenByClientCredentialsGrant([P::BLOCK_ACCOUNT]); $I->amBearerAuthenticated($accessToken); $this->route->pardon(1); diff --git a/tests/codeception/api/functional/AccountsAcceptRulesCest.php b/tests/codeception/api/functional/AccountsAcceptRulesCest.php index ae8269d..0de17e0 100644 --- a/tests/codeception/api/functional/AccountsAcceptRulesCest.php +++ b/tests/codeception/api/functional/AccountsAcceptRulesCest.php @@ -17,7 +17,7 @@ class AccountsAcceptRulesCest { public function testCurrent(FunctionalTester $I) { $I->amAuthenticated('Veleyaba'); - $this->route->acceptRules(); + $this->route->acceptRules(9); $I->canSeeResponseCodeIs(200); $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ diff --git a/tests/codeception/api/functional/AccountsChangeEmailConfirmNewEmailCest.php b/tests/codeception/api/functional/AccountsChangeEmailConfirmNewEmailCest.php index 53b31d8..f07231d 100644 --- a/tests/codeception/api/functional/AccountsChangeEmailConfirmNewEmailCest.php +++ b/tests/codeception/api/functional/AccountsChangeEmailConfirmNewEmailCest.php @@ -19,7 +19,7 @@ class AccountsChangeEmailConfirmNewEmailCest { $I->wantTo('change my email and get changed value'); $I->amAuthenticated('CrafterGameplays'); - $this->route->changeEmailConfirmNewEmail('H28HBDCHHAG2HGHGHS'); + $this->route->changeEmail(8, 'H28HBDCHHAG2HGHGHS'); $I->canSeeResponseCodeIs(200); $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ diff --git a/tests/codeception/api/functional/AccountsChangeEmailInitializeCest.php b/tests/codeception/api/functional/AccountsChangeEmailInitializeCest.php index 0dfa82e..c938caf 100644 --- a/tests/codeception/api/functional/AccountsChangeEmailInitializeCest.php +++ b/tests/codeception/api/functional/AccountsChangeEmailInitializeCest.php @@ -17,9 +17,9 @@ class AccountsChangeEmailInitializeCest { public function testChangeEmailInitialize(FunctionalTester $I) { $I->wantTo('send current email confirmation'); - $I->amAuthenticated(); + $id = $I->amAuthenticated(); - $this->route->changeEmailInitialize('password_0'); + $this->route->changeEmailInitialize($id, 'password_0'); $I->canSeeResponseCodeIs(200); $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ @@ -29,9 +29,9 @@ class AccountsChangeEmailInitializeCest { public function testChangeEmailInitializeFrequencyError(FunctionalTester $I) { $I->wantTo('see change email request frequency error'); - $I->amAuthenticated('ILLIMUNATI'); + $id = $I->amAuthenticated('ILLIMUNATI'); - $this->route->changeEmailInitialize('password_0'); + $this->route->changeEmailInitialize($id, 'password_0'); $I->canSeeResponseContainsJson([ 'success' => false, 'errors' => [ diff --git a/tests/codeception/api/functional/AccountsChangeEmailSubmitNewEmailCest.php b/tests/codeception/api/functional/AccountsChangeEmailSubmitNewEmailCest.php index d09383b..bb6c7e8 100644 --- a/tests/codeception/api/functional/AccountsChangeEmailSubmitNewEmailCest.php +++ b/tests/codeception/api/functional/AccountsChangeEmailSubmitNewEmailCest.php @@ -1,9 +1,10 @@ wantTo('submit new email'); - $I->amAuthenticated('ILLIMUNATI'); + Mock::func(EmailValidator::class, 'checkdnsrr')->andReturnTrue(); - $this->route->changeEmailSubmitNewEmail('H27HBDCHHAG2HGHGHS', 'my-new-email@ely.by'); + $I->wantTo('submit new email'); + $id = $I->amAuthenticated('ILLIMUNATI'); + + $this->route->changeEmailSubmitNewEmail($id, 'H27HBDCHHAG2HGHGHS', 'my-new-email@ely.by'); $I->canSeeResponseCodeIs(200); $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ diff --git a/tests/codeception/api/functional/AccountsChangeLangCest.php b/tests/codeception/api/functional/AccountsChangeLangCest.php index ffe229f..3b0e290 100644 --- a/tests/codeception/api/functional/AccountsChangeLangCest.php +++ b/tests/codeception/api/functional/AccountsChangeLangCest.php @@ -1,7 +1,6 @@ wantTo('change my account language'); - $I->amAuthenticated(); + $id = $I->amAuthenticated(); - $this->route->changeLang('ru'); + $this->route->changeLanguage($id, 'ru'); $I->canSeeResponseCodeIs(200); $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ diff --git a/tests/codeception/api/functional/AccountsChangePasswordCest.php b/tests/codeception/api/functional/AccountsChangePasswordCest.php index 056a340..091cd21 100644 --- a/tests/codeception/api/functional/AccountsChangePasswordCest.php +++ b/tests/codeception/api/functional/AccountsChangePasswordCest.php @@ -1,7 +1,6 @@ wantTo('change my password'); - $I->amAuthenticated(); + $id = $I->amAuthenticated(); - $this->route->changePassword('password_0', 'new-password', 'new-password'); + $this->route->changePassword($id, 'password_0', 'new-password', 'new-password'); $I->canSeeResponseCodeIs(200); $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ diff --git a/tests/codeception/api/functional/AccountsChangeUsernameCest.php b/tests/codeception/api/functional/AccountsChangeUsernameCest.php index b4a696f..69b9570 100644 --- a/tests/codeception/api/functional/AccountsChangeUsernameCest.php +++ b/tests/codeception/api/functional/AccountsChangeUsernameCest.php @@ -1,7 +1,6 @@ wantTo('change my nickname'); - $I->amAuthenticated(); + $id = $I->amAuthenticated(); - $this->route->changeUsername('password_0', 'bruce_wayne'); + $this->route->changeUsername($id, 'password_0', 'bruce_wayne'); $I->canSeeResponseCodeIs(200); $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ @@ -38,9 +37,9 @@ class AccountsChangeUsernameCest { public function testChangeUsernameNotAvailable(FunctionalTester $I) { $I->wantTo('see, that nickname "in use" is not available'); - $I->amAuthenticated(); + $id = $I->amAuthenticated(); - $this->route->changeUsername('password_0', 'Jon'); + $this->route->changeUsername($id, 'password_0', 'Jon'); $I->canSeeResponseCodeIs(200); $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ diff --git a/tests/codeception/api/functional/AccountsCurrentCest.php b/tests/codeception/api/functional/AccountsCurrentCest.php deleted file mode 100644 index c53be87..0000000 --- a/tests/codeception/api/functional/AccountsCurrentCest.php +++ /dev/null @@ -1,55 +0,0 @@ -route = new AccountsRoute($I); - } - - public function testCurrent(FunctionalTester $I) { - $I->amAuthenticated(); - - $this->route->current(); - $I->canSeeResponseCodeIs(200); - $I->canSeeResponseIsJson(); - $I->canSeeResponseContainsJson([ - 'id' => 1, - 'username' => 'Admin', - 'email' => 'admin@ely.by', - 'lang' => 'en', - 'isActive' => true, - 'hasMojangUsernameCollision' => false, - 'shouldAcceptRules' => false, - 'isOtpEnabled' => false, - ]); - $I->canSeeResponseJsonMatchesJsonPath('$.passwordChangedAt'); - } - - public function testExpiredCurrent(FunctionalTester $I) { - // Устанавливаем заведомо истёкший токен - $I->amBearerAuthenticated( - 'eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJodHRwOlwvXC9sb2NhbGhvc3QiLCJpc3MiOiJodHRwOlwvXC9sb2NhbGhvc3QiLCJpYXQiO' . - 'jE0NjQ2Mjc1NDUsImV4cCI6MTQ2NDYzMTE0NSwianRpIjoxfQ.9c1mm0BK-cuW1qh15F12s2Fh37IN43YeeZeU4DFtlrE' - ); - - $this->route->current(); - $I->canSeeResponseCodeIs(401); - $I->canSeeResponseIsJson(); - $I->canSeeResponseContainsJson([ - 'name' => 'Unauthorized', - 'message' => 'Token expired', - 'code' => 0, - 'status' => 401, - ]); - } - -} diff --git a/tests/codeception/api/functional/AccountsGetCest.php b/tests/codeception/api/functional/AccountsGetCest.php new file mode 100644 index 0000000..bc06402 --- /dev/null +++ b/tests/codeception/api/functional/AccountsGetCest.php @@ -0,0 +1,93 @@ +route = new AccountsRoute($I); + } + + public function testGetInfo(FunctionalTester $I) { + $accountId = $I->amAuthenticated(); + + $this->route->get($accountId); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'id' => 1, + 'uuid' => 'df936908-b2e1-544d-96f8-2977ec213022', + 'username' => 'Admin', + 'isOtpEnabled' => false, + 'email' => 'admin@ely.by', + 'lang' => 'en', + 'isActive' => true, + 'hasMojangUsernameCollision' => false, + 'shouldAcceptRules' => false, + 'elyProfileLink' => 'http://ely.by/u1', + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.passwordChangedAt'); + } + + public function testGetWithNotAcceptedLatestRules(FunctionalTester $I) { + $accountId = $I->amAuthenticated('Veleyaba'); + + $this->route->get($accountId); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'id' => 9, + 'uuid' => '410462d3-8e71-47cc-bac6-64f77f88cf80', + 'username' => 'Veleyaba', + 'email' => 'veleyaba@gmail.com', + 'isOtpEnabled' => false, + 'lang' => 'en', + 'isActive' => true, + 'hasMojangUsernameCollision' => false, + 'shouldAcceptRules' => true, + 'elyProfileLink' => 'http://ely.by/u9', + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.passwordChangedAt'); + } + + public function testGetInfoWithExpiredToken(FunctionalTester $I) { + // Устанавливаем заведомо истёкший токен + $I->amBearerAuthenticated( + // TODO: обновить токен + 'eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJodHRwOlwvXC9sb2NhbGhvc3QiLCJpc3MiOiJodHRwOlwvXC9sb2NhbGhvc3QiLCJpYXQiO' . + 'jE0NjQ2Mjc1NDUsImV4cCI6MTQ2NDYzMTE0NSwianRpIjoxfQ.9c1mm0BK-cuW1qh15F12s2Fh37IN43YeeZeU4DFtlrE' + ); + + $this->route->get(1); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'name' => 'Unauthorized', + 'message' => 'Token expired', + 'code' => 0, + 'status' => 401, + ]); + } + + public function testGetInfoNotCurrentAccount(FunctionalTester $I) { + $I->amAuthenticated(); + + $this->route->get(10); + $I->canSeeResponseCodeIs(403); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'name' => 'Forbidden', + 'message' => 'You are not allowed to perform this action.', + 'code' => 0, + 'status' => 403, + ]); + } + +} diff --git a/tests/codeception/api/functional/IdentityInfoCest.php b/tests/codeception/api/functional/IdentityInfoCest.php index 0050a32..ef8cb9b 100644 --- a/tests/codeception/api/functional/IdentityInfoCest.php +++ b/tests/codeception/api/functional/IdentityInfoCest.php @@ -1,7 +1,6 @@ getAccessToken([S::ACCOUNT_INFO]); + $accessToken = $I->getAccessToken(['account_info']); $I->amBearerAuthenticated($accessToken); $this->route->info(); $I->canSeeResponseCodeIs(200); @@ -47,7 +46,7 @@ class IdentityInfoCest { } public function testGetInfoWithEmail(OauthSteps $I) { - $accessToken = $I->getAccessToken([S::ACCOUNT_INFO, S::ACCOUNT_EMAIL]); + $accessToken = $I->getAccessToken(['account_info', 'account_email']); $I->amBearerAuthenticated($accessToken); $this->route->info(); $I->canSeeResponseCodeIs(200); diff --git a/tests/codeception/api/functional/RecoverPasswordCest.php b/tests/codeception/api/functional/RecoverPasswordCest.php index 6bd797f..1ba5116 100644 --- a/tests/codeception/api/functional/RecoverPasswordCest.php +++ b/tests/codeception/api/functional/RecoverPasswordCest.php @@ -21,7 +21,7 @@ class RecoverPasswordCest { $jwt = $I->grabDataFromResponseByJsonPath('$.access_token')[0]; $I->amBearerAuthenticated($jwt); $accountRoute = new AccountsRoute($I); - $accountRoute->current(); + $accountRoute->get(5); $I->canSeeResponseCodeIs(200); $I->canSeeResponseIsJson(); $I->notLoggedIn(); diff --git a/tests/codeception/api/functional/RepeatAccountActivationCest.php b/tests/codeception/api/functional/RepeatAccountActivationCest.php index 8f24d67..3b52ca4 100644 --- a/tests/codeception/api/functional/RepeatAccountActivationCest.php +++ b/tests/codeception/api/functional/RepeatAccountActivationCest.php @@ -1,7 +1,6 @@ amAuthenticated(); - $this->route->credentials(); + $accountId = $I->amAuthenticated(); + $this->route->credentials($accountId); $I->canSeeResponseCodeIs(200); $I->canSeeResponseIsJson(); $I->canSeeResponseJsonMatchesJsonPath('$.secret'); diff --git a/tests/codeception/api/functional/TwoFactorAuthDisableCest.php b/tests/codeception/api/functional/TwoFactorAuthDisableCest.php index 6ba350f..8b05de3 100644 --- a/tests/codeception/api/functional/TwoFactorAuthDisableCest.php +++ b/tests/codeception/api/functional/TwoFactorAuthDisableCest.php @@ -17,9 +17,9 @@ class TwoFactorAuthDisableCest { } public function testFails(FunctionalTester $I) { - $I->amAuthenticated('AccountWithEnabledOtp'); + $accountId = $I->amAuthenticated('AccountWithEnabledOtp'); - $this->route->disable(); + $this->route->disable($accountId); $I->canSeeResponseContainsJson([ 'success' => false, 'errors' => [ @@ -28,7 +28,7 @@ class TwoFactorAuthDisableCest { ], ]); - $this->route->disable('123456', 'invalid_password'); + $this->route->disable($accountId, '123456', 'invalid_password'); $I->canSeeResponseContainsJson([ 'success' => false, 'errors' => [ @@ -37,8 +37,8 @@ class TwoFactorAuthDisableCest { ], ]); - $I->amAuthenticated('AccountWithOtpSecret'); - $this->route->disable('123456', 'invalid_password'); + $accountId = $I->amAuthenticated('AccountWithOtpSecret'); + $this->route->disable($accountId, '123456', 'invalid_password'); $I->canSeeResponseContainsJson([ 'success' => false, 'errors' => [ @@ -48,9 +48,9 @@ class TwoFactorAuthDisableCest { } public function testSuccessEnable(FunctionalTester $I) { - $I->amAuthenticated('AccountWithEnabledOtp'); + $accountId = $I->amAuthenticated('AccountWithEnabledOtp'); $totp = TOTP::create('BBBB'); - $this->route->disable($totp->now(), 'password_0'); + $this->route->disable($accountId, $totp->now(), 'password_0'); $I->canSeeResponseCodeIs(200); $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ diff --git a/tests/codeception/api/functional/TwoFactorAuthEnableCest.php b/tests/codeception/api/functional/TwoFactorAuthEnableCest.php index 3e7bc0d..8a00b7b 100644 --- a/tests/codeception/api/functional/TwoFactorAuthEnableCest.php +++ b/tests/codeception/api/functional/TwoFactorAuthEnableCest.php @@ -17,9 +17,9 @@ class TwoFactorAuthEnableCest { } public function testFails(FunctionalTester $I) { - $I->amAuthenticated('AccountWithOtpSecret'); + $accountId = $I->amAuthenticated('AccountWithOtpSecret'); - $this->route->enable(); + $this->route->enable($accountId); $I->canSeeResponseContainsJson([ 'success' => false, 'errors' => [ @@ -28,7 +28,7 @@ class TwoFactorAuthEnableCest { ], ]); - $this->route->enable('123456', 'invalid_password'); + $this->route->enable($accountId, '123456', 'invalid_password'); $I->canSeeResponseContainsJson([ 'success' => false, 'errors' => [ @@ -37,8 +37,8 @@ class TwoFactorAuthEnableCest { ], ]); - $I->amAuthenticated('AccountWithEnabledOtp'); - $this->route->enable('123456', 'invalid_password'); + $accountId = $I->amAuthenticated('AccountWithEnabledOtp'); + $this->route->enable($accountId, '123456', 'invalid_password'); $I->canSeeResponseContainsJson([ 'success' => false, 'errors' => [ @@ -48,9 +48,9 @@ class TwoFactorAuthEnableCest { } public function testSuccessEnable(FunctionalTester $I) { - $I->amAuthenticated('AccountWithOtpSecret'); + $accountId = $I->amAuthenticated('AccountWithOtpSecret'); $totp = TOTP::create('AAAA'); - $this->route->enable($totp->now(), 'password_0'); + $this->route->enable($accountId, $totp->now(), 'password_0'); $I->canSeeResponseCodeIs(200); $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ diff --git a/tests/codeception/api/functional/_steps/OauthSteps.php b/tests/codeception/api/functional/_steps/OauthSteps.php index c8327e7..ff573b2 100644 --- a/tests/codeception/api/functional/_steps/OauthSteps.php +++ b/tests/codeception/api/functional/_steps/OauthSteps.php @@ -1,14 +1,13 @@ amAuthenticated(); $route = new OauthRoute($this); $route->complete([ @@ -32,7 +31,6 @@ class OauthSteps extends FunctionalTester { } public function getRefreshToken(array $permissions = []) { - // TODO: по идее можно напрямую сделать запись в базу, что ускорит процесс тестирования $authCode = $this->getAuthCode(array_merge([S::OFFLINE_ACCESS], $permissions)); $response = $this->issueToken($authCode); diff --git a/tests/codeception/api/functional/_steps/SessionServerSteps.php b/tests/codeception/api/functional/_steps/SessionServerSteps.php index b5b4d6a..a976752 100644 --- a/tests/codeception/api/functional/_steps/SessionServerSteps.php +++ b/tests/codeception/api/functional/_steps/SessionServerSteps.php @@ -1,15 +1,16 @@ scenario); - $accessToken = $oauthSteps->getAccessToken([S::MINECRAFT_SERVER_SESSION]); + $accessToken = $oauthSteps->getAccessToken([P::MINECRAFT_SERVER_SESSION]); $route = new SessionServerRoute($this); $serverId = Uuid::uuid(); $username = 'Admin'; diff --git a/tests/codeception/api/functional/internal/InfoCest.php b/tests/codeception/api/functional/internal/InfoCest.php index 986f9ff..7be397b 100644 --- a/tests/codeception/api/functional/internal/InfoCest.php +++ b/tests/codeception/api/functional/internal/InfoCest.php @@ -1,7 +1,6 @@ getAccessTokenByClientCredentialsGrant([S::INTERNAL_ACCOUNT_INFO]); + $accessToken = $I->getAccessTokenByClientCredentialsGrant(['internal_account_info']); $I->amBearerAuthenticated($accessToken); $this->route->info('id', 1); @@ -26,7 +25,7 @@ class InfoCest { } public function testGetInfoByUuid(OauthSteps $I) { - $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::INTERNAL_ACCOUNT_INFO]); + $accessToken = $I->getAccessTokenByClientCredentialsGrant(['internal_account_info']); $I->amBearerAuthenticated($accessToken); $this->route->info('uuid', 'df936908-b2e1-544d-96f8-2977ec213022'); @@ -34,7 +33,7 @@ class InfoCest { } public function testGetInfoByUsername(OauthSteps $I) { - $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::INTERNAL_ACCOUNT_INFO]); + $accessToken = $I->getAccessTokenByClientCredentialsGrant(['internal_account_info']); $I->amBearerAuthenticated($accessToken); $this->route->info('username', 'admin'); @@ -42,7 +41,7 @@ class InfoCest { } public function testInvalidParams(OauthSteps $I) { - $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::INTERNAL_ACCOUNT_INFO]); + $accessToken = $I->getAccessTokenByClientCredentialsGrant(['internal_account_info']); $I->amBearerAuthenticated($accessToken); $this->route->info('', ''); @@ -50,7 +49,7 @@ class InfoCest { } public function testAccountNotFound(OauthSteps $I) { - $accessToken = $I->getAccessTokenByClientCredentialsGrant([S::INTERNAL_ACCOUNT_INFO]); + $accessToken = $I->getAccessTokenByClientCredentialsGrant(['internal_account_info']); $I->amBearerAuthenticated($accessToken); $this->route->info('username', 'this-user-not-exists'); diff --git a/tests/codeception/api/functional/oauth/AccessTokenCest.php b/tests/codeception/api/functional/oauth/AccessTokenCest.php index 57c163a..559570a 100644 --- a/tests/codeception/api/functional/oauth/AccessTokenCest.php +++ b/tests/codeception/api/functional/oauth/AccessTokenCest.php @@ -1,7 +1,6 @@ getAuthCode([S::OFFLINE_ACCESS]); + $authCode = $I->getAuthCode(['offline_access']); $this->route->issueToken($this->buildParams( $authCode, 'ely', diff --git a/tests/codeception/api/functional/oauth/AuthCodeCest.php b/tests/codeception/api/functional/oauth/AuthCodeCest.php index 04b7d43..b2829e3 100644 --- a/tests/codeception/api/functional/oauth/AuthCodeCest.php +++ b/tests/codeception/api/functional/oauth/AuthCodeCest.php @@ -1,7 +1,7 @@ canSeeResponseCodeIs(200); @@ -101,7 +101,7 @@ class AuthCodeCest { 'ely', 'http://ely.by', 'code', - [S::MINECRAFT_SERVER_SESSION] + [P::MINECRAFT_SERVER_SESSION] )); $I->canSeeResponseCodeIs(401); $I->canSeeResponseContainsJson([ @@ -119,7 +119,7 @@ class AuthCodeCest { 'ely', 'http://ely.by', 'code', - [S::MINECRAFT_SERVER_SESSION] + [P::MINECRAFT_SERVER_SESSION] ), ['accept' => true]); $I->canSeeResponseCodeIs(200); $I->canSeeResponseContainsJson([ @@ -146,7 +146,7 @@ class AuthCodeCest { 'ely', 'http://ely.by', 'code', - [S::MINECRAFT_SERVER_SESSION] + [P::MINECRAFT_SERVER_SESSION] )); $I->canSeeResponseCodeIs(200); $I->canSeeResponseContainsJson([ @@ -162,13 +162,13 @@ class AuthCodeCest { 'ely', 'http://ely.by', 'code', - [S::MINECRAFT_SERVER_SESSION] + [P::MINECRAFT_SERVER_SESSION] ), ['accept' => true]); $this->route->complete($this->buildQueryParams( 'ely', 'http://ely.by', 'code', - [S::MINECRAFT_SERVER_SESSION, S::ACCOUNT_INFO] + [P::MINECRAFT_SERVER_SESSION, 'account_info'] )); $I->canSeeResponseCodeIs(401); $I->canSeeResponseContainsJson([ @@ -186,7 +186,7 @@ class AuthCodeCest { 'ely', 'http://ely.by', 'code', - [S::MINECRAFT_SERVER_SESSION] + [P::MINECRAFT_SERVER_SESSION] ), ['accept' => false]); $I->canSeeResponseCodeIs(401); $I->canSeeResponseContainsJson([ @@ -270,7 +270,7 @@ class AuthCodeCest { $I->wantTo('check behavior on some invalid scopes'); $this->route->$action($this->buildQueryParams('ely', 'http://ely.by', 'code', [ - S::MINECRAFT_SERVER_SESSION, + P::MINECRAFT_SERVER_SESSION, 'some_wrong_scope', ])); $I->canSeeResponseCodeIs(400); @@ -285,15 +285,15 @@ class AuthCodeCest { $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, + P::MINECRAFT_SERVER_SESSION, + P::BLOCK_ACCOUNT, ])); $I->canSeeResponseCodeIs(400); $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ 'success' => false, 'error' => 'invalid_scope', - 'parameter' => S::ACCOUNT_BLOCK, + 'parameter' => P::BLOCK_ACCOUNT, 'statusCode' => 400, ]); $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); diff --git a/tests/codeception/api/functional/oauth/ClientCredentialsCest.php b/tests/codeception/api/functional/oauth/ClientCredentialsCest.php index 2286c48..2b43a2b 100644 --- a/tests/codeception/api/functional/oauth/ClientCredentialsCest.php +++ b/tests/codeception/api/functional/oauth/ClientCredentialsCest.php @@ -1,7 +1,6 @@ route->issueToken($this->buildParams( 'ely', 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', - [S::ACCOUNT_BLOCK] + ['account_block'] )); $I->canSeeResponseCodeIs(400); $I->canSeeResponseIsJson(); @@ -90,7 +89,7 @@ class ClientCredentialsCest { $this->route->issueToken($this->buildParams( 'trusted-client', 'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9', - [S::ACCOUNT_BLOCK] + ['account_block'] )); $I->canSeeResponseCodeIs(200); $I->canSeeResponseIsJson(); diff --git a/tests/codeception/api/functional/oauth/RefreshTokenCest.php b/tests/codeception/api/functional/oauth/RefreshTokenCest.php index 7990af6..e5185c2 100644 --- a/tests/codeception/api/functional/oauth/RefreshTokenCest.php +++ b/tests/codeception/api/functional/oauth/RefreshTokenCest.php @@ -1,7 +1,8 @@ getRefreshToken([S::MINECRAFT_SERVER_SESSION]); + $refreshToken = $I->getRefreshToken([P::MINECRAFT_SERVER_SESSION]); $this->route->issueToken($this->buildParams( $refreshToken, 'ely', 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', - [S::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS] + [P::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS] )); $this->canSeeRefreshTokenSuccess($I); } public function testRefreshTokenTwice(OauthSteps $I) { - $refreshToken = $I->getRefreshToken([S::MINECRAFT_SERVER_SESSION]); + $refreshToken = $I->getRefreshToken([P::MINECRAFT_SERVER_SESSION]); $this->route->issueToken($this->buildParams( $refreshToken, 'ely', 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', - [S::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS] + [P::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS] )); $this->canSeeRefreshTokenSuccess($I); @@ -64,18 +65,18 @@ class RefreshTokenCest { $refreshToken, 'ely', 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', - [S::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS] + [P::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS] )); $this->canSeeRefreshTokenSuccess($I); } public function testRefreshTokenWithNewScopes(OauthSteps $I) { - $refreshToken = $I->getRefreshToken([S::MINECRAFT_SERVER_SESSION]); + $refreshToken = $I->getRefreshToken([P::MINECRAFT_SERVER_SESSION]); $this->route->issueToken($this->buildParams( $refreshToken, 'ely', 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', - [S::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS, S::ACCOUNT_EMAIL] + [P::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS, 'account_email'] )); $I->canSeeResponseCodeIs(400); $I->canSeeResponseIsJson(); diff --git a/tests/codeception/api/functional/sessionserver/JoinCest.php b/tests/codeception/api/functional/sessionserver/JoinCest.php index ed34400..af67ed6 100644 --- a/tests/codeception/api/functional/sessionserver/JoinCest.php +++ b/tests/codeception/api/functional/sessionserver/JoinCest.php @@ -1,7 +1,7 @@ wantTo('join to server, using modern oAuth2 generated token'); - $accessToken = $I->getAccessToken([S::MINECRAFT_SERVER_SESSION]); + $accessToken = $I->getAccessToken([P::MINECRAFT_SERVER_SESSION]); $this->route->join([ 'accessToken' => $accessToken, 'selectedProfile' => 'df936908-b2e1-544d-96f8-2977ec213022', @@ -54,7 +54,7 @@ class JoinCest { public function joinByModernOauth2TokenWithoutPermission(OauthSteps $I) { $I->wantTo('join to server, using moder oAuth2 generated token, but without minecraft auth permission'); - $accessToken = $I->getAccessToken([S::ACCOUNT_INFO, S::ACCOUNT_EMAIL]); + $accessToken = $I->getAccessToken(['account_info', 'account_email']); $this->route->join([ 'accessToken' => $accessToken, 'selectedProfile' => 'df936908-b2e1-544d-96f8-2977ec213022', diff --git a/tests/codeception/api/functional/sessionserver/JoinLegacyCest.php b/tests/codeception/api/functional/sessionserver/JoinLegacyCest.php index d691bce..a04cbc0 100644 --- a/tests/codeception/api/functional/sessionserver/JoinLegacyCest.php +++ b/tests/codeception/api/functional/sessionserver/JoinLegacyCest.php @@ -1,7 +1,7 @@ wantTo('join to server using modern oAuth2 generated token with new launcher session format'); - $accessToken = $I->getAccessToken([S::MINECRAFT_SERVER_SESSION]); + $accessToken = $I->getAccessToken([P::MINECRAFT_SERVER_SESSION]); $this->route->joinLegacy([ 'sessionId' => 'token:' . $accessToken . ':' . 'df936908-b2e1-544d-96f8-2977ec213022', 'user' => 'Admin', @@ -74,7 +74,7 @@ class JoinLegacyCest { public function joinWithAccessTokenWithoutMinecraftPermission(OauthSteps $I) { $I->wantTo('join to some server with wrong accessToken'); - $accessToken = $I->getAccessToken([S::ACCOUNT_INFO]); + $accessToken = $I->getAccessToken(['account_info']); $this->route->joinLegacy([ 'sessionId' => 'token:' . $accessToken . ':' . 'df936908-b2e1-544d-96f8-2977ec213022', 'user' => 'Admin', diff --git a/tests/codeception/api/unit/components/User/ComponentTest.php b/tests/codeception/api/unit/components/User/ComponentTest.php index 514fd1a..65cd5f9 100644 --- a/tests/codeception/api/unit/components/User/ComponentTest.php +++ b/tests/codeception/api/unit/components/User/ComponentTest.php @@ -2,14 +2,12 @@ namespace codeception\api\unit\components\User; use api\components\User\Component; -use api\components\User\LoginResult; -use api\components\User\RenewResult; -use api\models\AccountIdentity; -use Codeception\Specify; +use api\components\User\Identity; +use api\components\User\AuthenticationResult; +use common\models\Account; use common\models\AccountSession; -use Emarref\Jwt\Algorithm\AlgorithmInterface; -use Emarref\Jwt\Claim\ClaimInterface; -use Emarref\Jwt\Claim\Expiration; +use Emarref\Jwt\Claim; +use Emarref\Jwt\Jwt; use Emarref\Jwt\Token; use tests\codeception\api\unit\TestCase; use tests\codeception\common\_support\ProtectedCaller; @@ -20,7 +18,6 @@ use Yii; use yii\web\Request; class ComponentTest extends TestCase { - use Specify; use ProtectedCaller; /** @@ -30,7 +27,7 @@ class ComponentTest extends TestCase { public function _before() { parent::_before(); - $this->component = new Component($this->getComponentArguments()); + $this->component = new Component($this->getComponentConfig()); } public function _fixtures() { @@ -41,183 +38,140 @@ class ComponentTest extends TestCase { ]; } - public function testLogin() { + public function testCreateJwtAuthenticationToken() { $this->mockRequest(); - $this->specify('success get LoginResult object without session value', function() { - $account = new AccountIdentity(['id' => 1]); - $result = $this->component->login($account, false); - expect($result)->isInstanceOf(LoginResult::class); - expect($result->getSession())->null(); - expect($result->getIdentity())->equals($account); - $jwt = $result->getJwt(); - expect(is_string($jwt))->true(); - $token = $this->component->parseToken($jwt); - $claim = $token->getPayload()->findClaimByName(Expiration::NAME); - // Токен выписывается на 7 дней, но мы проверим хотя бы на 2 суток - expect($claim->getValue())->greaterThan(time() + 60 * 60 * 24 * 2); - }); - $this->specify('success get LoginResult object with session value if rememberMe is true', function() { - /** @var AccountIdentity $account */ - $account = AccountIdentity::findOne($this->tester->grabFixture('accounts', 'admin')['id']); - $result = $this->component->login($account, true); - expect($result)->isInstanceOf(LoginResult::class); - expect($result->getSession())->isInstanceOf(AccountSession::class); - expect($result->getIdentity())->equals($account); - expect($result->getSession()->refresh())->true(); - $jwt = $result->getJwt(); - expect(is_string($jwt))->true(); - $token = $this->component->parseToken($jwt); - $claim = $token->getPayload()->findClaimByName(Expiration::NAME); - // Токен выписывается на 1 час, т.к. в дальнейшем он будет рефрешиться - expect($claim->getValue())->lessOrEquals(time() + 3600); - }); + $account = new Account(['id' => 1]); + $result = $this->component->createJwtAuthenticationToken($account, false); + $this->assertInstanceOf(AuthenticationResult::class, $result); + $this->assertNull($result->getSession()); + $this->assertEquals($account, $result->getAccount()); + $payloads = (new Jwt())->deserialize($result->getJwt())->getPayload(); + /** @noinspection NullPointerExceptionInspection */ + $this->assertEquals(time(), $payloads->findClaimByName(Claim\IssuedAt::NAME)->getValue(), '', 3); + /** @noinspection SummerTimeUnsafeTimeManipulationInspection */ + /** @noinspection NullPointerExceptionInspection */ + $this->assertEquals(time() + 60 * 60 * 24 * 7, $payloads->findClaimByName('exp')->getValue(), '', 3); + /** @noinspection NullPointerExceptionInspection */ + $this->assertEquals('ely|1', $payloads->findClaimByName('sub')->getValue()); + /** @noinspection NullPointerExceptionInspection */ + $this->assertEquals('accounts_web_user', $payloads->findClaimByName('ely-scopes')->getValue()); + $this->assertNull($payloads->findClaimByName('jti')); + + /** @var Account $account */ + $account = $this->tester->grabFixture('accounts', 'admin'); + $result = $this->component->createJwtAuthenticationToken($account, true); + $this->assertInstanceOf(AuthenticationResult::class, $result); + $this->assertInstanceOf(AccountSession::class, $result->getSession()); + $this->assertEquals($account, $result->getAccount()); + /** @noinspection NullPointerExceptionInspection */ + $this->assertTrue($result->getSession()->refresh()); + $payloads = (new Jwt())->deserialize($result->getJwt())->getPayload(); + /** @noinspection NullPointerExceptionInspection */ + $this->assertEquals(time(), $payloads->findClaimByName(Claim\IssuedAt::NAME)->getValue(), '', 3); + /** @noinspection NullPointerExceptionInspection */ + $this->assertEquals(time() + 3600, $payloads->findClaimByName('exp')->getValue(), '', 3); + /** @noinspection NullPointerExceptionInspection */ + $this->assertEquals('ely|1', $payloads->findClaimByName('sub')->getValue()); + /** @noinspection NullPointerExceptionInspection */ + $this->assertEquals('accounts_web_user', $payloads->findClaimByName('ely-scopes')->getValue()); + /** @noinspection NullPointerExceptionInspection */ + $this->assertEquals($result->getSession()->id, $payloads->findClaimByName('jti')->getValue()); } - public function testRenew() { - $this->specify('success get RenewResult object', function() { - $userIP = '192.168.0.1'; - $this->mockRequest($userIP); - /** @var AccountSession $session */ - $session = AccountSession::findOne($this->tester->grabFixture('sessions', 'admin')['id']); - $callTime = time(); - $result = $this->component->renew($session); - expect($result)->isInstanceOf(RenewResult::class); - expect(is_string($result->getJwt()))->true(); - expect($result->getIdentity()->getId())->equals($session->account_id); - $session->refresh(); - expect($session->last_refreshed_at)->greaterOrEquals($callTime); - expect($session->getReadableIp())->equals($userIP); - }); + public function testRenewJwtAuthenticationToken() { + $userIP = '192.168.0.1'; + $this->mockRequest($userIP); + /** @var AccountSession $session */ + $session = $this->tester->grabFixture('sessions', 'admin'); + $result = $this->component->renewJwtAuthenticationToken($session); + $this->assertInstanceOf(AuthenticationResult::class, $result); + $this->assertEquals($session, $result->getSession()); + $this->assertEquals($session->account_id, $result->getAccount()->id); + $session->refresh(); // reload data from db + $this->assertEquals(time(), $session->last_refreshed_at, '', 3); + $this->assertEquals($userIP, $session->getReadableIp()); + $payloads = (new Jwt())->deserialize($result->getJwt())->getPayload(); + /** @noinspection NullPointerExceptionInspection */ + $this->assertEquals(time(), $payloads->findClaimByName(Claim\IssuedAt::NAME)->getValue(), '', 3); + /** @noinspection NullPointerExceptionInspection */ + $this->assertEquals(time() + 3600, $payloads->findClaimByName('exp')->getValue(), '', 3); + /** @noinspection NullPointerExceptionInspection */ + $this->assertEquals('ely|1', $payloads->findClaimByName('sub')->getValue()); + /** @noinspection NullPointerExceptionInspection */ + $this->assertEquals('accounts_web_user', $payloads->findClaimByName('ely-scopes')->getValue()); + /** @noinspection NullPointerExceptionInspection */ + $this->assertEquals($session->id, $payloads->findClaimByName('jti')->getValue(), 'session has not changed'); } public function testParseToken() { $this->mockRequest(); - $this->specify('success get RenewResult object', function() { - $identity = new AccountIdentity(['id' => 1]); - $token = $this->callProtected($this->component, 'createToken', $identity); - $jwt = $this->callProtected($this->component, 'serializeToken', $token); - - expect($this->component->parseToken($jwt))->isInstanceOf(Token::class); - }); + $token = $this->callProtected($this->component, 'createToken', new Account(['id' => 1])); + $jwt = $this->callProtected($this->component, 'serializeToken', $token); + $this->assertInstanceOf(Token::class, $this->component->parseToken($jwt), 'success get RenewResult object'); } public function testGetActiveSession() { - $this->specify('get used account session', function() { - /** @var AccountIdentity $identity */ - $identity = AccountIdentity::findOne($this->tester->grabFixture('accounts', 'admin')['id']); - $result = $this->component->login($identity, true); - $this->component->logout(); + $account = $this->tester->grabFixture('accounts', 'admin'); + $result = $this->component->createJwtAuthenticationToken($account, true); + $this->component->logout(); - /** @var Component|\PHPUnit_Framework_MockObject_MockObject $component */ - $component = $this->getMockBuilder(Component::class) - ->setMethods(['getIsGuest']) - ->setConstructorArgs([$this->getComponentArguments()]) - ->getMock(); + /** @var Component|\PHPUnit_Framework_MockObject_MockObject $component */ + $component = $this->getMockBuilder(Component::class) + ->setMethods(['getIsGuest']) + ->setConstructorArgs([$this->getComponentConfig()]) + ->getMock(); - $component - ->expects($this->any()) - ->method('getIsGuest') - ->willReturn(false); + $component + ->expects($this->any()) + ->method('getIsGuest') + ->willReturn(false); - $this->mockAuthorizationHeader($result->getJwt()); + $this->mockAuthorizationHeader($result->getJwt()); - $session = $component->getActiveSession(); - expect($session)->isInstanceOf(AccountSession::class); - expect($session->id)->equals($result->getSession()->id); - }); + $session = $component->getActiveSession(); + $this->assertInstanceOf(AccountSession::class, $session); + /** @noinspection NullPointerExceptionInspection */ + $this->assertEquals($session->id, $result->getSession()->id); } public function testTerminateSessions() { /** @var AccountSession $session */ $session = AccountSession::findOne($this->tester->grabFixture('sessions', 'admin2')['id']); - /** @var Component|\PHPUnit_Framework_MockObject_MockObject $component */ - $component = $this->getMockBuilder(Component::class) - ->setMethods(['getActiveSession']) - ->setConstructorArgs([$this->getComponentArguments()]) - ->getMock(); + /** @var Component|\Mockery\MockInterface $component */ + $component = mock(Component::class . '[getActiveSession]', [$this->getComponentConfig()])->shouldDeferMissing(); + $component->shouldReceive('getActiveSession')->times(1)->andReturn($session); - $component - ->expects($this->exactly(1)) - ->method('getActiveSession') - ->willReturn($session); + /** @var Account $account */ + $account = $this->tester->grabFixture('accounts', 'admin'); + $component->createJwtAuthenticationToken($account, true); - /** @var AccountIdentity $identity */ - $identity = AccountIdentity::findOne($this->tester->grabFixture('accounts', 'admin')['id']); - $component->login($identity, true); + $component->terminateSessions($account, Component::KEEP_MINECRAFT_SESSIONS | Component::KEEP_SITE_SESSIONS); + $this->assertNotEmpty($account->getMinecraftAccessKeys()->all()); + $this->assertNotEmpty($account->getSessions()->all()); - $component->terminateSessions(0); - $this->assertNotEmpty($identity->getMinecraftAccessKeys()->all()); - $this->assertNotEmpty($identity->getSessions()->all()); + $component->terminateSessions($account, Component::KEEP_SITE_SESSIONS); + $this->assertEmpty($account->getMinecraftAccessKeys()->all()); + $this->assertNotEmpty($account->getSessions()->all()); - $component->terminateSessions(Component::TERMINATE_MINECRAFT_SESSIONS); - $this->assertEmpty($identity->getMinecraftAccessKeys()->all()); - $this->assertNotEmpty($identity->getSessions()->all()); - - $component->terminateSessions(Component::TERMINATE_SITE_SESSIONS | Component::DO_NOT_TERMINATE_CURRENT_SESSION); - $sessions = $identity->getSessions()->all(); + $component->terminateSessions($account, Component::KEEP_CURRENT_SESSION); + $sessions = $account->getSessions()->all(); $this->assertEquals(1, count($sessions)); $this->assertTrue($sessions[0]->id === $session->id); - $component->terminateSessions(Component::TERMINATE_ALL); - $this->assertEmpty($identity->getSessions()->all()); - $this->assertEmpty($identity->getMinecraftAccessKeys()->all()); + $component->terminateSessions($account); + $this->assertEmpty($account->getSessions()->all()); + $this->assertEmpty($account->getMinecraftAccessKeys()->all()); } - public function testSerializeToken() { - $this->specify('get string, contained jwt token', function() { - $token = new Token(); - expect($this->callProtected($this->component, 'serializeToken', $token)) - ->regExp('/^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+\/=]*$/'); - }); - } - - public function testCreateToken() { - $this->specify('create token', function() { - expect($this->callProtected($this->component, 'createToken', new AccountIdentity(['id' => 1]))) - ->isInstanceOf(Token::class); - }); - } - - public function testGetAlgorithm() { - $this->specify('get expected hash algorithm object', function() { - expect($this->component->getAlgorithm())->isInstanceOf(AlgorithmInterface::class); - }); - } - - public function testGetClaims() { - $this->specify('get expected array of claims', function() { - $claims = $this->callProtected($this->component, 'getClaims', new AccountIdentity(['id' => 1])); - expect(is_array($claims))->true(); - expect('all array items should have valid type', array_filter($claims, function($claim) { - return !$claim instanceof ClaimInterface; - }))->isEmpty(); - }); - } - - /** - * @param string $userIP - * @return \PHPUnit_Framework_MockObject_MockObject - */ private function mockRequest($userIP = '127.0.0.1') { - $request = $this->getMockBuilder(Request::class) - ->setMethods(['getHostInfo', 'getUserIP']) - ->getMock(); - - $request - ->expects($this->any()) - ->method('getHostInfo') - ->will($this->returnValue('http://localhost')); - - $request - ->expects($this->any()) - ->method('getUserIP') - ->will($this->returnValue($userIP)); + /** @var Request|\Mockery\MockInterface $request */ + $request = mock(Request::class . '[getHostInfo,getUserIP]')->shouldDeferMissing(); + $request->shouldReceive('getHostInfo')->andReturn('http://localhost'); + $request->shouldReceive('getUserIP')->andReturn($userIP); Yii::$app->set('request', $request); - - return $request; } /** @@ -231,9 +185,9 @@ class ComponentTest extends TestCase { Yii::$app->request->headers->set('Authorization', $bearerToken); } - private function getComponentArguments() { + private function getComponentConfig() { return [ - 'identityClass' => AccountIdentity::class, + 'identityClass' => Identity::class, 'enableSession' => false, 'loginUrl' => null, 'secret' => 'secret', diff --git a/tests/codeception/api/unit/components/User/JwtAuthenticationResultTest.php b/tests/codeception/api/unit/components/User/JwtAuthenticationResultTest.php new file mode 100644 index 0000000..e906847 --- /dev/null +++ b/tests/codeception/api/unit/components/User/JwtAuthenticationResultTest.php @@ -0,0 +1,63 @@ +id = 123; + $model = new AuthenticationResult($account, '', null); + $this->assertEquals($account, $model->getAccount()); + } + + public function testGetJwt() { + $model = new AuthenticationResult(new Account(), 'mocked jwt', null); + $this->assertEquals('mocked jwt', $model->getJwt()); + } + + public function testGetSession() { + $model = new AuthenticationResult(new Account(), '', null); + $this->assertNull($model->getSession()); + + $session = new AccountSession(); + $session->id = 321; + $model = new AuthenticationResult(new Account(), '', $session); + $this->assertEquals($session, $model->getSession()); + } + + public function testGetAsResponse() { + $jwtToken = $this->createJwtToken(time() + 3600); + $model = new AuthenticationResult(new Account(), $jwtToken, null); + $result = $model->getAsResponse(); + $this->assertEquals($jwtToken, $result['access_token']); + $this->assertSame(3600, $result['expires_in']); + + /** @noinspection SummerTimeUnsafeTimeManipulationInspection */ + $jwtToken = $this->createJwtToken(time() + 86400); + $session = new AccountSession(); + $session->refresh_token = 'refresh token'; + $model = new AuthenticationResult(new Account(), $jwtToken, $session); + $result = $model->getAsResponse(); + $this->assertEquals($jwtToken, $result['access_token']); + $this->assertEquals('refresh token', $result['refresh_token']); + $this->assertSame(86400, $result['expires_in']); + } + + private function createJwtToken(int $expires): string { + $token = new Token(); + $token->addClaim(new Expiration($expires)); + + return (new Jwt())->serialize($token, EncryptionFactory::create(new Hs256('123'))); + } + +} diff --git a/tests/codeception/api/unit/filters/ActiveUserRuleTest.php b/tests/codeception/api/unit/filters/ActiveUserRuleTest.php deleted file mode 100644 index 5a44e92..0000000 --- a/tests/codeception/api/unit/filters/ActiveUserRuleTest.php +++ /dev/null @@ -1,66 +0,0 @@ -specify('get false if user not finished registration', function() use (&$account) { - $account->status = Account::STATUS_REGISTERED; - $filter = $this->getFilterMock($account); - expect($this->callProtected($filter, 'matchCustom', new Action(null, null)))->false(); - }); - - $this->specify('get false if user has banned status', function() use (&$account) { - $account->status = Account::STATUS_BANNED; - $filter = $this->getFilterMock($account); - expect($this->callProtected($filter, 'matchCustom', new Action(null, null)))->false(); - }); - - $this->specify('get false if user have old EULA agreement', function() use (&$account) { - $account->status = Account::STATUS_ACTIVE; - $account->rules_agreement_version = null; - $filter = $this->getFilterMock($account); - expect($this->callProtected($filter, 'matchCustom', new Action(null, null)))->false(); - }); - - $this->specify('get true if user fully active', function() use (&$account) { - $account->status = Account::STATUS_ACTIVE; - $account->rules_agreement_version = LATEST_RULES_VERSION; - $filter = $this->getFilterMock($account); - expect($this->callProtected($filter, 'matchCustom', new Action(null, null)))->true(); - }); - } - - /** - * @param AccountIdentity $returnIdentity - * @return ActiveUserRule|\PHPUnit_Framework_MockObject_MockObject - */ - private function getFilterMock(AccountIdentity $returnIdentity) { - /** @var ActiveUserRule|\PHPUnit_Framework_MockObject_MockObject $filter */ - $filter = $this - ->getMockBuilder(ActiveUserRule::class) - ->setMethods(['getIdentity']) - ->getMock(); - - $filter - ->expects($this->any()) - ->method('getIdentity') - ->will($this->returnValue($returnIdentity)); - - return $filter; - } - -} diff --git a/tests/codeception/api/unit/models/AccountIdentityTest.php b/tests/codeception/api/unit/models/JwtIdentityTest.php similarity index 70% rename from tests/codeception/api/unit/models/AccountIdentityTest.php rename to tests/codeception/api/unit/models/JwtIdentityTest.php index 819787d..6d6ba72 100644 --- a/tests/codeception/api/unit/models/AccountIdentityTest.php +++ b/tests/codeception/api/unit/models/JwtIdentityTest.php @@ -1,35 +1,34 @@ AccountFixture::class, ]; } public function testFindIdentityByAccessToken() { - $identity = AccountIdentity::findIdentityByAccessToken($this->generateToken()); + $token = $this->generateToken(); + $identity = JwtIdentity::findIdentityByAccessToken($token); $this->assertInstanceOf(IdentityInterface::class, $identity); - $this->assertEquals($this->tester->grabFixture('accounts', 'admin')['id'], $identity->getId()); + $this->assertEquals($token, $identity->getId()); + $this->assertEquals($this->tester->grabFixture('accounts', 'admin')['id'], $identity->getAccount()->id); } /** @@ -42,10 +41,10 @@ class AccountIdentityTest extends TestCase { $token->addClaim(new Claim\Issuer('http://localhost')); $token->addClaim(new Claim\IssuedAt(1464593193)); $token->addClaim(new Claim\Expiration(1464596793)); - $token->addClaim(new Claim\JwtId($this->tester->grabFixture('accounts', 'admin')['id'])); + $token->addClaim(new Claim\Subject('ely|' . $this->tester->grabFixture('accounts', 'admin')['id'])); $expiredToken = (new Jwt())->serialize($token, EncryptionFactory::create(Yii::$app->user->getAlgorithm())); - AccountIdentity::findIdentityByAccessToken($expiredToken); + JwtIdentity::findIdentityByAccessToken($expiredToken); } /** @@ -53,15 +52,14 @@ class AccountIdentityTest extends TestCase { * @expectedExceptionMessage Incorrect token */ public function testFindIdentityByAccessTokenWithEmptyToken() { - AccountIdentity::findIdentityByAccessToken(''); + JwtIdentity::findIdentityByAccessToken(''); } protected function generateToken() { /** @var \api\components\User\Component $component */ $component = Yii::$app->user; - /** @var AccountIdentity $account */ - $account = AccountIdentity::findOne($this->tester->grabFixture('accounts', 'admin')['id']); - + /** @var \common\models\Account $account */ + $account = $this->tester->grabFixture('accounts', 'admin'); $token = $this->callProtected($component, 'createToken', $account); return $this->callProtected($component, 'serializeToken', $token); diff --git a/tests/codeception/api/unit/models/authentication/ConfirmEmailFormTest.php b/tests/codeception/api/unit/models/authentication/ConfirmEmailFormTest.php index 2bbd5d7..404b994 100644 --- a/tests/codeception/api/unit/models/authentication/ConfirmEmailFormTest.php +++ b/tests/codeception/api/unit/models/authentication/ConfirmEmailFormTest.php @@ -1,7 +1,7 @@ tester->grabFixture('emailActivations', 'freshRegistrationConfirmation'); $model = $this->createModel($fixture['key']); $result = $model->confirm(); - $this->assertInstanceOf(LoginResult::class, $result); + $this->assertInstanceOf(AuthenticationResult::class, $result); $this->assertInstanceOf(AccountSession::class, $result->getSession(), 'session was generated'); $activationExists = EmailActivation::find()->andWhere(['key' => $fixture['key']])->exists(); $this->assertFalse($activationExists, 'email activation key is not exist'); diff --git a/tests/codeception/api/unit/models/authentication/LoginFormTest.php b/tests/codeception/api/unit/models/authentication/LoginFormTest.php index 7ee65fc..9ced5c1 100644 --- a/tests/codeception/api/unit/models/authentication/LoginFormTest.php +++ b/tests/codeception/api/unit/models/authentication/LoginFormTest.php @@ -1,8 +1,7 @@ specify('no errors if login exists', function () { $model = $this->createModel([ 'login' => 'mr-test', - 'account' => new AccountIdentity(), + 'account' => new Account(), ]); $model->validateLogin('login'); $this->assertEmpty($model->getErrors('login')); @@ -56,7 +55,7 @@ class LoginFormTest extends TestCase { $this->specify('error.password_incorrect if password invalid', function () { $model = $this->createModel([ 'password' => '87654321', - 'account' => new AccountIdentity(['password' => '12345678']), + 'account' => new Account(['password' => '12345678']), ]); $model->validatePassword('password'); $this->assertEquals(['error.password_incorrect'], $model->getErrors('password')); @@ -65,7 +64,7 @@ class LoginFormTest extends TestCase { $this->specify('no errors if password valid', function () { $model = $this->createModel([ 'password' => '12345678', - 'account' => new AccountIdentity(['password' => '12345678']), + 'account' => new Account(['password' => '12345678']), ]); $model->validatePassword('password'); $this->assertEmpty($model->getErrors('password')); @@ -73,7 +72,7 @@ class LoginFormTest extends TestCase { } public function testValidateTotp() { - $account = new AccountIdentity(['password' => '12345678']); + $account = new Account(['password' => '12345678']); $account->password = '12345678'; $account->is_otp_enabled = true; $account->otp_secret = 'AAAA'; @@ -103,7 +102,7 @@ class LoginFormTest extends TestCase { public function testValidateActivity() { $this->specify('error.account_not_activated if account in not activated state', function () { $model = $this->createModel([ - 'account' => new AccountIdentity(['status' => Account::STATUS_REGISTERED]), + 'account' => new Account(['status' => Account::STATUS_REGISTERED]), ]); $model->validateActivity('login'); $this->assertEquals(['error.account_not_activated'], $model->getErrors('login')); @@ -111,7 +110,7 @@ class LoginFormTest extends TestCase { $this->specify('error.account_banned if account has banned status', function () { $model = $this->createModel([ - 'account' => new AccountIdentity(['status' => Account::STATUS_BANNED]), + 'account' => new Account(['status' => Account::STATUS_BANNED]), ]); $model->validateActivity('login'); $this->assertEquals(['error.account_banned'], $model->getErrors('login')); @@ -119,7 +118,7 @@ class LoginFormTest extends TestCase { $this->specify('no errors if account active', function () { $model = $this->createModel([ - 'account' => new AccountIdentity(['status' => Account::STATUS_ACTIVE]), + 'account' => new Account(['status' => Account::STATUS_ACTIVE]), ]); $model->validateActivity('login'); $this->assertEmpty($model->getErrors('login')); @@ -130,13 +129,13 @@ class LoginFormTest extends TestCase { $model = $this->createModel([ 'login' => 'erickskrauch', 'password' => '12345678', - 'account' => new AccountIdentity([ + 'account' => new Account([ 'username' => 'erickskrauch', 'password' => '12345678', 'status' => Account::STATUS_ACTIVE, ]), ]); - $this->assertInstanceOf(LoginResult::class, $model->login(), 'model should login user'); + $this->assertInstanceOf(AuthenticationResult::class, $model->login(), 'model should login user'); $this->assertEmpty($model->getErrors(), 'error message should not be set'); } @@ -145,7 +144,7 @@ class LoginFormTest extends TestCase { 'login' => $this->tester->grabFixture('accounts', 'user-with-old-password-type')['username'], 'password' => '12345678', ]); - $this->assertInstanceOf(LoginResult::class, $model->login()); + $this->assertInstanceOf(AuthenticationResult::class, $model->login()); $this->assertEmpty($model->getErrors()); $this->assertEquals( Account::PASS_HASH_STRATEGY_YII2, @@ -166,7 +165,7 @@ class LoginFormTest extends TestCase { $this->_account = $value; } - public function getAccount() { + public function getAccount(): ?Account { return $this->_account; } }; diff --git a/tests/codeception/api/unit/models/authentication/LogoutFormTest.php b/tests/codeception/api/unit/models/authentication/LogoutFormTest.php index cd0c05a..861a666 100644 --- a/tests/codeception/api/unit/models/authentication/LogoutFormTest.php +++ b/tests/codeception/api/unit/models/authentication/LogoutFormTest.php @@ -2,7 +2,7 @@ namespace tests\codeception\api\models\authentication; use api\components\User\Component; -use api\models\AccountIdentity; +use api\components\User\Identity; use api\models\authentication\LogoutForm; use Codeception\Specify; use common\models\AccountSession; @@ -59,7 +59,7 @@ class LogoutFormTest extends TestCase { private function getComponentArgs() { return [ - 'identityClass' => AccountIdentity::class, + 'identityClass' => Identity::class, 'enableSession' => false, 'loginUrl' => null, 'secret' => 'secret', diff --git a/tests/codeception/api/unit/models/authentication/RecoverPasswordFormTest.php b/tests/codeception/api/unit/models/authentication/RecoverPasswordFormTest.php index 530ecb1..cfc1fb9 100644 --- a/tests/codeception/api/unit/models/authentication/RecoverPasswordFormTest.php +++ b/tests/codeception/api/unit/models/authentication/RecoverPasswordFormTest.php @@ -1,7 +1,7 @@ '12345678', ]); $result = $model->recoverPassword(); - $this->assertInstanceOf(LoginResult::class, $result); + $this->assertInstanceOf(AuthenticationResult::class, $result); $this->assertNull($result->getSession(), 'session was not generated'); $this->assertFalse(EmailActivation::find()->andWhere(['key' => $fixture['key']])->exists()); /** @var Account $account */ diff --git a/tests/codeception/api/unit/models/authentication/RefreshTokenFormTest.php b/tests/codeception/api/unit/models/authentication/RefreshTokenFormTest.php index 5aab389..9ff203b 100644 --- a/tests/codeception/api/unit/models/authentication/RefreshTokenFormTest.php +++ b/tests/codeception/api/unit/models/authentication/RefreshTokenFormTest.php @@ -1,7 +1,7 @@ validateRefreshToken(); - expect($model->getErrors('refresh_token'))->equals(['error.refresh_token_not_exist']); + $this->assertEquals(['error.refresh_token_not_exist'], $model->getErrors('refresh_token')); }); $this->specify('no errors if token exists', function() { @@ -37,14 +37,14 @@ class RefreshTokenFormTest extends TestCase { } }; $model->validateRefreshToken(); - expect($model->getErrors('refresh_token'))->isEmpty(); + $this->assertEmpty($model->getErrors('refresh_token')); }); } public function testRenew() { $model = new RefreshTokenForm(); $model->refresh_token = $this->tester->grabFixture('sessions', 'admin')['refresh_token']; - $this->assertInstanceOf(RenewResult::class, $model->renew()); + $this->assertInstanceOf(AuthenticationResult::class, $model->renew()); } } diff --git a/tests/codeception/api/unit/models/authentication/RegistrationFormTest.php b/tests/codeception/api/unit/models/authentication/RegistrationFormTest.php index 2ece316..e957e74 100644 --- a/tests/codeception/api/unit/models/authentication/RegistrationFormTest.php +++ b/tests/codeception/api/unit/models/authentication/RegistrationFormTest.php @@ -8,12 +8,13 @@ use common\models\Account; use common\models\EmailActivation; use common\models\UsernameHistory; use GuzzleHttp\ClientInterface; -use ReflectionClass; use tests\codeception\api\unit\TestCase; use tests\codeception\common\fixtures\AccountFixture; use tests\codeception\common\fixtures\EmailActivationFixture; use tests\codeception\common\fixtures\UsernameHistoryFixture; +use tests\codeception\common\helpers\Mock; use Yii; +use yii\validators\EmailValidator; use yii\web\Request; use const common\LATEST_RULES_VERSION; @@ -59,6 +60,7 @@ class RegistrationFormTest extends TestCase { } public function testSignup() { + Mock::func(EmailValidator::class, 'checkdnsrr')->andReturnTrue(); $model = new RegistrationForm([ 'username' => 'some_username', 'email' => 'some_email@example.com', @@ -75,6 +77,7 @@ class RegistrationFormTest extends TestCase { } public function testSignupWithDefaultLanguage() { + Mock::func(EmailValidator::class, 'checkdnsrr')->andReturnTrue(); $model = new RegistrationForm([ 'username' => 'some_username', 'email' => 'some_email@example.com', diff --git a/tests/codeception/api/unit/models/profile/AcceptRulesFormTest.php b/tests/codeception/api/unit/models/profile/AcceptRulesFormTest.php deleted file mode 100644 index 591d640..0000000 --- a/tests/codeception/api/unit/models/profile/AcceptRulesFormTest.php +++ /dev/null @@ -1,26 +0,0 @@ - AccountFixture::class, - ]; - } - - public function testAgreeWithLatestRules() { - /** @var Account $account */ - $account = Account::findOne($this->tester->grabFixture('accounts', 'account-with-old-rules-version')); - $model = new AcceptRulesForm($account); - $this->assertTrue($model->agreeWithLatestRules()); - $this->assertEquals(LATEST_RULES_VERSION, $account->rules_agreement_version); - } - -} diff --git a/tests/codeception/api/unit/models/profile/ChangeLanguageFormTest.php b/tests/codeception/api/unit/models/profile/ChangeLanguageFormTest.php deleted file mode 100644 index 287eea7..0000000 --- a/tests/codeception/api/unit/models/profile/ChangeLanguageFormTest.php +++ /dev/null @@ -1,26 +0,0 @@ - AccountFixture::class - ]; - } - - public function testApplyLanguage() { - /** @var Account $account */ - $account = $this->tester->grabFixture('accounts', 'admin'); - $model = new ChangeLanguageForm($account); - $model->lang = 'ru'; - $this->assertTrue($model->applyLanguage()); - $this->assertEquals('ru', $account->lang); - } - -} diff --git a/tests/codeception/api/unit/models/profile/ChangePasswordFormTest.php b/tests/codeception/api/unit/models/profile/ChangePasswordFormTest.php deleted file mode 100644 index 6e4de16..0000000 --- a/tests/codeception/api/unit/models/profile/ChangePasswordFormTest.php +++ /dev/null @@ -1,135 +0,0 @@ - AccountFixture::class, - 'accountSessions' => AccountSessionFixture::class, - ]; - } - - public function testValidatePasswordAndRePasswordMatch() { - $this->specify('error.rePassword_does_not_match expected if passwords not match', function() { - $account = new Account(); - $account->setPassword('12345678'); - $model = new ChangePasswordForm($account, [ - 'password' => '12345678', - 'newPassword' => 'my-new-password', - 'newRePassword' => 'another-password', - ]); - $model->validatePasswordAndRePasswordMatch('newRePassword'); - expect($model->getErrors('newRePassword'))->equals(['error.rePassword_does_not_match']); - }); - - $this->specify('no errors expected if passwords are valid', function() { - $account = new Account(); - $account->setPassword('12345678'); - $model = new ChangePasswordForm($account, [ - 'password' => '12345678', - 'newPassword' => 'my-new-password', - 'newRePassword' => 'my-new-password', - ]); - $model->validatePasswordAndRePasswordMatch('newRePassword'); - expect($model->getErrors('newRePassword'))->isEmpty(); - }); - - $this->specify('error.rePassword_does_not_match expected even if there are errors on other attributes', function() { - // this is very important, because password change flow may be combined of two steps - // therefore we need to validate password sameness before we will validate current account password - $account = new Account(); - $account->setPassword('12345678'); - $model = new ChangePasswordForm($account, [ - 'newPassword' => 'my-new-password', - 'newRePassword' => 'another-password', - ]); - $model->validate(); - expect($model->getErrors('newRePassword'))->equals(['error.rePassword_does_not_match']); - }); - } - - public function testChangePassword() { - $this->specify('successfully change password with modern hash strategy', function() { - /** @var Account $account */ - $account = Account::findOne($this->tester->grabFixture('accounts', 'admin')['id']); - $model = new ChangePasswordForm($account, [ - 'password' => 'password_0', - 'newPassword' => 'my-new-password', - 'newRePassword' => 'my-new-password', - ]); - - $callTime = time(); - expect('form should return true', $model->changePassword())->true(); - expect('new password should be successfully stored into account', $account->validatePassword('my-new-password'))->true(); - expect('password change time updated', $account->password_changed_at)->greaterOrEquals($callTime); - }); - - $this->specify('successfully change password with legacy hash strategy', function() { - /** @var Account $account */ - $account = Account::findOne($this->tester->grabFixture('accounts', 'user-with-old-password-type')['id']); - $model = new ChangePasswordForm($account, [ - 'password' => '12345678', - 'newPassword' => 'my-new-password', - 'newRePassword' => 'my-new-password', - ]); - - $callTime = time(); - expect($model->changePassword())->true(); - expect($account->validatePassword('my-new-password'))->true(); - expect($account->password_changed_at)->greaterOrEquals($callTime); - expect($account->password_hash_strategy)->equals(Account::PASS_HASH_STRATEGY_YII2); - }); - } - - public function testChangePasswordWithLogout() { - /** @var Component|\PHPUnit_Framework_MockObject_MockObject $component */ - $component = $this->getMockBuilder(Component::class) - ->setMethods(['getActiveSession', 'terminateSessions']) - ->setConstructorArgs([[ - 'identityClass' => AccountIdentity::class, - 'enableSession' => false, - 'loginUrl' => null, - 'secret' => 'secret', - ]]) - ->getMock(); - - /** @var AccountSession $session */ - $session = AccountSession::findOne($this->tester->grabFixture('accountSessions', 'admin2')['id']); - - $component - ->expects($this->any()) - ->method('getActiveSession') - ->will($this->returnValue($session)); - - $component - ->expects($this->once()) - ->method('terminateSessions'); - - Yii::$app->set('user', $component); - - /** @var Account $account */ - $account = $this->tester->grabFixture('accounts', 'admin'); - $model = new ChangePasswordForm($account, [ - 'password' => 'password_0', - 'newPassword' => 'my-new-password', - 'newRePassword' => 'my-new-password', - 'logoutAll' => true, - ]); - - $this->assertTrue($model->changePassword()); - } - -} diff --git a/tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php b/tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php index 5157b05..e69de29 100644 --- a/tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php +++ b/tests/codeception/api/unit/models/profile/TwoFactorAuthFormTest.php @@ -1,208 +0,0 @@ -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('<_/>'); - - $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('data:image/svg+xml,<_/>', $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 Component|\PHPUnit_Framework_MockObject_MockObject $component */ - $component = $this->getMockBuilder(Component::class) - ->setMethods(['terminateSessions']) - ->setConstructorArgs([[ - 'identityClass' => AccountIdentity::class, - 'enableSession' => false, - 'loginUrl' => null, - 'secret' => 'secret', - ]]) - ->getMock(); - - $component - ->expects($this->once()) - ->method('terminateSessions'); - - Yii::$app->set('user', $component); - - /** @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()); - } - - public function testSetOtpSecret() { - /** @var Account|\PHPUnit_Framework_MockObject_MockObject $account */ - $account = $this->getMockBuilder(Account::class) - ->setMethods(['save']) - ->getMock(); - - $account->expects($this->exactly(2)) - ->method('save') - ->willReturn(true); - - $model = new TwoFactorAuthForm($account); - $this->callProtected($model, 'setOtpSecret'); - $this->assertEquals(24, strlen($model->getAccount()->otp_secret)); - $this->assertSame(strtoupper($model->getAccount()->otp_secret), $model->getAccount()->otp_secret); - - $model = new TwoFactorAuthForm($account); - $this->callProtected($model, 'setOtpSecret', 25); - $this->assertEquals(25, strlen($model->getAccount()->otp_secret)); - $this->assertSame(strtoupper($model->getAccount()->otp_secret), $model->getAccount()->otp_secret); - } - -} diff --git a/tests/codeception/api/unit/modules/accounts/models/AcceptRulesFormTest.php b/tests/codeception/api/unit/modules/accounts/models/AcceptRulesFormTest.php new file mode 100644 index 0000000..775e3d8 --- /dev/null +++ b/tests/codeception/api/unit/modules/accounts/models/AcceptRulesFormTest.php @@ -0,0 +1,22 @@ +shouldReceive('save')->andReturn(true); + $account->rules_agreement_version = LATEST_RULES_VERSION - 1; + + $model = new AcceptRulesForm($account); + $this->assertTrue($model->performAction()); + $this->assertEquals(LATEST_RULES_VERSION, $account->rules_agreement_version); + } + +} diff --git a/tests/codeception/api/unit/models/profile/ChangeEmail/ConfirmNewEmailFormTest.php b/tests/codeception/api/unit/modules/accounts/models/ChangeEmailFormTest.php similarity index 82% rename from tests/codeception/api/unit/models/profile/ChangeEmail/ConfirmNewEmailFormTest.php rename to tests/codeception/api/unit/modules/accounts/models/ChangeEmailFormTest.php index 3ac9e26..eb7f8f5 100644 --- a/tests/codeception/api/unit/models/profile/ChangeEmail/ConfirmNewEmailFormTest.php +++ b/tests/codeception/api/unit/modules/accounts/models/ChangeEmailFormTest.php @@ -1,14 +1,14 @@ getAccountId()); $newEmailConfirmationFixture = $this->tester->grabFixture('emailActivations', 'newEmailConfirmation'); - $model = new ConfirmNewEmailForm($account, [ + $model = new ChangeEmailForm($account, [ 'key' => $newEmailConfirmationFixture['key'], ]); - $this->assertTrue($model->changeEmail()); + $this->assertTrue($model->performAction()); $this->assertNull(EmailActivation::findOne([ 'account_id' => $account->id, 'type' => EmailActivation::TYPE_NEW_EMAIL_CONFIRMATION, ])); + /** @noinspection UnserializeExploitsInspection */ $data = unserialize($newEmailConfirmationFixture['_data']); $this->assertEquals($data['newEmail'], $account->email); $this->tester->canSeeAmqpMessageIsCreated('events'); @@ -37,7 +38,7 @@ class ConfirmNewEmailFormTest extends TestCase { public function testCreateTask() { /** @var Account $account */ $account = Account::findOne($this->getAccountId()); - $model = new ConfirmNewEmailForm($account); + $model = new ChangeEmailForm($account); $model->createTask(1, 'test1@ely.by', 'test@ely.by'); $message = $this->tester->grabLastSentAmqpMessage('events'); $body = json_decode($message->getBody(), true); diff --git a/tests/codeception/api/unit/modules/accounts/models/ChangeLanguageFormTest.php b/tests/codeception/api/unit/modules/accounts/models/ChangeLanguageFormTest.php new file mode 100644 index 0000000..4476318 --- /dev/null +++ b/tests/codeception/api/unit/modules/accounts/models/ChangeLanguageFormTest.php @@ -0,0 +1,21 @@ +shouldReceive('save')->andReturn(true); + + $model = new ChangeLanguageForm($account); + $model->lang = 'ru'; + $this->assertTrue($model->performAction()); + $this->assertEquals('ru', $account->lang); + } + +} diff --git a/tests/codeception/api/unit/modules/accounts/models/ChangePasswordFormTest.php b/tests/codeception/api/unit/modules/accounts/models/ChangePasswordFormTest.php new file mode 100644 index 0000000..df6f612 --- /dev/null +++ b/tests/codeception/api/unit/modules/accounts/models/ChangePasswordFormTest.php @@ -0,0 +1,139 @@ +setPassword('12345678'); + $model = new ChangePasswordForm($account, [ + 'password' => '12345678', + 'newPassword' => 'my-new-password', + 'newRePassword' => 'another-password', + ]); + $model->validatePasswordAndRePasswordMatch('newRePassword'); + $this->assertEquals( + [E::NEW_RE_PASSWORD_DOES_NOT_MATCH], + $model->getErrors('newRePassword'), + 'error.rePassword_does_not_match expected if passwords not match' + ); + + $account = new Account(); + $account->setPassword('12345678'); + $model = new ChangePasswordForm($account, [ + 'password' => '12345678', + 'newPassword' => 'my-new-password', + 'newRePassword' => 'my-new-password', + ]); + $model->validatePasswordAndRePasswordMatch('newRePassword'); + $this->assertEmpty($model->getErrors('newRePassword'), 'no errors expected if passwords are valid'); + + // this is very important, because password change flow may be combined of two steps + // therefore we need to validate password sameness before we will validate current account password + $account = new Account(); + $account->setPassword('12345678'); + $model = new ChangePasswordForm($account, [ + 'newPassword' => 'my-new-password', + 'newRePassword' => 'another-password', + ]); + $model->validate(); + $this->assertEquals( + [E::NEW_RE_PASSWORD_DOES_NOT_MATCH], + $model->getErrors('newRePassword'), + 'error.rePassword_does_not_match expected even if there are errors on other attributes' + ); + $this->assertEmpty($model->getErrors('password')); + } + + public function testPerformAction() { + $component = mock(Component::class . '[terminateSessions]', [[ + 'identityClass' => Identity::class, + 'enableSession' => false, + 'loginUrl' => null, + 'secret' => 'secret', + ]]); + $component->shouldNotReceive('terminateSessions'); + + Yii::$app->set('user', $component); + + $transaction = mock(Transaction::class . '[commit]'); + $transaction->shouldReceive('commit'); + $connection = mock(Yii::$app->db); + $connection->shouldReceive('beginTransaction')->andReturn($transaction); + + Yii::$app->set('db', $connection); + + /** @var Account|\Mockery\MockInterface $account */ + $account = mock(Account::class . '[save]'); + $account->shouldReceive('save')->andReturn(true); + $account->setPassword('password_0'); + + $model = new ChangePasswordForm($account, [ + 'password' => 'password_0', + 'newPassword' => 'my-new-password', + 'newRePassword' => 'my-new-password', + ]); + + $callTime = time(); + $this->assertTrue($model->performAction(), 'successfully change password with modern hash strategy'); + $this->assertTrue($account->validatePassword('my-new-password'), 'new password should be successfully stored into account'); + $this->assertGreaterThanOrEqual($callTime, $account->password_changed_at, 'password change time updated'); + + /** @var Account|\Mockery\MockInterface $account */ + $account = mock(Account::class . '[save]'); + $account->shouldReceive('save')->andReturn(true); + $account->email = 'mock@ely.by'; + $account->password_hash_strategy = Account::PASS_HASH_STRATEGY_OLD_ELY; + $account->password_hash = UserPass::make($account->email, '12345678'); + + $model = new ChangePasswordForm($account, [ + 'password' => '12345678', + 'newPassword' => 'my-new-password', + 'newRePassword' => 'my-new-password', + ]); + + $callTime = time(); + $this->assertTrue($model->performAction(), 'successfully change password with legacy hash strategy'); + $this->assertTrue($account->validatePassword('my-new-password')); + $this->assertGreaterThanOrEqual($callTime, $account->password_changed_at); + $this->assertEquals(Account::PASS_HASH_STRATEGY_YII2, $account->password_hash_strategy); + } + + public function testPerformActionWithLogout() { + /** @var Account|\Mockery\MockInterface $account */ + $account = mock(Account::class . '[save]'); + $account->shouldReceive('save')->andReturn(true); + $account->setPassword('password_0'); + + /** @var Component|\Mockery\MockInterface $component */ + $component = mock(Component::class . '[terminateSessions]', [[ + 'identityClass' => Identity::class, + 'enableSession' => false, + 'loginUrl' => null, + 'secret' => 'secret', + ]]); + $component->shouldReceive('terminateSessions')->once()->withArgs([$account, Component::KEEP_CURRENT_SESSION]); + + Yii::$app->set('user', $component); + + $model = new ChangePasswordForm($account, [ + 'password' => 'password_0', + 'newPassword' => 'my-new-password', + 'newRePassword' => 'my-new-password', + 'logoutAll' => true, + ]); + + $this->assertTrue($model->performAction()); + } + +} diff --git a/tests/codeception/api/unit/models/profile/ChangeUsernameFormTest.php b/tests/codeception/api/unit/modules/accounts/models/ChangeUsernameFormTest.php similarity index 77% rename from tests/codeception/api/unit/models/profile/ChangeUsernameFormTest.php rename to tests/codeception/api/unit/modules/accounts/models/ChangeUsernameFormTest.php index cfc074d..1931272 100644 --- a/tests/codeception/api/unit/models/profile/ChangeUsernameFormTest.php +++ b/tests/codeception/api/unit/modules/accounts/models/ChangeUsernameFormTest.php @@ -1,18 +1,14 @@ user->setIdentity($this->getAccount()); - } - - public function testChange() { + public function testPerformAction() { $model = new ChangeUsernameForm($this->getAccount(), [ 'password' => 'password_0', 'username' => 'my_new_nickname', ]); - $this->assertTrue($model->change()); + $this->assertTrue($model->performAction()); $this->assertEquals('my_new_nickname', Account::findOne($this->getAccountId())->username); $this->assertInstanceOf(UsernameHistory::class, UsernameHistory::findOne(['username' => 'my_new_nickname'])); $this->tester->canSeeAmqpMessageIsCreated('events'); } - public function testChangeWithoutChange() { + public function testPerformActionWithTheSameUsername() { $account = $this->getAccount(); $username = $account->username; $model = new ChangeUsernameForm($account, [ @@ -45,7 +36,7 @@ class ChangeUsernameFormTest extends TestCase { 'username' => $username, ]); $callTime = time(); - $this->assertTrue($model->change()); + $this->assertTrue($model->performAction()); $this->assertNull(UsernameHistory::findOne([ 'AND', 'username' => $username, @@ -54,13 +45,13 @@ class ChangeUsernameFormTest extends TestCase { $this->tester->cantSeeAmqpMessageIsCreated('events'); } - public function testChangeCase() { + public function testPerformActionWithChangeCase() { $newUsername = mb_strtoupper($this->tester->grabFixture('accounts', 'admin')['username']); $model = new ChangeUsernameForm($this->getAccount(), [ 'password' => 'password_0', 'username' => $newUsername, ]); - $this->assertTrue($model->change()); + $this->assertTrue($model->performAction()); $this->assertEquals($newUsername, Account::findOne($this->getAccountId())->username); $this->assertInstanceOf( UsernameHistory::class, @@ -80,12 +71,12 @@ class ChangeUsernameFormTest extends TestCase { $this->assertEquals('test', $body['oldUsername']); } - private function getAccount(): AccountIdentity { - return AccountIdentity::findOne($this->getAccountId()); + private function getAccount(): Account { + return $this->tester->grabFixture('accounts', 'admin'); } private function getAccountId() { - return $this->tester->grabFixture('accounts', 'admin')->id; + return $this->getAccount()->id; } } diff --git a/tests/codeception/api/unit/modules/accounts/models/DisableTwoFactorAuthFormTest.php b/tests/codeception/api/unit/modules/accounts/models/DisableTwoFactorAuthFormTest.php new file mode 100644 index 0000000..648bffa --- /dev/null +++ b/tests/codeception/api/unit/modules/accounts/models/DisableTwoFactorAuthFormTest.php @@ -0,0 +1,42 @@ +makePartial(); + $account->shouldReceive('save')->once()->andReturn(true); + + $account->is_otp_enabled = true; + $account->otp_secret = 'mock secret'; + + /** @var DisableTwoFactorAuthForm|\Mockery\MockInterface $model */ + $model = mock(DisableTwoFactorAuthForm::class . '[validate]', [$account]); + $model->shouldReceive('validate')->once()->andReturn(true); + + $this->assertTrue($model->performAction()); + $this->assertNull($account->otp_secret); + $this->assertFalse($account->is_otp_enabled); + } + + public function testValidateOtpEnabled() { + $account = new Account(); + $account->is_otp_enabled = false; + $model = new DisableTwoFactorAuthForm($account); + $model->validateOtpEnabled('account'); + $this->assertEquals([E::OTP_NOT_ENABLED], $model->getErrors('account')); + + $account = new Account(); + $account->is_otp_enabled = true; + $model = new DisableTwoFactorAuthForm($account); + $model->validateOtpEnabled('account'); + $this->assertEmpty($model->getErrors('account')); + } + +} diff --git a/tests/codeception/api/unit/modules/accounts/models/EnableTwoFactorAuthFormTest.php b/tests/codeception/api/unit/modules/accounts/models/EnableTwoFactorAuthFormTest.php new file mode 100644 index 0000000..a95cce5 --- /dev/null +++ b/tests/codeception/api/unit/modules/accounts/models/EnableTwoFactorAuthFormTest.php @@ -0,0 +1,54 @@ +shouldReceive('save')->andReturn(true); + $account->is_otp_enabled = false; + $account->otp_secret = 'mock secret'; + + /** @var Component|\Mockery\MockInterface $component */ + $component = mock(Component::class . '[terminateSessions]', [[ + 'identityClass' => Identity::class, + 'enableSession' => false, + 'loginUrl' => null, + 'secret' => 'secret', + ]]); + $component->shouldReceive('terminateSessions')->withArgs([$account, Component::KEEP_CURRENT_SESSION]); + + Yii::$app->set('user', $component); + + /** @var EnableTwoFactorAuthForm|\Mockery\MockInterface $model */ + $model = mock(EnableTwoFactorAuthForm::class . '[validate]', [$account]); + $model->shouldReceive('validate')->andReturn(true); + + $this->assertTrue($model->performAction()); + $this->assertTrue($account->is_otp_enabled); + } + + public function testValidateOtpDisabled() { + $account = new Account(); + $account->is_otp_enabled = true; + $model = new EnableTwoFactorAuthForm($account); + $model->validateOtpDisabled('account'); + $this->assertEquals([E::OTP_ALREADY_ENABLED], $model->getErrors('account')); + + $account = new Account(); + $account->is_otp_enabled = false; + $model = new EnableTwoFactorAuthForm($account); + $model->validateOtpDisabled('account'); + $this->assertEmpty($model->getErrors('account')); + } + +} diff --git a/tests/codeception/api/unit/models/profile/ChangeEmail/InitStateFormTest.php b/tests/codeception/api/unit/modules/accounts/models/SendEmailVerificationFormTest.php similarity index 79% rename from tests/codeception/api/unit/models/profile/ChangeEmail/InitStateFormTest.php rename to tests/codeception/api/unit/modules/accounts/models/SendEmailVerificationFormTest.php index e073115..4a34e36 100644 --- a/tests/codeception/api/unit/models/profile/ChangeEmail/InitStateFormTest.php +++ b/tests/codeception/api/unit/modules/accounts/models/SendEmailVerificationFormTest.php @@ -1,7 +1,7 @@ tester->grabFixture('accounts', 'admin'); - $model = new InitStateForm($account); + $model = new SendEmailVerificationForm($account); $activationModel = $model->createCode(); $this->assertInstanceOf(CurrentEmailConfirmation::class, $activationModel); $this->assertEquals($account->id, $activationModel->account_id); @@ -31,10 +31,10 @@ class InitStateFormTest extends TestCase { public function testSendCurrentEmailConfirmation() { /** @var Account $account */ $account = $this->tester->grabFixture('accounts', 'admin'); - $model = new InitStateForm($account, [ + $model = new SendEmailVerificationForm($account, [ 'password' => 'password_0', ]); - $this->assertTrue($model->sendCurrentEmailConfirmation()); + $this->assertTrue($model->performAction()); $this->assertTrue(EmailActivation::find()->andWhere([ 'account_id' => $account->id, 'type' => EmailActivation::TYPE_CURRENT_EMAIL_CONFIRMATION, diff --git a/tests/codeception/api/unit/models/profile/ChangeEmail/NewEmailFormTest.php b/tests/codeception/api/unit/modules/accounts/models/SendNewEmailVerificationFormTest.php similarity index 74% rename from tests/codeception/api/unit/models/profile/ChangeEmail/NewEmailFormTest.php rename to tests/codeception/api/unit/modules/accounts/models/SendNewEmailVerificationFormTest.php index a34c842..0802019 100644 --- a/tests/codeception/api/unit/models/profile/ChangeEmail/NewEmailFormTest.php +++ b/tests/codeception/api/unit/modules/accounts/models/SendNewEmailVerificationFormTest.php @@ -1,15 +1,17 @@ tester->grabFixture('accounts', 'admin'); - $model = new NewEmailForm($account); + $model = new SendNewEmailVerificationForm($account); $model->email = 'my-new-email@ely.by'; $activationModel = $model->createCode(); $this->assertInstanceOf(NewEmailConfirmation::class, $activationModel); @@ -33,13 +35,14 @@ class NewEmailFormTest extends TestCase { public function testSendNewEmailConfirmation() { /** @var Account $account */ $account = $this->tester->grabFixture('accounts', 'account-with-change-email-init-state'); - /** @var NewEmailForm $model */ + /** @var SendNewEmailVerificationForm $model */ $key = $this->tester->grabFixture('emailActivations', 'currentChangeEmailConfirmation')['key']; - $model = new NewEmailForm($account, [ + $model = new SendNewEmailVerificationForm($account, [ 'key' => $key, 'email' => 'my-new-email@ely.by', ]); - $this->assertTrue($model->sendNewEmailConfirmation()); + Mock::func(EmailValidator::class, 'checkdnsrr')->andReturn(true); + $this->assertTrue($model->performAction()); $this->assertNull(EmailActivation::findOne($key)); $this->assertNotNull(EmailActivation::findOne([ 'account_id' => $account->id, diff --git a/tests/codeception/api/unit/modules/accounts/models/TwoFactorAuthInfoTest.php b/tests/codeception/api/unit/modules/accounts/models/TwoFactorAuthInfoTest.php new file mode 100644 index 0000000..6193a5e --- /dev/null +++ b/tests/codeception/api/unit/modules/accounts/models/TwoFactorAuthInfoTest.php @@ -0,0 +1,47 @@ +shouldReceive('save')->andReturn(true); + + $account->email = 'mock@email.com'; + $account->otp_secret = null; + + $model = new TwoFactorAuthInfo($account); + + $result = $model->getCredentials(); + $this->assertTrue(is_array($result)); + $this->assertArrayHasKey('qr', $result); + $this->assertArrayHasKey('uri', $result); + $this->assertArrayHasKey('secret', $result); + $this->assertSame($account->otp_secret, $result['secret']); + $this->assertSame(strtoupper($account->otp_secret), $account->otp_secret); + $this->assertStringStartsWith('data:image/svg+xml,assertEmpty(libxml_get_errors()); + + /** @var Account|\Mockery\MockInterface $account */ + $account = mock(Account::class . '[save]'); + $account->shouldReceive('save')->andReturn(true); + + $account->email = 'mock@email.com'; + $account->otp_secret = 'AAAA'; + + $model = new TwoFactorAuthInfo($account); + + $result = $model->getCredentials(); + $this->assertEquals('AAAA', $result['secret']); + } + +} diff --git a/tests/codeception/api/unit/modules/authserver/models/AuthenticationFormTest.php b/tests/codeception/api/unit/modules/authserver/models/AuthenticationFormTest.php index c7b4c5d..f4304fe 100644 --- a/tests/codeception/api/unit/modules/authserver/models/AuthenticationFormTest.php +++ b/tests/codeception/api/unit/modules/authserver/models/AuthenticationFormTest.php @@ -1,7 +1,6 @@ setMethods(['getAccount']) ->getMock(); - $account = new AccountIdentity(); + $account = new Account(); $account->username = 'dummy'; $account->email = 'dummy@ely.by'; $account->status = $status; diff --git a/tests/codeception/api/unit/modules/internal/models/BanFormTest.php b/tests/codeception/api/unit/modules/internal/models/BanFormTest.php index b63c15a..a9fe788 100644 --- a/tests/codeception/api/unit/modules/internal/models/BanFormTest.php +++ b/tests/codeception/api/unit/modules/internal/models/BanFormTest.php @@ -2,7 +2,7 @@ namespace tests\codeception\api\unit\modules\internal\models; use api\modules\internal\helpers\Error as E; -use api\modules\internal\models\BanForm; +use api\modules\accounts\models\BanAccountForm; use common\models\Account; use tests\codeception\api\unit\TestCase; @@ -11,13 +11,13 @@ class BanFormTest extends TestCase { public function testValidateAccountActivity() { $account = new Account(); $account->status = Account::STATUS_ACTIVE; - $form = new BanForm($account); + $form = new BanAccountForm($account); $form->validateAccountActivity(); $this->assertEmpty($form->getErrors('account')); $account = new Account(); $account->status = Account::STATUS_BANNED; - $form = new BanForm($account); + $form = new BanAccountForm($account); $form->validateAccountActivity(); $this->assertEquals([E::ACCOUNT_ALREADY_BANNED], $form->getErrors('account')); } @@ -32,8 +32,8 @@ class BanFormTest extends TestCase { ->method('save') ->willReturn(true); - $model = new BanForm($account); - $this->assertTrue($model->ban()); + $model = new BanAccountForm($account); + $this->assertTrue($model->performAction()); $this->assertEquals(Account::STATUS_BANNED, $account->status); $this->tester->canSeeAmqpMessageIsCreated('events'); } @@ -42,14 +42,14 @@ class BanFormTest extends TestCase { $account = new Account(); $account->id = 3; - $model = new BanForm($account); + $model = new BanAccountForm($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 = new BanAccountForm($account); $model->duration = 123; $model->message = 'test'; $model->createTask(); diff --git a/tests/codeception/api/unit/modules/internal/models/PardonFormTest.php b/tests/codeception/api/unit/modules/internal/models/PardonFormTest.php index c05d9a0..f245f7f 100644 --- a/tests/codeception/api/unit/modules/internal/models/PardonFormTest.php +++ b/tests/codeception/api/unit/modules/internal/models/PardonFormTest.php @@ -2,7 +2,7 @@ namespace tests\codeception\api\unit\modules\internal\models; use api\modules\internal\helpers\Error as E; -use api\modules\internal\models\PardonForm; +use api\modules\accounts\models\PardonAccountForm; use common\models\Account; use tests\codeception\api\unit\TestCase; @@ -11,13 +11,13 @@ class PardonFormTest extends TestCase { public function testValidateAccountBanned() { $account = new Account(); $account->status = Account::STATUS_BANNED; - $form = new PardonForm($account); + $form = new PardonAccountForm($account); $form->validateAccountBanned(); $this->assertEmpty($form->getErrors('account')); $account = new Account(); $account->status = Account::STATUS_ACTIVE; - $form = new PardonForm($account); + $form = new PardonAccountForm($account); $form->validateAccountBanned(); $this->assertEquals([E::ACCOUNT_NOT_BANNED], $form->getErrors('account')); } @@ -33,8 +33,8 @@ class PardonFormTest extends TestCase { ->willReturn(true); $account->status = Account::STATUS_BANNED; - $model = new PardonForm($account); - $this->assertTrue($model->pardon()); + $model = new PardonAccountForm($account); + $this->assertTrue($model->performAction()); $this->assertEquals(Account::STATUS_ACTIVE, $account->status); $this->tester->canSeeAmqpMessageIsCreated('events'); } @@ -43,7 +43,7 @@ class PardonFormTest extends TestCase { $account = new Account(); $account->id = 3; - $model = new PardonForm($account); + $model = new PardonAccountForm($account); $model->createTask(); $message = json_decode($this->tester->grabLastSentAmqpMessage('events')->body, true); $this->assertSame(3, $message['accountId']); diff --git a/tests/codeception/api/unit/traits/AccountFinderTest.php b/tests/codeception/api/unit/traits/AccountFinderTest.php index dcba39f..1ae15ac 100644 --- a/tests/codeception/api/unit/traits/AccountFinderTest.php +++ b/tests/codeception/api/unit/traits/AccountFinderTest.php @@ -1,7 +1,6 @@ specify('founded account for passed login data', function() { - $model = new AccountFinderTestTestClass(); - /** @var Account $account */ - $account = $this->tester->grabFixture('accounts', 'admin'); - $model->login = $account->email; - $account = $model->getAccount(); - expect($account)->isInstanceOf(Account::class); - expect($account->id)->equals($account->id); - }); + $model = new AccountFinderTestTestClass(); + /** @var Account $account */ + $accountFixture = $this->tester->grabFixture('accounts', 'admin'); + $model->login = $accountFixture->email; + $account = $model->getAccount(); + $this->assertInstanceOf(Account::class, $account); + $this->assertSame($accountFixture->id, $account->id, 'founded account for passed login data'); - $this->specify('founded account for passed login data with changed account model class name', function() { - /** @var AccountFinderTestTestClass $model */ - $model = new class extends AccountFinderTestTestClass { - protected function getAccountClassName() { - return AccountIdentity::class; - } - }; - /** @var Account $account */ - $account = $this->tester->grabFixture('accounts', 'admin'); - $model->login = $account->email; - $account = $model->getAccount(); - expect($account)->isInstanceOf(AccountIdentity::class); - expect($account->id)->equals($account->id); - }); - - $this->specify('null, if account not founded', function() { - $model = new AccountFinderTestTestClass(); - $model->login = 'unexpected'; - expect($account = $model->getAccount())->null(); - }); + $model = new AccountFinderTestTestClass(); + $model->login = 'unexpected'; + $this->assertNull($account = $model->getAccount(), 'null, if account can\'t be found'); } public function testGetLoginAttribute() { - $this->specify('if login look like email value, then \'email\'', function() { - $model = new AccountFinderTestTestClass(); - $model->login = 'erickskrauch@ely.by'; - expect($model->getLoginAttribute())->equals('email'); - }); + $model = new AccountFinderTestTestClass(); + $model->login = 'erickskrauch@ely.by'; + $this->assertEquals('email', $model->getLoginAttribute(), 'if login look like email value, then \'email\''); - $this->specify('username in any other case', function() { - $model = new AccountFinderTestTestClass(); - $model->login = 'erickskrauch'; - expect($model->getLoginAttribute())->equals('username'); - }); + $model = new AccountFinderTestTestClass(); + $model->login = 'erickskrauch'; + $this->assertEquals('username', $model->getLoginAttribute(), 'username in any other case'); } } @@ -71,7 +47,7 @@ class AccountFinderTestTestClass { public $login; - public function getLogin() { + public function getLogin(): string { return $this->login; } diff --git a/tests/codeception/common/helpers/Mock.php b/tests/codeception/common/helpers/Mock.php new file mode 100644 index 0000000..bc3e636 --- /dev/null +++ b/tests/codeception/common/helpers/Mock.php @@ -0,0 +1,24 @@ +getNamespaceName(); + } + +} diff --git a/tests/codeception/common/unit/rbac/rules/AccountOwnerTest.php b/tests/codeception/common/unit/rbac/rules/AccountOwnerTest.php new file mode 100644 index 0000000..2d8bb6d --- /dev/null +++ b/tests/codeception/common/unit/rbac/rules/AccountOwnerTest.php @@ -0,0 +1,53 @@ +id = 1; + $account->status = Account::STATUS_ACTIVE; + $account->rules_agreement_version = LATEST_RULES_VERSION; + + $identity = mock(IdentityInterface::class); + $identity->shouldReceive('getAccount')->andReturn($account); + + $component = mock(Component::class . '[findIdentityByAccessToken]', [['secret' => 'secret']]); + $component->shouldDeferMissing(); + $component->shouldReceive('findIdentityByAccessToken')->withArgs(['token'])->andReturn($identity); + + Yii::$app->set('user', $component); + + $this->assertFalse($rule->execute('token', $item, ['accountId' => 2])); + $this->assertFalse($rule->execute('token', $item, ['accountId' => '2'])); + $this->assertTrue($rule->execute('token', $item, ['accountId' => 1])); + $this->assertTrue($rule->execute('token', $item, ['accountId' => '1'])); + $account->rules_agreement_version = null; + $this->assertFalse($rule->execute('token', $item, ['accountId' => 1])); + $this->assertTrue($rule->execute('token', $item, ['accountId' => 1, 'optionalRules' => true])); + $account->rules_agreement_version = LATEST_RULES_VERSION; + $account->status = Account::STATUS_BANNED; + $this->assertFalse($rule->execute('token', $item, ['accountId' => 1])); + $this->assertFalse($rule->execute('token', $item, ['accountId' => 1, 'optionalRules' => true])); + } + + /** + * @expectedException \yii\base\InvalidParamException + */ + public function testExecuteWithException() { + (new AccountOwner())->execute('', new Item(), []); + } + +} diff --git a/tests/codeception/common/unit/validators/EmailValidatorTest.php b/tests/codeception/common/unit/validators/EmailValidatorTest.php index 4bf7256..d6f4425 100644 --- a/tests/codeception/common/unit/validators/EmailValidatorTest.php +++ b/tests/codeception/common/unit/validators/EmailValidatorTest.php @@ -3,8 +3,10 @@ namespace codeception\common\unit\validators; use common\validators\EmailValidator; use tests\codeception\common\fixtures\AccountFixture; +use tests\codeception\common\helpers\Mock; use tests\codeception\common\unit\TestCase; use yii\base\Model; +use yii\validators\EmailValidator as YiiEmailValidator; class EmailValidatorTest extends TestCase { @@ -29,6 +31,7 @@ class EmailValidatorTest extends TestCase { } public function testValidateAttributeLength() { + Mock::func(YiiEmailValidator::class, 'checkdnsrr')->andReturnTrue(); $model = $this->createModel( 'emailemailemailemailemailemailemailemailemailemailemailemailemailemailemailemailemail' . 'emailemailemailemailemailemailemailemailemailemailemailemailemailemailemailemailemail' . @@ -44,6 +47,8 @@ class EmailValidatorTest extends TestCase { } public function testValidateAttributeEmail() { + Mock::func(YiiEmailValidator::class, 'checkdnsrr')->times(3)->andReturnValues([false, false, true]); + $model = $this->createModel('non-email'); $this->validator->validateAttribute($model, 'field'); $this->assertEquals(['error.email_invalid'], $model->getErrors('field')); @@ -58,6 +63,8 @@ class EmailValidatorTest extends TestCase { } public function testValidateAttributeTempmail() { + Mock::func(YiiEmailValidator::class, 'checkdnsrr')->times(2)->andReturnTrue(); + $model = $this->createModel('ibrpycwyjdnt@dropmail.me'); $this->validator->validateAttribute($model, 'field'); $this->assertEquals(['error.email_is_tempmail'], $model->getErrors('field')); @@ -68,6 +75,8 @@ class EmailValidatorTest extends TestCase { } public function testValidateAttributeUnique() { + Mock::func(YiiEmailValidator::class, 'checkdnsrr')->times(3)->andReturnTrue(); + $this->tester->haveFixtures([ 'accounts' => AccountFixture::class, ]); diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index 66ea17f..42c486d 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -11,6 +11,7 @@ services: - testredis volumes: - ./..:/var/www/html + - ./.bash_history:/root/.bash_history environment: YII_DEBUG: "true" YII_ENV: "test"