Remove refresh_token from OAuth2 result. Return the same access_token as a refresh_token in case when it's requested. Make access_tokens to live forever.

This commit is contained in:
ErickSkrauch
2019-12-09 19:31:54 +03:00
parent efb97a2006
commit ba7fad84a0
23 changed files with 231 additions and 297 deletions

View File

@@ -3,7 +3,6 @@ declare(strict_types=1);
namespace api\components\OAuth2;
use api\components\OAuth2\Keys\EmptyKey;
use Carbon\CarbonInterval;
use DateInterval;
use League\OAuth2\Server\AuthorizationServer;
@@ -18,41 +17,45 @@ class Component extends BaseComponent {
public function getAuthServer(): AuthorizationServer {
if ($this->_authServer === null) {
$clientsRepo = new Repositories\ClientRepository();
$accessTokensRepo = new Repositories\AccessTokenRepository();
$publicScopesRepo = new Repositories\PublicScopeRepository();
$internalScopesRepo = new Repositories\InternalScopeRepository();
$authCodesRepo = new Repositories\AuthCodeRepository();
$refreshTokensRepo = new Repositories\RefreshTokenRepository();
$accessTokenTTL = CarbonInterval::days(2);
$authServer = new AuthorizationServer(
$clientsRepo,
$accessTokensRepo,
new Repositories\EmptyScopeRepository(),
new EmptyKey(),
'', // omit key because we use our own encryption mechanism
new ResponseTypes\BearerTokenResponse()
);
/** @noinspection PhpUnhandledExceptionInspection */
$authCodeGrant = new Grants\AuthCodeGrant($authCodesRepo, $refreshTokensRepo, new DateInterval('PT10M'));
$authCodeGrant->disableRequireCodeChallengeForPublicClients();
$authServer->enableGrantType($authCodeGrant, $accessTokenTTL);
$authCodeGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling
$refreshTokenGrant = new Grants\RefreshTokenGrant($refreshTokensRepo);
$authServer->enableGrantType($refreshTokenGrant);
$refreshTokenGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling
$clientCredentialsGrant = new Grants\ClientCredentialsGrant();
$authServer->enableGrantType($clientCredentialsGrant, CarbonInterval::create(-1)); // set negative value to make it non expiring
$clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling
$this->_authServer = $authServer;
$this->_authServer = $this->createAuthServer();
}
return $this->_authServer;
}
private function createAuthServer(): AuthorizationServer {
$clientsRepo = new Repositories\ClientRepository();
$accessTokensRepo = new Repositories\AccessTokenRepository();
$publicScopesRepo = new Repositories\PublicScopeRepository();
$internalScopesRepo = new Repositories\InternalScopeRepository();
$authCodesRepo = new Repositories\AuthCodeRepository();
$refreshTokensRepo = new Repositories\RefreshTokenRepository();
$accessTokenTTL = CarbonInterval::create(-1); // Set negative value to make tokens non expiring
$authServer = new AuthorizationServer(
$clientsRepo,
$accessTokensRepo,
new Repositories\EmptyScopeRepository(),
new Keys\EmptyKey(),
'', // Omit the key because we use our own encryption mechanism
new ResponseTypes\BearerTokenResponse()
);
/** @noinspection PhpUnhandledExceptionInspection */
$authCodeGrant = new Grants\AuthCodeGrant($authCodesRepo, $refreshTokensRepo, new DateInterval('PT10M'));
$authCodeGrant->disableRequireCodeChallengeForPublicClients();
$authServer->enableGrantType($authCodeGrant, $accessTokenTTL);
$authCodeGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling
$refreshTokenGrant = new Grants\RefreshTokenGrant($refreshTokensRepo);
$authServer->enableGrantType($refreshTokenGrant, $accessTokenTTL);
$refreshTokenGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling
$clientCredentialsGrant = new Grants\ClientCredentialsGrant();
$authServer->enableGrantType($clientCredentialsGrant, $accessTokenTTL);
$clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling
return $authServer;
}
}

View File

