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

View File

@ -3,64 +3,22 @@ declare(strict_types=1);
namespace api\components\OAuth2\Entities; 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\CryptKeyInterface;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface; 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\EntityTrait;
use League\OAuth2\Server\Entities\Traits\TokenEntityTrait; use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
use Yii; use Yii;
class AccessTokenEntity implements AccessTokenEntityInterface { class AccessTokenEntity implements AccessTokenEntityInterface {
use EntityTrait; use EntityTrait;
use TokenEntityTrait { use TokenEntityTrait;
getExpiryDateTime as parentGetExpiryDateTime;
}
/**
* 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 { public function __toString(): string {
$scopes = $this->scopes; return (string)Yii::$app->tokensFactory->createForOAuthClient($this);
$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;
} }
public function setPrivateKey(CryptKeyInterface $privateKey): void { 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 // 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; namespace api\components\OAuth2\Grants;
use api\components\OAuth2\CryptTrait; use api\components\OAuth2\CryptTrait;
use api\components\OAuth2\Events\RequestedRefreshToken;
use api\components\OAuth2\Repositories\PublicScopeRepository; use api\components\OAuth2\Repositories\PublicScopeRepository;
use DateInterval;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface; 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; use League\OAuth2\Server\Grant\AuthCodeGrant as BaseAuthCodeGrant;
class AuthCodeGrant extends BaseAuthCodeGrant { class AuthCodeGrant extends BaseAuthCodeGrant {
use CryptTrait; 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) { 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; namespace api\components\OAuth2\Grants;
use api\components\OAuth2\CryptTrait; use api\components\OAuth2\CryptTrait;
use api\components\Tokens\TokenReader;
use Carbon\Carbon;
use common\models\OauthSession; use common\models\OauthSession;
use InvalidArgumentException;
use Lcobucci\JWT\ValidationData;
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\Exception\OAuthServerException;
@ -32,7 +36,7 @@ class RefreshTokenGrant extends BaseRefreshTokenGrant {
return $this->validateLegacyRefreshToken($refreshToken); 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; 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\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
use Webmozart\Assert\Assert;
class RefreshTokenRepository implements RefreshTokenRepositoryInterface { class RefreshTokenRepository implements RefreshTokenRepositoryInterface {
public function getNewRefreshToken(): ?RefreshTokenEntityInterface { public function getNewRefreshToken(): ?RefreshTokenEntityInterface {
return new RefreshTokenEntity(); return null;
} }
public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity): void { public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity): void {
$model = new OauthRefreshToken(); // Do nothing
$model->id = $refreshTokenEntity->getIdentifier();
$model->account_id = $refreshTokenEntity->getAccessToken()->getUserIdentifier();
$model->client_id = $refreshTokenEntity->getAccessToken()->getClient()->getIdentifier();
Assert::true($model->save());
} }
public function revokeRefreshToken($tokenId): void { public function revokeRefreshToken($tokenId): void {
// Currently we're not rotating refresh tokens so do not revoke // Do nothing
// token during any OAuth2 grant
} }
public function isRefreshTokenRevoked($tokenId): bool { 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 * @return string
*/ */
private function prepareScopes(array $scopes): 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) { if ($scope instanceof ScopeEntityInterface) {
return $scope->getIdentifier(); return $scope->getIdentifier();
} }

View File

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

View File

@ -3,6 +3,7 @@ declare(strict_types=1);
namespace api\modules\authserver\models; namespace api\modules\authserver\models;
use api\components\Tokens\TokenReader;
use api\models\base\ApiForm; use api\models\base\ApiForm;
use api\modules\authserver\exceptions\ForbiddenOperationException; use api\modules\authserver\exceptions\ForbiddenOperationException;
use api\modules\authserver\validators\AccessTokenValidator; use api\modules\authserver\validators\AccessTokenValidator;
@ -49,16 +50,12 @@ class RefreshTokenForm extends ApiForm {
} }
} else { } else {
$token = Yii::$app->tokens->parse($this->accessToken); $token = Yii::$app->tokens->parse($this->accessToken);
$tokenReader = new TokenReader($token);
$encodedClientToken = $token->getClaim('ely-client-token'); if ($tokenReader->getMinecraftClientToken() !== $this->clientToken) {
$clientToken = Yii::$app->tokens->decryptValue($encodedClientToken);
if ($clientToken !== $this->clientToken) {
throw new ForbiddenOperationException('Invalid token.'); throw new ForbiddenOperationException('Invalid token.');
} }
$accountClaim = $token->getClaim('sub'); $account = Account::findOne(['id' => $tokenReader->getAccountId()]);
$accountId = (int)explode('|', $accountClaim)[1];
$account = Account::findOne(['id' => $accountId]);
} }
if ($account === null) { if ($account === null) {

View File

@ -6,6 +6,8 @@ namespace api\modules\oauth\controllers;
use api\controllers\Controller; use api\controllers\Controller;
use api\modules\oauth\models\OauthProcess; use api\modules\oauth\models\OauthProcess;
use api\rbac\Permissions as P; use api\rbac\Permissions as P;
use GuzzleHttp\Psr7\ServerRequest;
use Psr\Http\Message\ServerRequestInterface;
use Yii; use Yii;
use yii\filters\AccessControl; use yii\filters\AccessControl;
use yii\helpers\ArrayHelper; use yii\helpers\ArrayHelper;
@ -45,19 +47,23 @@ class AuthorizationController extends Controller {
} }
public function actionValidate(): array { public function actionValidate(): array {
return $this->createOauthProcess()->validate(); return $this->createOauthProcess()->validate($this->getServerRequest());
} }
public function actionComplete(): array { public function actionComplete(): array {
return $this->createOauthProcess()->complete(); return $this->createOauthProcess()->complete($this->getServerRequest());
} }
public function actionToken(): array { public function actionToken(): array {
return $this->createOauthProcess()->getToken(); return $this->createOauthProcess()->getToken($this->getServerRequest());
} }
private function createOauthProcess(): OauthProcess { private function createOauthProcess(): OauthProcess {
return new OauthProcess(Yii::$app->oauth->getAuthServer()); return new OauthProcess(Yii::$app->oauth->getAuthServer());
} }
private function getServerRequest(): ServerRequestInterface {
return ServerRequest::fromGlobals();
}
} }

