Add support for the legacy refresh tokens, make the new refresh tokens non-expire [skip ci]

This commit is contained in:
ErickSkrauch 2019-09-22 02:42:08 +03:00
parent 5536c34b9c
commit c722c46ad5
7 changed files with 94 additions and 66 deletions

View File

@ -45,11 +45,11 @@ class Component extends BaseComponent {
$authServer->enableGrantType($authCodeGrant, $accessTokenTTL); $authServer->enableGrantType($authCodeGrant, $accessTokenTTL);
$authCodeGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling $authCodeGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling
// TODO: extends refresh token life time to forever
$refreshTokenGrant = new RefreshTokenGrant($refreshTokensRepo); $refreshTokenGrant = new RefreshTokenGrant($refreshTokensRepo);
$authServer->enableGrantType($refreshTokenGrant, $accessTokenTTL); $authServer->enableGrantType($refreshTokenGrant);
$refreshTokenGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling $refreshTokenGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling
// TODO: make these access tokens live longer
$clientCredentialsGrant = new Grant\ClientCredentialsGrant(); $clientCredentialsGrant = new Grant\ClientCredentialsGrant();
$authServer->enableGrantType($clientCredentialsGrant, $accessTokenTTL); $authServer->enableGrantType($clientCredentialsGrant, $accessTokenTTL);
$clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling $clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling

View File

@ -3,6 +3,8 @@ declare(strict_types=1);
namespace api\components\OAuth2\Entities; namespace api\components\OAuth2\Entities;
use Carbon\CarbonImmutable;
use DateTimeImmutable;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Entities\Traits\EntityTrait; use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait; use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait;
@ -11,4 +13,17 @@ class RefreshTokenEntity implements RefreshTokenEntityInterface {
use EntityTrait; use EntityTrait;
use RefreshTokenTrait; 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');
}
} }

View File

@ -3,12 +3,36 @@ declare(strict_types=1);
namespace api\components\OAuth2\Grants; namespace api\components\OAuth2\Grants;
use common\models\OauthSession;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\Grant\RefreshTokenGrant as BaseRefreshTokenGrant; use League\OAuth2\Server\Grant\RefreshTokenGrant as BaseRefreshTokenGrant;
use Psr\Http\Message\ServerRequestInterface;
use Yii;
class RefreshTokenGrant extends BaseRefreshTokenGrant { 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. * Currently we're not rotating refresh tokens.
* So we overriding this method to always return null, which means, * So we overriding this method to always return null, which means,
@ -22,4 +46,35 @@ class RefreshTokenGrant extends BaseRefreshTokenGrant {
return null; 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,
];
}
} }

View File

@ -1,63 +0,0 @@
<?php
namespace api\components\OAuth2\Repositories;
use api\components\OAuth2\Entities\RefreshTokenEntity;
use common\components\Redis\Key;
use common\components\Redis\Set;
use common\models\OauthSession;
use ErrorException;
use League\OAuth2\Server\Entity\RefreshTokenEntity as OriginalRefreshTokenEntity;
use League\OAuth2\Server\Storage\AbstractStorage;
use League\OAuth2\Server\Storage\RefreshTokenInterface;
use Yii;
use yii\helpers\Json;
class RefreshTokenStorage extends AbstractStorage implements RefreshTokenInterface {
public $dataTable = 'oauth_refresh_tokens';
public function get($token) {
$result = Json::decode((new Key($this->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);
}
}

View File

@ -57,7 +57,7 @@ class AuthorizationController extends Controller {
} }
private function createOauthProcess(): OauthProcess { private function createOauthProcess(): OauthProcess {
return new OauthProcess(Yii::$app->oauth->authServer); return new OauthProcess(Yii::$app->oauth->getAuthServer());
} }
} }

View File

@ -56,6 +56,20 @@ class OauthSession extends ActiveRecord {
return (array)$this->scopes; 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 { public function beforeDelete(): bool {
if (!parent::beforeDelete()) { if (!parent::beforeDelete()) {
return false; return false;
@ -63,6 +77,7 @@ class OauthSession extends ActiveRecord {
if ($this->legacy_id !== null) { if ($this->legacy_id !== null) {
Yii::$app->redis->del($this->getLegacyRedisScopesKey()); Yii::$app->redis->del($this->getLegacyRedisScopesKey());
Yii::$app->redis->del($this->getLegacyRedisRefreshTokensKey());
} }
return true; return true;
@ -72,4 +87,8 @@ class OauthSession extends ActiveRecord {
return "oauth:sessions:{$this->legacy_id}:scopes"; return "oauth:sessions:{$this->legacy_id}:scopes";
} }
private function getLegacyRedisRefreshTokensKey(): string {
return "oauth:sessions:{$this->legacy_id}:refresh:tokens";
}
} }

View File

@ -27,6 +27,7 @@ class m190914_181236_rework_oauth_related_tables extends Migration {
// Change type again to make column nullable // Change type again to make column nullable
$this->alterColumn('oauth_sessions', 'id', $this->integer(11)->unsigned()->after('client_id')); $this->alterColumn('oauth_sessions', 'id', $this->integer(11)->unsigned()->after('client_id'));
$this->renameColumn('oauth_sessions', 'id', 'legacy_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->addPrimaryKey('id', 'oauth_sessions', ['account_id', 'client_id']);
$this->dropForeignKey('FK_oauth_session_to_client', 'oauth_sessions'); $this->dropForeignKey('FK_oauth_session_to_client', 'oauth_sessions');
$this->dropIndex('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->dropIndex('FK_oauth_session_to_oauth_client', 'oauth_sessions');
$this->dropPrimaryKey('PRIMARY', 'oauth_sessions'); $this->dropPrimaryKey('PRIMARY', 'oauth_sessions');
$this->delete('oauth_sessions', ['legacy_id' => null]); $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->alterColumn('oauth_sessions', 'legacy_id', $this->integer(11)->unsigned()->notNull()->append('AUTO_INCREMENT PRIMARY KEY FIRST'));
$this->renameColumn('oauth_sessions', 'legacy_id', 'id'); $this->renameColumn('oauth_sessions', 'legacy_id', 'id');
$this->alterColumn('oauth_sessions', 'client_id', $this->db->getTableSchema('oauth_clients')->getColumn('id')->dbType); $this->alterColumn('oauth_sessions', 'client_id', $this->db->getTableSchema('oauth_clients')->getColumn('id')->dbType);