diff --git a/api/components/User/Component.php b/api/components/User/Component.php index 0965720..09be245 100644 --- a/api/components/User/Component.php +++ b/api/components/User/Component.php @@ -1,6 +1,7 @@ account; + $transaction = Yii::$app->db->beginTransaction(); + try { + $identity = new AccountIdentity($account->attributes); + $jwt = $this->getJWT($identity); + + $result = new RenewResult($identity, $jwt); + + $session->setIp(Yii::$app->request->userIP); + $session->last_refreshed_at = time(); + if (!$session->save()) { + throw new ErrorException('Cannot update session info'); + } + + $transaction->commit(); + } catch (ErrorException $e) { + $transaction->rollBack(); + throw $e; + } + + return $result; + } + public function getJWT(IdentityInterface $identity) { $jwt = new Jwt(); $token = new Token(); diff --git a/api/components/User/RenewResult.php b/api/components/User/RenewResult.php new file mode 100644 index 0000000..71c81ca --- /dev/null +++ b/api/components/User/RenewResult.php @@ -0,0 +1,42 @@ +identity = $identity; + $this->jwt = $jwt; + } + + public function getIdentity() : IdentityInterface { + return $this->identity; + } + + public function getJwt() : string { + return $this->jwt; + } + + public function getAsResponse() { + /** @var Component $component */ + $component = Yii::$app->user; + + return [ + 'access_token' => $this->getJwt(), + 'expires_in' => $component->expirationTimeout, + ]; + } + +} diff --git a/api/controllers/AuthenticationController.php b/api/controllers/AuthenticationController.php index 4110582..125066b 100644 --- a/api/controllers/AuthenticationController.php +++ b/api/controllers/AuthenticationController.php @@ -4,6 +4,7 @@ namespace api\controllers; use api\models\authentication\ForgotPasswordForm; use api\models\authentication\LoginForm; use api\models\authentication\RecoverPasswordForm; +use api\models\authentication\RefreshTokenForm; use common\helpers\StringHelper; use Yii; use yii\filters\AccessControl; @@ -14,13 +15,13 @@ class AuthenticationController extends Controller { public function behaviors() { return ArrayHelper::merge(parent::behaviors(), [ 'authenticator' => [ - 'except' => ['login', 'forgot-password', 'recover-password'], + 'except' => ['login', 'forgot-password', 'recover-password', 'refresh-token'], ], 'access' => [ 'class' => AccessControl::class, 'rules' => [ [ - 'actions' => ['login', 'forgot-password', 'recover-password'], + 'actions' => ['login', 'forgot-password', 'recover-password', 'refresh-token'], 'allow' => true, 'roles' => ['?'], ], @@ -34,6 +35,7 @@ class AuthenticationController extends Controller { 'login' => ['POST'], 'forgot-password' => ['POST'], 'recover-password' => ['POST'], + 'refresh-token' => ['POST'], ]; } @@ -109,4 +111,19 @@ class AuthenticationController extends Controller { ], $result->getAsResponse()); } + public function actionRefreshToken() { + $model = new RefreshTokenForm(); + $model->load(Yii::$app->request->post()); + if (($result = $model->renew()) === false) { + return [ + 'success' => false, + 'errors' => $this->normalizeModelErrors($model->getErrors()), + ]; + } + + return array_merge([ + 'success' => true, + ], $result->getAsResponse()); + } + } diff --git a/api/models/authentication/RefreshTokenForm.php b/api/models/authentication/RefreshTokenForm.php new file mode 100644 index 0000000..e637e98 --- /dev/null +++ b/api/models/authentication/RefreshTokenForm.php @@ -0,0 +1,58 @@ +hasErrors()) { + /** @var AccountSession|null $token */ + if ($this->getSession() === null) { + $this->addError('refresh_token', 'error.refresh_token_not_exist'); + } + } + } + + /** + * @return \api\components\User\RenewResult|bool + */ + public function renew() { + if (!$this->validate()) { + return false; + } + + /** @var \api\components\User\Component $component */ + $component = Yii::$app->user; + + return $component->renew($this->getSession()); + } + + /** + * @return AccountSession|null + */ + public function getSession() { + if ($this->session === null) { + $this->session = AccountSession::findOne(['refresh_token' => $this->refresh_token]); + } + + return $this->session; + } + +} diff --git a/common/models/AccountSession.php b/common/models/AccountSession.php index 05ad1d5..b165802 100644 --- a/common/models/AccountSession.php +++ b/common/models/AccountSession.php @@ -12,7 +12,7 @@ use yii\db\ActiveRecord; * @property string $refresh_token * @property integer $last_used_ip * @property integer $created_at - * @property integer $last_refreshed + * @property integer $last_refreshed_at * * Отношения: * @property Account $account diff --git a/tests/codeception/api/_pages/AuthenticationRoute.php b/tests/codeception/api/_pages/AuthenticationRoute.php index 31ee65c..1b0d448 100644 --- a/tests/codeception/api/_pages/AuthenticationRoute.php +++ b/tests/codeception/api/_pages/AuthenticationRoute.php @@ -38,4 +38,11 @@ class AuthenticationRoute extends BasePage { ]); } + public function refreshToken($refreshToken = null) { + $this->route = ['authentication/refresh-token']; + $this->actor->sendPOST($this->getUrl(), [ + 'refresh_token' => $refreshToken, + ]); + } + } diff --git a/tests/codeception/api/functional/RefreshTokenCest.php b/tests/codeception/api/functional/RefreshTokenCest.php new file mode 100644 index 0000000..b0df007 --- /dev/null +++ b/tests/codeception/api/functional/RefreshTokenCest.php @@ -0,0 +1,32 @@ +wantTo('get error.refresh_token_not_exist if passed token is invalid'); + $route->refreshToken('invalid-token'); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'refresh_token' => 'error.refresh_token_not_exist', + ], + ]); + } + + public function testRefreshToken(FunctionalTester $I) { + $route = new AuthenticationRoute($I); + + $I->wantTo('get new access_token by my refresh_token'); + $route->refreshToken('SOutIr6Seeaii3uqMVy3Wan8sKFVFrNz'); + $I->canSeeResponseCodeIs(200); + $I->canSeeAuthCredentials(false); + } + +} diff --git a/tests/codeception/api/unit/components/User/ComponentTest.php b/tests/codeception/api/unit/components/User/ComponentTest.php index cdb8c93..8eb132b 100644 --- a/tests/codeception/api/unit/components/User/ComponentTest.php +++ b/tests/codeception/api/unit/components/User/ComponentTest.php @@ -3,6 +3,7 @@ namespace codeception\api\unit\components\User; use api\components\User\Component; use api\components\User\LoginResult; +use api\components\User\RenewResult; use api\models\AccountIdentity; use Codeception\Specify; use common\models\AccountSession; @@ -75,6 +76,24 @@ class ComponentTest extends DbTestCase { }); } + public function testRenew() { + $this->specify('success get RenewResult object', function() { + /** @var AccountSession $session */ + $session = AccountSession::findOne($this->sessions['admin']['id']); + $callTime = time(); + $usedRemoteAddr = $_SERVER['REMOTE_ADDR'] ?? null; + $_SERVER['REMOTE_ADDR'] = '192.168.0.1'; + $result = $this->component->renew($session); + expect($result)->isInstanceOf(RenewResult::class); + expect(is_string($result->getJwt()))->true(); + expect($result->getIdentity()->getId())->equals($session->account_id); + $session->refresh(); + expect($session->last_refreshed_at)->greaterOrEquals($callTime); + expect($session->getReadableIp())->equals($_SERVER['REMOTE_ADDR']); + $_SERVER['REMOTE_ADDR'] = $usedRemoteAddr; + }); + } + public function testGetJWT() { $this->specify('get string, contained jwt token', function() { expect($this->component->getJWT(new AccountIdentity(['id' => 1]))) diff --git a/tests/codeception/api/unit/models/authentication/RefreshTokenFormTest.php b/tests/codeception/api/unit/models/authentication/RefreshTokenFormTest.php new file mode 100644 index 0000000..f12db8a --- /dev/null +++ b/tests/codeception/api/unit/models/authentication/RefreshTokenFormTest.php @@ -0,0 +1,55 @@ + AccountSessionFixture::class, + ]; + } + + public function testValidateRefreshToken() { + $this->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(); + expect($model->getErrors('refresh_token'))->equals(['error.refresh_token_not_exist']); + }); + + $this->specify('no errors if token exists', function() { + /** @var RefreshTokenForm $model */ + $model = new class extends RefreshTokenForm { + public function getSession() { + return new AccountSession(); + } + }; + $model->validateRefreshToken(); + expect($model->getErrors('refresh_token'))->isEmpty(); + }); + } + + public function testRenew() { + $this->specify('success renew token', function() { + $model = new RefreshTokenForm(); + $model->refresh_token = $this->sessions['admin']['refresh_token']; + expect($model->renew())->isInstanceOf(RenewResult::class); + }); + } + +}