View File

@ -4,12 +4,12 @@ declare(strict_types=1);
namespace api\modules\oauth\models; namespace api\modules\oauth\models;
use api\components\OAuth2\Entities\UserEntity; use api\components\OAuth2\Entities\UserEntity;
use api\components\OAuth2\Events\RequestedRefreshToken;
use api\rbac\Permissions as P; use api\rbac\Permissions as P;
use common\models\Account; use common\models\Account;
use common\models\OauthClient; use common\models\OauthClient;
use common\models\OauthSession; use common\models\OauthSession;
use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\ServerRequest;
use League\OAuth2\Server\AuthorizationServer; use League\OAuth2\Server\AuthorizationServer;
use League\OAuth2\Server\Entities\ScopeEntityInterface; use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Exception\OAuthServerException;
@ -20,6 +20,7 @@ use Yii;
class OauthProcess { class OauthProcess {
// TODO: merge this with PublicScopesRepository
private const INTERNAL_PERMISSIONS_TO_PUBLIC_SCOPES = [ private const INTERNAL_PERMISSIONS_TO_PUBLIC_SCOPES = [
P::OBTAIN_OWN_ACCOUNT_INFO => 'account_info', P::OBTAIN_OWN_ACCOUNT_INFO => 'account_info',
P::OBTAIN_ACCOUNT_EMAIL => 'account_email', P::OBTAIN_ACCOUNT_EMAIL => 'account_email',
@ -49,11 +50,11 @@ class OauthProcess {
* *
* In addition, you can pass the description value to override the application's description. * In addition, you can pass the description value to override the application's description.
* *
* @param ServerRequestInterface $request
* @return array * @return array
*/ */
public function validate(): array { public function validate(ServerRequestInterface $request): array {
try { try {
$request = $this->getRequest();
$authRequest = $this->server->validateAuthorizationRequest($request); $authRequest = $this->server->validateAuthorizationRequest($request);
$client = $authRequest->getClient(); $client = $authRequest->getClient();
/** @var OauthClient $clientModel */ /** @var OauthClient $clientModel */
@ -83,13 +84,13 @@ class OauthProcess {
* If the field is present, it will be interpreted as any value resulting in false positives. * If the field is present, it will be interpreted as any value resulting in false positives.
* Otherwise, the value will be interpreted as "true". * Otherwise, the value will be interpreted as "true".
* *
* @param ServerRequestInterface $request
* @return array * @return array
*/ */
public function complete(): array { public function complete(ServerRequestInterface $request): array {
try { try {
Yii::$app->statsd->inc('oauth.complete.attempt'); Yii::$app->statsd->inc('oauth.complete.attempt');
$request = $this->getRequest();
$authRequest = $this->server->validateAuthorizationRequest($request); $authRequest = $this->server->validateAuthorizationRequest($request);
/** @var Account $account */ /** @var Account $account */
$account = Yii::$app->user->identity->getAccount(); $account = Yii::$app->user->identity->getAccount();
@ -151,18 +152,29 @@ class OauthProcess {
* grant_type, * grant_type,
* ] * ]
* *
* @param ServerRequestInterface $request
* @return array * @return array
*/ */
public function getToken(): array { public function getToken(ServerRequestInterface $request): array {
$request = $this->getRequest();
$params = (array)$request->getParsedBody(); $params = (array)$request->getParsedBody();
$clientId = $params['client_id'] ?? ''; $clientId = $params['client_id'] ?? '';
$grantType = $params['grant_type'] ?? 'null'; $grantType = $params['grant_type'] ?? 'null';
try { try {
Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.attempt"); Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.attempt");
$shouldIssueRefreshToken = false;
$this->server->getEmitter()->addOneTimeListener(RequestedRefreshToken::class, function() use (&$shouldIssueRefreshToken) {
$shouldIssueRefreshToken = true;
});
$response = $this->server->respondToAccessTokenRequest($request, new Response(200)); $response = $this->server->respondToAccessTokenRequest($request, new Response(200));
/** @noinspection JsonEncodingApiUsageInspection at this point json error is not possible */
$result = json_decode((string)$response->getBody(), true); $result = json_decode((string)$response->getBody(), true);
if ($shouldIssueRefreshToken) {
// Set the refresh_token field to keep compatibility with the old clients,
// which will be broken in case when refresh_token field will be missing
$result['refresh_token'] = $result['access_token'];
}
Yii::$app->statsd->inc("oauth.issueToken_client.{$clientId}"); Yii::$app->statsd->inc("oauth.issueToken_client.{$clientId}");
Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.success"); Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.success");
@ -312,10 +324,6 @@ class OauthProcess {
]; ];
} }
private function getRequest(): ServerRequestInterface {
return ServerRequest::fromGlobals();
}
private function createAcceptRequiredException(): OAuthServerException { private function createAcceptRequiredException(): OAuthServerException {
return new OAuthServerException( return new OAuthServerException(
'Client must accept authentication request.', 'Client must accept authentication request.',

View File

@ -27,7 +27,7 @@ class RefreshTokenCest {
'refresh_token' => $refreshToken, 'refresh_token' => $refreshToken,
'client_id' => 'ely', 'client_id' => 'ely',
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
'scope' => 'minecraft_server_session offline_access', 'scope' => 'minecraft_server_session',
]); ]);
$this->canSeeRefreshTokenSuccess($I); $this->canSeeRefreshTokenSuccess($I);
} }

View File

@ -5,7 +5,6 @@ namespace api\tests\unit\components\OAuth2\Entities;
use api\components\OAuth2\Entities\AccessTokenEntity; use api\components\OAuth2\Entities\AccessTokenEntity;
use api\tests\unit\TestCase; use api\tests\unit\TestCase;
use DateInterval;
use DateTimeImmutable; use DateTimeImmutable;
use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface; use League\OAuth2\Server\Entities\ScopeEntityInterface;
@ -22,35 +21,10 @@ class AccessTokenEntityTest extends TestCase {
$entity->setExpiryDateTime(new DateTimeImmutable()); $entity->setExpiryDateTime(new DateTimeImmutable());
$entity->addScope($this->createScopeEntity('first')); $entity->addScope($this->createScopeEntity('first'));
$entity->addScope($this->createScopeEntity('second')); $entity->addScope($this->createScopeEntity('second'));
$entity->addScope($this->createScopeEntity('offline_access'));
$token = (string)$entity; $token = (string)$entity;
$payloads = json_decode(base64_decode(explode('.', $token)[1]), true); $payloads = json_decode(base64_decode(explode('.', $token)[1]), true);
$this->assertStringNotContainsString('offline_access', $payloads['ely-scopes']); $this->assertSame('first,second', $payloads['ely-scopes']);
$scopes = $entity->getScopes();
$this->assertCount(3, $scopes);
$this->assertSame('first', $scopes[0]->getIdentifier());
$this->assertSame('second', $scopes[1]->getIdentifier());
$this->assertSame('offline_access', $scopes[2]->getIdentifier());
}
public function testGetExpiryDateTime() {
$initialExpiry = (new DateTimeImmutable())->add(new DateInterval('P1D'));
$entity = new AccessTokenEntity();
$entity->setExpiryDateTime($initialExpiry);
$this->assertSame($initialExpiry, $entity->getExpiryDateTime());
$entity = new AccessTokenEntity();
$entity->setExpiryDateTime($initialExpiry);
$entity->addScope($this->createScopeEntity('change_skin'));
$this->assertEqualsWithDelta(time() + 60 * 60, $entity->getExpiryDateTime()->getTimestamp(), 5);
$entity = new AccessTokenEntity();
$entity->setExpiryDateTime($initialExpiry);
$entity->addScope($this->createScopeEntity('obtain_account_email'));
$this->assertEqualsWithDelta(time() + 60 * 60, $entity->getExpiryDateTime()->getTimestamp(), 5);
} }
private function createScopeEntity(string $id): ScopeEntityInterface { private function createScopeEntity(string $id): ScopeEntityInterface {

View File

@ -50,10 +50,6 @@ class JwtIdentityTest extends TestCase {
'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ.yth31f2PyhUkYSfBlizzUXWIgOvxxk8gNP-js0z8g1OT5rig40FPTIkgsZRctAwAAlj6QoIWW7-hxLTcSb2vmw', 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ.yth31f2PyhUkYSfBlizzUXWIgOvxxk8gNP-js0z8g1OT5rig40FPTIkgsZRctAwAAlj6QoIWW7-hxLTcSb2vmw',
'Incorrect token', 'Incorrect token',
]; ];
yield 'invalid sub' => [
'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoxMjM0fQ.yigP5nWFdX0ktbuZC_Unb9bWxpAVd7Nv8Fb1Vsa0t5WkVA88VbhPi2P-CenbDOy8ngwoGV9m3c3upMs2V3gqvw',
'Incorrect token',
];
yield 'empty token' => ['', 'Incorrect token']; yield 'empty token' => ['', 'Incorrect token'];
} }
@ -66,6 +62,10 @@ class JwtIdentityTest extends TestCase {
$identity = JwtIdentity::findIdentityByAccessToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDk5OTk5In0.1pAnhkR-_ZqzjLBR-PNIMJUXRSUK3aYixrFNKZg2ynPNPiDvzh8U-iBTT6XRfMP5nvfXZucRpoPVoiXtx40CUQ'); $identity = JwtIdentity::findIdentityByAccessToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDk5OTk5In0.1pAnhkR-_ZqzjLBR-PNIMJUXRSUK3aYixrFNKZg2ynPNPiDvzh8U-iBTT6XRfMP5nvfXZucRpoPVoiXtx40CUQ');
$this->assertNull($identity->getAccount()); $this->assertNull($identity->getAccount());
// Sub contains invalid value
$identity = JwtIdentity::findIdentityByAccessToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoxMjM0fQ.yigP5nWFdX0ktbuZC_Unb9bWxpAVd7Nv8Fb1Vsa0t5WkVA88VbhPi2P-CenbDOy8ngwoGV9m3c3upMs2V3gqvw');
$this->assertNull($identity->getAccount());
// Token without sub claim // Token without sub claim
$identity = JwtIdentity::findIdentityByAccessToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Mn0.QxmYgSflZOQmhzYRr8bowU767yu4yKgTVaho0MPuyCmUfZO_0O0SQASMKVILf-wlT0ODTTG7vD753a2MTAmPmw'); $identity = JwtIdentity::findIdentityByAccessToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Mn0.QxmYgSflZOQmhzYRr8bowU767yu4yKgTVaho0MPuyCmUfZO_0O0SQASMKVILf-wlT0ODTTG7vD753a2MTAmPmw');
$this->assertNull($identity->getAccount()); $this->assertNull($identity->getAccount());

View File

@ -41,7 +41,6 @@ use const common\LATEST_RULES_VERSION;
* @property UsernameHistory[] $usernameHistory * @property UsernameHistory[] $usernameHistory
* @property AccountSession[] $sessions * @property AccountSession[] $sessions
* @property MinecraftAccessKey[] $minecraftAccessKeys * @property MinecraftAccessKey[] $minecraftAccessKeys
* @property-read OauthRefreshToken[] $oauthRefreshTokens
* *
* Behaviors: * Behaviors:
* @mixin TimestampBehavior * @mixin TimestampBehavior
@ -102,10 +101,6 @@ class Account extends ActiveRecord {
return $this->hasMany(OauthClient::class, ['account_id' => 'id']); return $this->hasMany(OauthClient::class, ['account_id' => 'id']);
} }
public function getOauthRefreshTokens(): ActiveQuery {
return $this->hasMany(OauthRefreshToken::class, ['account_id' => 'id']);
}
public function getUsernameHistory(): ActiveQuery { public function getUsernameHistory(): ActiveQuery {
return $this->hasMany(UsernameHistory::class, ['account_id' => 'id']); return $this->hasMany(UsernameHistory::class, ['account_id' => 'id']);
} }

View File

@ -26,7 +26,6 @@ use yii\db\ActiveRecord;
* Behaviors: * Behaviors:
* @property Account|null $account * @property Account|null $account
* @property OauthSession[] $sessions * @property OauthSession[] $sessions
* @property-read OauthRefreshToken[] $refreshTokens
*/ */
class OauthClient extends ActiveRecord { class OauthClient extends ActiveRecord {
@ -58,10 +57,6 @@ class OauthClient extends ActiveRecord {
return $this->hasMany(OauthSession::class, ['client_id' => 'id']); return $this->hasMany(OauthSession::class, ['client_id' => 'id']);
} }
public function getRefreshTokens(): ActiveQuery {
return $this->hasMany(OauthRefreshToken::class, ['client_id' => 'id']);
}
public static function find(): OauthClientQuery { public static function find(): OauthClientQuery {
return Yii::createObject(OauthClientQuery::class, [static::class]); return Yii::createObject(OauthClientQuery::class, [static::class]);
} }

View File

@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace common\models;
use yii\behaviors\TimestampBehavior;
use yii\db\ActiveQuery;
use yii\db\ActiveRecord;
/**
* Fields:
* @property string $id
* @property int $account_id
* @property int $client_id
* @property int $issued_at
*
* Relations:
* @property-read OauthSession $session
* @property-read Account $account
* @property-read OauthClient $client
*/
class OauthRefreshToken extends ActiveRecord {
public static function tableName(): string {
return 'oauth_refresh_tokens';
}
public function behaviors(): array {
return [
[
'class' => TimestampBehavior::class,
'createdAtAttribute' => 'issued_at',
'updatedAtAttribute' => false,
],
];
}
public function getSession(): ActiveQuery {
return $this->hasOne(OauthSession::class, ['account_id' => 'account_id', 'client_id' => 'client_id']);
}
public function getAccount(): ActiveQuery {
return $this->hasOne(Account::class, ['id' => 'account_id']);
}
public function getClient(): ActiveQuery {
return $this->hasOne(OauthClient::class, ['id' => 'client_id']);
}
}

View File

@ -19,7 +19,6 @@ use yii\db\ActiveRecord;
* Relations: * Relations:
* @property-read OauthClient $client * @property-read OauthClient $client
* @property-read Account $account * @property-read Account $account
* @property-read OauthRefreshToken[] $refreshTokens
*/ */
class OauthSession extends ActiveRecord { class OauthSession extends ActiveRecord {
@ -44,10 +43,6 @@ class OauthSession extends ActiveRecord {
return $this->hasOne(Account::class, ['id' => 'owner_id']); return $this->hasOne(Account::class, ['id' => 'owner_id']);
} }
public function getRefreshTokens(): ActiveQuery {
return $this->hasMany(OauthRefreshToken::class, ['account_id' => 'account_id', 'client_id' => 'client_id']);
}
public function getScopes(): array { public function getScopes(): array {
if (empty($this->scopes) && $this->legacy_id !== null) { if (empty($this->scopes) && $this->legacy_id !== null) {
return Yii::$app->redis->smembers($this->getLegacyRedisScopesKey()); return Yii::$app->redis->smembers($this->getLegacyRedisScopesKey());

View File

@ -55,7 +55,6 @@ class FixtureHelper extends Module {
'legacyOauthSessionsScopes' => fixtures\LegacyOauthSessionScopeFixtures::class, 'legacyOauthSessionsScopes' => fixtures\LegacyOauthSessionScopeFixtures::class,
'legacyOauthAccessTokens' => fixtures\LegacyOauthAccessTokenFixture::class, 'legacyOauthAccessTokens' => fixtures\LegacyOauthAccessTokenFixture::class,
'legacyOauthAccessTokensScopes' => fixtures\LegacyOauthAccessTokenScopeFixture::class, 'legacyOauthAccessTokensScopes' => fixtures\LegacyOauthAccessTokenScopeFixture::class,
'oauthRefreshTokens' => fixtures\OauthRefreshTokensFixture::class,
'legacyOauthRefreshTokens' => fixtures\LegacyOauthRefreshTokenFixture::class, 'legacyOauthRefreshTokens' => fixtures\LegacyOauthRefreshTokenFixture::class,
'minecraftAccessKeys' => fixtures\MinecraftAccessKeyFixture::class, 'minecraftAccessKeys' => fixtures\MinecraftAccessKeyFixture::class,
]; ];

View File

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace common\tests\fixtures;
use common\models\OauthRefreshToken;
use yii\test\ActiveFixture;
class OauthRefreshTokensFixture extends ActiveFixture {
public $modelClass = OauthRefreshToken::class;
public $dataFile = '@root/common/tests/fixtures/data/oauth-refresh-tokens.php';
public $depends = [
OauthSessionFixture::class,
];
}

View File

@ -34,20 +34,9 @@ class m190914_181236_rework_oauth_related_tables extends Migration {
$this->addForeignKey('FK_oauth_session_to_account', 'oauth_sessions', 'account_id', 'accounts', 'id', 'CASCADE', 'CASCADE'); $this->addForeignKey('FK_oauth_session_to_account', 'oauth_sessions', 'account_id', 'accounts', 'id', 'CASCADE', 'CASCADE');
$this->addForeignKey('FK_oauth_session_to_oauth_client', 'oauth_sessions', 'client_id', 'oauth_clients', 'id', 'CASCADE', 'CASCADE'); $this->addForeignKey('FK_oauth_session_to_oauth_client', 'oauth_sessions', 'client_id', 'oauth_clients', 'id', 'CASCADE', 'CASCADE');
$this->addColumn('oauth_sessions', 'scopes', $this->json()->toString('scopes') . ' AFTER `legacy_id`'); $this->addColumn('oauth_sessions', 'scopes', $this->json()->toString('scopes') . ' AFTER `legacy_id`');
$this->createTable('oauth_refresh_tokens', [
'id' => $this->string(80)->notNull()->unique(),
'account_id' => $this->db->getTableSchema('oauth_sessions', true)->getColumn('account_id')->dbType . ' NOT NULL',
'client_id' => $this->db->getTableSchema('oauth_sessions', true)->getColumn('client_id')->dbType . ' NOT NULL',
'issued_at' => $this->integer(11)->unsigned()->notNull(),
$this->primary('id'),
]);
$this->addForeignKey('FK_oauth_refresh_token_to_oauth_session', 'oauth_refresh_tokens', ['account_id', 'client_id'], 'oauth_sessions', ['account_id', 'client_id'], 'CASCADE');
} }
public function safeDown() { public function safeDown() {
$this->dropTable('oauth_refresh_tokens');
$this->dropColumn('oauth_sessions', 'scopes'); $this->dropColumn('oauth_sessions', 'scopes');
$this->dropForeignKey('FK_oauth_session_to_oauth_client', 'oauth_sessions'); $this->dropForeignKey('FK_oauth_session_to_oauth_client', 'oauth_sessions');
$this->dropForeignKey('FK_oauth_session_to_account', 'oauth_sessions'); $this->dropForeignKey('FK_oauth_session_to_account', 'oauth_sessions');