@@ -3,64 +3,22 @@ declare(strict_types=1);
namespace api\components\OAuth2\Entities;
use api\components\OAuth2\Repositories\PublicScopeRepository;
use api\rbac\Permissions;
use Carbon\CarbonImmutable;
use DateTimeImmutable;
use League\OAuth2\Server\CryptKeyInterface;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
use Yii;
class AccessTokenEntity implements AccessTokenEntityInterface {
use EntityTrait;
use TokenEntityTrait {
getExpiryDateTime as parentGetExpiryDateTime;
}
use TokenEntityTrait;
/**
* There is no need to store offline_access scope in the resulting access_token.
* We cannot remove it from the token because otherwise we won't be able to form a refresh_token.
* That's why we delete offline_access before creating the token and then return it back.
*
* @return string
*/
public function __toString(): string {
$scopes = $this->scopes;
$this->scopes = array_filter($this->scopes, function(ScopeEntityInterface $scope): bool {
return $scope->getIdentifier() !== PublicScopeRepository::OFFLINE_ACCESS;
});
$token = Yii::$app->tokensFactory->createForOAuthClient($this);
$this->scopes = $scopes;
return (string)$token;
return (string)Yii::$app->tokensFactory->createForOAuthClient($this);
}
public function setPrivateKey(CryptKeyInterface $privateKey): void {
// We use a general-purpose component to build JWT tokens, so there is no need to keep the key
}
public function getExpiryDateTime(): DateTimeImmutable {
$expiryTime = $this->parentGetExpiryDateTime();
if ($this->hasScope(PublicScopeRepository::CHANGE_SKIN) || $this->hasScope(Permissions::OBTAIN_ACCOUNT_EMAIL)) {
$expiryTime = min($expiryTime, CarbonImmutable::now()->addHour());
}
return $expiryTime;
}
private function hasScope(string $scopeIdentifier): bool {
foreach ($this->getScopes() as $scope) {
if ($scope->getIdentifier() === $scopeIdentifier) {
return true;
}
}
return false;
}
}

View File

@@ -1,29 +0,0 @@
<?php
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;
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');
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace api\components\OAuth2\Events;
use League\Event\AbstractEvent;
class RequestedRefreshToken extends AbstractEvent {
}

View File

@@ -4,22 +4,40 @@ declare(strict_types=1);
namespace api\components\OAuth2\Grants;
use api\components\OAuth2\CryptTrait;
use api\components\OAuth2\Events\RequestedRefreshToken;
use api\components\OAuth2\Repositories\PublicScopeRepository;
use DateInterval;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Grant\AuthCodeGrant as BaseAuthCodeGrant;
class AuthCodeGrant extends BaseAuthCodeGrant {
use CryptTrait;
protected function issueRefreshToken(AccessTokenEntityInterface $accessToken): ?RefreshTokenEntityInterface {
foreach ($accessToken->getScopes() as $scope) {
/**
* @param DateInterval $accessTokenTTL
* @param ClientEntityInterface $client
* @param string|null $userIdentifier
* @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes
*
* @return AccessTokenEntityInterface
* @throws \League\OAuth2\Server\Exception\OAuthServerException
* @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException
*/
protected function issueAccessToken(
DateInterval $accessTokenTTL,
ClientEntityInterface $client,
$userIdentifier,
array $scopes = []
): AccessTokenEntityInterface {
foreach ($scopes as $i => $scope) {
if ($scope->getIdentifier() === PublicScopeRepository::OFFLINE_ACCESS) {
return parent::issueRefreshToken($accessToken);
unset($scopes[$i]);
$this->getEmitter()->emit(new RequestedRefreshToken());
}
}
return null;
return parent::issueAccessToken($accessTokenTTL, $client, $userIdentifier, $scopes);
}
}

View File

@@ -4,7 +4,11 @@ declare(strict_types=1);
namespace api\components\OAuth2\Grants;
use api\components\OAuth2\CryptTrait;
use api\components\Tokens\TokenReader;
use Carbon\Carbon;
use common\models\OauthSession;
use InvalidArgumentException;
use Lcobucci\JWT\ValidationData;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Exception\OAuthServerException;
@@ -32,7 +36,7 @@ class RefreshTokenGrant extends BaseRefreshTokenGrant {
return $this->validateLegacyRefreshToken($refreshToken);
}
return parent::validateOldRefreshToken($request, $clientId);
return $this->validateAccessToken($refreshToken);
}
/**
@@ -84,4 +88,36 @@ class RefreshTokenGrant extends BaseRefreshTokenGrant {
];
}
/**
* @param string $jwt
* @return array
* @throws OAuthServerException
*/
private function validateAccessToken(string $jwt): array {
try {
$token = Yii::$app->tokens->parse($jwt);
} catch (InvalidArgumentException $e) {
throw OAuthServerException::invalidRefreshToken('Cannot decrypt the refresh token', $e);
}
if (!Yii::$app->tokens->verify($token)) {
throw OAuthServerException::invalidRefreshToken('Cannot decrypt the refresh token');
}
if (!$token->validate(new ValidationData(Carbon::now()->getTimestamp()))) {
throw OAuthServerException::invalidRefreshToken('Token has expired');
}
$reader = new TokenReader($token);
return [
'client_id' => $reader->getClientId(),
'refresh_token_id' => '', // This value used only to invalidate old token
'access_token_id' => '', // This value used only to invalidate old token
'scopes' => $reader->getScopes(),
'user_id' => $reader->getAccountId(),
'expire_time' => null,
];
}
}

View File

