Rework identity provider for the legacy OAuth2 tokens [skip ci]

This commit is contained in:
ErickSkrauch 2019-09-22 18:42:21 +03:00
parent c722c46ad5
commit cf62c686b1
10 changed files with 141 additions and 284 deletions

View File

@ -11,9 +11,6 @@ use League\OAuth2\Server\AuthorizationServer;
use League\OAuth2\Server\Grant; use League\OAuth2\Server\Grant;
use yii\base\Component as BaseComponent; use yii\base\Component as BaseComponent;
/**
* @property AuthorizationServer $authServer
*/
class Component extends BaseComponent { class Component extends BaseComponent {
/** /**
@ -39,7 +36,6 @@ class Component extends BaseComponent {
new EmptyKey(), new EmptyKey(),
'123' // TODO: extract to the variable '123' // TODO: extract to the variable
); );
/** @noinspection PhpUnhandledExceptionInspection */
$authCodeGrant = new AuthCodeGrant($authCodesRepo, $refreshTokensRepo, new DateInterval('PT10M')); $authCodeGrant = new AuthCodeGrant($authCodesRepo, $refreshTokensRepo, new DateInterval('PT10M'));
$authCodeGrant->disableRequireCodeChallengeForPublicClients(); $authCodeGrant->disableRequireCodeChallengeForPublicClients();
$authServer->enableGrantType($authCodeGrant, $accessTokenTTL); $authServer->enableGrantType($authCodeGrant, $accessTokenTTL);

View File

@ -1,70 +0,0 @@
<?php
namespace api\components\OAuth2\Repositories;
use api\components\OAuth2\Entities\AccessTokenEntity;
use common\components\Redis\Key;
use common\components\Redis\Set;
use League\OAuth2\Server\Entity\AccessTokenEntity as OriginalAccessTokenEntity;
use League\OAuth2\Server\Entity\ScopeEntity;
use League\OAuth2\Server\Storage\AbstractStorage;
use League\OAuth2\Server\Storage\AccessTokenInterface;
use yii\helpers\Json;
class AccessTokenStorage extends AbstractStorage implements AccessTokenInterface {
public $dataTable = 'oauth_access_tokens';
public function get($token) {
$result = Json::decode((new Key($this->dataTable, $token))->getValue());
if ($result === null) {
return null;
}
$token = new AccessTokenEntity($this->server);
$token->setId($result['id']);
$token->setExpireTime($result['expire_time']);
$token->setSessionId($result['session_id']);
return $token;
}
public function getScopes(OriginalAccessTokenEntity $token) {
$scopes = $this->scopes($token->getId());
$entities = [];
foreach ($scopes as $scope) {
if ($this->server->getScopeStorage()->get($scope) !== null) {
$entities[] = (new ScopeEntity($this->server))->hydrate(['id' => $scope]);
}
}
return $entities;
}
public function create($token, $expireTime, $sessionId) {
$payload = Json::encode([
'id' => $token,
'expire_time' => $expireTime,
'session_id' => $sessionId,
]);
$this->key($token)->setValue($payload)->expireAt($expireTime);
}
public function associateScope(OriginalAccessTokenEntity $token, ScopeEntity $scope) {
$this->scopes($token->getId())->add($scope->getId())->expireAt($token->getExpireTime());
}
public function delete(OriginalAccessTokenEntity $token) {
$this->key($token->getId())->delete();
$this->scopes($token->getId())->delete();
}
private function key(string $token): Key {
return new Key($this->dataTable, $token);
}
private function scopes(string $token): Set {
return new Set($this->dataTable, $token, 'scopes');
}
}

View File

@ -8,19 +8,24 @@ use yii\web\UnauthorizedHttpException;
class IdentityFactory { class IdentityFactory {
/** /**
* @throws UnauthorizedHttpException * @param string $token
* @param string $type
*
* @return IdentityInterface * @return IdentityInterface
* @throws UnauthorizedHttpException
*/ */
public static function findIdentityByAccessToken($token, $type = null): IdentityInterface { public static function findIdentityByAccessToken($token, $type = null): IdentityInterface {
if (empty($token)) { if (!empty($token)) {
throw new UnauthorizedHttpException('Incorrect token'); if (mb_strlen($token) === 40) {
return LegacyOAuth2Identity::findIdentityByAccessToken($token, $type);
} }
if (substr_count($token, '.') === 2) { if (substr_count($token, '.') === 2) {
return JwtIdentity::findIdentityByAccessToken($token, $type); return JwtIdentity::findIdentityByAccessToken($token, $type);
} }
}
return OAuth2Identity::findIdentityByAccessToken($token, $type); throw new UnauthorizedHttpException('Incorrect token');
} }
} }

View File

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace api\components\User;
use common\models\Account;
use common\models\OauthSession;
use Exception;
use Yii;
use yii\base\NotSupportedException;
use yii\web\UnauthorizedHttpException;
class LegacyOAuth2Identity implements IdentityInterface {
/**
* @var string
*/
private $accessToken;
/**
* @var string
*/
private $sessionId;
/**
* @var string[]
*/
private $scopes;
/**
* @var OauthSession|null
*/
private $session = false;
private function __construct(string $accessToken, string $sessionId, array $scopes) {
$this->accessToken = $accessToken;
$this->sessionId = $sessionId;
$this->scopes = $scopes;
}
/**
* @inheritdoc
* @throws UnauthorizedHttpException
* @return IdentityInterface
*/
public static function findIdentityByAccessToken($token, $type = null): IdentityInterface {
$tokenParams = self::findRecordOnLegacyStorage($token);
if ($tokenParams === null) {
throw new UnauthorizedHttpException('Incorrect token');
}
if ($tokenParams['expire_time'] < time()) {
throw new UnauthorizedHttpException('Token expired');
}
return new static($token, $tokenParams['session_id'], $tokenParams['scopes']);
}
public function getAccount(): ?Account {
$session = $this->getSession();
if ($session === null) {
return null;
}
return $session->account;
}
/**
* @return string[]
*/
public function getAssignedPermissions(): array {
return $this->scopes;
}
public function getId(): string {
return $this->accessToken;
}
// @codeCoverageIgnoreStart
public function getAuthKey() {
throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth');
}
public function validateAuthKey($authKey) {
throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth');
}
public static function findIdentity($id) {
throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth');
}
// @codeCoverageIgnoreEnd
private static function findRecordOnLegacyStorage(string $accessToken): ?array {
$record = Yii::$app->redis->get("oauth:access:tokens:{$accessToken}");
if ($record === null) {
return null;
}
try {
$data = json_decode($record, true, 512, JSON_THROW_ON_ERROR);
} catch (Exception $e) {
return null;
}
$data['scopes'] = (array)Yii::$app->redis->smembers("oauth:access:tokens:{$accessToken}:scopes");
return $data;
}
private function getSession(): ?OauthSession {
if ($this->session === false) {
$this->session = OauthSession::findOne(['id' => $this->sessionId]);
}
return $this->session;
}
}

View File

@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
namespace api\components\User;
use api\components\OAuth2\Entities\AccessTokenEntity;
use common\models\Account;
use common\models\OauthSession;
use Yii;
use yii\base\NotSupportedException;
use yii\web\UnauthorizedHttpException;
class OAuth2Identity implements IdentityInterface {
/**
* @var AccessTokenEntity
*/
private $_accessToken;
private function __construct(AccessTokenEntity $accessToken) {
$this->_accessToken = $accessToken;
}
/**
* @inheritdoc
* @throws UnauthorizedHttpException
* @return IdentityInterface
*/
public static function findIdentityByAccessToken($token, $type = null): IdentityInterface {
/** @var AccessTokenEntity|null $model */
// TODO: rework
$model = Yii::$app->oauth->getAccessTokenStorage()->get($token);
if ($model === null) {
throw new UnauthorizedHttpException('Incorrect token');
}
if ($model->isExpired()) {
throw new UnauthorizedHttpException('Token expired');
}
return new static($model);
}
public function getAccount(): ?Account {
return $this->getSession()->account;
}
/**
* @return string[]
*/
public function getAssignedPermissions(): array {
return array_keys($this->_accessToken->getScopes());
}
public function getId(): string {
return $this->_accessToken->getId();
}
// @codeCoverageIgnoreStart
public function getAuthKey() {
throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth');
}
public function validateAuthKey($authKey) {
throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth');
}
public static function findIdentity($id) {
throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth');
}
// @codeCoverageIgnoreEnd
private function getSession(): OauthSession {
return OauthSession::findOne(['id' => $this->_accessToken->getSessionId()]);
}
}

View File

@ -5,7 +5,7 @@ namespace codeception\api\unit\components\User;
use api\components\User\Component; use api\components\User\Component;
use api\components\User\JwtIdentity; use api\components\User\JwtIdentity;
use api\components\User\OAuth2Identity; use api\components\User\LegacyOAuth2Identity;
use api\tests\unit\TestCase; use api\tests\unit\TestCase;
use common\models\Account; use common\models\Account;
use common\models\AccountSession; use common\models\AccountSession;
@ -41,7 +41,7 @@ class ComponentTest extends TestCase {
$this->assertNull($component->getActiveSession()); $this->assertNull($component->getActiveSession());
// Identity is a Oauth2Identity // Identity is a Oauth2Identity
$component->setIdentity(mock(OAuth2Identity::class)); $component->setIdentity(mock(LegacyOAuth2Identity::class));
$this->assertNull($component->getActiveSession()); $this->assertNull($component->getActiveSession());
// Identity is correct, but have no jti claim // Identity is correct, but have no jti claim

View File

@ -7,7 +7,7 @@ use api\components\OAuth2\Component;
use api\components\OAuth2\Entities\AccessTokenEntity; use api\components\OAuth2\Entities\AccessTokenEntity;
use api\components\User\IdentityFactory; use api\components\User\IdentityFactory;
use api\components\User\JwtIdentity; use api\components\User\JwtIdentity;
use api\components\User\OAuth2Identity; use api\components\User\LegacyOAuth2Identity;
use api\tests\unit\TestCase; use api\tests\unit\TestCase;
use Carbon\Carbon; use Carbon\Carbon;
use League\OAuth2\Server\AbstractServer; use League\OAuth2\Server\AbstractServer;
@ -37,7 +37,7 @@ class IdentityFactoryTest extends TestCase {
Yii::$app->set('oauth', $component); Yii::$app->set('oauth', $component);
$identity = IdentityFactory::findIdentityByAccessToken('mock-token'); $identity = IdentityFactory::findIdentityByAccessToken('mock-token');
$this->assertInstanceOf(OAuth2Identity::class, $identity); $this->assertInstanceOf(LegacyOAuth2Identity::class, $identity);
} }
public function testFindIdentityByAccessTokenWithEmptyValue() { public function testFindIdentityByAccessTokenWithEmptyValue() {

View File

@ -5,14 +5,12 @@ namespace api\tests\unit\components\User;
use api\components\OAuth2\Component; use api\components\OAuth2\Component;
use api\components\OAuth2\Entities\AccessTokenEntity; use api\components\OAuth2\Entities\AccessTokenEntity;
use api\components\User\OAuth2Identity; use api\components\User\LegacyOAuth2Identity;
use api\tests\unit\TestCase; use api\tests\unit\TestCase;
use League\OAuth2\Server\AbstractServer;
use League\OAuth2\Server\Storage\AccessTokenInterface;
use Yii; use Yii;
use yii\web\UnauthorizedHttpException; use yii\web\UnauthorizedHttpException;
class OAuth2IdentityTest extends TestCase { class LegacyOAuth2IdentityTest extends TestCase {
public function testFindIdentityByAccessToken() { public function testFindIdentityByAccessToken() {
$accessToken = new AccessTokenEntity(mock(AbstractServer::class)); $accessToken = new AccessTokenEntity(mock(AbstractServer::class));
@ -20,7 +18,7 @@ class OAuth2IdentityTest extends TestCase {
$accessToken->setId('mock-token'); $accessToken->setId('mock-token');
$this->mockFoundedAccessToken($accessToken); $this->mockFoundedAccessToken($accessToken);
$identity = OAuth2Identity::findIdentityByAccessToken('mock-token'); $identity = LegacyOAuth2Identity::findIdentityByAccessToken('mock-token');
$this->assertSame('mock-token', $identity->getId()); $this->assertSame('mock-token', $identity->getId());
} }
@ -28,7 +26,7 @@ class OAuth2IdentityTest extends TestCase {
$this->expectException(UnauthorizedHttpException::class); $this->expectException(UnauthorizedHttpException::class);
$this->expectExceptionMessage('Incorrect token'); $this->expectExceptionMessage('Incorrect token');
OAuth2Identity::findIdentityByAccessToken('not exists token'); LegacyOAuth2Identity::findIdentityByAccessToken('not exists token');
} }
public function testFindIdentityByAccessTokenWithExpiredToken() { public function testFindIdentityByAccessTokenWithExpiredToken() {
@ -39,7 +37,7 @@ class OAuth2IdentityTest extends TestCase {
$accessToken->setExpireTime(time() - 3600); $accessToken->setExpireTime(time() - 3600);
$this->mockFoundedAccessToken($accessToken); $this->mockFoundedAccessToken($accessToken);
OAuth2Identity::findIdentityByAccessToken('mock-token'); LegacyOAuth2Identity::findIdentityByAccessToken('mock-token');
} }
private function mockFoundedAccessToken(AccessTokenEntity $accessToken) { private function mockFoundedAccessToken(AccessTokenEntity $accessToken) {

View File

@ -1,63 +0,0 @@
<?php
namespace common\components\Redis;
use InvalidArgumentException;
use Yii;
/**
* @deprecated
*/
class Key {
private $key;
public function __construct(...$key) {
if (empty($key)) {
throw new InvalidArgumentException('You must specify at least one key.');
}
$this->key = $this->buildKey($key);
}
public function getKey(): string {
return $this->key;
}
public function getValue() {
return Yii::$app->redis->get($this->key);
}
public function setValue($value): self {
Yii::$app->redis->set($this->key, $value);
return $this;
}
public function delete(): self {
Yii::$app->redis->del($this->getKey());
return $this;
}
public function exists(): bool {
return (bool)Yii::$app->redis->exists($this->key);
}
public function expire(int $ttl): self {
Yii::$app->redis->expire($this->key, $ttl);
return $this;
}
public function expireAt(int $unixTimestamp): self {
Yii::$app->redis->expireat($this->key, $unixTimestamp);
return $this;
}
private function buildKey(array $parts): string {
$keyParts = [];
foreach ($parts as $part) {
$keyParts[] = str_replace('_', ':', $part);
}
return implode(':', $keyParts);
}
}

View File

@ -1,50 +0,0 @@
<?php
namespace common\components\Redis;
use ArrayIterator;
use IteratorAggregate;
use Yii;
/**
* @deprecated
*/
class Set extends Key implements IteratorAggregate {
public function add($value): self {
Yii::$app->redis->sadd($this->getKey(), $value);
return $this;
}
public function remove($value): self {
Yii::$app->redis->srem($this->getKey(), $value);
return $this;
}
public function members(): array {
return Yii::$app->redis->smembers($this->getKey());
}
public function getValue(): array {
return $this->members();
}
public function exists(string $value = null): bool {
if ($value === null) {
return parent::exists();
}
return (bool)Yii::$app->redis->sismember($this->getKey(), $value);
}
public function diff(array $sets): array {
return Yii::$app->redis->sdiff([$this->getKey(), implode(' ', $sets)]);
}
/**
* @inheritdoc
*/
public function getIterator() {
return new ArrayIterator($this->members());
}
}