From c722c46ad5d3db45341642a5a268a5b9144c43c2 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sun, 22 Sep 2019 02:42:08 +0300 Subject: [PATCH] Add support for the legacy refresh tokens, make the new refresh tokens non-expire [skip ci] --- api/components/OAuth2/Component.php | 4 +- .../OAuth2/Entities/RefreshTokenEntity.php | 15 +++++ .../OAuth2/Grants/RefreshTokenGrant.php | 55 ++++++++++++++++ .../Repositories/RefreshTokenStorage.php | 63 ------------------- .../controllers/AuthorizationController.php | 2 +- common/models/OauthSession.php | 19 ++++++ ...914_181236_rework_oauth_related_tables.php | 2 + 7 files changed, 94 insertions(+), 66 deletions(-) delete mode 100644 api/components/OAuth2/Repositories/RefreshTokenStorage.php diff --git a/api/components/OAuth2/Component.php b/api/components/OAuth2/Component.php index 83f1a3e..93656bb 100644 --- a/api/components/OAuth2/Component.php +++ b/api/components/OAuth2/Component.php @@ -45,11 +45,11 @@ class Component extends BaseComponent { $authServer->enableGrantType($authCodeGrant, $accessTokenTTL); $authCodeGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling - // TODO: extends refresh token life time to forever $refreshTokenGrant = new RefreshTokenGrant($refreshTokensRepo); - $authServer->enableGrantType($refreshTokenGrant, $accessTokenTTL); + $authServer->enableGrantType($refreshTokenGrant); $refreshTokenGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling + // TODO: make these access tokens live longer $clientCredentialsGrant = new Grant\ClientCredentialsGrant(); $authServer->enableGrantType($clientCredentialsGrant, $accessTokenTTL); $clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling diff --git a/api/components/OAuth2/Entities/RefreshTokenEntity.php b/api/components/OAuth2/Entities/RefreshTokenEntity.php index aea8f5a..0b8383d 100644 --- a/api/components/OAuth2/Entities/RefreshTokenEntity.php +++ b/api/components/OAuth2/Entities/RefreshTokenEntity.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace api\components\OAuth2\Entities; +use Carbon\CarbonImmutable; +use DateTimeImmutable; use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; use League\OAuth2\Server\Entities\Traits\EntityTrait; use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait; @@ -11,4 +13,17 @@ class RefreshTokenEntity implements RefreshTokenEntityInterface { use EntityTrait; use RefreshTokenTrait; + /** + * We don't rotate refresh tokens, so that to always pass validation in the internal validator + * of the oauth2 server implementation we set the lifetime as far as possible. + * + * In 2038 this may cause problems, but I am sure that by then this code, if it still works, + * will be rewritten several times and the problem will be solved in a completely different way. + * + * @return DateTimeImmutable + */ + public function getExpiryDateTime(): DateTimeImmutable { + return CarbonImmutable::create(2038, 11, 11, 22, 13, 0, 'Europe/Minsk'); + } + } diff --git a/api/components/OAuth2/Grants/RefreshTokenGrant.php b/api/components/OAuth2/Grants/RefreshTokenGrant.php index 13ceb58..b1ce1bf 100644 --- a/api/components/OAuth2/Grants/RefreshTokenGrant.php +++ b/api/components/OAuth2/Grants/RefreshTokenGrant.php @@ -3,12 +3,36 @@ declare(strict_types=1); namespace api\components\OAuth2\Grants; +use common\models\OauthSession; use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; +use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Grant\RefreshTokenGrant as BaseRefreshTokenGrant; +use Psr\Http\Message\ServerRequestInterface; +use Yii; class RefreshTokenGrant extends BaseRefreshTokenGrant { + /** + * Previously, refresh tokens was stored in Redis. + * If received refresh token is matches the legacy token template, + * restore the information from the legacy storage. + * + * @param ServerRequestInterface $request + * @param string $clientId + * + * @return array + * @throws OAuthServerException + */ + protected function validateOldRefreshToken(ServerRequestInterface $request, $clientId): array { + $refreshToken = $this->getRequestParameter('refresh_token', $request); + if ($refreshToken !== null && mb_strlen($refreshToken) === 40) { + return $this->validateLegacyRefreshToken($refreshToken); + } + + return parent::validateOldRefreshToken($request, $clientId); + } + /** * Currently we're not rotating refresh tokens. * So we overriding this method to always return null, which means, @@ -22,4 +46,35 @@ class RefreshTokenGrant extends BaseRefreshTokenGrant { return null; } + private function validateLegacyRefreshToken(string $refreshToken): array { + $result = Yii::$app->redis->get("oauth:refresh:tokens:{$refreshToken}"); + if ($result === null) { + throw OAuthServerException::invalidRefreshToken('Token has been revoked'); + } + + try { + [ + 'access_token_id' => $accessTokenId, + 'session_id' => $sessionId, + ] = json_decode($result, true, 512, JSON_THROW_ON_ERROR); + } catch (\Exception $e) { + throw OAuthServerException::invalidRefreshToken('Cannot decrypt the refresh token', $e); + } + + /** @var OauthSession|null $relatedSession */ + $relatedSession = OauthSession::findOne(['legacy_id' => $sessionId]); + if ($relatedSession === null) { + throw OAuthServerException::invalidRefreshToken('Token has been revoked'); + } + + return [ + 'client_id' => $relatedSession->client_id, + 'refresh_token_id' => $refreshToken, + 'access_token_id' => $accessTokenId, + 'scopes' => $relatedSession->getScopes(), + 'user_id' => $relatedSession->account_id, + 'expire_time' => null, + ]; + } + } diff --git a/api/components/OAuth2/Repositories/RefreshTokenStorage.php b/api/components/OAuth2/Repositories/RefreshTokenStorage.php deleted file mode 100644 index 4057fc3..0000000 --- a/api/components/OAuth2/Repositories/RefreshTokenStorage.php +++ /dev/null @@ -1,63 +0,0 @@ -dataTable, $token))->getValue()); - if ($result === null) { - return null; - } - - $entity = new RefreshTokenEntity($this->server); - $entity->setId($result['id']); - $entity->setAccessTokenId($result['access_token_id']); - $entity->setSessionId($result['session_id']); - - return $entity; - } - - public function create($token, $expireTime, $accessToken) { - $sessionId = $this->server->getAccessTokenStorage()->get($accessToken)->getSession()->getId(); - $payload = Json::encode([ - 'id' => $token, - 'access_token_id' => $accessToken, - 'session_id' => $sessionId, - ]); - - $this->key($token)->setValue($payload); - $this->sessionHash($sessionId)->add($token); - } - - public function delete(OriginalRefreshTokenEntity $token) { - if (!$token instanceof RefreshTokenEntity) { - throw new ErrorException('Token must be instance of ' . RefreshTokenEntity::class); - } - - $this->key($token->getId())->delete(); - $this->sessionHash($token->getSessionId())->remove($token->getId()); - } - - public function sessionHash(string $sessionId): Set { - $tableName = Yii::$app->db->getSchema()->getRawTableName(OauthSession::tableName()); - return new Set($tableName, $sessionId, 'refresh_tokens'); - } - - private function key(string $token): Key { - return new Key($this->dataTable, $token); - } - -} diff --git a/api/modules/oauth/controllers/AuthorizationController.php b/api/modules/oauth/controllers/AuthorizationController.php index b98e89b..85c1616 100644 --- a/api/modules/oauth/controllers/AuthorizationController.php +++ b/api/modules/oauth/controllers/AuthorizationController.php @@ -57,7 +57,7 @@ class AuthorizationController extends Controller { } private function createOauthProcess(): OauthProcess { - return new OauthProcess(Yii::$app->oauth->authServer); + return new OauthProcess(Yii::$app->oauth->getAuthServer()); } } diff --git a/common/models/OauthSession.php b/common/models/OauthSession.php index a52f4f9..fb0e4d6 100644 --- a/common/models/OauthSession.php +++ b/common/models/OauthSession.php @@ -56,6 +56,20 @@ class OauthSession extends ActiveRecord { return (array)$this->scopes; } + /** + * In the early period of the project existence, the refresh tokens related to the current session + * were stored in Redis. This method allows to get a list of these tokens. + * + * @return array of refresh tokens (ids) + */ + public function getLegacyRefreshTokens(): array { + if ($this->legacy_id === null) { + return []; + } + + return Yii::$app->redis->smembers($this->getLegacyRedisRefreshTokensKey()); + } + public function beforeDelete(): bool { if (!parent::beforeDelete()) { return false; @@ -63,6 +77,7 @@ class OauthSession extends ActiveRecord { if ($this->legacy_id !== null) { Yii::$app->redis->del($this->getLegacyRedisScopesKey()); + Yii::$app->redis->del($this->getLegacyRedisRefreshTokensKey()); } return true; @@ -72,4 +87,8 @@ class OauthSession extends ActiveRecord { return "oauth:sessions:{$this->legacy_id}:scopes"; } + private function getLegacyRedisRefreshTokensKey(): string { + return "oauth:sessions:{$this->legacy_id}:refresh:tokens"; + } + } diff --git a/console/migrations/m190914_181236_rework_oauth_related_tables.php b/console/migrations/m190914_181236_rework_oauth_related_tables.php index 51d7adc..d9256d7 100644 --- a/console/migrations/m190914_181236_rework_oauth_related_tables.php +++ b/console/migrations/m190914_181236_rework_oauth_related_tables.php @@ -27,6 +27,7 @@ class m190914_181236_rework_oauth_related_tables extends Migration { // Change type again to make column nullable $this->alterColumn('oauth_sessions', 'id', $this->integer(11)->unsigned()->after('client_id')); $this->renameColumn('oauth_sessions', 'id', 'legacy_id'); + $this->createIndex('legacy_id', 'oauth_sessions', 'legacy_id', true); $this->addPrimaryKey('id', 'oauth_sessions', ['account_id', 'client_id']); $this->dropForeignKey('FK_oauth_session_to_client', 'oauth_sessions'); $this->dropIndex('FK_oauth_session_to_client', 'oauth_sessions'); @@ -53,6 +54,7 @@ class m190914_181236_rework_oauth_related_tables extends Migration { $this->dropIndex('FK_oauth_session_to_oauth_client', 'oauth_sessions'); $this->dropPrimaryKey('PRIMARY', 'oauth_sessions'); $this->delete('oauth_sessions', ['legacy_id' => null]); + $this->dropIndex('legacy_id', 'oauth_sessions'); $this->alterColumn('oauth_sessions', 'legacy_id', $this->integer(11)->unsigned()->notNull()->append('AUTO_INCREMENT PRIMARY KEY FIRST')); $this->renameColumn('oauth_sessions', 'legacy_id', 'id'); $this->alterColumn('oauth_sessions', 'client_id', $this->db->getTableSchema('oauth_clients')->getColumn('id')->dbType);