mirror of
https://github.com/elyby/accounts.git
synced 2025-05-31 14:11:46 +05:30
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:
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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');
|
||||
}
|
||||
|
||||
}
|
10
api/components/OAuth2/Events/RequestedRefreshToken.php
Normal file
10
api/components/OAuth2/Events/RequestedRefreshToken.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace api\components\OAuth2\Events;
|
||||
|
||||
use League\Event\AbstractEvent;
|
||||
|
||||
class RequestedRefreshToken extends AbstractEvent {
|
||||
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
64
api/components/Tokens/TokenReader.php
Normal file
64
api/components/Tokens/TokenReader.php
Normal 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);
|
||||
}
|
||||
|
||||
}
|
@@ -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();
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
Reference in New Issue
Block a user