@@ -3,34 +3,25 @@ declare(strict_types=1);
namespace api\components\OAuth2\Repositories;
use api\components\OAuth2\Entities\RefreshTokenEntity;
use common\models\OauthRefreshToken;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
use Webmozart\Assert\Assert;
class RefreshTokenRepository implements RefreshTokenRepositoryInterface {
public function getNewRefreshToken(): ?RefreshTokenEntityInterface {
return new RefreshTokenEntity();
return null;
}
public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity): void {
$model = new OauthRefreshToken();
$model->id = $refreshTokenEntity->getIdentifier();
$model->account_id = $refreshTokenEntity->getAccessToken()->getUserIdentifier();
$model->client_id = $refreshTokenEntity->getAccessToken()->getClient()->getIdentifier();
Assert::true($model->save());
// Do nothing
}
public function revokeRefreshToken($tokenId): void {
// Currently we're not rotating refresh tokens so do not revoke
// token during any OAuth2 grant
// Do nothing
}
public function isRefreshTokenRevoked($tokenId): bool {
return OauthRefreshToken::find()->andWhere(['id' => $tokenId])->exists() === false;
return false;
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace api\components\Tokens;
use Lcobucci\JWT\Token;
use Yii;
class TokenReader {
/**
* @var Token
*/
private $token;
public function __construct(Token $token) {
$this->token = $token;
}
public function getAccountId(): ?int {
$sub = $this->token->getClaim('sub', false);
if ($sub === false) {
return null;
}
if (mb_strpos((string)$sub, TokensFactory::SUB_ACCOUNT_PREFIX) !== 0) {
return null;
}
return (int)mb_substr($sub, mb_strlen(TokensFactory::SUB_ACCOUNT_PREFIX));
}
public function getClientId(): ?string {
$aud = $this->token->getClaim('aud', false);
if ($aud === false) {
return null;
}
if (mb_strpos((string)$aud, TokensFactory::AUD_CLIENT_PREFIX) !== 0) {
return null;
}
return mb_substr($aud, mb_strlen(TokensFactory::AUD_CLIENT_PREFIX));
}
public function getScopes(): ?array {
$scopes = $this->token->getClaim('ely-scopes', false);
if ($scopes === false) {
return null;
}
return explode(',', $scopes);
}
public function getMinecraftClientToken(): ?string {
$encodedClientToken = $this->token->getClaim('ely-client-token', false);
if ($encodedClientToken === false) {
return null;
}
return Yii::$app->tokens->decryptValue($encodedClientToken);
}
}

View File

@@ -68,7 +68,7 @@ class TokensFactory extends Component {
* @return string
*/
private function prepareScopes(array $scopes): string {
return implode(',', array_map(function($scope): string {
return implode(',', array_map(function($scope): string { // TODO: replace to the space if it's possible
if ($scope instanceof ScopeEntityInterface) {
return $scope->getIdentifier();
}

View File

@@ -3,13 +3,12 @@ declare(strict_types=1);
namespace api\components\User;
use api\components\Tokens\TokensFactory;
use api\components\Tokens\TokenReader;
use Carbon\Carbon;
use common\models\Account;
use Exception;
use Lcobucci\JWT\Token;
use Lcobucci\JWT\ValidationData;
use Webmozart\Assert\Assert;
use Yii;
use yii\base\NotSupportedException;
use yii\web\UnauthorizedHttpException;
@@ -21,6 +20,11 @@ class JwtIdentity implements IdentityInterface {
*/
private $token;
/**
* @var TokenReader|null
*/
private $reader;
private function __construct(Token $token) {
$this->token = $token;
}
@@ -46,11 +50,6 @@ class JwtIdentity implements IdentityInterface {
throw new UnauthorizedHttpException('Incorrect token');
}
$sub = $token->getClaim('sub', false);
if ($sub !== false && strpos((string)$sub, TokensFactory::SUB_ACCOUNT_PREFIX) !== 0) {
throw new UnauthorizedHttpException('Incorrect token');
}
return new self($token);
}
@@ -59,24 +58,11 @@ class JwtIdentity implements IdentityInterface {
}
public function getAccount(): ?Account {
$subject = $this->token->getClaim('sub', false);
if ($subject === false) {
return null;
}
Assert::startsWith($subject, TokensFactory::SUB_ACCOUNT_PREFIX);
$accountId = (int)mb_substr($subject, mb_strlen(TokensFactory::SUB_ACCOUNT_PREFIX));
return Account::findOne(['id' => $accountId]);
return Account::findOne(['id' => $this->getReader()->getAccountId()]);
}
public function getAssignedPermissions(): array {
$scopesClaim = $this->token->getClaim('ely-scopes', false);
if ($scopesClaim === false) {
return [];
}
return explode(',', $scopesClaim);
return $this->getReader()->getScopes() ?? [];
}
public function getId(): string {
@@ -98,4 +84,12 @@ class JwtIdentity implements IdentityInterface {
// @codeCoverageIgnoreEnd
private function getReader(): TokenReader {
if ($this->reader === null) {
$this->reader = new TokenReader($this->token);
}
return $this->reader;
}
}