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..a16cea4 --- /dev/null +++ b/api/components/Tokens/Component.php @@ -0,0 +1,96 @@ +issuedAt($time) + ->expiresAt($time + self::EXPIRATION_TIMEOUT); + 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..e92446a --- /dev/null +++ b/api/components/Tokens/TokensFactory.php @@ -0,0 +1,31 @@ + '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'] = time() + 60 * 60 * 24 * 7; // 7d + } 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 10d2acf..955a857 100644 --- a/api/components/User/Component.php +++ b/api/components/User/Component.php @@ -3,28 +3,11 @@ declare(strict_types=1); namespace api\components\User; -use api\exceptions\ThisShouldNotHappenException; use common\models\Account; use common\models\AccountSession; -use common\rbac\Roles as R; -use DateInterval; -use DateTime; -use Emarref\Jwt\Algorithm\AlgorithmInterface; -use Emarref\Jwt\Algorithm\Hs256; -use Emarref\Jwt\Algorithm\Rs256; -use Emarref\Jwt\Claim; -use Emarref\Jwt\Encryption\Asymmetric as AsymmetricEncryption; -use Emarref\Jwt\Encryption\EncryptionInterface; -use Emarref\Jwt\Encryption\Factory as EncryptionFactory; -use Emarref\Jwt\Exception\VerificationException; -use Emarref\Jwt\HeaderParameter\Custom; -use Emarref\Jwt\Token; -use Emarref\Jwt\Verification\Context as VerificationContext; use Exception; use InvalidArgumentException; -use Webmozart\Assert\Assert; use Yii; -use yii\base\InvalidConfigException; use yii\web\UnauthorizedHttpException; use yii\web\User as YiiUserComponent; @@ -41,52 +24,29 @@ class Component extends YiiUserComponent { public const KEEP_SITE_SESSIONS = 2; public const KEEP_CURRENT_SESSION = 4; - public const JWT_SUBJECT_PREFIX = 'ely|'; - - private const LATEST_JWT_VERSION = 1; - public $enableSession = false; public $loginUrl = null; - public $identityClass = Identity::class; - - public $secret; - - public $publicKeyPath; - - public $privateKeyPath; - - public $expirationTimeout = 'PT1H'; - - public $sessionTimeout = 'P7D'; - - private $publicKey; - - private $privateKey; - /** - * @var Token[] + * We don't use the standard web authorization mechanism via cookies. + * Therefore, only one static method findIdentityByAccessToken is used from + * the whole IdentityInterface interface, which is implemented in the factory. + * The method only used from loginByAccessToken from base class. + * + * @var string */ - private static $parsedTokensCache = []; - - public function init() { - parent::init(); - Assert::notEmpty($this->secret, 'secret must be specified'); - Assert::notEmpty($this->publicKeyPath, 'public key path must be specified'); - Assert::notEmpty($this->privateKeyPath, 'private key path must be specified'); - } + public $identityClass = IdentityFactory::class; 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); + return IdentityFactory::findIdentityByAccessToken($accessToken); } catch (UnauthorizedHttpException $e) { + // TODO: if this exception is catched there, how it forms "Token expired" exception? // Do nothing. It's okay to catch this. } catch (Exception $e) { Yii::error($e); @@ -95,77 +55,6 @@ class Component extends YiiUserComponent { return null; } - public function createJwtAuthenticationToken(Account $account, AccountSession $session = null): Token { - $token = $this->createToken($account); - if ($session !== null) { - $token->addClaim(new Claim\JwtId($session->id)); - } else { - // 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)))); - } - - return $token; - } - - 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; - } - - public function serializeToken(Token $token): string { - $encryption = $this->getEncryptionForVersion(self::LATEST_JWT_VERSION); - $this->prepareEncryptionForEncoding($encryption); - - return (new Jwt())->serialize($token, $encryption); - } - - /** - * @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); - } - - $versionHeader = $notVerifiedToken->getHeader()->findParameterByName('v'); - $version = $versionHeader ? $versionHeader->getValue() : 0; - $encryption = $this->getEncryptionForVersion($version); - $this->prepareEncryptionForDecoding($encryption); - - $context = new VerificationContext($encryption); - $context->setSubject(self::JWT_SUBJECT_PREFIX); - $jwt->verify($notVerifiedToken, $context); - - $token = $notVerifiedToken; - } - - return $token; - } - /** * 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: @@ -188,17 +77,17 @@ class Component extends YiiUserComponent { } try { - $token = $this->parseToken($bearer); - } catch (VerificationException $e) { + $token = Yii::$app->tokens->parse($bearer); + } catch (InvalidArgumentException $e) { return null; } - $sessionId = $token->getPayload()->findClaimByName(Claim\JwtId::NAME); - if ($sessionId === null) { + $sessionId = $token->getClaim('jti', false); + if ($sessionId === false) { return null; } - return AccountSession::findOne($sessionId->getValue()); + return AccountSession::findOne($sessionId); } public function terminateSessions(Account $account, int $mode = 0): void { @@ -222,66 +111,6 @@ class Component extends YiiUserComponent { } } - private function getPublicKey() { - if (empty($this->publicKey)) { - if (!($this->publicKey = file_get_contents($this->publicKeyPath))) { - throw new InvalidConfigException('invalid public key path'); - } - } - - return $this->publicKey; - } - - private function getPrivateKey() { - if (empty($this->privateKey)) { - if (!($this->privateKey = file_get_contents($this->privateKeyPath))) { - throw new InvalidConfigException('invalid private key path'); - } - } - - return $this->privateKey; - } - - private function createToken(Account $account): Token { - $token = new Token(); - $token->addHeader(new Custom('v', 1)); - foreach ($this->getClaims($account) as $claim) { - $token->addClaim($claim); - } - - return $token; - } - - /** - * @param Account $account - * @return Claim\AbstractClaim[] - */ - private 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 getEncryptionForVersion(int $version): EncryptionInterface { - return EncryptionFactory::create($this->getAlgorithm($version ?? 0)); - } - - private function getAlgorithm(int $version): AlgorithmInterface { - switch ($version) { - case 0: - return new Hs256($this->secret); - case 1: - return new Rs256(); - } - - throw new InvalidArgumentException('Unsupported token version'); - } - private function getBearerToken(): ?string { $authHeader = Yii::$app->request->getHeaders()->get('Authorization'); if ($authHeader === null || !preg_match('/^Bearer\s+(.*?)$/', $authHeader, $matches)) { @@ -291,16 +120,4 @@ class Component extends YiiUserComponent { return $matches[1]; } - private function prepareEncryptionForEncoding(EncryptionInterface $encryption): void { - if ($encryption instanceof AsymmetricEncryption) { - $encryption->setPrivateKey($this->getPrivateKey()); - } - } - - private function prepareEncryptionForDecoding(EncryptionInterface $encryption) { - if ($encryption instanceof AsymmetricEncryption) { - $encryption->setPublicKey($this->getPublicKey()); - } - } - } diff --git a/api/components/User/IdentityFactory.php b/api/components/User/IdentityFactory.php new file mode 100644 index 0000000..f38f47e --- /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..479351f 100644 --- a/api/components/User/JwtIdentity.php +++ b/api/components/User/JwtIdentity.php @@ -1,82 +1,80 @@ 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'); + } + + if ($token->isExpired()) { + throw new UnauthorizedHttpException('Token expired'); + } + + if (!$token->validate(new ValidationData())) { + throw new UnauthorizedHttpException('Incorrect token'); + } + + $sub = $token->getClaim('sub', false); + if ($sub !== false && strpos($sub, TokensFactory::SUB_ACCOUNT_PREFIX) !== 0) { + throw new UnauthorizedHttpException('Incorrect token'); + } + + return new self($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; } public function getAuthKey() { diff --git a/api/components/User/Identity.php b/api/components/User/Oauth2Identity.php similarity index 81% rename from api/components/User/Identity.php rename to api/components/User/Oauth2Identity.php index 0220eb2..8a9001a 100644 --- a/api/components/User/Identity.php +++ b/api/components/User/Oauth2Identity.php @@ -1,4 +1,6 @@ oauth->getAccessTokenStorage()->get($token); if ($model === null) { 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 8fdec47..1a9ffc2 100644 --- a/api/config/config.php +++ b/api/config/config.php @@ -10,9 +10,13 @@ return [ 'components' => [ 'user' => [ 'class' => api\components\User\Component::class, - 'secret' => getenv('JWT_USER_SECRET'), - 'publicKeyPath' => getenv('JWT_PUBLIC_KEY') ?: 'data/certs/public.crt', - 'privateKeyPath' => getenv('JWT_PRIVATE_KEY') ?: 'data/certs/private.key', + ], + '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 9738bd2..60c6608 100644 --- a/api/models/authentication/ConfirmEmailForm.php +++ b/api/models/authentication/ConfirmEmailForm.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace api\models\authentication; use api\aop\annotations\CollectModelMetrics; -use api\components\User\AuthenticationResult; +use api\components\Tokens\TokensFactory; use api\models\base\ApiForm; use api\validators\EmailActivationKeyValidator; use common\models\Account; @@ -25,12 +25,10 @@ class ConfirmEmailForm extends ApiForm { /** * @CollectModelMetrics(prefix="signup.confirmEmail") - * @return AuthenticationResult|bool - * @throws \Throwable */ - public function confirm() { + public function confirm(): ?AuthenticationResult { if (!$this->validate()) { - return false; + return null; } $transaction = Yii::$app->db->beginTransaction(); @@ -39,6 +37,7 @@ class ConfirmEmailForm extends ApiForm { $confirmModel = $this->key; $account = $confirmModel->account; $account->status = Account::STATUS_ACTIVE; + /** @noinspection PhpUnhandledExceptionInspection */ Assert::notSame($confirmModel->delete(), false, 'Unable remove activation key.'); Assert::true($account->save(), 'Unable activate user account.'); @@ -49,12 +48,11 @@ class ConfirmEmailForm extends ApiForm { $session->generateRefreshToken(); Assert::true($session->save(), 'Cannot save account session model'); - $token = Yii::$app->user->createJwtAuthenticationToken($account, $session); - $jwt = Yii::$app->user->serializeToken($token); + $token = TokensFactory::createForAccount($account, $session); $transaction->commit(); - return new AuthenticationResult($account, $jwt, $session); + return new AuthenticationResult($token, $session->refresh_token); } } diff --git a/api/models/authentication/LoginForm.php b/api/models/authentication/LoginForm.php index 9925a7f..88c86ed 100644 --- a/api/models/authentication/LoginForm.php +++ b/api/models/authentication/LoginForm.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace api\models\authentication; use api\aop\annotations\CollectModelMetrics; -use api\components\User\AuthenticationResult; +use api\components\Tokens\TokensFactory; use api\models\base\ApiForm; use api\traits\AccountFinder; use api\validators\TotpValidator; @@ -30,12 +30,12 @@ class LoginForm extends ApiForm { ['login', 'required', 'message' => 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'], @@ -97,11 +97,10 @@ class LoginForm extends ApiForm { /** * @CollectModelMetrics(prefix="authentication.login") - * @return AuthenticationResult|bool */ - public function login() { + public function login(): ?AuthenticationResult { if (!$this->validate()) { - return false; + return null; } $transaction = Yii::$app->db->beginTransaction(); @@ -113,21 +112,22 @@ class LoginForm extends ApiForm { Assert::true($account->save(), 'Unable to upgrade user\'s password'); } - $session = null; + $refreshToken = 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'); + + $refreshToken = $session->refresh_token; } - $token = Yii::$app->user->createJwtAuthenticationToken($account, $session); - $jwt = Yii::$app->user->serializeToken($token); + $token = TokensFactory::createForAccount($account, $session); $transaction->commit(); - return new AuthenticationResult($account, $jwt, $session); + return new AuthenticationResult($token, $refreshToken); } } diff --git a/api/models/authentication/RecoverPasswordForm.php b/api/models/authentication/RecoverPasswordForm.php index 6f0e186..957c1ec 100644 --- a/api/models/authentication/RecoverPasswordForm.php +++ b/api/models/authentication/RecoverPasswordForm.php @@ -4,6 +4,7 @@ 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\helpers\Error as E; @@ -38,12 +39,10 @@ class RecoverPasswordForm extends ApiForm { /** * @CollectModelMetrics(prefix="authentication.recoverPassword") - * @return \api\components\User\AuthenticationResult|bool - * @throws \Throwable */ - public function recoverPassword() { + public function recoverPassword(): ?AuthenticationResult { if (!$this->validate()) { - return false; + return null; } $transaction = Yii::$app->db->beginTransaction(); @@ -52,16 +51,16 @@ class RecoverPasswordForm extends ApiForm { $confirmModel = $this->key; $account = $confirmModel->account; $account->password = $this->newPassword; + /** @noinspection PhpUnhandledExceptionInspection */ Assert::notSame($confirmModel->delete(), false, 'Unable remove activation key.'); Assert::true($account->save(), 'Unable activate user account.'); - $token = Yii::$app->user->createJwtAuthenticationToken($account); - $jwt = Yii::$app->user->serializeToken($token); + $token = TokensFactory::createForAccount($account); $transaction->commit(); - return new \api\components\User\AuthenticationResult($account, $jwt, null); + 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/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 f1d66f6..5061011 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,16 +13,15 @@ use Yii; class FunctionalTester extends Actor { use FunctionalTesterActions; - public function amAuthenticated(string $asUsername = 'admin') { + public function amAuthenticated(string $asUsername = 'admin'): int { /** @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}\""); } - $token = Yii::$app->user->createJwtAuthenticationToken($account); - $jwt = Yii::$app->user->serializeToken($token); - $this->amBearerAuthenticated($jwt); + $token = TokensFactory::createForAccount($account); + $this->amBearerAuthenticated((string)$token); return $account->id; } @@ -31,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/unit/components/Tokens/ComponentTest.php b/api/tests/unit/components/Tokens/ComponentTest.php new file mode 100644 index 0000000..8792eeb --- /dev/null +++ b/api/tests/unit/components/Tokens/ComponentTest.php @@ -0,0 +1,92 @@ +component = Yii::$app->tokens; + } + + public function testCreate() { + // Run without any arguments + $token = $this->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, + ]; + } + + 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 a22fced..6dd371b 100644 --- a/api/tests/unit/components/User/ComponentTest.php +++ b/api/tests/unit/components/User/ComponentTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace codeception\api\unit\components\User; use api\components\User\Component; -use api\components\User\Identity; +use api\components\User\IdentityFactory; use api\tests\unit\TestCase; use common\models\Account; use common\models\AccountSession; @@ -36,29 +36,7 @@ class ComponentTest extends TestCase { ]; } - public function testCreateJwtAuthenticationToken() { - $this->mockRequest(); - - // Token without session - $account = new Account(['id' => 1]); - $token = $this->component->createJwtAuthenticationToken($account); - $payloads = $token->getPayload(); - $this->assertEqualsWithDelta(time(), $payloads->findClaimByName('iat')->getValue(), 3); - $this->assertEqualsWithDelta(time() + 60 * 60 * 24 * 7, $payloads->findClaimByName('exp')->getValue(), 3); - $this->assertSame('ely|1', $payloads->findClaimByName('sub')->getValue()); - $this->assertSame('accounts_web_user', $payloads->findClaimByName('ely-scopes')->getValue()); - $this->assertNull($payloads->findClaimByName('jti')); - - $session = new AccountSession(['id' => 2]); - $token = $this->component->createJwtAuthenticationToken($account, $session); - $payloads = $token->getPayload(); - $this->assertEqualsWithDelta(time(), $payloads->findClaimByName('iat')->getValue(), 3); - $this->assertEqualsWithDelta(time() + 3600, $payloads->findClaimByName('exp')->getValue(), 3); - $this->assertSame('ely|1', $payloads->findClaimByName('sub')->getValue()); - $this->assertSame('accounts_web_user', $payloads->findClaimByName('ely-scopes')->getValue()); - $this->assertSame(2, $payloads->findClaimByName('jti')->getValue()); - } - + // TODO: move test to refresh token form public function testRenewJwtAuthenticationToken() { $userIP = '192.168.0.1'; $this->mockRequest($userIP); @@ -83,14 +61,6 @@ class ComponentTest extends TestCase { $this->assertSame($session->id, $payloads->findClaimByName('jti')->getValue(), 'session has not changed'); } - public function testParseToken() { - $this->mockRequest(); - $account = new Account(['id' => 1]); - $token = $this->component->createJwtAuthenticationToken($account); - $jwt = $this->component->serializeToken($token); - $this->component->parseToken($jwt); - } - public function testGetActiveSession() { /** @var Account $account */ $account = $this->tester->grabFixture('accounts', 'admin'); @@ -172,7 +142,7 @@ class ComponentTest extends TestCase { private function getComponentConfig() { return [ - 'identityClass' => Identity::class, + 'identityClass' => IdentityFactory::class, 'enableSession' => false, 'loginUrl' => null, 'secret' => 'secret', 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/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/LoginFormTest.php b/api/tests/unit/models/authentication/LoginFormTest.php index 5f4675f..35e7615 100644 --- a/api/tests/unit/models/authentication/LoginFormTest.php +++ b/api/tests/unit/models/authentication/LoginFormTest.php @@ -1,7 +1,8 @@ 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 b13124d..e5426f9 100644 --- a/api/tests/unit/models/authentication/LogoutFormTest.php +++ b/api/tests/unit/models/authentication/LogoutFormTest.php @@ -2,7 +2,7 @@ namespace api\tests\_support\models\authentication; use api\components\User\Component; -use api\components\User\Identity; +use api\components\User\IdentityFactory; use api\models\authentication\LogoutForm; use api\tests\unit\TestCase; use Codeception\Specify; @@ -59,7 +59,7 @@ class LogoutFormTest extends TestCase { private function getComponentArgs() { return [ - 'identityClass' => Identity::class, + 'identityClass' => IdentityFactory::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..9a89703 100644 --- a/api/tests/unit/models/authentication/RefreshTokenFormTest.php +++ b/api/tests/unit/models/authentication/RefreshTokenFormTest.php @@ -1,7 +1,8 @@ refresh_token = $this->tester->grabFixture('sessions', 'admin')['refresh_token']; - $this->assertInstanceOf(AuthenticationResult::class, $model->renew()); + $this->assertNotNull($model->renew()); } } diff --git a/api/tests/unit/modules/accounts/models/ChangePasswordFormTest.php b/api/tests/unit/modules/accounts/models/ChangePasswordFormTest.php index f1fefca..803bea3 100644 --- a/api/tests/unit/modules/accounts/models/ChangePasswordFormTest.php +++ b/api/tests/unit/modules/accounts/models/ChangePasswordFormTest.php @@ -2,7 +2,7 @@ namespace api\tests\unit\modules\accounts\models; use api\components\User\Component; -use api\components\User\Identity; +use api\components\User\IdentityFactory; use api\modules\accounts\models\ChangePasswordForm; use api\tests\unit\TestCase; use common\components\UserPass; @@ -57,7 +57,7 @@ class ChangePasswordFormTest extends TestCase { public function testPerformAction() { $component = mock(Component::class . '[terminateSessions]', [[ - 'identityClass' => Identity::class, + 'identityClass' => IdentityFactory::class, 'enableSession' => false, 'loginUrl' => null, 'secret' => 'secret', @@ -119,7 +119,7 @@ class ChangePasswordFormTest extends TestCase { /** @var Component|\Mockery\MockInterface $component */ $component = mock(Component::class . '[terminateSessions]', [[ - 'identityClass' => Identity::class, + 'identityClass' => IdentityFactory::class, 'enableSession' => false, 'loginUrl' => null, 'secret' => 'secret', diff --git a/api/tests/unit/modules/accounts/models/EnableTwoFactorAuthFormTest.php b/api/tests/unit/modules/accounts/models/EnableTwoFactorAuthFormTest.php index c70c98f..09647a4 100644 --- a/api/tests/unit/modules/accounts/models/EnableTwoFactorAuthFormTest.php +++ b/api/tests/unit/modules/accounts/models/EnableTwoFactorAuthFormTest.php @@ -2,7 +2,7 @@ namespace api\tests\unit\modules\accounts\models; use api\components\User\Component; -use api\components\User\Identity; +use api\components\User\IdentityFactory; use api\modules\accounts\models\EnableTwoFactorAuthForm; use api\tests\unit\TestCase; use common\helpers\Error as E; @@ -20,7 +20,7 @@ class EnableTwoFactorAuthFormTest extends TestCase { /** @var Component|\Mockery\MockInterface $component */ $component = mock(Component::class . '[terminateSessions]', [[ - 'identityClass' => Identity::class, + 'identityClass' => IdentityFactory::class, 'enableSession' => false, 'loginUrl' => null, 'secret' => 'secret', diff --git a/autocompletion.php b/autocompletion.php index ba53026..fa14d25 100644 --- a/autocompletion.php +++ b/autocompletion.php @@ -25,6 +25,7 @@ class Yii extends \yii\BaseYii { * @property \api\components\OAuth2\Component $oauth * @property \common\components\StatsD $statsd * @property \yii\queue\Queue $queue + * @property \api\components\Tokens\Component $tokens */ abstract class BaseApplication extends yii\base\Application { } diff --git a/common/config/config-test.php b/common/config/config-test.php index 2cffd5e..ecea105 100644 --- a/common/config/config-test.php +++ b/common/config/config-test.php @@ -6,6 +6,9 @@ return [ 'fromEmail' => '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/composer.json b/composer.json index 864979b..9a0d0e1 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "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", "paragonie/constant_time_encoding": "^2.0", diff --git a/composer.lock b/composer.lock index 7a3d699..6271d21 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": "2dfa204a51a82cd7c7d6a5b7d1ccbc0c", "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": { @@ -1146,6 +1146,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", diff --git a/data/certs/private.key b/data/certs/private.key deleted file mode 100644 index 5423a00..0000000 --- a/data/certs/private.key +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEwAIBADANBgkqhkiG9w0BAQEFAASCBKowggSmAgEAAoIBAQDbTqmRpLg3XjDH -3Z97uHdNq4F5j77Rp+M7ctyfUhtb+U7VWppjk2Dxyp2/iPzKK3K0lC91zlnxO4HT -jFCWTIQzSfiFx/Z6nbUXYFZunzRkbt6UgXjUhnYLSIVvNDneph/BZTSxNThky7a8 -weng1+1e7cYcYx7pJWUXB9XINEKdyZ/pF+kB8UPK/LCLY4jFTm7t+N1Rm1R6VpEy -VqhwoDTefkiP9H/QZBp4Ihy48v/NTtgHdsc3Yz+//M6km39MmxIh4wBrZiIictzg -5Xmd1vXamDYFbGZHpKRuujCSufZaglrjGvgaAq1lSS+Cwc5eNCDTlw8OWGJyeSMy -AvYKK5pnAgMBAAECggEBAKcg02kCtsC7L0GhS6Dle0XdpdYWDb2IzErJxghEckUt -QT6mxXGNJxwc5QrKQptvcQLcyy5kC3cjelTVYbSoqzbK8HJDaTsYZKFj8XpsKWlA -dK+H26Vasyr2IXoVuuRKhXjEv9ssS8XE2YYP4URQSb1GRuvrPes/bEKY3fqsmPfU -/rpaUNG9OvskfIDzT+VoEe5RfPW0+uchHZHypWdnhSxLC/oH8KjcUxmCdQ3q46fT -2GhDJnDLXC8MGbyUp7Nw+eSg+4UTCjaNqV7c4vOSXqSBPch7nYFf1YqYuseok21t -UK1G55JrBfsUAmldSi1UVdnAanVRNZiC2LsdDe9PpUECgYEA7kVk7nFqtHqx6EOz -4p6AeDlslrPEWz996AgV1qezBboGlpPkDv+of5cOG4ZMpDJD5KbSIJXTPC06G+3V -VgYpg7cYO9il3I5vaxo64dC9Ib5HQe8UTreVI5763S7Zq7V0jWKOzrlKzA/KQl3x -1kHXS5levDp1uuwAdRBn6DvXnv0CgYEA66ALVI1BUU+OhqSGRQu9pZATfyB5hJaD -1iICiOgl1LRwMJW7/uWUTQ+h5H3lYDmyf+y9/8x8jTfEVZYEwV2bw9wzII87YA9R -pKQl+HMlynrgYWZ2Z94mRFs3poxU8AgpU9MDN84b2cHyP3TGhQjkdtdyFE4lcCiQ -yQqnWa+BBjMCgYEArKeKQKHcoVT7D4PnmIIkM3ng7r7qvPggAv/A219/gNnQplIa -AqhM78+EgHtrk9t8iPY88zG99DANmGlZmlEyyefl3o/ZeB2aLPC/1BvOwOHBfsyA -WZ37qukrfRTS0/LTtxPAyZlI0t9qP3cVo5zoJjbHh/uQjdcvaaRutsCOOP0CgYEA -10TB9T6UdVgM6+A2N7CxVCicV2HxA3yL+D/cNv55SaqMcSbrucY/xmPI0btfq5kr -BorhT2mgRVi0zEiiEZOXMsrj/xQ899cnDRdXBXUWCrZWd0YoWV7xcTQxVL0TALVE -JKw9XWe1tC3oR6dFk9d6+0R8miaHN8An/zT3jg21AFcCgYEAslWiTkT1ULAAhlHa -KLbSW1slYJR8/i9mwIDOoD2BvVJUSqbowAogD4mXRm6S77AxoQX4nygzE6XscR4V -h+fINRJeh7yrFk5x/GUjh7tQo9EITjY89X0s35hZ27i61l66eZ5u06j4xE5+Y424 -HMsBjKAmKFNPebTWFcAlXXaeCPU= ------END PRIVATE KEY----- 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.crt b/data/certs/public.crt deleted file mode 100644 index 8659eb4..0000000 --- a/data/certs/public.crt +++ /dev/null @@ -1,16 +0,0 @@ ------BEGIN CERTIFICATE----- -MIICljCCAX4CCQDA6sdDyK1Y/zANBgkqhkiG9w0BAQsFADANMQswCQYDVQQGEwJC -WTAeFw0xOTA3MjQxMDI5NTdaFw0yMTA3MjMxMDI5NTdaMA0xCzAJBgNVBAYTAkJZ -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA206pkaS4N14wx92fe7h3 -TauBeY++0afjO3Lcn1IbW/lO1VqaY5Ng8cqdv4j8yitytJQvdc5Z8TuB04xQlkyE -M0n4hcf2ep21F2BWbp80ZG7elIF41IZ2C0iFbzQ53qYfwWU0sTU4ZMu2vMHp4Nft -Xu3GHGMe6SVlFwfVyDRCncmf6RfpAfFDyvywi2OIxU5u7fjdUZtUelaRMlaocKA0 -3n5Ij/R/0GQaeCIcuPL/zU7YB3bHN2M/v/zOpJt/TJsSIeMAa2YiInLc4OV5ndb1 -2pg2BWxmR6Skbrowkrn2WoJa4xr4GgKtZUkvgsHOXjQg05cPDlhicnkjMgL2Ciua -ZwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQB+i6Q3Ltg5MPEqHZ3GCpsFMV+xWKp5 -TSgguFr422az9v/Da01VHOX884D0dZt1r6W+zzfIQzIXpRqQkl4YuS1N17Q/KN3E -7rJ0R7gsXM7+KiGVrZyoZlxRaRXCiErUWBOxamIPy07zOWLnWa1kZZNDvgiurMbF -yaREQargFM8G91zkA6XiMXFoermARYB6RLtyHD0EC3I2DSZpOuMD9Kg1k/uw6f3W -xwsQY6kpzoZkYfTqoM4ky16yNPRf9vsej2dYlRr1YPWWQOicY1TrwFJMKoogylTD -lN61u8WED7Z8M00F6FYuuFffzt2Si9GrYeTuf8ZShpKiDqK0P22oiAao ------END CERTIFICATE----- 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-----