Cleanup User Component, update tests

This commit is contained in:
ErickSkrauch 2019-07-26 17:04:57 +03:00
parent 445c234360
commit 4c2a9cc172
7 changed files with 172 additions and 155 deletions

View File

@ -1,4 +1,6 @@
<?php
declare(strict_types=1);
namespace api\components\User;
use api\exceptions\ThisShouldNotHappenException;
@ -11,6 +13,7 @@ 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;
@ -18,6 +21,7 @@ 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;
@ -39,6 +43,8 @@ class Component extends YiiUserComponent {
public const JWT_SUBJECT_PREFIX = 'ely|';
private const LATEST_JWT_VERSION = 1;
public $enableSession = false;
public $loginUrl = null;
@ -71,26 +77,6 @@ class Component extends YiiUserComponent {
Assert::notEmpty($this->privateKeyPath, 'private key path must be specified');
}
public function getPublicKey() {
if (empty($this->publicKey)) {
if (!($this->publicKey = file_get_contents($this->publicKeyPath))) {
throw new InvalidConfigException('invalid public key path');
}
}
return $this->publicKey;
}
public function getPrivateKey() {
if (empty($this->privateKey)) {
if (!($this->privateKey = file_get_contents($this->privateKeyPath))) {
throw new InvalidConfigException('invalid private key path');
}
}
return $this->privateKey;
}
public function findIdentityByAccessToken($accessToken): ?IdentityInterface {
if (empty($accessToken)) {
return null;
@ -109,29 +95,17 @@ class Component extends YiiUserComponent {
return null;
}
public function createJwtAuthenticationToken(Account $account, bool $rememberMe): AuthenticationResult {
$ip = Yii::$app->request->userIP;
public function createJwtAuthenticationToken(Account $account, AccountSession $session = null): Token {
$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');
}
if ($session !== null) {
$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);
return $token;
}
public function renewJwtAuthenticationToken(AccountSession $session): AuthenticationResult {
@ -155,6 +129,13 @@ class Component extends YiiUserComponent {
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
@ -170,9 +151,10 @@ class Component extends YiiUserComponent {
throw new VerificationException('Incorrect token encoding', 0, $e);
}
$version = $notVerifiedToken->getHeader()->findParameterByName('v');
$version = $version ? $version->getValue() : null;
$encryption = $this->getEncryption($version);
$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);
@ -240,28 +222,27 @@ class Component extends YiiUserComponent {
}
}
public function getAlgorithm(): AlgorithmInterface {
return new Rs256();
}
public function getEncryption(?int $version): EncryptionInterface {
$algorithm = $version ? new Rs256() : new Hs256($this->secret);
$encryption = EncryptionFactory::create($algorithm);
if ($version) {
$encryption->setPublicKey($this->getPublicKey())->setPrivateKey($this->getPrivateKey());
private function getPublicKey() {
if (empty($this->publicKey)) {
if (!($this->publicKey = file_get_contents($this->publicKeyPath))) {
throw new InvalidConfigException('invalid public key path');
}
}
return $encryption;
return $this->publicKey;
}
protected function serializeToken(Token $token): string {
$encryption = $this->getEncryption(1);
private function getPrivateKey() {
if (empty($this->privateKey)) {
if (!($this->privateKey = file_get_contents($this->privateKeyPath))) {
throw new InvalidConfigException('invalid private key path');
}
}
return (new Jwt())->serialize($token, $encryption);
return $this->privateKey;
}
protected function createToken(Account $account): Token {
private function createToken(Account $account): Token {
$token = new Token();
$token->addHeader(new Custom('v', 1));
foreach ($this->getClaims($account) as $claim) {
@ -275,7 +256,7 @@ class Component extends YiiUserComponent {
* @param Account $account
* @return Claim\AbstractClaim[]
*/
protected function getClaims(Account $account): array {
private function getClaims(Account $account): array {
$currentTime = new DateTime();
return [
@ -286,7 +267,22 @@ class Component extends YiiUserComponent {
];
}
private function getBearerToken() {
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)) {
return null;
@ -295,4 +291,16 @@ 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());
}
}
}

View File

@ -4,12 +4,14 @@ declare(strict_types=1);
namespace api\models\authentication;
use api\aop\annotations\CollectModelMetrics;
use api\components\User\AuthenticationResult;
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,8 +25,8 @@ class ConfirmEmailForm extends ApiForm {
/**
* @CollectModelMetrics(prefix="signup.confirmEmail")
* @return \api\components\User\AuthenticationResult|bool
* @throws ErrorException
* @return AuthenticationResult|bool
* @throws \Throwable
*/
public function confirm() {
if (!$this->validate()) {
@ -37,17 +39,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.');
}
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 = Yii::$app->user->createJwtAuthenticationToken($account, $session);
$jwt = Yii::$app->user->serializeToken($token);
$transaction->commit();
return Yii::$app->user->createJwtAuthenticationToken($account, true);
return new AuthenticationResult($account, $jwt, $session);
}
}

View File

@ -1,12 +1,17 @@
<?php
declare(strict_types=1);
namespace api\models\authentication;
use api\aop\annotations\CollectModelMetrics;
use api\components\User\AuthenticationResult;
use api\models\base\ApiForm;
use api\traits\AccountFinder;
use api\validators\TotpValidator;
use common\helpers\Error as E;
use common\models\Account;
use common\models\AccountSession;
use Webmozart\Assert\Assert;
use Yii;
class LoginForm extends ApiForm {
@ -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,37 @@ class LoginForm extends ApiForm {
/**
* @CollectModelMetrics(prefix="authentication.login")
* @return \api\components\User\AuthenticationResult|bool
* @return AuthenticationResult|bool
*/
public function login() {
if (!$this->validate()) {
return false;
}
$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 = Yii::$app->user->createJwtAuthenticationToken($account, $session);
$jwt = Yii::$app->user->serializeToken($token);
$transaction->commit();
return new AuthenticationResult($account, $jwt, $session);
}
}

View File

@ -1,4 +1,6 @@
<?php
declare(strict_types=1);
namespace api\models\authentication;
use api\aop\annotations\CollectModelMetrics;
@ -7,8 +9,8 @@ use api\validators\EmailActivationKeyValidator;
use common\helpers\Error as E;
use common\models\EmailActivation;
use common\validators\PasswordValidator;
use Webmozart\Assert\Assert;
use Yii;
use yii\base\ErrorException;
class RecoverPasswordForm extends ApiForm {
@ -18,7 +20,7 @@ class RecoverPasswordForm extends ApiForm {
public $newRePassword;
public function rules() {
public function rules(): array {
return [
['key', EmailActivationKeyValidator::class, 'type' => EmailActivation::TYPE_FORGOT_PASSWORD_KEY],
['newPassword', 'required', 'message' => E::NEW_PASSWORD_REQUIRED],
@ -28,18 +30,16 @@ 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
* @throws \Throwable
*/
public function recoverPassword() {
if (!$this->validate()) {
@ -52,17 +52,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.');
}
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 = Yii::$app->user->createJwtAuthenticationToken($account);
$jwt = Yii::$app->user->serializeToken($token);
$transaction->commit();
return Yii::$app->user->createJwtAuthenticationToken($account, false);
return new \api\components\User\AuthenticationResult($account, $jwt, null);
}
}

View File

@ -19,8 +19,9 @@ class FunctionalTester extends Actor {
throw new InvalidArgumentException("Cannot find account for username \"{$asUsername}\"");
}
$result = Yii::$app->user->createJwtAuthenticationToken($account, false);
$this->amBearerAuthenticated($result->getJwt());
$token = Yii::$app->user->createJwtAuthenticationToken($account);
$jwt = Yii::$app->user->serializeToken($token);
$this->amBearerAuthenticated($jwt);
return $account->id;
}

View File

@ -1,24 +1,22 @@
<?php
declare(strict_types=1);
namespace codeception\api\unit\components\User;
use api\components\User\AuthenticationResult;
use api\components\User\Component;
use api\components\User\Identity;
use api\tests\unit\TestCase;
use common\models\Account;
use common\models\AccountSession;
use common\tests\_support\ProtectedCaller;
use common\tests\fixtures\AccountFixture;
use common\tests\fixtures\AccountSessionFixture;
use common\tests\fixtures\MinecraftAccessKeyFixture;
use Emarref\Jwt\Claim;
use Emarref\Jwt\Jwt;
use Emarref\Jwt\Token;
use Yii;
use yii\web\Request;
class ComponentTest extends TestCase {
use ProtectedCaller;
/**
* @var Component|\PHPUnit\Framework\MockObject\MockObject
@ -41,42 +39,24 @@ class ComponentTest extends TestCase {
public function testCreateJwtAuthenticationToken() {
$this->mockRequest();
// Token without session
$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 */
$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);
/** @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 */
$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);
/** @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());
$this->assertSame(2, $payloads->findClaimByName('jti')->getValue());
}
public function testRenewJwtAuthenticationToken() {
@ -105,15 +85,19 @@ class ComponentTest extends TestCase {
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');
$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');
$result = $this->component->createJwtAuthenticationToken($account, true);
$this->component->logout();
/** @var AccountSession $session */
$session = $this->tester->grabFixture('sessions', 'admin');
$token = $this->component->createJwtAuthenticationToken($account, $session);
$jwt = $this->component->serializeToken($token);
/** @var Component|\PHPUnit\Framework\MockObject\MockObject $component */
$component = $this->getMockBuilder(Component::class)
@ -125,39 +109,42 @@ class ComponentTest extends TestCase {
->method('getIsGuest')
->willReturn(false);
$this->mockAuthorizationHeader($result->getJwt());
$this->mockAuthorizationHeader($jwt);
$session = $component->getActiveSession();
$this->assertInstanceOf(AccountSession::class, $session);
/** @noinspection NullPointerExceptionInspection */
$this->assertSame($session->id, $result->getSession()->id);
$foundSession = $component->getActiveSession();
$this->assertInstanceOf(AccountSession::class, $foundSession);
$this->assertSame($session->id, $foundSession->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]', [$this->getComponentConfig()])->makePartial();
$component->shouldReceive('getActiveSession')->times(1)->andReturn($session);
/** @var Account $account */
$account = $this->tester->grabFixture('accounts', 'admin');
$component->createJwtAuthenticationToken($account, true);
$component->createJwtAuthenticationToken($account);
// 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());
@ -165,7 +152,7 @@ class ComponentTest extends TestCase {
private function mockRequest($userIP = '127.0.0.1') {
/** @var Request|\Mockery\MockInterface $request */
$request = mock(Request::class . '[getHostInfo,getUserIP]')->shouldDeferMissing();
$request = mock(Request::class . '[getHostInfo,getUserIP]')->makePartial();
$request->shouldReceive('getHostInfo')->andReturn('http://localhost');
$request->shouldReceive('getUserIP')->andReturn($userIP);

View File

@ -1,19 +1,15 @@
<?php
declare(strict_types=1);
namespace codeception\api\unit\models;
use api\components\User\Jwt;
use api\components\User\JwtIdentity;
use api\tests\unit\TestCase;
use common\tests\_support\ProtectedCaller;
use common\tests\fixtures\AccountFixture;
use Emarref\Jwt\Claim;
use Emarref\Jwt\Encryption\Factory as EncryptionFactory;
use Emarref\Jwt\HeaderParameter\Custom;
use Emarref\Jwt\Token;
use Emarref\Jwt\Claim\Expiration as ExpirationClaim;
use Yii;
class JwtIdentityTest extends TestCase {
use ProtectedCaller;
public function _fixtures(): array {
return [
@ -33,13 +29,7 @@ class JwtIdentityTest extends TestCase {
* @expectedExceptionMessage Token expired
*/
public function testFindIdentityByAccessTokenWithExpiredToken() {
$token = new Token();
$token->addHeader(new Custom('v', 1));
$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())->setPrivateKey(Yii::$app->user->privateKey));
$expiredToken = $this->generateToken(time() - 3600);
JwtIdentity::findIdentityByAccessToken($expiredToken);
}
@ -51,14 +41,17 @@ class JwtIdentityTest extends TestCase {
JwtIdentity::findIdentityByAccessToken('');
}
protected function generateToken() {
private function generateToken(int $expiresAt = null): string {
/** @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);
$token = $component->createJwtAuthenticationToken($account);
if ($expiresAt !== null) {
$token->addClaim(new ExpirationClaim($expiresAt));
}
return $this->callProtected($component, 'serializeToken', $token);
return $component->serializeToken($token);
}
}