diff --git a/.env-dist b/.env-dist index 95d87e4..9ef67e1 100644 --- a/.env-dist +++ b/.env-dist @@ -7,6 +7,8 @@ EMAILS_RENDERER_HOST=http://emails-renderer:3000 ## Security params JWT_USER_SECRET= +JWT_PUBLIC_KEY_PATH= +JWT_PRIVATE_KEY_PATH= ## External services RECAPTCHA_PUBLIC= diff --git a/api/codeception.dist.yml b/api/codeception.dist.yml index 4f785ce..ff9f984 100644 --- a/api/codeception.dist.yml +++ b/api/codeception.dist.yml @@ -22,4 +22,5 @@ coverage: - tests/* - codeception.dist.yml - codeception.yml + - index.php c3url: 'http://localhost/api/web/index.php' diff --git a/api/components/OAuth2/Entities/RefreshTokenEntity.php b/api/components/OAuth2/Entities/RefreshTokenEntity.php index 5ef3fc7..372f003 100644 --- a/api/components/OAuth2/Entities/RefreshTokenEntity.php +++ b/api/components/OAuth2/Entities/RefreshTokenEntity.php @@ -1,9 +1,11 @@ session; } + /** @var SessionStorage $sessionStorage */ $sessionStorage = $this->server->getSessionStorage(); - if (!$sessionStorage instanceof SessionStorage) { - throw new ErrorException('SessionStorage must be instance of ' . SessionStorage::class); - } + Assert::isInstanceOf($sessionStorage, SessionStorage::class); return $sessionStorage->getById($this->sessionId); } @@ -32,7 +33,7 @@ class RefreshTokenEntity extends \League\OAuth2\Server\Entity\RefreshTokenEntity public function setSession(OriginalSessionEntity $session): self { parent::setSession($session); - $this->setSessionId($session->getId()); + $this->setSessionId((int)$session->getId()); return $this; } diff --git a/api/components/OAuth2/Storage/ScopeStorage.php b/api/components/OAuth2/Storage/ScopeStorage.php index 89e5981..2ff847a 100644 --- a/api/components/OAuth2/Storage/ScopeStorage.php +++ b/api/components/OAuth2/Storage/ScopeStorage.php @@ -3,8 +3,8 @@ namespace api\components\OAuth2\Storage; use api\components\OAuth2\Entities\ClientEntity; use api\components\OAuth2\Entities\ScopeEntity; +use api\rbac\Permissions as P; use Assert\Assert; -use common\rbac\Permissions as P; use League\OAuth2\Server\Storage\AbstractStorage; use League\OAuth2\Server\Storage\ScopeInterface; diff --git a/api/components/Tokens/AlgorithmIsNotDefinedException.php b/api/components/Tokens/AlgorithmIsNotDefinedException.php new file mode 100644 index 0000000..740ca13 --- /dev/null +++ b/api/components/Tokens/AlgorithmIsNotDefinedException.php @@ -0,0 +1,12 @@ +privateKey = $privateKey; + $this->privateKeyPass = $privateKeyPass; + $this->publicKey = $publicKey; + } + + public function getAlgorithmId(): string { + return 'ES256'; + } + + public function getSigner(): Signer { + return new Sha256(); + } + + public function getPrivateKey(): Key { + if ($this->loadedPrivateKey === null) { + $this->loadedPrivateKey = new Key($this->privateKey, $this->privateKeyPass); + } + + return $this->loadedPrivateKey; + } + + public function getPublicKey(): Key { + if ($this->loadedPublicKey === null) { + $this->loadedPublicKey = new Key($this->publicKey); + } + + return $this->loadedPublicKey; + } + +} diff --git a/api/components/Tokens/Algorithms/HS256.php b/api/components/Tokens/Algorithms/HS256.php new file mode 100644 index 0000000..2bc7e66 --- /dev/null +++ b/api/components/Tokens/Algorithms/HS256.php @@ -0,0 +1,50 @@ +key = $key; + } + + public function getAlgorithmId(): string { + return 'HS256'; + } + + public function getSigner(): Signer { + return new Sha256(); + } + + public function getPrivateKey(): Key { + return $this->loadKey(); + } + + public function getPublicKey(): Key { + return $this->loadKey(); + } + + private function loadKey(): Key { + if ($this->loadedKey === null) { + $this->loadedKey = new Key($this->key); + } + + return $this->loadedKey; + } + +} diff --git a/api/components/Tokens/AlgorithmsManager.php b/api/components/Tokens/AlgorithmsManager.php new file mode 100644 index 0000000..af9d074 --- /dev/null +++ b/api/components/Tokens/AlgorithmsManager.php @@ -0,0 +1,42 @@ +getAlgorithmId(); + Assert::keyNotExists($this->algorithms, $id, 'passed algorithm is already exists'); + $this->algorithms[$algorithm->getSigner()->getAlgorithmId()] = $algorithm; + + return $this; + } + + /** + * @param string $algorithmId + * + * @return AlgorithmInterface + * @throws AlgorithmIsNotDefinedException + */ + public function get(string $algorithmId): AlgorithmInterface { + if (!isset($this->algorithms[$algorithmId])) { + throw new AlgorithmIsNotDefinedException($algorithmId); + } + + return $this->algorithms[$algorithmId]; + } + +} diff --git a/api/components/Tokens/Component.php b/api/components/Tokens/Component.php new file mode 100644 index 0000000..ec19bbf --- /dev/null +++ b/api/components/Tokens/Component.php @@ -0,0 +1,103 @@ +hmacKey, 'hmacKey must be set'); + Assert::notEmpty($this->privateKeyPath, 'privateKeyPath must be set'); + Assert::notEmpty($this->publicKeyPath, 'publicKeyPath must be set'); + } + + public function create(array $payloads = [], array $headers = []): Token { + $now = Carbon::now(); + $builder = (new Builder()) + ->issuedAt($now->getTimestamp()) + ->expiresAt($now->addHour()->getTimestamp()); + foreach ($payloads as $claim => $value) { + $builder->withClaim($claim, $value); + } + + foreach ($headers as $claim => $value) { + $builder->withHeader($claim, $value); + } + + /** @noinspection PhpUnhandledExceptionInspection */ + $algorithm = $this->getAlgorithmManager()->get(self::PREFERRED_ALGORITHM); + + return $builder->getToken($algorithm->getSigner(), $algorithm->getPrivateKey()); + } + + /** + * @param string $jwt + * + * @return Token + * @throws \InvalidArgumentException + */ + public function parse(string $jwt): Token { + return (new Parser())->parse($jwt); + } + + public function verify(Token $token): bool { + try { + $algorithm = $this->getAlgorithmManager()->get($token->getHeader('alg')); + return $token->verify($algorithm->getSigner(), $algorithm->getPublicKey()); + } catch (Exception $e) { + return false; + } + } + + private function getAlgorithmManager(): AlgorithmsManager { + if ($this->algorithmManager === null) { + $this->algorithmManager = new AlgorithmsManager([ + new Algorithms\HS256($this->hmacKey), + new Algorithms\ES256( + "file://{$this->privateKeyPath}", + $this->privateKeyPass, + "file://{$this->publicKeyPath}" + ), + ]); + } + + return $this->algorithmManager; + } + +} diff --git a/api/components/Tokens/TokensFactory.php b/api/components/Tokens/TokensFactory.php new file mode 100644 index 0000000..00fee41 --- /dev/null +++ b/api/components/Tokens/TokensFactory.php @@ -0,0 +1,32 @@ + 'accounts_web_user', + 'sub' => self::SUB_ACCOUNT_PREFIX . $account->id, + ]; + if ($session === null) { + // If we don't remember a session, the token should live longer + // so that the session doesn't end while working with the account + $payloads['exp'] = Carbon::now()->addDays(7)->getTimestamp(); + } else { + $payloads['jti'] = $session->id; + } + + return Yii::$app->tokens->create($payloads); + } + +} diff --git a/api/components/User/AuthenticationResult.php b/api/components/User/AuthenticationResult.php deleted file mode 100644 index 3cc0010..0000000 --- a/api/components/User/AuthenticationResult.php +++ /dev/null @@ -1,60 +0,0 @@ -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 f215fd0..bf00f9c 100644 --- a/api/components/User/Component.php +++ b/api/components/User/Component.php @@ -1,23 +1,10 @@ secret) { - throw new InvalidConfigException('secret must be specified'); - } - } - - public function findIdentityByAccessToken($accessToken): ?IdentityInterface { - if (empty($accessToken)) { - return null; - } - - /** @var \api\components\User\IdentityInterface|string $identityClass */ - $identityClass = $this->identityClass; - try { - return $identityClass::findIdentityByAccessToken($accessToken); - } catch (UnauthorizedHttpException $e) { - // Do nothing. It's okay to catch this. - } catch (Exception $e) { - Yii::error($e); - } - - return null; - } - - public function createJwtAuthenticationToken(Account $account, bool $rememberMe): AuthenticationResult { - $ip = Yii::$app->request->userIP; - $token = $this->createToken($account); - if ($rememberMe) { - $session = new AccountSession(); - $session->account_id = $account->id; - $session->setIp($ip); - $session->generateRefreshToken(); - if (!$session->save()) { - throw new ThisShouldNotHappenException('Cannot save account session model'); - } - - $token->addClaim(new Claim\JwtId($session->id)); - } else { - $session = null; - // If we don't remember a session, the token should live longer - // so that the session doesn't end while working with the account - $token->addClaim(new Claim\Expiration((new DateTime())->add(new DateInterval($this->sessionTimeout)))); - } - - $jwt = $this->serializeToken($token); - - return new AuthenticationResult($account, $jwt, $session); - } - - public function renewJwtAuthenticationToken(AccountSession $session): AuthenticationResult { - $transaction = Yii::$app->db->beginTransaction(); - - $account = $session->account; - $token = $this->createToken($account); - $token->addClaim(new Claim\JwtId($session->id)); - $jwt = $this->serializeToken($token); - - $result = new AuthenticationResult($account, $jwt, $session); - - $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; - } - - /** - * @param string $jwtString - * @return Token - * @throws VerificationException in case when some Claim not pass the validation - */ - public function parseToken(string $jwtString): Token { - $token = &self::$parsedTokensCache[$jwtString]; - if ($token === null) { - $jwt = new Jwt(); - try { - $notVerifiedToken = $jwt->deserialize($jwtString); - } catch (Exception $e) { - throw new VerificationException('Incorrect token encoding', 0, $e); - } - - $context = new VerificationContext(EncryptionFactory::create($this->getAlgorithm())); - $context->setSubject(self::JWT_SUBJECT_PREFIX); - $jwt->verify($notVerifiedToken, $context); - - $token = $notVerifiedToken; - } - - return $token; - } + public $identityClass = IdentityFactory::class; /** * The method searches AccountSession model, which one has been used to create current JWT token. * null will be returned in case when any of the following situations occurred: * - The user isn't authorized - * - There is no header with a token - * - Token validation isn't passed and some exception has been thrown + * - The user isn't authorized via JWT token * - No session key found in the token. This is possible if the user chose not to remember me * or just some old tokens, without the support of saving the used session * @@ -164,23 +49,18 @@ class Component extends YiiUserComponent { return null; } - $bearer = $this->getBearerToken(); - if ($bearer === null) { + /** @var IdentityInterface $identity */ + $identity = $this->getIdentity(); + if (!$identity instanceof JwtIdentity) { return null; } - try { - $token = $this->parseToken($bearer); - } catch (VerificationException $e) { + $sessionId = $identity->getToken()->getClaim('jti', false); + if ($sessionId === false) { return null; } - $sessionId = $token->getPayload()->findClaimByName(Claim\JwtId::NAME); - if ($sessionId === null) { - return null; - } - - return AccountSession::findOne($sessionId->getValue()); + return AccountSession::findOne($sessionId); } public function terminateSessions(Account $account, int $mode = 0): void { @@ -204,45 +84,4 @@ class Component extends YiiUserComponent { } } - public function getAlgorithm(): AlgorithmInterface { - return new Hs256($this->secret); - } - - protected function serializeToken(Token $token): string { - return (new Jwt())->serialize($token, EncryptionFactory::create($this->getAlgorithm())); - } - - protected function createToken(Account $account): Token { - $token = new Token(); - foreach ($this->getClaims($account) as $claim) { - $token->addClaim($claim); - } - - return $token; - } - - /** - * @param Account $account - * @return Claim\AbstractClaim[] - */ - protected function getClaims(Account $account): array { - $currentTime = new DateTime(); - - return [ - new ScopesClaim([R::ACCOUNTS_WEB_USER]), - new Claim\IssuedAt($currentTime), - new Claim\Expiration($currentTime->add(new DateInterval($this->expirationTimeout))), - new Claim\Subject(self::JWT_SUBJECT_PREFIX . $account->id), - ]; - } - - private function getBearerToken() { - $authHeader = Yii::$app->request->getHeaders()->get('Authorization'); - if ($authHeader === null || !preg_match('/^Bearer\s+(.*?)$/', $authHeader, $matches)) { - return null; - } - - return $matches[1]; - } - } diff --git a/api/components/User/IdentityFactory.php b/api/components/User/IdentityFactory.php new file mode 100644 index 0000000..2b59630 --- /dev/null +++ b/api/components/User/IdentityFactory.php @@ -0,0 +1,26 @@ + $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 index 71b41e1..e327e32 100644 --- a/api/components/User/JwtIdentity.php +++ b/api/components/User/JwtIdentity.php @@ -1,84 +1,89 @@ rawToken = $rawToken; + private function __construct(Token $token) { $this->token = $token; } public static function findIdentityByAccessToken($rawToken, $type = null): IdentityInterface { - /** @var \api\components\User\Component $component */ - $component = Yii::$app->user; try { - $token = $component->parseToken($rawToken); - } catch (ExpiredException $e) { - throw new UnauthorizedHttpException('Token expired'); + $token = Yii::$app->tokens->parse($rawToken); } catch (Exception $e) { Yii::error($e); throw new UnauthorizedHttpException('Incorrect token'); } - return new self($rawToken, $token); + if (!Yii::$app->tokens->verify($token)) { + throw new UnauthorizedHttpException('Incorrect token'); + } + + $now = Carbon::now(); + if ($token->isExpired($now)) { + throw new UnauthorizedHttpException('Token expired'); + } + + if (!$token->validate(new ValidationData($now->getTimestamp()))) { + throw new UnauthorizedHttpException('Incorrect token'); + } + + $sub = $token->getClaim('sub', false); + if ($sub !== false && strpos((string)$sub, TokensFactory::SUB_ACCOUNT_PREFIX) !== 0) { + throw new UnauthorizedHttpException('Incorrect token'); + } + + return new self($token); + } + + public function getToken(): Token { + return $this->token; } public function getAccount(): ?Account { - /** @var Subject $subject */ - $subject = $this->token->getPayload()->findClaimByName(Subject::NAME); - if ($subject === null) { + $subject = $this->token->getClaim('sub', false); + if ($subject === false) { return null; } - $value = $subject->getValue(); - if (!StringHelper::startsWith($value, Component::JWT_SUBJECT_PREFIX)) { - Yii::warning('Unknown jwt subject: ' . $value); - return null; - } + Assert::startsWith($subject, TokensFactory::SUB_ACCOUNT_PREFIX); + $accountId = (int)mb_substr($subject, mb_strlen(TokensFactory::SUB_ACCOUNT_PREFIX)); - $accountId = (int)mb_substr($value, mb_strlen(Component::JWT_SUBJECT_PREFIX)); - $account = Account::findOne($accountId); - if ($account === null) { - return null; - } - - return $account; + return Account::findOne(['id' => $accountId]); } public function getAssignedPermissions(): array { - /** @var Subject $scopesClaim */ - $scopesClaim = $this->token->getPayload()->findClaimByName(ScopesClaim::NAME); - if ($scopesClaim === null) { + $scopesClaim = $this->token->getClaim('ely-scopes', false); + if ($scopesClaim === false) { return []; } - return explode(',', $scopesClaim->getValue()); + return explode(',', $scopesClaim); } public function getId(): string { - return $this->rawToken; + return (string)$this->token; } + // @codeCoverageIgnoreStart public function getAuthKey() { throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth'); } @@ -91,4 +96,6 @@ class JwtIdentity implements IdentityInterface { throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth'); } + // @codeCoverageIgnoreEnd + } diff --git a/api/components/User/Identity.php b/api/components/User/OAuth2Identity.php similarity index 77% rename from api/components/User/Identity.php rename to api/components/User/OAuth2Identity.php index 0220eb2..bbb06cf 100644 --- a/api/components/User/Identity.php +++ b/api/components/User/OAuth2Identity.php @@ -1,4 +1,6 @@ oauth->getAccessTokenStorage()->get($token); if ($model === null) { @@ -65,6 +55,7 @@ class Identity implements IdentityInterface { return $this->_accessToken->getId(); } + // @codeCoverageIgnoreStart public function getAuthKey() { throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth'); } @@ -77,8 +68,10 @@ class Identity implements IdentityInterface { throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth'); } + // @codeCoverageIgnoreEnd + private function getSession(): OauthSession { - return OauthSession::findOne($this->_accessToken->getSessionId()); + return OauthSession::findOne(['id' => $this->_accessToken->getSessionId()]); } } diff --git a/api/components/User/ScopesClaim.php b/api/components/User/ScopesClaim.php deleted file mode 100644 index d89c76b..0000000 --- a/api/components/User/ScopesClaim.php +++ /dev/null @@ -1,30 +0,0 @@ -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-test.php b/api/config/config-test.php index 02ad46e..8f0952d 100644 --- a/api/config/config-test.php +++ b/api/config/config-test.php @@ -1,8 +1,11 @@ [ - 'user' => [ - 'secret' => 'tests-secret-key', + 'tokens' => [ + 'hmacKey' => 'tests-secret-key', + 'privateKeyPath' => codecept_data_dir('certs/private.pem'), + 'privateKeyPass' => null, + 'publicKeyPath' => codecept_data_dir('certs/public.pem'), ], 'reCaptcha' => [ 'public' => 'public-key', diff --git a/api/config/config.php b/api/config/config.php index 58b593e..1a9ffc2 100644 --- a/api/config/config.php +++ b/api/config/config.php @@ -10,7 +10,13 @@ return [ 'components' => [ 'user' => [ 'class' => api\components\User\Component::class, - 'secret' => getenv('JWT_USER_SECRET'), + ], + 'tokens' => [ + 'class' => api\components\Tokens\Component::class, + 'hmacKey' => getenv('JWT_USER_SECRET'), + 'privateKeyPath' => getenv('JWT_PRIVATE_KEY_PATH') ?: __DIR__ . '/../../data/certs/private.pem', + 'privateKeyPass' => getenv('JWT_PRIVATE_KEY_PASS') ?: null, + 'publicKeyPath' => getenv('JWT_PUBLIC_KEY_PATH') ?: __DIR__ . '/../../data/certs/public.pem', ], 'log' => [ 'traceLevel' => YII_DEBUG ? 3 : 0, diff --git a/api/controllers/AuthenticationController.php b/api/controllers/AuthenticationController.php index 27f1995..ee8c000 100644 --- a/api/controllers/AuthenticationController.php +++ b/api/controllers/AuthenticationController.php @@ -51,7 +51,7 @@ class AuthenticationController extends Controller { public function actionLogin() { $model = new LoginForm(); $model->load(Yii::$app->request->post()); - if (($result = $model->login()) === false) { + if (($result = $model->login()) === null) { $data = [ 'success' => false, 'errors' => $model->getFirstErrors(), @@ -66,7 +66,7 @@ class AuthenticationController extends Controller { return array_merge([ 'success' => true, - ], $result->getAsResponse()); + ], $result->formatAsOAuth2Response()); } public function actionLogout() { @@ -117,7 +117,7 @@ class AuthenticationController extends Controller { public function actionRecoverPassword() { $model = new RecoverPasswordForm(); $model->load(Yii::$app->request->post()); - if (($result = $model->recoverPassword()) === false) { + if (($result = $model->recoverPassword()) === null) { return [ 'success' => false, 'errors' => $model->getFirstErrors(), @@ -126,20 +126,20 @@ class AuthenticationController extends Controller { return array_merge([ 'success' => true, - ], $result->getAsResponse()); + ], $result->formatAsOAuth2Response()); } public function actionRefreshToken() { $model = new RefreshTokenForm(); $model->load(Yii::$app->request->post()); - if (($result = $model->renew()) === false) { + if (($result = $model->renew()) === null) { return [ 'success' => false, 'errors' => $model->getFirstErrors(), ]; } - $response = $result->getAsResponse(); + $response = $result->formatAsOAuth2Response(); unset($response['refresh_token']); return array_merge([ diff --git a/api/controllers/SignupController.php b/api/controllers/SignupController.php index 3634dc7..4f209f0 100644 --- a/api/controllers/SignupController.php +++ b/api/controllers/SignupController.php @@ -89,7 +89,7 @@ class SignupController extends Controller { return array_merge([ 'success' => true, - ], $result->getAsResponse()); + ], $result->formatAsOAuth2Response()); } } diff --git a/api/models/authentication/AuthenticationResult.php b/api/models/authentication/AuthenticationResult.php new file mode 100644 index 0000000..5e1db23 --- /dev/null +++ b/api/models/authentication/AuthenticationResult.php @@ -0,0 +1,47 @@ +token = $token; + $this->refreshToken = $refreshToken; + } + + public function getToken(): Token { + return $this->token; + } + + public function getRefreshToken(): ?string { + return $this->refreshToken; + } + + public function formatAsOAuth2Response(): array { + $response = [ + 'access_token' => (string)$this->token, + 'expires_in' => $this->token->getClaim('exp') - time(), + ]; + + $refreshToken = $this->refreshToken; + if ($refreshToken !== null) { + $response['refresh_token'] = $refreshToken; + } + + return $response; + } + +} diff --git a/api/models/authentication/ConfirmEmailForm.php b/api/models/authentication/ConfirmEmailForm.php index 5d6b7cc..60c6608 100644 --- a/api/models/authentication/ConfirmEmailForm.php +++ b/api/models/authentication/ConfirmEmailForm.php @@ -4,12 +4,14 @@ declare(strict_types=1); namespace api\models\authentication; use api\aop\annotations\CollectModelMetrics; +use api\components\Tokens\TokensFactory; use api\models\base\ApiForm; use api\validators\EmailActivationKeyValidator; use common\models\Account; +use common\models\AccountSession; use common\models\EmailActivation; +use Webmozart\Assert\Assert; use Yii; -use yii\base\ErrorException; class ConfirmEmailForm extends ApiForm { @@ -23,12 +25,10 @@ class ConfirmEmailForm extends ApiForm { /** * @CollectModelMetrics(prefix="signup.confirmEmail") - * @return \api\components\User\AuthenticationResult|bool - * @throws ErrorException */ - public function confirm() { + public function confirm(): ?AuthenticationResult { if (!$this->validate()) { - return false; + return null; } $transaction = Yii::$app->db->beginTransaction(); @@ -37,17 +37,22 @@ class ConfirmEmailForm extends ApiForm { $confirmModel = $this->key; $account = $confirmModel->account; $account->status = Account::STATUS_ACTIVE; - if (!$confirmModel->delete()) { - throw new ErrorException('Unable remove activation key.'); - } + /** @noinspection PhpUnhandledExceptionInspection */ + Assert::notSame($confirmModel->delete(), false, 'Unable remove activation key.'); - if (!$account->save()) { - throw new ErrorException('Unable activate user account.'); - } + Assert::true($account->save(), 'Unable activate user account.'); + + $session = new AccountSession(); + $session->account_id = $account->id; + $session->setIp(Yii::$app->request->userIP); + $session->generateRefreshToken(); + Assert::true($session->save(), 'Cannot save account session model'); + + $token = TokensFactory::createForAccount($account, $session); $transaction->commit(); - return Yii::$app->user->createJwtAuthenticationToken($account, true); + return new AuthenticationResult($token, $session->refresh_token); } } diff --git a/api/models/authentication/LoginForm.php b/api/models/authentication/LoginForm.php index 8919300..455bfa4 100644 --- a/api/models/authentication/LoginForm.php +++ b/api/models/authentication/LoginForm.php @@ -1,12 +1,17 @@ E::LOGIN_REQUIRED], ['login', 'validateLogin'], - ['password', 'required', 'when' => function(self $model) { + ['password', 'required', 'when' => function(self $model): bool { return !$model->hasErrors(); }, 'message' => E::PASSWORD_REQUIRED], ['password', 'validatePassword'], - ['totp', 'required', 'when' => function(self $model) { + ['totp', 'required', 'when' => function(self $model): bool { return !$model->hasErrors() && $model->getAccount()->is_otp_enabled; }, 'message' => E::TOTP_REQUIRED], ['totp', 'validateTotp'], @@ -41,15 +46,13 @@ class LoginForm extends ApiForm { ]; } - public function validateLogin($attribute) { - if (!$this->hasErrors()) { - if ($this->getAccount() === null) { - $this->addError($attribute, E::LOGIN_NOT_EXIST); - } + public function validateLogin(string $attribute): void { + if (!$this->hasErrors() && $this->getAccount() === null) { + $this->addError($attribute, E::LOGIN_NOT_EXIST); } } - public function validatePassword($attribute) { + public function validatePassword(string $attribute): void { if (!$this->hasErrors()) { $account = $this->getAccount(); if ($account === null || !$account->validatePassword($this->password)) { @@ -58,11 +61,12 @@ class LoginForm extends ApiForm { } } - public function validateTotp($attribute) { + public function validateTotp(string $attribute): void { if ($this->hasErrors()) { return; } + /** @var Account $account */ $account = $this->getAccount(); if (!$account->is_otp_enabled) { return; @@ -73,8 +77,9 @@ class LoginForm extends ApiForm { $validator->validateAttribute($this, $attribute); } - public function validateActivity($attribute) { + public function validateActivity(string $attribute): void { if (!$this->hasErrors()) { + /** @var Account $account */ $account = $this->getAccount(); if ($account->status === Account::STATUS_BANNED) { $this->addError($attribute, E::ACCOUNT_BANNED); @@ -92,20 +97,35 @@ class LoginForm extends ApiForm { /** * @CollectModelMetrics(prefix="authentication.login") - * @return \api\components\User\AuthenticationResult|bool */ - public function login() { + public function login(): ?AuthenticationResult { if (!$this->validate()) { - return false; + return null; } + $transaction = Yii::$app->db->beginTransaction(); + + /** @var Account $account */ $account = $this->getAccount(); if ($account->password_hash_strategy !== Account::PASS_HASH_STRATEGY_YII2) { $account->setPassword($this->password); - $account->save(); + Assert::true($account->save(), 'Unable to upgrade user\'s password'); } - return Yii::$app->user->createJwtAuthenticationToken($account, $this->rememberMe); + $session = null; + if ($this->rememberMe) { + $session = new AccountSession(); + $session->account_id = $account->id; + $session->setIp(Yii::$app->request->userIP); + $session->generateRefreshToken(); + Assert::true($session->save(), 'Cannot save account session model'); + } + + $token = TokensFactory::createForAccount($account, $session); + + $transaction->commit(); + + return new AuthenticationResult($token, $session ? $session->refresh_token : null); } } diff --git a/api/models/authentication/RecoverPasswordForm.php b/api/models/authentication/RecoverPasswordForm.php index 358fec0..957c1ec 100644 --- a/api/models/authentication/RecoverPasswordForm.php +++ b/api/models/authentication/RecoverPasswordForm.php @@ -1,14 +1,17 @@ EmailActivation::TYPE_FORGOT_PASSWORD_KEY], ['newPassword', 'required', 'message' => E::NEW_PASSWORD_REQUIRED], @@ -28,22 +31,18 @@ class RecoverPasswordForm extends ApiForm { ]; } - public function validatePasswordAndRePasswordMatch($attribute) { - if (!$this->hasErrors()) { - if ($this->newPassword !== $this->newRePassword) { - $this->addError($attribute, E::NEW_RE_PASSWORD_DOES_NOT_MATCH); - } + public function validatePasswordAndRePasswordMatch(string $attribute): void { + if (!$this->hasErrors() && $this->newPassword !== $this->newRePassword) { + $this->addError($attribute, E::NEW_RE_PASSWORD_DOES_NOT_MATCH); } } /** * @CollectModelMetrics(prefix="authentication.recoverPassword") - * @return \api\components\User\AuthenticationResult|bool - * @throws ErrorException */ - public function recoverPassword() { + public function recoverPassword(): ?AuthenticationResult { if (!$this->validate()) { - return false; + return null; } $transaction = Yii::$app->db->beginTransaction(); @@ -52,17 +51,16 @@ class RecoverPasswordForm extends ApiForm { $confirmModel = $this->key; $account = $confirmModel->account; $account->password = $this->newPassword; - if (!$confirmModel->delete()) { - throw new ErrorException('Unable remove activation key.'); - } + /** @noinspection PhpUnhandledExceptionInspection */ + Assert::notSame($confirmModel->delete(), false, 'Unable remove activation key.'); - if (!$account->save(false)) { - throw new ErrorException('Unable activate user account.'); - } + Assert::true($account->save(), 'Unable activate user account.'); + + $token = TokensFactory::createForAccount($account); $transaction->commit(); - return Yii::$app->user->createJwtAuthenticationToken($account, false); + return new AuthenticationResult($token); } } diff --git a/api/models/authentication/RefreshTokenForm.php b/api/models/authentication/RefreshTokenForm.php index 378f3af..92cd72c 100644 --- a/api/models/authentication/RefreshTokenForm.php +++ b/api/models/authentication/RefreshTokenForm.php @@ -1,10 +1,14 @@ E::REFRESH_TOKEN_REQUIRED], ['refresh_token', 'validateRefreshToken'], ]; } - public function validateRefreshToken() { - if (!$this->hasErrors()) { - /** @var AccountSession|null $token */ - if ($this->getSession() === null) { - $this->addError('refresh_token', E::REFRESH_TOKEN_NOT_EXISTS); - } + public function validateRefreshToken(): void { + if (!$this->hasErrors() && $this->findSession() === null) { + $this->addError('refresh_token', E::REFRESH_TOKEN_NOT_EXISTS); } } /** * @CollectModelMetrics(prefix="authentication.renew") - * @return \api\components\User\AuthenticationResult|bool */ - public function renew() { + public function renew(): ?AuthenticationResult { if (!$this->validate()) { - return false; + return null; } - /** @var \api\components\User\Component $component */ - $component = Yii::$app->user; + /** @var AccountSession $session */ + $session = $this->findSession(); + $account = $session->account; - return $component->renewJwtAuthenticationToken($this->getSession()); + $transaction = Yii::$app->db->beginTransaction(); + + $token = TokensFactory::createForAccount($account, $session); + + $session->setIp(Yii::$app->request->userIP); + $session->touch('last_refreshed_at'); + Assert::true($session->save(), 'Cannot update session info'); + + $transaction->commit(); + + return new AuthenticationResult($token, $session->refresh_token); } - /** - * @return AccountSession|null - */ - public function getSession() { + private function findSession(): ?AccountSession { if ($this->session === null) { $this->session = AccountSession::findOne(['refresh_token' => $this->refresh_token]); } diff --git a/api/modules/accounts/controllers/DefaultController.php b/api/modules/accounts/controllers/DefaultController.php index b9ac258..911d434 100644 --- a/api/modules/accounts/controllers/DefaultController.php +++ b/api/modules/accounts/controllers/DefaultController.php @@ -5,8 +5,8 @@ use api\controllers\Controller; use api\modules\accounts\actions; use api\modules\accounts\models\AccountInfo; use api\modules\accounts\models\TwoFactorAuthInfo; +use api\rbac\Permissions as P; use common\models\Account; -use common\rbac\Permissions as P; use Yii; use yii\filters\AccessControl; use yii\helpers\ArrayHelper; diff --git a/api/modules/accounts/models/AccountInfo.php b/api/modules/accounts/models/AccountInfo.php index 87b486a..0086cf9 100644 --- a/api/modules/accounts/models/AccountInfo.php +++ b/api/modules/accounts/models/AccountInfo.php @@ -2,8 +2,8 @@ namespace api\modules\accounts\models; use api\models\base\BaseAccountForm; +use api\rbac\Permissions as P; use common\models\Account; -use common\rbac\Permissions as P; use yii\di\Instance; use yii\web\User; diff --git a/api/modules/internal/controllers/AccountsController.php b/api/modules/internal/controllers/AccountsController.php index 92bf23c..abea541 100644 --- a/api/modules/internal/controllers/AccountsController.php +++ b/api/modules/internal/controllers/AccountsController.php @@ -2,8 +2,8 @@ namespace api\modules\internal\controllers; use api\controllers\Controller; +use api\rbac\Permissions as P; use common\models\Account; -use common\rbac\Permissions as P; use yii\filters\AccessControl; use yii\helpers\ArrayHelper; use yii\web\BadRequestHttpException; diff --git a/api/modules/oauth/controllers/AuthorizationController.php b/api/modules/oauth/controllers/AuthorizationController.php index 03d073f..51b1ae4 100644 --- a/api/modules/oauth/controllers/AuthorizationController.php +++ b/api/modules/oauth/controllers/AuthorizationController.php @@ -3,7 +3,7 @@ namespace api\modules\oauth\controllers; use api\controllers\Controller; use api\modules\oauth\models\OauthProcess; -use common\rbac\Permissions as P; +use api\rbac\Permissions as P; use Yii; use yii\filters\AccessControl; use yii\helpers\ArrayHelper; diff --git a/api/modules/oauth/controllers/ClientsController.php b/api/modules/oauth/controllers/ClientsController.php index 485c1c5..9439376 100644 --- a/api/modules/oauth/controllers/ClientsController.php +++ b/api/modules/oauth/controllers/ClientsController.php @@ -7,9 +7,9 @@ use api\modules\oauth\exceptions\UnsupportedOauthClientType; use api\modules\oauth\models\OauthClientForm; use api\modules\oauth\models\OauthClientFormFactory; use api\modules\oauth\models\OauthClientTypeForm; +use api\rbac\Permissions as P; use common\models\Account; use common\models\OauthClient; -use common\rbac\Permissions as P; use Yii; use yii\filters\AccessControl; use yii\helpers\ArrayHelper; diff --git a/api/modules/oauth/controllers/IdentityController.php b/api/modules/oauth/controllers/IdentityController.php index 5f0f952..034e8c5 100644 --- a/api/modules/oauth/controllers/IdentityController.php +++ b/api/modules/oauth/controllers/IdentityController.php @@ -3,7 +3,7 @@ namespace api\modules\oauth\controllers; use api\controllers\Controller; use api\modules\oauth\models\IdentityInfo; -use common\rbac\Permissions as P; +use api\rbac\Permissions as P; use Yii; use yii\filters\AccessControl; use yii\helpers\ArrayHelper; diff --git a/api/modules/oauth/models/OauthProcess.php b/api/modules/oauth/models/OauthProcess.php index d282c95..ee0ebd6 100644 --- a/api/modules/oauth/models/OauthProcess.php +++ b/api/modules/oauth/models/OauthProcess.php @@ -5,9 +5,9 @@ use api\components\OAuth2\Exception\AcceptRequiredException; use api\components\OAuth2\Exception\AccessDeniedException; use api\components\OAuth2\Grants\AuthCodeGrant; use api\components\OAuth2\Grants\AuthorizeParams; +use api\rbac\Permissions as P; use common\models\Account; use common\models\OauthClient; -use common\rbac\Permissions as P; use League\OAuth2\Server\AuthorizationServer; use League\OAuth2\Server\Exception\InvalidGrantException; use League\OAuth2\Server\Exception\OAuthException; diff --git a/api/modules/session/models/JoinForm.php b/api/modules/session/models/JoinForm.php index 0361ae5..bc4e2ad 100644 --- a/api/modules/session/models/JoinForm.php +++ b/api/modules/session/models/JoinForm.php @@ -6,10 +6,10 @@ use api\modules\session\exceptions\IllegalArgumentException; use api\modules\session\models\protocols\JoinInterface; use api\modules\session\Module as Session; use api\modules\session\validators\RequiredValidator; +use api\rbac\Permissions as P; use common\helpers\StringHelper; use common\models\Account; use common\models\MinecraftAccessKey; -use common\rbac\Permissions as P; use Ramsey\Uuid\Uuid; use Yii; use yii\base\ErrorException; diff --git a/common/rbac/.generated/.gitignore b/api/rbac/.generated/.gitignore similarity index 100% rename from common/rbac/.generated/.gitignore rename to api/rbac/.generated/.gitignore diff --git a/api/rbac/Manager.php b/api/rbac/Manager.php new file mode 100644 index 0000000..9829158 --- /dev/null +++ b/api/rbac/Manager.php @@ -0,0 +1,38 @@ +user->getIdentity(); + if ($identity === null) { + return []; + } + + /** @noinspection NullPointerExceptionInspection */ + $rawPermissions = $identity->getAssignedPermissions(); + $result = []; + foreach ($rawPermissions as $name) { + $result[$name] = new Assignment(['roleName' => $name]); + } + + return $result; + } + +} diff --git a/common/rbac/Permissions.php b/api/rbac/Permissions.php similarity index 97% rename from common/rbac/Permissions.php rename to api/rbac/Permissions.php index 1b0b710..914905c 100644 --- a/common/rbac/Permissions.php +++ b/api/rbac/Permissions.php @@ -1,5 +1,7 @@ user->findIdentityByAccessToken($accessToken); + $identity = Yii::$app->user->getIdentity(); if ($identity === null) { return false; } diff --git a/common/rbac/rules/OauthClientOwner.php b/api/rbac/rules/OauthClientOwner.php similarity index 81% rename from common/rbac/rules/OauthClientOwner.php rename to api/rbac/rules/OauthClientOwner.php index b03636a..942dc13 100644 --- a/common/rbac/rules/OauthClientOwner.php +++ b/api/rbac/rules/OauthClientOwner.php @@ -1,10 +1,11 @@ execute($accessToken, $item, ['accountId' => $accountId]); } - $clientId = $params['clientId'] ?? null; - if ($clientId === null) { - return false; - } - + Assert::keyExists($params, 'clientId'); /** @var OauthClient|null $client */ - $client = OauthClient::findOne($clientId); + $client = OauthClient::findOne(['id' => $params['clientId']]); if ($client === null) { return true; } - $identity = Yii::$app->user->findIdentityByAccessToken($accessToken); + $identity = Yii::$app->user->getIdentity(); if ($identity === null) { return false; } diff --git a/api/tests/_data/certs/private.pem b/api/tests/_data/certs/private.pem new file mode 100644 index 0000000..fde4474 --- /dev/null +++ b/api/tests/_data/certs/private.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIKrv4e6B9XP7l8F94ZMJotA+7FtjK7k9/olQi7Eb2tgmoAoGCCqGSM49 +AwEHoUQDQgAES2Pyq9r0CyyviLaWwq0ki5uy8hr/ZbNO++3j4XP43uLD9/GYkrKG +IRl+Hu5HT+LwZvrFcEaVhPk5CvtV4zlYJg== +-----END EC PRIVATE KEY----- diff --git a/api/tests/_data/certs/public.pem b/api/tests/_data/certs/public.pem new file mode 100644 index 0000000..684c55a --- /dev/null +++ b/api/tests/_data/certs/public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAES2Pyq9r0CyyviLaWwq0ki5uy8hr/ +ZbNO++3j4XP43uLD9/GYkrKGIRl+Hu5HT+LwZvrFcEaVhPk5CvtV4zlYJg== +-----END PUBLIC KEY----- diff --git a/api/tests/_support/FunctionalTester.php b/api/tests/_support/FunctionalTester.php index d39a6a2..86bef30 100644 --- a/api/tests/_support/FunctionalTester.php +++ b/api/tests/_support/FunctionalTester.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace api\tests; +use api\components\Tokens\TokensFactory; use api\tests\_generated\FunctionalTesterActions; use Codeception\Actor; use common\models\Account; @@ -12,15 +13,15 @@ use Yii; class FunctionalTester extends Actor { use FunctionalTesterActions; - public function amAuthenticated(string $asUsername = 'admin') { + public function amAuthenticated(string $asUsername = 'admin') { // Do not declare type /** @var Account $account */ $account = Account::findOne(['username' => $asUsername]); if ($account === null) { - throw new InvalidArgumentException("Cannot find account for username \"{$asUsername}\""); + throw new InvalidArgumentException("Cannot find account with username \"{$asUsername}\""); } - $result = Yii::$app->user->createJwtAuthenticationToken($account, false); - $this->amBearerAuthenticated($result->getJwt()); + $token = TokensFactory::createForAccount($account); + $this->amBearerAuthenticated((string)$token); return $account->id; } @@ -30,10 +31,10 @@ class FunctionalTester extends Actor { Yii::$app->user->logout(); } - public function canSeeAuthCredentials($expectRefresh = false): void { + public function canSeeAuthCredentials($expectRefreshToken = false): void { $this->canSeeResponseJsonMatchesJsonPath('$.access_token'); $this->canSeeResponseJsonMatchesJsonPath('$.expires_in'); - if ($expectRefresh) { + if ($expectRefreshToken) { $this->canSeeResponseJsonMatchesJsonPath('$.refresh_token'); } else { $this->cantSeeResponseJsonMatchesJsonPath('$.refresh_token'); diff --git a/api/tests/functional/_steps/AuthserverSteps.php b/api/tests/functional/_steps/AuthserverSteps.php index 84102a8..97cb5bf 100644 --- a/api/tests/functional/_steps/AuthserverSteps.php +++ b/api/tests/functional/_steps/AuthserverSteps.php @@ -9,7 +9,7 @@ use Ramsey\Uuid\Uuid; class AuthserverSteps extends FunctionalTester { - public function amAuthenticated(string $asUsername = 'admin', string $password = 'password_0'): array { + public function amAuthenticated(string $asUsername = 'admin', string $password = 'password_0') { $route = new AuthserverRoute($this); $clientToken = Uuid::uuid4()->toString(); $route->authenticate([ diff --git a/api/tests/functional/_steps/SessionServerSteps.php b/api/tests/functional/_steps/SessionServerSteps.php index f33e2c8..00e8071 100644 --- a/api/tests/functional/_steps/SessionServerSteps.php +++ b/api/tests/functional/_steps/SessionServerSteps.php @@ -1,9 +1,9 @@ component->create(); + $this->assertSame('ES256', $token->getHeader('alg')); + $this->assertEmpty(array_diff(array_keys($token->getClaims()), ['iat', 'exp'])); + $this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 1); + $this->assertEqualsWithDelta(time() + 3600, $token->getClaim('exp'), 2); + + // Pass custom payloads + $token = $this->component->create(['find' => 'me']); + $this->assertArrayHasKey('find', $token->getClaims()); + $this->assertSame('me', $token->getClaim('find')); + + // Pass custom headers + $token = $this->component->create([], ['find' => 'me']); + $this->assertArrayHasKey('find', $token->getHeaders()); + $this->assertSame('me', $token->getHeader('find')); + } + + public function testParse() { + // Valid token signed with HS256 + $token = $this->component->parse('eyJhbGciOiJIUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ1Mjc0NzYsImV4cCI6MTU2NDUzMTA3Niwic3ViIjoiZWx5fDEiLCJqdGkiOjMwNjk1OTJ9.ixapBbhaUCejbcPTnFi5nqk75XKd1_lQJd1ZPgGTLEc'); + $this->assertValidParsedToken($token, 'HS256'); + + // Valid token signed with ES256 + $token = $this->component->parse('eyJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ1Mjc0NzYsImV4cCI6MTU2NDUzMTA3Niwic3ViIjoiZWx5fDEiLCJqdGkiOjMwNjk1OTJ9.M8Kam9bv0BXui3k7Posq_vc0I95Kb_Tw7L2vPdEPlwsHqh1VJHoWtlQc32_SlsotttL7j6RYbffBkRFX2wDGFQ'); + $this->assertValidParsedToken($token, 'ES256'); + + // Valid token signed with ES256, but the signature is invalid + $token = $this->component->parse('eyJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ1Mjc0NzYsImV4cCI6MTU2NDUzMTA3Niwic3ViIjoiZWx5fDEiLCJqdGkiOjMwNjk1OTJ9.xxx'); + $this->assertValidParsedToken($token, 'ES256'); + + // Completely invalid token + $this->expectException(InvalidArgumentException::class); + $this->component->parse('How do you tame a horse in Minecraft?'); + } + + /** + * @dataProvider getVerifyCases + */ + public function testVerify(Token $token, bool $shouldBeValid) { + $this->assertSame($shouldBeValid, $this->component->verify($token)); + } + + public function getVerifyCases() { + yield 'HS256' => [ + (new Parser())->parse('eyJhbGciOiJIUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ1Mjc0NzYsImV4cCI6MTU2NDUzMTA3Niwic3ViIjoiZWx5fDEiLCJqdGkiOjMwNjk1OTJ9.ixapBbhaUCejbcPTnFi5nqk75XKd1_lQJd1ZPgGTLEc'), + true, + ]; + yield 'ES256' => [ + (new Parser())->parse('eyJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ1Mjc0NzYsImV4cCI6MTU2NDUzMTA3Niwic3ViIjoiZWx5fDEiLCJqdGkiOjMwNjk1OTJ9.M8Kam9bv0BXui3k7Posq_vc0I95Kb_Tw7L2vPdEPlwsHqh1VJHoWtlQc32_SlsotttL7j6RYbffBkRFX2wDGFQ'), + true, + ]; + yield 'ES256 with an invalid signature' => [ + (new Parser())->parse('eyJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ1Mjc0NzYsImV4cCI6MTU2NDUzMTA3Niwic3ViIjoiZWx5fDEiLCJqdGkiOjMwNjk1OTJ9.xxx'), + false, + ]; + yield 'RS256 (unsupported)' => [ + (new Parser())->parse('eyJhbGciOiJSUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ1Mjc0NzYsImV4cCI6MTU2NDUzMTA3Niwic3ViIjoiZWx5fDEiLCJqdGkiOjMwNjk1OTJ9.t3c68OMaoWWXxNFuz6SW-RfNmCOwAagyPSedbzJ1K3gR3bY5C8PRP6IEyE-OQvAcSFQcake0brsa4caXAmVlU0c3jQxpjk0bl4fBMd-InpGCoo42G89lgAY-dqWeJqokRORCpUL5Mzptbm5fNDlCrnNhI_6EmQygL3WXh1uorCbcxxO-Lb2Nr7Sge7GV0t24-I61I7ErrFL2ZC9ybSi6V8pdhFZlfO6MSUM0ASyRN994sVmcQEZHDiQFP7zj79zoAFamfYe8JBFAGtC-p4LeVYjrw052VahNXyRuGLxW7y1gX-znpyx0T-7lgKSWVxhJ6k3qt5qT33utdC76w1vihEdYinpEE3VbTMN01bxAFpyDbK11R49FCwCKStPjw_wdoLZChx_zob95yVU6IUCJwPYVc4SBtrAPV0uVe3mL3Gzgtr6MkhJAF3diFevTLGfnOOCAWwhdjVs10VWqcajBwvfFlm_Yw5MYZnetEECqumqFEr_u6CdRxtx0gCiPReDG8XwYHt0EqEw-LoRqxGWp5zqfud7f0DWv6cXlLbnKsB8XQh8EqnKblvNCFilXJIgfknCZ34PAob1pUkXO1geMLw4b8NUnKta1D3ad3AxGW5CEmOjWzEhzMOxIgnouU2ZVtWFDrPVs12Q4494BxTvGKXrG2cT6TK18-XY26DllglY'), + false, + ]; + } + + protected function _setUp() { + parent::_setUp(); + $this->component = Yii::$app->tokens; + } + + private function assertValidParsedToken(Token $token, string $expectedAlg) { + $this->assertSame($expectedAlg, $token->getHeader('alg')); + $this->assertSame(1564527476, $token->getClaim('iat')); + $this->assertSame(1564531076, $token->getClaim('exp')); + $this->assertSame('ely|1', $token->getClaim('sub')); + $this->assertSame(3069592, $token->getClaim('jti')); + $this->assertSame('accounts_web_user', $token->getClaim('ely-scopes')); + } + +} diff --git a/api/tests/unit/components/Tokens/TokensFactoryTest.php b/api/tests/unit/components/Tokens/TokensFactoryTest.php new file mode 100644 index 0000000..cd54c00 --- /dev/null +++ b/api/tests/unit/components/Tokens/TokensFactoryTest.php @@ -0,0 +1,35 @@ +id = 1; + + $token = TokensFactory::createForAccount($account); + $this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 1); + $this->assertEqualsWithDelta(time() + 60 * 60 * 24 * 7, $token->getClaim('exp'), 2); + $this->assertSame('ely|1', $token->getClaim('sub')); + $this->assertSame('accounts_web_user', $token->getClaim('ely-scopes')); + $this->assertArrayNotHasKey('jti', $token->getClaims()); + + $session = new AccountSession(); + $session->id = 2; + + $token = TokensFactory::createForAccount($account, $session); + $this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 1); + $this->assertEqualsWithDelta(time() + 3600, $token->getClaim('exp'), 2); + $this->assertSame('ely|1', $token->getClaim('sub')); + $this->assertSame('accounts_web_user', $token->getClaim('ely-scopes')); + $this->assertSame(2, $token->getClaim('jti')); + } + +} diff --git a/api/tests/unit/components/User/ComponentTest.php b/api/tests/unit/components/User/ComponentTest.php index 17aca72..e9ca1cc 100644 --- a/api/tests/unit/components/User/ComponentTest.php +++ b/api/tests/unit/components/User/ComponentTest.php @@ -1,24 +1,21 @@ component = new Component($this->getComponentConfig()); + $this->component = new Component(); } public function _fixtures(): array { @@ -38,158 +35,70 @@ class ComponentTest extends TestCase { ]; } - public function testCreateJwtAuthenticationToken() { - $this->mockRequest(); - - $account = new Account(['id' => 1]); - $result = $this->component->createJwtAuthenticationToken($account, false); - $this->assertInstanceOf(AuthenticationResult::class, $result); - $this->assertNull($result->getSession()); - $this->assertSame($account, $result->getAccount()); - $payloads = (new Jwt())->deserialize($result->getJwt())->getPayload(); - /** @noinspection NullPointerExceptionInspection */ - $this->assertEqualsWithDelta(time(), $payloads->findClaimByName(Claim\IssuedAt::NAME)->getValue(), 3); - /** @noinspection SummerTimeUnsafeTimeManipulationInspection */ - /** @noinspection NullPointerExceptionInspection */ - $this->assertEqualsWithDelta(time() + 60 * 60 * 24 * 7, $payloads->findClaimByName('exp')->getValue(), 3); - /** @noinspection NullPointerExceptionInspection */ - $this->assertSame('ely|1', $payloads->findClaimByName('sub')->getValue()); - /** @noinspection NullPointerExceptionInspection */ - $this->assertSame('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->assertSame($account, $result->getAccount()); - /** @noinspection NullPointerExceptionInspection */ - $this->assertTrue($result->getSession()->refresh()); - $payloads = (new Jwt())->deserialize($result->getJwt())->getPayload(); - /** @noinspection NullPointerExceptionInspection */ - $this->assertEqualsWithDelta(time(), $payloads->findClaimByName(Claim\IssuedAt::NAME)->getValue(), 3); - /** @noinspection NullPointerExceptionInspection */ - $this->assertEqualsWithDelta(time() + 3600, $payloads->findClaimByName('exp')->getValue(), 3); - /** @noinspection NullPointerExceptionInspection */ - $this->assertSame('ely|1', $payloads->findClaimByName('sub')->getValue()); - /** @noinspection NullPointerExceptionInspection */ - $this->assertSame('accounts_web_user', $payloads->findClaimByName('ely-scopes')->getValue()); - /** @noinspection NullPointerExceptionInspection */ - $this->assertSame($result->getSession()->id, $payloads->findClaimByName('jti')->getValue()); - } - - 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->assertSame($session, $result->getSession()); - $this->assertSame($session->account_id, $result->getAccount()->id); - $session->refresh(); // reload data from db - $this->assertEqualsWithDelta(time(), $session->last_refreshed_at, 3); - $this->assertSame($userIP, $session->getReadableIp()); - $payloads = (new Jwt())->deserialize($result->getJwt())->getPayload(); - /** @noinspection NullPointerExceptionInspection */ - $this->assertEqualsWithDelta(time(), $payloads->findClaimByName(Claim\IssuedAt::NAME)->getValue(), 3); - /** @noinspection NullPointerExceptionInspection */ - $this->assertEqualsWithDelta(time() + 3600, $payloads->findClaimByName('exp')->getValue(), 3); - /** @noinspection NullPointerExceptionInspection */ - $this->assertSame('ely|1', $payloads->findClaimByName('sub')->getValue()); - /** @noinspection NullPointerExceptionInspection */ - $this->assertSame('accounts_web_user', $payloads->findClaimByName('ely-scopes')->getValue()); - /** @noinspection NullPointerExceptionInspection */ - $this->assertSame($session->id, $payloads->findClaimByName('jti')->getValue(), 'session has not changed'); - } - - public function testParseToken() { - $this->mockRequest(); - $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() { - $account = $this->tester->grabFixture('accounts', 'admin'); - $result = $this->component->createJwtAuthenticationToken($account, true); - $this->component->logout(); + // User is guest + $component = new Component(); + $this->assertNull($component->getActiveSession()); - /** @var Component|\PHPUnit\Framework\MockObject\MockObject $component */ - $component = $this->getMockBuilder(Component::class) - ->setMethods(['getIsGuest']) - ->setConstructorArgs([$this->getComponentConfig()]) - ->getMock(); + // Identity is a Oauth2Identity + $component->setIdentity(mock(OAuth2Identity::class)); + $this->assertNull($component->getActiveSession()); - $component - ->method('getIsGuest') - ->willReturn(false); + // Identity is correct, but have no jti claim + /** @var JwtIdentity|\Mockery\MockInterface $identity */ + $identity = mock(JwtIdentity::class); + $identity->shouldReceive('getToken')->andReturn(new Token()); + $component->setIdentity($identity); + $this->assertNull($component->getActiveSession()); - $this->mockAuthorizationHeader($result->getJwt()); + // Identity is correct and has jti claim, but there is no associated session + /** @var JwtIdentity|\Mockery\MockInterface $identity */ + $identity = mock(JwtIdentity::class); + $identity->shouldReceive('getToken')->andReturn(new Token([], ['jti' => new Basic('jti', 999999)])); + $component->setIdentity($identity); + $this->assertNull($component->getActiveSession()); + // Identity is correct, has jti claim and associated session exists + /** @var JwtIdentity|\Mockery\MockInterface $identity */ + $identity = mock(JwtIdentity::class); + $identity->shouldReceive('getToken')->andReturn(new Token([], ['jti' => new Basic('jti', 1)])); + $component->setIdentity($identity); $session = $component->getActiveSession(); - $this->assertInstanceOf(AccountSession::class, $session); - /** @noinspection NullPointerExceptionInspection */ - $this->assertSame($session->id, $result->getSession()->id); + $this->assertNotNull($session); + $this->assertSame(1, $session->id); } public function testTerminateSessions() { /** @var AccountSession $session */ - $session = AccountSession::findOne($this->tester->grabFixture('sessions', 'admin2')['id']); + $session = $this->tester->grabFixture('sessions', 'admin2'); /** @var Component|\Mockery\MockInterface $component */ - $component = mock(Component::class . '[getActiveSession]', [$this->getComponentConfig()])->shouldDeferMissing(); + $component = mock(Component::class . '[getActiveSession]')->makePartial(); $component->shouldReceive('getActiveSession')->times(1)->andReturn($session); /** @var Account $account */ $account = $this->tester->grabFixture('accounts', 'admin'); - $component->createJwtAuthenticationToken($account, true); + // Dry run: no sessions should be removed $component->terminateSessions($account, Component::KEEP_MINECRAFT_SESSIONS | Component::KEEP_SITE_SESSIONS); $this->assertNotEmpty($account->getMinecraftAccessKeys()->all()); $this->assertNotEmpty($account->getSessions()->all()); + // All Minecraft sessions should be removed. Web sessions should be kept $component->terminateSessions($account, Component::KEEP_SITE_SESSIONS); $this->assertEmpty($account->getMinecraftAccessKeys()->all()); $this->assertNotEmpty($account->getSessions()->all()); + // All sessions should be removed except the current one $component->terminateSessions($account, Component::KEEP_CURRENT_SESSION); $sessions = $account->getSessions()->all(); $this->assertCount(1, $sessions); $this->assertSame($session->id, $sessions[0]->id); + // With no arguments each and every session should be removed $component->terminateSessions($account); $this->assertEmpty($account->getSessions()->all()); $this->assertEmpty($account->getMinecraftAccessKeys()->all()); } - private function mockRequest($userIP = '127.0.0.1') { - /** @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); - } - - /** - * @param string $bearerToken - */ - private function mockAuthorizationHeader($bearerToken = null) { - if ($bearerToken !== null) { - $bearerToken = 'Bearer ' . $bearerToken; - } - - Yii::$app->request->headers->set('Authorization', $bearerToken); - } - - private function getComponentConfig() { - return [ - 'identityClass' => Identity::class, - 'enableSession' => false, - 'loginUrl' => null, - 'secret' => 'secret', - ]; - } - } diff --git a/api/tests/unit/components/User/IdentityFactoryTest.php b/api/tests/unit/components/User/IdentityFactoryTest.php new file mode 100644 index 0000000..b3d6851 --- /dev/null +++ b/api/tests/unit/components/User/IdentityFactoryTest.php @@ -0,0 +1,59 @@ +assertInstanceOf(JwtIdentity::class, $identity); + + // Find identity by oauth2 token + $accessToken = new AccessTokenEntity(mock(AbstractServer::class)); + $accessToken->setExpireTime(time() + 3600); + $accessToken->setId('mock-token'); + + /** @var AccessTokenInterface|\Mockery\MockInterface $accessTokensStorage */ + $accessTokensStorage = mock(AccessTokenInterface::class); + $accessTokensStorage->shouldReceive('get')->with('mock-token')->andReturn($accessToken); + + /** @var Component|\Mockery\MockInterface $component */ + $component = mock(Component::class); + $component->shouldReceive('getAccessTokenStorage')->andReturn($accessTokensStorage); + Yii::$app->set('oauth', $component); + + $identity = IdentityFactory::findIdentityByAccessToken('mock-token'); + $this->assertInstanceOf(OAuth2Identity::class, $identity); + } + + public function testFindIdentityByAccessTokenWithEmptyValue() { + $this->expectException(UnauthorizedHttpException::class); + $this->expectExceptionMessage('Incorrect token'); + IdentityFactory::findIdentityByAccessToken(''); + } + + protected function _setUp() { + parent::_setUp(); + Carbon::setTestNow(Carbon::create(2019, 8, 1, 1, 2, 22, 'Europe/Minsk')); + } + + protected function _tearDown() { + parent::_tearDown(); + Carbon::setTestNow(); + } + +} diff --git a/api/tests/unit/components/User/JwtAuthenticationResultTest.php b/api/tests/unit/components/User/JwtAuthenticationResultTest.php deleted file mode 100644 index 29c636d..0000000 --- a/api/tests/unit/components/User/JwtAuthenticationResultTest.php +++ /dev/null @@ -1,63 +0,0 @@ -id = 123; - $model = new AuthenticationResult($account, '', null); - $this->assertSame($account, $model->getAccount()); - } - - public function testGetJwt() { - $model = new AuthenticationResult(new Account(), 'mocked jwt', null); - $this->assertSame('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->assertSame($session, $model->getSession()); - } - - public function testGetAsResponse() { - $jwtToken = $this->createJwtToken(time() + 3600); - $model = new AuthenticationResult(new Account(), $jwtToken, null); - $result = $model->getAsResponse(); - $this->assertSame($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->assertSame($jwtToken, $result['access_token']); - $this->assertSame('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/api/tests/unit/components/User/JwtIdentityTest.php b/api/tests/unit/components/User/JwtIdentityTest.php new file mode 100644 index 0000000..ef8c082 --- /dev/null +++ b/api/tests/unit/components/User/JwtIdentityTest.php @@ -0,0 +1,94 @@ + AccountFixture::class, + ]; + } + + public function testFindIdentityByAccessToken() { + $token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ.4Oidvuo4spvUf9hkpHR72eeqZUh2Zbxh_L8Od3vcgTj--0iOrcOEp6zwmEW6vF7BTHtjz2b3mXce61bqsCjXjQ'; + /** @var JwtIdentity $identity */ + $identity = JwtIdentity::findIdentityByAccessToken($token); + $this->assertSame($token, $identity->getId()); + $this->assertSame($token, (string)$identity->getToken()); + /** @var \common\models\Account $account */ + $account = $this->tester->grabFixture('accounts', 'admin'); + $this->assertSame($account->id, $identity->getAccount()->id); + } + + /** + * @dataProvider getFindIdentityByAccessTokenInvalidCases + */ + public function testFindIdentityByAccessTokenInvalidCases(string $token, string $expectedExceptionMessage) { + $this->expectException(UnauthorizedHttpException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + JwtIdentity::findIdentityByAccessToken($token); + } + + public function getFindIdentityByAccessTokenInvalidCases() { + yield 'expired token' => [ + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MDMzNDIsImV4cCI6MTU2NDYwNjk0Miwic3ViIjoiZWx5fDEifQ.36cDWyiXRArv-lgK_S5dyC5m_Ddytwkb78tMrxcPcbWEpoeg2VtwPC7zr6NI0cd0CuLw6InC2hZ9Ey95SSOsHw', + 'Token expired', + ]; + yield 'iat from future' => [ + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTc3NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ._6hj6XUSmSLibgT9ZE1Pokf4oI9r-d6tEc1z2J-fBlr1710Qiso5yNcXqb3Z_xy7Qtemyq8jOlOZA8DvmkVBrg', + 'Incorrect token', + ]; + yield 'invalid signature' => [ + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ.yth31f2PyhUkYSfBlizzUXWIgOvxxk8gNP-js0z8g1OT5rig40FPTIkgsZRctAwAAlj6QoIWW7-hxLTcSb2vmw', + 'Incorrect token', + ]; + yield 'invalid sub' => [ + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoxMjM0fQ.yigP5nWFdX0ktbuZC_Unb9bWxpAVd7Nv8Fb1Vsa0t5WkVA88VbhPi2P-CenbDOy8ngwoGV9m3c3upMs2V3gqvw', + 'Incorrect token', + ]; + yield 'empty token' => ['', 'Incorrect token']; + } + + public function testGetAccount() { + // Token with sub claim + $identity = JwtIdentity::findIdentityByAccessToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ.4Oidvuo4spvUf9hkpHR72eeqZUh2Zbxh_L8Od3vcgTj--0iOrcOEp6zwmEW6vF7BTHtjz2b3mXce61bqsCjXjQ'); + $this->assertSame(1, $identity->getAccount()->id); + + // Sub presented, but account not exists + $identity = JwtIdentity::findIdentityByAccessToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDk5OTk5In0.1pAnhkR-_ZqzjLBR-PNIMJUXRSUK3aYixrFNKZg2ynPNPiDvzh8U-iBTT6XRfMP5nvfXZucRpoPVoiXtx40CUQ'); + $this->assertNull($identity->getAccount()); + + // Token without sub claim + $identity = JwtIdentity::findIdentityByAccessToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Mn0.QxmYgSflZOQmhzYRr8bowU767yu4yKgTVaho0MPuyCmUfZO_0O0SQASMKVILf-wlT0ODTTG7vD753a2MTAmPmw'); + $this->assertNull($identity->getAccount()); + } + + public function testGetAssignedPermissions() { + // Token with ely-scopes claim + $identity = JwtIdentity::findIdentityByAccessToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoicGVybTEscGVybTIscGVybTMiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ.MO6T92EOFcZSPIdK8VBUG0qyV-pdayzOPQmpWLPwpl1933E9ann9GdV49piX1IfLHeCHVGThm5_v7AJgyZ5Oaw'); + $this->assertSame(['perm1', 'perm2', 'perm3'], $identity->getAssignedPermissions()); + + // Token without sub claim + $identity = JwtIdentity::findIdentityByAccessToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ.jsjv2dDetSxu4xivlHoTeDUhqsl-cxSI6SktufJhwR9wqDgQCVIONiqQCUzTzyTwyAz4Ztvel4lKjMCstdJOEw'); + $this->assertSame([], $identity->getAssignedPermissions()); + } + + protected function _before() { + parent::_before(); + Carbon::setTestNow(Carbon::create(2019, 8, 1, 1, 2, 22, 'Europe/Minsk')); + } + + protected function _after() { + parent::_after(); + Carbon::setTestNow(); + } + +} diff --git a/api/tests/unit/components/User/OAuth2IdentityTest.php b/api/tests/unit/components/User/OAuth2IdentityTest.php new file mode 100644 index 0000000..790f139 --- /dev/null +++ b/api/tests/unit/components/User/OAuth2IdentityTest.php @@ -0,0 +1,56 @@ +setExpireTime(time() + 3600); + $accessToken->setId('mock-token'); + $this->mockFoundedAccessToken($accessToken); + + $identity = OAuth2Identity::findIdentityByAccessToken('mock-token'); + $this->assertSame('mock-token', $identity->getId()); + } + + public function testFindIdentityByAccessTokenWithNonExistsToken() { + $this->expectException(UnauthorizedHttpException::class); + $this->expectExceptionMessage('Incorrect token'); + + OAuth2Identity::findIdentityByAccessToken('not exists token'); + } + + public function testFindIdentityByAccessTokenWithExpiredToken() { + $this->expectException(UnauthorizedHttpException::class); + $this->expectExceptionMessage('Token expired'); + + $accessToken = new AccessTokenEntity(mock(AbstractServer::class)); + $accessToken->setExpireTime(time() - 3600); + $this->mockFoundedAccessToken($accessToken); + + OAuth2Identity::findIdentityByAccessToken('mock-token'); + } + + private function mockFoundedAccessToken(AccessTokenEntity $accessToken) { + /** @var AccessTokenInterface|\Mockery\MockInterface $accessTokensStorage */ + $accessTokensStorage = mock(AccessTokenInterface::class); + $accessTokensStorage->shouldReceive('get')->with('mock-token')->andReturn($accessToken); + + /** @var Component|\Mockery\MockInterface $component */ + $component = mock(Component::class); + $component->shouldReceive('getAccessTokenStorage')->andReturn($accessTokensStorage); + Yii::$app->set('oauth', $component); + } + +} diff --git a/api/tests/unit/models/FeedbackFormTest.php b/api/tests/unit/models/FeedbackFormTest.php index b3e2cfa..b9cba73 100644 --- a/api/tests/unit/models/FeedbackFormTest.php +++ b/api/tests/unit/models/FeedbackFormTest.php @@ -30,20 +30,19 @@ class FeedbackFormTest extends TestCase { ->getMock(); $model - ->expects($this->any()) ->method('getAccount') - ->will($this->returnValue(new Account([ + ->willReturn(new Account([ 'id' => '123', 'username' => 'Erick', 'email' => 'find-this@email.net', 'created_at' => time() - 86400, - ]))); + ])); $this->assertTrue($model->sendMessage()); /** @var Message $message */ $message = $this->tester->grabLastSentEmail(); $this->assertInstanceOf(Message::class, $message); $data = (string)$message; - $this->assertContains('find-this@email.net', $data); + $this->assertStringContainsString('find-this@email.net', $data); } } diff --git a/api/tests/unit/models/JwtIdentityTest.php b/api/tests/unit/models/JwtIdentityTest.php deleted file mode 100644 index 94347be..0000000 --- a/api/tests/unit/models/JwtIdentityTest.php +++ /dev/null @@ -1,62 +0,0 @@ - AccountFixture::class, - ]; - } - - public function testFindIdentityByAccessToken() { - $token = $this->generateToken(); - $identity = JwtIdentity::findIdentityByAccessToken($token); - $this->assertSame($token, $identity->getId()); - $this->assertSame($this->tester->grabFixture('accounts', 'admin')['id'], $identity->getAccount()->id); - } - - /** - * @expectedException \yii\web\UnauthorizedHttpException - * @expectedExceptionMessage Token expired - */ - public function testFindIdentityByAccessTokenWithExpiredToken() { - $token = new Token(); - $token->addClaim(new Claim\IssuedAt(1464593193)); - $token->addClaim(new Claim\Expiration(1464596793)); - $token->addClaim(new Claim\Subject('ely|' . $this->tester->grabFixture('accounts', 'admin')['id'])); - $expiredToken = (new Jwt())->serialize($token, EncryptionFactory::create(Yii::$app->user->getAlgorithm())); - - JwtIdentity::findIdentityByAccessToken($expiredToken); - } - - /** - * @expectedException \yii\web\UnauthorizedHttpException - * @expectedExceptionMessage Incorrect token - */ - public function testFindIdentityByAccessTokenWithEmptyToken() { - JwtIdentity::findIdentityByAccessToken(''); - } - - protected function generateToken() { - /** @var \api\components\User\Component $component */ - $component = Yii::$app->user; - /** @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/api/tests/unit/models/authentication/AuthenticationResultTest.php b/api/tests/unit/models/authentication/AuthenticationResultTest.php new file mode 100644 index 0000000..0945b98 --- /dev/null +++ b/api/tests/unit/models/authentication/AuthenticationResultTest.php @@ -0,0 +1,40 @@ +assertSame($token, $model->getToken()); + $this->assertNull($model->getRefreshToken()); + + $model = new AuthenticationResult($token, 'refresh_token'); + $this->assertSame('refresh_token', $model->getRefreshToken()); + } + + public function testGetAsResponse() { + $token = Yii::$app->tokens->create(); + $jwt = (string)$token; + + $model = new AuthenticationResult($token); + $result = $model->formatAsOAuth2Response(); + $this->assertSame($jwt, $result['access_token']); + $this->assertEqualsWithDelta(3600, $result['expires_in'], 1); + $this->assertArrayNotHasKey('refresh_token', $result); + + $model = new AuthenticationResult($token, 'refresh_token'); + $result = $model->formatAsOAuth2Response(); + $this->assertSame($jwt, $result['access_token']); + $this->assertEqualsWithDelta(3600, $result['expires_in'], 1); + $this->assertSame('refresh_token', $result['refresh_token']); + } + +} diff --git a/api/tests/unit/models/authentication/ConfirmEmailFormTest.php b/api/tests/unit/models/authentication/ConfirmEmailFormTest.php index 8b38e17..ef7971c 100644 --- a/api/tests/unit/models/authentication/ConfirmEmailFormTest.php +++ b/api/tests/unit/models/authentication/ConfirmEmailFormTest.php @@ -1,11 +1,11 @@ tester->grabFixture('emailActivations', 'freshRegistrationConfirmation'); $model = $this->createModel($fixture['key']); $result = $model->confirm(); - $this->assertInstanceOf(AuthenticationResult::class, $result); - $this->assertInstanceOf(AccountSession::class, $result->getSession(), 'session was generated'); + $this->assertNotNull($result); + $this->assertNotNull($result->getRefreshToken(), 'session was generated'); $activationExists = EmailActivation::find()->andWhere(['key' => $fixture['key']])->exists(); $this->assertFalse($activationExists, 'email activation key is not exist'); /** @var Account $account */ diff --git a/api/tests/unit/models/authentication/ForgotPasswordFormTest.php b/api/tests/unit/models/authentication/ForgotPasswordFormTest.php index d8ace21..3ee8114 100644 --- a/api/tests/unit/models/authentication/ForgotPasswordFormTest.php +++ b/api/tests/unit/models/authentication/ForgotPasswordFormTest.php @@ -1,4 +1,6 @@ set(ReCaptchaValidator::class, new class(mock(ClientInterface::class)) extends ReCaptchaValidator { public function validateValue($value) { diff --git a/api/tests/unit/models/authentication/LoginFormTest.php b/api/tests/unit/models/authentication/LoginFormTest.php index 5f4675f..384e63a 100644 --- a/api/tests/unit/models/authentication/LoginFormTest.php +++ b/api/tests/unit/models/authentication/LoginFormTest.php @@ -1,7 +1,8 @@ originalRemoteAddr = $_SERVER['REMOTE_ADDR'] ?? null; $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; parent::setUp(); } - protected function tearDown() { + protected function tearDown(): void { parent::tearDown(); $_SERVER['REMOTE_ADDR'] = $this->originalRemoteAddr; } @@ -135,7 +136,7 @@ class LoginFormTest extends TestCase { 'status' => Account::STATUS_ACTIVE, ]), ]); - $this->assertInstanceOf(AuthenticationResult::class, $model->login(), 'model should login user'); + $this->assertNotNull($model->login(), 'model should login user'); $this->assertEmpty($model->getErrors(), 'error message should not be set'); } @@ -144,7 +145,7 @@ class LoginFormTest extends TestCase { 'login' => $this->tester->grabFixture('accounts', 'user-with-old-password-type')['username'], 'password' => '12345678', ]); - $this->assertInstanceOf(AuthenticationResult::class, $model->login()); + $this->assertNotNull($model->login()); $this->assertEmpty($model->getErrors()); $this->assertSame( Account::PASS_HASH_STRATEGY_YII2, diff --git a/api/tests/unit/models/authentication/LogoutFormTest.php b/api/tests/unit/models/authentication/LogoutFormTest.php index ec512d0..9608b52 100644 --- a/api/tests/unit/models/authentication/LogoutFormTest.php +++ b/api/tests/unit/models/authentication/LogoutFormTest.php @@ -1,8 +1,9 @@ specify('No actions if active session is not exists', function() { $userComp = $this ->getMockBuilder(Component::class) - ->setConstructorArgs([$this->getComponentArgs()]) ->setMethods(['getActiveSession']) ->getMock(); $userComp @@ -42,7 +42,6 @@ class LogoutFormTest extends TestCase { $userComp = $this ->getMockBuilder(Component::class) - ->setConstructorArgs([$this->getComponentArgs()]) ->setMethods(['getActiveSession']) ->getMock(); $userComp @@ -57,13 +56,4 @@ class LogoutFormTest extends TestCase { }); } - private function getComponentArgs() { - return [ - 'identityClass' => Identity::class, - 'enableSession' => false, - 'loginUrl' => null, - 'secret' => 'secret', - ]; - } - } diff --git a/api/tests/unit/models/authentication/RecoverPasswordFormTest.php b/api/tests/unit/models/authentication/RecoverPasswordFormTest.php index c7d6da5..b3d19d9 100644 --- a/api/tests/unit/models/authentication/RecoverPasswordFormTest.php +++ b/api/tests/unit/models/authentication/RecoverPasswordFormTest.php @@ -1,7 +1,8 @@ '12345678', ]); $result = $model->recoverPassword(); - $this->assertInstanceOf(AuthenticationResult::class, $result); - $this->assertNull($result->getSession(), 'session was not generated'); + $this->assertNotNull($result); + $this->assertNull($result->getRefreshToken(), 'session was not generated'); $this->assertFalse(EmailActivation::find()->andWhere(['key' => $fixture['key']])->exists()); /** @var Account $account */ $account = Account::findOne($fixture['account_id']); diff --git a/api/tests/unit/models/authentication/RefreshTokenFormTest.php b/api/tests/unit/models/authentication/RefreshTokenFormTest.php index fada46e..4d26c96 100644 --- a/api/tests/unit/models/authentication/RefreshTokenFormTest.php +++ b/api/tests/unit/models/authentication/RefreshTokenFormTest.php @@ -1,12 +1,15 @@ specify('error.refresh_token_not_exist if passed token not exists', function() { - /** @var RefreshTokenForm $model */ - $model = new class extends RefreshTokenForm { - public function getSession() { - return null; - } - }; - $model->validateRefreshToken(); - $this->assertSame(['error.refresh_token_not_exist'], $model->getErrors('refresh_token')); - }); + public function testRenew() { + /** @var Request|\Mockery\MockInterface $request */ + $request = mock(Request::class . '[getUserIP]')->makePartial(); + $request->shouldReceive('getUserIP')->andReturn('10.1.2.3'); + Yii::$app->set('request', $request); - $this->specify('no errors if token exists', function() { - /** @var RefreshTokenForm $model */ - $model = new class extends RefreshTokenForm { - public function getSession() { - return new AccountSession(); - } - }; - $model->validateRefreshToken(); - $this->assertEmpty($model->getErrors('refresh_token')); - }); + $model = new RefreshTokenForm(); + $model->refresh_token = 'SOutIr6Seeaii3uqMVy3Wan8sKFVFrNz'; + $result = $model->renew(); + $this->assertNotNull($result); + $this->assertSame('SOutIr6Seeaii3uqMVy3Wan8sKFVFrNz', $result->getRefreshToken()); + + $token = $result->getToken(); + $this->assertSame('ely|1', $token->getClaim('sub')); + $this->assertSame('accounts_web_user', $token->getClaim('ely-scopes')); + $this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 5); + $this->assertEqualsWithDelta(time() + 3600, $token->getClaim('exp'), 5); + $this->assertSame(1, $token->getClaim('jti')); + + /** @var AccountSession $session */ + $session = AccountSession::findOne(['refresh_token' => 'SOutIr6Seeaii3uqMVy3Wan8sKFVFrNz']); + $this->assertEqualsWithDelta(time(), $session->last_refreshed_at, 5); + $this->assertSame('10.1.2.3', $session->getReadableIp()); } - public function testRenew() { + public function testRenewWithInvalidRefreshToken() { $model = new RefreshTokenForm(); - $model->refresh_token = $this->tester->grabFixture('sessions', 'admin')['refresh_token']; - $this->assertInstanceOf(AuthenticationResult::class, $model->renew()); + $model->refresh_token = 'unknown refresh token'; + $this->assertNull($model->renew()); + $this->assertSame(['error.refresh_token_not_exist'], $model->getErrors('refresh_token')); } } diff --git a/api/tests/unit/models/authentication/RegistrationFormTest.php b/api/tests/unit/models/authentication/RegistrationFormTest.php index cc61563..eb4e472 100644 --- a/api/tests/unit/models/authentication/RegistrationFormTest.php +++ b/api/tests/unit/models/authentication/RegistrationFormTest.php @@ -22,7 +22,7 @@ use const common\LATEST_RULES_VERSION; class RegistrationFormTest extends TestCase { - protected function setUp() { + protected function setUp(): void { parent::setUp(); $this->mockRequest(); Yii::$container->set(ReCaptchaValidator::class, new class(mock(ClientInterface::class)) extends ReCaptchaValidator { diff --git a/api/tests/unit/models/authentication/RepeatAccountActivationFormTest.php b/api/tests/unit/models/authentication/RepeatAccountActivationFormTest.php index e0cdfcd..9e9895f 100644 --- a/api/tests/unit/models/authentication/RepeatAccountActivationFormTest.php +++ b/api/tests/unit/models/authentication/RepeatAccountActivationFormTest.php @@ -1,4 +1,6 @@ set(ReCaptchaValidator::class, new class(mock(ClientInterface::class)) extends ReCaptchaValidator { public function validateValue($value) { diff --git a/api/tests/unit/modules/accounts/models/ChangePasswordFormTest.php b/api/tests/unit/modules/accounts/models/ChangePasswordFormTest.php index 552a185..67dd574 100644 --- a/api/tests/unit/modules/accounts/models/ChangePasswordFormTest.php +++ b/api/tests/unit/modules/accounts/models/ChangePasswordFormTest.php @@ -1,8 +1,9 @@ Identity::class, - 'enableSession' => false, - 'loginUrl' => null, - 'secret' => 'secret', - ]]); + $component = mock(Component::class . '[terminateSessions]'); $component->shouldNotReceive('terminateSessions'); Yii::$app->set('user', $component); @@ -116,12 +112,7 @@ class ChangePasswordFormTest extends TestCase { $account->setPassword('password_0'); /** @var Component|\Mockery\MockInterface $component */ - $component = mock(Component::class . '[terminateSessions]', [[ - 'identityClass' => Identity::class, - 'enableSession' => false, - 'loginUrl' => null, - 'secret' => 'secret', - ]]); + $component = mock(Component::class . '[terminateSessions]'); $component->shouldReceive('terminateSessions')->once()->withArgs([$account, Component::KEEP_CURRENT_SESSION]); Yii::$app->set('user', $component); diff --git a/api/tests/unit/modules/accounts/models/EnableTwoFactorAuthFormTest.php b/api/tests/unit/modules/accounts/models/EnableTwoFactorAuthFormTest.php index 84878ff..322c908 100644 --- a/api/tests/unit/modules/accounts/models/EnableTwoFactorAuthFormTest.php +++ b/api/tests/unit/modules/accounts/models/EnableTwoFactorAuthFormTest.php @@ -1,8 +1,9 @@ otp_secret = 'mock secret'; /** @var Component|\Mockery\MockInterface $component */ - $component = mock(Component::class . '[terminateSessions]', [[ - 'identityClass' => Identity::class, - 'enableSession' => false, - 'loginUrl' => null, - 'secret' => 'secret', - ]]); + $component = mock(Component::class . '[terminateSessions]'); $component->shouldReceive('terminateSessions')->withArgs([$account, Component::KEEP_CURRENT_SESSION]); Yii::$app->set('user', $component); diff --git a/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php b/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php index 0cb9288..35e2db2 100644 --- a/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php +++ b/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php @@ -1,7 +1,10 @@ expectException(ForbiddenOperationException::class); + $this->expectExceptionMessage('Invalid credentials. Invalid nickname or password.'); + $authForm = $this->createAuthForm(); $authForm->username = 'wrong-username'; @@ -36,11 +38,10 @@ class AuthenticationFormTest extends TestCase { $authForm->authenticate(); } - /** - * @expectedException \api\modules\authserver\exceptions\ForbiddenOperationException - * @expectedExceptionMessage Invalid credentials. Invalid email or password. - */ public function testAuthenticateByWrongEmailPass() { + $this->expectException(ForbiddenOperationException::class); + $this->expectExceptionMessage('Invalid credentials. Invalid email or password.'); + $authForm = $this->createAuthForm(); $authForm->username = 'wrong-email@ely.by'; @@ -50,11 +51,10 @@ class AuthenticationFormTest extends TestCase { $authForm->authenticate(); } - /** - * @expectedException \api\modules\authserver\exceptions\ForbiddenOperationException - * @expectedExceptionMessage This account has been suspended. - */ public function testAuthenticateByValidCredentialsIntoBlockedAccount() { + $this->expectException(ForbiddenOperationException::class); + $this->expectExceptionMessage('This account has been suspended.'); + $authForm = $this->createAuthForm(Account::STATUS_BANNED); $authForm->username = 'dummy'; @@ -71,7 +71,7 @@ class AuthenticationFormTest extends TestCase { $minecraftAccessKey->access_token = Uuid::uuid4(); $authForm->expects($this->once()) ->method('createMinecraftAccessToken') - ->will($this->returnValue($minecraftAccessKey)); + ->willReturn($minecraftAccessKey); $authForm->username = 'dummy'; $authForm->password = 'password_0'; @@ -122,18 +122,18 @@ class AuthenticationFormTest extends TestCase { $account->status = $status; $account->setPassword('password_0'); - $loginForm->expects($this->any()) + $loginForm ->method('getAccount') - ->will($this->returnValue($account)); + ->willReturn($account); /** @var AuthenticationForm|\PHPUnit\Framework\MockObject\MockObject $authForm */ $authForm = $this->getMockBuilder(AuthenticationForm::class) ->setMethods(['createLoginForm', 'createMinecraftAccessToken']) ->getMock(); - $authForm->expects($this->any()) + $authForm ->method('createLoginForm') - ->will($this->returnValue($loginForm)); + ->willReturn($loginForm); return $authForm; } diff --git a/api/tests/unit/modules/authserver/validators/RequiredValidatorTest.php b/api/tests/unit/modules/authserver/validators/RequiredValidatorTest.php index e31704b..254545f 100644 --- a/api/tests/unit/modules/authserver/validators/RequiredValidatorTest.php +++ b/api/tests/unit/modules/authserver/validators/RequiredValidatorTest.php @@ -1,6 +1,9 @@ assertNull($this->callProtected($validator, 'validateValue', 'dummy')); } - /** - * @expectedException \api\modules\authserver\exceptions\IllegalArgumentException - */ public function testValidateValueEmpty() { + $this->expectException(IllegalArgumentException::class); + $validator = new RequiredValidator(); $this->assertNull($this->callProtected($validator, 'validateValue', '')); } diff --git a/api/tests/unit/modules/oauth/models/OauthClientFormFactoryTest.php b/api/tests/unit/modules/oauth/models/OauthClientFormFactoryTest.php index 2b26b2f..60ac323 100644 --- a/api/tests/unit/modules/oauth/models/OauthClientFormFactoryTest.php +++ b/api/tests/unit/modules/oauth/models/OauthClientFormFactoryTest.php @@ -1,6 +1,9 @@ assertSame('localhost:12345', $requestForm->minecraftServerIp); } - /** - * @expectedException \api\modules\oauth\exceptions\UnsupportedOauthClientType - */ public function testCreateUnknownType() { + $this->expectException(UnsupportedOauthClientType::class); + $client = new OauthClient(); $client->type = 'unknown-type'; OauthClientFormFactory::create($client); diff --git a/api/tests/unit/modules/session/filters/RateLimiterTest.php b/api/tests/unit/modules/session/filters/RateLimiterTest.php index 2860059..94ff1de 100644 --- a/api/tests/unit/modules/session/filters/RateLimiterTest.php +++ b/api/tests/unit/modules/session/filters/RateLimiterTest.php @@ -10,6 +10,7 @@ use Faker\Provider\Internet; use Yii; use yii\redis\Connection; use yii\web\Request; +use yii\web\TooManyRequestsHttpException; class RateLimiterTest extends TestCase { @@ -63,10 +64,9 @@ class RateLimiterTest extends TestCase { $filter->checkRateLimit(null, $request, null, null); } - /** - * @expectedException \yii\web\TooManyRequestsHttpException - */ public function testCheckRateLimiter() { + $this->expectException(TooManyRequestsHttpException::class); + /** @var Connection|\PHPUnit\Framework\MockObject\MockObject $redis */ $redis = $this->getMockBuilder(Connection::class) ->setMethods(['executeCommand']) diff --git a/common/tests/unit/rbac/rules/AccountOwnerTest.php b/api/tests/unit/rbac/rules/AccountOwnerTest.php similarity index 60% rename from common/tests/unit/rbac/rules/AccountOwnerTest.php rename to api/tests/unit/rbac/rules/AccountOwnerTest.php index 184bbb6..679d2ee 100644 --- a/common/tests/unit/rbac/rules/AccountOwnerTest.php +++ b/api/tests/unit/rbac/rules/AccountOwnerTest.php @@ -1,46 +1,46 @@ 'secret']]); - $component->shouldDeferMissing(); - $component->shouldReceive('findIdentityByAccessToken')->andReturn(null); - - Yii::$app->set('user', $component); - - $this->assertFalse((new AccountOwner())->execute('some token', new Item(), ['accountId' => 123])); - } - public function testExecute() { $rule = new AccountOwner(); $item = new Item(); + // Identity is null + $this->assertFalse($rule->execute('some token', $item, ['accountId' => 123])); + + // Identity presented, but have no account + /** @var IdentityInterface|\Mockery\MockInterface $identity */ + $identity = mock(IdentityInterface::class); + $identity->shouldReceive('getAccount')->andReturn(null); + Yii::$app->user->setIdentity($identity); + + $this->assertFalse($rule->execute('some token', $item, ['accountId' => 123])); + + // Identity has an account $account = new Account(); $account->id = 1; $account->status = Account::STATUS_ACTIVE; $account->rules_agreement_version = LATEST_RULES_VERSION; + /** @var IdentityInterface|\Mockery\MockInterface $identity */ $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->user->setIdentity($identity); - Yii::$app->set('user', $component); - - $this->assertFalse($rule->execute('token', $item, [])); $this->assertFalse($rule->execute('token', $item, ['accountId' => 2])); $this->assertFalse($rule->execute('token', $item, ['accountId' => '2'])); $this->assertTrue($rule->execute('token', $item, ['accountId' => 1])); @@ -54,4 +54,11 @@ class AccountOwnerTest extends TestCase { $this->assertFalse($rule->execute('token', $item, ['accountId' => 1, 'optionalRules' => true])); } + public function testExecuteWithoutAccountId() { + $this->expectException(InvalidArgumentException::class); + + $rule = new AccountOwner(); + $this->assertFalse($rule->execute('token', new Item(), [])); + } + } diff --git a/common/tests/unit/rbac/rules/OauthClientOwnerTest.php b/api/tests/unit/rbac/rules/OauthClientOwnerTest.php similarity index 53% rename from common/tests/unit/rbac/rules/OauthClientOwnerTest.php rename to api/tests/unit/rbac/rules/OauthClientOwnerTest.php index 95582af..b828a0c 100644 --- a/common/tests/unit/rbac/rules/OauthClientOwnerTest.php +++ b/api/tests/unit/rbac/rules/OauthClientOwnerTest.php @@ -1,13 +1,15 @@ assertTrue($rule->execute('some token', $item, ['clientId' => 'not exists client id'])); + + // Client exists, but identity is null + $this->assertFalse($rule->execute('some token', $item, ['clientId' => 'ely'])); + + // Client exists, identity presented, but have no account + /** @var IdentityInterface|\Mockery\MockInterface $identity */ + $identity = mock(IdentityInterface::class); + $identity->shouldReceive('getAccount')->andReturn(null); + Yii::$app->user->setIdentity($identity); + + $this->assertFalse($rule->execute('some token', $item, ['clientId' => 'ely'])); + + // Identity has an account $account = new Account(); $account->id = 1; $account->status = Account::STATUS_ACTIVE; @@ -32,15 +49,8 @@ class OauthClientOwnerTest extends TestCase { /** @var IdentityInterface|\Mockery\MockInterface $identity */ $identity = mock(IdentityInterface::class); $identity->shouldReceive('getAccount')->andReturn($account); + Yii::$app->user->setIdentity($identity); - /** @var Component|\Mockery\MockInterface $component */ - $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, [])); $this->assertTrue($rule->execute('token', $item, ['clientId' => 'admin-oauth-client'])); $this->assertTrue($rule->execute('token', $item, ['clientId' => 'not-exists-client'])); $account->id = 2; @@ -50,4 +60,11 @@ class OauthClientOwnerTest extends TestCase { $this->assertFalse($rule->execute('token', $item, ['accountId' => 1])); } + public function testExecuteWithoutClientId() { + $this->expectException(InvalidArgumentException::class); + + $rule = new OauthClientOwner(); + $this->assertFalse($rule->execute('token', new Item(), [])); + } + } diff --git a/api/tests/unit/validators/PasswordRequiredValidatorTest.php b/api/tests/unit/validators/PasswordRequiredValidatorTest.php index f60aed6..1c26e1d 100644 --- a/api/tests/unit/validators/PasswordRequiredValidatorTest.php +++ b/api/tests/unit/validators/PasswordRequiredValidatorTest.php @@ -1,11 +1,11 @@ 'ely@ely.by', ], 'components' => [ + 'cache' => [ + 'class' => \yii\caching\FileCache::class, + ], 'security' => [ // It's allows us to increase tests speed by decreasing password hashing algorithm complexity 'passwordHashCost' => 4, diff --git a/common/config/config.php b/common/config/config.php index d6cd125..bc10472 100644 --- a/common/config/config.php +++ b/common/config/config.php @@ -98,9 +98,9 @@ return [ 'class' => api\components\OAuth2\Component::class, ], 'authManager' => [ - 'class' => common\rbac\Manager::class, - 'itemFile' => '@common/rbac/.generated/items.php', - 'ruleFile' => '@common/rbac/.generated/rules.php', + 'class' => \api\rbac\Manager::class, + 'itemFile' => '@api/rbac/.generated/items.php', + 'ruleFile' => '@api/rbac/.generated/rules.php', ], 'statsd' => [ 'class' => common\components\StatsD::class, diff --git a/common/rbac/Manager.php b/common/rbac/Manager.php deleted file mode 100644 index 0565240..0000000 --- a/common/rbac/Manager.php +++ /dev/null @@ -1,34 +0,0 @@ -user->findIdentityByAccessToken($accessToken); - if ($identity === null) { - return []; - } - - /** @noinspection NullPointerExceptionInspection */ - $permissions = $identity->getAssignedPermissions(); - if (empty($permissions)) { - return []; - } - - return array_flip($permissions); - } - -} diff --git a/common/tests/unit/TestCase.php b/common/tests/unit/TestCase.php index 6e5d085..7c46c95 100644 --- a/common/tests/unit/TestCase.php +++ b/common/tests/unit/TestCase.php @@ -13,7 +13,7 @@ class TestCase extends Unit { */ protected $tester; - protected function tearDown() { + protected function tearDown(): void { parent::tearDown(); Mockery::close(); } diff --git a/common/tests/unit/components/EmailsRenderer/ComponentTest.php b/common/tests/unit/components/EmailsRenderer/ComponentTest.php index ea95708..e57203d 100644 --- a/common/tests/unit/components/EmailsRenderer/ComponentTest.php +++ b/common/tests/unit/components/EmailsRenderer/ComponentTest.php @@ -20,7 +20,7 @@ class ComponentTest extends TestCase { */ private $component; - protected function setUp() { + protected function setUp(): void { parent::setUp(); $this->api = $this->createMock(Api::class); diff --git a/common/tests/unit/tasks/CreateWebHooksDeliveriesTest.php b/common/tests/unit/tasks/CreateWebHooksDeliveriesTest.php index f67a186..5503af3 100644 --- a/common/tests/unit/tasks/CreateWebHooksDeliveriesTest.php +++ b/common/tests/unit/tasks/CreateWebHooksDeliveriesTest.php @@ -38,9 +38,8 @@ class CreateWebHooksDeliveriesTest extends TestCase { 'status' => 0, ]; $result = CreateWebHooksDeliveries::createAccountEdit($account, $changedAttributes); - $this->assertInstanceOf(CreateWebHooksDeliveries::class, $result); $this->assertSame('account.edit', $result->type); - $this->assertArraySubset([ + $this->assertEmpty(array_diff_assoc([ 'id' => 123, 'uuid' => 'afc8dc7a-4bbf-4d3a-8699-68890088cf84', 'username' => 'mock-username', @@ -48,8 +47,8 @@ class CreateWebHooksDeliveriesTest extends TestCase { 'lang' => 'en', 'isActive' => true, 'registered' => '2018-07-08T00:13:34+00:00', - 'changedAttributes' => $changedAttributes, - ], $result->payloads); + ], $result->payloads)); + $this->assertSame($changedAttributes, $result->payloads['changedAttributes']); } public function testExecute() { diff --git a/common/tests/unit/tasks/DeliveryWebHookTest.php b/common/tests/unit/tasks/DeliveryWebHookTest.php index e2f980e..586212b 100644 --- a/common/tests/unit/tasks/DeliveryWebHookTest.php +++ b/common/tests/unit/tasks/DeliveryWebHookTest.php @@ -90,10 +90,9 @@ class DeliveryWebHookTest extends TestCase { $task->execute(mock(Queue::class)); } - /** - * @expectedException \GuzzleHttp\Exception\ServerException - */ public function testExecuteUnhandledException() { + $this->expectException(ServerException::class); + $this->response = new Response(502); $task = $this->createMockedTask(); $task->type = 'account.edit'; diff --git a/common/tests/unit/tasks/PullMojangUsernameTest.php b/common/tests/unit/tasks/PullMojangUsernameTest.php index 4435f39..3e0caf5 100644 --- a/common/tests/unit/tasks/PullMojangUsernameTest.php +++ b/common/tests/unit/tasks/PullMojangUsernameTest.php @@ -50,7 +50,7 @@ class PullMojangUsernameTest extends TestCase { public function testExecuteUsernameExists() { $this->mockedMethod->willReturn(new ProfileInfo('069a79f444e94726a5befca90e38aaf5', 'Notch')); - /** @var \common\models\MojangUsername $mojangUsernameFixture */ + /** @var MojangUsername $mojangUsernameFixture */ $mojangUsernameFixture = $this->tester->grabFixture('mojangUsernames', 'Notch'); $task = new PullMojangUsername(); $task->username = 'Notch'; @@ -89,7 +89,7 @@ class PullMojangUsernameTest extends TestCase { } public function testExecuteRemoveIfExistsNoMore() { - $this->mockedMethod->willThrowException(new NoContentException(new Request('', ''), new Response())); + $this->mockedMethod->willThrowException(new NoContentException(new Request('GET', ''), new Response())); $username = $this->tester->grabFixture('mojangUsernames', 'not-exists')['username']; $task = new PullMojangUsername(); diff --git a/common/tests/unit/tasks/SendCurrentEmailConfirmationTest.php b/common/tests/unit/tasks/SendCurrentEmailConfirmationTest.php index 768001b..6301d88 100644 --- a/common/tests/unit/tasks/SendCurrentEmailConfirmationTest.php +++ b/common/tests/unit/tasks/SendCurrentEmailConfirmationTest.php @@ -41,7 +41,7 @@ class SendCurrentEmailConfirmationTest extends TestCase { $this->assertSame(['mock@ely.by' => 'mock-username'], $email->getTo()); $this->assertSame('Ely.by Account change E-mail confirmation', $email->getSubject()); $children = $email->getSwiftMessage()->getChildren()[0]; - $this->assertContains('GFEDCBA', $children->getBody()); + $this->assertStringContainsString('GFEDCBA', $children->getBody()); } } diff --git a/common/tests/unit/tasks/SendNewEmailConfirmationTest.php b/common/tests/unit/tasks/SendNewEmailConfirmationTest.php index d6ec76d..33b2444 100644 --- a/common/tests/unit/tasks/SendNewEmailConfirmationTest.php +++ b/common/tests/unit/tasks/SendNewEmailConfirmationTest.php @@ -41,7 +41,7 @@ class SendNewEmailConfirmationTest extends TestCase { $this->assertSame(['mock@ely.by' => 'mock-username'], $email->getTo()); $this->assertSame('Ely.by Account new E-mail confirmation', $email->getSubject()); $children = $email->getSwiftMessage()->getChildren()[0]; - $this->assertContains('GFEDCBA', $children->getBody()); + $this->assertStringContainsString('GFEDCBA', $children->getBody()); } } diff --git a/composer.json b/composer.json index 864979b..6f06d85 100644 --- a/composer.json +++ b/composer.json @@ -16,11 +16,12 @@ "domnikl/statsd": "^2.6", "ely/mojang-api": "^0.2.0", "ely/yii2-tempmail-validator": "^2.0", - "emarref/jwt": "~1.0.3", "goaop/framework": "^2.2.0", "guzzlehttp/guzzle": "^6.0.0", + "lcobucci/jwt": "^3.3", "league/oauth2-server": "^4.1", "mito/yii2-sentry": "^1.0", + "nesbot/carbon": "^2.22", "paragonie/constant_time_encoding": "^2.0", "ramsey/uuid": "^3.5", "spomky-labs/otphp": "^9.0.2", @@ -31,7 +32,7 @@ "yiisoft/yii2-swiftmailer": "~2.1.0" }, "require-dev": { - "codeception/base": "^3.0.0", + "codeception/codeception": "^3.0", "codeception/specify": "^1.0.0", "ely/php-code-style": "^0.3.0", "flow/jsonpath": "^0.4.0", @@ -46,7 +47,8 @@ "symfony/polyfill-ctype": "*", "symfony/polyfill-mbstring": "*", "symfony/polyfill-php70": "*", - "symfony/polyfill-php72": "*" + "symfony/polyfill-php72": "*", + "symfony/polyfill-php73": "*" }, "repositories": [ { diff --git a/composer.lock b/composer.lock index 7a3d699..c7464af 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "80ccf8b828493911307a9daa95021dfc", + "content-hash": "35095ab389bcc73cacbafceffa74fb71", "packages": [ { "name": "bacon/bacon-qr-code", @@ -54,16 +54,16 @@ }, { "name": "beberlei/assert", - "version": "v3.2.0", + "version": "v3.2.1", "source": { "type": "git", "url": "https://github.com/beberlei/assert.git", - "reference": "fd82f4c8592c8128dd74481034c31da71ebafc56" + "reference": "ce139b6bf8f07fb8389d2c8e15b98dc24fdd93c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/beberlei/assert/zipball/fd82f4c8592c8128dd74481034c31da71ebafc56", - "reference": "fd82f4c8592c8128dd74481034c31da71ebafc56", + "url": "https://api.github.com/repos/beberlei/assert/zipball/ce139b6bf8f07fb8389d2c8e15b98dc24fdd93c7", + "reference": "ce139b6bf8f07fb8389d2c8e15b98dc24fdd93c7", "shasum": "" }, "require": { @@ -72,7 +72,7 @@ "require-dev": { "friendsofphp/php-cs-fixer": "*", "phpstan/phpstan-shim": "*", - "phpunit/phpunit": "*" + "phpunit/phpunit": ">=6.0.0 <8" }, "type": "library", "autoload": { @@ -90,13 +90,13 @@ "authors": [ { "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de", - "role": "Lead Developer" + "role": "Lead Developer", + "email": "kontakt@beberlei.de" }, { "name": "Richard Quadling", - "email": "rquadling@gmail.com", - "role": "Collaborator" + "role": "Collaborator", + "email": "rquadling@gmail.com" } ], "description": "Thin assertion library for input validation in business models.", @@ -105,7 +105,7 @@ "assertion", "validation" ], - "time": "2018-12-24T15:25:25+00:00" + "time": "2019-05-28T15:18:28+00:00" }, { "name": "bower-asset/inputmask", @@ -151,7 +151,7 @@ "version": "v1.3.2", "source": { "type": "git", - "url": "git@github.com:bestiejs/punycode.js.git", + "url": "https://github.com/bestiejs/punycode.js.git", "reference": "38c8d3131a82567bfef18da09f7f4db68c84f8a3" }, "dist": { @@ -701,48 +701,6 @@ ], "time": "2017-09-30T22:51:45+00:00" }, - { - "name": "emarref/jwt", - "version": "1.0.3", - "source": { - "type": "git", - "url": "https://github.com/emarref/jwt.git", - "reference": "79f563750ff90dabd4fa677c4b4e5ec9ed52d9b4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/emarref/jwt/zipball/79f563750ff90dabd4fa677c4b4e5ec9ed52d9b4", - "reference": "79f563750ff90dabd4fa677c4b4e5ec9ed52d9b4", - "shasum": "" - }, - "require": { - "php": ">=5.4" - }, - "require-dev": { - "phpunit/phpunit": "*" - }, - "suggest": { - "ext-openssl": "Enables more token encryption options" - }, - "type": "library", - "autoload": { - "psr-4": { - "Emarref\\Jwt\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Malcolm Fell", - "email": "emarref@gmail.com" - } - ], - "description": "A JWT implementation", - "time": "2016-09-05T20:33:06+00:00" - }, { "name": "ezyang/htmlpurifier", "version": "v4.10.0", @@ -1026,33 +984,37 @@ }, { "name": "guzzlehttp/psr7", - "version": "1.5.2", + "version": "1.6.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "9f83dded91781a01c63574e387eaa769be769115" + "reference": "239400de7a173fe9901b9ac7c06497751f00727a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/9f83dded91781a01c63574e387eaa769be769115", - "reference": "9f83dded91781a01c63574e387eaa769be769115", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/239400de7a173fe9901b9ac7c06497751f00727a", + "reference": "239400de7a173fe9901b9ac7c06497751f00727a", "shasum": "" }, "require": { "php": ">=5.4.0", "psr/http-message": "~1.0", - "ralouphie/getallheaders": "^2.0.5" + "ralouphie/getallheaders": "^2.0.5 || ^3.0.0" }, "provide": { "psr/http-message-implementation": "1.0" }, "require-dev": { + "ext-zlib": "*", "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.8" }, + "suggest": { + "zendframework/zend-httphandlerrunner": "Emit PSR-7 responses" + }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.5-dev" + "dev-master": "1.6-dev" } }, "autoload": { @@ -1089,7 +1051,7 @@ "uri", "url" ], - "time": "2018-12-04T20:46:45+00:00" + "time": "2019-07-01T23:21:34+00:00" }, { "name": "jakubledl/dissect", @@ -1146,6 +1108,61 @@ ], "time": "2013-01-29T21:29:14+00:00" }, + { + "name": "lcobucci/jwt", + "version": "3.3.1", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "a11ec5f4b4d75d1fcd04e133dede4c317aac9e18" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/a11ec5f4b4d75d1fcd04e133dede4c317aac9e18", + "reference": "a11ec5f4b4d75d1fcd04e133dede4c317aac9e18", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-openssl": "*", + "php": "^5.6 || ^7.0" + }, + "require-dev": { + "mikey179/vfsstream": "~1.5", + "phpmd/phpmd": "~2.2", + "phpunit/php-invoker": "~1.1", + "phpunit/phpunit": "^5.7 || ^7.3", + "squizlabs/php_codesniffer": "~2.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Otávio Cobucci Oblonczyk", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "time": "2019-05-24T18:30:49+00:00" + }, { "name": "league/event", "version": "2.2.0", @@ -1314,6 +1331,73 @@ ], "time": "2017-11-28T16:52:35+00:00" }, + { + "name": "nesbot/carbon", + "version": "2.22.0", + "source": { + "type": "git", + "url": "https://github.com/briannesbitt/Carbon.git", + "reference": "1a0e48b5f656065ba3c265b058b25d36c2162a5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/1a0e48b5f656065ba3c265b058b25d36c2162a5e", + "reference": "1a0e48b5f656065ba3c265b058b25d36c2162a5e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.1.8 || ^8.0", + "symfony/translation": "^3.4 || ^4.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.14 || ^3.0", + "kylekatarnls/multi-tester": "^1.1", + "phpmd/phpmd": "dev-php-7.1-compatibility", + "phpstan/phpstan": "^0.11", + "phpunit/phpunit": "^7.5 || ^8.0", + "squizlabs/php_codesniffer": "^3.4" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "http://nesbot.com" + }, + { + "name": "kylekatarnls", + "homepage": "http://github.com/kylekatarnls" + } + ], + "description": "A simple API extension for DateTime.", + "homepage": "http://carbon.nesbot.com", + "keywords": [ + "date", + "datetime", + "time" + ], + "time": "2019-07-28T09:02:12+00:00" + }, { "name": "nikic/php-parser", "version": "v4.2.1", @@ -1479,24 +1563,24 @@ }, { "name": "ralouphie/getallheaders", - "version": "2.0.5", + "version": "3.0.3", "source": { "type": "git", "url": "https://github.com/ralouphie/getallheaders.git", - "reference": "5601c8a83fbba7ef674a7369456d12f1e0d0eafa" + "reference": "120b605dfeb996808c31b6477290a714d356e822" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/5601c8a83fbba7ef674a7369456d12f1e0d0eafa", - "reference": "5601c8a83fbba7ef674a7369456d12f1e0d0eafa", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", "shasum": "" }, "require": { - "php": ">=5.3" + "php": ">=5.6" }, "require-dev": { - "phpunit/phpunit": "~3.7.0", - "satooshi/php-coveralls": ">=1.0" + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" }, "type": "library", "autoload": { @@ -1515,7 +1599,7 @@ } ], "description": "A polyfill for getallheaders.", - "time": "2016-02-11T07:05:27+00:00" + "time": "2019-03-08T08:55:37+00:00" }, { "name": "ramsey/uuid", @@ -1785,16 +1869,16 @@ }, { "name": "symfony/finder", - "version": "v4.2.8", + "version": "v4.3.3", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "e45135658bd6c14b61850bf131c4f09a55133f69" + "reference": "9638d41e3729459860bb96f6247ccb61faaa45f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/e45135658bd6c14b61850bf131c4f09a55133f69", - "reference": "e45135658bd6c14b61850bf131c4f09a55133f69", + "url": "https://api.github.com/repos/symfony/finder/zipball/9638d41e3729459860bb96f6247ccb61faaa45f2", + "reference": "9638d41e3729459860bb96f6247ccb61faaa45f2", "shasum": "" }, "require": { @@ -1803,7 +1887,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -1830,7 +1914,7 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-04-06T13:51:08+00:00" + "time": "2019-06-28T13:16:30+00:00" }, { "name": "symfony/http-foundation", @@ -1935,6 +2019,139 @@ "homepage": "https://symfony.com", "time": "2019-04-10T16:20:36+00:00" }, + { + "name": "symfony/translation", + "version": "v4.3.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "4e3e39cc485304f807622bdc64938e4633396406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/4e3e39cc485304f807622bdc64938e4633396406", + "reference": "4e3e39cc485304f807622bdc64938e4633396406", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^1.1.2" + }, + "conflict": { + "symfony/config": "<3.4", + "symfony/dependency-injection": "<3.4", + "symfony/yaml": "<3.4" + }, + "provide": { + "symfony/translation-implementation": "1.0" + }, + "require-dev": { + "psr/log": "~1.0", + "symfony/config": "~3.4|~4.0", + "symfony/console": "~3.4|~4.0", + "symfony/dependency-injection": "~3.4|~4.0", + "symfony/finder": "~2.8|~3.0|~4.0", + "symfony/http-kernel": "~3.4|~4.0", + "symfony/intl": "~3.4|~4.0", + "symfony/service-contracts": "^1.1.2", + "symfony/var-dumper": "~3.4|~4.0", + "symfony/yaml": "~3.4|~4.0" + }, + "suggest": { + "psr/log-implementation": "To use logging capability in translator", + "symfony/config": "", + "symfony/yaml": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.3-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Translation Component", + "homepage": "https://symfony.com", + "time": "2019-07-18T10:34:59+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v1.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "cb4b18ad7b92a26e83b65dde940fab78339e6f3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/cb4b18ad7b92a26e83b65dde940fab78339e6f3c", + "reference": "cb4b18ad7b92a26e83b65dde940fab78339e6f3c", + "shasum": "" + }, + "require": { + "php": "^7.1.3" + }, + "suggest": { + "symfony/translation-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-06-13T11:15:36+00:00" + }, { "name": "webmozart/assert", "version": "1.4.0", @@ -2390,17 +2607,17 @@ "time": "2019-01-16T14:22:17+00:00" }, { - "name": "codeception/base", - "version": "3.0.0", + "name": "codeception/codeception", + "version": "3.0.3", "source": { "type": "git", - "url": "https://github.com/Codeception/base.git", - "reference": "86f10d5dcb05895e76711e6d25e5eb8ead354a09" + "url": "https://github.com/Codeception/Codeception.git", + "reference": "feb566a9dc26993611602011ae3834d8e3c1dd7f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/base/zipball/86f10d5dcb05895e76711e6d25e5eb8ead354a09", - "reference": "86f10d5dcb05895e76711e6d25e5eb8ead354a09", + "url": "https://api.github.com/repos/Codeception/Codeception/zipball/feb566a9dc26993611602011ae3834d8e3c1dd7f", + "reference": "feb566a9dc26993611602011ae3834d8e3c1dd7f", "shasum": "" }, "require": { @@ -2410,6 +2627,8 @@ "ext-curl": "*", "ext-json": "*", "ext-mbstring": "*", + "facebook/webdriver": "^1.6.0", + "guzzlehttp/guzzle": "^6.3.0", "guzzlehttp/psr7": "~1.4", "hoa/console": "~3.0", "php": ">=5.6.0 <8.0", @@ -2423,6 +2642,8 @@ }, "require-dev": { "codeception/specify": "~0.3", + "doctrine/annotations": "^1", + "doctrine/orm": "^2", "flow/jsonpath": "~0.2", "monolog/monolog": "~1.8", "pda/pheanstalk": "~3.0", @@ -2477,25 +2698,26 @@ "functional testing", "unit testing" ], - "time": "2019-04-24T12:13:51+00:00" + "time": "2019-07-18T16:21:08+00:00" }, { "name": "codeception/phpunit-wrapper", - "version": "7.7.1", + "version": "8.0.4", "source": { "type": "git", "url": "https://github.com/Codeception/phpunit-wrapper.git", - "reference": "ab04a956264291505ea84998f43cf91639b4575d" + "reference": "7090736f36b4398cae6ef838b9a2bdfe8d8d104b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/phpunit-wrapper/zipball/ab04a956264291505ea84998f43cf91639b4575d", - "reference": "ab04a956264291505ea84998f43cf91639b4575d", + "url": "https://api.github.com/repos/Codeception/phpunit-wrapper/zipball/7090736f36b4398cae6ef838b9a2bdfe8d8d104b", + "reference": "7090736f36b4398cae6ef838b9a2bdfe8d8d104b", "shasum": "" }, "require": { - "phpunit/php-code-coverage": "^6.0", - "phpunit/phpunit": "7.5.*", + "php": ">=7.2", + "phpunit/php-code-coverage": "^7.0", + "phpunit/phpunit": "^8.0", "sebastian/comparator": "^3.0", "sebastian/diff": "^3.0" }, @@ -2520,26 +2742,26 @@ } ], "description": "PHPUnit classes used by Codeception", - "time": "2019-02-26T20:35:32+00:00" + "time": "2019-02-27T12:58:57+00:00" }, { "name": "codeception/specify", - "version": "1.1", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/Codeception/Specify.git", - "reference": "504ac7a882e6f7226b0cff44c72a6c0bbd0bad95" + "reference": "e3fefa22f2304170024b9242b2bd8b01cf5a2ac0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/Specify/zipball/504ac7a882e6f7226b0cff44c72a6c0bbd0bad95", - "reference": "504ac7a882e6f7226b0cff44c72a6c0bbd0bad95", + "url": "https://api.github.com/repos/Codeception/Specify/zipball/e3fefa22f2304170024b9242b2bd8b01cf5a2ac0", + "reference": "e3fefa22f2304170024b9242b2bd8b01cf5a2ac0", "shasum": "" }, "require": { "myclabs/deep-copy": "~1.1", "php": ">=7.1.0", - "phpunit/phpunit": "^7.0" + "phpunit/phpunit": ">=7.0 <9.0" }, "type": "library", "autoload": { @@ -2558,7 +2780,7 @@ } ], "description": "BDD code blocks for PHPUnit and Codeception", - "time": "2018-03-12T23:55:10+00:00" + "time": "2019-08-01T20:09:26+00:00" }, { "name": "codeception/stub", @@ -2801,6 +3023,66 @@ ], "time": "2019-02-23T17:29:08+00:00" }, + { + "name": "facebook/webdriver", + "version": "1.7.1", + "source": { + "type": "git", + "url": "https://github.com/facebook/php-webdriver.git", + "reference": "e43de70f3c7166169d0f14a374505392734160e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/facebook/php-webdriver/zipball/e43de70f3c7166169d0f14a374505392734160e5", + "reference": "e43de70f3c7166169d0f14a374505392734160e5", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-zip": "*", + "php": "^5.6 || ~7.0", + "symfony/process": "^2.8 || ^3.1 || ^4.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.0", + "jakub-onderka/php-parallel-lint": "^0.9.2", + "php-coveralls/php-coveralls": "^2.0", + "php-mock/php-mock-phpunit": "^1.1", + "phpunit/phpunit": "^5.7", + "sebastian/environment": "^1.3.4 || ^2.0 || ^3.0", + "squizlabs/php_codesniffer": "^2.6", + "symfony/var-dumper": "^3.3 || ^4.0" + }, + "suggest": { + "ext-SimpleXML": "For Firefox profile creation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-community": "1.5-dev" + } + }, + "autoload": { + "psr-4": { + "Facebook\\WebDriver\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "A PHP client for Selenium WebDriver", + "homepage": "https://github.com/facebook/php-webdriver", + "keywords": [ + "facebook", + "php", + "selenium", + "webdriver" + ], + "time": "2019-06-13T08:02:18+00:00" + }, { "name": "flow/jsonpath", "version": "0.4.0", @@ -4165,16 +4447,16 @@ }, { "name": "phpspec/prophecy", - "version": "1.8.0", + "version": "1.8.1", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "4ba436b55987b4bf311cb7c6ba82aa528aac0a06" + "reference": "1927e75f4ed19131ec9bcc3b002e07fb1173ee76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/4ba436b55987b4bf311cb7c6ba82aa528aac0a06", - "reference": "4ba436b55987b4bf311cb7c6ba82aa528aac0a06", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/1927e75f4ed19131ec9bcc3b002e07fb1173ee76", + "reference": "1927e75f4ed19131ec9bcc3b002e07fb1173ee76", "shasum": "" }, "require": { @@ -4195,8 +4477,8 @@ } }, "autoload": { - "psr-0": { - "Prophecy\\": "src/" + "psr-4": { + "Prophecy\\": "src/Prophecy" } }, "notification-url": "https://packagist.org/downloads/", @@ -4224,44 +4506,44 @@ "spy", "stub" ], - "time": "2018-08-05T17:53:17+00:00" + "time": "2019-06-13T12:50:23+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "6.1.4", + "version": "7.0.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d" + "reference": "7743bbcfff2a907e9ee4a25be13d0f8ec5e73800" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", - "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7743bbcfff2a907e9ee4a25be13d0f8ec5e73800", + "reference": "7743bbcfff2a907e9ee4a25be13d0f8ec5e73800", "shasum": "" }, "require": { "ext-dom": "*", "ext-xmlwriter": "*", - "php": "^7.1", - "phpunit/php-file-iterator": "^2.0", + "php": "^7.2", + "phpunit/php-file-iterator": "^2.0.2", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-token-stream": "^3.0", + "phpunit/php-token-stream": "^3.1.0", "sebastian/code-unit-reverse-lookup": "^1.0.1", - "sebastian/environment": "^3.1 || ^4.0", + "sebastian/environment": "^4.2.2", "sebastian/version": "^2.0.1", - "theseer/tokenizer": "^1.1" + "theseer/tokenizer": "^1.1.3" }, "require-dev": { - "phpunit/phpunit": "^7.0" + "phpunit/phpunit": "^8.2.2" }, "suggest": { - "ext-xdebug": "^2.6.0" + "ext-xdebug": "^2.7.2" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "6.1-dev" + "dev-master": "7.0-dev" } }, "autoload": { @@ -4276,8 +4558,8 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "role": "lead", + "email": "sebastian@phpunit.de" } ], "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", @@ -4287,7 +4569,7 @@ "testing", "xunit" ], - "time": "2018-10-31T16:06:48+00:00" + "time": "2019-07-25T05:31:54+00:00" }, { "name": "phpunit/php-file-iterator", @@ -4382,16 +4664,16 @@ }, { "name": "phpunit/php-timer", - "version": "2.1.1", + "version": "2.1.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "8b389aebe1b8b0578430bda0c7c95a829608e059" + "reference": "1038454804406b0b5f5f520358e78c1c2f71501e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/8b389aebe1b8b0578430bda0c7c95a829608e059", - "reference": "8b389aebe1b8b0578430bda0c7c95a829608e059", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/1038454804406b0b5f5f520358e78c1c2f71501e", + "reference": "1038454804406b0b5f5f520358e78c1c2f71501e", "shasum": "" }, "require": { @@ -4418,8 +4700,8 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "role": "lead", + "email": "sebastian@phpunit.de" } ], "description": "Utility class for timing", @@ -4427,20 +4709,20 @@ "keywords": [ "timer" ], - "time": "2019-02-20T10:12:59+00:00" + "time": "2019-06-07T04:22:29+00:00" }, { "name": "phpunit/php-token-stream", - "version": "3.0.1", + "version": "3.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "c99e3be9d3e85f60646f152f9002d46ed7770d18" + "reference": "e899757bb3df5ff6e95089132f32cd59aac2220a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/c99e3be9d3e85f60646f152f9002d46ed7770d18", - "reference": "c99e3be9d3e85f60646f152f9002d46ed7770d18", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/e899757bb3df5ff6e95089132f32cd59aac2220a", + "reference": "e899757bb3df5ff6e95089132f32cd59aac2220a", "shasum": "" }, "require": { @@ -4453,7 +4735,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "3.1-dev" } }, "autoload": { @@ -4476,57 +4758,56 @@ "keywords": [ "tokenizer" ], - "time": "2018-10-30T05:52:18+00:00" + "time": "2019-07-25T05:29:42+00:00" }, { "name": "phpunit/phpunit", - "version": "7.5.10", + "version": "8.3.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "d7d9cee051d03ed98df6023aad93f7902731a780" + "reference": "21461ce5b162d0f1a0fa658e27f975517c5d4234" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d7d9cee051d03ed98df6023aad93f7902731a780", - "reference": "d7d9cee051d03ed98df6023aad93f7902731a780", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/21461ce5b162d0f1a0fa658e27f975517c5d4234", + "reference": "21461ce5b162d0f1a0fa658e27f975517c5d4234", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.1", + "doctrine/instantiator": "^1.2.0", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", "ext-xml": "*", - "myclabs/deep-copy": "^1.7", - "phar-io/manifest": "^1.0.2", - "phar-io/version": "^2.0", - "php": "^7.1", - "phpspec/prophecy": "^1.7", - "phpunit/php-code-coverage": "^6.0.7", - "phpunit/php-file-iterator": "^2.0.1", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.9.1", + "phar-io/manifest": "^1.0.3", + "phar-io/version": "^2.0.1", + "php": "^7.2", + "phpspec/prophecy": "^1.8.1", + "phpunit/php-code-coverage": "^7.0.7", + "phpunit/php-file-iterator": "^2.0.2", "phpunit/php-text-template": "^1.2.1", - "phpunit/php-timer": "^2.1", - "sebastian/comparator": "^3.0", - "sebastian/diff": "^3.0", - "sebastian/environment": "^4.0", - "sebastian/exporter": "^3.1", - "sebastian/global-state": "^2.0", + "phpunit/php-timer": "^2.1.2", + "sebastian/comparator": "^3.0.2", + "sebastian/diff": "^3.0.2", + "sebastian/environment": "^4.2.2", + "sebastian/exporter": "^3.1.0", + "sebastian/global-state": "^3.0.0", "sebastian/object-enumerator": "^3.0.3", - "sebastian/resource-operations": "^2.0", + "sebastian/resource-operations": "^2.0.1", + "sebastian/type": "^1.1.3", "sebastian/version": "^2.0.1" }, - "conflict": { - "phpunit/phpunit-mock-objects": "*" - }, "require-dev": { "ext-pdo": "*" }, "suggest": { "ext-soap": "*", "ext-xdebug": "*", - "phpunit/php-invoker": "^2.0" + "phpunit/php-invoker": "^2.0.0" }, "bin": [ "phpunit" @@ -4534,7 +4815,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "7.5-dev" + "dev-master": "8.3-dev" } }, "autoload": { @@ -4549,8 +4830,8 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "role": "lead", + "email": "sebastian@phpunit.de" } ], "description": "The PHP Unit Testing framework.", @@ -4560,7 +4841,7 @@ "testing", "xunit" ], - "time": "2019-05-09T05:06:47+00:00" + "time": "2019-08-02T07:54:25+00:00" }, { "name": "predis/predis", @@ -4612,6 +4893,55 @@ ], "time": "2016-06-16T16:22:20+00:00" }, + { + "name": "psr/container", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "time": "2017-02-14T16:28:37+00:00" + }, { "name": "psr/log", "version": "1.1.0", @@ -5233,23 +5563,26 @@ }, { "name": "sebastian/global-state", - "version": "2.0.0", + "version": "3.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4" + "reference": "edf8a461cf1d4005f19fb0b6b8b95a9f7fa0adc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4", - "reference": "e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/edf8a461cf1d4005f19fb0b6b8b95a9f7fa0adc4", + "reference": "edf8a461cf1d4005f19fb0b6b8b95a9f7fa0adc4", "shasum": "" }, "require": { - "php": "^7.0" + "php": "^7.2", + "sebastian/object-reflector": "^1.1.1", + "sebastian/recursion-context": "^3.0" }, "require-dev": { - "phpunit/phpunit": "^6.0" + "ext-dom": "*", + "phpunit/phpunit": "^8.0" }, "suggest": { "ext-uopz": "*" @@ -5257,7 +5590,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -5280,7 +5613,7 @@ "keywords": [ "global state" ], - "time": "2017-04-27T15:39:26+00:00" + "time": "2019-02-01T05:30:01+00:00" }, { "name": "sebastian/object-enumerator", @@ -5469,6 +5802,52 @@ "homepage": "https://www.github.com/sebastianbergmann/resource-operations", "time": "2018-10-04T04:07:39+00:00" }, + { + "name": "sebastian/type", + "version": "1.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "3aaaa15fa71d27650d62a948be022fe3b48541a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/3aaaa15fa71d27650d62a948be022fe3b48541a3", + "reference": "3aaaa15fa71d27650d62a948be022fe3b48541a3", + "shasum": "" + }, + "require": { + "php": "^7.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "role": "lead", + "email": "sebastian@phpunit.de" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "time": "2019-07-02T08:10:15+00:00" + }, { "name": "sebastian/version", "version": "2.0.1", @@ -5514,16 +5893,16 @@ }, { "name": "symfony/browser-kit", - "version": "v4.2.8", + "version": "v4.3.3", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "c09c18cca96d7067152f78956faf55346c338283" + "reference": "a29dd02a1f3f81b9a15c7730cc3226718ddb55ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/c09c18cca96d7067152f78956faf55346c338283", - "reference": "c09c18cca96d7067152f78956faf55346c338283", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/a29dd02a1f3f81b9a15c7730cc3226718ddb55ca", + "reference": "a29dd02a1f3f81b9a15c7730cc3226718ddb55ca", "shasum": "" }, "require": { @@ -5532,6 +5911,8 @@ }, "require-dev": { "symfony/css-selector": "~3.4|~4.0", + "symfony/http-client": "^4.3", + "symfony/mime": "^4.3", "symfony/process": "~3.4|~4.0" }, "suggest": { @@ -5540,7 +5921,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -5567,29 +5948,31 @@ ], "description": "Symfony BrowserKit Component", "homepage": "https://symfony.com", - "time": "2019-04-07T09:56:43+00:00" + "time": "2019-06-11T15:41:59+00:00" }, { "name": "symfony/console", - "version": "v4.2.8", + "version": "v4.3.3", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "e2840bb38bddad7a0feaf85931e38fdcffdb2f81" + "reference": "8b0ae5742ce9aaa8b0075665862c1ca397d1c1d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/e2840bb38bddad7a0feaf85931e38fdcffdb2f81", - "reference": "e2840bb38bddad7a0feaf85931e38fdcffdb2f81", + "url": "https://api.github.com/repos/symfony/console/zipball/8b0ae5742ce9aaa8b0075665862c1ca397d1c1d9", + "reference": "8b0ae5742ce9aaa8b0075665862c1ca397d1c1d9", "shasum": "" }, "require": { "php": "^7.1.3", - "symfony/contracts": "^1.0", - "symfony/polyfill-mbstring": "~1.0" + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php73": "^1.8", + "symfony/service-contracts": "^1.1" }, "conflict": { "symfony/dependency-injection": "<3.4", + "symfony/event-dispatcher": "<4.3", "symfony/process": "<3.3" }, "provide": { @@ -5599,9 +5982,10 @@ "psr/log": "~1.0", "symfony/config": "~3.4|~4.0", "symfony/dependency-injection": "~3.4|~4.0", - "symfony/event-dispatcher": "~3.4|~4.0", + "symfony/event-dispatcher": "^4.3", "symfony/lock": "~3.4|~4.0", - "symfony/process": "~3.4|~4.0" + "symfony/process": "~3.4|~4.0", + "symfony/var-dumper": "^4.3" }, "suggest": { "psr/log": "For using the console logger", @@ -5612,7 +5996,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -5639,7 +6023,7 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-04-08T14:23:48+00:00" + "time": "2019-07-24T17:13:59+00:00" }, { "name": "symfony/contracts", @@ -5714,16 +6098,16 @@ }, { "name": "symfony/css-selector", - "version": "v4.2.8", + "version": "v4.3.3", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "48eddf66950fa57996e1be4a55916d65c10c604a" + "reference": "105c98bb0c5d8635bea056135304bd8edcc42b4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/48eddf66950fa57996e1be4a55916d65c10c604a", - "reference": "48eddf66950fa57996e1be4a55916d65c10c604a", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/105c98bb0c5d8635bea056135304bd8edcc42b4d", + "reference": "105c98bb0c5d8635bea056135304bd8edcc42b4d", "shasum": "" }, "require": { @@ -5732,7 +6116,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -5748,14 +6132,14 @@ "MIT" ], "authors": [ - { - "name": "Jean-François Simon", - "email": "jeanfrancois.simon@sensiolabs.com" - }, { "name": "Fabien Potencier", "email": "fabien@symfony.com" }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" @@ -5763,20 +6147,20 @@ ], "description": "Symfony CssSelector Component", "homepage": "https://symfony.com", - "time": "2019-01-16T20:31:39+00:00" + "time": "2019-01-16T21:53:39+00:00" }, { "name": "symfony/dom-crawler", - "version": "v4.2.8", + "version": "v4.3.3", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "53c97769814c80a84a8403efcf3ae7ae966d53bb" + "reference": "291397232a2eefb3347eaab9170409981eaad0e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/53c97769814c80a84a8403efcf3ae7ae966d53bb", - "reference": "53c97769814c80a84a8403efcf3ae7ae966d53bb", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/291397232a2eefb3347eaab9170409981eaad0e2", + "reference": "291397232a2eefb3347eaab9170409981eaad0e2", "shasum": "" }, "require": { @@ -5784,7 +6168,11 @@ "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.0" }, + "conflict": { + "masterminds/html5": "<2.6" + }, "require-dev": { + "masterminds/html5": "^2.6", "symfony/css-selector": "~3.4|~4.0" }, "suggest": { @@ -5793,7 +6181,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -5820,34 +6208,40 @@ ], "description": "Symfony DomCrawler Component", "homepage": "https://symfony.com", - "time": "2019-02-23T15:17:42+00:00" + "time": "2019-06-13T11:03:18+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v4.2.8", + "version": "v4.3.3", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "fbce53cd74ac509cbe74b6f227622650ab759b02" + "reference": "212b020949331b6531250584531363844b34a94e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/fbce53cd74ac509cbe74b6f227622650ab759b02", - "reference": "fbce53cd74ac509cbe74b6f227622650ab759b02", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/212b020949331b6531250584531363844b34a94e", + "reference": "212b020949331b6531250584531363844b34a94e", "shasum": "" }, "require": { "php": "^7.1.3", - "symfony/contracts": "^1.0" + "symfony/event-dispatcher-contracts": "^1.1" }, "conflict": { "symfony/dependency-injection": "<3.4" }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "1.1" + }, "require-dev": { "psr/log": "~1.0", "symfony/config": "~3.4|~4.0", "symfony/dependency-injection": "~3.4|~4.0", "symfony/expression-language": "~3.4|~4.0", + "symfony/http-foundation": "^3.4|^4.0", + "symfony/service-contracts": "^1.1", "symfony/stopwatch": "~3.4|~4.0" }, "suggest": { @@ -5857,7 +6251,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -5884,7 +6278,65 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2019-04-06T13:51:08+00:00" + "time": "2019-06-27T06:42:14+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v1.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c61766f4440ca687de1084a5c00b08e167a2575c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c61766f4440ca687de1084a5c00b08e167a2575c", + "reference": "c61766f4440ca687de1084a5c00b08e167a2575c", + "shasum": "" + }, + "require": { + "php": "^7.1.3" + }, + "suggest": { + "psr/event-dispatcher": "", + "symfony/event-dispatcher-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-06-20T06:46:26+00:00" }, { "name": "symfony/filesystem", @@ -5990,6 +6442,64 @@ ], "time": "2019-01-16T21:31:25+00:00" }, + { + "name": "symfony/service-contracts", + "version": "v1.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d", + "reference": "f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d", + "shasum": "" + }, + "require": { + "php": "^7.1.3", + "psr/container": "^1.0" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "time": "2019-06-13T11:15:36+00:00" + }, { "name": "symfony/stopwatch", "version": "v4.2.3", @@ -6042,16 +6552,16 @@ }, { "name": "symfony/yaml", - "version": "v4.2.8", + "version": "v4.3.3", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "6712daf03ee25b53abb14e7e8e0ede1a770efdb1" + "reference": "34d29c2acd1ad65688f58452fd48a46bd996d5a6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/6712daf03ee25b53abb14e7e8e0ede1a770efdb1", - "reference": "6712daf03ee25b53abb14e7e8e0ede1a770efdb1", + "url": "https://api.github.com/repos/symfony/yaml/zipball/34d29c2acd1ad65688f58452fd48a46bd996d5a6", + "reference": "34d29c2acd1ad65688f58452fd48a46bd996d5a6", "shasum": "" }, "require": { @@ -6070,7 +6580,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.2-dev" + "dev-master": "4.3-dev" } }, "autoload": { @@ -6097,20 +6607,20 @@ ], "description": "Symfony Yaml Component", "homepage": "https://symfony.com", - "time": "2019-03-30T15:58:42+00:00" + "time": "2019-07-24T14:47:54+00:00" }, { "name": "theseer/tokenizer", - "version": "1.1.2", + "version": "1.1.3", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "1c42705be2b6c1de5904f8afacef5895cab44bf8" + "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/1c42705be2b6c1de5904f8afacef5895cab44bf8", - "reference": "1c42705be2b6c1de5904f8afacef5895cab44bf8", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/11336f6f84e16a720dae9d8e6ed5019efa85a0f9", + "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9", "shasum": "" }, "require": { @@ -6137,7 +6647,7 @@ } ], "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", - "time": "2019-04-04T09:56:43+00:00" + "time": "2019-06-13T22:48:21+00:00" } ], "aliases": [], diff --git a/console/codeception.dist.yml b/console/codeception.dist.yml index 673cb42..9aed054 100644 --- a/console/codeception.dist.yml +++ b/console/codeception.dist.yml @@ -18,6 +18,7 @@ coverage: - config/* - runtime/* - migrations/* + - tests/* - views/* - codeception.dist.yml - codeception.yml diff --git a/console/controllers/RbacController.php b/console/controllers/RbacController.php index 66f9a7c..2d0b8a0 100644 --- a/console/controllers/RbacController.php +++ b/console/controllers/RbacController.php @@ -1,13 +1,14 @@ getAuthManager(); $role = $authManager->createRole($name); - if (!$authManager->add($role)) { - throw new ErrorException('Cannot save role in authManager'); - } + Assert::true($authManager->add($role), 'Cannot save role in authManager'); return $role; } @@ -96,9 +95,7 @@ class RbacController extends Controller { $permission = $authManager->createPermission($name); if ($ruleClassName !== null) { $rule = new $ruleClassName(); - if (!$rule instanceof Rule) { - throw new InvalidArgumentException('ruleClassName must be rule class name'); - } + Assert::isInstanceOf($rule, Rule::class, 'ruleClassName must be rule class name'); $ruleFromAuthManager = $authManager->getRule($rule->name); if ($ruleFromAuthManager === null) { @@ -108,9 +105,7 @@ class RbacController extends Controller { $permission->ruleName = $rule->name; } - if (!$authManager->add($permission)) { - throw new ErrorException('Cannot save permission in authManager'); - } + Assert::true($authManager->add($permission), 'Cannot save permission in authManager'); return $permission; } diff --git a/data/.gitignore b/data/.gitignore index d6b7ef3..84a9470 100644 --- a/data/.gitignore +++ b/data/.gitignore @@ -1,2 +1,3 @@ * +!certs/* !.gitignore diff --git a/data/certs/private.pem b/data/certs/private.pem new file mode 100644 index 0000000..cdf213f --- /dev/null +++ b/data/certs/private.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIJ5ERywpRs5Rxn3JsSBhQTkzyYShmbKk1ziwif6yeBRooAoGCCqGSM49 +AwEHoUQDQgAEv6ENZA59mzFvoDKTX3BI3Nx6di+xWnsOAo9+zx0hnMnfzdhOS930 +ocFTBcyZmmF7iM7nhGicfiDfJKIyV8w+BA== +-----END EC PRIVATE KEY----- diff --git a/data/certs/public.pem b/data/certs/public.pem new file mode 100644 index 0000000..aeb8f26 --- /dev/null +++ b/data/certs/public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEv6ENZA59mzFvoDKTX3BI3Nx6di+x +WnsOAo9+zx0hnMnfzdhOS930ocFTBcyZmmF7iM7nhGicfiDfJKIyV8w+BA== +-----END PUBLIC KEY----- diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9d1a6a0..851cf7a 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -27,6 +27,8 @@ services: - db - redis env_file: .env + volumes: + - certs-storage:/var/www/html/data/certs networks: default: aliases: @@ -71,6 +73,14 @@ services: volumes: - ./data/redis:/data +volumes: + certs-storage: + driver: local + driver_opts: + type: none + device: $PWD/data/certs + o: bind + networks: nginx-proxy: external: diff --git a/docker/php/docker-entrypoint.sh b/docker/php/docker-entrypoint.sh index 7898d21..9cf1d5e 100755 --- a/docker/php/docker-entrypoint.sh +++ b/docker/php/docker-entrypoint.sh @@ -31,6 +31,14 @@ fi # Fix permissions for cron tasks chmod 644 /etc/cron.d/* +JWT_PRIVATE_PEM_LOCATION="/var/www/html/data/certs/private.pem" +JWT_PUBLIC_PEM_LOCATION="/var/www/html/data/certs/public.pem" +if [ ! -f "$JWT_PRIVATE_PEM_LOCATION" ] ; then + echo "There is no private key. Generating the new one." + openssl ecparam -name prime256v1 -genkey -noout -out "$JWT_PRIVATE_PEM_LOCATION" + openssl ec -in "$JWT_PRIVATE_PEM_LOCATION" -pubout -out "$JWT_PUBLIC_PEM_LOCATION" +fi + if [ "$1" = "crond" ] ; then # see: https://github.com/dubiousjim/dcron/issues/13 # ignore using `exec` for `dcron` to get another pid instead of `1`