mirror of
https://github.com/elyby/accounts.git
synced 2025-01-12 23:12:18 +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:
parent
efb97a2006
commit
ba7fad84a0
api
components
modules
authserver/models
oauth
tests
functional/oauth
unit/components
common
models
tests
console/migrations
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace api\modules\authserver\models;
|
||||
|
||||
use api\components\Tokens\TokenReader;
|
||||
use api\models\base\ApiForm;
|
||||
use api\modules\authserver\exceptions\ForbiddenOperationException;
|
||||
use api\modules\authserver\validators\AccessTokenValidator;
|
||||
@ -49,16 +50,12 @@ class RefreshTokenForm extends ApiForm {
|
||||
}
|
||||
} else {
|
||||
$token = Yii::$app->tokens->parse($this->accessToken);
|
||||
|
||||
$encodedClientToken = $token->getClaim('ely-client-token');
|
||||
$clientToken = Yii::$app->tokens->decryptValue($encodedClientToken);
|
||||
if ($clientToken !== $this->clientToken) {
|
||||
$tokenReader = new TokenReader($token);
|
||||
if ($tokenReader->getMinecraftClientToken() !== $this->clientToken) {
|
||||
throw new ForbiddenOperationException('Invalid token.');
|
||||
}
|
||||
|
||||
$accountClaim = $token->getClaim('sub');
|
||||
$accountId = (int)explode('|', $accountClaim)[1];
|
||||
$account = Account::findOne(['id' => $accountId]);
|
||||
$account = Account::findOne(['id' => $tokenReader->getAccountId()]);
|
||||
}
|
||||
|
||||
if ($account === null) {
|
||||
|
@ -6,6 +6,8 @@ namespace api\modules\oauth\controllers;
|
||||
use api\controllers\Controller;
|
||||
use api\modules\oauth\models\OauthProcess;
|
||||
use api\rbac\Permissions as P;
|
||||
use GuzzleHttp\Psr7\ServerRequest;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Yii;
|
||||
use yii\filters\AccessControl;
|
||||
use yii\helpers\ArrayHelper;
|
||||
@ -45,19 +47,23 @@ class AuthorizationController extends Controller {
|
||||
}
|
||||
|
||||
public function actionValidate(): array {
|
||||
return $this->createOauthProcess()->validate();
|
||||
return $this->createOauthProcess()->validate($this->getServerRequest());
|
||||
}
|
||||
|
||||
public function actionComplete(): array {
|
||||
return $this->createOauthProcess()->complete();
|
||||
return $this->createOauthProcess()->complete($this->getServerRequest());
|
||||
}
|
||||
|
||||
public function actionToken(): array {
|
||||
return $this->createOauthProcess()->getToken();
|
||||
return $this->createOauthProcess()->getToken($this->getServerRequest());
|
||||
}
|
||||
|
||||
private function createOauthProcess(): OauthProcess {
|
||||
return new OauthProcess(Yii::$app->oauth->getAuthServer());
|
||||
}
|
||||
|
||||
private function getServerRequest(): ServerRequestInterface {
|
||||
return ServerRequest::fromGlobals();
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -4,12 +4,12 @@ declare(strict_types=1);
|
||||
namespace api\modules\oauth\models;
|
||||
|
||||
use api\components\OAuth2\Entities\UserEntity;
|
||||
use api\components\OAuth2\Events\RequestedRefreshToken;
|
||||
use api\rbac\Permissions as P;
|
||||
use common\models\Account;
|
||||
use common\models\OauthClient;
|
||||
use common\models\OauthSession;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use GuzzleHttp\Psr7\ServerRequest;
|
||||
use League\OAuth2\Server\AuthorizationServer;
|
||||
use League\OAuth2\Server\Entities\ScopeEntityInterface;
|
||||
use League\OAuth2\Server\Exception\OAuthServerException;
|
||||
@ -20,6 +20,7 @@ use Yii;
|
||||
|
||||
class OauthProcess {
|
||||
|
||||
// TODO: merge this with PublicScopesRepository
|
||||
private const INTERNAL_PERMISSIONS_TO_PUBLIC_SCOPES = [
|
||||
P::OBTAIN_OWN_ACCOUNT_INFO => 'account_info',
|
||||
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.
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @return array
|
||||
*/
|
||||
public function validate(): array {
|
||||
public function validate(ServerRequestInterface $request): array {
|
||||
try {
|
||||
$request = $this->getRequest();
|
||||
$authRequest = $this->server->validateAuthorizationRequest($request);
|
||||
$client = $authRequest->getClient();
|
||||
/** @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.
|
||||
* Otherwise, the value will be interpreted as "true".
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @return array
|
||||
*/
|
||||
public function complete(): array {
|
||||
public function complete(ServerRequestInterface $request): array {
|
||||
try {
|
||||
Yii::$app->statsd->inc('oauth.complete.attempt');
|
||||
|
||||
$request = $this->getRequest();
|
||||
$authRequest = $this->server->validateAuthorizationRequest($request);
|
||||
/** @var Account $account */
|
||||
$account = Yii::$app->user->identity->getAccount();
|
||||
@ -151,18 +152,29 @@ class OauthProcess {
|
||||
* grant_type,
|
||||
* ]
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @return array
|
||||
*/
|
||||
public function getToken(): array {
|
||||
$request = $this->getRequest();
|
||||
public function getToken(ServerRequestInterface $request): array {
|
||||
$params = (array)$request->getParsedBody();
|
||||
$clientId = $params['client_id'] ?? '';
|
||||
$grantType = $params['grant_type'] ?? 'null';
|
||||
try {
|
||||
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));
|
||||
/** @noinspection JsonEncodingApiUsageInspection at this point json error is not possible */
|
||||
$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_{$grantType}.success");
|
||||
@ -312,10 +324,6 @@ class OauthProcess {
|
||||
];
|
||||
}
|
||||
|
||||
private function getRequest(): ServerRequestInterface {
|
||||
return ServerRequest::fromGlobals();
|
||||
}
|
||||
|
||||
private function createAcceptRequiredException(): OAuthServerException {
|
||||
return new OAuthServerException(
|
||||
'Client must accept authentication request.',
|
||||
|
@ -27,7 +27,7 @@ class RefreshTokenCest {
|
||||
'refresh_token' => $refreshToken,
|
||||
'client_id' => 'ely',
|
||||
'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM',
|
||||
'scope' => 'minecraft_server_session offline_access',
|
||||
'scope' => 'minecraft_server_session',
|
||||
]);
|
||||
$this->canSeeRefreshTokenSuccess($I);
|
||||
}
|
||||
|
@ -5,7 +5,6 @@ namespace api\tests\unit\components\OAuth2\Entities;
|
||||
|
||||
use api\components\OAuth2\Entities\AccessTokenEntity;
|
||||
use api\tests\unit\TestCase;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use League\OAuth2\Server\Entities\ClientEntityInterface;
|
||||
use League\OAuth2\Server\Entities\ScopeEntityInterface;
|
||||
@ -22,35 +21,10 @@ class AccessTokenEntityTest extends TestCase {
|
||||
$entity->setExpiryDateTime(new DateTimeImmutable());
|
||||
$entity->addScope($this->createScopeEntity('first'));
|
||||
$entity->addScope($this->createScopeEntity('second'));
|
||||
$entity->addScope($this->createScopeEntity('offline_access'));
|
||||
|
||||
$token = (string)$entity;
|
||||
$payloads = json_decode(base64_decode(explode('.', $token)[1]), true);
|
||||
$this->assertStringNotContainsString('offline_access', $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);
|
||||
$this->assertSame('first,second', $payloads['ely-scopes']);
|
||||
}
|
||||
|
||||
private function createScopeEntity(string $id): ScopeEntityInterface {
|
||||
|
@ -50,10 +50,6 @@ class JwtIdentityTest extends TestCase {
|
||||
'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ.yth31f2PyhUkYSfBlizzUXWIgOvxxk8gNP-js0z8g1OT5rig40FPTIkgsZRctAwAAlj6QoIWW7-hxLTcSb2vmw',
|
||||
'Incorrect token',
|
||||
];
|
||||
yield 'invalid sub' => [
|
||||
'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoxMjM0fQ.yigP5nWFdX0ktbuZC_Unb9bWxpAVd7Nv8Fb1Vsa0t5WkVA88VbhPi2P-CenbDOy8ngwoGV9m3c3upMs2V3gqvw',
|
||||
'Incorrect token',
|
||||
];
|
||||
yield 'empty token' => ['', 'Incorrect token'];
|
||||
}
|
||||
|
||||
@ -66,6 +62,10 @@ class JwtIdentityTest extends TestCase {
|
||||
$identity = JwtIdentity::findIdentityByAccessToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDk5OTk5In0.1pAnhkR-_ZqzjLBR-PNIMJUXRSUK3aYixrFNKZg2ynPNPiDvzh8U-iBTT6XRfMP5nvfXZucRpoPVoiXtx40CUQ');
|
||||
$this->assertNull($identity->getAccount());
|
||||
|
||||
// Sub contains invalid value
|
||||
$identity = JwtIdentity::findIdentityByAccessToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoxMjM0fQ.yigP5nWFdX0ktbuZC_Unb9bWxpAVd7Nv8Fb1Vsa0t5WkVA88VbhPi2P-CenbDOy8ngwoGV9m3c3upMs2V3gqvw');
|
||||
$this->assertNull($identity->getAccount());
|
||||
|
||||
// Token without sub claim
|
||||
$identity = JwtIdentity::findIdentityByAccessToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Mn0.QxmYgSflZOQmhzYRr8bowU767yu4yKgTVaho0MPuyCmUfZO_0O0SQASMKVILf-wlT0ODTTG7vD753a2MTAmPmw');
|
||||
$this->assertNull($identity->getAccount());
|
||||
|
@ -41,7 +41,6 @@ use const common\LATEST_RULES_VERSION;
|
||||
* @property UsernameHistory[] $usernameHistory
|
||||
* @property AccountSession[] $sessions
|
||||
* @property MinecraftAccessKey[] $minecraftAccessKeys
|
||||
* @property-read OauthRefreshToken[] $oauthRefreshTokens
|
||||
*
|
||||
* Behaviors:
|
||||
* @mixin TimestampBehavior
|
||||
@ -102,10 +101,6 @@ class Account extends ActiveRecord {
|
||||
return $this->hasMany(OauthClient::class, ['account_id' => 'id']);
|
||||
}
|
||||
|
||||
public function getOauthRefreshTokens(): ActiveQuery {
|
||||
return $this->hasMany(OauthRefreshToken::class, ['account_id' => 'id']);
|
||||
}
|
||||
|
||||
public function getUsernameHistory(): ActiveQuery {
|
||||
return $this->hasMany(UsernameHistory::class, ['account_id' => 'id']);
|
||||
}
|
||||
|
@ -26,7 +26,6 @@ use yii\db\ActiveRecord;
|
||||
* Behaviors:
|
||||
* @property Account|null $account
|
||||
* @property OauthSession[] $sessions
|
||||
* @property-read OauthRefreshToken[] $refreshTokens
|
||||
*/
|
||||
class OauthClient extends ActiveRecord {
|
||||
|
||||
@ -58,10 +57,6 @@ class OauthClient extends ActiveRecord {
|
||||
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 {
|
||||
return Yii::createObject(OauthClientQuery::class, [static::class]);
|
||||
}
|
||||
|
@ -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']);
|
||||
}
|
||||
|
||||
}
|
@ -19,7 +19,6 @@ use yii\db\ActiveRecord;
|
||||
* Relations:
|
||||
* @property-read OauthClient $client
|
||||
* @property-read Account $account
|
||||
* @property-read OauthRefreshToken[] $refreshTokens
|
||||
*/
|
||||
class OauthSession extends ActiveRecord {
|
||||
|
||||
@ -44,10 +43,6 @@ class OauthSession extends ActiveRecord {
|
||||
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 {
|
||||
if (empty($this->scopes) && $this->legacy_id !== null) {
|
||||
return Yii::$app->redis->smembers($this->getLegacyRedisScopesKey());
|
||||
|
@ -55,7 +55,6 @@ class FixtureHelper extends Module {
|
||||
'legacyOauthSessionsScopes' => fixtures\LegacyOauthSessionScopeFixtures::class,
|
||||
'legacyOauthAccessTokens' => fixtures\LegacyOauthAccessTokenFixture::class,
|
||||
'legacyOauthAccessTokensScopes' => fixtures\LegacyOauthAccessTokenScopeFixture::class,
|
||||
'oauthRefreshTokens' => fixtures\OauthRefreshTokensFixture::class,
|
||||
'legacyOauthRefreshTokens' => fixtures\LegacyOauthRefreshTokenFixture::class,
|
||||
'minecraftAccessKeys' => fixtures\MinecraftAccessKeyFixture::class,
|
||||
];
|
||||
|
@ -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,
|
||||
];
|
||||
|
||||
}
|
@ -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_oauth_client', 'oauth_sessions', 'client_id', 'oauth_clients', 'id', 'CASCADE', 'CASCADE');
|
||||
$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() {
|
||||
$this->dropTable('oauth_refresh_tokens');
|
||||
|
||||
$this->dropColumn('oauth_sessions', 'scopes');
|
||||
$this->dropForeignKey('FK_oauth_session_to_oauth_client', 'oauth_sessions');
|
||||
$this->dropForeignKey('FK_oauth_session_to_account', 'oauth_sessions');
|
||||
|
Loading…
x
Reference in New Issue
Block a user