From 0b63dc2d84abc4dcee62ba7c01eade8b8713dee8 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Fri, 23 Aug 2019 11:28:04 +0300 Subject: [PATCH 01/28] Upgrade oauth2-server to 8.0.0 version, rewrite repositories and entities, start rewriting tests. Intermediate commit [skip ci] --- api/components/OAuth2/Component.php | 52 +++--- .../OAuth2/Entities/AccessTokenEntity.php | 2 +- .../OAuth2/Entities/AuthCodeEntity.php | 33 ++-- .../OAuth2/Entities/ClientEntity.php | 33 ++-- .../OAuth2/Entities/RefreshTokenEntity.php | 43 +---- .../OAuth2/Entities/ScopeEntity.php | 14 +- .../Exception/AcceptRequiredException.php | 22 --- .../Exception/AccessDeniedException.php | 11 -- .../OAuth2/Grants/AuthCodeGrant.php | 2 +- api/components/OAuth2/Keys/EmptyKey.php | 18 ++ .../Repositories/AccessTokenRepository.php | 56 ++++++ .../AccessTokenStorage.php | 2 +- .../Repositories/AuthCodeRepository.php | 26 +++ .../AuthCodeStorage.php | 2 +- .../OAuth2/Repositories/ClientRepository.php | 41 +++++ .../ClientStorage.php | 2 +- .../Repositories/RefreshTokenRepository.php | 51 ++++++ .../RefreshTokenStorage.php | 2 +- .../OAuth2/Repositories/ScopeRepository.php | 37 ++++ .../ScopeStorage.php | 2 +- .../SessionStorage.php | 2 +- api/components/User/OAuth2Identity.php | 1 + .../controllers/AuthorizationController.php | 7 +- api/modules/oauth/models/OauthProcess.php | 153 ++++++++-------- api/tests/_pages/OauthRoute.php | 32 ++++ api/tests/functional/_steps/OauthSteps.php | 14 +- api/tests/functional/oauth/AuthCodeCest.php | 57 +----- .../functional/oauth/RefreshTokenCest.php | 2 +- api/tests/functional/oauth/ValidateCest.php | 62 +++++++ common/models/OauthClient.php | 4 +- common/models/OauthSession.php | 3 +- composer.json | 8 +- composer.lock | 171 +++++++++++------- 33 files changed, 604 insertions(+), 363 deletions(-) delete mode 100644 api/components/OAuth2/Exception/AcceptRequiredException.php delete mode 100644 api/components/OAuth2/Exception/AccessDeniedException.php create mode 100644 api/components/OAuth2/Keys/EmptyKey.php create mode 100644 api/components/OAuth2/Repositories/AccessTokenRepository.php rename api/components/OAuth2/{Storage => Repositories}/AccessTokenStorage.php (97%) create mode 100644 api/components/OAuth2/Repositories/AuthCodeRepository.php rename api/components/OAuth2/{Storage => Repositories}/AuthCodeStorage.php (98%) create mode 100644 api/components/OAuth2/Repositories/ClientRepository.php rename api/components/OAuth2/{Storage => Repositories}/ClientStorage.php (98%) create mode 100644 api/components/OAuth2/Repositories/RefreshTokenRepository.php rename api/components/OAuth2/{Storage => Repositories}/RefreshTokenStorage.php (97%) create mode 100644 api/components/OAuth2/Repositories/ScopeRepository.php rename api/components/OAuth2/{Storage => Repositories}/ScopeStorage.php (98%) rename api/components/OAuth2/{Storage => Repositories}/SessionStorage.php (98%) create mode 100644 api/tests/functional/oauth/ValidateCest.php diff --git a/api/components/OAuth2/Component.php b/api/components/OAuth2/Component.php index 0fa3e4f..c09f939 100644 --- a/api/components/OAuth2/Component.php +++ b/api/components/OAuth2/Component.php @@ -1,10 +1,13 @@ _authServer === null) { - $authServer = new AuthorizationServer(); - $authServer->setAccessTokenStorage(new Storage\AccessTokenStorage()); - $authServer->setClientStorage(new Storage\ClientStorage()); - $authServer->setScopeStorage(new Storage\ScopeStorage()); - $authServer->setSessionStorage(new Storage\SessionStorage()); - $authServer->setAuthCodeStorage(new Storage\AuthCodeStorage()); - $authServer->setRefreshTokenStorage(new Storage\RefreshTokenStorage()); - $authServer->setAccessTokenTTL(86400); // 1d + $clientsRepo = new Repositories\ClientRepository(); + $accessTokensRepo = new Repositories\AccessTokenRepository(); + $scopesRepo = new Repositories\ScopeRepository(); + $authCodesRepo = new Repositories\AuthCodeRepository(); + $refreshTokensRepo = new Repositories\RefreshTokenRepository(); - $authServer->addGrantType(new Grants\AuthCodeGrant()); - $authServer->addGrantType(new Grants\RefreshTokenGrant()); - $authServer->addGrantType(new Grants\ClientCredentialsGrant()); + $accessTokenTTL = new DateInterval('P1D'); + + $authServer = new AuthorizationServer( + $clientsRepo, + $accessTokensRepo, + $scopesRepo, + new EmptyKey(), + '123' // TODO: extract to the variable + ); + /** @noinspection PhpUnhandledExceptionInspection */ + $authCodeGrant = new Grant\AuthCodeGrant($authCodesRepo, $refreshTokensRepo, new DateInterval('PT10M')); + $authCodeGrant->disableRequireCodeChallengeForPublicClients(); + $authServer->enableGrantType($authCodeGrant, $accessTokenTTL); + $authServer->enableGrantType(new Grant\RefreshTokenGrant($refreshTokensRepo), $accessTokenTTL); + $authServer->enableGrantType(new Grant\ClientCredentialsGrant(), $accessTokenTTL); $this->_authServer = $authServer; } @@ -38,16 +50,4 @@ class Component extends BaseComponent { return $this->_authServer; } - public function getAccessTokenStorage(): AccessTokenInterface { - return $this->getAuthServer()->getAccessTokenStorage(); - } - - public function getRefreshTokenStorage(): RefreshTokenInterface { - return $this->getAuthServer()->getRefreshTokenStorage(); - } - - public function getSessionStorage(): SessionInterface { - return $this->getAuthServer()->getSessionStorage(); - } - } diff --git a/api/components/OAuth2/Entities/AccessTokenEntity.php b/api/components/OAuth2/Entities/AccessTokenEntity.php index 183704d..ad876ed 100644 --- a/api/components/OAuth2/Entities/AccessTokenEntity.php +++ b/api/components/OAuth2/Entities/AccessTokenEntity.php @@ -1,7 +1,7 @@ sessionId; - } - - /** - * @inheritdoc - * @return static - */ - public function setSession(OriginalSessionEntity $session) { - parent::setSession($session); - $this->sessionId = $session->getId(); - - return $this; - } - - public function setSessionId(string $sessionId) { - $this->sessionId = $sessionId; - } + // TODO: constructor } diff --git a/api/components/OAuth2/Entities/ClientEntity.php b/api/components/OAuth2/Entities/ClientEntity.php index e88f424..be9f68a 100644 --- a/api/components/OAuth2/Entities/ClientEntity.php +++ b/api/components/OAuth2/Entities/ClientEntity.php @@ -1,32 +1,21 @@ id = $id; - } - - public function setName(string $name) { + public function __construct(string $id, string $name, $redirectUri, bool $isTrusted = false) { + $this->identifier = $id; $this->name = $name; - } - - public function setSecret(string $secret) { - $this->secret = $secret; - } - - public function setRedirectUri($redirectUri) { $this->redirectUri = $redirectUri; - } - - public function setIsTrusted(bool $isTrusted) { - $this->isTrusted = $isTrusted; - } - - public function isTrusted(): bool { - return $this->isTrusted; + $this->isConfidential = $isTrusted; } } diff --git a/api/components/OAuth2/Entities/RefreshTokenEntity.php b/api/components/OAuth2/Entities/RefreshTokenEntity.php index 372f003..aea8f5a 100644 --- a/api/components/OAuth2/Entities/RefreshTokenEntity.php +++ b/api/components/OAuth2/Entities/RefreshTokenEntity.php @@ -3,43 +3,12 @@ declare(strict_types=1); namespace api\components\OAuth2\Entities; -use api\components\OAuth2\Storage\SessionStorage; -use League\OAuth2\Server\Entity\SessionEntity as OriginalSessionEntity; -use Webmozart\Assert\Assert; +use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; +use League\OAuth2\Server\Entities\Traits\EntityTrait; +use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait; -class RefreshTokenEntity extends \League\OAuth2\Server\Entity\RefreshTokenEntity { - - private $sessionId; - - public function isExpired(): bool { - return false; - } - - public function getSession(): SessionEntity { - if ($this->session instanceof SessionEntity) { - return $this->session; - } - - /** @var SessionStorage $sessionStorage */ - $sessionStorage = $this->server->getSessionStorage(); - Assert::isInstanceOf($sessionStorage, SessionStorage::class); - - return $sessionStorage->getById($this->sessionId); - } - - public function getSessionId(): int { - return $this->sessionId; - } - - public function setSession(OriginalSessionEntity $session): self { - parent::setSession($session); - $this->setSessionId((int)$session->getId()); - - return $this; - } - - public function setSessionId(int $sessionId): void { - $this->sessionId = $sessionId; - } +class RefreshTokenEntity implements RefreshTokenEntityInterface { + use EntityTrait; + use RefreshTokenTrait; } diff --git a/api/components/OAuth2/Entities/ScopeEntity.php b/api/components/OAuth2/Entities/ScopeEntity.php index 7b9f3c0..24895c2 100644 --- a/api/components/OAuth2/Entities/ScopeEntity.php +++ b/api/components/OAuth2/Entities/ScopeEntity.php @@ -1,10 +1,18 @@ id = $id; +class ScopeEntity implements ScopeEntityInterface { + use EntityTrait; + use ScopeTrait; + + public function __construct(string $id) { + $this->identifier = $id; } } diff --git a/api/components/OAuth2/Exception/AcceptRequiredException.php b/api/components/OAuth2/Exception/AcceptRequiredException.php deleted file mode 100644 index 540650c..0000000 --- a/api/components/OAuth2/Exception/AcceptRequiredException.php +++ /dev/null @@ -1,22 +0,0 @@ -redirectUri = $redirectUri; - } - -} diff --git a/api/components/OAuth2/Grants/AuthCodeGrant.php b/api/components/OAuth2/Grants/AuthCodeGrant.php index 069dfa2..35f66a0 100644 --- a/api/components/OAuth2/Grants/AuthCodeGrant.php +++ b/api/components/OAuth2/Grants/AuthCodeGrant.php @@ -6,7 +6,7 @@ use api\components\OAuth2\Entities\AuthCodeEntity; use api\components\OAuth2\Entities\ClientEntity; use api\components\OAuth2\Entities\RefreshTokenEntity; use api\components\OAuth2\Entities\SessionEntity; -use api\components\OAuth2\Storage\ScopeStorage; +use api\components\OAuth2\Repositories\ScopeStorage; use api\components\OAuth2\Utils\Scopes; use League\OAuth2\Server\Entity\AuthCodeEntity as BaseAuthCodeEntity; use League\OAuth2\Server\Entity\ClientEntity as BaseClientEntity; diff --git a/api/components/OAuth2/Keys/EmptyKey.php b/api/components/OAuth2/Keys/EmptyKey.php new file mode 100644 index 0000000..daf017e --- /dev/null +++ b/api/components/OAuth2/Keys/EmptyKey.php @@ -0,0 +1,18 @@ +findModel($clientId); + if ($client === null) { + return null; + } + + return new ClientEntity($client->id, $client->name, $client->redirect_uri, (bool)$client->is_trusted); + } + + public function validateClient($clientId, $clientSecret, $grantType): bool { + $client = $this->findModel($clientId); + if ($client === null) { + return false; + } + + if ($clientSecret !== null && $clientSecret !== $client->secret) { + return false; + } + + // TODO: there is missing behavior of checking redirectUri. Is it now bundled into grant? + + return true; + } + + private function findModel(string $id): ?OauthClient { + return OauthClient::findOne(['id' => $id]); + } + +} diff --git a/api/components/OAuth2/Storage/ClientStorage.php b/api/components/OAuth2/Repositories/ClientStorage.php similarity index 98% rename from api/components/OAuth2/Storage/ClientStorage.php rename to api/components/OAuth2/Repositories/ClientStorage.php index fa1aae4..878d97a 100644 --- a/api/components/OAuth2/Storage/ClientStorage.php +++ b/api/components/OAuth2/Repositories/ClientStorage.php @@ -1,5 +1,5 @@ oauth->getAccessTokenStorage()->get($token); if ($model === null) { throw new UnauthorizedHttpException('Incorrect token'); diff --git a/api/modules/oauth/controllers/AuthorizationController.php b/api/modules/oauth/controllers/AuthorizationController.php index 51b1ae4..b98e89b 100644 --- a/api/modules/oauth/controllers/AuthorizationController.php +++ b/api/modules/oauth/controllers/AuthorizationController.php @@ -1,4 +1,6 @@ oauth->authServer; - $server->setRequest(null); // Enforce request recreation (test environment bug) - - return new OauthProcess($server); + return new OauthProcess(Yii::$app->oauth->authServer); } } diff --git a/api/modules/oauth/models/OauthProcess.php b/api/modules/oauth/models/OauthProcess.php index ee0ebd6..d7d6461 100644 --- a/api/modules/oauth/models/OauthProcess.php +++ b/api/modules/oauth/models/OauthProcess.php @@ -1,19 +1,18 @@ getAuthorizationCodeGrant()->checkAuthorizeParams(); - $client = $authParams->getClient(); + $request = $this->getRequest(); + $authRequest = $this->server->validateAuthorizationRequest($request); + $client = $authRequest->getClient(); /** @var OauthClient $clientModel */ - $clientModel = $this->findClient($client->getId()); - $response = $this->buildSuccessResponse( - Yii::$app->request->getQueryParams(), - $clientModel, - $authParams->getScopes() - ); - } catch (OAuthException $e) { + $clientModel = $this->findClient($client->getIdentifier()); + $response = $this->buildSuccessResponse($request, $clientModel, $authRequest->getScopes()); + } catch (OAuthServerException $e) { $response = $this->buildErrorResponse($e); } @@ -88,33 +84,37 @@ class OauthProcess { public function complete(): array { try { Yii::$app->statsd->inc('oauth.complete.attempt'); - $grant = $this->getAuthorizationCodeGrant(); - $authParams = $grant->checkAuthorizeParams(); + + $request = $this->getRequest(); + $authRequest = $this->server->validateAuthorizationRequest($request); /** @var Account $account */ $account = Yii::$app->user->identity->getAccount(); - /** @var \common\models\OauthClient $clientModel */ - $clientModel = $this->findClient($authParams->getClient()->getId()); + /** @var OauthClient $clientModel */ + $clientModel = $this->findClient($authRequest->getClient()->getIdentifier()); - if (!$this->canAutoApprove($account, $clientModel, $authParams)) { + if (!$this->canAutoApprove($account, $clientModel, $authRequest)) { Yii::$app->statsd->inc('oauth.complete.approve_required'); - $isAccept = Yii::$app->request->post('accept'); - if ($isAccept === null) { - throw new AcceptRequiredException(); + + $accept = ((array)$request->getParsedBody())['accept'] ?? null; + if ($accept === null) { + throw $this->createAcceptRequiredException(); } - if (!$isAccept) { - throw new AccessDeniedException($authParams->getRedirectUri()); + if (!in_array($accept, [1, '1', true, 'true'], true)) { + throw OAuthServerException::accessDenied(null, $authRequest->getRedirectUri()); } } - $redirectUri = $grant->newAuthorizeRequest('user', $account->id, $authParams); + $responseObj = $this->server->completeAuthorizationRequest($authRequest, new Response(200)); + $response = [ 'success' => true, - 'redirectUri' => $redirectUri, + 'redirectUri' => $responseObj->getHeader('Location'), // TODO: ensure that this is correct type and behavior ]; + Yii::$app->statsd->inc('oauth.complete.success'); - } catch (OAuthException $e) { - if (!$e instanceof AcceptRequiredException) { + } catch (OAuthServerException $e) { + if ($e->getErrorType() === 'accept_required') { Yii::$app->statsd->inc('oauth.complete.fail'); } @@ -146,19 +146,28 @@ class OauthProcess { * @return array */ public function getToken(): array { - $grantType = Yii::$app->request->post('grant_type', 'null'); + $request = $this->getRequest(); + $params = (array)$request->getParsedBody(); + $grantType = $params['grant_type'] ?? 'null'; try { Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.attempt"); - $response = $this->server->issueAccessToken(); - $clientId = Yii::$app->request->post('client_id'); + + $responseObj = new Response(200); + $this->server->respondToAccessTokenRequest($request, $responseObj); + $clientId = $params['client_id']; + + // TODO: build response from the responseObj + $response = []; + Yii::$app->statsd->inc("oauth.issueToken_client.{$clientId}"); Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.success"); - } catch (OAuthException $e) { + } catch (OAuthServerException $e) { Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.fail"); - Yii::$app->response->statusCode = $e->httpStatusCode; + Yii::$app->response->statusCode = $e->getHttpStatusCode(); + $response = [ - 'error' => $e->errorType, - 'message' => $e->getMessage(), + 'error' => $e->getErrorType(), + 'message' => $e->getMessage(), // TODO: use hint field? ]; } @@ -166,7 +175,7 @@ class OauthProcess { } private function findClient(string $clientId): ?OauthClient { - return OauthClient::findOne($clientId); + return OauthClient::findOne(['id' => $clientId]); } /** @@ -175,11 +184,11 @@ class OauthProcess { * * @param Account $account * @param OauthClient $client - * @param AuthorizeParams $oauthParams + * @param AuthorizationRequest $request * * @return bool */ - private function canAutoApprove(Account $account, OauthClient $client, AuthorizeParams $oauthParams): bool { + private function canAutoApprove(Account $account, OauthClient $client, AuthorizationRequest $request): bool { if ($client->is_trusted) { return true; } @@ -188,7 +197,7 @@ class OauthProcess { $session = $account->getOauthSessions()->andWhere(['client_id' => $client->id])->one(); if ($session !== null) { $existScopes = $session->getScopes()->members(); - if (empty(array_diff(array_keys($oauthParams->getScopes()), $existScopes))) { + if (empty(array_diff(array_keys($request->getScopes()), $existScopes))) { return true; } } @@ -197,17 +206,17 @@ class OauthProcess { } /** - * @param array $queryParams + * @param ServerRequestInterface $request * @param OauthClient $client - * @param \api\components\OAuth2\Entities\ScopeEntity[] $scopes + * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes * * @return array */ - private function buildSuccessResponse(array $queryParams, OauthClient $client, array $scopes): array { + private function buildSuccessResponse(ServerRequestInterface $request, OauthClient $client, array $scopes): array { return [ 'success' => true, // We return only those keys which are related to the OAuth2 standard parameters - 'oAuth' => array_intersect_key($queryParams, array_flip([ + 'oAuth' => array_intersect_key($request->getQueryParams(), array_flip([ 'client_id', 'redirect_uri', 'response_type', @@ -217,55 +226,57 @@ class OauthProcess { 'client' => [ 'id' => $client->id, 'name' => $client->name, - 'description' => ArrayHelper::getValue($queryParams, 'description', $client->description), + 'description' => $request->getQueryParams()['description'] ?? $client->description, ], 'session' => [ - 'scopes' => $this->fixScopesNames(array_keys($scopes)), + 'scopes' => $this->buildScopesArray($scopes), ], ]; } - private function fixScopesNames(array $scopes): array { - foreach ($scopes as &$scope) { - if (isset(self::INTERNAL_PERMISSIONS_TO_PUBLIC_SCOPES[$scope])) { - $scope = self::INTERNAL_PERMISSIONS_TO_PUBLIC_SCOPES[$scope]; - } + /** + * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes + * @return array + */ + private function buildScopesArray(array $scopes): array { + $result = []; + foreach ($scopes as $scope) { + $result[] = self::INTERNAL_PERMISSIONS_TO_PUBLIC_SCOPES[$scope->getIdentifier()] ?? $scope->getIdentifier(); } - return $scopes; + return $result; } - private function buildErrorResponse(OAuthException $e): array { + private function buildErrorResponse(OAuthServerException $e): array { $response = [ 'success' => false, - 'error' => $e->errorType, - 'parameter' => $e->parameter, - 'statusCode' => $e->httpStatusCode, + 'error' => $e->getErrorType(), + // 'parameter' => $e->parameter, // TODO: if this is necessary, the parameter can be extracted from the hint + 'statusCode' => $e->getHttpStatusCode(), ]; - if ($e->shouldRedirect()) { + if ($e->hasRedirect()) { $response['redirectUri'] = $e->getRedirectUri(); } - if ($e->httpStatusCode !== 200) { - Yii::$app->response->setStatusCode($e->httpStatusCode); + if ($e->getHttpStatusCode() !== 200) { + Yii::$app->response->setStatusCode($e->getHttpStatusCode()); } return $response; } - private function getGrant(string $grantType = null): GrantTypeInterface { - return $this->server->getGrantType($grantType ?? Yii::$app->request->get('grant_type')); + private function getRequest(): ServerRequestInterface { + return ServerRequest::fromGlobals(); } - private function getAuthorizationCodeGrant(): AuthCodeGrant { - /** @var GrantTypeInterface $grantType */ - $grantType = $this->getGrant('authorization_code'); - if (!$grantType instanceof AuthCodeGrant) { - throw new InvalidGrantException('authorization_code grant have invalid realisation'); - } - - return $grantType; + private function createAcceptRequiredException(): OAuthServerException { + return new OAuthServerException( + 'Client must accept authentication request.', + 0, + 'accept_required', + 401 + ); } } diff --git a/api/tests/_pages/OauthRoute.php b/api/tests/_pages/OauthRoute.php index a72aeef..2e9a3ce 100644 --- a/api/tests/_pages/OauthRoute.php +++ b/api/tests/_pages/OauthRoute.php @@ -1,40 +1,72 @@ getActor()->sendGET('/api/oauth2/v1/validate', $queryParams); } + /** + * @deprecated + */ public function complete(array $queryParams = [], array $postParams = []): void { $this->getActor()->sendPOST('/api/oauth2/v1/complete?' . http_build_query($queryParams), $postParams); } + /** + * @deprecated + */ public function issueToken(array $postParams = []): void { $this->getActor()->sendPOST('/api/oauth2/v1/token', $postParams); } + /** + * @deprecated + */ public function createClient(string $type, array $postParams): void { $this->getActor()->sendPOST('/api/v1/oauth2/' . $type, $postParams); } + /** + * @deprecated + */ public function updateClient(string $clientId, array $params): void { $this->getActor()->sendPUT('/api/v1/oauth2/' . $clientId, $params); } + /** + * @deprecated + */ public function deleteClient(string $clientId): void { $this->getActor()->sendDELETE('/api/v1/oauth2/' . $clientId); } + /** + * @deprecated + */ public function resetClient(string $clientId, bool $regenerateSecret = false): void { $this->getActor()->sendPOST("/api/v1/oauth2/{$clientId}/reset" . ($regenerateSecret ? '?regenerateSecret' : '')); } + /** + * @deprecated + */ public function getClient(string $clientId): void { $this->getActor()->sendGET("/api/v1/oauth2/{$clientId}"); } + /** + * @deprecated + */ public function getPerAccount(int $accountId): void { $this->getActor()->sendGET("/api/v1/accounts/{$accountId}/oauth2/clients"); } diff --git a/api/tests/functional/_steps/OauthSteps.php b/api/tests/functional/_steps/OauthSteps.php index da3b9a6..e12d5f2 100644 --- a/api/tests/functional/_steps/OauthSteps.php +++ b/api/tests/functional/_steps/OauthSteps.php @@ -1,13 +1,15 @@ amAuthenticated(); $route = new OauthRoute($this); $route->complete([ @@ -23,21 +25,21 @@ class OauthSteps extends FunctionalTester { return $matches[1]; } - public function getAccessToken(array $permissions = []) { + public function getAccessToken(array $permissions = []): string { $authCode = $this->getAuthCode($permissions); $response = $this->issueToken($authCode); return $response['access_token']; } - public function getRefreshToken(array $permissions = []) { + public function getRefreshToken(array $permissions = []): string { $authCode = $this->getAuthCode(array_merge([S::OFFLINE_ACCESS], $permissions)); $response = $this->issueToken($authCode); return $response['refresh_token']; } - public function issueToken($authCode) { + public function issueToken($authCode): array { $route = new OauthRoute($this); $route->issueToken([ 'code' => $authCode, @@ -50,7 +52,7 @@ class OauthSteps extends FunctionalTester { return json_decode($this->grabResponse(), true); } - public function getAccessTokenByClientCredentialsGrant(array $permissions = [], $useTrusted = true) { + public function getAccessTokenByClientCredentialsGrant(array $permissions = [], $useTrusted = true): string { $route = new OauthRoute($this); $route->issueToken([ 'client_id' => $useTrusted ? 'trusted-client' : 'default-client', diff --git a/api/tests/functional/oauth/AuthCodeCest.php b/api/tests/functional/oauth/AuthCodeCest.php index d0c4efa..cab2d35 100644 --- a/api/tests/functional/oauth/AuthCodeCest.php +++ b/api/tests/functional/oauth/AuthCodeCest.php @@ -1,4 +1,6 @@ testOauthParamsValidation($I, 'validate'); - - $I->wantTo('validate and obtain information about new auth request'); - $this->route->validate($this->buildQueryParams( - 'ely', - 'http://ely.by', - 'code', - [P::MINECRAFT_SERVER_SESSION, 'account_info', 'account_email'], - 'test-state' - )); - $I->canSeeResponseCodeIs(200); - $I->canSeeResponseIsJson(); - $I->canSeeResponseContainsJson([ - 'success' => true, - 'oAuth' => [ - 'client_id' => 'ely', - 'redirect_uri' => 'http://ely.by', - 'response_type' => 'code', - 'scope' => 'minecraft_server_session,account_info,account_email', - 'state' => 'test-state', - ], - 'client' => [ - 'id' => 'ely', - 'name' => 'Ely.by', - 'description' => 'Всем знакомое елуби', - ], - 'session' => [ - 'scopes' => [ - 'minecraft_server_session', - 'account_info', - 'account_email', - ], - ], - ]); - } - - public function testValidateWithDescriptionReplaceRequest(FunctionalTester $I) { - $I->amAuthenticated(); - $I->wantTo('validate and get information with description replacement'); - $this->route->validate($this->buildQueryParams( - 'ely', - 'http://ely.by', - 'code', - null, - null, - [ - 'description' => 'all familiar eliby', - ] - )); - $I->canSeeResponseCodeIs(200); - $I->canSeeResponseIsJson(); - $I->canSeeResponseContainsJson([ - 'client' => [ - 'description' => 'all familiar eliby', - ], - ]); } public function testCompleteValidationAction(FunctionalTester $I) { diff --git a/api/tests/functional/oauth/RefreshTokenCest.php b/api/tests/functional/oauth/RefreshTokenCest.php index 480e2d9..d2dad6b 100644 --- a/api/tests/functional/oauth/RefreshTokenCest.php +++ b/api/tests/functional/oauth/RefreshTokenCest.php @@ -1,7 +1,7 @@ wantTo('validate and obtain information about new oauth request'); + $I->sendGET('/api/oauth2/v1/validate', [ + 'client_id' => 'ely', + 'redirect_uri' => 'http://ely.by', + 'response_type' => 'code', + 'scope' => 'minecraft_server_session account_info account_email', + 'state' => 'test-state', + ]); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseContainsJson([ + 'success' => true, + 'oAuth' => [ + 'client_id' => 'ely', + 'redirect_uri' => 'http://ely.by', + 'response_type' => 'code', + 'scope' => 'minecraft_server_session account_info account_email', + 'state' => 'test-state', + ], + 'client' => [ + 'id' => 'ely', + 'name' => 'Ely.by', + 'description' => 'Всем знакомое елуби', + ], + 'session' => [ + 'scopes' => [ + 'minecraft_server_session', + 'account_info', + 'account_email', + ], + ], + ]); + } + + public function completelyValidateValidRequestWithOverriddenDescription(FunctionalTester $I) { + $I->wantTo('validate and get information with description replacement'); + $I->sendGET('/api/oauth2/v1/validate', [ + 'client_id' => 'ely', + 'redirect_uri' => 'http://ely.by', + 'response_type' => 'code', + 'description' => 'all familiar eliby', + ]); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseContainsJson([ + 'client' => [ + 'description' => 'all familiar eliby', + ], + ]); + } + +} diff --git a/common/models/OauthClient.php b/common/models/OauthClient.php index 5a856b0..799e07f 100644 --- a/common/models/OauthClient.php +++ b/common/models/OauthClient.php @@ -13,13 +13,13 @@ use yii\db\ActiveRecord; * @property string $type * @property string $name * @property string $description - * @property string $redirect_uri + * @property string|null $redirect_uri * @property string $website_url * @property string $minecraft_server_ip * @property integer $account_id * @property bool $is_trusted * @property bool $is_deleted - * @property integer $created_at + * @property int $created_at * * Behaviors: * @property Account|null $account diff --git a/common/models/OauthSession.php b/common/models/OauthSession.php index cc38d7e..cb98e33 100644 --- a/common/models/OauthSession.php +++ b/common/models/OauthSession.php @@ -69,7 +69,8 @@ class OauthSession extends ActiveRecord { } public function removeRefreshToken(): void { - /** @var \api\components\OAuth2\Storage\RefreshTokenStorage $refreshTokensStorage */ + /** @var \api\components\OAuth2\Repositories\RefreshTokenStorage $refreshTokensStorage */ + // TODO: rework $refreshTokensStorage = Yii::$app->oauth->getRefreshTokenStorage(); $refreshTokensSet = $refreshTokensStorage->sessionHash($this->id); foreach ($refreshTokensSet->members() as $refreshTokenId) { diff --git a/composer.json b/composer.json index 6f06d85..fa8b7f5 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "type": "project", "minimum-stability": "stable", "require": { - "php": "^7.2", + "php": "^7.3", "ext-intl": "*", "ext-json": "*", "ext-libxml": "*", @@ -19,7 +19,7 @@ "goaop/framework": "^2.2.0", "guzzlehttp/guzzle": "^6.0.0", "lcobucci/jwt": "^3.3", - "league/oauth2-server": "^4.1", + "league/oauth2-server": "dev-adaptation", "mito/yii2-sentry": "^1.0", "nesbot/carbon": "^2.22", "paragonie/constant_time_encoding": "^2.0", @@ -54,6 +54,10 @@ { "type": "composer", "url": "https://asset-packagist.org" + }, + { + "type": "github", + "url": "https://github.com/elyby/oauth2-server.git" } ], "config": { diff --git a/composer.lock b/composer.lock index c7464af..0cfc271 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "35095ab389bcc73cacbafceffa74fb71", + "content-hash": "35a16287a6dc45c16e0553022aa34f5b", "packages": [ { "name": "bacon/bacon-qr-code", @@ -305,6 +305,69 @@ ], "time": "2018-04-13T00:48:04+00:00" }, + { + "name": "defuse/php-encryption", + "version": "v2.2.1", + "source": { + "type": "git", + "url": "https://github.com/defuse/php-encryption.git", + "reference": "0f407c43b953d571421e0020ba92082ed5fb7620" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/defuse/php-encryption/zipball/0f407c43b953d571421e0020ba92082ed5fb7620", + "reference": "0f407c43b953d571421e0020ba92082ed5fb7620", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "paragonie/random_compat": ">= 2", + "php": ">=5.4.0" + }, + "require-dev": { + "nikic/php-parser": "^2.0|^3.0|^4.0", + "phpunit/phpunit": "^4|^5" + }, + "bin": [ + "bin/generate-defuse-key" + ], + "type": "library", + "autoload": { + "psr-4": { + "Defuse\\Crypto\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Hornby", + "email": "taylor@defuse.ca", + "homepage": "https://defuse.ca/" + }, + { + "name": "Scott Arciszewski", + "email": "info@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "Secure PHP Encryption Library", + "keywords": [ + "aes", + "authenticated encryption", + "cipher", + "crypto", + "cryptography", + "encrypt", + "encryption", + "openssl", + "security", + "symmetric key cryptography" + ], + "time": "2018-07-24T23:27:56+00:00" + }, { "name": "doctrine/annotations", "version": "v1.6.0", @@ -1215,30 +1278,37 @@ }, { "name": "league/oauth2-server", - "version": "4.1.7", + "version": "dev-adaptation", "source": { "type": "git", - "url": "https://github.com/thephpleague/oauth2-server.git", - "reference": "138524984ac472652c69399529a35b6595cf22d3" + "url": "https://github.com/elyby/oauth2-server.git", + "reference": "08e470e81a20896109704bac4b7c24781797dfc3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/138524984ac472652c69399529a35b6595cf22d3", - "reference": "138524984ac472652c69399529a35b6595cf22d3", + "url": "https://api.github.com/repos/elyby/oauth2-server/zipball/08e470e81a20896109704bac4b7c24781797dfc3", + "reference": "08e470e81a20896109704bac4b7c24781797dfc3", "shasum": "" }, "require": { - "league/event": "~2.1", - "php": ">=5.4.0", - "symfony/http-foundation": "~2.4|~3.0" + "defuse/php-encryption": "^2.2.1", + "ext-json": "*", + "ext-openssl": "*", + "lcobucci/jwt": "^3.3.1", + "league/event": "^2.2", + "php": ">=7.1.0", + "psr/http-message": "^1.0.1" }, "replace": { "league/oauth2server": "*", "lncd/oauth2": "*" }, "require-dev": { - "mockery/mockery": "0.9.*", - "phpunit/phpunit": "4.3.*" + "phpstan/phpstan": "^0.11.8", + "phpstan/phpstan-phpunit": "^0.11.2", + "phpunit/phpunit": "^7.5.13 || ^8.2.3", + "roave/security-advisories": "dev-master", + "zendframework/zend-diactoros": "^2.1.2" }, "type": "library", "autoload": { @@ -1246,7 +1316,11 @@ "League\\OAuth2\\Server\\": "src/" } }, - "notification-url": "https://packagist.org/downloads/", + "autoload-dev": { + "psr-4": { + "LeagueTests\\": "tests/" + } + }, "license": [ "MIT" ], @@ -1256,14 +1330,21 @@ "email": "hello@alexbilbie.com", "homepage": "http://www.alexbilbie.com", "role": "Developer" + }, + { + "name": "Andy Millington", + "email": "andrew@noexceptions.io", + "homepage": "https://www.noexceptions.io", + "role": "Developer" } ], "description": "A lightweight and powerful OAuth 2.0 authorization and resource server library with support for all the core specification grants. This library will allow you to secure your API with OAuth and allow your applications users to approve apps that want to access their data from your API.", - "homepage": "http://oauth2.thephpleague.com/", + "homepage": "https://oauth2.thephpleague.com/", "keywords": [ - "Authentication", "api", "auth", + "auth", + "authentication", "authorisation", "authorization", "oauth", @@ -1275,7 +1356,10 @@ "secure", "server" ], - "time": "2018-06-23T16:27:31+00:00" + "support": { + "source": "https://github.com/elyby/oauth2-server/tree/adaptation" + }, + "time": "2019-08-22T21:17:49+00:00" }, { "name": "mito/yii2-sentry", @@ -1916,60 +2000,6 @@ "homepage": "https://symfony.com", "time": "2019-06-28T13:16:30+00:00" }, - { - "name": "symfony/http-foundation", - "version": "v3.4.22", - "source": { - "type": "git", - "url": "https://github.com/symfony/http-foundation.git", - "reference": "9a81d2330ea255ded06a69b4f7fb7804836e7a05" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9a81d2330ea255ded06a69b4f7fb7804836e7a05", - "reference": "9a81d2330ea255ded06a69b4f7fb7804836e7a05", - "shasum": "" - }, - "require": { - "php": "^5.5.9|>=7.0.8", - "symfony/polyfill-mbstring": "~1.1", - "symfony/polyfill-php70": "~1.6" - }, - "require-dev": { - "symfony/expression-language": "~2.8|~3.0|~4.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.4-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Component\\HttpFoundation\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony HttpFoundation Component", - "homepage": "https://symfony.com", - "time": "2019-01-27T09:04:14+00:00" - }, { "name": "symfony/process", "version": "v4.2.8", @@ -6653,12 +6683,13 @@ "aliases": [], "minimum-stability": "stable", "stability-flags": { + "league/oauth2-server": 20, "roave/security-advisories": 20 }, "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^7.2", + "php": "^7.3", "ext-intl": "*", "ext-json": "*", "ext-libxml": "*", From 4dc2a3025be04dd4f3cf0979f7d80776f5df2658 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Fri, 6 Sep 2019 02:32:57 +0300 Subject: [PATCH 02/28] Rewrite tests for OAuth2 validate and auth code complete steps [skip ci] --- api/components/OAuth2/Entities/UserEntity.php | 16 + api/modules/oauth/models/OauthProcess.php | 2 + api/tests/functional/oauth/AuthCodeCest.php | 297 +++++++----------- api/tests/functional/oauth/ValidateCest.php | 55 +++- 4 files changed, 193 insertions(+), 177 deletions(-) create mode 100644 api/components/OAuth2/Entities/UserEntity.php diff --git a/api/components/OAuth2/Entities/UserEntity.php b/api/components/OAuth2/Entities/UserEntity.php new file mode 100644 index 0000000..9101263 --- /dev/null +++ b/api/components/OAuth2/Entities/UserEntity.php @@ -0,0 +1,16 @@ +identifier = $id; + } + +} diff --git a/api/modules/oauth/models/OauthProcess.php b/api/modules/oauth/models/OauthProcess.php index d7d6461..5ed5c89 100644 --- a/api/modules/oauth/models/OauthProcess.php +++ b/api/modules/oauth/models/OauthProcess.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace api\modules\oauth\models; +use api\components\OAuth2\Entities\UserEntity; use api\rbac\Permissions as P; use common\models\Account; use common\models\OauthClient; @@ -105,6 +106,7 @@ class OauthProcess { } } + $authRequest->setUser(new UserEntity($account->id)); $responseObj = $this->server->completeAuthorizationRequest($authRequest, new Response(200)); $response = [ diff --git a/api/tests/functional/oauth/AuthCodeCest.php b/api/tests/functional/oauth/AuthCodeCest.php index cab2d35..cdbae07 100644 --- a/api/tests/functional/oauth/AuthCodeCest.php +++ b/api/tests/functional/oauth/AuthCodeCest.php @@ -3,100 +3,19 @@ declare(strict_types=1); namespace api\tests\functional\oauth; -use api\rbac\Permissions as P; -use api\tests\_pages\OauthRoute; use api\tests\FunctionalTester; class AuthCodeCest { - /** - * @var OauthRoute - */ - private $route; - - public function _before(FunctionalTester $I) { - $this->route = new OauthRoute($I); - } - - public function testValidateRequest(FunctionalTester $I) { - $this->testOauthParamsValidation($I, 'validate'); - } - - public function testCompleteValidationAction(FunctionalTester $I) { - $I->amAuthenticated(); - $I->wantTo('validate all oAuth params on complete request'); - $this->testOauthParamsValidation($I, 'complete'); - } - - public function testCompleteActionOnWrongConditions(FunctionalTester $I) { - $I->amAuthenticated(); - - $I->wantTo('get accept_required if I don\'t require any scope, but this is first time request'); - $this->route->complete($this->buildQueryParams( - 'ely', - 'http://ely.by', - 'code' - )); - $I->canSeeResponseCodeIs(401); - $I->canSeeResponseContainsJson([ - 'success' => false, - 'error' => 'accept_required', - 'parameter' => '', - 'statusCode' => 401, - ]); - - $I->wantTo('get accept_required if I require some scopes on first time'); - $this->route->complete($this->buildQueryParams( - 'ely', - 'http://ely.by', - 'code', - [P::MINECRAFT_SERVER_SESSION] - )); - $I->canSeeResponseCodeIs(401); - $I->canSeeResponseContainsJson([ - 'success' => false, - 'error' => 'accept_required', - 'parameter' => '', - 'statusCode' => 401, - ]); - } - - public function testCompleteActionSuccess(FunctionalTester $I) { + public function completeSuccess(FunctionalTester $I) { $I->amAuthenticated(); $I->wantTo('get auth code if I require some scope and pass accept field'); - $this->route->complete($this->buildQueryParams( - 'ely', - 'http://ely.by', - 'code', - [P::MINECRAFT_SERVER_SESSION] - ), ['accept' => true]); - $I->canSeeResponseCodeIs(200); - $I->canSeeResponseContainsJson([ - 'success' => true, - ]); - $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); - - $I->wantTo('get auth code if I don\'t require any scope and don\'t pass accept field, but previously have ' . - 'successful request'); - $this->route->complete($this->buildQueryParams( - 'ely', - 'http://ely.by', - 'code' - )); - $I->canSeeResponseCodeIs(200); - $I->canSeeResponseContainsJson([ - 'success' => true, - ]); - $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); - - $I->wantTo('get auth code if I require some scopes and don\'t pass accept field, but previously have successful ' . - 'request with same scopes'); - $this->route->complete($this->buildQueryParams( - 'ely', - 'http://ely.by', - 'code', - [P::MINECRAFT_SERVER_SESSION] - )); + $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ + 'client_id' => 'ely', + 'redirect_uri' => 'http://ely.by', + 'response_type' => 'code', + 'scope' => 'minecraft_server_session', + ]), ['accept' => true]); $I->canSeeResponseCodeIs(200); $I->canSeeResponseContainsJson([ 'success' => true, @@ -104,21 +23,93 @@ class AuthCodeCest { $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); } - public function testAcceptRequiredOnNewScope(FunctionalTester $I) { + /** + * @before completeSuccess + */ + public function completeSuccessWithLessScopes(FunctionalTester $I) { + $I->amAuthenticated(); + $I->wantTo('get auth code with less scopes as passed in the previous request without accept param'); + $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ + 'client_id' => 'ely', + 'redirect_uri' => 'http://ely.by', + 'response_type' => 'code', + ])); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); + } + + /** + * @before completeSuccess + */ + public function completeSuccessWithSameScopes(FunctionalTester $I) { + $I->amAuthenticated(); + $I->wantTo('get auth code with the same scopes as passed in the previous request without accept param'); + $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ + 'client_id' => 'ely', + 'redirect_uri' => 'http://ely.by', + 'response_type' => 'code', + 'scope' => 'minecraft_server_session', + ])); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); + } + + public function acceptRequiredOnFirstAuthRequest1(FunctionalTester $I) { + $I->amAuthenticated(); + $I->wantTo('get accept_required if I don\'t require any scope, but this is first time request'); + $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ + 'client_id' => 'ely', + 'redirect_uri' => 'http://ely.by', + 'response_type' => 'code', + ])); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'error' => 'accept_required', + 'parameter' => '', + 'statusCode' => 401, + ]); + } + + public function acceptRequiredOnFirstAuthRequest2(FunctionalTester $I) { + $I->amAuthenticated(); + $I->wantTo('get accept_required if I require some scopes on first time'); + $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ + 'client_id' => 'ely', + 'redirect_uri' => 'http://ely.by', + 'response_type' => 'code', + 'scope' => 'minecraft_server_session', + ])); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'error' => 'accept_required', + 'parameter' => '', + 'statusCode' => 401, + ]); + } + + public function acceptRequiredOnNewScope(FunctionalTester $I) { $I->amAuthenticated(); $I->wantTo('get accept_required if I have previous successful request, but now require some new scope'); - $this->route->complete($this->buildQueryParams( - 'ely', - 'http://ely.by', - 'code', - [P::MINECRAFT_SERVER_SESSION] - ), ['accept' => true]); - $this->route->complete($this->buildQueryParams( - 'ely', - 'http://ely.by', - 'code', - [P::MINECRAFT_SERVER_SESSION, 'account_info'] - )); + $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ + 'client_id' => 'ely', + 'redirect_uri' => 'http://ely.by', + 'response_type' => 'code', + 'scope' => 'minecraft_server_session', + ]), ['accept' => true]); + $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ + 'client_id' => 'ely', + 'redirect_uri' => 'http://ely.by', + 'response_type' => 'code', + 'scope' => 'minecraft_server_session account_info', + ])); $I->canSeeResponseCodeIs(401); $I->canSeeResponseContainsJson([ 'success' => false, @@ -131,12 +122,12 @@ class AuthCodeCest { public function testCompleteActionWithDismissState(FunctionalTester $I) { $I->amAuthenticated(); $I->wantTo('get access_denied error if I pass accept in false state'); - $this->route->complete($this->buildQueryParams( - 'ely', - 'http://ely.by', - 'code', - [P::MINECRAFT_SERVER_SESSION] - ), ['accept' => false]); + $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ + 'client_id' => 'ely', + 'redirect_uri' => 'http://ely.by', + 'response_type' => 'code', + 'scope' => 'minecraft_server_session', + ]), ['accept' => false]); $I->canSeeResponseCodeIs(401); $I->canSeeResponseContainsJson([ 'success' => false, @@ -147,56 +138,14 @@ class AuthCodeCest { $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); } - private function buildQueryParams( - $clientId = null, - $redirectUri = null, - $responseType = null, - $scopes = [], - $state = null, - $customData = [] - ) { - $params = $customData; - if ($clientId !== null) { - $params['client_id'] = $clientId; - } - - if ($redirectUri !== null) { - $params['redirect_uri'] = $redirectUri; - } - - if ($responseType !== null) { - $params['response_type'] = $responseType; - } - - if ($state !== null) { - $params['state'] = $state; - } - - if (!empty($scopes)) { - if (is_array($scopes)) { - $scopes = implode(',', $scopes); - } - - $params['scope'] = $scopes; - } - - return $params; - } - - private function testOauthParamsValidation(FunctionalTester $I, $action) { - $I->wantTo('check behavior on invalid request without one or few params'); - $this->route->$action($this->buildQueryParams()); - $I->canSeeResponseCodeIs(400); - $I->canSeeResponseIsJson(); - $I->canSeeResponseContainsJson([ - 'success' => false, - 'error' => 'invalid_request', - 'parameter' => 'client_id', - 'statusCode' => 400, - ]); - + public function invalidClientId(FunctionalTester $I) { + $I->amAuthenticated(); $I->wantTo('check behavior on invalid client id'); - $this->route->$action($this->buildQueryParams('non-exists-client', 'http://some-resource.by', 'code')); + $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ + 'client_id' => 'non-exists-client', + 'redirect_uri' => 'http://some-resource.by', + 'response_type' => 'code', + ])); $I->canSeeResponseCodeIs(401); $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ @@ -204,23 +153,16 @@ class AuthCodeCest { 'error' => 'invalid_client', 'statusCode' => 401, ]); + } - $I->wantTo('check behavior on invalid response type'); - $this->route->$action($this->buildQueryParams('ely', 'http://ely.by', 'kitty')); - $I->canSeeResponseCodeIs(400); - $I->canSeeResponseIsJson(); - $I->canSeeResponseContainsJson([ - 'success' => false, - 'error' => 'unsupported_response_type', - 'parameter' => 'kitty', - 'statusCode' => 400, - ]); - $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); - + public function invalidScopes(FunctionalTester $I) { + $I->amAuthenticated(); $I->wantTo('check behavior on some invalid scopes'); - $this->route->$action($this->buildQueryParams('ely', 'http://ely.by', 'code', [ - P::MINECRAFT_SERVER_SESSION, - 'some_wrong_scope', + $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ + 'client_id' => 'ely', + 'redirect_uri' => 'http://ely.by', + 'response_type' => 'code', + 'scope' => 'minecraft_server_session some_wrong_scope', ])); $I->canSeeResponseCodeIs(400); $I->canSeeResponseIsJson(); @@ -231,18 +173,23 @@ class AuthCodeCest { 'statusCode' => 400, ]); $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); + } + public function requestInternalScope(FunctionalTester $I) { + $I->amAuthenticated(); $I->wantTo('check behavior on request internal scope'); - $this->route->$action($this->buildQueryParams('ely', 'http://ely.by', 'code', [ - P::MINECRAFT_SERVER_SESSION, - P::BLOCK_ACCOUNT, + $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ + 'client_id' => 'ely', + 'redirect_uri' => 'http://ely.by', + 'response_type' => 'code', + 'scope' => 'minecraft_server_session block_account', ])); $I->canSeeResponseCodeIs(400); $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ 'success' => false, 'error' => 'invalid_scope', - 'parameter' => P::BLOCK_ACCOUNT, + 'parameter' => 'block_account', 'statusCode' => 400, ]); $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); diff --git a/api/tests/functional/oauth/ValidateCest.php b/api/tests/functional/oauth/ValidateCest.php index e021422..9769f92 100644 --- a/api/tests/functional/oauth/ValidateCest.php +++ b/api/tests/functional/oauth/ValidateCest.php @@ -7,8 +7,6 @@ use api\tests\FunctionalTester; class ValidateCest { - // TODO: validate case, when scopes are passed with commas - public function completelyValidateValidRequest(FunctionalTester $I) { $I->wantTo('validate and obtain information about new oauth request'); $I->sendGET('/api/oauth2/v1/validate', [ @@ -59,4 +57,57 @@ class ValidateCest { ]); } + public function unknownClientId(FunctionalTester $I) { + $I->wantTo('check behavior on invalid client id'); + $I->sendGET('/api/oauth2/v1/validate', [ + 'client_id' => 'non-exists-client', + 'redirect_uri' => 'http://some-resource.by', + 'response_type' => 'code', + ]); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'error' => 'invalid_client', + 'statusCode' => 401, + ]); + } + + public function invalidScopes(FunctionalTester $I) { + $I->wantTo('check behavior on some invalid scopes'); + $I->sendGET('/api/oauth2/v1/validate', [ + 'client_id' => 'ely', + 'redirect_uri' => 'http://ely.by', + 'response_type' => 'code', + 'scope' => 'minecraft_server_session some_wrong_scope', + ]); + $I->canSeeResponseCodeIs(400); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'error' => 'invalid_scope', + 'parameter' => 'some_wrong_scope', + 'statusCode' => 400, + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); + } + + public function requestInternalScope(FunctionalTester $I) { + $I->wantTo('check behavior on request internal scope'); + $I->sendGET('/api/oauth2/v1/validate', [ + 'client_id' => 'ely', + 'redirect_uri' => 'http://ely.by', + 'response_type' => 'code', + 'scope' => 'minecraft_server_session block_account', + ]); + $I->canSeeResponseCodeIs(400); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'error' => 'invalid_scope', + 'parameter' => 'block_account', + 'statusCode' => 400, + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); + } + } From 8a1d7148d04349c4dc7263d627882ac3b2253003 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Fri, 13 Sep 2019 01:19:03 +0300 Subject: [PATCH 03/28] Implemented public scopes repository. Fix some auth cases [skip ci] --- api/components/OAuth2/Component.php | 2 + .../Repositories/PublicScopeRepository.php | 54 +++++++++++++++++++ api/modules/oauth/models/OauthProcess.php | 8 ++- api/tests/functional/oauth/AuthCodeCest.php | 2 +- 4 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 api/components/OAuth2/Repositories/PublicScopeRepository.php diff --git a/api/components/OAuth2/Component.php b/api/components/OAuth2/Component.php index c09f939..178adce 100644 --- a/api/components/OAuth2/Component.php +++ b/api/components/OAuth2/Component.php @@ -25,6 +25,7 @@ class Component extends BaseComponent { $clientsRepo = new Repositories\ClientRepository(); $accessTokensRepo = new Repositories\AccessTokenRepository(); $scopesRepo = new Repositories\ScopeRepository(); + $publicScopesRepo = new Repositories\PublicScopeRepository(); $authCodesRepo = new Repositories\AuthCodeRepository(); $refreshTokensRepo = new Repositories\RefreshTokenRepository(); @@ -41,6 +42,7 @@ class Component extends BaseComponent { $authCodeGrant = new Grant\AuthCodeGrant($authCodesRepo, $refreshTokensRepo, new DateInterval('PT10M')); $authCodeGrant->disableRequireCodeChallengeForPublicClients(); $authServer->enableGrantType($authCodeGrant, $accessTokenTTL); + $authCodeGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling $authServer->enableGrantType(new Grant\RefreshTokenGrant($refreshTokensRepo), $accessTokenTTL); $authServer->enableGrantType(new Grant\ClientCredentialsGrant(), $accessTokenTTL); diff --git a/api/components/OAuth2/Repositories/PublicScopeRepository.php b/api/components/OAuth2/Repositories/PublicScopeRepository.php new file mode 100644 index 0000000..fe5297e --- /dev/null +++ b/api/components/OAuth2/Repositories/PublicScopeRepository.php @@ -0,0 +1,54 @@ + P::OBTAIN_OWN_ACCOUNT_INFO, + self::ACCOUNT_EMAIL => P::OBTAIN_ACCOUNT_EMAIL, + ]; + + private const ALLOWED_SCOPES = [ + P::OBTAIN_OWN_ACCOUNT_INFO, + P::OBTAIN_ACCOUNT_EMAIL, + P::MINECRAFT_SERVER_SESSION, + self::OFFLINE_ACCESS, + self::CHANGE_SKIN, + ]; + + public function getScopeEntityByIdentifier($identifier): ?ScopeEntityInterface { + $identifier = $this->convertToInternalPermission($identifier); + if (!in_array($identifier, self::ALLOWED_SCOPES, true)) { + return null; + } + + return new ScopeEntity($identifier); + } + + public function finalizeScopes( + array $scopes, + $grantType, + ClientEntityInterface $clientEntity, + $userIdentifier = null + ): array { + return $scopes; + } + + private function convertToInternalPermission(string $publicScope): string { + return self::PUBLIC_SCOPES_TO_INTERNAL_PERMISSIONS[$publicScope] ?? $publicScope; + } + +} diff --git a/api/modules/oauth/models/OauthProcess.php b/api/modules/oauth/models/OauthProcess.php index 5ed5c89..984c72e 100644 --- a/api/modules/oauth/models/OauthProcess.php +++ b/api/modules/oauth/models/OauthProcess.php @@ -107,6 +107,7 @@ class OauthProcess { } $authRequest->setUser(new UserEntity($account->id)); + $authRequest->setAuthorizationApproved(true); $responseObj = $this->server->completeAuthorizationRequest($authRequest, new Response(200)); $response = [ @@ -250,10 +251,15 @@ class OauthProcess { } private function buildErrorResponse(OAuthServerException $e): array { + $hint = $e->getPayload()['hint'] ?? ''; + if (preg_match('/the `(\w+)` scope/', $hint, $matches)) { + $parameter = $matches[1]; + } + $response = [ 'success' => false, 'error' => $e->getErrorType(), - // 'parameter' => $e->parameter, // TODO: if this is necessary, the parameter can be extracted from the hint + 'parameter' => $parameter ?? null, 'statusCode' => $e->getHttpStatusCode(), ]; diff --git a/api/tests/functional/oauth/AuthCodeCest.php b/api/tests/functional/oauth/AuthCodeCest.php index cdbae07..fba1dd7 100644 --- a/api/tests/functional/oauth/AuthCodeCest.php +++ b/api/tests/functional/oauth/AuthCodeCest.php @@ -132,7 +132,7 @@ class AuthCodeCest { $I->canSeeResponseContainsJson([ 'success' => false, 'error' => 'access_denied', - 'parameter' => '', + 'parameter' => null, 'statusCode' => 401, ]); $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); From 45101d64538df2901736f33f5e7bb087070378a9 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Wed, 18 Sep 2019 02:14:05 +0300 Subject: [PATCH 04/28] Completely restored authorization_code grant for user side. Reworked oauth_sessions table. Added extension to use MariaDB's JSON columns. Rewritten tests for authorization_code grant for client side. Deprecate some old shit. [skip ci] --- api/components/OAuth2/Component.php | 1 - .../OAuth2/Repositories/AuthCodeStorage.php | 72 --------- .../OAuth2/Repositories/ClientRepository.php | 2 - .../OAuth2/Repositories/SessionStorage.php | 97 ------------ .../AuthorizationRequestProxy.php | 97 ++++++++++++ .../ThisShouldNotHappenException.php | 2 + api/modules/oauth/models/OauthProcess.php | 110 +++++++++---- api/tests/functional/_steps/OauthSteps.php | 17 +- .../applications}/CreateClientCest.php | 4 +- .../applications}/DeleteClientCest.php | 4 +- .../applications}/GetClientsCest.php | 4 +- .../applications}/IdentityInfoCest.php | 4 +- .../applications}/ResetClientCest.php | 4 +- .../applications}/UpdateClientCest.php | 4 +- .../functional/oauth/AccessTokenCest.php | 148 ++++++++---------- api/tests/functional/oauth/AuthCodeCest.php | 6 +- common/components/Redis/Key.php | 3 + common/components/Redis/Set.php | 3 + common/db/mysql/QueryBuilder.php | 4 +- common/db/mysql/Schema.php | 12 +- common/models/Account.php | 2 +- common/models/OauthSession.php | 59 +++---- common/models/OauthSessionQuery.php | 41 ----- common/tests/fixtures/data/oauth-sessions.php | 24 +-- composer.json | 1 + composer.lock | 40 ++++- ...914_181236_rework_oauth_sessions_table.php | 57 +++++++ 27 files changed, 418 insertions(+), 404 deletions(-) delete mode 100644 api/components/OAuth2/Repositories/AuthCodeStorage.php delete mode 100644 api/components/OAuth2/Repositories/SessionStorage.php create mode 100644 api/components/OAuth2/RequestTypes/AuthorizationRequestProxy.php rename api/tests/functional/{oauth => dev/applications}/CreateClientCest.php (98%) rename api/tests/functional/{oauth => dev/applications}/DeleteClientCest.php (88%) rename api/tests/functional/{oauth => dev/applications}/GetClientsCest.php (97%) rename api/tests/functional/{oauth => dev/applications}/IdentityInfoCest.php (97%) rename api/tests/functional/{oauth => dev/applications}/ResetClientCest.php (96%) rename api/tests/functional/{oauth => dev/applications}/UpdateClientCest.php (96%) delete mode 100644 common/models/OauthSessionQuery.php create mode 100644 console/migrations/m190914_181236_rework_oauth_sessions_table.php diff --git a/api/components/OAuth2/Component.php b/api/components/OAuth2/Component.php index 178adce..0b77dee 100644 --- a/api/components/OAuth2/Component.php +++ b/api/components/OAuth2/Component.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace api\components\OAuth2; use api\components\OAuth2\Keys\EmptyKey; -use api\components\OAuth2\Repositories; use DateInterval; use League\OAuth2\Server\AuthorizationServer; use League\OAuth2\Server\Grant; diff --git a/api/components/OAuth2/Repositories/AuthCodeStorage.php b/api/components/OAuth2/Repositories/AuthCodeStorage.php deleted file mode 100644 index a083064..0000000 --- a/api/components/OAuth2/Repositories/AuthCodeStorage.php +++ /dev/null @@ -1,72 +0,0 @@ -dataTable, $code))->getValue()); - if ($result === null) { - return null; - } - - $entity = new AuthCodeEntity($this->server); - $entity->setId($result['id']); - $entity->setExpireTime($result['expire_time']); - $entity->setSessionId($result['session_id']); - $entity->setRedirectUri($result['client_redirect_uri']); - - return $entity; - } - - public function create($token, $expireTime, $sessionId, $redirectUri) { - $payload = Json::encode([ - 'id' => $token, - 'expire_time' => $expireTime, - 'session_id' => $sessionId, - 'client_redirect_uri' => $redirectUri, - ]); - - $this->key($token)->setValue($payload)->expireAt($expireTime); - } - - public function getScopes(OriginalAuthCodeEntity $token) { - $scopes = $this->scopes($token->getId()); - $scopesEntities = []; - foreach ($scopes as $scope) { - if ($this->server->getScopeStorage()->get($scope) !== null) { - $scopesEntities[] = (new ScopeEntity($this->server))->hydrate(['id' => $scope]); - } - } - - return $scopesEntities; - } - - public function associateScope(OriginalAuthCodeEntity $token, ScopeEntity $scope) { - $this->scopes($token->getId())->add($scope->getId())->expireAt($token->getExpireTime()); - } - - public function delete(OriginalAuthCodeEntity $token) { - $this->key($token->getId())->delete(); - $this->scopes($token->getId())->delete(); - } - - private function key(string $token): Key { - return new Key($this->dataTable, $token); - } - - private function scopes(string $token): Set { - return new Set($this->dataTable, $token, 'scopes'); - } - -} diff --git a/api/components/OAuth2/Repositories/ClientRepository.php b/api/components/OAuth2/Repositories/ClientRepository.php index c44b496..05d4231 100644 --- a/api/components/OAuth2/Repositories/ClientRepository.php +++ b/api/components/OAuth2/Repositories/ClientRepository.php @@ -29,8 +29,6 @@ class ClientRepository implements ClientRepositoryInterface { return false; } - // TODO: there is missing behavior of checking redirectUri. Is it now bundled into grant? - return true; } diff --git a/api/components/OAuth2/Repositories/SessionStorage.php b/api/components/OAuth2/Repositories/SessionStorage.php deleted file mode 100644 index c598db2..0000000 --- a/api/components/OAuth2/Repositories/SessionStorage.php +++ /dev/null @@ -1,97 +0,0 @@ -hydrate($this->getSessionModel($sessionId)); - } - - public function getByAccessToken(OriginalAccessTokenEntity $accessToken) { - throw new ErrorException('This method is not implemented and should not be used'); - } - - public function getByAuthCode(OriginalAuthCodeEntity $authCode) { - if (!$authCode instanceof AuthCodeEntity) { - throw new ErrorException('This module assumes that $authCode typeof ' . AuthCodeEntity::class); - } - - return $this->getById($authCode->getSessionId()); - } - - public function getScopes(OriginalSessionEntity $session) { - $result = []; - foreach ($this->getSessionModel($session->getId())->getScopes() as $scope) { - if ($this->server->getScopeStorage()->get($scope) !== null) { - $result[] = (new ScopeEntity($this->server))->hydrate(['id' => $scope]); - } - } - - return $result; - } - - public function create($ownerType, $ownerId, $clientId, $clientRedirectUri = null) { - $sessionId = OauthSession::find() - ->select('id') - ->andWhere([ - 'client_id' => $clientId, - 'owner_type' => $ownerType, - 'owner_id' => (string)$ownerId, // Casts as a string to make the indexes work, because the varchar field - ])->scalar(); - - if ($sessionId === false) { - $model = new OauthSession(); - $model->client_id = $clientId; - $model->owner_type = $ownerType; - $model->owner_id = $ownerId; - $model->client_redirect_uri = $clientRedirectUri; - - if (!$model->save()) { - throw new Exception('Cannot save ' . OauthSession::class . ' model.'); - } - - $sessionId = $model->id; - } - - return $sessionId; - } - - public function associateScope(OriginalSessionEntity $session, ScopeEntity $scope) { - $this->getSessionModel($session->getId())->getScopes()->add($scope->getId()); - } - - private function getSessionModel(string $sessionId): OauthSession { - $session = OauthSession::findOne($sessionId); - if ($session === null) { - throw new ErrorException('Cannot find oauth session'); - } - - return $session; - } - - private function hydrate(OauthSession $sessionModel) { - $entity = new SessionEntity($this->server); - $entity->setId($sessionModel->id); - $entity->setClientId($sessionModel->client_id); - $entity->setOwner($sessionModel->owner_type, $sessionModel->owner_id); - - return $entity; - } - -} diff --git a/api/components/OAuth2/RequestTypes/AuthorizationRequestProxy.php b/api/components/OAuth2/RequestTypes/AuthorizationRequestProxy.php new file mode 100644 index 0000000..0d07bc1 --- /dev/null +++ b/api/components/OAuth2/RequestTypes/AuthorizationRequestProxy.php @@ -0,0 +1,97 @@ +authorizationRequest = $authorizationRequest; + } + + public function getOriginalAuthorizationRequest(): AuthorizationRequest { + return $this->authorizationRequest; + } + + public function getGrantTypeId(): string { + return $this->authorizationRequest->getGrantTypeId(); + } + + public function setGrantTypeId($grantTypeId): void { + $this->authorizationRequest->setGrantTypeId($grantTypeId); + } + + public function getClient(): ClientEntityInterface { + return $this->authorizationRequest->getClient(); + } + + public function setClient(ClientEntityInterface $client): void { + $this->authorizationRequest->setClient($client); + } + + public function getUser(): UserEntityInterface { + return $this->authorizationRequest->getUser(); + } + + public function setUser(UserEntityInterface $user): void { + $this->authorizationRequest->setUser($user); + } + + public function getScopes(): array { + return $this->authorizationRequest->getScopes(); + } + + public function setScopes(array $scopes): void { + $this->authorizationRequest->setScopes($scopes); + } + + public function isAuthorizationApproved(): bool { + return $this->authorizationRequest->isAuthorizationApproved(); + } + + public function setAuthorizationApproved($authorizationApproved): void { + $this->authorizationRequest->setAuthorizationApproved($authorizationApproved); + } + + public function getRedirectUri(): ?string { + return $this->authorizationRequest->getRedirectUri(); + } + + public function setRedirectUri($redirectUri): void { + $this->authorizationRequest->setRedirectUri($redirectUri); + } + + public function getState(): ?string { + return $this->authorizationRequest->getState(); + } + + public function setState($state): void { + $this->authorizationRequest->setState($state); + } + + public function getCodeChallenge(): string { + return $this->authorizationRequest->getCodeChallenge(); + } + + public function setCodeChallenge($codeChallenge): void { + $this->authorizationRequest->setCodeChallenge($codeChallenge); + } + + public function getCodeChallengeMethod(): string { + return $this->authorizationRequest->getCodeChallengeMethod(); + } + + public function setCodeChallengeMethod($codeChallengeMethod): void { + $this->authorizationRequest->setCodeChallengeMethod($codeChallengeMethod); + } + +} diff --git a/api/exceptions/ThisShouldNotHappenException.php b/api/exceptions/ThisShouldNotHappenException.php index 0c18aeb..bd6921c 100644 --- a/api/exceptions/ThisShouldNotHappenException.php +++ b/api/exceptions/ThisShouldNotHappenException.php @@ -7,6 +7,8 @@ namespace api\exceptions; * The exception can be used for cases where the outcome doesn't seem to be expected, * but can theoretically happen. The goal is to capture these areas and refine the logic * if such situations do occur. + * + * @deprecated use \Webmozart\Assert\Assert to ensure, that action has been successfully performed */ class ThisShouldNotHappenException extends Exception { diff --git a/api/modules/oauth/models/OauthProcess.php b/api/modules/oauth/models/OauthProcess.php index 984c72e..43f83cc 100644 --- a/api/modules/oauth/models/OauthProcess.php +++ b/api/modules/oauth/models/OauthProcess.php @@ -7,12 +7,15 @@ use api\components\OAuth2\Entities\UserEntity; 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; use League\OAuth2\Server\RequestTypes\AuthorizationRequest; use Psr\Http\Message\ServerRequestInterface; +use Webmozart\Assert\Assert; use Yii; class OauthProcess { @@ -57,7 +60,7 @@ class OauthProcess { $clientModel = $this->findClient($client->getIdentifier()); $response = $this->buildSuccessResponse($request, $clientModel, $authRequest->getScopes()); } catch (OAuthServerException $e) { - $response = $this->buildErrorResponse($e); + $response = $this->buildCompleteErrorResponse($e); } return $response; @@ -90,29 +93,31 @@ class OauthProcess { $authRequest = $this->server->validateAuthorizationRequest($request); /** @var Account $account */ $account = Yii::$app->user->identity->getAccount(); - /** @var OauthClient $clientModel */ - $clientModel = $this->findClient($authRequest->getClient()->getIdentifier()); + /** @var OauthClient $client */ + $client = $this->findClient($authRequest->getClient()->getIdentifier()); - if (!$this->canAutoApprove($account, $clientModel, $authRequest)) { + $approved = $this->canAutoApprove($account, $client, $authRequest); + if (!$approved) { Yii::$app->statsd->inc('oauth.complete.approve_required'); - $accept = ((array)$request->getParsedBody())['accept'] ?? null; - if ($accept === null) { + $acceptParam = ((array)$request->getParsedBody())['accept'] ?? null; + if ($acceptParam === null) { throw $this->createAcceptRequiredException(); } - if (!in_array($accept, [1, '1', true, 'true'], true)) { - throw OAuthServerException::accessDenied(null, $authRequest->getRedirectUri()); + $approved = in_array($acceptParam, [1, '1', true, 'true'], true); + if ($approved) { + $this->storeOauthSession($account, $client, $authRequest); } } $authRequest->setUser(new UserEntity($account->id)); - $authRequest->setAuthorizationApproved(true); - $responseObj = $this->server->completeAuthorizationRequest($authRequest, new Response(200)); + $authRequest->setAuthorizationApproved($approved); + $response = $this->server->completeAuthorizationRequest($authRequest, new Response(200)); - $response = [ + $result = [ 'success' => true, - 'redirectUri' => $responseObj->getHeader('Location'), // TODO: ensure that this is correct type and behavior + 'redirectUri' => $response->getHeaderLine('Location'), ]; Yii::$app->statsd->inc('oauth.complete.success'); @@ -121,10 +126,10 @@ class OauthProcess { Yii::$app->statsd->inc('oauth.complete.fail'); } - $response = $this->buildErrorResponse($e); + $result = $this->buildCompleteErrorResponse($e); } - return $response; + return $result; } /** @@ -168,10 +173,7 @@ class OauthProcess { Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.fail"); Yii::$app->response->statusCode = $e->getHttpStatusCode(); - $response = [ - 'error' => $e->getErrorType(), - 'message' => $e->getMessage(), // TODO: use hint field? - ]; + $response = $this->buildIssueErrorResponse($e); } return $response; @@ -196,22 +198,31 @@ class OauthProcess { return true; } - /** @var \common\models\OauthSession|null $session */ - $session = $account->getOauthSessions()->andWhere(['client_id' => $client->id])->one(); - if ($session !== null) { - $existScopes = $session->getScopes()->members(); - if (empty(array_diff(array_keys($request->getScopes()), $existScopes))) { - return true; - } + $session = $this->findOauthSession($account, $client); + if ($session === null) { + return false; } - return false; + return empty(array_diff($this->getScopesList($request), $session->getScopes())); + } + + private function storeOauthSession(Account $account, OauthClient $client, AuthorizationRequest $request): void { + $session = $this->findOauthSession($account, $client); + if ($session === null) { + $session = new OauthSession(); + $session->account_id = $account->id; + $session->client_id = $client->id; + } + + $session->scopes = array_unique(array_merge($session->getScopes(), $this->getScopesList($request))); + + Assert::true($session->save()); } /** * @param ServerRequestInterface $request * @param OauthClient $client - * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes + * @param ScopeEntityInterface[] $scopes * * @return array */ @@ -238,7 +249,7 @@ class OauthProcess { } /** - * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes + * @param ScopeEntityInterface[] $scopes * @return array */ private function buildScopesArray(array $scopes): array { @@ -250,7 +261,7 @@ class OauthProcess { return $result; } - private function buildErrorResponse(OAuthServerException $e): array { + private function buildCompleteErrorResponse(OAuthServerException $e): array { $hint = $e->getPayload()['hint'] ?? ''; if (preg_match('/the `(\w+)` scope/', $hint, $matches)) { $parameter = $matches[1]; @@ -274,6 +285,35 @@ class OauthProcess { return $response; } + /** + * Raw error messages aren't very informative for the end user, as they don't contain + * information about the parameter that caused the error. + * This method is intended to build a more understandable description. + * + * Part of the existing texts is a legacy from the previous implementation. + * + * @param OAuthServerException $e + * @return array + */ + private function buildIssueErrorResponse(OAuthServerException $e): array { + $errorType = $e->getErrorType(); + $message = $e->getMessage(); + $hint = $e->getHint(); + switch ($hint) { + case 'Invalid redirect URI': + $errorType = 'invalid_client'; + $message = 'Client authentication failed.'; + break; + case 'Cannot decrypt the authorization code': + $message .= ' Check the "code" parameter.'; + } + + return [ + 'error' => $errorType, + 'message' => $message, + ]; + } + private function getRequest(): ServerRequestInterface { return ServerRequest::fromGlobals(); } @@ -287,4 +327,16 @@ class OauthProcess { ); } + private function getScopesList(AuthorizationRequest $request): array { + // TODO: replace with an arrow function in PHP 7.4 + return array_map(function(ScopeEntityInterface $scope): string { + return $scope->getIdentifier(); + }, $request->getScopes()); + } + + private function findOauthSession(Account $account, OauthClient $client): ?OauthSession { + /** @noinspection PhpIncompatibleReturnTypeInspection */ + return $account->getOauthSessions()->andWhere(['client_id' => $client->id])->one(); + } + } diff --git a/api/tests/functional/_steps/OauthSteps.php b/api/tests/functional/_steps/OauthSteps.php index e12d5f2..5c0559d 100644 --- a/api/tests/functional/_steps/OauthSteps.php +++ b/api/tests/functional/_steps/OauthSteps.php @@ -9,31 +9,30 @@ use api\tests\FunctionalTester; class OauthSteps extends FunctionalTester { - public function getAuthCode(array $permissions = []): string { + public function obtainAuthCode(array $permissions = []): string { $this->amAuthenticated(); - $route = new OauthRoute($this); - $route->complete([ + $this->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ 'client_id' => 'ely', 'redirect_uri' => 'http://ely.by', 'response_type' => 'code', - 'scope' => implode(',', $permissions), - ], ['accept' => true]); + 'scope' => implode(' ', $permissions), + ]), ['accept' => true]); $this->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); - $response = json_decode($this->grabResponse(), true); - preg_match('/code=([\w-]+)/', $response['redirectUri'], $matches); + [$redirectUri] = $this->grabDataFromResponseByJsonPath('$.redirectUri'); + preg_match('/code=([\w-]+)/', $redirectUri, $matches); return $matches[1]; } public function getAccessToken(array $permissions = []): string { - $authCode = $this->getAuthCode($permissions); + $authCode = $this->obtainAuthCode($permissions); $response = $this->issueToken($authCode); return $response['access_token']; } public function getRefreshToken(array $permissions = []): string { - $authCode = $this->getAuthCode(array_merge([S::OFFLINE_ACCESS], $permissions)); + $authCode = $this->obtainAuthCode(array_merge([S::OFFLINE_ACCESS], $permissions)); $response = $this->issueToken($authCode); return $response['refresh_token']; diff --git a/api/tests/functional/oauth/CreateClientCest.php b/api/tests/functional/dev/applications/CreateClientCest.php similarity index 98% rename from api/tests/functional/oauth/CreateClientCest.php rename to api/tests/functional/dev/applications/CreateClientCest.php index 2eab1a2..a5523a4 100644 --- a/api/tests/functional/oauth/CreateClientCest.php +++ b/api/tests/functional/dev/applications/CreateClientCest.php @@ -1,5 +1,7 @@ route = new OauthRoute($I); + public function successfullyIssueToken(OauthSteps $I) { + $I->wantTo('complete oauth flow and obtain access_token'); + $authCode = $I->obtainAuthCode(); + $I->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'authorization_code', + 'code' => $authCode, + 'client_id' => 'ely', + 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + 'redirect_uri' => 'http://ely.by', + ]); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseContainsJson([ + 'token_type' => 'Bearer', + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.access_token'); + $I->canSeeResponseJsonMatchesJsonPath('$.expires_in'); + $I->cantSeeResponseJsonMatchesJsonPath('$.refresh_token'); } - public function testIssueTokenWithWrongArgs(OauthSteps $I) { - $I->wantTo('check behavior on on request without any credentials'); - $this->route->issueToken(); + public function successfullyIssueOfflineToken(OauthSteps $I) { + $I->wantTo('complete oauth flow with offline_access scope and obtain access_token and refresh_token'); + $authCode = $I->obtainAuthCode(['offline_access']); + $I->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'authorization_code', + 'code' => $authCode, + 'client_id' => 'ely', + 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + 'redirect_uri' => 'http://ely.by', + ]); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseContainsJson([ + 'token_type' => 'Bearer', + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.access_token'); + $I->canSeeResponseJsonMatchesJsonPath('$.expires_in'); + $I->canSeeResponseJsonMatchesJsonPath('$.refresh_token'); + } + + public function callEndpointWithByEmptyRequest(OauthSteps $I) { + $I->wantTo('check behavior on on request without any params'); + $I->sendPOST('/api/oauth2/v1/token'); $I->canSeeResponseCodeIs(400); $I->canSeeResponseContainsJson([ - 'error' => 'invalid_request', - 'message' => 'The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. Check the "grant_type" parameter.', + 'error' => 'unsupported_grant_type', + 'message' => 'The authorization grant type is not supported by the authorization server.', ]); + } + public function issueTokenByPassingInvalidAuthCode(OauthSteps $I) { $I->wantTo('check behavior on passing invalid auth code'); - $this->route->issueToken($this->buildParams( - 'wrong-auth-code', - 'ely', - 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', - 'http://ely.by' - )); + $I->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'authorization_code', + 'code' => 'wrong-auth-code', + 'client_id' => 'ely', + 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + 'redirect_uri' => 'http://ely.by', + ]); $I->canSeeResponseCodeIs(400); $I->canSeeResponseContainsJson([ 'error' => 'invalid_request', 'message' => 'The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. Check the "code" parameter.', ]); + } - $authCode = $I->getAuthCode(); + public function issueTokenByPassingInvalidRedirectUri(OauthSteps $I) { $I->wantTo('check behavior on passing invalid redirect_uri'); - $this->route->issueToken($this->buildParams( - $authCode, - 'ely', - 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', - 'http://some-other.domain' - )); - $I->canSeeResponseCodeIs(401); + $authCode = $I->obtainAuthCode(); + $I->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'authorization_code', + 'code' => $authCode, + 'client_id' => 'ely', + 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + 'redirect_uri' => 'http://some-other.domain', + ]); + $I->canSeeResponseCodeIs(400); $I->canSeeResponseContainsJson([ 'error' => 'invalid_client', 'message' => 'Client authentication failed.', ]); } - public function testIssueToken(OauthSteps $I) { - $authCode = $I->getAuthCode(); - $this->route->issueToken($this->buildParams( - $authCode, - 'ely', - 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', - 'http://ely.by' - )); - $I->canSeeResponseCodeIs(200); - $I->canSeeResponseIsJson(); - $I->canSeeResponseContainsJson([ - 'token_type' => 'Bearer', - ]); - $I->canSeeResponseJsonMatchesJsonPath('$.access_token'); - $I->cantSeeResponseJsonMatchesJsonPath('$.refresh_token'); - $I->canSeeResponseJsonMatchesJsonPath('$.expires_in'); - } - - public function testIssueTokenWithRefreshToken(OauthSteps $I) { - $authCode = $I->getAuthCode(['offline_access']); - $this->route->issueToken($this->buildParams( - $authCode, - 'ely', - 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', - 'http://ely.by' - )); - $I->canSeeResponseCodeIs(200); - $I->canSeeResponseIsJson(); - $I->canSeeResponseContainsJson([ - 'token_type' => 'Bearer', - ]); - $I->canSeeResponseJsonMatchesJsonPath('$.access_token'); - $I->canSeeResponseJsonMatchesJsonPath('$.refresh_token'); - $I->canSeeResponseJsonMatchesJsonPath('$.expires_in'); - } - - private function buildParams($code = null, $clientId = null, $clientSecret = null, $redirectUri = null) { - $params = ['grant_type' => 'authorization_code']; - if ($code !== null) { - $params['code'] = $code; - } - - if ($clientId !== null) { - $params['client_id'] = $clientId; - } - - if ($clientSecret !== null) { - $params['client_secret'] = $clientSecret; - } - - if ($redirectUri !== null) { - $params['redirect_uri'] = $redirectUri; - } - - return $params; - } - } diff --git a/api/tests/functional/oauth/AuthCodeCest.php b/api/tests/functional/oauth/AuthCodeCest.php index fba1dd7..7041079 100644 --- a/api/tests/functional/oauth/AuthCodeCest.php +++ b/api/tests/functional/oauth/AuthCodeCest.php @@ -72,7 +72,7 @@ class AuthCodeCest { $I->canSeeResponseContainsJson([ 'success' => false, 'error' => 'accept_required', - 'parameter' => '', + 'parameter' => null, 'statusCode' => 401, ]); } @@ -90,7 +90,7 @@ class AuthCodeCest { $I->canSeeResponseContainsJson([ 'success' => false, 'error' => 'accept_required', - 'parameter' => '', + 'parameter' => null, 'statusCode' => 401, ]); } @@ -114,7 +114,7 @@ class AuthCodeCest { $I->canSeeResponseContainsJson([ 'success' => false, 'error' => 'accept_required', - 'parameter' => '', + 'parameter' => null, 'statusCode' => 401, ]); } diff --git a/common/components/Redis/Key.php b/common/components/Redis/Key.php index d48f420..c8b3be5 100644 --- a/common/components/Redis/Key.php +++ b/common/components/Redis/Key.php @@ -4,6 +4,9 @@ namespace common\components\Redis; use InvalidArgumentException; use Yii; +/** + * @deprecated + */ class Key { private $key; diff --git a/common/components/Redis/Set.php b/common/components/Redis/Set.php index b6a07ec..5023aea 100644 --- a/common/components/Redis/Set.php +++ b/common/components/Redis/Set.php @@ -5,6 +5,9 @@ use ArrayIterator; use IteratorAggregate; use Yii; +/** + * @deprecated + */ class Set extends Key implements IteratorAggregate { public function add($value): self { diff --git a/common/db/mysql/QueryBuilder.php b/common/db/mysql/QueryBuilder.php index 66c5a24..22cbbc2 100644 --- a/common/db/mysql/QueryBuilder.php +++ b/common/db/mysql/QueryBuilder.php @@ -1,8 +1,10 @@ db); + $result = new QueryBuilder($this->db); + $result->setExpressionBuilders([ + 'yii\db\JsonExpression' => JsonExpressionBuilder::class, + ]); + + return $result; } } diff --git a/common/models/Account.php b/common/models/Account.php index 49b240a..fc227b8 100644 --- a/common/models/Account.php +++ b/common/models/Account.php @@ -93,7 +93,7 @@ class Account extends ActiveRecord { } public function getOauthSessions(): ActiveQuery { - return $this->hasMany(OauthSession::class, ['owner_id' => 'id'])->andWhere(['owner_type' => 'user']); + return $this->hasMany(OauthSession::class, ['account_id' => 'id']); } public function getOauthClients(): OauthClientQuery { diff --git a/common/models/OauthSession.php b/common/models/OauthSession.php index cb98e33..2806cb7 100644 --- a/common/models/OauthSession.php +++ b/common/models/OauthSession.php @@ -1,38 +1,32 @@ TimestampBehavior::class, @@ -49,39 +43,28 @@ class OauthSession extends ActiveRecord { return $this->hasOne(Account::class, ['id' => 'owner_id']); } - public function getScopes(): Set { - return new Set(static::getDb()->getSchema()->getRawTableName(static::tableName()), $this->id, 'scopes'); - } + public function getScopes(): array { + if (empty($this->scopes) && $this->legacy_id !== null) { + return Yii::$app->redis->smembers($this->getLegacyRedisScopesKey()); + } - public function getAccessTokens() { - throw new NotSupportedException('This method is possible, but not implemented'); + return (array)$this->scopes; } public function beforeDelete(): bool { - if (!$result = parent::beforeDelete()) { - return $result; + if (!parent::beforeDelete()) { + return false; } - $this->clearScopes(); - $this->removeRefreshToken(); + if ($this->legacy_id !== null) { + Yii::$app->redis->del($this->getLegacyRedisScopesKey()); + } return true; } - public function removeRefreshToken(): void { - /** @var \api\components\OAuth2\Repositories\RefreshTokenStorage $refreshTokensStorage */ - // TODO: rework - $refreshTokensStorage = Yii::$app->oauth->getRefreshTokenStorage(); - $refreshTokensSet = $refreshTokensStorage->sessionHash($this->id); - foreach ($refreshTokensSet->members() as $refreshTokenId) { - $refreshTokensStorage->delete($refreshTokensStorage->get($refreshTokenId)); - } - - $refreshTokensSet->delete(); - } - - public function clearScopes(): void { - $this->getScopes()->delete(); + private function getLegacyRedisScopesKey(): string { + return "oauth:sessions:{$this->legacy_id}:scopes"; } } diff --git a/common/models/OauthSessionQuery.php b/common/models/OauthSessionQuery.php deleted file mode 100644 index ffd2949..0000000 --- a/common/models/OauthSessionQuery.php +++ /dev/null @@ -1,41 +0,0 @@ -primaryModel instanceof Account && $this->link === ['owner_id' => 'id']) { - $this->primaryModel->id = (string)$this->primaryModel->id; - $idHasBeenCastedToString = true; - } - - $query = parent::prepare($builder); - - if ($idHasBeenCastedToString) { - $this->primaryModel->id = (int)$this->primaryModel->id; - } - - return $query; - } - -} diff --git a/common/tests/fixtures/data/oauth-sessions.php b/common/tests/fixtures/data/oauth-sessions.php index 678833b..1075642 100644 --- a/common/tests/fixtures/data/oauth-sessions.php +++ b/common/tests/fixtures/data/oauth-sessions.php @@ -1,35 +1,27 @@ [ - 'id' => 1, - 'owner_type' => 'user', - 'owner_id' => 1, + 'account_id' => 1, 'client_id' => 'test1', - 'client_redirect_uri' => 'http://test1.net/oauth', + 'scopes' => null, 'created_at' => 1479944472, ], 'banned-account-session' => [ - 'id' => 2, - 'owner_type' => 'user', - 'owner_id' => 10, + 'account_id' => 10, 'client_id' => 'test1', - 'client_redirect_uri' => 'http://test1.net/oauth', + 'scopes' => null, 'created_at' => 1481421663, ], 'deleted-client-session' => [ - 'id' => 3, - 'owner_type' => 'user', - 'owner_id' => 1, + 'account_id' => 1, 'client_id' => 'deleted-oauth-client-with-sessions', - 'client_redirect_uri' => 'http://not-exists-site.com/oauth/ely', + 'scopes' => null, 'created_at' => 1519510065, ], 'actual-deleted-client-session' => [ - 'id' => 4, - 'owner_type' => 'user', - 'owner_id' => 2, + 'account_id' => 2, 'client_id' => 'deleted-oauth-client-with-sessions', - 'client_redirect_uri' => 'http://not-exists-site.com/oauth/ely', + 'scopes' => null, 'created_at' => 1519511568, ], ]; diff --git a/composer.json b/composer.json index fa8b7f5..d2451b9 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "nesbot/carbon": "^2.22", "paragonie/constant_time_encoding": "^2.0", "ramsey/uuid": "^3.5", + "sam-it/yii2-mariadb": "^1.1", "spomky-labs/otphp": "^9.0.2", "webmozart/assert": "^1.2.0", "yiisoft/yii2": "~2.0.20", diff --git a/composer.lock b/composer.lock index 0cfc271..d5c2a2b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "35a16287a6dc45c16e0553022aa34f5b", + "content-hash": "7ee1d380684b79ffabf92f115d7de4a8", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1767,6 +1767,44 @@ ], "time": "2018-07-19T23:38:55+00:00" }, + { + "name": "sam-it/yii2-mariadb", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/SAM-IT/yii2-mariadb.git", + "reference": "9d0551dc05332e51c4546b070e0cb221217d75d6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SAM-IT/yii2-mariadb/zipball/9d0551dc05332e51c4546b070e0cb221217d75d6", + "reference": "9d0551dc05332e51c4546b070e0cb221217d75d6", + "shasum": "" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.9", + "phpunit/phpunit": "^7.2", + "yiisoft/yii2-dev": "^2.0.15" + }, + "type": "yii2-extension", + "autoload": { + "psr-4": { + "SamIT\\Yii2\\MariaDb\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sam Mousa", + "email": "sam@mousa.nl" + } + ], + "description": "MariaDB Driver for Yii2", + "time": "2019-04-23T13:27:38+00:00" + }, { "name": "sentry/sentry", "version": "1.10.0", diff --git a/console/migrations/m190914_181236_rework_oauth_sessions_table.php b/console/migrations/m190914_181236_rework_oauth_sessions_table.php new file mode 100644 index 0000000..20cf5c5 --- /dev/null +++ b/console/migrations/m190914_181236_rework_oauth_sessions_table.php @@ -0,0 +1,57 @@ +delete('oauth_sessions', ['NOT', ['owner_type' => 'user']]); + $this->dropColumn('oauth_sessions', 'owner_type'); + $this->dropColumn('oauth_sessions', 'client_redirect_uri'); + $this->execute(' + DELETE os1 + FROM oauth_sessions os1, + oauth_sessions os2 + WHERE os1.id > os2.id + AND os1.owner_id = os2.owner_id + AND os1.client_id = os2.client_id + '); + $this->dropIndex('owner_id', 'oauth_sessions'); + $this->renameColumn('oauth_sessions', 'owner_id', 'account_id'); + $this->alterColumn('oauth_sessions', 'account_id', $this->db->getTableSchema('accounts')->getColumn('id')->dbType . ' NOT NULL'); + $this->alterColumn('oauth_sessions', 'client_id', $this->db->getTableSchema('oauth_clients')->getColumn('id')->dbType . ' NOT NULL'); + // Change type to be able to remove primary key + $this->alterColumn('oauth_sessions', 'id', $this->integer(11)->unsigned()->after('client_id')); + $this->dropPrimaryKey('PRIMARY', 'oauth_sessions'); + // Change type again to make column nullable + $this->alterColumn('oauth_sessions', 'id', $this->integer(11)->unsigned()->after('client_id')); + $this->renameColumn('oauth_sessions', 'id', 'legacy_id'); + $this->addPrimaryKey('id', 'oauth_sessions', ['account_id', 'client_id']); + $this->dropForeignKey('FK_oauth_session_to_client', 'oauth_sessions'); + $this->dropIndex('FK_oauth_session_to_client', 'oauth_sessions'); + $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`'); + } + + public function safeDown() { + $this->dropColumn('oauth_sessions', 'scopes'); + $this->dropForeignKey('FK_oauth_session_to_oauth_client', 'oauth_sessions'); + $this->dropForeignKey('FK_oauth_session_to_account', 'oauth_sessions'); + $this->dropIndex('FK_oauth_session_to_oauth_client', 'oauth_sessions'); + $this->dropPrimaryKey('PRIMARY', 'oauth_sessions'); + $this->delete('oauth_sessions', ['legacy_id' => null]); + $this->alterColumn('oauth_sessions', 'legacy_id', $this->integer(11)->unsigned()->notNull()->append('AUTO_INCREMENT PRIMARY KEY FIRST')); + $this->renameColumn('oauth_sessions', 'legacy_id', 'id'); + $this->alterColumn('oauth_sessions', 'client_id', $this->db->getTableSchema('oauth_clients')->getColumn('id')->dbType); + $this->alterColumn('oauth_sessions', 'account_id', $this->string()); + $this->renameColumn('oauth_sessions', 'account_id', 'owner_id'); + $this->createIndex('owner_id', 'oauth_sessions', 'owner_id'); + $this->addColumn('oauth_sessions', 'owner_type', $this->string()->notNull()->after('id')); + $this->update('oauth_sessions', ['owner_type' => 'user']); + $this->addColumn('oauth_sessions', 'client_redirect_uri', $this->string()->after('client_id')); + $this->addForeignKey('FK_oauth_session_to_client', 'oauth_sessions', 'client_id', 'oauth_clients', 'id', 'CASCADE', 'CASCADE'); + } + +} From 5536c34b9c9e5bac992eeac4bf575cb77e757e15 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sun, 22 Sep 2019 00:17:21 +0300 Subject: [PATCH 05/28] Restore full functionality of OAuth2 server [skip ci] --- api/components/OAuth2/Component.php | 19 +- .../OAuth2/Entities/AccessTokenEntity.php | 50 ++-- .../OAuth2/Entities/AuthCodeEntity.php | 2 - .../OAuth2/Entities/ClientEntity.php | 17 +- .../OAuth2/Entities/SessionEntity.php | 27 -- api/components/OAuth2/Entities/UserEntity.php | 2 +- .../OAuth2/Grants/AuthCodeGrant.php | 242 +----------------- .../OAuth2/Grants/AuthorizeParams.php | 58 ----- .../OAuth2/Grants/ClientCredentialsGrant.php | 86 ------- .../OAuth2/Grants/RefreshTokenGrant.php | 184 +------------ api/components/OAuth2/Keys/EmptyKey.php | 2 +- .../Repositories/AccessTokenRepository.php | 49 ++-- .../OAuth2/Repositories/ClientRepository.php | 4 + .../OAuth2/Repositories/ClientStorage.php | 80 ------ .../Repositories/EmptyScopeRepository.php | 30 +++ .../Repositories/InternalScopeRepository.php | 54 ++++ .../Repositories/PublicScopeRepository.php | 3 +- .../Repositories/RefreshTokenRepository.php | 50 ++-- .../OAuth2/Repositories/ScopeRepository.php | 37 --- .../OAuth2/Repositories/ScopeStorage.php | 93 ------- .../OAuth2/Traits/ValidateScopesTrait.php | 12 + api/components/Tokens/TokensFactory.php | 28 +- api/modules/oauth/models/OauthProcess.php | 17 +- api/tests/_pages/OauthRoute.php | 22 +- api/tests/functional/_steps/OauthSteps.php | 19 +- .../functional/oauth/AccessTokenCest.php | 4 +- api/tests/functional/oauth/AuthCodeCest.php | 2 +- .../oauth/ClientCredentialsCest.php | 155 +++++------ .../functional/oauth/RefreshTokenCest.php | 156 +++++------ common/models/Account.php | 5 + common/models/OauthClient.php | 11 +- common/models/OauthOwnerType.php | 23 -- common/models/OauthRefreshToken.php | 50 ++++ common/models/OauthSession.php | 9 +- common/tests/_support/FixtureHelper.php | 25 +- .../fixtures/OauthRefreshTokensFixture.php | 19 ++ common/tests/fixtures/OauthSessionFixture.php | 2 + .../fixtures/data/oauth-refresh-tokens.php | 2 + ...14_181236_rework_oauth_related_tables.php} | 13 +- 39 files changed, 506 insertions(+), 1157 deletions(-) delete mode 100644 api/components/OAuth2/Entities/SessionEntity.php delete mode 100644 api/components/OAuth2/Grants/AuthorizeParams.php delete mode 100644 api/components/OAuth2/Grants/ClientCredentialsGrant.php delete mode 100644 api/components/OAuth2/Repositories/ClientStorage.php create mode 100644 api/components/OAuth2/Repositories/EmptyScopeRepository.php create mode 100644 api/components/OAuth2/Repositories/InternalScopeRepository.php delete mode 100644 api/components/OAuth2/Repositories/ScopeRepository.php delete mode 100644 api/components/OAuth2/Repositories/ScopeStorage.php create mode 100644 api/components/OAuth2/Traits/ValidateScopesTrait.php delete mode 100644 common/models/OauthOwnerType.php create mode 100644 common/models/OauthRefreshToken.php create mode 100644 common/tests/fixtures/OauthRefreshTokensFixture.php create mode 100644 common/tests/fixtures/data/oauth-refresh-tokens.php rename console/migrations/{m190914_181236_rework_oauth_sessions_table.php => m190914_181236_rework_oauth_related_tables.php} (81%) diff --git a/api/components/OAuth2/Component.php b/api/components/OAuth2/Component.php index 0b77dee..83f1a3e 100644 --- a/api/components/OAuth2/Component.php +++ b/api/components/OAuth2/Component.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace api\components\OAuth2; +use api\components\OAuth2\Grants\AuthCodeGrant; +use api\components\OAuth2\Grants\RefreshTokenGrant; use api\components\OAuth2\Keys\EmptyKey; use DateInterval; use League\OAuth2\Server\AuthorizationServer; @@ -23,8 +25,8 @@ class Component extends BaseComponent { if ($this->_authServer === null) { $clientsRepo = new Repositories\ClientRepository(); $accessTokensRepo = new Repositories\AccessTokenRepository(); - $scopesRepo = new Repositories\ScopeRepository(); $publicScopesRepo = new Repositories\PublicScopeRepository(); + $internalScopesRepo = new Repositories\InternalScopeRepository(); $authCodesRepo = new Repositories\AuthCodeRepository(); $refreshTokensRepo = new Repositories\RefreshTokenRepository(); @@ -33,17 +35,24 @@ class Component extends BaseComponent { $authServer = new AuthorizationServer( $clientsRepo, $accessTokensRepo, - $scopesRepo, + new Repositories\EmptyScopeRepository(), new EmptyKey(), '123' // TODO: extract to the variable ); /** @noinspection PhpUnhandledExceptionInspection */ - $authCodeGrant = new Grant\AuthCodeGrant($authCodesRepo, $refreshTokensRepo, new DateInterval('PT10M')); + $authCodeGrant = new AuthCodeGrant($authCodesRepo, $refreshTokensRepo, new DateInterval('PT10M')); $authCodeGrant->disableRequireCodeChallengeForPublicClients(); $authServer->enableGrantType($authCodeGrant, $accessTokenTTL); $authCodeGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling - $authServer->enableGrantType(new Grant\RefreshTokenGrant($refreshTokensRepo), $accessTokenTTL); - $authServer->enableGrantType(new Grant\ClientCredentialsGrant(), $accessTokenTTL); + + // TODO: extends refresh token life time to forever + $refreshTokenGrant = new RefreshTokenGrant($refreshTokensRepo); + $authServer->enableGrantType($refreshTokenGrant, $accessTokenTTL); + $refreshTokenGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling + + $clientCredentialsGrant = new Grant\ClientCredentialsGrant(); + $authServer->enableGrantType($clientCredentialsGrant, $accessTokenTTL); + $clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling $this->_authServer = $authServer; } diff --git a/api/components/OAuth2/Entities/AccessTokenEntity.php b/api/components/OAuth2/Entities/AccessTokenEntity.php index ad876ed..f9441fe 100644 --- a/api/components/OAuth2/Entities/AccessTokenEntity.php +++ b/api/components/OAuth2/Entities/AccessTokenEntity.php @@ -1,44 +1,32 @@ sessionId; +class AccessTokenEntity implements AccessTokenEntityInterface { + use EntityTrait; + use TokenEntityTrait { + getExpiryDateTime as parentGetExpiryDateTime; } - public function setSessionId($sessionId) { - $this->sessionId = $sessionId; + public function __toString(): string { + // TODO: strip "offline_access" scope from the scopes list + return (string)TokensFactory::createForOAuthClient($this); } - /** - * @inheritdoc - * @return static - */ - public function setSession(OriginalSessionEntity $session) { - parent::setSession($session); - $this->sessionId = $session->getId(); - - return $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 getSession() { - if ($this->session instanceof OriginalSessionEntity) { - return $this->session; - } - - $sessionStorage = $this->server->getSessionStorage(); - if (!$sessionStorage instanceof SessionStorage) { - throw new ErrorException('SessionStorage must be instance of ' . SessionStorage::class); - } - - return $sessionStorage->getById($this->sessionId); + public function getExpiryDateTime() { + // TODO: extend token life depending on scopes list + return $this->parentGetExpiryDateTime(); } } diff --git a/api/components/OAuth2/Entities/AuthCodeEntity.php b/api/components/OAuth2/Entities/AuthCodeEntity.php index 93c484c..1db3362 100644 --- a/api/components/OAuth2/Entities/AuthCodeEntity.php +++ b/api/components/OAuth2/Entities/AuthCodeEntity.php @@ -13,6 +13,4 @@ class AuthCodeEntity implements AuthCodeEntityInterface { use AuthCodeTrait; use TokenEntityTrait; - // TODO: constructor - } diff --git a/api/components/OAuth2/Entities/ClientEntity.php b/api/components/OAuth2/Entities/ClientEntity.php index be9f68a..36374ad 100644 --- a/api/components/OAuth2/Entities/ClientEntity.php +++ b/api/components/OAuth2/Entities/ClientEntity.php @@ -11,11 +11,24 @@ class ClientEntity implements ClientEntityInterface { use EntityTrait; use ClientTrait; - public function __construct(string $id, string $name, $redirectUri, bool $isTrusted = false) { + /** + * @var bool + */ + private $isTrusted; + + public function __construct(string $id, string $name, $redirectUri, bool $isTrusted) { $this->identifier = $id; $this->name = $name; $this->redirectUri = $redirectUri; - $this->isConfidential = $isTrusted; + $this->isTrusted = $isTrusted; + } + + public function isConfidential(): bool { + return true; + } + + public function isTrusted(): bool { + return $this->isTrusted; } } diff --git a/api/components/OAuth2/Entities/SessionEntity.php b/api/components/OAuth2/Entities/SessionEntity.php deleted file mode 100644 index eea6fb3..0000000 --- a/api/components/OAuth2/Entities/SessionEntity.php +++ /dev/null @@ -1,27 +0,0 @@ -clientId; - } - - public function associateClient(OriginalClientEntity $client) { - parent::associateClient($client); - $this->clientId = $client->getId(); - - return $this; - } - - public function setClientId(string $clientId) { - $this->clientId = $clientId; - } - -} diff --git a/api/components/OAuth2/Entities/UserEntity.php b/api/components/OAuth2/Entities/UserEntity.php index 9101263..d8a2bca 100644 --- a/api/components/OAuth2/Entities/UserEntity.php +++ b/api/components/OAuth2/Entities/UserEntity.php @@ -9,7 +9,7 @@ use League\OAuth2\Server\Entities\UserEntityInterface; class UserEntity implements UserEntityInterface { use EntityTrait; - public function __construct($id) { + public function __construct(int $id) { $this->identifier = $id; } diff --git a/api/components/OAuth2/Grants/AuthCodeGrant.php b/api/components/OAuth2/Grants/AuthCodeGrant.php index 35f66a0..5cc1ea7 100644 --- a/api/components/OAuth2/Grants/AuthCodeGrant.php +++ b/api/components/OAuth2/Grants/AuthCodeGrant.php @@ -1,239 +1,23 @@ authTokenTTL = $authTokenTTL; - } - - public function setRequireClientSecret(bool $required): void { - $this->requireClientSecret = $required; - } - - public function shouldRequireClientSecret(): bool { - return $this->requireClientSecret; - } - - /** - * Check authorize parameters - * - * @return AuthorizeParams Authorize request parameters - * @throws Exception\OAuthException - * - * @throws - */ - public function checkAuthorizeParams(): AuthorizeParams { - // Get required params - $clientId = $this->server->getRequest()->query->get('client_id'); - if ($clientId === null) { - throw new Exception\InvalidRequestException('client_id'); + protected function issueRefreshToken(AccessTokenEntityInterface $accessToken): ?RefreshTokenEntityInterface { + foreach ($accessToken->getScopes() as $scope) { + if ($scope->getIdentifier() === PublicScopeRepository::OFFLINE_ACCESS) { + return parent::issueRefreshToken($accessToken); + } } - $redirectUri = $this->server->getRequest()->query->get('redirect_uri'); - if ($redirectUri === null) { - throw new Exception\InvalidRequestException('redirect_uri'); - } - - // Validate client ID and redirect URI - $client = $this->server->getClientStorage()->get($clientId, null, $redirectUri, $this->getIdentifier()); - if (!$client instanceof ClientEntity) { - $this->server->getEventEmitter()->emit(new ClientAuthenticationFailedEvent($this->server->getRequest())); - throw new Exception\InvalidClientException(); - } - - $state = $this->server->getRequest()->query->get('state'); - if ($state === null && $this->server->stateParamRequired()) { - throw new Exception\InvalidRequestException('state', $redirectUri); - } - - $responseType = $this->server->getRequest()->query->get('response_type'); - if ($responseType === null) { - throw new Exception\InvalidRequestException('response_type', $redirectUri); - } - - // Ensure response type is one that is recognised - if (!in_array($responseType, $this->server->getResponseTypes(), true)) { - throw new Exception\UnsupportedResponseTypeException($responseType, $redirectUri); - } - - // Validate any scopes that are in the request - $scopeParam = $this->server->getRequest()->query->get('scope', ''); - $scopes = $this->validateScopes($scopeParam, $client, $redirectUri); - - return new AuthorizeParams($client, $redirectUri, $state, $responseType, $scopes); - } - - /** - * Parse a new authorize request - * - * @param string $type The session owner's type - * @param string $typeId The session owner's ID - * @param AuthorizeParams $authParams The authorize request $_GET parameters - * - * @return string An authorisation code - */ - public function newAuthorizeRequest(string $type, string $typeId, AuthorizeParams $authParams): string { - // Create a new session - $session = new SessionEntity($this->server); - $session->setOwner($type, $typeId); - $session->associateClient($authParams->getClient()); - - // Create a new auth code - $authCode = new AuthCodeEntity($this->server); - $authCode->setId(SecureKey::generate()); - $authCode->setRedirectUri($authParams->getRedirectUri()); - $authCode->setExpireTime(time() + $this->authTokenTTL); - - foreach ($authParams->getScopes() as $scope) { - $authCode->associateScope($scope); - $session->associateScope($scope); - } - - $session->save(); - $authCode->setSession($session); - $authCode->save(); - - return $authCode->generateRedirectUri($authParams->getState()); - } - - /** - * Complete the auth code grant - * - * @return array - * - * @throws Exception\OAuthException - */ - public function completeFlow(): array { - // Get the required params - $clientId = $this->server->getRequest()->request->get('client_id', $this->server->getRequest()->getUser()); - if ($clientId === null) { - throw new Exception\InvalidRequestException('client_id'); - } - - $clientSecret = $this->server->getRequest()->request->get( - 'client_secret', - $this->server->getRequest()->getPassword() - ); - if ($clientSecret === null && $this->shouldRequireClientSecret()) { - throw new Exception\InvalidRequestException('client_secret'); - } - - $redirectUri = $this->server->getRequest()->request->get('redirect_uri'); - if ($redirectUri === null) { - throw new Exception\InvalidRequestException('redirect_uri'); - } - - // Validate client ID and client secret - $client = $this->server->getClientStorage()->get($clientId, $clientSecret, $redirectUri, $this->getIdentifier()); - if (!$client instanceof BaseClientEntity) { - $this->server->getEventEmitter()->emit(new ClientAuthenticationFailedEvent($this->server->getRequest())); - throw new Exception\InvalidClientException(); - } - - // Validate the auth code - $authCode = $this->server->getRequest()->request->get('code'); - if ($authCode === null) { - throw new Exception\InvalidRequestException('code'); - } - - $code = $this->server->getAuthCodeStorage()->get($authCode); - if (($code instanceof BaseAuthCodeEntity) === false) { - throw new Exception\InvalidRequestException('code'); - } - - // Ensure the auth code hasn't expired - if ($code->isExpired()) { - throw new Exception\InvalidRequestException('code'); - } - - // Check redirect URI presented matches redirect URI originally used in authorize request - if ($code->getRedirectUri() !== $redirectUri) { - throw new Exception\InvalidRequestException('redirect_uri'); - } - - $session = $code->getSession(); - $session->associateClient($client); - - $authCodeScopes = $code->getScopes(); - - // Generate the access token - $accessToken = new AccessTokenEntity($this->server); - $accessToken->setId(SecureKey::generate()); - $accessToken->setExpireTime($this->getAccessTokenTTL() + time()); - - foreach ($authCodeScopes as $authCodeScope) { - $session->associateScope($authCodeScope); - } - - foreach ($session->getScopes() as $scope) { - $accessToken->associateScope($scope); - } - - $this->server->getTokenType()->setSession($session); - $this->server->getTokenType()->setParam('access_token', $accessToken->getId()); - $this->server->getTokenType()->setParam('expires_in', $this->getAccessTokenTTL()); - - // Set refresh_token param only in case when offline_access requested - if (isset($accessToken->getScopes()[ScopeStorage::OFFLINE_ACCESS])) { - /** @var RefreshTokenGrant $refreshTokenGrant */ - $refreshTokenGrant = $this->server->getGrantType('refresh_token'); - $refreshToken = new RefreshTokenEntity($this->server); - $refreshToken->setId(SecureKey::generate()); - $refreshToken->setExpireTime($refreshTokenGrant->getRefreshTokenTTL() + time()); - $this->server->getTokenType()->setParam('refresh_token', $refreshToken->getId()); - } - - // Expire the auth code - $code->expire(); - - // Save all the things - $accessToken->setSession($session); - $accessToken->save(); - - if (isset($refreshToken)) { - $refreshToken->setAccessToken($accessToken); - $refreshToken->save(); - } - - return $this->server->getTokenType()->generateResponse(); - } - - /** - * In the earlier versions of Accounts Ely.by backend we had a comma-separated scopes - * list, while by OAuth2 standard it they should be separated by a space. Shit happens :) - * So override scopes validation function to reformat passed value. - * - * @param string $scopeParam - * @param BaseClientEntity $client - * @param string $redirectUri - * - * @return \League\OAuth2\Server\Entity\ScopeEntity[] - */ - public function validateScopes($scopeParam = '', BaseClientEntity $client, $redirectUri = null) { - return parent::validateScopes(Scopes::format($scopeParam), $client, $redirectUri); + return null; } } diff --git a/api/components/OAuth2/Grants/AuthorizeParams.php b/api/components/OAuth2/Grants/AuthorizeParams.php deleted file mode 100644 index c47e90a..0000000 --- a/api/components/OAuth2/Grants/AuthorizeParams.php +++ /dev/null @@ -1,58 +0,0 @@ -client = $client; - $this->redirectUri = $redirectUri; - $this->state = $state; - $this->responseType = $responseType; - $this->scopes = $scopes; - } - - public function getClient(): ClientEntity { - return $this->client; - } - - public function getRedirectUri(): string { - return $this->redirectUri; - } - - public function getState(): ?string { - return $this->state; - } - - public function getResponseType(): string { - return $this->responseType; - } - - /** - * @return \api\components\OAuth2\Entities\ScopeEntity[] - */ - public function getScopes(): array { - return $this->scopes ?? []; - } - -} diff --git a/api/components/OAuth2/Grants/ClientCredentialsGrant.php b/api/components/OAuth2/Grants/ClientCredentialsGrant.php deleted file mode 100644 index 06db2d5..0000000 --- a/api/components/OAuth2/Grants/ClientCredentialsGrant.php +++ /dev/null @@ -1,86 +0,0 @@ -server->getRequest()->request->get('client_id', $this->server->getRequest()->getUser()); - if ($clientId === null) { - throw new Exception\InvalidRequestException('client_id'); - } - - $clientSecret = $this->server->getRequest()->request->get('client_secret'); - if ($clientSecret === null) { - throw new Exception\InvalidRequestException('client_secret'); - } - - // Validate client ID and client secret - $client = $this->server->getClientStorage()->get($clientId, $clientSecret, null, $this->getIdentifier()); - if (!$client instanceof BaseClientEntity) { - $this->server->getEventEmitter()->emit(new Event\ClientAuthenticationFailedEvent($this->server->getRequest())); - throw new Exception\InvalidClientException(); - } - - // Validate any scopes that are in the request - $scopeParam = $this->server->getRequest()->request->get('scope', ''); - $scopes = $this->validateScopes($scopeParam, $client); - - // Create a new session - $session = new SessionEntity($this->server); - $session->setOwner('client', $client->getId()); - $session->associateClient($client); - - // Generate an access token - $accessToken = new AccessTokenEntity($this->server); - $accessToken->setId(SecureKey::generate()); - $accessToken->setExpireTime($this->getAccessTokenTTL() + time()); - - // Associate scopes with the session and access token - foreach ($scopes as $scope) { - $session->associateScope($scope); - $accessToken->associateScope($scope); - } - - // Save everything - $session->save(); - $accessToken->setSession($session); - $accessToken->save(); - - $this->server->getTokenType()->setSession($session); - $this->server->getTokenType()->setParam('access_token', $accessToken->getId()); - $this->server->getTokenType()->setParam('expires_in', $this->getAccessTokenTTL()); - - return $this->server->getTokenType()->generateResponse(); - } - - /** - * In the earlier versions of Accounts Ely.by backend we had a comma-separated scopes - * list, while by OAuth2 standard it they should be separated by a space. Shit happens :) - * So override scopes validation function to reformat passed value. - * - * @param string $scopeParam - * @param BaseClientEntity $client - * @param string $redirectUri - * - * @return \League\OAuth2\Server\Entity\ScopeEntity[] - */ - public function validateScopes($scopeParam = '', BaseClientEntity $client, $redirectUri = null) { - return parent::validateScopes(Scopes::format($scopeParam), $client, $redirectUri); - } - -} diff --git a/api/components/OAuth2/Grants/RefreshTokenGrant.php b/api/components/OAuth2/Grants/RefreshTokenGrant.php index 4f20ad9..13ceb58 100644 --- a/api/components/OAuth2/Grants/RefreshTokenGrant.php +++ b/api/components/OAuth2/Grants/RefreshTokenGrant.php @@ -1,183 +1,25 @@ refreshTokenTTL = $refreshTokenTTL; - } - - public function getRefreshTokenTTL(): int { - return $this->refreshTokenTTL; - } - - public function setRefreshTokenRotation(bool $refreshTokenRotate = true): void { - $this->refreshTokenRotate = $refreshTokenRotate; - } - - public function shouldRotateRefreshTokens(): bool { - return $this->refreshTokenRotate; - } - - public function setRequireClientSecret(string $required): void { - $this->requireClientSecret = $required; - } - - public function shouldRequireClientSecret(): bool { - return $this->requireClientSecret; - } +class RefreshTokenGrant extends BaseRefreshTokenGrant { /** - * In the earlier versions of Accounts Ely.by backend we had a comma-separated scopes - * list, while by OAuth2 standard it they should be separated by a space. Shit happens :) - * So override scopes validation function to reformat passed value. + * Currently we're not rotating refresh tokens. + * So we overriding this method to always return null, which means, + * that refresh_token will not be issued. * - * @param string $scopeParam - * @param BaseClientEntity $client - * @param string $redirectUri + * @param AccessTokenEntityInterface $accessToken * - * @return \League\OAuth2\Server\Entity\ScopeEntity[] + * @return RefreshTokenEntityInterface|null */ - public function validateScopes($scopeParam = '', BaseClientEntity $client, $redirectUri = null) { - return parent::validateScopes(Scopes::format($scopeParam), $client, $redirectUri); - } - - /** - * The method has been overridden because we stores access_tokens in Redis with expire value, - * so they might not exists at the moment, when it will be requested via refresh_token. - * That's why we extends RefreshTokenEntity to give it knowledge about related session. - * - * @inheritdoc - * @throws \League\OAuth2\Server\Exception\OAuthException - */ - public function completeFlow(): array { - $clientId = $this->server->getRequest()->request->get('client_id', $this->server->getRequest()->getUser()); - if ($clientId === null) { - throw new Exception\InvalidRequestException('client_id'); - } - - $clientSecret = $this->server->getRequest()->request->get( - 'client_secret', - $this->server->getRequest()->getPassword() - ); - if ($clientSecret === null && $this->shouldRequireClientSecret()) { - throw new Exception\InvalidRequestException('client_secret'); - } - - // Validate client ID and client secret - $client = $this->server->getClientStorage()->get($clientId, $clientSecret, null, $this->getIdentifier()); - if (($client instanceof BaseClientEntity) === false) { - $this->server->getEventEmitter()->emit(new ClientAuthenticationFailedEvent($this->server->getRequest())); - throw new Exception\InvalidClientException(); - } - - $oldRefreshTokenParam = $this->server->getRequest()->request->get('refresh_token'); - if ($oldRefreshTokenParam === null) { - throw new Exception\InvalidRequestException('refresh_token'); - } - - // Validate refresh token - $oldRefreshToken = $this->server->getRefreshTokenStorage()->get($oldRefreshTokenParam); - if (($oldRefreshToken instanceof BaseRefreshTokenEntity) === false) { - throw new Exception\InvalidRefreshException(); - } - - // Ensure the old refresh token hasn't expired - if ($oldRefreshToken->isExpired()) { - throw new Exception\InvalidRefreshException(); - } - - /** @var AccessTokenEntity|null $oldAccessToken */ - $oldAccessToken = $oldRefreshToken->getAccessToken(); - if ($oldAccessToken instanceof AccessTokenEntity) { - // Get the scopes for the original session - $session = $oldAccessToken->getSession(); - } else { - if (!$oldRefreshToken instanceof RefreshTokenEntity) { - /** @noinspection ExceptionsAnnotatingAndHandlingInspection */ - throw new ErrorException('oldRefreshToken must be instance of ' . RefreshTokenEntity::class); - } - - $session = $oldRefreshToken->getSession(); - } - - $scopes = $this->formatScopes($session->getScopes()); - - // Get and validate any requested scopes - $requestedScopesString = $this->server->getRequest()->request->get('scope', ''); - $requestedScopes = $this->validateScopes($requestedScopesString, $client); - - // If no new scopes are requested then give the access token the original session scopes - if (count($requestedScopes) === 0) { - $newScopes = $scopes; - } else { - // The OAuth spec says that a refreshed access token can have the original scopes or fewer so ensure - // the request doesn't include any new scopes - foreach ($requestedScopes as $requestedScope) { - if (!isset($scopes[$requestedScope->getId()])) { - throw new Exception\InvalidScopeException($requestedScope->getId()); - } - } - - $newScopes = $requestedScopes; - } - - // Generate a new access token and assign it the correct sessions - $newAccessToken = new AccessTokenEntity($this->server); - $newAccessToken->setId(SecureKey::generate()); - $newAccessToken->setExpireTime($this->getAccessTokenTTL() + time()); - $newAccessToken->setSession($session); - - foreach ($newScopes as $newScope) { - $newAccessToken->associateScope($newScope); - } - - // Expire the old token and save the new one - $oldAccessToken instanceof BaseAccessTokenEntity && $oldAccessToken->expire(); - $newAccessToken->save(); - - $this->server->getTokenType()->setSession($session); - $this->server->getTokenType()->setParam('access_token', $newAccessToken->getId()); - $this->server->getTokenType()->setParam('expires_in', $this->getAccessTokenTTL()); - - if ($this->shouldRotateRefreshTokens()) { - // Expire the old refresh token - $oldRefreshToken->expire(); - - // Generate a new refresh token - $newRefreshToken = new RefreshTokenEntity($this->server); - $newRefreshToken->setId(SecureKey::generate()); - $newRefreshToken->setExpireTime($this->getRefreshTokenTTL() + time()); - $newRefreshToken->setAccessToken($newAccessToken); - $newRefreshToken->save(); - - $this->server->getTokenType()->setParam('refresh_token', $newRefreshToken->getId()); - } else { - $oldRefreshToken->setAccessToken($newAccessToken); - $oldRefreshToken->save(); - } - - return $this->server->getTokenType()->generateResponse(); + protected function issueRefreshToken(AccessTokenEntityInterface $accessToken): ?RefreshTokenEntityInterface { + return null; } } diff --git a/api/components/OAuth2/Keys/EmptyKey.php b/api/components/OAuth2/Keys/EmptyKey.php index daf017e..bf2f8f8 100644 --- a/api/components/OAuth2/Keys/EmptyKey.php +++ b/api/components/OAuth2/Keys/EmptyKey.php @@ -12,7 +12,7 @@ class EmptyKey implements CryptKeyInterface { } public function getPassPhrase(): ?string { - return ''; + return null; } } diff --git a/api/components/OAuth2/Repositories/AccessTokenRepository.php b/api/components/OAuth2/Repositories/AccessTokenRepository.php index d0d2a78..edc03ec 100644 --- a/api/components/OAuth2/Repositories/AccessTokenRepository.php +++ b/api/components/OAuth2/Repositories/AccessTokenRepository.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace api\components\OAuth2\Repositories; +use api\components\OAuth2\Entities\AccessTokenEntity; use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; @@ -13,44 +14,36 @@ class AccessTokenRepository implements AccessTokenRepositoryInterface { * Create a new access token * * @param ClientEntityInterface $clientEntity - * @param \League\OAuth2\Server\Entities\ScopeEntityInterface $scopes + * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes * @param mixed $userIdentifier * * @return AccessTokenEntityInterface */ - public function getNewToken(ClientEntityInterface $clientEntity, array $scopes, $userIdentifier = null) { - // TODO: Implement getNewToken() method. + public function getNewToken( + ClientEntityInterface $clientEntity, + array $scopes, + $userIdentifier = null + ): AccessTokenEntityInterface { + $accessToken = new AccessTokenEntity(); + $accessToken->setClient($clientEntity); + array_map([$accessToken, 'addScope'], $scopes); + if ($userIdentifier !== null) { + $accessToken->setUserIdentifier($userIdentifier); + } + + return $accessToken; } - /** - * Persists a new access token to permanent storage. - * - * @param AccessTokenEntityInterface $accessTokenEntity - * - * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException - */ - public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity) { - // TODO: Implement persistNewAccessToken() method. + public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity): void { + // We don't store access tokens, so there's no need to do anything here } - /** - * Revoke an access token. - * - * @param string $tokenId - */ - public function revokeAccessToken($tokenId) { - // TODO: Implement revokeAccessToken() method. + public function revokeAccessToken($tokenId): void { + // We don't store access tokens, so there's no need to do anything here } - /** - * Check if the access token has been revoked. - * - * @param string $tokenId - * - * @return bool Return true if this token has been revoked - */ - public function isAccessTokenRevoked($tokenId) { - // TODO: Implement isAccessTokenRevoked() method. + public function isAccessTokenRevoked($tokenId): bool { + return false; } } diff --git a/api/components/OAuth2/Repositories/ClientRepository.php b/api/components/OAuth2/Repositories/ClientRepository.php index 05d4231..abc0d43 100644 --- a/api/components/OAuth2/Repositories/ClientRepository.php +++ b/api/components/OAuth2/Repositories/ClientRepository.php @@ -25,6 +25,10 @@ class ClientRepository implements ClientRepositoryInterface { return false; } + if ($client->type !== OauthClient::TYPE_APPLICATION) { + return false; + } + if ($clientSecret !== null && $clientSecret !== $client->secret) { return false; } diff --git a/api/components/OAuth2/Repositories/ClientStorage.php b/api/components/OAuth2/Repositories/ClientStorage.php deleted file mode 100644 index 878d97a..0000000 --- a/api/components/OAuth2/Repositories/ClientStorage.php +++ /dev/null @@ -1,80 +0,0 @@ -findClient($clientId); - if ($model === null) { - return null; - } - - if ($clientSecret !== null && $clientSecret !== $model->secret) { - return null; - } - - // TODO: should check application type - // For "desktop" app type redirect_uri is not required and should be by default set - // to the static redirect, but for "site" it's required always. - if ($redirectUri !== null) { - if (in_array($redirectUri, [self::REDIRECT_STATIC_PAGE, self::REDIRECT_STATIC_PAGE_WITH_CODE], true)) { - // I think we should check the type of application here - } else { - if (!StringHelper::startsWith($redirectUri, $model->redirect_uri, false)) { - return null; - } - } - } - - $entity = $this->hydrate($model); - $entity->setRedirectUri($redirectUri); - - return $entity; - } - - /** - * @inheritdoc - */ - public function getBySession(OriginalSessionEntity $session) { - if (!$session instanceof SessionEntity) { - throw new \ErrorException('This module assumes that $session typeof ' . SessionEntity::class); - } - - $model = $this->findClient($session->getClientId()); - if ($model === null) { - return null; - } - - return $this->hydrate($model); - } - - private function hydrate(OauthClient $model): ClientEntity { - $entity = new ClientEntity($this->server); - $entity->setId($model->id); - $entity->setName($model->name); - $entity->setSecret($model->secret); - $entity->setIsTrusted($model->is_trusted); - $entity->setRedirectUri($model->redirect_uri); - - return $entity; - } - - private function findClient(string $clientId): ?OauthClient { - return OauthClient::findOne($clientId); - } - -} diff --git a/api/components/OAuth2/Repositories/EmptyScopeRepository.php b/api/components/OAuth2/Repositories/EmptyScopeRepository.php new file mode 100644 index 0000000..aa7e6ad --- /dev/null +++ b/api/components/OAuth2/Repositories/EmptyScopeRepository.php @@ -0,0 +1,30 @@ +isTrusted()) { + throw OAuthServerException::invalidScope($scopes[0]->getIdentifier()); + } + + return $scopes; + } + +} diff --git a/api/components/OAuth2/Repositories/PublicScopeRepository.php b/api/components/OAuth2/Repositories/PublicScopeRepository.php index fe5297e..60335e6 100644 --- a/api/components/OAuth2/Repositories/PublicScopeRepository.php +++ b/api/components/OAuth2/Repositories/PublicScopeRepository.php @@ -11,7 +11,8 @@ use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; class PublicScopeRepository implements ScopeRepositoryInterface { - private const OFFLINE_ACCESS = 'offline_access'; + public const OFFLINE_ACCESS = 'offline_access'; + private const CHANGE_SKIN = 'change_skin'; private const ACCOUNT_INFO = 'account_info'; private const ACCOUNT_EMAIL = 'account_email'; diff --git a/api/components/OAuth2/Repositories/RefreshTokenRepository.php b/api/components/OAuth2/Repositories/RefreshTokenRepository.php index 2bb61ce..fc43c68 100644 --- a/api/components/OAuth2/Repositories/RefreshTokenRepository.php +++ b/api/components/OAuth2/Repositories/RefreshTokenRepository.php @@ -3,49 +3,35 @@ 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 { - /** - * Creates a new refresh token - * - * @return RefreshTokenEntityInterface|null - */ - public function getNewRefreshToken(): RefreshTokenEntityInterface { - // TODO: Implement getNewRefreshToken() method. + public function getNewRefreshToken(): ?RefreshTokenEntityInterface { + return new RefreshTokenEntity(); } - /** - * Create a new refresh token_name. - * - * @param RefreshTokenEntityInterface $refreshTokenEntity - * - * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException - */ - public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity) { - // TODO: Implement persistNewRefreshToken() method. + 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()); } - /** - * Revoke the refresh token. - * - * @param string $tokenId - */ - public function revokeRefreshToken($tokenId) { - // TODO: Implement revokeRefreshToken() method. + public function revokeRefreshToken($tokenId): void { + // Currently we're not rotating refresh tokens so do not revoke + // token during any OAuth2 grant } - /** - * Check if the refresh token has been revoked. - * - * @param string $tokenId - * - * @return bool Return true if this token has been revoked - */ - public function isRefreshTokenRevoked($tokenId) { - // TODO: Implement isRefreshTokenRevoked() method. + public function isRefreshTokenRevoked($tokenId): bool { + // TODO: validate old refresh tokens + return !OauthRefreshToken::find()->andWhere(['id' => $tokenId])->exists(); } } diff --git a/api/components/OAuth2/Repositories/ScopeRepository.php b/api/components/OAuth2/Repositories/ScopeRepository.php deleted file mode 100644 index 4d0c429..0000000 --- a/api/components/OAuth2/Repositories/ScopeRepository.php +++ /dev/null @@ -1,37 +0,0 @@ - P::OBTAIN_OWN_ACCOUNT_INFO, - 'account_email' => P::OBTAIN_ACCOUNT_EMAIL, - 'account_block' => P::BLOCK_ACCOUNT, - 'internal_account_info' => P::OBTAIN_EXTENDED_ACCOUNT_INFO, - ]; - - private const AUTHORIZATION_CODE_PERMISSIONS = [ - P::OBTAIN_OWN_ACCOUNT_INFO, - P::OBTAIN_ACCOUNT_EMAIL, - P::MINECRAFT_SERVER_SESSION, - self::OFFLINE_ACCESS, - self::CHANGE_SKIN, - ]; - - private const CLIENT_CREDENTIALS_PERMISSIONS = [ - ]; - - private const CLIENT_CREDENTIALS_PERMISSIONS_INTERNAL = [ - P::CHANGE_ACCOUNT_USERNAME, - P::CHANGE_ACCOUNT_PASSWORD, - P::BLOCK_ACCOUNT, - P::OBTAIN_EXTENDED_ACCOUNT_INFO, - P::ESCAPE_IDENTITY_VERIFICATION, - ]; - - /** - * @param string $scope - * @param string $grantType is passed on if called from the grant. - * In this case, you only need to filter out the rights that you can get on this grant. - * @param string $clientId - * - * @return ScopeEntity|null - */ - public function get($scope, $grantType = null, $clientId = null): ?ScopeEntity { - $permission = $this->convertToInternalPermission($scope); - - if ($grantType === 'authorization_code') { - $permissions = self::AUTHORIZATION_CODE_PERMISSIONS; - } elseif ($grantType === 'client_credentials') { - $permissions = self::CLIENT_CREDENTIALS_PERMISSIONS; - $isTrusted = false; - if ($clientId !== null) { - /** @var ClientEntity $client */ - $client = $this->server->getClientStorage()->get($clientId); - Assert::that($client)->isInstanceOf(ClientEntity::class); - - /** @noinspection NullPointerExceptionInspection */ - $isTrusted = $client->isTrusted(); - } - - if ($isTrusted) { - $permissions = array_merge($permissions, self::CLIENT_CREDENTIALS_PERMISSIONS_INTERNAL); - } - } else { - $permissions = array_merge( - self::AUTHORIZATION_CODE_PERMISSIONS, - self::CLIENT_CREDENTIALS_PERMISSIONS, - self::CLIENT_CREDENTIALS_PERMISSIONS_INTERNAL - ); - } - - if (!in_array($permission, $permissions, true)) { - return null; - } - - $entity = new ScopeEntity($this->server); - $entity->setId($permission); - - return $entity; - } - - private function convertToInternalPermission(string $publicScope): string { - return self::PUBLIC_SCOPES_TO_INTERNAL_PERMISSIONS[$publicScope] ?? $publicScope; - } - -} diff --git a/api/components/OAuth2/Traits/ValidateScopesTrait.php b/api/components/OAuth2/Traits/ValidateScopesTrait.php new file mode 100644 index 0000000..c94f65b --- /dev/null +++ b/api/components/OAuth2/Traits/ValidateScopesTrait.php @@ -0,0 +1,12 @@ + 'accounts_web_user', - 'sub' => self::SUB_ACCOUNT_PREFIX . $account->id, + 'sub' => self::buildSub($account->id), ]; if ($session === null) { // If we don't remember a session, the token should live longer @@ -29,4 +32,27 @@ class TokensFactory { return Yii::$app->tokens->create($payloads); } + public static function createForOAuthClient(AccessTokenEntityInterface $accessToken): Token { + $payloads = [ + 'aud' => self::buildAud($accessToken->getClient()->getIdentifier()), + 'ely-scopes' => array_map(static function(ScopeEntityInterface $scope): string { + return $scope->getIdentifier(); + }, $accessToken->getScopes()), + 'exp' => $accessToken->getExpiryDateTime()->getTimestamp(), + ]; + if ($accessToken->getUserIdentifier() !== null) { + $payloads['sub'] = self::buildSub($accessToken->getUserIdentifier()); + } + + return Yii::$app->tokens->create($payloads); + } + + private static function buildSub(int $accountId): string { + return self::SUB_ACCOUNT_PREFIX . $accountId; + } + + private static function buildAud(string $clientId): string { + return self::AUD_CLIENT_PREFIX . $clientId; + } + } diff --git a/api/modules/oauth/models/OauthProcess.php b/api/modules/oauth/models/OauthProcess.php index 43f83cc..fca30dd 100644 --- a/api/modules/oauth/models/OauthProcess.php +++ b/api/modules/oauth/models/OauthProcess.php @@ -156,16 +156,13 @@ class OauthProcess { public function getToken(): array { $request = $this->getRequest(); $params = (array)$request->getParsedBody(); + $clientId = $params['client_id'] ?? ''; $grantType = $params['grant_type'] ?? 'null'; try { Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.attempt"); - $responseObj = new Response(200); - $this->server->respondToAccessTokenRequest($request, $responseObj); - $clientId = $params['client_id']; - - // TODO: build response from the responseObj - $response = []; + $response = $this->server->respondToAccessTokenRequest($request, new Response(200)); + $result = json_decode((string)$response->getBody(), true); Yii::$app->statsd->inc("oauth.issueToken_client.{$clientId}"); Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.success"); @@ -173,10 +170,10 @@ class OauthProcess { Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.fail"); Yii::$app->response->statusCode = $e->getHttpStatusCode(); - $response = $this->buildIssueErrorResponse($e); + $result = $this->buildIssueErrorResponse($e); } - return $response; + return $result; } private function findClient(string $clientId): ?OauthClient { @@ -290,7 +287,7 @@ class OauthProcess { * information about the parameter that caused the error. * This method is intended to build a more understandable description. * - * Part of the existing texts is a legacy from the previous implementation. + * Part of the existing texts are the legacy from the previous implementation. * * @param OAuthServerException $e * @return array @@ -306,6 +303,7 @@ class OauthProcess { break; case 'Cannot decrypt the authorization code': $message .= ' Check the "code" parameter.'; + break; } return [ @@ -328,7 +326,6 @@ class OauthProcess { } private function getScopesList(AuthorizationRequest $request): array { - // TODO: replace with an arrow function in PHP 7.4 return array_map(function(ScopeEntityInterface $scope): string { return $scope->getIdentifier(); }, $request->getScopes()); diff --git a/api/tests/_pages/OauthRoute.php b/api/tests/_pages/OauthRoute.php index 2e9a3ce..564d7d6 100644 --- a/api/tests/_pages/OauthRoute.php +++ b/api/tests/_pages/OauthRoute.php @@ -5,30 +5,10 @@ namespace api\tests\_pages; /** * @deprecated + * TODO: remove */ class OauthRoute extends BasePage { - /** - * @deprecated - */ - public function validate(array $queryParams): void { - $this->getActor()->sendGET('/api/oauth2/v1/validate', $queryParams); - } - - /** - * @deprecated - */ - public function complete(array $queryParams = [], array $postParams = []): void { - $this->getActor()->sendPOST('/api/oauth2/v1/complete?' . http_build_query($queryParams), $postParams); - } - - /** - * @deprecated - */ - public function issueToken(array $postParams = []): void { - $this->getActor()->sendPOST('/api/oauth2/v1/token', $postParams); - } - /** * @deprecated */ diff --git a/api/tests/functional/_steps/OauthSteps.php b/api/tests/functional/_steps/OauthSteps.php index 5c0559d..8d7d2bd 100644 --- a/api/tests/functional/_steps/OauthSteps.php +++ b/api/tests/functional/_steps/OauthSteps.php @@ -3,8 +3,7 @@ declare(strict_types=1); namespace api\tests\functional\_steps; -use api\components\OAuth2\Repositories\ScopeStorage as S; -use api\tests\_pages\OauthRoute; +use api\components\OAuth2\Repositories\PublicScopeRepository; use api\tests\FunctionalTester; class OauthSteps extends FunctionalTester { @@ -32,31 +31,29 @@ class OauthSteps extends FunctionalTester { } public function getRefreshToken(array $permissions = []): string { - $authCode = $this->obtainAuthCode(array_merge([S::OFFLINE_ACCESS], $permissions)); + $authCode = $this->obtainAuthCode(array_merge([PublicScopeRepository::OFFLINE_ACCESS], $permissions)); $response = $this->issueToken($authCode); return $response['refresh_token']; } - public function issueToken($authCode): array { - $route = new OauthRoute($this); - $route->issueToken([ + public function issueToken(string $authCode): array { + $this->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'authorization_code', 'code' => $authCode, 'client_id' => 'ely', 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', 'redirect_uri' => 'http://ely.by', - 'grant_type' => 'authorization_code', ]); return json_decode($this->grabResponse(), true); } - public function getAccessTokenByClientCredentialsGrant(array $permissions = [], $useTrusted = true): string { - $route = new OauthRoute($this); - $route->issueToken([ + public function getAccessTokenByClientCredentialsGrant(array $permissions = [], bool $useTrusted = true): string { + $this->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'client_credentials', 'client_id' => $useTrusted ? 'trusted-client' : 'default-client', 'client_secret' => $useTrusted ? 'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9' : 'AzWRy7ZjS1yRQUk2vRBDic8fprOKDB1W', - 'grant_type' => 'client_credentials', 'scope' => implode(',', $permissions), ]); diff --git a/api/tests/functional/oauth/AccessTokenCest.php b/api/tests/functional/oauth/AccessTokenCest.php index 838f3fa..c6ede99 100644 --- a/api/tests/functional/oauth/AccessTokenCest.php +++ b/api/tests/functional/oauth/AccessTokenCest.php @@ -81,10 +81,10 @@ class AccessTokenCest { 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', 'redirect_uri' => 'http://some-other.domain', ]); - $I->canSeeResponseCodeIs(400); + $I->canSeeResponseCodeIs(401); $I->canSeeResponseContainsJson([ 'error' => 'invalid_client', - 'message' => 'Client authentication failed.', + 'message' => 'Client authentication failed', ]); } diff --git a/api/tests/functional/oauth/AuthCodeCest.php b/api/tests/functional/oauth/AuthCodeCest.php index 7041079..b5b9fb8 100644 --- a/api/tests/functional/oauth/AuthCodeCest.php +++ b/api/tests/functional/oauth/AuthCodeCest.php @@ -183,7 +183,7 @@ class AuthCodeCest { 'redirect_uri' => 'http://ely.by', 'response_type' => 'code', 'scope' => 'minecraft_server_session block_account', - ])); + ]), ['accept' => true]); // TODO: maybe remove? $I->canSeeResponseCodeIs(400); $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ diff --git a/api/tests/functional/oauth/ClientCredentialsCest.php b/api/tests/functional/oauth/ClientCredentialsCest.php index 5a5ff37..b83f78b 100644 --- a/api/tests/functional/oauth/ClientCredentialsCest.php +++ b/api/tests/functional/oauth/ClientCredentialsCest.php @@ -1,120 +1,87 @@ route = new OauthRoute($I); + public function issueTokenWithPublicScopes(FunctionalTester $I) { + $I->wantTo('issue token as not trusted client and require only public scopes'); + // We don't have any public scopes yet for this grant, so the test runs with an empty set + $I->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'client_credentials', + 'client_id' => 'ely', + 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + 'scope' => '', + ]); + $this->assertSuccessResponse($I); } - public function testIssueTokenWithWrongArgs(FunctionalTester $I) { - $I->wantTo('check behavior on on request without any credentials'); - $this->route->issueToken($this->buildParams()); - $I->canSeeResponseCodeIs(400); - $I->canSeeResponseContainsJson([ - 'error' => 'invalid_request', + public function issueTokenWithInternalScopesAsNotTrustedClient(FunctionalTester $I) { + $I->wantTo('issue token as not trusted client and require some internal scope'); + $I->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'client_credentials', + 'client_id' => 'ely', + 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + 'scope' => 'block_account', ]); - - $I->wantTo('check behavior on passing invalid client_id'); - $this->route->issueToken($this->buildParams( - 'invalid-client', - 'invalid-secret', - ['invalid-scope'] - )); - $I->canSeeResponseCodeIs(401); - $I->canSeeResponseContainsJson([ - 'error' => 'invalid_client', - ]); - - $I->wantTo('check behavior on passing invalid client_secret'); - $this->route->issueToken($this->buildParams( - 'ely', - 'invalid-secret', - ['invalid-scope'] - )); - $I->canSeeResponseCodeIs(401); - $I->canSeeResponseContainsJson([ - 'error' => 'invalid_client', - ]); - - $I->wantTo('check behavior on passing invalid client_secret'); - $this->route->issueToken($this->buildParams( - 'ely', - 'invalid-secret', - ['invalid-scope'] - )); - $I->canSeeResponseCodeIs(401); - $I->canSeeResponseContainsJson([ - 'error' => 'invalid_client', - ]); - } - - public function testIssueTokenWithPublicScopes(OauthSteps $I) { - // TODO: we don't have any public scopes yet for this grant, so the test runs with an empty set - $this->route->issueToken($this->buildParams( - 'ely', - 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', - [] - )); - $I->canSeeResponseCodeIs(200); - $I->canSeeResponseIsJson(); - $I->canSeeResponseContainsJson([ - 'token_type' => 'Bearer', - ]); - $I->canSeeResponseJsonMatchesJsonPath('$.access_token'); - $I->canSeeResponseJsonMatchesJsonPath('$.expires_in'); - } - - public function testIssueTokenWithInternalScopes(OauthSteps $I) { - $this->route->issueToken($this->buildParams( - 'ely', - 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', - ['account_block'] - )); $I->canSeeResponseCodeIs(400); $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ 'error' => 'invalid_scope', ]); + } - $this->route->issueToken($this->buildParams( - 'trusted-client', - 'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9', - ['account_block'] - )); + public function issueTokenWithInternalScopesAsTrustedClient(OauthSteps $I) { + $I->wantTo('issue token as trusted client and require some internal scope'); + $I->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'client_credentials', + 'client_id' => 'trusted-client', + 'client_secret' => 'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9', + 'scope' => 'block_account', + ]); + $this->assertSuccessResponse($I); + } + + public function issueTokenByPassingInvalidClientId(FunctionalTester $I) { + $I->wantToTest('behavior on passing invalid client_id'); + $I->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'client_credentials', + 'client_id' => 'invalid-client', + 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + 'scope' => 'block_account', + ]); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'error' => 'invalid_client', + ]); + } + + public function issueTokenByPassingInvalidClientSecret(FunctionalTester $I) { + $I->wantTo('check behavior on passing invalid client_secret'); + $I->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'client_credentials', + 'client_id' => 'trusted-client', + 'client_secret' => 'invalid-secret', + 'scope' => 'block_account', + ]); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'error' => 'invalid_client', + ]); + } + + private function assertSuccessResponse(FunctionalTester $I): void { $I->canSeeResponseCodeIs(200); - $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ 'token_type' => 'Bearer', ]); $I->canSeeResponseJsonMatchesJsonPath('$.access_token'); $I->canSeeResponseJsonMatchesJsonPath('$.expires_in'); - } - - private function buildParams($clientId = null, $clientSecret = null, array $scopes = null) { - $params = ['grant_type' => 'client_credentials']; - if ($clientId !== null) { - $params['client_id'] = $clientId; - } - - if ($clientSecret !== null) { - $params['client_secret'] = $clientSecret; - } - - if ($scopes !== null) { - $params['scope'] = implode(',', $scopes); - } - - return $params; + $I->cantSeeResponseJsonMatchesJsonPath('$.refresh_token'); } } diff --git a/api/tests/functional/oauth/RefreshTokenCest.php b/api/tests/functional/oauth/RefreshTokenCest.php index d2dad6b..dc5e291 100644 --- a/api/tests/functional/oauth/RefreshTokenCest.php +++ b/api/tests/functional/oauth/RefreshTokenCest.php @@ -1,83 +1,83 @@ route = new OauthRoute($I); + public function refreshToken(OauthSteps $I) { + $I->wantTo('refresh token without passing the desired scopes'); + $refreshToken = $I->getRefreshToken(); + $I->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + 'client_id' => 'ely', + 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + ]); + $this->canSeeRefreshTokenSuccess($I); } - public function testInvalidRefreshToken(OauthSteps $I) { - $this->route->issueToken($this->buildParams( - 'some-invalid-refresh-token', - 'ely', - 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM' - )); + public function refreshTokenWithSameScopes(OauthSteps $I) { + $refreshToken = $I->getRefreshToken(['minecraft_server_session']); + $I->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + 'client_id' => 'ely', + 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + 'scope' => 'minecraft_server_session offline_access', + ]); + $this->canSeeRefreshTokenSuccess($I); + } + + public function refreshTokenTwice(OauthSteps $I) { + $I->wantTo('refresh token two times in a row and ensure, that token isn\'t rotating'); + $refreshToken = $I->getRefreshToken(['minecraft_server_session']); + $I->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + 'client_id' => 'ely', + 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + 'scope' => 'minecraft_server_session', + ]); + $this->canSeeRefreshTokenSuccess($I); + + $I->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + 'client_id' => 'ely', + 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + 'scope' => 'minecraft_server_session', + ]); + $this->canSeeRefreshTokenSuccess($I); + } + + public function passInvalidRefreshToken(OauthSteps $I) { + $I->wantToTest('behaviour of the server when invalid refresh token passed'); + $I->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => 'some-invalid-refresh-token', + 'client_id' => 'ely', + 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + ]); + $I->canSeeResponseCodeIs(401); $I->canSeeResponseContainsJson([ 'error' => 'invalid_request', 'message' => 'The refresh token is invalid.', ]); } - public function testRefreshToken(OauthSteps $I) { - $refreshToken = $I->getRefreshToken(); - $this->route->issueToken($this->buildParams( - $refreshToken, - 'ely', - 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM' - )); - $this->canSeeRefreshTokenSuccess($I); - } - - public function testRefreshTokenWithSameScopes(OauthSteps $I) { - $refreshToken = $I->getRefreshToken([P::MINECRAFT_SERVER_SESSION]); - $this->route->issueToken($this->buildParams( - $refreshToken, - 'ely', - 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', - [P::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS] - )); - $this->canSeeRefreshTokenSuccess($I); - } - - public function testRefreshTokenTwice(OauthSteps $I) { - $refreshToken = $I->getRefreshToken([P::MINECRAFT_SERVER_SESSION]); - $this->route->issueToken($this->buildParams( - $refreshToken, - 'ely', - 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', - [P::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS] - )); - $this->canSeeRefreshTokenSuccess($I); - - $this->route->issueToken($this->buildParams( - $refreshToken, - 'ely', - 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', - [P::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS] - )); - $this->canSeeRefreshTokenSuccess($I); - } - - public function testRefreshTokenWithNewScopes(OauthSteps $I) { - $refreshToken = $I->getRefreshToken([P::MINECRAFT_SERVER_SESSION]); - $this->route->issueToken($this->buildParams( - $refreshToken, - 'ely', - 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', - [P::MINECRAFT_SERVER_SESSION, S::OFFLINE_ACCESS, 'account_email'] - )); + public function requireNewScopes(OauthSteps $I) { + $I->wantToTest('behavior when required the new scope that was not issued with original token'); + $refreshToken = $I->getRefreshToken(['minecraft_server_session']); + $I->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + 'client_id' => 'ely', + 'client_secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + 'scope' => 'minecraft_server_session account_email', + ]); $I->canSeeResponseCodeIs(400); $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ @@ -85,34 +85,8 @@ class RefreshTokenCest { ]); } - private function buildParams($refreshToken = null, $clientId = null, $clientSecret = null, $scopes = []) { - $params = ['grant_type' => 'refresh_token']; - if ($refreshToken !== null) { - $params['refresh_token'] = $refreshToken; - } - - if ($clientId !== null) { - $params['client_id'] = $clientId; - } - - if ($clientSecret !== null) { - $params['client_secret'] = $clientSecret; - } - - if (!empty($scopes)) { - if (is_array($scopes)) { - $scopes = implode(',', $scopes); - } - - $params['scope'] = $scopes; - } - - return $params; - } - private function canSeeRefreshTokenSuccess(OauthSteps $I) { $I->canSeeResponseCodeIs(200); - $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ 'token_type' => 'Bearer', ]); diff --git a/common/models/Account.php b/common/models/Account.php index fc227b8..af104a3 100644 --- a/common/models/Account.php +++ b/common/models/Account.php @@ -41,6 +41,7 @@ use const common\LATEST_RULES_VERSION; * @property UsernameHistory[] $usernameHistory * @property AccountSession[] $sessions * @property MinecraftAccessKey[] $minecraftAccessKeys + * @property-read OauthRefreshToken[] $oauthRefreshTokens * * Behaviors: * @mixin TimestampBehavior @@ -101,6 +102,10 @@ 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']); } diff --git a/common/models/OauthClient.php b/common/models/OauthClient.php index 799e07f..259bf05 100644 --- a/common/models/OauthClient.php +++ b/common/models/OauthClient.php @@ -1,4 +1,6 @@ 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]); } diff --git a/common/models/OauthOwnerType.php b/common/models/OauthOwnerType.php deleted file mode 100644 index 56b97f8..0000000 --- a/common/models/OauthOwnerType.php +++ /dev/null @@ -1,23 +0,0 @@ - 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']); + } + +} diff --git a/common/models/OauthSession.php b/common/models/OauthSession.php index 2806cb7..a52f4f9 100644 --- a/common/models/OauthSession.php +++ b/common/models/OauthSession.php @@ -17,8 +17,9 @@ use yii\db\ActiveRecord; * @property integer $created_at * * Relations: - * @property OauthClient $client - * @property Account $account + * @property-read OauthClient $client + * @property-read Account $account + * @property-read OauthRefreshToken[] $refreshTokens */ class OauthSession extends ActiveRecord { @@ -43,6 +44,10 @@ 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()); diff --git a/common/tests/_support/FixtureHelper.php b/common/tests/_support/FixtureHelper.php index 17914cd..7fb0d73 100644 --- a/common/tests/_support/FixtureHelper.php +++ b/common/tests/_support/FixtureHelper.php @@ -1,15 +1,11 @@ AccountFixture::class, - 'accountSessions' => AccountSessionFixture::class, - 'emailActivations' => EmailActivationFixture::class, - 'usernamesHistory' => UsernameHistoryFixture::class, - 'oauthClients' => OauthClientFixture::class, - 'oauthSessions' => OauthSessionFixture::class, - 'minecraftAccessKeys' => MinecraftAccessKeyFixture::class, + 'accounts' => fixtures\AccountFixture::class, + 'accountSessions' => fixtures\AccountSessionFixture::class, + 'emailActivations' => fixtures\EmailActivationFixture::class, + 'usernamesHistory' => fixtures\UsernameHistoryFixture::class, + 'oauthClients' => fixtures\OauthClientFixture::class, + 'oauthSessions' => fixtures\OauthSessionFixture::class, + 'oauthRefreshTokens' => fixtures\OauthRefreshTokensFixture::class, + 'minecraftAccessKeys' => fixtures\MinecraftAccessKeyFixture::class, ]; } diff --git a/common/tests/fixtures/OauthRefreshTokensFixture.php b/common/tests/fixtures/OauthRefreshTokensFixture.php new file mode 100644 index 0000000..85345d2 --- /dev/null +++ b/common/tests/fixtures/OauthRefreshTokensFixture.php @@ -0,0 +1,19 @@ +delete('oauth_sessions', ['NOT', ['owner_type' => 'user']]); @@ -33,9 +33,20 @@ class m190914_181236_rework_oauth_sessions_table 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'); From c722c46ad5d3db45341642a5a268a5b9144c43c2 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sun, 22 Sep 2019 02:42:08 +0300 Subject: [PATCH 06/28] Add support for the legacy refresh tokens, make the new refresh tokens non-expire [skip ci] --- api/components/OAuth2/Component.php | 4 +- .../OAuth2/Entities/RefreshTokenEntity.php | 15 +++++ .../OAuth2/Grants/RefreshTokenGrant.php | 55 ++++++++++++++++ .../Repositories/RefreshTokenStorage.php | 63 ------------------- .../controllers/AuthorizationController.php | 2 +- common/models/OauthSession.php | 19 ++++++ ...914_181236_rework_oauth_related_tables.php | 2 + 7 files changed, 94 insertions(+), 66 deletions(-) delete mode 100644 api/components/OAuth2/Repositories/RefreshTokenStorage.php diff --git a/api/components/OAuth2/Component.php b/api/components/OAuth2/Component.php index 83f1a3e..93656bb 100644 --- a/api/components/OAuth2/Component.php +++ b/api/components/OAuth2/Component.php @@ -45,11 +45,11 @@ class Component extends BaseComponent { $authServer->enableGrantType($authCodeGrant, $accessTokenTTL); $authCodeGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling - // TODO: extends refresh token life time to forever $refreshTokenGrant = new RefreshTokenGrant($refreshTokensRepo); - $authServer->enableGrantType($refreshTokenGrant, $accessTokenTTL); + $authServer->enableGrantType($refreshTokenGrant); $refreshTokenGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling + // TODO: make these access tokens live longer $clientCredentialsGrant = new Grant\ClientCredentialsGrant(); $authServer->enableGrantType($clientCredentialsGrant, $accessTokenTTL); $clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling diff --git a/api/components/OAuth2/Entities/RefreshTokenEntity.php b/api/components/OAuth2/Entities/RefreshTokenEntity.php index aea8f5a..0b8383d 100644 --- a/api/components/OAuth2/Entities/RefreshTokenEntity.php +++ b/api/components/OAuth2/Entities/RefreshTokenEntity.php @@ -3,6 +3,8 @@ 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; @@ -11,4 +13,17 @@ 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'); + } + } diff --git a/api/components/OAuth2/Grants/RefreshTokenGrant.php b/api/components/OAuth2/Grants/RefreshTokenGrant.php index 13ceb58..b1ce1bf 100644 --- a/api/components/OAuth2/Grants/RefreshTokenGrant.php +++ b/api/components/OAuth2/Grants/RefreshTokenGrant.php @@ -3,12 +3,36 @@ declare(strict_types=1); namespace api\components\OAuth2\Grants; +use common\models\OauthSession; use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; +use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Grant\RefreshTokenGrant as BaseRefreshTokenGrant; +use Psr\Http\Message\ServerRequestInterface; +use Yii; class RefreshTokenGrant extends BaseRefreshTokenGrant { + /** + * Previously, refresh tokens was stored in Redis. + * If received refresh token is matches the legacy token template, + * restore the information from the legacy storage. + * + * @param ServerRequestInterface $request + * @param string $clientId + * + * @return array + * @throws OAuthServerException + */ + protected function validateOldRefreshToken(ServerRequestInterface $request, $clientId): array { + $refreshToken = $this->getRequestParameter('refresh_token', $request); + if ($refreshToken !== null && mb_strlen($refreshToken) === 40) { + return $this->validateLegacyRefreshToken($refreshToken); + } + + return parent::validateOldRefreshToken($request, $clientId); + } + /** * Currently we're not rotating refresh tokens. * So we overriding this method to always return null, which means, @@ -22,4 +46,35 @@ class RefreshTokenGrant extends BaseRefreshTokenGrant { return null; } + private function validateLegacyRefreshToken(string $refreshToken): array { + $result = Yii::$app->redis->get("oauth:refresh:tokens:{$refreshToken}"); + if ($result === null) { + throw OAuthServerException::invalidRefreshToken('Token has been revoked'); + } + + try { + [ + 'access_token_id' => $accessTokenId, + 'session_id' => $sessionId, + ] = json_decode($result, true, 512, JSON_THROW_ON_ERROR); + } catch (\Exception $e) { + throw OAuthServerException::invalidRefreshToken('Cannot decrypt the refresh token', $e); + } + + /** @var OauthSession|null $relatedSession */ + $relatedSession = OauthSession::findOne(['legacy_id' => $sessionId]); + if ($relatedSession === null) { + throw OAuthServerException::invalidRefreshToken('Token has been revoked'); + } + + return [ + 'client_id' => $relatedSession->client_id, + 'refresh_token_id' => $refreshToken, + 'access_token_id' => $accessTokenId, + 'scopes' => $relatedSession->getScopes(), + 'user_id' => $relatedSession->account_id, + 'expire_time' => null, + ]; + } + } diff --git a/api/components/OAuth2/Repositories/RefreshTokenStorage.php b/api/components/OAuth2/Repositories/RefreshTokenStorage.php deleted file mode 100644 index 4057fc3..0000000 --- a/api/components/OAuth2/Repositories/RefreshTokenStorage.php +++ /dev/null @@ -1,63 +0,0 @@ -dataTable, $token))->getValue()); - if ($result === null) { - return null; - } - - $entity = new RefreshTokenEntity($this->server); - $entity->setId($result['id']); - $entity->setAccessTokenId($result['access_token_id']); - $entity->setSessionId($result['session_id']); - - return $entity; - } - - public function create($token, $expireTime, $accessToken) { - $sessionId = $this->server->getAccessTokenStorage()->get($accessToken)->getSession()->getId(); - $payload = Json::encode([ - 'id' => $token, - 'access_token_id' => $accessToken, - 'session_id' => $sessionId, - ]); - - $this->key($token)->setValue($payload); - $this->sessionHash($sessionId)->add($token); - } - - public function delete(OriginalRefreshTokenEntity $token) { - if (!$token instanceof RefreshTokenEntity) { - throw new ErrorException('Token must be instance of ' . RefreshTokenEntity::class); - } - - $this->key($token->getId())->delete(); - $this->sessionHash($token->getSessionId())->remove($token->getId()); - } - - public function sessionHash(string $sessionId): Set { - $tableName = Yii::$app->db->getSchema()->getRawTableName(OauthSession::tableName()); - return new Set($tableName, $sessionId, 'refresh_tokens'); - } - - private function key(string $token): Key { - return new Key($this->dataTable, $token); - } - -} diff --git a/api/modules/oauth/controllers/AuthorizationController.php b/api/modules/oauth/controllers/AuthorizationController.php index b98e89b..85c1616 100644 --- a/api/modules/oauth/controllers/AuthorizationController.php +++ b/api/modules/oauth/controllers/AuthorizationController.php @@ -57,7 +57,7 @@ class AuthorizationController extends Controller { } private function createOauthProcess(): OauthProcess { - return new OauthProcess(Yii::$app->oauth->authServer); + return new OauthProcess(Yii::$app->oauth->getAuthServer()); } } diff --git a/common/models/OauthSession.php b/common/models/OauthSession.php index a52f4f9..fb0e4d6 100644 --- a/common/models/OauthSession.php +++ b/common/models/OauthSession.php @@ -56,6 +56,20 @@ class OauthSession extends ActiveRecord { return (array)$this->scopes; } + /** + * In the early period of the project existence, the refresh tokens related to the current session + * were stored in Redis. This method allows to get a list of these tokens. + * + * @return array of refresh tokens (ids) + */ + public function getLegacyRefreshTokens(): array { + if ($this->legacy_id === null) { + return []; + } + + return Yii::$app->redis->smembers($this->getLegacyRedisRefreshTokensKey()); + } + public function beforeDelete(): bool { if (!parent::beforeDelete()) { return false; @@ -63,6 +77,7 @@ class OauthSession extends ActiveRecord { if ($this->legacy_id !== null) { Yii::$app->redis->del($this->getLegacyRedisScopesKey()); + Yii::$app->redis->del($this->getLegacyRedisRefreshTokensKey()); } return true; @@ -72,4 +87,8 @@ class OauthSession extends ActiveRecord { return "oauth:sessions:{$this->legacy_id}:scopes"; } + private function getLegacyRedisRefreshTokensKey(): string { + return "oauth:sessions:{$this->legacy_id}:refresh:tokens"; + } + } diff --git a/console/migrations/m190914_181236_rework_oauth_related_tables.php b/console/migrations/m190914_181236_rework_oauth_related_tables.php index 51d7adc..d9256d7 100644 --- a/console/migrations/m190914_181236_rework_oauth_related_tables.php +++ b/console/migrations/m190914_181236_rework_oauth_related_tables.php @@ -27,6 +27,7 @@ class m190914_181236_rework_oauth_related_tables extends Migration { // Change type again to make column nullable $this->alterColumn('oauth_sessions', 'id', $this->integer(11)->unsigned()->after('client_id')); $this->renameColumn('oauth_sessions', 'id', 'legacy_id'); + $this->createIndex('legacy_id', 'oauth_sessions', 'legacy_id', true); $this->addPrimaryKey('id', 'oauth_sessions', ['account_id', 'client_id']); $this->dropForeignKey('FK_oauth_session_to_client', 'oauth_sessions'); $this->dropIndex('FK_oauth_session_to_client', 'oauth_sessions'); @@ -53,6 +54,7 @@ class m190914_181236_rework_oauth_related_tables extends Migration { $this->dropIndex('FK_oauth_session_to_oauth_client', 'oauth_sessions'); $this->dropPrimaryKey('PRIMARY', 'oauth_sessions'); $this->delete('oauth_sessions', ['legacy_id' => null]); + $this->dropIndex('legacy_id', 'oauth_sessions'); $this->alterColumn('oauth_sessions', 'legacy_id', $this->integer(11)->unsigned()->notNull()->append('AUTO_INCREMENT PRIMARY KEY FIRST')); $this->renameColumn('oauth_sessions', 'legacy_id', 'id'); $this->alterColumn('oauth_sessions', 'client_id', $this->db->getTableSchema('oauth_clients')->getColumn('id')->dbType); From cf62c686b19191a7f02622e87e07a390123c9adb Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sun, 22 Sep 2019 18:42:21 +0300 Subject: [PATCH 07/28] Rework identity provider for the legacy OAuth2 tokens [skip ci] --- api/components/OAuth2/Component.php | 4 - .../Repositories/AccessTokenStorage.php | 70 ----------- api/components/User/IdentityFactory.php | 21 ++-- api/components/User/LegacyOAuth2Identity.php | 119 ++++++++++++++++++ api/components/User/OAuth2Identity.php | 78 ------------ .../unit/components/User/ComponentTest.php | 4 +- .../components/User/IdentityFactoryTest.php | 4 +- ...yTest.php => LegacyOAuth2IdentityTest.php} | 12 +- common/components/Redis/Key.php | 63 ---------- common/components/Redis/Set.php | 50 -------- 10 files changed, 141 insertions(+), 284 deletions(-) delete mode 100644 api/components/OAuth2/Repositories/AccessTokenStorage.php create mode 100644 api/components/User/LegacyOAuth2Identity.php delete mode 100644 api/components/User/OAuth2Identity.php rename api/tests/unit/components/User/{OAuth2IdentityTest.php => LegacyOAuth2IdentityTest.php} (82%) delete mode 100644 common/components/Redis/Key.php delete mode 100644 common/components/Redis/Set.php diff --git a/api/components/OAuth2/Component.php b/api/components/OAuth2/Component.php index 93656bb..10350f0 100644 --- a/api/components/OAuth2/Component.php +++ b/api/components/OAuth2/Component.php @@ -11,9 +11,6 @@ use League\OAuth2\Server\AuthorizationServer; use League\OAuth2\Server\Grant; use yii\base\Component as BaseComponent; -/** - * @property AuthorizationServer $authServer - */ class Component extends BaseComponent { /** @@ -39,7 +36,6 @@ class Component extends BaseComponent { new EmptyKey(), '123' // TODO: extract to the variable ); - /** @noinspection PhpUnhandledExceptionInspection */ $authCodeGrant = new AuthCodeGrant($authCodesRepo, $refreshTokensRepo, new DateInterval('PT10M')); $authCodeGrant->disableRequireCodeChallengeForPublicClients(); $authServer->enableGrantType($authCodeGrant, $accessTokenTTL); diff --git a/api/components/OAuth2/Repositories/AccessTokenStorage.php b/api/components/OAuth2/Repositories/AccessTokenStorage.php deleted file mode 100644 index bfe6765..0000000 --- a/api/components/OAuth2/Repositories/AccessTokenStorage.php +++ /dev/null @@ -1,70 +0,0 @@ -dataTable, $token))->getValue()); - if ($result === null) { - return null; - } - - $token = new AccessTokenEntity($this->server); - $token->setId($result['id']); - $token->setExpireTime($result['expire_time']); - $token->setSessionId($result['session_id']); - - return $token; - } - - public function getScopes(OriginalAccessTokenEntity $token) { - $scopes = $this->scopes($token->getId()); - $entities = []; - foreach ($scopes as $scope) { - if ($this->server->getScopeStorage()->get($scope) !== null) { - $entities[] = (new ScopeEntity($this->server))->hydrate(['id' => $scope]); - } - } - - return $entities; - } - - public function create($token, $expireTime, $sessionId) { - $payload = Json::encode([ - 'id' => $token, - 'expire_time' => $expireTime, - 'session_id' => $sessionId, - ]); - - $this->key($token)->setValue($payload)->expireAt($expireTime); - } - - public function associateScope(OriginalAccessTokenEntity $token, ScopeEntity $scope) { - $this->scopes($token->getId())->add($scope->getId())->expireAt($token->getExpireTime()); - } - - public function delete(OriginalAccessTokenEntity $token) { - $this->key($token->getId())->delete(); - $this->scopes($token->getId())->delete(); - } - - private function key(string $token): Key { - return new Key($this->dataTable, $token); - } - - private function scopes(string $token): Set { - return new Set($this->dataTable, $token, 'scopes'); - } - -} diff --git a/api/components/User/IdentityFactory.php b/api/components/User/IdentityFactory.php index 2b59630..bf53c19 100644 --- a/api/components/User/IdentityFactory.php +++ b/api/components/User/IdentityFactory.php @@ -8,19 +8,24 @@ use yii\web\UnauthorizedHttpException; class IdentityFactory { /** - * @throws UnauthorizedHttpException + * @param string $token + * @param string $type + * * @return IdentityInterface + * @throws UnauthorizedHttpException */ public static function findIdentityByAccessToken($token, $type = null): IdentityInterface { - if (empty($token)) { - throw new UnauthorizedHttpException('Incorrect token'); + if (!empty($token)) { + if (mb_strlen($token) === 40) { + return LegacyOAuth2Identity::findIdentityByAccessToken($token, $type); + } + + if (substr_count($token, '.') === 2) { + return JwtIdentity::findIdentityByAccessToken($token, $type); + } } - if (substr_count($token, '.') === 2) { - return JwtIdentity::findIdentityByAccessToken($token, $type); - } - - return OAuth2Identity::findIdentityByAccessToken($token, $type); + throw new UnauthorizedHttpException('Incorrect token'); } } diff --git a/api/components/User/LegacyOAuth2Identity.php b/api/components/User/LegacyOAuth2Identity.php new file mode 100644 index 0000000..422a460 --- /dev/null +++ b/api/components/User/LegacyOAuth2Identity.php @@ -0,0 +1,119 @@ +accessToken = $accessToken; + $this->sessionId = $sessionId; + $this->scopes = $scopes; + } + + /** + * @inheritdoc + * @throws UnauthorizedHttpException + * @return IdentityInterface + */ + public static function findIdentityByAccessToken($token, $type = null): IdentityInterface { + $tokenParams = self::findRecordOnLegacyStorage($token); + if ($tokenParams === null) { + throw new UnauthorizedHttpException('Incorrect token'); + } + + if ($tokenParams['expire_time'] < time()) { + throw new UnauthorizedHttpException('Token expired'); + } + + return new static($token, $tokenParams['session_id'], $tokenParams['scopes']); + } + + public function getAccount(): ?Account { + $session = $this->getSession(); + if ($session === null) { + return null; + } + + return $session->account; + } + + /** + * @return string[] + */ + public function getAssignedPermissions(): array { + return $this->scopes; + } + + public function getId(): string { + return $this->accessToken; + } + + // @codeCoverageIgnoreStart + public function getAuthKey() { + throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth'); + } + + public function validateAuthKey($authKey) { + throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth'); + } + + public static function findIdentity($id) { + throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth'); + } + + // @codeCoverageIgnoreEnd + + private static function findRecordOnLegacyStorage(string $accessToken): ?array { + $record = Yii::$app->redis->get("oauth:access:tokens:{$accessToken}"); + if ($record === null) { + return null; + } + + try { + $data = json_decode($record, true, 512, JSON_THROW_ON_ERROR); + } catch (Exception $e) { + return null; + } + + $data['scopes'] = (array)Yii::$app->redis->smembers("oauth:access:tokens:{$accessToken}:scopes"); + + return $data; + } + + private function getSession(): ?OauthSession { + if ($this->session === false) { + $this->session = OauthSession::findOne(['id' => $this->sessionId]); + } + + return $this->session; + } + +} diff --git a/api/components/User/OAuth2Identity.php b/api/components/User/OAuth2Identity.php deleted file mode 100644 index 63acd81..0000000 --- a/api/components/User/OAuth2Identity.php +++ /dev/null @@ -1,78 +0,0 @@ -_accessToken = $accessToken; - } - - /** - * @inheritdoc - * @throws UnauthorizedHttpException - * @return IdentityInterface - */ - public static function findIdentityByAccessToken($token, $type = null): IdentityInterface { - /** @var AccessTokenEntity|null $model */ - // TODO: rework - $model = Yii::$app->oauth->getAccessTokenStorage()->get($token); - if ($model === null) { - throw new UnauthorizedHttpException('Incorrect token'); - } - - if ($model->isExpired()) { - throw new UnauthorizedHttpException('Token expired'); - } - - return new static($model); - } - - public function getAccount(): ?Account { - return $this->getSession()->account; - } - - /** - * @return string[] - */ - public function getAssignedPermissions(): array { - return array_keys($this->_accessToken->getScopes()); - } - - public function getId(): string { - return $this->_accessToken->getId(); - } - - // @codeCoverageIgnoreStart - public function getAuthKey() { - throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth'); - } - - public function validateAuthKey($authKey) { - throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth'); - } - - public static function findIdentity($id) { - throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth'); - } - - // @codeCoverageIgnoreEnd - - private function getSession(): OauthSession { - return OauthSession::findOne(['id' => $this->_accessToken->getSessionId()]); - } - -} diff --git a/api/tests/unit/components/User/ComponentTest.php b/api/tests/unit/components/User/ComponentTest.php index e9ca1cc..1a2fea5 100644 --- a/api/tests/unit/components/User/ComponentTest.php +++ b/api/tests/unit/components/User/ComponentTest.php @@ -5,7 +5,7 @@ namespace codeception\api\unit\components\User; use api\components\User\Component; use api\components\User\JwtIdentity; -use api\components\User\OAuth2Identity; +use api\components\User\LegacyOAuth2Identity; use api\tests\unit\TestCase; use common\models\Account; use common\models\AccountSession; @@ -41,7 +41,7 @@ class ComponentTest extends TestCase { $this->assertNull($component->getActiveSession()); // Identity is a Oauth2Identity - $component->setIdentity(mock(OAuth2Identity::class)); + $component->setIdentity(mock(LegacyOAuth2Identity::class)); $this->assertNull($component->getActiveSession()); // Identity is correct, but have no jti claim diff --git a/api/tests/unit/components/User/IdentityFactoryTest.php b/api/tests/unit/components/User/IdentityFactoryTest.php index b3d6851..899f364 100644 --- a/api/tests/unit/components/User/IdentityFactoryTest.php +++ b/api/tests/unit/components/User/IdentityFactoryTest.php @@ -7,7 +7,7 @@ use api\components\OAuth2\Component; use api\components\OAuth2\Entities\AccessTokenEntity; use api\components\User\IdentityFactory; use api\components\User\JwtIdentity; -use api\components\User\OAuth2Identity; +use api\components\User\LegacyOAuth2Identity; use api\tests\unit\TestCase; use Carbon\Carbon; use League\OAuth2\Server\AbstractServer; @@ -37,7 +37,7 @@ class IdentityFactoryTest extends TestCase { Yii::$app->set('oauth', $component); $identity = IdentityFactory::findIdentityByAccessToken('mock-token'); - $this->assertInstanceOf(OAuth2Identity::class, $identity); + $this->assertInstanceOf(LegacyOAuth2Identity::class, $identity); } public function testFindIdentityByAccessTokenWithEmptyValue() { diff --git a/api/tests/unit/components/User/OAuth2IdentityTest.php b/api/tests/unit/components/User/LegacyOAuth2IdentityTest.php similarity index 82% rename from api/tests/unit/components/User/OAuth2IdentityTest.php rename to api/tests/unit/components/User/LegacyOAuth2IdentityTest.php index 790f139..e19804d 100644 --- a/api/tests/unit/components/User/OAuth2IdentityTest.php +++ b/api/tests/unit/components/User/LegacyOAuth2IdentityTest.php @@ -5,14 +5,12 @@ namespace api\tests\unit\components\User; use api\components\OAuth2\Component; use api\components\OAuth2\Entities\AccessTokenEntity; -use api\components\User\OAuth2Identity; +use api\components\User\LegacyOAuth2Identity; use api\tests\unit\TestCase; -use League\OAuth2\Server\AbstractServer; -use League\OAuth2\Server\Storage\AccessTokenInterface; use Yii; use yii\web\UnauthorizedHttpException; -class OAuth2IdentityTest extends TestCase { +class LegacyOAuth2IdentityTest extends TestCase { public function testFindIdentityByAccessToken() { $accessToken = new AccessTokenEntity(mock(AbstractServer::class)); @@ -20,7 +18,7 @@ class OAuth2IdentityTest extends TestCase { $accessToken->setId('mock-token'); $this->mockFoundedAccessToken($accessToken); - $identity = OAuth2Identity::findIdentityByAccessToken('mock-token'); + $identity = LegacyOAuth2Identity::findIdentityByAccessToken('mock-token'); $this->assertSame('mock-token', $identity->getId()); } @@ -28,7 +26,7 @@ class OAuth2IdentityTest extends TestCase { $this->expectException(UnauthorizedHttpException::class); $this->expectExceptionMessage('Incorrect token'); - OAuth2Identity::findIdentityByAccessToken('not exists token'); + LegacyOAuth2Identity::findIdentityByAccessToken('not exists token'); } public function testFindIdentityByAccessTokenWithExpiredToken() { @@ -39,7 +37,7 @@ class OAuth2IdentityTest extends TestCase { $accessToken->setExpireTime(time() - 3600); $this->mockFoundedAccessToken($accessToken); - OAuth2Identity::findIdentityByAccessToken('mock-token'); + LegacyOAuth2Identity::findIdentityByAccessToken('mock-token'); } private function mockFoundedAccessToken(AccessTokenEntity $accessToken) { diff --git a/common/components/Redis/Key.php b/common/components/Redis/Key.php deleted file mode 100644 index c8b3be5..0000000 --- a/common/components/Redis/Key.php +++ /dev/null @@ -1,63 +0,0 @@ -key = $this->buildKey($key); - } - - public function getKey(): string { - return $this->key; - } - - public function getValue() { - return Yii::$app->redis->get($this->key); - } - - public function setValue($value): self { - Yii::$app->redis->set($this->key, $value); - return $this; - } - - public function delete(): self { - Yii::$app->redis->del($this->getKey()); - return $this; - } - - public function exists(): bool { - return (bool)Yii::$app->redis->exists($this->key); - } - - public function expire(int $ttl): self { - Yii::$app->redis->expire($this->key, $ttl); - return $this; - } - - public function expireAt(int $unixTimestamp): self { - Yii::$app->redis->expireat($this->key, $unixTimestamp); - return $this; - } - - private function buildKey(array $parts): string { - $keyParts = []; - foreach ($parts as $part) { - $keyParts[] = str_replace('_', ':', $part); - } - - return implode(':', $keyParts); - } - -} diff --git a/common/components/Redis/Set.php b/common/components/Redis/Set.php deleted file mode 100644 index 5023aea..0000000 --- a/common/components/Redis/Set.php +++ /dev/null @@ -1,50 +0,0 @@ -redis->sadd($this->getKey(), $value); - return $this; - } - - public function remove($value): self { - Yii::$app->redis->srem($this->getKey(), $value); - return $this; - } - - public function members(): array { - return Yii::$app->redis->smembers($this->getKey()); - } - - public function getValue(): array { - return $this->members(); - } - - public function exists(string $value = null): bool { - if ($value === null) { - return parent::exists(); - } - - return (bool)Yii::$app->redis->sismember($this->getKey(), $value); - } - - public function diff(array $sets): array { - return Yii::$app->redis->sdiff([$this->getKey(), implode(' ', $sets)]); - } - - /** - * @inheritdoc - */ - public function getIterator() { - return new ArrayIterator($this->members()); - } - -} From a148da2ecfb6bffdf7b63f56f0ce8b194fc4d89a Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 23 Sep 2019 00:53:13 +0300 Subject: [PATCH 08/28] Add tests for the legacy tokens --- .../OAuth2/Grants/RefreshTokenGrant.php | 2 +- api/components/User/LegacyOAuth2Identity.php | 2 +- api/tests/functional.suite.dist.yml | 2 +- .../functional/oauth/RefreshTokenCest.php | 15 +++- .../components/User/IdentityFactoryTest.php | 32 +++----- .../User/LegacyOAuth2IdentityTest.php | 39 +++------ common/tests/_support/FixtureHelper.php | 4 + common/tests/_support/Redis/Fixture.php | 82 +++++++++++++++++++ .../LegacyOauthAccessTokenFixture.php | 14 ++++ .../LegacyOauthAccessTokenScopeFixture.php | 16 ++++ .../LegacyOauthRefreshTokenFixture.php | 14 ++++ .../LegacyOauthSessionScopeFixtures.php | 16 ++++ .../legacy-oauth-access-tokens-scopes.php | 4 + .../data/legacy-oauth-access-tokens.php | 16 ++++ .../data/legacy-oauth-refresh-tokens.php | 8 ++ .../data/legacy-oauth-sessions-scopes.php | 4 + common/tests/fixtures/data/oauth-sessions.php | 4 + 17 files changed, 222 insertions(+), 52 deletions(-) create mode 100644 common/tests/_support/Redis/Fixture.php create mode 100644 common/tests/fixtures/LegacyOauthAccessTokenFixture.php create mode 100644 common/tests/fixtures/LegacyOauthAccessTokenScopeFixture.php create mode 100644 common/tests/fixtures/LegacyOauthRefreshTokenFixture.php create mode 100644 common/tests/fixtures/LegacyOauthSessionScopeFixtures.php create mode 100644 common/tests/fixtures/data/legacy-oauth-access-tokens-scopes.php create mode 100644 common/tests/fixtures/data/legacy-oauth-access-tokens.php create mode 100644 common/tests/fixtures/data/legacy-oauth-refresh-tokens.php create mode 100644 common/tests/fixtures/data/legacy-oauth-sessions-scopes.php diff --git a/api/components/OAuth2/Grants/RefreshTokenGrant.php b/api/components/OAuth2/Grants/RefreshTokenGrant.php index b1ce1bf..53d7b12 100644 --- a/api/components/OAuth2/Grants/RefreshTokenGrant.php +++ b/api/components/OAuth2/Grants/RefreshTokenGrant.php @@ -14,7 +14,7 @@ use Yii; class RefreshTokenGrant extends BaseRefreshTokenGrant { /** - * Previously, refresh tokens was stored in Redis. + * Previously, refresh tokens were stored in Redis. * If received refresh token is matches the legacy token template, * restore the information from the legacy storage. * diff --git a/api/components/User/LegacyOAuth2Identity.php b/api/components/User/LegacyOAuth2Identity.php index 422a460..44b5023 100644 --- a/api/components/User/LegacyOAuth2Identity.php +++ b/api/components/User/LegacyOAuth2Identity.php @@ -32,7 +32,7 @@ class LegacyOAuth2Identity implements IdentityInterface { */ private $session = false; - private function __construct(string $accessToken, string $sessionId, array $scopes) { + private function __construct(string $accessToken, int $sessionId, array $scopes) { $this->accessToken = $accessToken; $this->sessionId = $sessionId; $this->scopes = $scopes; diff --git a/api/tests/functional.suite.dist.yml b/api/tests/functional.suite.dist.yml index 93e869e..c5ed796 100644 --- a/api/tests/functional.suite.dist.yml +++ b/api/tests/functional.suite.dist.yml @@ -17,4 +17,4 @@ modules: host: redis port: 6379 database: 0 - cleanupBefore: 'test' + cleanupBefore: 'suite' diff --git a/api/tests/functional/oauth/RefreshTokenCest.php b/api/tests/functional/oauth/RefreshTokenCest.php index dc5e291..edc68bb 100644 --- a/api/tests/functional/oauth/RefreshTokenCest.php +++ b/api/tests/functional/oauth/RefreshTokenCest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace api\tests\functional\oauth; use api\tests\functional\_steps\OauthSteps; +use api\tests\FunctionalTester; class RefreshTokenCest { @@ -53,6 +54,18 @@ class RefreshTokenCest { $this->canSeeRefreshTokenSuccess($I); } + public function refreshTokenUsingLegacyToken(FunctionalTester $I) { + $I->wantTo('refresh token using the legacy token'); + $I->sendPOST('/api/oauth2/v1/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => 'op7kPGAgHlsXRBJtkFg7wKOTpodvtHVW5NxR7Tjr', + 'client_id' => 'test1', + 'client_secret' => 'eEvrKHF47sqiaX94HsX-xXzdGiz3mcsq', + 'scope' => 'minecraft_server_session account_info', + ]); + $this->canSeeRefreshTokenSuccess($I); + } + public function passInvalidRefreshToken(OauthSteps $I) { $I->wantToTest('behaviour of the server when invalid refresh token passed'); $I->sendPOST('/api/oauth2/v1/token', [ @@ -85,7 +98,7 @@ class RefreshTokenCest { ]); } - private function canSeeRefreshTokenSuccess(OauthSteps $I) { + private function canSeeRefreshTokenSuccess(FunctionalTester $I) { $I->canSeeResponseCodeIs(200); $I->canSeeResponseContainsJson([ 'token_type' => 'Bearer', diff --git a/api/tests/unit/components/User/IdentityFactoryTest.php b/api/tests/unit/components/User/IdentityFactoryTest.php index 899f364..73bb6a4 100644 --- a/api/tests/unit/components/User/IdentityFactoryTest.php +++ b/api/tests/unit/components/User/IdentityFactoryTest.php @@ -3,40 +3,30 @@ declare(strict_types=1); namespace api\tests\unit\components\User; -use api\components\OAuth2\Component; -use api\components\OAuth2\Entities\AccessTokenEntity; use api\components\User\IdentityFactory; use api\components\User\JwtIdentity; use api\components\User\LegacyOAuth2Identity; use api\tests\unit\TestCase; use Carbon\Carbon; -use League\OAuth2\Server\AbstractServer; -use League\OAuth2\Server\Storage\AccessTokenInterface; -use Yii; +use common\tests\fixtures; use yii\web\UnauthorizedHttpException; class IdentityFactoryTest extends TestCase { + public function _fixtures(): array { + return [ + fixtures\LegacyOauthAccessTokenFixture::class, + fixtures\LegacyOauthAccessTokenScopeFixture::class, + ]; + } + public function testFindIdentityByAccessToken() { - // Find identity by jwt token + // Find identity by the JWT $identity = IdentityFactory::findIdentityByAccessToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ.4Oidvuo4spvUf9hkpHR72eeqZUh2Zbxh_L8Od3vcgTj--0iOrcOEp6zwmEW6vF7BTHtjz2b3mXce61bqsCjXjQ'); $this->assertInstanceOf(JwtIdentity::class, $identity); - // Find identity by oauth2 token - $accessToken = new AccessTokenEntity(mock(AbstractServer::class)); - $accessToken->setExpireTime(time() + 3600); - $accessToken->setId('mock-token'); - - /** @var AccessTokenInterface|\Mockery\MockInterface $accessTokensStorage */ - $accessTokensStorage = mock(AccessTokenInterface::class); - $accessTokensStorage->shouldReceive('get')->with('mock-token')->andReturn($accessToken); - - /** @var Component|\Mockery\MockInterface $component */ - $component = mock(Component::class); - $component->shouldReceive('getAccessTokenStorage')->andReturn($accessTokensStorage); - Yii::$app->set('oauth', $component); - - $identity = IdentityFactory::findIdentityByAccessToken('mock-token'); + // Find identity by the legacy OAuth2 token + $identity = IdentityFactory::findIdentityByAccessToken('ZZQP8sS9urzriy8N9h6FwFNMOH3PkZ5T5PLqS6SX'); $this->assertInstanceOf(LegacyOAuth2Identity::class, $identity); } diff --git a/api/tests/unit/components/User/LegacyOAuth2IdentityTest.php b/api/tests/unit/components/User/LegacyOAuth2IdentityTest.php index e19804d..9c37d29 100644 --- a/api/tests/unit/components/User/LegacyOAuth2IdentityTest.php +++ b/api/tests/unit/components/User/LegacyOAuth2IdentityTest.php @@ -3,52 +3,37 @@ declare(strict_types=1); namespace api\tests\unit\components\User; -use api\components\OAuth2\Component; -use api\components\OAuth2\Entities\AccessTokenEntity; use api\components\User\LegacyOAuth2Identity; use api\tests\unit\TestCase; -use Yii; +use common\tests\fixtures; use yii\web\UnauthorizedHttpException; class LegacyOAuth2IdentityTest extends TestCase { - public function testFindIdentityByAccessToken() { - $accessToken = new AccessTokenEntity(mock(AbstractServer::class)); - $accessToken->setExpireTime(time() + 3600); - $accessToken->setId('mock-token'); - $this->mockFoundedAccessToken($accessToken); + public function _fixtures(): array { + return [ + fixtures\LegacyOauthAccessTokenFixture::class, + fixtures\LegacyOauthAccessTokenScopeFixture::class, + ]; + } - $identity = LegacyOAuth2Identity::findIdentityByAccessToken('mock-token'); - $this->assertSame('mock-token', $identity->getId()); + public function testFindIdentityByAccessToken() { + $identity = LegacyOAuth2Identity::findIdentityByAccessToken('ZZQP8sS9urzriy8N9h6FwFNMOH3PkZ5T5PLqS6SX'); + $this->assertSame('ZZQP8sS9urzriy8N9h6FwFNMOH3PkZ5T5PLqS6SX', $identity->getId()); } public function testFindIdentityByAccessTokenWithNonExistsToken() { $this->expectException(UnauthorizedHttpException::class); $this->expectExceptionMessage('Incorrect token'); - LegacyOAuth2Identity::findIdentityByAccessToken('not exists token'); + LegacyOAuth2Identity::findIdentityByAccessToken('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'); } public function testFindIdentityByAccessTokenWithExpiredToken() { $this->expectException(UnauthorizedHttpException::class); $this->expectExceptionMessage('Token expired'); - $accessToken = new AccessTokenEntity(mock(AbstractServer::class)); - $accessToken->setExpireTime(time() - 3600); - $this->mockFoundedAccessToken($accessToken); - - LegacyOAuth2Identity::findIdentityByAccessToken('mock-token'); - } - - private function mockFoundedAccessToken(AccessTokenEntity $accessToken) { - /** @var AccessTokenInterface|\Mockery\MockInterface $accessTokensStorage */ - $accessTokensStorage = mock(AccessTokenInterface::class); - $accessTokensStorage->shouldReceive('get')->with('mock-token')->andReturn($accessToken); - - /** @var Component|\Mockery\MockInterface $component */ - $component = mock(Component::class); - $component->shouldReceive('getAccessTokenStorage')->andReturn($accessTokensStorage); - Yii::$app->set('oauth', $component); + LegacyOAuth2Identity::findIdentityByAccessToken('rc0sOF1SLdOxuD3bJcCQENmGTeYrGgy12qJScMx4'); } } diff --git a/common/tests/_support/FixtureHelper.php b/common/tests/_support/FixtureHelper.php index 7fb0d73..31b4070 100644 --- a/common/tests/_support/FixtureHelper.php +++ b/common/tests/_support/FixtureHelper.php @@ -52,7 +52,11 @@ class FixtureHelper extends Module { 'usernamesHistory' => fixtures\UsernameHistoryFixture::class, 'oauthClients' => fixtures\OauthClientFixture::class, 'oauthSessions' => fixtures\OauthSessionFixture::class, + 'legacyOauthSessionsScopes' => fixtures\LegacyOauthSessionScopeFixtures::class, + 'legacyOauthAccessTokens' => fixtures\LegacyOauthAccessTokenFixture::class, + 'legacyOauthAccessTokensScopes' => fixtures\LegacyOauthAccessTokenScopeFixture::class, 'oauthRefreshTokens' => fixtures\OauthRefreshTokensFixture::class, + 'legacyOauthRefreshTokens' => fixtures\LegacyOauthRefreshTokenFixture::class, 'minecraftAccessKeys' => fixtures\MinecraftAccessKeyFixture::class, ]; } diff --git a/common/tests/_support/Redis/Fixture.php b/common/tests/_support/Redis/Fixture.php new file mode 100644 index 0000000..e69cc75 --- /dev/null +++ b/common/tests/_support/Redis/Fixture.php @@ -0,0 +1,82 @@ +redis = Instance::ensure($this->redis, Connection::class); + } + + public function load() { + $this->data = []; + foreach ($this->getData() as $key => $data) { + $key = $this->buildKey($key); + $preparedData = $this->prepareData($data); + if (is_array($preparedData)) { + $this->redis->sadd($key, ...$preparedData); + } else { + $this->redis->set($key, $preparedData); + } + + $this->data[$key] = $data; + } + } + + public function unload() { + $this->redis->flushdb(); + } + + protected function getData(): array { + return $this->loadData($this->dataFile); + } + + protected function prepareData($input) { + if (is_string($input)) { + return $input; + } + + if (is_int($input) || is_bool($input)) { + return (string)$input; + } + + if (is_array($input)) { + if (!ArrayHelper::isAssociative($input)) { + return $input; + } + + return Json::encode($input); + } + + throw new InvalidArgumentException('Unsupported input type'); + } + + protected function buildKey($key): string { + return $this->keysPrefix . $key . $this->keysPostfix; + } + +} diff --git a/common/tests/fixtures/LegacyOauthAccessTokenFixture.php b/common/tests/fixtures/LegacyOauthAccessTokenFixture.php new file mode 100644 index 0000000..397ff71 --- /dev/null +++ b/common/tests/fixtures/LegacyOauthAccessTokenFixture.php @@ -0,0 +1,14 @@ + ['minecraft_server_session', 'obtain_own_account_info'], +]; diff --git a/common/tests/fixtures/data/legacy-oauth-access-tokens.php b/common/tests/fixtures/data/legacy-oauth-access-tokens.php new file mode 100644 index 0000000..dfa4445 --- /dev/null +++ b/common/tests/fixtures/data/legacy-oauth-access-tokens.php @@ -0,0 +1,16 @@ + [ + 'id' => 'ZZQP8sS9urzriy8N9h6FwFNMOH3PkZ5T5PLqS6SX', + 'session_id' => 1, + 'expire_time' => Carbon::now()->addHour()->getTimestamp(), + ], + 'rc0sOF1SLdOxuD3bJcCQENmGTeYrGgy12qJScMx4' => [ + 'id' => 'rc0sOF1SLdOxuD3bJcCQENmGTeYrGgy12qJScMx4', + 'session_id' => 1, + 'expire_time' => Carbon::now()->subHour()->getTimestamp(), + ], +]; diff --git a/common/tests/fixtures/data/legacy-oauth-refresh-tokens.php b/common/tests/fixtures/data/legacy-oauth-refresh-tokens.php new file mode 100644 index 0000000..1cd6ff4 --- /dev/null +++ b/common/tests/fixtures/data/legacy-oauth-refresh-tokens.php @@ -0,0 +1,8 @@ + [ + 'id' => 'op7kPGAgHlsXRBJtkFg7wKOTpodvtHVW5NxR7Tjr', + 'access_token_id' => 'cynbpR53GK5HyvHuTtriHP7JpdqvFaYnWSS1twXX', + 'session_id' => 1, + ], +]; diff --git a/common/tests/fixtures/data/legacy-oauth-sessions-scopes.php b/common/tests/fixtures/data/legacy-oauth-sessions-scopes.php new file mode 100644 index 0000000..510c92c --- /dev/null +++ b/common/tests/fixtures/data/legacy-oauth-sessions-scopes.php @@ -0,0 +1,4 @@ + ['minecraft_server_session', 'obtain_own_account_info'], +]; diff --git a/common/tests/fixtures/data/oauth-sessions.php b/common/tests/fixtures/data/oauth-sessions.php index 1075642..5828c20 100644 --- a/common/tests/fixtures/data/oauth-sessions.php +++ b/common/tests/fixtures/data/oauth-sessions.php @@ -3,24 +3,28 @@ return [ 'admin-test1' => [ 'account_id' => 1, 'client_id' => 'test1', + 'legacy_id' => 1, 'scopes' => null, 'created_at' => 1479944472, ], 'banned-account-session' => [ 'account_id' => 10, 'client_id' => 'test1', + 'legacy_id' => 2, 'scopes' => null, 'created_at' => 1481421663, ], 'deleted-client-session' => [ 'account_id' => 1, 'client_id' => 'deleted-oauth-client-with-sessions', + 'legacy_id' => 3, 'scopes' => null, 'created_at' => 1519510065, ], 'actual-deleted-client-session' => [ 'account_id' => 2, 'client_id' => 'deleted-oauth-client-with-sessions', + 'legacy_id' => 4, 'scopes' => null, 'created_at' => 1519511568, ], From 2beacd08279e0a5d1bb110757f6862574defabea Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 23 Sep 2019 01:03:36 +0300 Subject: [PATCH 09/28] Add tests for the legacy tokens, fix some tests cases [skip ci] --- api/components/Tokens/TokensFactory.php | 4 ++-- common/tests/unit/tasks/ClearOauthSessionsTest.php | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/components/Tokens/TokensFactory.php b/api/components/Tokens/TokensFactory.php index 992c909..2af8142 100644 --- a/api/components/Tokens/TokensFactory.php +++ b/api/components/Tokens/TokensFactory.php @@ -35,9 +35,9 @@ class TokensFactory { public static function createForOAuthClient(AccessTokenEntityInterface $accessToken): Token { $payloads = [ 'aud' => self::buildAud($accessToken->getClient()->getIdentifier()), - 'ely-scopes' => array_map(static function(ScopeEntityInterface $scope): string { + 'ely-scopes' => implode(',', array_map(static function(ScopeEntityInterface $scope): string { return $scope->getIdentifier(); - }, $accessToken->getScopes()), + }, $accessToken->getScopes())), 'exp' => $accessToken->getExpiryDateTime()->getTimestamp(), ]; if ($accessToken->getUserIdentifier() !== null) { diff --git a/common/tests/unit/tasks/ClearOauthSessionsTest.php b/common/tests/unit/tasks/ClearOauthSessionsTest.php index e962ba5..3246867 100644 --- a/common/tests/unit/tasks/ClearOauthSessionsTest.php +++ b/common/tests/unit/tasks/ClearOauthSessionsTest.php @@ -38,14 +38,14 @@ class ClearOauthSessionsTest extends TestCase { $task->notSince = 1519510065; $task->execute(mock(Queue::class)); - $this->assertFalse(OauthSession::find()->andWhere(['id' => 3])->exists()); - $this->assertTrue(OauthSession::find()->andWhere(['id' => 4])->exists()); + $this->assertFalse(OauthSession::find()->andWhere(['legacy_id' => 3])->exists()); + $this->assertTrue(OauthSession::find()->andWhere(['legacy_id' => 4])->exists()); $task = new ClearOauthSessions(); $task->clientId = 'deleted-oauth-client-with-sessions'; $task->execute(mock(Queue::class)); - $this->assertFalse(OauthSession::find()->andWhere(['id' => 4])->exists()); + $this->assertFalse(OauthSession::find()->andWhere(['legacy_id' => 4])->exists()); $task = new ClearOauthSessions(); $task->clientId = 'some-not-exists-client-id'; From 22982b319b36aa097eb96423d92122f2f44b754b Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Tue, 24 Sep 2019 01:56:32 +0300 Subject: [PATCH 10/28] Fix all tests --- .../OAuth2/Repositories/InternalScopeRepository.php | 9 +++++++++ api/tests/functional/_steps/OauthSteps.php | 2 +- api/tests/unit/models/authentication/LoginFormTest.php | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/api/components/OAuth2/Repositories/InternalScopeRepository.php b/api/components/OAuth2/Repositories/InternalScopeRepository.php index ef0ca87..c5f46d6 100644 --- a/api/components/OAuth2/Repositories/InternalScopeRepository.php +++ b/api/components/OAuth2/Repositories/InternalScopeRepository.php @@ -22,7 +22,12 @@ class InternalScopeRepository implements ScopeRepositoryInterface { P::ESCAPE_IDENTITY_VERIFICATION, ]; + private const PUBLIC_SCOPES_TO_INTERNAL_PERMISSIONS = [ + 'internal_account_info' => P::OBTAIN_EXTENDED_ACCOUNT_INFO, + ]; + public function getScopeEntityByIdentifier($identifier): ?ScopeEntityInterface { + $identifier = $this->convertToInternalPermission($identifier); if (!in_array($identifier, self::ALLOWED_SCOPES, true)) { return null; } @@ -51,4 +56,8 @@ class InternalScopeRepository implements ScopeRepositoryInterface { return $scopes; } + private function convertToInternalPermission(string $publicScope): string { + return self::PUBLIC_SCOPES_TO_INTERNAL_PERMISSIONS[$publicScope] ?? $publicScope; + } + } diff --git a/api/tests/functional/_steps/OauthSteps.php b/api/tests/functional/_steps/OauthSteps.php index 8d7d2bd..c6cffc4 100644 --- a/api/tests/functional/_steps/OauthSteps.php +++ b/api/tests/functional/_steps/OauthSteps.php @@ -54,7 +54,7 @@ class OauthSteps extends FunctionalTester { 'grant_type' => 'client_credentials', 'client_id' => $useTrusted ? 'trusted-client' : 'default-client', 'client_secret' => $useTrusted ? 'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9' : 'AzWRy7ZjS1yRQUk2vRBDic8fprOKDB1W', - 'scope' => implode(',', $permissions), + 'scope' => implode(' ', $permissions), ]); $response = json_decode($this->grabResponse(), true); diff --git a/api/tests/unit/models/authentication/LoginFormTest.php b/api/tests/unit/models/authentication/LoginFormTest.php index 384e63a..f299e40 100644 --- a/api/tests/unit/models/authentication/LoginFormTest.php +++ b/api/tests/unit/models/authentication/LoginFormTest.php @@ -131,6 +131,7 @@ class LoginFormTest extends TestCase { 'login' => 'erickskrauch', 'password' => '12345678', 'account' => new Account([ + 'id' => 1, 'username' => 'erickskrauch', 'password' => '12345678', 'status' => Account::STATUS_ACTIVE, From e52dbdbf194cf63b82108adb41edccece08c4bec Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Thu, 7 Nov 2019 01:12:18 +0300 Subject: [PATCH 11/28] Do not include offline_access scope into access_token --- .../OAuth2/Entities/AccessTokenEntity.php | 24 ++++++++-- .../OAuth2/Entities/AccessTokenEntityTest.php | 44 +++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 api/tests/unit/components/OAuth2/Entities/AccessTokenEntityTest.php diff --git a/api/components/OAuth2/Entities/AccessTokenEntity.php b/api/components/OAuth2/Entities/AccessTokenEntity.php index f9441fe..7b077ef 100644 --- a/api/components/OAuth2/Entities/AccessTokenEntity.php +++ b/api/components/OAuth2/Entities/AccessTokenEntity.php @@ -3,9 +3,12 @@ declare(strict_types=1); namespace api\components\OAuth2\Entities; +use api\components\OAuth2\Repositories\PublicScopeRepository; use api\components\Tokens\TokensFactory; +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; @@ -15,16 +18,31 @@ class AccessTokenEntity implements AccessTokenEntityInterface { 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 { - // TODO: strip "offline_access" scope from the scopes list - return (string)TokensFactory::createForOAuthClient($this); + $scopes = $this->scopes; + $this->scopes = array_filter($this->scopes, function(ScopeEntityInterface $scope): bool { + return $scope->getIdentifier() !== PublicScopeRepository::OFFLINE_ACCESS; + }); + + $token = TokensFactory::createForOAuthClient($this); + + $this->scopes = $scopes; + + return (string)$token; } 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() { + public function getExpiryDateTime(): DateTimeImmutable { // TODO: extend token life depending on scopes list return $this->parentGetExpiryDateTime(); } diff --git a/api/tests/unit/components/OAuth2/Entities/AccessTokenEntityTest.php b/api/tests/unit/components/OAuth2/Entities/AccessTokenEntityTest.php new file mode 100644 index 0000000..959c52e --- /dev/null +++ b/api/tests/unit/components/OAuth2/Entities/AccessTokenEntityTest.php @@ -0,0 +1,44 @@ +createMock(ClientEntityInterface::class); + $client->method('getIdentifier')->willReturn('mockClientId'); + + $entity = new AccessTokenEntity(); + $entity->setClient($client); + $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()); + } + + private function createScopeEntity(string $id): ScopeEntityInterface { + /** @var ScopeEntityInterface|\PHPUnit\Framework\MockObject\MockObject $entity */ + $entity = $this->createMock(ScopeEntityInterface::class); + $entity->method('getIdentifier')->willReturn($id); + + return $entity; + } + +} From a9a56c9e1d6089fad376cddff42a72646e886167 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Wed, 4 Dec 2019 13:24:30 +0300 Subject: [PATCH 12/28] Extract encryption key into the configuration param --- .env-dist | 2 ++ api/components/OAuth2/Component.php | 7 ++++++- api/config/config-test.php | 3 +++ api/config/config.php | 4 ++++ autocompletion.php | 2 +- common/config/config.php | 7 ++----- 6 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.env-dist b/.env-dist index 0b0949b..f2d53d9 100644 --- a/.env-dist +++ b/.env-dist @@ -7,8 +7,10 @@ EMAILS_RENDERER_HOST=http://emails-renderer:3000 ## Security params JWT_USER_SECRET= +JWT_ENCRYPTION_KEY= JWT_PUBLIC_KEY_PATH= JWT_PRIVATE_KEY_PATH= +JWT_PRIVATE_KEY_PASS= ## External services RECAPTCHA_PUBLIC= diff --git a/api/components/OAuth2/Component.php b/api/components/OAuth2/Component.php index 10350f0..38081a1 100644 --- a/api/components/OAuth2/Component.php +++ b/api/components/OAuth2/Component.php @@ -13,6 +13,11 @@ use yii\base\Component as BaseComponent; class Component extends BaseComponent { + /** + * @var string|\Defuse\Crypto\Key + */ + public $encryptionKey; + /** * @var AuthorizationServer */ @@ -34,7 +39,7 @@ class Component extends BaseComponent { $accessTokensRepo, new Repositories\EmptyScopeRepository(), new EmptyKey(), - '123' // TODO: extract to the variable + $this->encryptionKey ); $authCodeGrant = new AuthCodeGrant($authCodesRepo, $refreshTokensRepo, new DateInterval('PT10M')); $authCodeGrant->disableRequireCodeChallengeForPublicClients(); diff --git a/api/config/config-test.php b/api/config/config-test.php index 8f0952d..af8f598 100644 --- a/api/config/config-test.php +++ b/api/config/config-test.php @@ -1,6 +1,9 @@ [ + 'oauth' => [ + 'encryptionKey' => 'mock-encryption-key', + ], 'tokens' => [ 'hmacKey' => 'tests-secret-key', 'privateKeyPath' => codecept_data_dir('certs/private.pem'), diff --git a/api/config/config.php b/api/config/config.php index b51df70..420a73b 100644 --- a/api/config/config.php +++ b/api/config/config.php @@ -11,6 +11,10 @@ return [ 'user' => [ 'class' => api\components\User\Component::class, ], + 'oauth' => [ + 'class' => api\components\OAuth2\Component::class, + 'encryptionKey' => getenv('JWT_ENCRYPTION_KEY'), + ], 'tokens' => [ 'class' => api\components\Tokens\Component::class, 'hmacKey' => getenv('JWT_USER_SECRET'), diff --git a/autocompletion.php b/autocompletion.php index fa14d25..e07fc51 100644 --- a/autocompletion.php +++ b/autocompletion.php @@ -22,7 +22,6 @@ class Yii extends \yii\BaseYii { * @property \GuzzleHttp\Client $guzzle * @property \common\components\EmailsRenderer\Component $emailsRenderer * @property \mito\sentry\Component $sentry - * @property \api\components\OAuth2\Component $oauth * @property \common\components\StatsD $statsd * @property \yii\queue\Queue $queue * @property \api\components\Tokens\Component $tokens @@ -36,6 +35,7 @@ abstract class BaseApplication extends yii\base\Application { * * @property \api\components\User\Component $user User component. * @property \api\components\ReCaptcha\Component $reCaptcha + * @property \api\components\OAuth2\Component $oauth * * @method \api\components\User\Component getUser() */ diff --git a/common/config/config.php b/common/config/config.php index bc10472..7f282c9 100644 --- a/common/config/config.php +++ b/common/config/config.php @@ -12,7 +12,7 @@ return [ '@console' => '@root/console', ], 'params' => [ - 'fromEmail' => 'ely@ely.by', + 'fromEmail' => 'account@ely.by', 'supportEmail' => 'support@ely.by', ], 'container' => [ @@ -91,12 +91,9 @@ return [ ], 'emailsRenderer' => [ 'class' => common\components\EmailsRenderer\Component::class, - 'serviceUrl' => getenv('EMAILS_RENDERER_HOST'), + 'serviceUrl' => getenv('EMAILS_RENDERER_HOST') ?: 'http://emails-renderer:3000', 'basePath' => '/images/emails', ], - 'oauth' => [ - 'class' => api\components\OAuth2\Component::class, - ], 'authManager' => [ 'class' => \api\rbac\Manager::class, 'itemFile' => '@api/rbac/.generated/items.php', From 060a4e960a905364398223a94e335a1f0f478233 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Wed, 4 Dec 2019 13:40:12 +0300 Subject: [PATCH 13/28] Handle legacy refresh tokens --- api/components/OAuth2/Grants/RefreshTokenGrant.php | 5 +++++ .../OAuth2/Repositories/RefreshTokenRepository.php | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/api/components/OAuth2/Grants/RefreshTokenGrant.php b/api/components/OAuth2/Grants/RefreshTokenGrant.php index 53d7b12..648ef69 100644 --- a/api/components/OAuth2/Grants/RefreshTokenGrant.php +++ b/api/components/OAuth2/Grants/RefreshTokenGrant.php @@ -46,6 +46,11 @@ class RefreshTokenGrant extends BaseRefreshTokenGrant { return null; } + /** + * @param string $refreshToken + * @return array + * @throws OAuthServerException + */ private function validateLegacyRefreshToken(string $refreshToken): array { $result = Yii::$app->redis->get("oauth:refresh:tokens:{$refreshToken}"); if ($result === null) { diff --git a/api/components/OAuth2/Repositories/RefreshTokenRepository.php b/api/components/OAuth2/Repositories/RefreshTokenRepository.php index fc43c68..b1096ed 100644 --- a/api/components/OAuth2/Repositories/RefreshTokenRepository.php +++ b/api/components/OAuth2/Repositories/RefreshTokenRepository.php @@ -30,8 +30,7 @@ class RefreshTokenRepository implements RefreshTokenRepositoryInterface { } public function isRefreshTokenRevoked($tokenId): bool { - // TODO: validate old refresh tokens - return !OauthRefreshToken::find()->andWhere(['id' => $tokenId])->exists(); + return OauthRefreshToken::find()->andWhere(['id' => $tokenId])->exists() === false; } } From a81ef5cac25932216fac34c03b76e67fe56c5557 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Wed, 4 Dec 2019 21:10:15 +0300 Subject: [PATCH 14/28] Replace separate minecraft access tokens with JWT --- .../OAuth2/Entities/AccessTokenEntity.php | 4 +- api/components/Tokens/Component.php | 23 +++++- api/components/Tokens/EncryptedValue.php | 21 ++++++ api/components/Tokens/TokensFactory.php | 36 ++++++--- api/config/config-test.php | 1 + api/config/config.php | 4 + .../authentication/ConfirmEmailForm.php | 3 +- api/models/authentication/LoginForm.php | 3 +- .../authentication/RecoverPasswordForm.php | 3 +- .../authentication/RefreshTokenForm.php | 3 +- api/models/base/ApiForm.php | 4 +- .../controllers/AuthenticationController.php | 35 +++++++-- .../authserver/models/AuthenticateData.php | 40 ++++++---- .../authserver/models/AuthenticationForm.php | 52 ++++++------- .../authserver/models/InvalidateForm.php | 22 +++--- .../authserver/models/RefreshTokenForm.php | 51 +++++++++---- api/modules/authserver/models/SignoutForm.php | 28 +++---- .../authserver/models/ValidateForm.php | 30 ++++---- .../validators/AccessTokenValidator.php | 69 +++++++++++++++++ .../validators/ClientTokenValidator.php | 7 +- .../validators/RequiredValidator.php | 4 +- api/tests/_pages/AuthserverRoute.php | 26 ------- api/tests/_support/FunctionalTester.php | 3 +- .../functional/_steps/AuthserverSteps.php | 6 +- .../authserver/AuthorizationCest.php | 75 ++++++------------- .../functional/authserver/InvalidateCest.php | 16 +--- .../functional/authserver/RefreshCest.php | 64 ++++++++++------ .../functional/authserver/SignoutCest.php | 37 +++------ .../functional/authserver/ValidateCest.php | 45 +++++++---- .../components/Tokens/TokensFactoryTest.php | 6 +- .../models/AuthenticationFormTest.php | 2 +- autocompletion.php | 9 ++- composer.json | 1 + composer.lock | 2 +- 34 files changed, 432 insertions(+), 303 deletions(-) create mode 100644 api/components/Tokens/EncryptedValue.php create mode 100644 api/modules/authserver/validators/AccessTokenValidator.php delete mode 100644 api/tests/_pages/AuthserverRoute.php diff --git a/api/components/OAuth2/Entities/AccessTokenEntity.php b/api/components/OAuth2/Entities/AccessTokenEntity.php index 7b077ef..5d352e5 100644 --- a/api/components/OAuth2/Entities/AccessTokenEntity.php +++ b/api/components/OAuth2/Entities/AccessTokenEntity.php @@ -4,13 +4,13 @@ declare(strict_types=1); namespace api\components\OAuth2\Entities; use api\components\OAuth2\Repositories\PublicScopeRepository; -use api\components\Tokens\TokensFactory; 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; @@ -31,7 +31,7 @@ class AccessTokenEntity implements AccessTokenEntityInterface { return $scope->getIdentifier() !== PublicScopeRepository::OFFLINE_ACCESS; }); - $token = TokensFactory::createForOAuthClient($this); + $token = Yii::$app->tokensFactory->createForOAuthClient($this); $this->scopes = $scopes; diff --git a/api/components/Tokens/Component.php b/api/components/Tokens/Component.php index ec19bbf..b7bfca4 100644 --- a/api/components/Tokens/Component.php +++ b/api/components/Tokens/Component.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace api\components\Tokens; use Carbon\Carbon; +use Defuse\Crypto\Crypto; use Exception; use Lcobucci\JWT\Builder; use Lcobucci\JWT\Parser; @@ -35,6 +36,11 @@ class Component extends BaseComponent { */ public $privateKeyPass; + /** + * @var string|\Defuse\Crypto\Key + */ + public $encryptionKey; + /** * @var AlgorithmsManager|null */ @@ -45,6 +51,7 @@ class Component extends BaseComponent { Assert::notEmpty($this->hmacKey, 'hmacKey must be set'); Assert::notEmpty($this->privateKeyPath, 'privateKeyPath must be set'); Assert::notEmpty($this->publicKeyPath, 'publicKeyPath must be set'); + Assert::notEmpty($this->encryptionKey, 'encryptionKey must be set'); } public function create(array $payloads = [], array $headers = []): Token { @@ -53,11 +60,11 @@ class Component extends BaseComponent { ->issuedAt($now->getTimestamp()) ->expiresAt($now->addHour()->getTimestamp()); foreach ($payloads as $claim => $value) { - $builder->withClaim($claim, $value); + $builder->withClaim($claim, $this->prepareValue($value)); } foreach ($headers as $claim => $value) { - $builder->withHeader($claim, $value); + $builder->withHeader($claim, $this->prepareValue($value)); } /** @noinspection PhpUnhandledExceptionInspection */ @@ -85,6 +92,10 @@ class Component extends BaseComponent { } } + public function decryptValue(string $encryptedValue): string { + return Crypto::decryptWithPassword($encryptedValue, $this->encryptionKey); + } + private function getAlgorithmManager(): AlgorithmsManager { if ($this->algorithmManager === null) { $this->algorithmManager = new AlgorithmsManager([ @@ -100,4 +111,12 @@ class Component extends BaseComponent { return $this->algorithmManager; } + private function prepareValue($value) { + if ($value instanceof EncryptedValue) { + return Crypto::encryptWithPassword($value->getValue(), $this->encryptionKey); + } + + return $value; + } + } diff --git a/api/components/Tokens/EncryptedValue.php b/api/components/Tokens/EncryptedValue.php new file mode 100644 index 0000000..83866c1 --- /dev/null +++ b/api/components/Tokens/EncryptedValue.php @@ -0,0 +1,21 @@ +value = $value; + } + + public function getValue(): string { + return $this->value; + } + +} diff --git a/api/components/Tokens/TokensFactory.php b/api/components/Tokens/TokensFactory.php index 2af8142..4c29339 100644 --- a/api/components/Tokens/TokensFactory.php +++ b/api/components/Tokens/TokensFactory.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace api\components\Tokens; +use api\rbac\Permissions as P; +use api\rbac\Roles as R; use Carbon\Carbon; use common\models\Account; use common\models\AccountSession; @@ -10,16 +12,17 @@ use Lcobucci\JWT\Token; use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\ScopeEntityInterface; use Yii; +use yii\base\Component; -class TokensFactory { +class TokensFactory extends Component { public const SUB_ACCOUNT_PREFIX = 'ely|'; public const AUD_CLIENT_PREFIX = 'client|'; - public static function createForAccount(Account $account, AccountSession $session = null): Token { + public function createForWebAccount(Account $account, AccountSession $session = null): Token { $payloads = [ - 'ely-scopes' => 'accounts_web_user', - 'sub' => self::buildSub($account->id), + 'ely-scopes' => R::ACCOUNTS_WEB_USER, + 'sub' => $this->buildSub($account->id), ]; if ($session === null) { // If we don't remember a session, the token should live longer @@ -32,26 +35,39 @@ class TokensFactory { return Yii::$app->tokens->create($payloads); } - public static function createForOAuthClient(AccessTokenEntityInterface $accessToken): Token { + public function createForOAuthClient(AccessTokenEntityInterface $accessToken): Token { $payloads = [ - 'aud' => self::buildAud($accessToken->getClient()->getIdentifier()), - 'ely-scopes' => implode(',', array_map(static function(ScopeEntityInterface $scope): string { + 'aud' => $this->buildAud($accessToken->getClient()->getIdentifier()), + 'ely-scopes' => $this->joinScopes(array_map(static function(ScopeEntityInterface $scope): string { return $scope->getIdentifier(); }, $accessToken->getScopes())), 'exp' => $accessToken->getExpiryDateTime()->getTimestamp(), ]; if ($accessToken->getUserIdentifier() !== null) { - $payloads['sub'] = self::buildSub($accessToken->getUserIdentifier()); + $payloads['sub'] = $this->buildSub($accessToken->getUserIdentifier()); } return Yii::$app->tokens->create($payloads); } - private static function buildSub(int $accountId): string { + public function createForMinecraftAccount(Account $account, string $clientToken): Token { + return Yii::$app->tokens->create([ + 'ely-scopes' => $this->joinScopes([P::MINECRAFT_SERVER_SESSION]), + 'ely-client-token' => new EncryptedValue($clientToken), + 'sub' => $this->buildSub($account->id), + 'exp' => Carbon::now()->addDays(2)->getTimestamp(), + ]); + } + + private function joinScopes(array $scopes): string { + return implode(',', $scopes); + } + + private function buildSub(int $accountId): string { return self::SUB_ACCOUNT_PREFIX . $accountId; } - private static function buildAud(string $clientId): string { + private function buildAud(string $clientId): string { return self::AUD_CLIENT_PREFIX . $clientId; } diff --git a/api/config/config-test.php b/api/config/config-test.php index af8f598..a99c96d 100644 --- a/api/config/config-test.php +++ b/api/config/config-test.php @@ -9,6 +9,7 @@ return [ 'privateKeyPath' => codecept_data_dir('certs/private.pem'), 'privateKeyPass' => null, 'publicKeyPath' => codecept_data_dir('certs/public.pem'), + 'encryptionKey' => 'mock-encryption-key', ], 'reCaptcha' => [ 'public' => 'public-key', diff --git a/api/config/config.php b/api/config/config.php index 420a73b..beb193d 100644 --- a/api/config/config.php +++ b/api/config/config.php @@ -21,6 +21,10 @@ return [ 'privateKeyPath' => getenv('JWT_PRIVATE_KEY_PATH') ?: __DIR__ . '/../../data/certs/private.pem', 'privateKeyPass' => getenv('JWT_PRIVATE_KEY_PASS') ?: null, 'publicKeyPath' => getenv('JWT_PUBLIC_KEY_PATH') ?: __DIR__ . '/../../data/certs/public.pem', + 'encryptionKey' => getenv('JWT_ENCRYPTION_KEY'), + ], + 'tokensFactory' => [ + 'class' => api\components\Tokens\TokensFactory::class, ], 'log' => [ 'traceLevel' => YII_DEBUG ? 3 : 0, diff --git a/api/models/authentication/ConfirmEmailForm.php b/api/models/authentication/ConfirmEmailForm.php index 60c6608..e283fd6 100644 --- a/api/models/authentication/ConfirmEmailForm.php +++ b/api/models/authentication/ConfirmEmailForm.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace api\models\authentication; use api\aop\annotations\CollectModelMetrics; -use api\components\Tokens\TokensFactory; use api\models\base\ApiForm; use api\validators\EmailActivationKeyValidator; use common\models\Account; @@ -48,7 +47,7 @@ class ConfirmEmailForm extends ApiForm { $session->generateRefreshToken(); Assert::true($session->save(), 'Cannot save account session model'); - $token = TokensFactory::createForAccount($account, $session); + $token = Yii::$app->tokensFactory->createForWebAccount($account, $session); $transaction->commit(); diff --git a/api/models/authentication/LoginForm.php b/api/models/authentication/LoginForm.php index 455bfa4..7dc91fc 100644 --- a/api/models/authentication/LoginForm.php +++ b/api/models/authentication/LoginForm.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace api\models\authentication; use api\aop\annotations\CollectModelMetrics; -use api\components\Tokens\TokensFactory; use api\models\base\ApiForm; use api\traits\AccountFinder; use api\validators\TotpValidator; @@ -121,7 +120,7 @@ class LoginForm extends ApiForm { Assert::true($session->save(), 'Cannot save account session model'); } - $token = TokensFactory::createForAccount($account, $session); + $token = Yii::$app->tokensFactory->createForWebAccount($account, $session); $transaction->commit(); diff --git a/api/models/authentication/RecoverPasswordForm.php b/api/models/authentication/RecoverPasswordForm.php index 957c1ec..9fd99fb 100644 --- a/api/models/authentication/RecoverPasswordForm.php +++ b/api/models/authentication/RecoverPasswordForm.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace api\models\authentication; use api\aop\annotations\CollectModelMetrics; -use api\components\Tokens\TokensFactory; use api\models\base\ApiForm; use api\validators\EmailActivationKeyValidator; use common\helpers\Error as E; @@ -56,7 +55,7 @@ class RecoverPasswordForm extends ApiForm { Assert::true($account->save(), 'Unable activate user account.'); - $token = TokensFactory::createForAccount($account); + $token = Yii::$app->tokensFactory->createForWebAccount($account); $transaction->commit(); diff --git a/api/models/authentication/RefreshTokenForm.php b/api/models/authentication/RefreshTokenForm.php index 92cd72c..fb4fd38 100644 --- a/api/models/authentication/RefreshTokenForm.php +++ b/api/models/authentication/RefreshTokenForm.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace api\models\authentication; use api\aop\annotations\CollectModelMetrics; -use api\components\Tokens\TokensFactory; use api\models\base\ApiForm; use common\helpers\Error as E; use common\models\AccountSession; @@ -47,7 +46,7 @@ class RefreshTokenForm extends ApiForm { $transaction = Yii::$app->db->beginTransaction(); - $token = TokensFactory::createForAccount($account, $session); + $token = Yii::$app->tokensFactory->createForWebAccount($account, $session); $session->setIp(Yii::$app->request->userIP); $session->touch('last_refreshed_at'); diff --git a/api/models/base/ApiForm.php b/api/models/base/ApiForm.php index 1987bf2..70e0f05 100644 --- a/api/models/base/ApiForm.php +++ b/api/models/base/ApiForm.php @@ -1,11 +1,13 @@ ['POST'], 'refresh' => ['POST'], @@ -24,21 +26,35 @@ class AuthenticationController extends Controller { ]; } - public function actionAuthenticate() { + /** + * @return array + * @throws \api\modules\authserver\exceptions\ForbiddenOperationException + * @throws \api\modules\authserver\exceptions\IllegalArgumentException + */ + public function actionAuthenticate(): array { $model = new models\AuthenticationForm(); $model->load(Yii::$app->request->post()); return $model->authenticate()->getResponseData(true); } - public function actionRefresh() { + /** + * @return array + * @throws \api\modules\authserver\exceptions\ForbiddenOperationException + * @throws \api\modules\authserver\exceptions\IllegalArgumentException + */ + public function actionRefresh(): array { $model = new models\RefreshTokenForm(); $model->load(Yii::$app->request->post()); return $model->refresh()->getResponseData(false); } - public function actionValidate() { + /** + * @throws \api\modules\authserver\exceptions\ForbiddenOperationException + * @throws \api\modules\authserver\exceptions\IllegalArgumentException + */ + public function actionValidate(): void { $model = new models\ValidateForm(); $model->load(Yii::$app->request->post()); $model->validateToken(); @@ -46,7 +62,11 @@ class AuthenticationController extends Controller { // In case of an error, an exception is thrown which will be processed by ErrorHandler } - public function actionSignout() { + /** + * @throws \api\modules\authserver\exceptions\ForbiddenOperationException + * @throws \api\modules\authserver\exceptions\IllegalArgumentException + */ + public function actionSignout(): void { $model = new models\SignoutForm(); $model->load(Yii::$app->request->post()); $model->signout(); @@ -54,7 +74,10 @@ class AuthenticationController extends Controller { // In case of an error, an exception is thrown which will be processed by ErrorHandler } - public function actionInvalidate() { + /** + * @throws \api\modules\authserver\exceptions\IllegalArgumentException + */ + public function actionInvalidate(): void { $model = new models\InvalidateForm(); $model->load(Yii::$app->request->post()); $model->invalidateToken(); diff --git a/api/modules/authserver/models/AuthenticateData.php b/api/modules/authserver/models/AuthenticateData.php index 5efc34a..f9f9426 100644 --- a/api/modules/authserver/models/AuthenticateData.php +++ b/api/modules/authserver/models/AuthenticateData.php @@ -1,39 +1,47 @@ minecraftAccessKey = $minecraftAccessKey; - } + /** + * @var Token + */ + private $accessToken; - public function getMinecraftAccessKey(): MinecraftAccessKey { - return $this->minecraftAccessKey; + /** + * @var string + */ + private $clientToken; + + public function __construct(Account $account, string $accessToken, string $clientToken) { + $this->account = $account; + $this->accessToken = $accessToken; + $this->clientToken = $clientToken; } public function getResponseData(bool $includeAvailableProfiles = false): array { - $accessKey = $this->minecraftAccessKey; - $account = $accessKey->account; - $result = [ - 'accessToken' => $accessKey->access_token, - 'clientToken' => $accessKey->client_token, + 'accessToken' => $this->accessToken, + 'clientToken' => $this->clientToken, 'selectedProfile' => [ - 'id' => $account->uuid, - 'name' => $account->username, + 'id' => $this->account->uuid, + 'name' => $this->account->username, 'legacy' => false, ], ]; if ($includeAvailableProfiles) { - // The Moiangs themselves haven't come up with anything yet with these availableProfiles + // The Mojang themselves haven't come up with anything yet with these availableProfiles $availableProfiles[0] = $result['selectedProfile']; $result['availableProfiles'] = $availableProfiles; } diff --git a/api/modules/authserver/models/AuthenticationForm.php b/api/modules/authserver/models/AuthenticationForm.php index 4accba6..1792a0a 100644 --- a/api/modules/authserver/models/AuthenticationForm.php +++ b/api/modules/authserver/models/AuthenticationForm.php @@ -1,4 +1,6 @@ validate(); Authserver::info("Trying to authenticate user by login = '{$this->username}'."); - $loginForm = $this->createLoginForm(); + $loginForm = new LoginForm(); $loginForm->login = $this->username; $loginForm->password = $this->password; if (!$loginForm->validate()) { @@ -68,37 +81,14 @@ class AuthenticationForm extends ApiForm { throw new ForbiddenOperationException("Invalid credentials. Invalid {$attribute} or password."); } + /** @var Account $account */ $account = $loginForm->getAccount(); - $accessTokenModel = $this->createMinecraftAccessToken($account); - $dataModel = new AuthenticateData($accessTokenModel); + $token = Yii::$app->tokensFactory->createForMinecraftAccount($account, $this->clientToken); + $dataModel = new AuthenticateData($account, (string)$token, $this->clientToken); Authserver::info("User with id = {$account->id}, username = '{$account->username}' and email = '{$account->email}' successfully logged in."); return $dataModel; } - protected function createMinecraftAccessToken(Account $account): MinecraftAccessKey { - /** @var MinecraftAccessKey|null $accessTokenModel */ - $accessTokenModel = MinecraftAccessKey::findOne([ - 'account_id' => $account->id, - 'client_token' => $this->clientToken, - ]); - - if ($accessTokenModel === null) { - $accessTokenModel = new MinecraftAccessKey(); - $accessTokenModel->client_token = $this->clientToken; - $accessTokenModel->account_id = $account->id; - $accessTokenModel->insert(); - } else { - $accessTokenModel->refreshPrimaryKeyValue(); - $accessTokenModel->update(); - } - - return $accessTokenModel; - } - - protected function createLoginForm(): LoginForm { - return new LoginForm(); - } - } diff --git a/api/modules/authserver/models/InvalidateForm.php b/api/modules/authserver/models/InvalidateForm.php index d270c7f..de029e7 100644 --- a/api/modules/authserver/models/InvalidateForm.php +++ b/api/modules/authserver/models/InvalidateForm.php @@ -1,17 +1,24 @@ validate(); - $token = MinecraftAccessKey::findOne([ - 'access_token' => $this->accessToken, - 'client_token' => $this->clientToken, - ]); - - if ($token !== null) { - $token->delete(); - } + // We're can't invalidate access token because it's not stored in our database return true; } diff --git a/api/modules/authserver/models/RefreshTokenForm.php b/api/modules/authserver/models/RefreshTokenForm.php index 282cc18..92eb6a5 100644 --- a/api/modules/authserver/models/RefreshTokenForm.php +++ b/api/modules/authserver/models/RefreshTokenForm.php @@ -1,48 +1,71 @@ false], ]; } /** * @return AuthenticateData - * @throws \api\modules\authserver\exceptions\AuthserverException + * @throws \api\modules\authserver\exceptions\IllegalArgumentException + * @throws \api\modules\authserver\exceptions\ForbiddenOperationException */ - public function refresh() { + public function refresh(): AuthenticateData { $this->validate(); - /** @var MinecraftAccessKey|null $accessToken */ - $accessToken = MinecraftAccessKey::findOne([ - 'access_token' => $this->accessToken, - 'client_token' => $this->clientToken, - ]); - if ($accessToken === null) { - throw new ForbiddenOperationException('Invalid token.'); + if (mb_strlen($this->accessToken) === 36) { + /** @var MinecraftAccessKey $token */ + $token = MinecraftAccessKey::findOne([ + 'access_token' => $this->accessToken, + 'client_token' => $this->clientToken, + ]); + $account = $token->account; + } else { + $token = Yii::$app->tokens->parse($this->accessToken); + + $encodedClientToken = $token->getClaim('ely-client-token'); + $clientToken = Yii::$app->tokens->decryptValue($encodedClientToken); + if ($clientToken !== $this->clientToken) { + throw new ForbiddenOperationException('Invalid token.'); + } + + $accountClaim = $token->getClaim('sub'); + $accountId = (int)explode('|', $accountClaim)[1]; + $account = Account::findOne(['id' => $accountId]); } - if ($accessToken->account->status === Account::STATUS_BANNED) { + if ($account === null || $account->status === Account::STATUS_BANNED) { throw new ForbiddenOperationException('This account has been suspended.'); } - $accessToken->refreshPrimaryKeyValue(); - $accessToken->update(); + $token = Yii::$app->tokensFactory->createForMinecraftAccount($account, $this->clientToken); - return new AuthenticateData($accessToken); + return new AuthenticateData($account, (string)$token, $this->clientToken); } } diff --git a/api/modules/authserver/models/SignoutForm.php b/api/modules/authserver/models/SignoutForm.php index 8765b02..f2a7ef3 100644 --- a/api/modules/authserver/models/SignoutForm.php +++ b/api/modules/authserver/models/SignoutForm.php @@ -1,4 +1,6 @@ validate(); @@ -44,16 +55,7 @@ class SignoutForm extends ApiForm { throw new ForbiddenOperationException("Invalid credentials. Invalid {$attribute} or password."); } - $account = $loginForm->getAccount(); - - /** @noinspection SqlResolve */ - Yii::$app->db->createCommand(' - DELETE - FROM ' . MinecraftAccessKey::tableName() . ' - WHERE account_id = :userId - ', [ - 'userId' => $account->id, - ])->execute(); + // We're unable to invalidate access tokens because they aren't stored in our database return true; } diff --git a/api/modules/authserver/models/ValidateForm.php b/api/modules/authserver/models/ValidateForm.php index bff97ae..7bf9fe6 100644 --- a/api/modules/authserver/models/ValidateForm.php +++ b/api/modules/authserver/models/ValidateForm.php @@ -1,35 +1,33 @@ validate(); - - /** @var MinecraftAccessKey|null $result */ - $result = MinecraftAccessKey::findOne($this->accessToken); - if ($result === null) { - throw new ForbiddenOperationException('Invalid token.'); - } - - if ($result->isExpired()) { - throw new ForbiddenOperationException('Token expired.'); - } - - return true; + return $this->validate(); } } diff --git a/api/modules/authserver/validators/AccessTokenValidator.php b/api/modules/authserver/validators/AccessTokenValidator.php new file mode 100644 index 0000000..535193d --- /dev/null +++ b/api/modules/authserver/validators/AccessTokenValidator.php @@ -0,0 +1,69 @@ +validateLegacyToken($value); + } + + try { + $token = Yii::$app->tokens->parse($value); + } catch (Exception $e) { + throw new ForbiddenOperationException('Invalid token.'); + } + + if (!Yii::$app->tokens->verify($token)) { + throw new ForbiddenOperationException('Invalid token.'); + } + + if ($this->verifyExpiration && !$token->validate(new ValidationData(Carbon::now()->getTimestamp()))) { + throw new ForbiddenOperationException('Token expired.'); + } + + return null; + } + + /** + * @param string $value + * + * @return array|null + * @throws ForbiddenOperationException + */ + private function validateLegacyToken(string $value): ?array { + /** @var MinecraftAccessKey|null $result */ + $result = MinecraftAccessKey::findOne(['access_token' => $value]); + if ($result === null) { + throw new ForbiddenOperationException('Invalid token.'); + } + + if ($this->verifyExpiration && $result->isExpired()) { + throw new ForbiddenOperationException('Token expired.'); + } + + return null; + } + +} diff --git a/api/modules/authserver/validators/ClientTokenValidator.php b/api/modules/authserver/validators/ClientTokenValidator.php index 21c336b..49c1380 100644 --- a/api/modules/authserver/validators/ClientTokenValidator.php +++ b/api/modules/authserver/validators/ClientTokenValidator.php @@ -1,20 +1,23 @@ 255) { throw new IllegalArgumentException('clientToken is too long.'); } diff --git a/api/modules/authserver/validators/RequiredValidator.php b/api/modules/authserver/validators/RequiredValidator.php index 4d38f37..ab22de7 100644 --- a/api/modules/authserver/validators/RequiredValidator.php +++ b/api/modules/authserver/validators/RequiredValidator.php @@ -1,4 +1,6 @@ getActor()->sendPOST('/api/authserver/authentication/authenticate', $params); - } - - public function refresh($params) { - $this->getActor()->sendPOST('/api/authserver/authentication/refresh', $params); - } - - public function validate($params) { - $this->getActor()->sendPOST('/api/authserver/authentication/validate', $params); - } - - public function invalidate($params) { - $this->getActor()->sendPOST('/api/authserver/authentication/invalidate', $params); - } - - public function signout($params) { - $this->getActor()->sendPOST('/api/authserver/authentication/signout', $params); - } - -} diff --git a/api/tests/_support/FunctionalTester.php b/api/tests/_support/FunctionalTester.php index 86bef30..74a35ad 100644 --- a/api/tests/_support/FunctionalTester.php +++ b/api/tests/_support/FunctionalTester.php @@ -3,7 +3,6 @@ declare(strict_types=1); namespace api\tests; -use api\components\Tokens\TokensFactory; use api\tests\_generated\FunctionalTesterActions; use Codeception\Actor; use common\models\Account; @@ -20,7 +19,7 @@ class FunctionalTester extends Actor { throw new InvalidArgumentException("Cannot find account with username \"{$asUsername}\""); } - $token = TokensFactory::createForAccount($account); + $token = Yii::$app->tokensFactory->createForWebAccount($account); $this->amBearerAuthenticated((string)$token); return $account->id; diff --git a/api/tests/functional/_steps/AuthserverSteps.php b/api/tests/functional/_steps/AuthserverSteps.php index 97cb5bf..d656417 100644 --- a/api/tests/functional/_steps/AuthserverSteps.php +++ b/api/tests/functional/_steps/AuthserverSteps.php @@ -3,16 +3,14 @@ declare(strict_types=1); namespace api\tests\functional\_steps; -use api\tests\_pages\AuthserverRoute; use api\tests\FunctionalTester; use Ramsey\Uuid\Uuid; class AuthserverSteps extends FunctionalTester { - public function amAuthenticated(string $asUsername = 'admin', string $password = 'password_0') { - $route = new AuthserverRoute($this); + public function amAuthenticated(string $asUsername = 'admin', string $password = 'password_0'): array { $clientToken = Uuid::uuid4()->toString(); - $route->authenticate([ + $this->sendPOST('/api/authserver/authentication/authenticate', [ 'username' => $asUsername, 'password' => $password, 'clientToken' => $clientToken, diff --git a/api/tests/functional/authserver/AuthorizationCest.php b/api/tests/functional/authserver/AuthorizationCest.php index f64cd25..0d4f652 100644 --- a/api/tests/functional/authserver/AuthorizationCest.php +++ b/api/tests/functional/authserver/AuthorizationCest.php @@ -1,48 +1,38 @@ route = new AuthserverRoute($I); - } - - public function byName(FunctionalTester $I) { + public function byFormParamsPostRequest(FunctionalTester $I, Example $example) { $I->wantTo('authenticate by username and password'); - $this->route->authenticate([ - 'username' => 'admin', - 'password' => 'password_0', + $I->sendPOST('/api/authserver/authentication/authenticate', [ + 'username' => $example['login'], + 'password' => $example['password'], 'clientToken' => Uuid::uuid4()->toString(), ]); $this->testSuccessResponse($I); } - public function byEmail(FunctionalTester $I) { - $I->wantTo('authenticate by email and password'); - $this->route->authenticate([ - 'username' => 'admin@ely.by', - 'password' => 'password_0', - 'clientToken' => Uuid::uuid4()->toString(), - ]); - - $this->testSuccessResponse($I); - } - - public function byNamePassedViaPOSTBody(FunctionalTester $I) { + /** + * @example {"login": "admin", "password": "password_0"} + * @example {"login": "admin@ely.by", "password": "password_0"} + */ + public function byJsonPostRequest(FunctionalTester $I, Example $example) { $I->wantTo('authenticate by username and password sent via post body'); - $this->route->authenticate(json_encode([ - 'username' => 'admin', - 'password' => 'password_0', + $I->sendPOST('/api/authserver/authentication/authenticate', json_encode([ + 'username' => $example['login'], + 'password' => $example['password'], 'clientToken' => Uuid::uuid4()->toString(), ])); @@ -51,7 +41,7 @@ class AuthorizationCest { public function byEmailWithEnabledTwoFactorAuth(FunctionalTester $I) { $I->wantTo('get valid error by authenticate account with enabled two factor auth'); - $this->route->authenticate([ + $I->sendPOST('/api/authserver/authentication/authenticate', [ 'username' => 'otp@gmail.com', 'password' => 'password_0', 'clientToken' => Uuid::uuid4()->toString(), @@ -64,30 +54,9 @@ class AuthorizationCest { ]); } - public function byEmailWithParamsAsJsonInPostBody(FunctionalTester $I) { - $I->wantTo('authenticate by email and password, passing values as serialized string in post body'); - $this->route->authenticate(json_encode([ - 'username' => 'admin@ely.by', - 'password' => 'password_0', - 'clientToken' => Uuid::uuid4()->toString(), - ])); - - $this->testSuccessResponse($I); - } - - public function longClientToken(FunctionalTester $I) { - $I->wantTo('send non uuid clientToken, but less then 255 characters'); - $this->route->authenticate([ - 'username' => 'admin@ely.by', - 'password' => 'password_0', - 'clientToken' => str_pad('', 255, 'x'), - ]); - $this->testSuccessResponse($I); - } - public function tooLongClientToken(FunctionalTester $I) { $I->wantTo('send non uuid clientToken with more then 255 characters length'); - $this->route->authenticate([ + $I->sendPOST('/api/authserver/authentication/authenticate', [ 'username' => 'admin@ely.by', 'password' => 'password_0', 'clientToken' => str_pad('', 256, 'x'), @@ -102,7 +71,7 @@ class AuthorizationCest { public function wrongArguments(FunctionalTester $I) { $I->wantTo('get error on wrong amount of arguments'); - $this->route->authenticate([ + $I->sendPOST('/api/authserver/authentication/authenticate', [ 'key' => 'value', ]); $I->canSeeResponseCodeIs(400); @@ -115,7 +84,7 @@ class AuthorizationCest { public function wrongNicknameAndPassword(FunctionalTester $I) { $I->wantTo('authenticate by username and password with wrong data'); - $this->route->authenticate([ + $I->sendPOST('/api/authserver/authentication/authenticate', [ 'username' => 'nonexistent_user', 'password' => 'nonexistent_password', 'clientToken' => Uuid::uuid4()->toString(), @@ -130,7 +99,7 @@ class AuthorizationCest { public function bannedAccount(FunctionalTester $I) { $I->wantTo('authenticate in suspended account'); - $this->route->authenticate([ + $I->sendPOST('/api/authserver/authentication/authenticate', [ 'username' => 'Banned', 'password' => 'password_0', 'clientToken' => Uuid::uuid4()->toString(), diff --git a/api/tests/functional/authserver/InvalidateCest.php b/api/tests/functional/authserver/InvalidateCest.php index ad1506a..3426af8 100644 --- a/api/tests/functional/authserver/InvalidateCest.php +++ b/api/tests/functional/authserver/InvalidateCest.php @@ -3,25 +3,15 @@ declare(strict_types=1); namespace api\tests\functional\authserver; -use api\tests\_pages\AuthserverRoute; use api\tests\functional\_steps\AuthserverSteps; use Ramsey\Uuid\Uuid; class InvalidateCest { - /** - * @var AuthserverRoute - */ - private $route; - - public function _before(AuthserverSteps $I) { - $this->route = new AuthserverRoute($I); - } - public function invalidate(AuthserverSteps $I) { $I->wantTo('invalidate my token'); [$accessToken, $clientToken] = $I->amAuthenticated(); - $this->route->invalidate([ + $I->sendPOST('/api/authserver/authentication/invalidate', [ 'accessToken' => $accessToken, 'clientToken' => $clientToken, ]); @@ -31,7 +21,7 @@ class InvalidateCest { public function wrongArguments(AuthserverSteps $I) { $I->wantTo('get error on wrong amount of arguments'); - $this->route->invalidate([ + $I->sendPOST('/api/authserver/authentication/invalidate', [ 'key' => 'value', ]); $I->canSeeResponseCodeIs(400); @@ -44,7 +34,7 @@ class InvalidateCest { public function wrongAccessTokenOrClientToken(AuthserverSteps $I) { $I->wantTo('invalidate by wrong client and access token'); - $this->route->invalidate([ + $I->sendPOST('/api/authserver/authentication/invalidate', [ 'accessToken' => Uuid::uuid4()->toString(), 'clientToken' => Uuid::uuid4()->toString(), ]); diff --git a/api/tests/functional/authserver/RefreshCest.php b/api/tests/functional/authserver/RefreshCest.php index 9877bc6..5245732 100644 --- a/api/tests/functional/authserver/RefreshCest.php +++ b/api/tests/functional/authserver/RefreshCest.php @@ -1,42 +1,49 @@ route = new AuthserverRoute($I); - } - public function refresh(AuthserverSteps $I) { - $I->wantTo('refresh my accessToken'); + $I->wantTo('refresh accessToken'); [$accessToken, $clientToken] = $I->amAuthenticated(); - $this->route->refresh([ + $I->sendPOST('/api/authserver/authentication/refresh', [ 'accessToken' => $accessToken, 'clientToken' => $clientToken, ]); + $this->assertSuccessResponse($I); + } - $I->seeResponseCodeIs(200); - $I->seeResponseIsJson(); - $I->canSeeResponseJsonMatchesJsonPath('$.accessToken'); - $I->canSeeResponseJsonMatchesJsonPath('$.clientToken'); - $I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.id'); - $I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.name'); - $I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.legacy'); - $I->cantSeeResponseJsonMatchesJsonPath('$.availableProfiles'); + public function refreshLegacyAccessToken(AuthserverSteps $I) { + $I->wantTo('refresh legacy accessToken'); + $I->sendPOST('/api/authserver/authentication/refresh', [ + 'accessToken' => 'e7bb6648-2183-4981-9b86-eba5e7f87b42', + 'clientToken' => '6f380440-0c05-47bd-b7c6-d011f1b5308f', + ]); + $this->assertSuccessResponse($I); + } + + /** + * @example {"accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpYXQiOjE1NzU0Nzk1NTMsImV4cCI6MTU3NTY1MjM1MywiZWx5LXNjb3BlcyI6Im1pbmVjcmFmdF9zZXJ2ZXJfc2Vzc2lvbiIsImVseS1jbGllbnQtdG9rZW4iOiJkZWY1MDIwMDE2ZTEzMTBmMzM2YzVjYWQzZDdiMTJmYjcyNmVhYzdlYjgyOGUzMzg1MzBhMmFmODdkZTJhMjRiMTVmNzAxNWQ1MjU1MjhiNGZiMjgzMTgxOTA2ODhlMWE4Njk5MjAwMzBlMTQyZmQ5ZWM5ODBlZDkzMWI1Mzc2MzgyMTliMjVjMjI1MjQyYzdmMjgzMjE0NjcyNDg3ZDQ4MTYxYjMwMGU1MGIzYWJlMTYwYjVkMmE4ZWMyMzMwMGJhMGNlMTg3MzYyYTgyMjJiYjQ4OTU0MzM4MDJiNTBlZDBhYzFhMWUwZDk3NDgxNDciLCJzdWIiOiJlbHl8MSJ9.PuM-8rzj4qtD9l0lUANSIWC8yjJe8ifarOYsAjc3r4iYFt0P6za-gzJEPncDC80oCXsYVlJHtrEypcsB9wJFSg", "clientToken": "d1b1162c-3d73-4b35-b64f-7bf68bd0e853"} + * @example {"accessToken": "6042634a-a1e2-4aed-866c-c661fe4e63e2", "clientToken": "47fb164a-2332-42c1-8bad-549e67bb210c"} + */ + public function refreshExpiredToken(AuthserverSteps $I, Example $example) { + $I->wantTo('refresh legacy accessToken'); + $I->sendPOST('/api/authserver/authentication/refresh', [ + 'accessToken' => $example['accessToken'], + 'clientToken' => $example['clientToken'], + ]); + $this->assertSuccessResponse($I); } public function wrongArguments(AuthserverSteps $I) { $I->wantTo('get error on wrong amount of arguments'); - $this->route->refresh([ + $I->sendPOST('/api/authserver/authentication/refresh', [ 'key' => 'value', ]); $I->canSeeResponseCodeIs(400); @@ -49,7 +56,7 @@ class RefreshCest { public function wrongAccessToken(AuthserverSteps $I) { $I->wantTo('get error on wrong access or client tokens'); - $this->route->refresh([ + $I->sendPOST('/api/authserver/authentication/refresh', [ 'accessToken' => Uuid::uuid4()->toString(), 'clientToken' => Uuid::uuid4()->toString(), ]); @@ -63,7 +70,7 @@ class RefreshCest { public function refreshTokenFromBannedUser(AuthserverSteps $I) { $I->wantTo('refresh token from suspended account'); - $this->route->refresh([ + $I->sendPOST('/api/authserver/authentication/refresh', [ 'accessToken' => '918ecb41-616c-40ee-a7d2-0b0ef0d0d732', 'clientToken' => '6042634a-a1e2-4aed-866c-c661fe4e63e2', ]); @@ -74,4 +81,15 @@ class RefreshCest { ]); } + private function assertSuccessResponse(AuthserverSteps $I) { + $I->seeResponseCodeIs(200); + $I->seeResponseIsJson(); + $I->canSeeResponseJsonMatchesJsonPath('$.accessToken'); + $I->canSeeResponseJsonMatchesJsonPath('$.clientToken'); + $I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.id'); + $I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.name'); + $I->canSeeResponseJsonMatchesJsonPath('$.selectedProfile.legacy'); + $I->cantSeeResponseJsonMatchesJsonPath('$.availableProfiles'); + } + } diff --git a/api/tests/functional/authserver/SignoutCest.php b/api/tests/functional/authserver/SignoutCest.php index f15b671..ba1d9a2 100644 --- a/api/tests/functional/authserver/SignoutCest.php +++ b/api/tests/functional/authserver/SignoutCest.php @@ -1,35 +1,22 @@ route = new AuthserverRoute($I); - } - - public function byName(AuthserverSteps $I) { + public function signout(AuthserverSteps $I, Example $example) { $I->wantTo('signout by nickname and password'); - $this->route->signout([ - 'username' => 'admin', - 'password' => 'password_0', - ]); - $I->canSeeResponseCodeIs(200); - $I->canSeeResponseEquals(''); - } - - public function byEmail(AuthserverSteps $I) { - $I->wantTo('signout by email and password'); - $this->route->signout([ - 'username' => 'admin@ely.by', - 'password' => 'password_0', + $I->sendPOST('/api/authserver/authentication/signout', [ + 'username' => $example['login'], + 'password' => $example['password'], ]); $I->canSeeResponseCodeIs(200); $I->canSeeResponseEquals(''); @@ -37,7 +24,7 @@ class SignoutCest { public function wrongArguments(AuthserverSteps $I) { $I->wantTo('get error on wrong amount of arguments'); - $this->route->signout([ + $I->sendPOST('/api/authserver/authentication/signout', [ 'key' => 'value', ]); $I->canSeeResponseCodeIs(400); @@ -50,7 +37,7 @@ class SignoutCest { public function wrongNicknameAndPassword(AuthserverSteps $I) { $I->wantTo('signout by nickname and password with wrong data'); - $this->route->signout([ + $I->sendPOST('/api/authserver/authentication/signout', [ 'username' => 'nonexistent_user', 'password' => 'nonexistent_password', ]); @@ -64,7 +51,7 @@ class SignoutCest { public function bannedAccount(AuthserverSteps $I) { $I->wantTo('signout from banned account'); - $this->route->signout([ + $I->sendPOST('/api/authserver/authentication/signout', [ 'username' => 'Banned', 'password' => 'password_0', ]); diff --git a/api/tests/functional/authserver/ValidateCest.php b/api/tests/functional/authserver/ValidateCest.php index b31160c..e374257 100644 --- a/api/tests/functional/authserver/ValidateCest.php +++ b/api/tests/functional/authserver/ValidateCest.php @@ -1,34 +1,35 @@ route = new AuthserverRoute($I); - } - public function validate(AuthserverSteps $I) { $I->wantTo('validate my accessToken'); [$accessToken] = $I->amAuthenticated(); - $this->route->validate([ + $I->sendPOST('/api/authserver/authentication/validate', [ 'accessToken' => $accessToken, ]); $I->seeResponseCodeIs(200); $I->canSeeResponseEquals(''); } + public function validateLegacyToken(AuthserverSteps $I) { + $I->wantTo('validate my legacy accessToken'); + $I->sendPOST('/api/authserver/authentication/validate', [ + 'accessToken' => 'e7bb6648-2183-4981-9b86-eba5e7f87b42', + ]); + $I->seeResponseCodeIs(200); + $I->canSeeResponseEquals(''); + } + public function wrongArguments(AuthserverSteps $I) { $I->wantTo('get error on wrong amount of arguments'); - $this->route->validate([ + $I->sendPOST('/api/authserver/authentication/validate', [ 'key' => 'value', ]); $I->canSeeResponseCodeIs(400); @@ -41,7 +42,7 @@ class ValidateCest { public function wrongAccessToken(AuthserverSteps $I) { $I->wantTo('get error on wrong accessToken'); - $this->route->validate([ + $I->sendPOST('/api/authserver/authentication/validate', [ 'accessToken' => Uuid::uuid4()->toString(), ]); $I->canSeeResponseCodeIs(401); @@ -54,9 +55,21 @@ class ValidateCest { public function expiredAccessToken(AuthserverSteps $I) { $I->wantTo('get error on expired accessToken'); - $this->route->validate([ - // Knowingly expired token from the dump - 'accessToken' => '6042634a-a1e2-4aed-866c-c661fe4e63e2', + $I->sendPOST('/api/authserver/authentication/validate', [ + 'accessToken' => 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpYXQiOjE1NzU0Nzk1NTMsImV4cCI6MTU3NTQ3OTU1MywiZWx5LXNjb3BlcyI6Im1pbmVjcmFmdF9zZXJ2ZXJfc2Vzc2lvbiIsImVseS1jbGllbnQtdG9rZW4iOiJyZW1vdmVkIiwic3ViIjoiZWx5fDEifQ.xDMs5B48nH6p3a1k3WoZKtW4zoNHGGaLD1OGTFte-sUJb2fNMR65LuuBW8DzqO2odgco2xX660zqbhB-tp2OsA', + ]); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'error' => 'ForbiddenOperationException', + 'errorMessage' => 'Token expired.', + ]); + } + + public function expiredLegacyAccessToken(AuthserverSteps $I) { + $I->wantTo('get error on expired legacy accessToken'); + $I->sendPOST('/api/authserver/authentication/validate', [ + 'accessToken' => '6042634a-a1e2-4aed-866c-c661fe4e63e2', // Already expired token from the fixtures ]); $I->canSeeResponseCodeIs(401); $I->canSeeResponseIsJson(); diff --git a/api/tests/unit/components/Tokens/TokensFactoryTest.php b/api/tests/unit/components/Tokens/TokensFactoryTest.php index cd54c00..7dc353b 100644 --- a/api/tests/unit/components/Tokens/TokensFactoryTest.php +++ b/api/tests/unit/components/Tokens/TokensFactoryTest.php @@ -14,7 +14,9 @@ class TokensFactoryTest extends TestCase { $account = new Account(); $account->id = 1; - $token = TokensFactory::createForAccount($account); + $factory = new TokensFactory(); + + $token = $factory->createForWebAccount($account); $this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 1); $this->assertEqualsWithDelta(time() + 60 * 60 * 24 * 7, $token->getClaim('exp'), 2); $this->assertSame('ely|1', $token->getClaim('sub')); @@ -24,7 +26,7 @@ class TokensFactoryTest extends TestCase { $session = new AccountSession(); $session->id = 2; - $token = TokensFactory::createForAccount($account, $session); + $token = $factory->createForWebAccount($account, $session); $this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 1); $this->assertEqualsWithDelta(time() + 3600, $token->getClaim('exp'), 2); $this->assertSame('ely|1', $token->getClaim('sub')); diff --git a/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php b/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php index 35e2db2..d6ebf7e 100644 --- a/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php +++ b/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php @@ -79,7 +79,7 @@ class AuthenticationFormTest extends TestCase { $result = $authForm->authenticate(); $this->assertInstanceOf(AuthenticateData::class, $result); - $this->assertSame($minecraftAccessKey->access_token, $result->getMinecraftAccessKey()->access_token); + $this->assertSame($minecraftAccessKey->access_token, $result->getToken()->access_token); } public function testCreateMinecraftAccessToken() { diff --git a/autocompletion.php b/autocompletion.php index e07fc51..8e62e76 100644 --- a/autocompletion.php +++ b/autocompletion.php @@ -24,7 +24,6 @@ class Yii extends \yii\BaseYii { * @property \mito\sentry\Component $sentry * @property \common\components\StatsD $statsd * @property \yii\queue\Queue $queue - * @property \api\components\Tokens\Component $tokens */ abstract class BaseApplication extends yii\base\Application { } @@ -33,9 +32,11 @@ abstract class BaseApplication extends yii\base\Application { * Class WebApplication * Include only Web application related components here * - * @property \api\components\User\Component $user User component. - * @property \api\components\ReCaptcha\Component $reCaptcha - * @property \api\components\OAuth2\Component $oauth + * @property \api\components\User\Component $user + * @property \api\components\ReCaptcha\Component $reCaptcha + * @property \api\components\OAuth2\Component $oauth + * @property \api\components\Tokens\Component $tokens + * @property \api\components\Tokens\TokensFactory $tokensFactory * * @method \api\components\User\Component getUser() */ diff --git a/composer.json b/composer.json index d2451b9..e0e5efa 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "ext-pdo": "*", "ext-simplexml": "*", "bacon/bacon-qr-code": "^1.0", + "defuse/php-encryption": "^2.2", "domnikl/statsd": "^2.6", "ely/mojang-api": "^0.2.0", "ely/yii2-tempmail-validator": "^2.0", diff --git a/composer.lock b/composer.lock index d5c2a2b..e96b55c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7ee1d380684b79ffabf92f115d7de4a8", + "content-hash": "10af6b999939a9f213664883387184ed", "packages": [ { "name": "bacon/bacon-qr-code", From 25f1ca912cdb296ce7228c78e84035fdeba3226a Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Thu, 5 Dec 2019 00:52:27 +0300 Subject: [PATCH 15/28] Fix tests --- .../AuthorizationRequestProxy.php | 97 ------------- .../OAuth2/Traits/ValidateScopesTrait.php | 12 -- api/components/OAuth2/Utils/Scopes.php | 28 ---- api/config/config.php | 2 +- .../authserver/models/RefreshTokenForm.php | 12 +- .../functional/authserver/RefreshCest.php | 25 ++++ .../models/AuthenticationFormTest.php | 134 ++++-------------- common/tests/_support/ProtectedCaller.php | 9 +- 8 files changed, 70 insertions(+), 249 deletions(-) delete mode 100644 api/components/OAuth2/RequestTypes/AuthorizationRequestProxy.php delete mode 100644 api/components/OAuth2/Traits/ValidateScopesTrait.php delete mode 100644 api/components/OAuth2/Utils/Scopes.php diff --git a/api/components/OAuth2/RequestTypes/AuthorizationRequestProxy.php b/api/components/OAuth2/RequestTypes/AuthorizationRequestProxy.php deleted file mode 100644 index 0d07bc1..0000000 --- a/api/components/OAuth2/RequestTypes/AuthorizationRequestProxy.php +++ /dev/null @@ -1,97 +0,0 @@ -authorizationRequest = $authorizationRequest; - } - - public function getOriginalAuthorizationRequest(): AuthorizationRequest { - return $this->authorizationRequest; - } - - public function getGrantTypeId(): string { - return $this->authorizationRequest->getGrantTypeId(); - } - - public function setGrantTypeId($grantTypeId): void { - $this->authorizationRequest->setGrantTypeId($grantTypeId); - } - - public function getClient(): ClientEntityInterface { - return $this->authorizationRequest->getClient(); - } - - public function setClient(ClientEntityInterface $client): void { - $this->authorizationRequest->setClient($client); - } - - public function getUser(): UserEntityInterface { - return $this->authorizationRequest->getUser(); - } - - public function setUser(UserEntityInterface $user): void { - $this->authorizationRequest->setUser($user); - } - - public function getScopes(): array { - return $this->authorizationRequest->getScopes(); - } - - public function setScopes(array $scopes): void { - $this->authorizationRequest->setScopes($scopes); - } - - public function isAuthorizationApproved(): bool { - return $this->authorizationRequest->isAuthorizationApproved(); - } - - public function setAuthorizationApproved($authorizationApproved): void { - $this->authorizationRequest->setAuthorizationApproved($authorizationApproved); - } - - public function getRedirectUri(): ?string { - return $this->authorizationRequest->getRedirectUri(); - } - - public function setRedirectUri($redirectUri): void { - $this->authorizationRequest->setRedirectUri($redirectUri); - } - - public function getState(): ?string { - return $this->authorizationRequest->getState(); - } - - public function setState($state): void { - $this->authorizationRequest->setState($state); - } - - public function getCodeChallenge(): string { - return $this->authorizationRequest->getCodeChallenge(); - } - - public function setCodeChallenge($codeChallenge): void { - $this->authorizationRequest->setCodeChallenge($codeChallenge); - } - - public function getCodeChallengeMethod(): string { - return $this->authorizationRequest->getCodeChallengeMethod(); - } - - public function setCodeChallengeMethod($codeChallengeMethod): void { - $this->authorizationRequest->setCodeChallengeMethod($codeChallengeMethod); - } - -} diff --git a/api/components/OAuth2/Traits/ValidateScopesTrait.php b/api/components/OAuth2/Traits/ValidateScopesTrait.php deleted file mode 100644 index c94f65b..0000000 --- a/api/components/OAuth2/Traits/ValidateScopesTrait.php +++ /dev/null @@ -1,12 +0,0 @@ - getenv('JWT_ENCRYPTION_KEY'), ], 'tokensFactory' => [ - 'class' => api\components\Tokens\TokensFactory::class, + 'class' => api\components\Tokens\TokensFactory::class, ], 'log' => [ 'traceLevel' => YII_DEBUG ? 3 : 0, diff --git a/api/modules/authserver/models/RefreshTokenForm.php b/api/modules/authserver/models/RefreshTokenForm.php index 92eb6a5..d719c8d 100644 --- a/api/modules/authserver/models/RefreshTokenForm.php +++ b/api/modules/authserver/models/RefreshTokenForm.php @@ -37,14 +37,16 @@ class RefreshTokenForm extends ApiForm { */ public function refresh(): AuthenticateData { $this->validate(); - + $account = null; if (mb_strlen($this->accessToken) === 36) { /** @var MinecraftAccessKey $token */ $token = MinecraftAccessKey::findOne([ 'access_token' => $this->accessToken, 'client_token' => $this->clientToken, ]); - $account = $token->account; + if ($token !== null) { + $account = $token->account; + } } else { $token = Yii::$app->tokens->parse($this->accessToken); @@ -59,7 +61,11 @@ class RefreshTokenForm extends ApiForm { $account = Account::findOne(['id' => $accountId]); } - if ($account === null || $account->status === Account::STATUS_BANNED) { + if ($account === null) { + throw new ForbiddenOperationException('Invalid token.'); + } + + if ($account->status === Account::STATUS_BANNED) { throw new ForbiddenOperationException('This account has been suspended.'); } diff --git a/api/tests/functional/authserver/RefreshCest.php b/api/tests/functional/authserver/RefreshCest.php index 5245732..1a92c16 100644 --- a/api/tests/functional/authserver/RefreshCest.php +++ b/api/tests/functional/authserver/RefreshCest.php @@ -28,6 +28,31 @@ class RefreshCest { $this->assertSuccessResponse($I); } + public function refreshWithInvalidClientToken(AuthserverSteps $I) { + $I->wantTo('refresh accessToken with not matched client token'); + [$accessToken] = $I->amAuthenticated(); + $I->sendPOST('/api/authserver/authentication/refresh', [ + 'accessToken' => $accessToken, + 'clientToken' => Uuid::uuid4()->toString(), + ]); + $I->canSeeResponseContainsJson([ + 'error' => 'ForbiddenOperationException', + 'errorMessage' => 'Invalid token.', + ]); + } + + public function refreshLegacyAccessTokenWithInvalidClientToken(AuthserverSteps $I) { + $I->wantTo('refresh legacy accessToken with not matched client token'); + $I->sendPOST('/api/authserver/authentication/refresh', [ + 'accessToken' => 'e7bb6648-2183-4981-9b86-eba5e7f87b42', + 'clientToken' => Uuid::uuid4()->toString(), + ]); + $I->canSeeResponseContainsJson([ + 'error' => 'ForbiddenOperationException', + 'errorMessage' => 'Invalid token.', + ]); + } + /** * @example {"accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpYXQiOjE1NzU0Nzk1NTMsImV4cCI6MTU3NTY1MjM1MywiZWx5LXNjb3BlcyI6Im1pbmVjcmFmdF9zZXJ2ZXJfc2Vzc2lvbiIsImVseS1jbGllbnQtdG9rZW4iOiJkZWY1MDIwMDE2ZTEzMTBmMzM2YzVjYWQzZDdiMTJmYjcyNmVhYzdlYjgyOGUzMzg1MzBhMmFmODdkZTJhMjRiMTVmNzAxNWQ1MjU1MjhiNGZiMjgzMTgxOTA2ODhlMWE4Njk5MjAwMzBlMTQyZmQ5ZWM5ODBlZDkzMWI1Mzc2MzgyMTliMjVjMjI1MjQyYzdmMjgzMjE0NjcyNDg3ZDQ4MTYxYjMwMGU1MGIzYWJlMTYwYjVkMmE4ZWMyMzMwMGJhMGNlMTg3MzYyYTgyMjJiYjQ4OTU0MzM4MDJiNTBlZDBhYzFhMWUwZDk3NDgxNDciLCJzdWIiOiJlbHl8MSJ9.PuM-8rzj4qtD9l0lUANSIWC8yjJe8ifarOYsAjc3r4iYFt0P6za-gzJEPncDC80oCXsYVlJHtrEypcsB9wJFSg", "clientToken": "d1b1162c-3d73-4b35-b64f-7bf68bd0e853"} * @example {"accessToken": "6042634a-a1e2-4aed-866c-c661fe4e63e2", "clientToken": "47fb164a-2332-42c1-8bad-549e67bb210c"} diff --git a/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php b/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php index d6ebf7e..01c352f 100644 --- a/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php +++ b/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php @@ -3,139 +3,61 @@ declare(strict_types=1); namespace codeception\api\unit\modules\authserver\models; -use api\models\authentication\LoginForm; use api\modules\authserver\exceptions\ForbiddenOperationException; -use api\modules\authserver\models\AuthenticateData; use api\modules\authserver\models\AuthenticationForm; use api\tests\unit\TestCase; -use common\models\Account; -use common\models\MinecraftAccessKey; -use common\tests\_support\ProtectedCaller; use common\tests\fixtures\AccountFixture; -use common\tests\fixtures\MinecraftAccessKeyFixture; use Ramsey\Uuid\Uuid; class AuthenticationFormTest extends TestCase { - use ProtectedCaller; public function _fixtures(): array { return [ 'accounts' => AccountFixture::class, - 'minecraftAccessKeys' => MinecraftAccessKeyFixture::class, ]; } - public function testAuthenticateByWrongNicknamePass() { + public function testAuthenticateByValidCredentials() { + $authForm = new AuthenticationForm(); + $authForm->username = 'admin'; + $authForm->password = 'password_0'; + $authForm->clientToken = Uuid::uuid4()->toString(); + $result = $authForm->authenticate()->getResponseData(); + $this->assertRegExp('/^[\w=-]+\.[\w=-]+\.[\w=-]+$/', $result['accessToken']); + $this->assertSame($authForm->clientToken, $result['clientToken']); + $this->assertSame('df936908-b2e1-544d-96f8-2977ec213022', $result['selectedProfile']['id']); + $this->assertSame('Admin', $result['selectedProfile']['name']); + $this->assertFalse($result['selectedProfile']['legacy']); + } + + /** + * @dataProvider getInvalidCredentialsCases + */ + public function testAuthenticateByWrongNicknamePass(string $expectedFieldError, string $login, string $password) { $this->expectException(ForbiddenOperationException::class); - $this->expectExceptionMessage('Invalid credentials. Invalid nickname or password.'); - - $authForm = $this->createAuthForm(); - - $authForm->username = 'wrong-username'; - $authForm->password = 'wrong-password'; - $authForm->clientToken = Uuid::uuid4(); + $this->expectExceptionMessage("Invalid credentials. Invalid {$expectedFieldError} or password."); + $authForm = new AuthenticationForm(); + $authForm->username = $login; + $authForm->password = $password; + $authForm->clientToken = Uuid::uuid4()->toString(); $authForm->authenticate(); } - public function testAuthenticateByWrongEmailPass() { - $this->expectException(ForbiddenOperationException::class); - $this->expectExceptionMessage('Invalid credentials. Invalid email or password.'); - - $authForm = $this->createAuthForm(); - - $authForm->username = 'wrong-email@ely.by'; - $authForm->password = 'wrong-password'; - $authForm->clientToken = Uuid::uuid4(); - - $authForm->authenticate(); + public function getInvalidCredentialsCases() { + yield ['nickname', 'wrong-username', 'wrong-password']; + yield ['email', 'wrong-email@ely.by', 'wrong-password']; } public function testAuthenticateByValidCredentialsIntoBlockedAccount() { $this->expectException(ForbiddenOperationException::class); $this->expectExceptionMessage('This account has been suspended.'); - $authForm = $this->createAuthForm(Account::STATUS_BANNED); - - $authForm->username = 'dummy'; + $authForm = new AuthenticationForm(); + $authForm->username = 'Banned'; $authForm->password = 'password_0'; - $authForm->clientToken = Uuid::uuid4(); - + $authForm->clientToken = Uuid::uuid4()->toString(); $authForm->authenticate(); } - public function testAuthenticateByValidCredentials() { - $authForm = $this->createAuthForm(); - - $minecraftAccessKey = new MinecraftAccessKey(); - $minecraftAccessKey->access_token = Uuid::uuid4(); - $authForm->expects($this->once()) - ->method('createMinecraftAccessToken') - ->willReturn($minecraftAccessKey); - - $authForm->username = 'dummy'; - $authForm->password = 'password_0'; - $authForm->clientToken = Uuid::uuid4(); - - $result = $authForm->authenticate(); - $this->assertInstanceOf(AuthenticateData::class, $result); - $this->assertSame($minecraftAccessKey->access_token, $result->getToken()->access_token); - } - - public function testCreateMinecraftAccessToken() { - $authForm = new AuthenticationForm(); - $authForm->clientToken = Uuid::uuid4(); - /** @var Account $account */ - $account = $this->tester->grabFixture('accounts', 'admin'); - /** @var MinecraftAccessKey $result */ - $result = $this->callProtected($authForm, 'createMinecraftAccessToken', $account); - $this->assertInstanceOf(MinecraftAccessKey::class, $result); - $this->assertSame($account->id, $result->account_id); - $this->assertSame($authForm->clientToken, $result->client_token); - $this->assertInstanceOf(MinecraftAccessKey::class, MinecraftAccessKey::findOne($result->access_token)); - } - - public function testCreateMinecraftAccessTokenWithExistsClientId() { - $authForm = new AuthenticationForm(); - $minecraftFixture = $this->tester->grabFixture('minecraftAccessKeys', 'admin-token'); - $authForm->clientToken = $minecraftFixture['client_token']; - /** @var Account $account */ - $account = $this->tester->grabFixture('accounts', 'admin'); - /** @var MinecraftAccessKey $result */ - $result = $this->callProtected($authForm, 'createMinecraftAccessToken', $account); - $this->assertInstanceOf(MinecraftAccessKey::class, $result); - $this->assertSame($account->id, $result->account_id); - $this->assertSame($authForm->clientToken, $result->client_token); - $this->assertNull(MinecraftAccessKey::findOne($minecraftFixture['access_token'])); - $this->assertInstanceOf(MinecraftAccessKey::class, MinecraftAccessKey::findOne($result->access_token)); - } - - private function createAuthForm($status = Account::STATUS_ACTIVE) { - /** @var LoginForm|\PHPUnit\Framework\MockObject\MockObject $loginForm */ - $loginForm = $this->getMockBuilder(LoginForm::class) - ->setMethods(['getAccount']) - ->getMock(); - - $account = new Account(); - $account->username = 'dummy'; - $account->email = 'dummy@ely.by'; - $account->status = $status; - $account->setPassword('password_0'); - - $loginForm - ->method('getAccount') - ->willReturn($account); - - /** @var AuthenticationForm|\PHPUnit\Framework\MockObject\MockObject $authForm */ - $authForm = $this->getMockBuilder(AuthenticationForm::class) - ->setMethods(['createLoginForm', 'createMinecraftAccessToken']) - ->getMock(); - - $authForm - ->method('createLoginForm') - ->willReturn($loginForm); - - return $authForm; - } - } diff --git a/common/tests/_support/ProtectedCaller.php b/common/tests/_support/ProtectedCaller.php index a5e69a9..08a6a59 100644 --- a/common/tests/_support/ProtectedCaller.php +++ b/common/tests/_support/ProtectedCaller.php @@ -1,13 +1,18 @@ getMethod($function); + $method = $class->getMethod($methodName); $method->setAccessible(true); return $method->invokeArgs($object, $args); From c3ffb08c4a0936652b013aaf83529c4f9425f78b Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Thu, 5 Dec 2019 01:15:45 +0300 Subject: [PATCH 16/28] Cleanup session server module --- .../session/controllers/SessionController.php | 31 +++++++++--- api/modules/session/models/HasJoinedForm.php | 19 ++++++-- api/modules/session/models/JoinForm.php | 47 ++++++++++++++----- 3 files changed, 73 insertions(+), 24 deletions(-) diff --git a/api/modules/session/controllers/SessionController.php b/api/modules/session/controllers/SessionController.php index 6e88b11..4e1fc34 100644 --- a/api/modules/session/controllers/SessionController.php +++ b/api/modules/session/controllers/SessionController.php @@ -1,4 +1,6 @@ response->format = Response::FORMAT_JSON; $data = Yii::$app->request->post(); $protocol = new ModernJoin($data['accessToken'] ?? '', $data['selectedProfile'] ?? '', $data['serverId'] ?? ''); $joinForm = new JoinForm($protocol); - $joinForm->join(); + $joinForm->join(); // will throw an exception in case of any error return ['id' => 'OK']; } - public function actionJoinLegacy() { + public function actionJoinLegacy(): string { Yii::$app->response->format = Response::FORMAT_RAW; $data = Yii::$app->request->get(); @@ -64,7 +71,12 @@ class SessionController extends Controller { return 'OK'; } - public function actionHasJoined() { + /** + * @return array + * @throws ForbiddenOperationException + * @throws IllegalArgumentException + */ + public function actionHasJoined(): array { Yii::$app->response->format = Response::FORMAT_JSON; $data = Yii::$app->request->get(); @@ -76,7 +88,7 @@ class SessionController extends Controller { return $textures->getMinecraftResponse(); } - public function actionHasJoinedLegacy() { + public function actionHasJoinedLegacy(): string { Yii::$app->response->format = Response::FORMAT_RAW; $data = Yii::$app->request->get(); @@ -95,7 +107,14 @@ class SessionController extends Controller { return 'YES'; } - public function actionProfile($uuid) { + /** + * @param string $uuid + * + * @return array + * @throws ForbiddenOperationException + * @throws IllegalArgumentException + */ + public function actionProfile(string $uuid): array { try { $uuid = Uuid::fromString($uuid)->toString(); } catch (\InvalidArgumentException $e) { diff --git a/api/modules/session/models/HasJoinedForm.php b/api/modules/session/models/HasJoinedForm.php index da5a2ee..d6a547b 100644 --- a/api/modules/session/models/HasJoinedForm.php +++ b/api/modules/session/models/HasJoinedForm.php @@ -1,4 +1,6 @@ protocol = $protocol; parent::__construct($config); + $this->protocol = $protocol; } + /** + * @return Account + * @throws ForbiddenOperationException + * @throws IllegalArgumentException + */ public function hasJoined(): Account { Yii::$app->statsd->inc('sessionserver.hasJoined.attempt'); if (!$this->protocol->validate()) { @@ -38,10 +48,9 @@ class HasJoinedForm extends Model { } $joinModel->delete(); + /** @var Account $account */ $account = $joinModel->getAccount(); - if ($account === null) { - throw new ErrorException('Account must exists'); - } + Assert::notNull($account); Session::info("User with username = '{$username}' successfully verified by server with server_id = '{$serverId}'."); Yii::$app->statsd->inc('sessionserver.hasJoined.success'); diff --git a/api/modules/session/models/JoinForm.php b/api/modules/session/models/JoinForm.php index bc4e2ad..29764ca 100644 --- a/api/modules/session/models/JoinForm.php +++ b/api/modules/session/models/JoinForm.php @@ -1,4 +1,6 @@ protocol = $protocol; $this->accessToken = $protocol->getAccessToken(); $this->selectedProfile = $protocol->getSelectedProfile(); $this->serverId = $protocol->getServerId(); - - parent::__construct($config); } - public function rules() { + public function rules(): array { return [ [['accessToken', 'serverId'], RequiredValidator::class], [['accessToken', 'selectedProfile'], 'validateUuid'], @@ -51,7 +52,12 @@ class JoinForm extends Model { ]; } - public function join() { + /** + * @return bool + * @throws IllegalArgumentException + * @throws ForbiddenOperationException + */ + public function join(): bool { $serverId = $this->serverId; $accessToken = $this->accessToken; Session::info("User with access_token = '{$accessToken}' trying join to server with server_id = '{$serverId}'."); @@ -62,9 +68,7 @@ class JoinForm extends Model { $account = $this->getAccount(); $sessionModel = new SessionModel($account->username, $serverId); - if (!$sessionModel->save()) { - throw new ErrorException('Cannot save join session model'); - } + Assert::true($sessionModel->save()); Session::info("User with access_token = '{$accessToken}' and nickname = '{$account->username}' successfully joined to server_id = '{$serverId}'."); Yii::$app->statsd->inc('sessionserver.join.success'); @@ -72,7 +76,14 @@ class JoinForm extends Model { return true; } - public function validate($attributeNames = null, $clearErrors = true) { + /** + * @param string $attributeNames + * @param bool $clearErrors + * + * @return bool + * @throws IllegalArgumentException + */ + public function validate($attributeNames = null, $clearErrors = true): bool { if (!$this->protocol->validate()) { throw new IllegalArgumentException(); } @@ -80,7 +91,12 @@ class JoinForm extends Model { return parent::validate($attributeNames, $clearErrors); } - public function validateUuid($attribute) { + /** + * @param string $attribute + * + * @throws IllegalArgumentException + */ + public function validateUuid(string $attribute): void { if ($this->hasErrors($attribute)) { return; } @@ -91,18 +107,19 @@ class JoinForm extends Model { } /** - * @throws \api\modules\session\exceptions\SessionServerException + * @throws \api\modules\session\exceptions\ForbiddenOperationException */ - public function validateAccessToken() { + public function validateAccessToken(): void { $accessToken = $this->accessToken; /** @var MinecraftAccessKey|null $accessModel */ - $accessModel = MinecraftAccessKey::findOne($accessToken); + $accessModel = MinecraftAccessKey::findOne(['access_token' => $accessToken]); if ($accessModel !== null) { Yii::$app->statsd->inc('sessionserver.authentication.legacy_minecraft_protocol'); /** @var MinecraftAccessKey|\api\components\OAuth2\Entities\AccessTokenEntity $accessModel */ if ($accessModel->isExpired()) { Session::error("User with access_token = '{$accessToken}' failed join by expired access_token."); Yii::$app->statsd->inc('sessionserver.authentication.legacy_minecraft_protocol_token_expired'); + throw new ForbiddenOperationException('Expired access_token.'); } @@ -117,6 +134,7 @@ class JoinForm extends Model { if ($identity === null) { Session::error("User with access_token = '{$accessToken}' failed join by wrong access_token."); Yii::$app->statsd->inc('sessionserver.join.fail_wrong_token'); + throw new ForbiddenOperationException('Invalid access_token.'); } @@ -124,6 +142,7 @@ class JoinForm extends Model { if (!Yii::$app->user->can(P::MINECRAFT_SERVER_SESSION)) { Session::error("User with access_token = '{$accessToken}' doesn't have enough scopes to make join."); Yii::$app->statsd->inc('sessionserver.authentication.oauth2_not_enough_scopes'); + throw new ForbiddenOperationException('The token does not have required scope.'); } @@ -135,12 +154,14 @@ class JoinForm extends Model { if ($isUuid && $account->uuid !== $this->normalizeUUID($selectedProfile)) { Session::error("User with access_token = '{$accessToken}' trying to join with identity = '{$selectedProfile}', but access_token issued to account with id = '{$account->uuid}'."); Yii::$app->statsd->inc('sessionserver.join.fail_uuid_mismatch'); + throw new ForbiddenOperationException('Wrong selected_profile.'); } if (!$isUuid && mb_strtolower($account->username) !== mb_strtolower($selectedProfile)) { Session::error("User with access_token = '{$accessToken}' trying to join with identity = '{$selectedProfile}', but access_token issued to account with username = '{$account->username}'."); Yii::$app->statsd->inc('sessionserver.join.fail_username_mismatch'); + throw new ForbiddenOperationException('Invalid credentials'); } From 642db2e045290fea373908323eb3b82728e5db60 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Thu, 5 Dec 2019 19:37:46 +0300 Subject: [PATCH 17/28] Use libsodium to encrypt data, stored in jwt tokens --- api/components/Tokens/Component.php | 24 ++++++++++++++++--- api/config/config-test.php | 2 +- .../functional/authserver/RefreshCest.php | 2 +- composer.json | 2 +- composer.lock | 5 ++-- 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/api/components/Tokens/Component.php b/api/components/Tokens/Component.php index b7bfca4..8d40827 100644 --- a/api/components/Tokens/Component.php +++ b/api/components/Tokens/Component.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace api\components\Tokens; use Carbon\Carbon; -use Defuse\Crypto\Crypto; use Exception; use Lcobucci\JWT\Builder; use Lcobucci\JWT\Parser; @@ -92,8 +91,27 @@ class Component extends BaseComponent { } } + public function encryptValue(string $rawValue): string { + /** @noinspection PhpUnhandledExceptionInspection */ + $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $cipher = base64_encode($nonce . sodium_crypto_secretbox($rawValue, $nonce, $this->encryptionKey)); + sodium_memzero($rawValue); + + return $cipher; + } + public function decryptValue(string $encryptedValue): string { - return Crypto::decryptWithPassword($encryptedValue, $this->encryptionKey); + $decoded = base64_decode($encryptedValue); + Assert::true($decoded !== false, 'passed value has an invalid base64 encoding'); + Assert::true(mb_strlen($decoded, '8bit') >= (SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES)); + $nonce = mb_substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit'); + $cipherText = mb_substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, '8bit'); + + $rawValue = sodium_crypto_secretbox_open($cipherText, $nonce, $this->encryptionKey); + Assert::true($rawValue !== false); + sodium_memzero($cipherText); + + return $rawValue; } private function getAlgorithmManager(): AlgorithmsManager { @@ -113,7 +131,7 @@ class Component extends BaseComponent { private function prepareValue($value) { if ($value instanceof EncryptedValue) { - return Crypto::encryptWithPassword($value->getValue(), $this->encryptionKey); + return $this->encryptValue($value->getValue()); } return $value; diff --git a/api/config/config-test.php b/api/config/config-test.php index a99c96d..1ebbde8 100644 --- a/api/config/config-test.php +++ b/api/config/config-test.php @@ -9,7 +9,7 @@ return [ 'privateKeyPath' => codecept_data_dir('certs/private.pem'), 'privateKeyPass' => null, 'publicKeyPath' => codecept_data_dir('certs/public.pem'), - 'encryptionKey' => 'mock-encryption-key', + 'encryptionKey' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', ], 'reCaptcha' => [ 'public' => 'public-key', diff --git a/api/tests/functional/authserver/RefreshCest.php b/api/tests/functional/authserver/RefreshCest.php index 1a92c16..b93525f 100644 --- a/api/tests/functional/authserver/RefreshCest.php +++ b/api/tests/functional/authserver/RefreshCest.php @@ -54,7 +54,7 @@ class RefreshCest { } /** - * @example {"accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpYXQiOjE1NzU0Nzk1NTMsImV4cCI6MTU3NTY1MjM1MywiZWx5LXNjb3BlcyI6Im1pbmVjcmFmdF9zZXJ2ZXJfc2Vzc2lvbiIsImVseS1jbGllbnQtdG9rZW4iOiJkZWY1MDIwMDE2ZTEzMTBmMzM2YzVjYWQzZDdiMTJmYjcyNmVhYzdlYjgyOGUzMzg1MzBhMmFmODdkZTJhMjRiMTVmNzAxNWQ1MjU1MjhiNGZiMjgzMTgxOTA2ODhlMWE4Njk5MjAwMzBlMTQyZmQ5ZWM5ODBlZDkzMWI1Mzc2MzgyMTliMjVjMjI1MjQyYzdmMjgzMjE0NjcyNDg3ZDQ4MTYxYjMwMGU1MGIzYWJlMTYwYjVkMmE4ZWMyMzMwMGJhMGNlMTg3MzYyYTgyMjJiYjQ4OTU0MzM4MDJiNTBlZDBhYzFhMWUwZDk3NDgxNDciLCJzdWIiOiJlbHl8MSJ9.PuM-8rzj4qtD9l0lUANSIWC8yjJe8ifarOYsAjc3r4iYFt0P6za-gzJEPncDC80oCXsYVlJHtrEypcsB9wJFSg", "clientToken": "d1b1162c-3d73-4b35-b64f-7bf68bd0e853"} + * @example {"accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpYXQiOjE1NzU1NjE1MjgsImV4cCI6MTU3NTU2MTUyOCwiZWx5LXNjb3BlcyI6Im1pbmVjcmFmdF9zZXJ2ZXJfc2Vzc2lvbiIsImVseS1jbGllbnQtdG9rZW4iOiJZQU1YZ3kwQXBCOWdnVC9VWDYzSWk3SnBrTXdmcE5sWmhPMlVVRHhGd2ExZmdoOHZLMjdEbVdubzdsam5NaVlicENVbktPWFZ0dldWK1VYNXVkUFVRbCtOMWNwQWZSQS9hK2VtQWc9PSIsInN1YiI6ImVseXwxIn0.Yt3k9NpTthBVrrmcO6npd8n3zksolC2RI1m-NH2-_YEiaaCGC2vW8iszi3WB-g6f6Q64OYuQXxxXMl516PLTfA", "clientToken": "4f368b58-9097-4e56-80b1-f421ae4b53cf"} * @example {"accessToken": "6042634a-a1e2-4aed-866c-c661fe4e63e2", "clientToken": "47fb164a-2332-42c1-8bad-549e67bb210c"} */ public function refreshExpiredToken(AuthserverSteps $I, Example $example) { diff --git a/composer.json b/composer.json index e0e5efa..921d41b 100644 --- a/composer.json +++ b/composer.json @@ -12,8 +12,8 @@ "ext-mbstring": "*", "ext-pdo": "*", "ext-simplexml": "*", + "ext-sodium": "*", "bacon/bacon-qr-code": "^1.0", - "defuse/php-encryption": "^2.2", "domnikl/statsd": "^2.6", "ely/mojang-api": "^0.2.0", "ely/yii2-tempmail-validator": "^2.0", diff --git a/composer.lock b/composer.lock index e96b55c..cd3dbe9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "10af6b999939a9f213664883387184ed", + "content-hash": "95971ae8836e4d182aae9e5c44021321", "packages": [ { "name": "bacon/bacon-qr-code", @@ -6733,7 +6733,8 @@ "ext-libxml": "*", "ext-mbstring": "*", "ext-pdo": "*", - "ext-simplexml": "*" + "ext-simplexml": "*", + "ext-sodium": "*" }, "platform-dev": [] } From 6fb32ec76d0228e9716d91f0b9bf3c945b7e6b02 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Fri, 6 Dec 2019 14:37:51 +0300 Subject: [PATCH 18/28] Use libsodium to encrypt all data, related to OAuth2 --- api/components/OAuth2/Component.php | 18 +++++-------- api/components/OAuth2/CryptTrait.php | 26 +++++++++++++++++++ .../OAuth2/Grants/AuthCodeGrant.php | 2 ++ .../OAuth2/Grants/ClientCredentialsGrant.php | 12 +++++++++ .../OAuth2/Grants/RefreshTokenGrant.php | 2 ++ .../ResponseTypes/BearerTokenResponse.php | 12 +++++++++ api/components/Tokens/Component.php | 12 +++++++-- api/config/config-test.php | 3 --- api/config/config.php | 1 - api/tests/functional/_steps/OauthSteps.php | 2 +- 10 files changed, 71 insertions(+), 19 deletions(-) create mode 100644 api/components/OAuth2/CryptTrait.php create mode 100644 api/components/OAuth2/Grants/ClientCredentialsGrant.php create mode 100644 api/components/OAuth2/ResponseTypes/BearerTokenResponse.php diff --git a/api/components/OAuth2/Component.php b/api/components/OAuth2/Component.php index 38081a1..125ea57 100644 --- a/api/components/OAuth2/Component.php +++ b/api/components/OAuth2/Component.php @@ -3,21 +3,13 @@ declare(strict_types=1); namespace api\components\OAuth2; -use api\components\OAuth2\Grants\AuthCodeGrant; -use api\components\OAuth2\Grants\RefreshTokenGrant; use api\components\OAuth2\Keys\EmptyKey; use DateInterval; use League\OAuth2\Server\AuthorizationServer; -use League\OAuth2\Server\Grant; use yii\base\Component as BaseComponent; class Component extends BaseComponent { - /** - * @var string|\Defuse\Crypto\Key - */ - public $encryptionKey; - /** * @var AuthorizationServer */ @@ -39,19 +31,21 @@ class Component extends BaseComponent { $accessTokensRepo, new Repositories\EmptyScopeRepository(), new EmptyKey(), - $this->encryptionKey + '', // omit key because we use our own encryption mechanism + new ResponseTypes\BearerTokenResponse() ); - $authCodeGrant = new AuthCodeGrant($authCodesRepo, $refreshTokensRepo, new DateInterval('PT10M')); + /** @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 RefreshTokenGrant($refreshTokensRepo); + $refreshTokenGrant = new Grants\RefreshTokenGrant($refreshTokensRepo); $authServer->enableGrantType($refreshTokenGrant); $refreshTokenGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling // TODO: make these access tokens live longer - $clientCredentialsGrant = new Grant\ClientCredentialsGrant(); + $clientCredentialsGrant = new Grants\ClientCredentialsGrant(); $authServer->enableGrantType($clientCredentialsGrant, $accessTokenTTL); $clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling diff --git a/api/components/OAuth2/CryptTrait.php b/api/components/OAuth2/CryptTrait.php new file mode 100644 index 0000000..c3728a3 --- /dev/null +++ b/api/components/OAuth2/CryptTrait.php @@ -0,0 +1,26 @@ +tokens->encryptValue($unencryptedData); + } + + protected function decrypt($encryptedData): string { + return Yii::$app->tokens->decryptValue($encryptedData); + } + +} diff --git a/api/components/OAuth2/Grants/AuthCodeGrant.php b/api/components/OAuth2/Grants/AuthCodeGrant.php index 5cc1ea7..09fda6d 100644 --- a/api/components/OAuth2/Grants/AuthCodeGrant.php +++ b/api/components/OAuth2/Grants/AuthCodeGrant.php @@ -3,12 +3,14 @@ declare(strict_types=1); namespace api\components\OAuth2\Grants; +use api\components\OAuth2\CryptTrait; use api\components\OAuth2\Repositories\PublicScopeRepository; use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; 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) { diff --git a/api/components/OAuth2/Grants/ClientCredentialsGrant.php b/api/components/OAuth2/Grants/ClientCredentialsGrant.php new file mode 100644 index 0000000..fa72668 --- /dev/null +++ b/api/components/OAuth2/Grants/ClientCredentialsGrant.php @@ -0,0 +1,12 @@ +encryptionKey)); + $cipher = $this->base64UrlEncode($nonce . sodium_crypto_secretbox($rawValue, $nonce, $this->encryptionKey)); sodium_memzero($rawValue); return $cipher; } public function decryptValue(string $encryptedValue): string { - $decoded = base64_decode($encryptedValue); + $decoded = $this->base64UrlDecode($encryptedValue); Assert::true($decoded !== false, 'passed value has an invalid base64 encoding'); Assert::true(mb_strlen($decoded, '8bit') >= (SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES)); $nonce = mb_substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit'); @@ -137,4 +137,12 @@ class Component extends BaseComponent { return $value; } + private function base64UrlEncode(string $rawValue): string { + return rtrim(strtr(base64_encode($rawValue), '+/', '-_'), '='); + } + + private function base64UrlDecode(string $encodedValue): string { + return base64_decode(str_pad(strtr($encodedValue, '-_', '+/'), strlen($encodedValue) % 4, '=', STR_PAD_RIGHT)); + } + } diff --git a/api/config/config-test.php b/api/config/config-test.php index 1ebbde8..ae91b69 100644 --- a/api/config/config-test.php +++ b/api/config/config-test.php @@ -1,9 +1,6 @@ [ - 'oauth' => [ - 'encryptionKey' => 'mock-encryption-key', - ], 'tokens' => [ 'hmacKey' => 'tests-secret-key', 'privateKeyPath' => codecept_data_dir('certs/private.pem'), diff --git a/api/config/config.php b/api/config/config.php index 3383e9e..115b8ab 100644 --- a/api/config/config.php +++ b/api/config/config.php @@ -13,7 +13,6 @@ return [ ], 'oauth' => [ 'class' => api\components\OAuth2\Component::class, - 'encryptionKey' => getenv('JWT_ENCRYPTION_KEY'), ], 'tokens' => [ 'class' => api\components\Tokens\Component::class, diff --git a/api/tests/functional/_steps/OauthSteps.php b/api/tests/functional/_steps/OauthSteps.php index c6cffc4..6c09d4a 100644 --- a/api/tests/functional/_steps/OauthSteps.php +++ b/api/tests/functional/_steps/OauthSteps.php @@ -18,7 +18,7 @@ class OauthSteps extends FunctionalTester { ]), ['accept' => true]); $this->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); [$redirectUri] = $this->grabDataFromResponseByJsonPath('$.redirectUri'); - preg_match('/code=([\w-]+)/', $redirectUri, $matches); + preg_match('/code=([^&$]+)/', $redirectUri, $matches); return $matches[1]; } From f0a73f2b7a0d85b5fc117910f0c6c62ea62705f4 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Fri, 6 Dec 2019 18:31:04 +0300 Subject: [PATCH 19/28] Make tokens, created by client credentials grant to live forever --- api/components/OAuth2/Component.php | 6 +- api/components/Tokens/Component.php | 8 ++- api/components/Tokens/TokensFactory.php | 30 ++++++--- api/config/config.php | 16 ++--- .../oauth/ClientCredentialsCest.php | 3 +- .../unit/components/Tokens/ComponentTest.php | 6 +- .../components/Tokens/TokensFactoryTest.php | 66 ++++++++++++++++++- .../AuthenticationResultTest.php | 7 +- 8 files changed, 113 insertions(+), 29 deletions(-) diff --git a/api/components/OAuth2/Component.php b/api/components/OAuth2/Component.php index 125ea57..3712109 100644 --- a/api/components/OAuth2/Component.php +++ b/api/components/OAuth2/Component.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace api\components\OAuth2; use api\components\OAuth2\Keys\EmptyKey; +use Carbon\CarbonInterval; use DateInterval; use League\OAuth2\Server\AuthorizationServer; use yii\base\Component as BaseComponent; @@ -24,7 +25,7 @@ class Component extends BaseComponent { $authCodesRepo = new Repositories\AuthCodeRepository(); $refreshTokensRepo = new Repositories\RefreshTokenRepository(); - $accessTokenTTL = new DateInterval('P1D'); + $accessTokenTTL = CarbonInterval::day(); $authServer = new AuthorizationServer( $clientsRepo, @@ -44,9 +45,8 @@ class Component extends BaseComponent { $authServer->enableGrantType($refreshTokenGrant); $refreshTokenGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling - // TODO: make these access tokens live longer $clientCredentialsGrant = new Grants\ClientCredentialsGrant(); - $authServer->enableGrantType($clientCredentialsGrant, $accessTokenTTL); + $authServer->enableGrantType($clientCredentialsGrant, CarbonInterval::create(-1)); // set negative value to make it non expiring $clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling $this->_authServer = $authServer; diff --git a/api/components/Tokens/Component.php b/api/components/Tokens/Component.php index 5f0efbe..7294a74 100644 --- a/api/components/Tokens/Component.php +++ b/api/components/Tokens/Component.php @@ -55,9 +55,11 @@ class Component extends BaseComponent { public function create(array $payloads = [], array $headers = []): Token { $now = Carbon::now(); - $builder = (new Builder()) - ->issuedAt($now->getTimestamp()) - ->expiresAt($now->addHour()->getTimestamp()); + $builder = (new Builder())->issuedAt($now->getTimestamp()); + if (isset($payloads['exp'])) { + $builder->expiresAt($payloads['exp']); + } + foreach ($payloads as $claim => $value) { $builder->withClaim($claim, $this->prepareValue($value)); } diff --git a/api/components/Tokens/TokensFactory.php b/api/components/Tokens/TokensFactory.php index 4c29339..7adb800 100644 --- a/api/components/Tokens/TokensFactory.php +++ b/api/components/Tokens/TokensFactory.php @@ -8,6 +8,7 @@ use api\rbac\Roles as R; use Carbon\Carbon; use common\models\Account; use common\models\AccountSession; +use DateTime; use Lcobucci\JWT\Token; use League\OAuth2\Server\Entities\AccessTokenEntityInterface; use League\OAuth2\Server\Entities\ScopeEntityInterface; @@ -21,8 +22,9 @@ class TokensFactory extends Component { public function createForWebAccount(Account $account, AccountSession $session = null): Token { $payloads = [ - 'ely-scopes' => R::ACCOUNTS_WEB_USER, + 'ely-scopes' => $this->prepareScopes([R::ACCOUNTS_WEB_USER]), 'sub' => $this->buildSub($account->id), + 'exp' => Carbon::now()->addHour()->getTimestamp(), ]; if ($session === null) { // If we don't remember a session, the token should live longer @@ -38,11 +40,12 @@ class TokensFactory extends Component { public function createForOAuthClient(AccessTokenEntityInterface $accessToken): Token { $payloads = [ 'aud' => $this->buildAud($accessToken->getClient()->getIdentifier()), - 'ely-scopes' => $this->joinScopes(array_map(static function(ScopeEntityInterface $scope): string { - return $scope->getIdentifier(); - }, $accessToken->getScopes())), - 'exp' => $accessToken->getExpiryDateTime()->getTimestamp(), + 'ely-scopes' => $this->prepareScopes($accessToken->getScopes()), ]; + if ($accessToken->getExpiryDateTime() > new DateTime()) { + $payloads['exp'] = $accessToken->getExpiryDateTime()->getTimestamp(); + } + if ($accessToken->getUserIdentifier() !== null) { $payloads['sub'] = $this->buildSub($accessToken->getUserIdentifier()); } @@ -52,15 +55,26 @@ class TokensFactory extends Component { public function createForMinecraftAccount(Account $account, string $clientToken): Token { return Yii::$app->tokens->create([ - 'ely-scopes' => $this->joinScopes([P::MINECRAFT_SERVER_SESSION]), + 'ely-scopes' => $this->prepareScopes([P::MINECRAFT_SERVER_SESSION]), 'ely-client-token' => new EncryptedValue($clientToken), 'sub' => $this->buildSub($account->id), 'exp' => Carbon::now()->addDays(2)->getTimestamp(), ]); } - private function joinScopes(array $scopes): string { - return implode(',', $scopes); + /** + * @param ScopeEntityInterface[]|string[] $scopes + * + * @return string + */ + private function prepareScopes(array $scopes): string { + return implode(',', array_map(function($scope): string { + if ($scope instanceof ScopeEntityInterface) { + return $scope->getIdentifier(); + } + + return $scope; + }, $scopes)); } private function buildSub(int $accountId): string { diff --git a/api/config/config.php b/api/config/config.php index 115b8ab..0b3dda6 100644 --- a/api/config/config.php +++ b/api/config/config.php @@ -7,6 +7,14 @@ return [ 'params' => [ 'authserverHost' => getenv('AUTHSERVER_HOST') ?: 'authserver.ely.by', ], + 'modules' => [ + 'authserver' => api\modules\authserver\Module::class, + 'session' => api\modules\session\Module::class, + 'mojang' => api\modules\mojang\Module::class, + 'internal' => api\modules\internal\Module::class, + 'accounts' => api\modules\accounts\Module::class, + 'oauth' => api\modules\oauth\Module::class, + ], 'components' => [ 'user' => [ 'class' => api\components\User\Component::class, @@ -90,12 +98,4 @@ return [ 'class' => api\components\ErrorHandler::class, ], ], - 'modules' => [ - 'authserver' => api\modules\authserver\Module::class, - 'session' => api\modules\session\Module::class, - 'mojang' => api\modules\mojang\Module::class, - 'internal' => api\modules\internal\Module::class, - 'accounts' => api\modules\accounts\Module::class, - 'oauth' => api\modules\oauth\Module::class, - ], ]; diff --git a/api/tests/functional/oauth/ClientCredentialsCest.php b/api/tests/functional/oauth/ClientCredentialsCest.php index b83f78b..8712803 100644 --- a/api/tests/functional/oauth/ClientCredentialsCest.php +++ b/api/tests/functional/oauth/ClientCredentialsCest.php @@ -3,7 +3,6 @@ declare(strict_types=1); namespace api\tests\functional\oauth; -use api\tests\functional\_steps\OauthSteps; use api\tests\FunctionalTester; class ClientCredentialsCest { @@ -35,7 +34,7 @@ class ClientCredentialsCest { ]); } - public function issueTokenWithInternalScopesAsTrustedClient(OauthSteps $I) { + public function issueTokenWithInternalScopesAsTrustedClient(FunctionalTester $I) { $I->wantTo('issue token as trusted client and require some internal scope'); $I->sendPOST('/api/oauth2/v1/token', [ 'grant_type' => 'client_credentials', diff --git a/api/tests/unit/components/Tokens/ComponentTest.php b/api/tests/unit/components/Tokens/ComponentTest.php index d8a7018..e2d9e87 100644 --- a/api/tests/unit/components/Tokens/ComponentTest.php +++ b/api/tests/unit/components/Tokens/ComponentTest.php @@ -22,7 +22,11 @@ class ComponentTest extends TestCase { $this->assertSame('ES256', $token->getHeader('alg')); $this->assertEmpty(array_diff(array_keys($token->getClaims()), ['iat', 'exp'])); $this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 1); - $this->assertEqualsWithDelta(time() + 3600, $token->getClaim('exp'), 2); + + // Pass exp claim + $time = time() + 60; + $token = $this->component->create(['exp' => $time]); + $this->assertSame($time, $token->getClaim('exp')); // Pass custom payloads $token = $this->component->create(['find' => 'me']); diff --git a/api/tests/unit/components/Tokens/TokensFactoryTest.php b/api/tests/unit/components/Tokens/TokensFactoryTest.php index 7dc353b..36e536a 100644 --- a/api/tests/unit/components/Tokens/TokensFactoryTest.php +++ b/api/tests/unit/components/Tokens/TokensFactoryTest.php @@ -5,16 +5,22 @@ namespace api\tests\unit\components\Tokens; use api\components\Tokens\TokensFactory; use api\tests\unit\TestCase; +use Carbon\Carbon; use common\models\Account; use common\models\AccountSession; +use League\OAuth2\Server\Entities\AccessTokenEntityInterface; +use League\OAuth2\Server\Entities\ClientEntityInterface; +use League\OAuth2\Server\Entities\ScopeEntityInterface; class TokensFactoryTest extends TestCase { public function testCreateForAccount() { + $factory = new TokensFactory(); + $account = new Account(); $account->id = 1; - $factory = new TokensFactory(); + // Create for account $token = $factory->createForWebAccount($account); $this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 1); @@ -26,6 +32,8 @@ class TokensFactoryTest extends TestCase { $session = new AccountSession(); $session->id = 2; + // Create for account with remember me + $token = $factory->createForWebAccount($account, $session); $this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 1); $this->assertEqualsWithDelta(time() + 3600, $token->getClaim('exp'), 2); @@ -34,4 +42,60 @@ class TokensFactoryTest extends TestCase { $this->assertSame(2, $token->getClaim('jti')); } + public function testCreateForOauthClient() { + $factory = new TokensFactory(); + + $client = $this->createMock(ClientEntityInterface::class); + $client->method('getIdentifier')->willReturn('clientId'); + + $scope1 = $this->createMock(ScopeEntityInterface::class); + $scope1->method('getIdentifier')->willReturn('scope1'); + $scope2 = $this->createMock(ScopeEntityInterface::class); + $scope2->method('getIdentifier')->willReturn('scope2'); + + $expiryDateTime = Carbon::now()->addDay(); + + // Create for auth code grant + + $accessToken = $this->createMock(AccessTokenEntityInterface::class); + $accessToken->method('getClient')->willReturn($client); + $accessToken->method('getScopes')->willReturn([$scope1, $scope2]); + $accessToken->method('getExpiryDateTime')->willReturn($expiryDateTime); + $accessToken->method('getUserIdentifier')->willReturn(1); + + $token = $factory->createForOAuthClient($accessToken); + $this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 1); + $this->assertEqualsWithDelta($expiryDateTime->getTimestamp(), $token->getClaim('exp'), 2); + $this->assertSame('ely|1', $token->getClaim('sub')); + $this->assertSame('client|clientId', $token->getClaim('aud')); + $this->assertSame('scope1,scope2', $token->getClaim('ely-scopes')); + + // Create for client credentials grant + + $accessToken = $this->createMock(AccessTokenEntityInterface::class); + $accessToken->method('getClient')->willReturn($client); + $accessToken->method('getScopes')->willReturn([$scope1, $scope2]); + $accessToken->method('getExpiryDateTime')->willReturn(Carbon::now()->subDay()); + $accessToken->method('getUserIdentifier')->willReturn(null); + + $token = $factory->createForOAuthClient($accessToken); + $this->assertSame('no value', $token->getClaim('exp', 'no value')); + $this->assertSame('no value', $token->getClaim('sub', 'no value')); + } + + public function testCreateForMinecraftAccount() { + $factory = new TokensFactory(); + + $account = new Account(); + $account->id = 1; + $clientToken = 'e44fae79-f80e-4975-952e-47e8a9ed9472'; + + $token = $factory->createForMinecraftAccount($account, $clientToken); + $this->assertEqualsWithDelta(time(), $token->getClaim('iat'), 5); + $this->assertEqualsWithDelta(time() + 60 * 60 * 24 * 2, $token->getClaim('exp'), 5); + $this->assertSame('minecraft_server_session', $token->getClaim('ely-scopes')); + $this->assertNotSame('e44fae79-f80e-4975-952e-47e8a9ed9472', $token->getClaim('ely-client-token')); + $this->assertSame('ely|1', $token->getClaim('sub')); + } + } diff --git a/api/tests/unit/models/authentication/AuthenticationResultTest.php b/api/tests/unit/models/authentication/AuthenticationResultTest.php index 0945b98..9439cc5 100644 --- a/api/tests/unit/models/authentication/AuthenticationResultTest.php +++ b/api/tests/unit/models/authentication/AuthenticationResultTest.php @@ -21,19 +21,20 @@ class AuthenticationResultTest extends TestCase { } public function testGetAsResponse() { - $token = Yii::$app->tokens->create(); + $time = time() + 3600; + $token = Yii::$app->tokens->create(['exp' => $time]); $jwt = (string)$token; $model = new AuthenticationResult($token); $result = $model->formatAsOAuth2Response(); $this->assertSame($jwt, $result['access_token']); - $this->assertEqualsWithDelta(3600, $result['expires_in'], 1); + $this->assertSame(3600, $result['expires_in']); $this->assertArrayNotHasKey('refresh_token', $result); $model = new AuthenticationResult($token, 'refresh_token'); $result = $model->formatAsOAuth2Response(); $this->assertSame($jwt, $result['access_token']); - $this->assertEqualsWithDelta(3600, $result['expires_in'], 1); + $this->assertSame(3600, $result['expires_in']); $this->assertSame('refresh_token', $result['refresh_token']); } From efb97a2006944a109f40b773604654dd0ba3b76e Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Fri, 6 Dec 2019 19:07:08 +0300 Subject: [PATCH 20/28] Set access tokens TTL depending on the requested scopes --- api/components/OAuth2/Component.php | 2 +- .../OAuth2/Entities/AccessTokenEntity.php | 20 +++++++++++++++-- .../Repositories/PublicScopeRepository.php | 2 +- .../OAuth2/Entities/AccessTokenEntityTest.php | 22 ++++++++++++++++++- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/api/components/OAuth2/Component.php b/api/components/OAuth2/Component.php index 3712109..fc65b5b 100644 --- a/api/components/OAuth2/Component.php +++ b/api/components/OAuth2/Component.php @@ -25,7 +25,7 @@ class Component extends BaseComponent { $authCodesRepo = new Repositories\AuthCodeRepository(); $refreshTokensRepo = new Repositories\RefreshTokenRepository(); - $accessTokenTTL = CarbonInterval::day(); + $accessTokenTTL = CarbonInterval::days(2); $authServer = new AuthorizationServer( $clientsRepo, diff --git a/api/components/OAuth2/Entities/AccessTokenEntity.php b/api/components/OAuth2/Entities/AccessTokenEntity.php index 5d352e5..a3c0f78 100644 --- a/api/components/OAuth2/Entities/AccessTokenEntity.php +++ b/api/components/OAuth2/Entities/AccessTokenEntity.php @@ -4,6 +4,8 @@ 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; @@ -43,8 +45,22 @@ class AccessTokenEntity implements AccessTokenEntityInterface { } public function getExpiryDateTime(): DateTimeImmutable { - // TODO: extend token life depending on scopes list - return $this->parentGetExpiryDateTime(); + $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; } } diff --git a/api/components/OAuth2/Repositories/PublicScopeRepository.php b/api/components/OAuth2/Repositories/PublicScopeRepository.php index 60335e6..16991fb 100644 --- a/api/components/OAuth2/Repositories/PublicScopeRepository.php +++ b/api/components/OAuth2/Repositories/PublicScopeRepository.php @@ -12,8 +12,8 @@ use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; class PublicScopeRepository implements ScopeRepositoryInterface { public const OFFLINE_ACCESS = 'offline_access'; + public const CHANGE_SKIN = 'change_skin'; - private const CHANGE_SKIN = 'change_skin'; private const ACCOUNT_INFO = 'account_info'; private const ACCOUNT_EMAIL = 'account_email'; diff --git a/api/tests/unit/components/OAuth2/Entities/AccessTokenEntityTest.php b/api/tests/unit/components/OAuth2/Entities/AccessTokenEntityTest.php index 959c52e..a43a664 100644 --- a/api/tests/unit/components/OAuth2/Entities/AccessTokenEntityTest.php +++ b/api/tests/unit/components/OAuth2/Entities/AccessTokenEntityTest.php @@ -5,6 +5,8 @@ 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; @@ -17,7 +19,7 @@ class AccessTokenEntityTest extends TestCase { $entity = new AccessTokenEntity(); $entity->setClient($client); - $entity->setExpiryDateTime(new \DateTimeImmutable()); + $entity->setExpiryDateTime(new DateTimeImmutable()); $entity->addScope($this->createScopeEntity('first')); $entity->addScope($this->createScopeEntity('second')); $entity->addScope($this->createScopeEntity('offline_access')); @@ -33,6 +35,24 @@ class AccessTokenEntityTest extends TestCase { $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 { /** @var ScopeEntityInterface|\PHPUnit\Framework\MockObject\MockObject $entity */ $entity = $this->createMock(ScopeEntityInterface::class); From ba7fad84a0b02846889cc8e7320cdd116e4d6e02 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Mon, 9 Dec 2019 19:31:54 +0300 Subject: [PATCH 21/28] 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. --- api/components/OAuth2/Component.php | 69 ++++++++++--------- .../OAuth2/Entities/AccessTokenEntity.php | 46 +------------ .../OAuth2/Entities/RefreshTokenEntity.php | 29 -------- .../OAuth2/Events/RequestedRefreshToken.php | 10 +++ .../OAuth2/Grants/AuthCodeGrant.php | 28 ++++++-- .../OAuth2/Grants/RefreshTokenGrant.php | 38 +++++++++- .../Repositories/RefreshTokenRepository.php | 17 ++--- api/components/Tokens/TokenReader.php | 64 +++++++++++++++++ api/components/Tokens/TokensFactory.php | 2 +- api/components/User/JwtIdentity.php | 38 +++++----- .../authserver/models/RefreshTokenForm.php | 11 ++- .../controllers/AuthorizationController.php | 12 +++- api/modules/oauth/models/OauthProcess.php | 30 +++++--- .../functional/oauth/RefreshTokenCest.php | 2 +- .../OAuth2/Entities/AccessTokenEntityTest.php | 28 +------- .../unit/components/User/JwtIdentityTest.php | 8 +-- common/models/Account.php | 5 -- common/models/OauthClient.php | 5 -- common/models/OauthRefreshToken.php | 50 -------------- common/models/OauthSession.php | 5 -- common/tests/_support/FixtureHelper.php | 1 - .../fixtures/OauthRefreshTokensFixture.php | 19 ----- ...914_181236_rework_oauth_related_tables.php | 11 --- 23 files changed, 231 insertions(+), 297 deletions(-) delete mode 100644 api/components/OAuth2/Entities/RefreshTokenEntity.php create mode 100644 api/components/OAuth2/Events/RequestedRefreshToken.php create mode 100644 api/components/Tokens/TokenReader.php delete mode 100644 common/models/OauthRefreshToken.php delete mode 100644 common/tests/fixtures/OauthRefreshTokensFixture.php diff --git a/api/components/OAuth2/Component.php b/api/components/OAuth2/Component.php index fc65b5b..dd962a8 100644 --- a/api/components/OAuth2/Component.php +++ b/api/components/OAuth2/Component.php @@ -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; + } + } diff --git a/api/components/OAuth2/Entities/AccessTokenEntity.php b/api/components/OAuth2/Entities/AccessTokenEntity.php index a3c0f78..04c34e7 100644 --- a/api/components/OAuth2/Entities/AccessTokenEntity.php +++ b/api/components/OAuth2/Entities/AccessTokenEntity.php @@ -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; - } - } diff --git a/api/components/OAuth2/Entities/RefreshTokenEntity.php b/api/components/OAuth2/Entities/RefreshTokenEntity.php deleted file mode 100644 index 0b8383d..0000000 --- a/api/components/OAuth2/Entities/RefreshTokenEntity.php +++ /dev/null @@ -1,29 +0,0 @@ -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); } } diff --git a/api/components/OAuth2/Grants/RefreshTokenGrant.php b/api/components/OAuth2/Grants/RefreshTokenGrant.php index 18f070e..675d388 100644 --- a/api/components/OAuth2/Grants/RefreshTokenGrant.php +++ b/api/components/OAuth2/Grants/RefreshTokenGrant.php @@ -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, + ]; + } + } diff --git a/api/components/OAuth2/Repositories/RefreshTokenRepository.php b/api/components/OAuth2/Repositories/RefreshTokenRepository.php index b1096ed..199e342 100644 --- a/api/components/OAuth2/Repositories/RefreshTokenRepository.php +++ b/api/components/OAuth2/Repositories/RefreshTokenRepository.php @@ -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; } } diff --git a/api/components/Tokens/TokenReader.php b/api/components/Tokens/TokenReader.php new file mode 100644 index 0000000..95fb934 --- /dev/null +++ b/api/components/Tokens/TokenReader.php @@ -0,0 +1,64 @@ +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); + } + +} diff --git a/api/components/Tokens/TokensFactory.php b/api/components/Tokens/TokensFactory.php index 7adb800..0b7837c 100644 --- a/api/components/Tokens/TokensFactory.php +++ b/api/components/Tokens/TokensFactory.php @@ -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(); } diff --git a/api/components/User/JwtIdentity.php b/api/components/User/JwtIdentity.php index e327e32..34ee296 100644 --- a/api/components/User/JwtIdentity.php +++ b/api/components/User/JwtIdentity.php @@ -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; + } + } diff --git a/api/modules/authserver/models/RefreshTokenForm.php b/api/modules/authserver/models/RefreshTokenForm.php index d719c8d..f9adff1 100644 --- a/api/modules/authserver/models/RefreshTokenForm.php +++ b/api/modules/authserver/models/RefreshTokenForm.php @@ -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) { diff --git a/api/modules/oauth/controllers/AuthorizationController.php b/api/modules/oauth/controllers/AuthorizationController.php index 85c1616..d54d21d 100644 --- a/api/modules/oauth/controllers/AuthorizationController.php +++ b/api/modules/oauth/controllers/AuthorizationController.php @@ -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(); + } + } diff --git a/api/modules/oauth/models/OauthProcess.php b/api/modules/oauth/models/OauthProcess.php index fca30dd..761c49d 100644 --- a/api/modules/oauth/models/OauthProcess.php +++ b/api/modules/oauth/models/OauthProcess.php @@ -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.', diff --git a/api/tests/functional/oauth/RefreshTokenCest.php b/api/tests/functional/oauth/RefreshTokenCest.php index edc68bb..7b5997c 100644 --- a/api/tests/functional/oauth/RefreshTokenCest.php +++ b/api/tests/functional/oauth/RefreshTokenCest.php @@ -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); } diff --git a/api/tests/unit/components/OAuth2/Entities/AccessTokenEntityTest.php b/api/tests/unit/components/OAuth2/Entities/AccessTokenEntityTest.php index a43a664..83a8af7 100644 --- a/api/tests/unit/components/OAuth2/Entities/AccessTokenEntityTest.php +++ b/api/tests/unit/components/OAuth2/Entities/AccessTokenEntityTest.php @@ -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 { diff --git a/api/tests/unit/components/User/JwtIdentityTest.php b/api/tests/unit/components/User/JwtIdentityTest.php index ef8c082..cc91a25 100644 --- a/api/tests/unit/components/User/JwtIdentityTest.php +++ b/api/tests/unit/components/User/JwtIdentityTest.php @@ -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()); diff --git a/common/models/Account.php b/common/models/Account.php index af104a3..fc227b8 100644 --- a/common/models/Account.php +++ b/common/models/Account.php @@ -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']); } diff --git a/common/models/OauthClient.php b/common/models/OauthClient.php index 259bf05..cad84a0 100644 --- a/common/models/OauthClient.php +++ b/common/models/OauthClient.php @@ -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]); } diff --git a/common/models/OauthRefreshToken.php b/common/models/OauthRefreshToken.php deleted file mode 100644 index e65aa00..0000000 --- a/common/models/OauthRefreshToken.php +++ /dev/null @@ -1,50 +0,0 @@ - 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']); - } - -} diff --git a/common/models/OauthSession.php b/common/models/OauthSession.php index fb0e4d6..aaa5c7e 100644 --- a/common/models/OauthSession.php +++ b/common/models/OauthSession.php @@ -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()); diff --git a/common/tests/_support/FixtureHelper.php b/common/tests/_support/FixtureHelper.php index 31b4070..4c7f6a3 100644 --- a/common/tests/_support/FixtureHelper.php +++ b/common/tests/_support/FixtureHelper.php @@ -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, ]; diff --git a/common/tests/fixtures/OauthRefreshTokensFixture.php b/common/tests/fixtures/OauthRefreshTokensFixture.php deleted file mode 100644 index 85345d2..0000000 --- a/common/tests/fixtures/OauthRefreshTokensFixture.php +++ /dev/null @@ -1,19 +0,0 @@ -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'); From 016a1932632afedcb55b493b024437fd1acf3e6a Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Tue, 10 Dec 2019 01:38:09 +0300 Subject: [PATCH 22/28] Introduce revokation mechanism --- api/components/Tokens/Component.php | 2 +- api/components/User/Component.php | 11 ++++++++ api/components/User/JwtIdentity.php | 18 ++++++++++++ .../authserver/models/AuthenticationForm.php | 1 + .../unit/components/User/ComponentTest.php | 1 + .../unit/components/User/JwtIdentityTest.php | 12 ++++++++ common/models/Account.php | 28 +++++++++---------- common/models/OauthClient.php | 6 ++++ common/models/OauthSession.php | 12 ++++---- common/tests/fixtures/data/oauth-sessions.php | 20 +++++++++++++ ...914_181236_rework_oauth_related_tables.php | 12 ++++++++ 11 files changed, 103 insertions(+), 20 deletions(-) diff --git a/api/components/Tokens/Component.php b/api/components/Tokens/Component.php index 7294a74..c5025a2 100644 --- a/api/components/Tokens/Component.php +++ b/api/components/Tokens/Component.php @@ -36,7 +36,7 @@ class Component extends BaseComponent { public $privateKeyPass; /** - * @var string|\Defuse\Crypto\Key + * @var string */ public $encryptionKey; diff --git a/api/components/User/Component.php b/api/components/User/Component.php index bf00f9c..966601d 100644 --- a/api/components/User/Component.php +++ b/api/components/User/Component.php @@ -5,6 +5,8 @@ namespace api\components\User; use common\models\Account; use common\models\AccountSession; +use common\models\OauthClient; +use Webmozart\Assert\Assert; use yii\web\User as YiiUserComponent; /** @@ -78,6 +80,15 @@ class Component extends YiiUserComponent { } if (!($mode & self::KEEP_MINECRAFT_SESSIONS)) { + /** @var \common\models\OauthSession|null $minecraftSession */ + $minecraftSession = $account->getSessions() + ->andWhere(['client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER]) + ->one(); + if ($minecraftSession !== null) { + $minecraftSession->revoked_at = time(); + Assert::true($minecraftSession->save()); + } + foreach ($account->minecraftAccessKeys as $minecraftAccessKey) { $minecraftAccessKey->delete(); } diff --git a/api/components/User/JwtIdentity.php b/api/components/User/JwtIdentity.php index 34ee296..9fe214f 100644 --- a/api/components/User/JwtIdentity.php +++ b/api/components/User/JwtIdentity.php @@ -6,6 +6,8 @@ namespace api\components\User; use api\components\Tokens\TokenReader; use Carbon\Carbon; use common\models\Account; +use common\models\OauthClient; +use common\models\OauthSession; use Exception; use Lcobucci\JWT\Token; use Lcobucci\JWT\ValidationData; @@ -50,9 +52,25 @@ class JwtIdentity implements IdentityInterface { throw new UnauthorizedHttpException('Incorrect token'); } + $tokenReader = new TokenReader($token); + $accountId = $tokenReader->getAccountId(); + $iat = $token->getClaim('iat'); + if ($tokenReader->getMinecraftClientToken() !== null && self::isRevoked($accountId, OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER, $iat)) { + throw new UnauthorizedHttpException('Token has been revoked'); + } + + if ($tokenReader->getClientId() !== null && self::isRevoked($accountId, $tokenReader->getClientId(), $iat)) { + throw new UnauthorizedHttpException('Token has been revoked'); + } + return new self($token); } + private static function isRevoked(int $accountId, string $clientId, int $iat): bool { + $session = OauthSession::findOne(['account_id' => $accountId, 'client_id' => $clientId]); + return $session !== null && $session->revoked_at !== null && $session->revoked_at > $iat; + } + public function getToken(): Token { return $this->token; } diff --git a/api/modules/authserver/models/AuthenticationForm.php b/api/modules/authserver/models/AuthenticationForm.php index 1792a0a..8e37372 100644 --- a/api/modules/authserver/models/AuthenticationForm.php +++ b/api/modules/authserver/models/AuthenticationForm.php @@ -85,6 +85,7 @@ class AuthenticationForm extends ApiForm { $account = $loginForm->getAccount(); $token = Yii::$app->tokensFactory->createForMinecraftAccount($account, $this->clientToken); $dataModel = new AuthenticateData($account, (string)$token, $this->clientToken); + // TODO: issue session in the oauth_sessions Authserver::info("User with id = {$account->id}, username = '{$account->username}' and email = '{$account->email}' successfully logged in."); diff --git a/api/tests/unit/components/User/ComponentTest.php b/api/tests/unit/components/User/ComponentTest.php index 1a2fea5..04d8a53 100644 --- a/api/tests/unit/components/User/ComponentTest.php +++ b/api/tests/unit/components/User/ComponentTest.php @@ -88,6 +88,7 @@ class ComponentTest extends TestCase { $component->terminateSessions($account, Component::KEEP_SITE_SESSIONS); $this->assertEmpty($account->getMinecraftAccessKeys()->all()); $this->assertNotEmpty($account->getSessions()->all()); + // TODO: write test about invalidating new minecraft access tokens based on JWT // All sessions should be removed except the current one $component->terminateSessions($account, Component::KEEP_CURRENT_SESSION); diff --git a/api/tests/unit/components/User/JwtIdentityTest.php b/api/tests/unit/components/User/JwtIdentityTest.php index cc91a25..3428ccd 100644 --- a/api/tests/unit/components/User/JwtIdentityTest.php +++ b/api/tests/unit/components/User/JwtIdentityTest.php @@ -7,6 +7,8 @@ use api\components\User\JwtIdentity; use api\tests\unit\TestCase; use Carbon\Carbon; use common\tests\fixtures\AccountFixture; +use common\tests\fixtures\OauthClientFixture; +use common\tests\fixtures\OauthSessionFixture; use yii\web\UnauthorizedHttpException; class JwtIdentityTest extends TestCase { @@ -14,6 +16,8 @@ class JwtIdentityTest extends TestCase { public function _fixtures(): array { return [ 'accounts' => AccountFixture::class, + 'oauthClients' => OauthClientFixture::class, + 'oauthSessions' => OauthSessionFixture::class, ]; } @@ -46,6 +50,14 @@ class JwtIdentityTest extends TestCase { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTc3NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ._6hj6XUSmSLibgT9ZE1Pokf4oI9r-d6tEc1z2J-fBlr1710Qiso5yNcXqb3Z_xy7Qtemyq8jOlOZA8DvmkVBrg', 'Incorrect token', ]; + yield 'revoked by oauth client' => [ + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudF9pbmZvLG1pbmVjcmFmdF9zZXJ2ZXJfc2Vzc2lvbiIsImlhdCI6MTU2NDYxMDUwMCwic3ViIjoiZWx5fDEiLCJhdWQiOiJjbGllbnR8dGxhdW5jaGVyIn0.YzUzvnREEoQPu8CvU6WLdysUU0bC_xzigQPs2LK1su38uysSYgSbPzNOZYkQnvcmVLehHY-ON44x-oA8Os-9ZA', + 'Token has been revoked', + ]; + yield 'revoked by unauthorized minecraft launcher' => [ + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoibWluZWNyYWZ0X3NlcnZlcl9zZXNzaW9uIiwiZWx5LWNsaWVudC10b2tlbiI6IllBTVhneTBBcEI5Z2dUL1VYNjNJaTdKcGtNd2ZwTmxaaE8yVVVEeEZ3YTFmZ2g4dksyN0RtV25vN2xqbk1pWWJwQ1VuS09YVnR2V1YrVVg1dWRQVVFsK04xY3BBZlJBL2ErZW1BZz09IiwiaWF0IjoxNTY0NjEwNTAwLCJzdWIiOiJlbHl8MSJ9.mxFgf4M1QSG4_Zd3sGoJUx9L9_XbjHd4T8-CWIVzmSPp2_9OHjq-CIFEwSwlfoz3QGN7NV0TpC8-PfRvjd93eQ', + 'Token has been revoked', + ]; yield 'invalid signature' => [ 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ.yth31f2PyhUkYSfBlizzUXWIgOvxxk8gNP-js0z8g1OT5rig40FPTIkgsZRctAwAAlj6QoIWW7-hxLTcSb2vmw', 'Incorrect token', diff --git a/common/models/Account.php b/common/models/Account.php index fc227b8..9cf7ef0 100644 --- a/common/models/Account.php +++ b/common/models/Account.php @@ -14,33 +14,33 @@ use const common\LATEST_RULES_VERSION; /** * Fields: - * @property integer $id + * @property int $id * @property string $uuid * @property string $username * @property string $email * @property string $password_hash - * @property integer $password_hash_strategy + * @property int $password_hash_strategy * @property string $lang - * @property integer $status - * @property integer $rules_agreement_version + * @property int $status + * @property int $rules_agreement_version * @property string $registration_ip * @property string $otp_secret - * @property integer $is_otp_enabled - * @property integer $created_at - * @property integer $updated_at - * @property integer $password_changed_at + * @property int $is_otp_enabled + * @property int $created_at + * @property int $updated_at + * @property int $password_changed_at * * Getters-setters: * @property-write string $password plain user's password * @property-read string $profileLink link to the user's Ely.by profile * * Relations: - * @property EmailActivation[] $emailActivations - * @property OauthSession[] $oauthSessions - * @property OauthClient[] $oauthClients - * @property UsernameHistory[] $usernameHistory - * @property AccountSession[] $sessions - * @property MinecraftAccessKey[] $minecraftAccessKeys + * @property-read EmailActivation[] $emailActivations + * @property-read OauthSession[] $oauthSessions + * @property-read OauthClient[] $oauthClients + * @property-read UsernameHistory[] $usernameHistory + * @property-read AccountSession[] $sessions + * @property-read MinecraftAccessKey[] $minecraftAccessKeys * * Behaviors: * @mixin TimestampBehavior diff --git a/common/models/OauthClient.php b/common/models/OauthClient.php index cad84a0..260272f 100644 --- a/common/models/OauthClient.php +++ b/common/models/OauthClient.php @@ -31,6 +31,12 @@ class OauthClient extends ActiveRecord { public const TYPE_APPLICATION = 'application'; public const TYPE_MINECRAFT_SERVER = 'minecraft-server'; + public const TYPE_MINECRAFT_GAME_LAUNCHER = 'minecraft-game-launcher'; + + /** + * Abstract oauth_client, used to + */ + public const UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER = 'unauthorized_minecraft_game_launcher'; public static function tableName(): string { return 'oauth_clients'; diff --git a/common/models/OauthSession.php b/common/models/OauthSession.php index aaa5c7e..bca5167 100644 --- a/common/models/OauthSession.php +++ b/common/models/OauthSession.php @@ -10,11 +10,12 @@ use yii\db\ActiveRecord; /** * Fields: - * @property int $account_id - * @property string $client_id - * @property int $legacy_id - * @property array $scopes - * @property integer $created_at + * @property int $account_id + * @property string $client_id + * @property int|null $legacy_id + * @property array $scopes + * @property int $created_at + * @property int|null $revoked_at * * Relations: * @property-read OauthClient $client @@ -58,6 +59,7 @@ class OauthSession extends ActiveRecord { * @return array of refresh tokens (ids) */ public function getLegacyRefreshTokens(): array { + // TODO: it seems that this method isn't used anywhere if ($this->legacy_id === null) { return []; } diff --git a/common/tests/fixtures/data/oauth-sessions.php b/common/tests/fixtures/data/oauth-sessions.php index 5828c20..70023bc 100644 --- a/common/tests/fixtures/data/oauth-sessions.php +++ b/common/tests/fixtures/data/oauth-sessions.php @@ -6,6 +6,23 @@ return [ 'legacy_id' => 1, 'scopes' => null, 'created_at' => 1479944472, + 'revoked_at' => null, + ], + 'revoked-tlauncher' => [ + 'account_id' => 1, + 'client_id' => 'tlauncher', + 'legacy_id' => null, + 'scopes' => null, + 'created_at' => Carbon\Carbon::create(2019, 8, 1, 0, 0, 0, 'Europe/Minsk')->unix(), + 'revoked_at' => Carbon\Carbon::create(2019, 8, 1, 1, 2, 0, 'Europe/Minsk')->unix(), + ], + 'revoked-minecraft-game-launchers' => [ + 'account_id' => 1, + 'client_id' => common\models\OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER, + 'legacy_id' => null, + 'scopes' => null, + 'created_at' => Carbon\Carbon::create(2019, 8, 1, 0, 0, 0, 'Europe/Minsk')->unix(), + 'revoked_at' => Carbon\Carbon::create(2019, 8, 1, 1, 2, 0, 'Europe/Minsk')->unix(), ], 'banned-account-session' => [ 'account_id' => 10, @@ -13,6 +30,7 @@ return [ 'legacy_id' => 2, 'scopes' => null, 'created_at' => 1481421663, + 'revoked_at' => null, ], 'deleted-client-session' => [ 'account_id' => 1, @@ -20,6 +38,7 @@ return [ 'legacy_id' => 3, 'scopes' => null, 'created_at' => 1519510065, + 'revoked_at' => null, ], 'actual-deleted-client-session' => [ 'account_id' => 2, @@ -27,5 +46,6 @@ return [ 'legacy_id' => 4, 'scopes' => null, 'created_at' => 1519511568, + 'revoked_at' => null, ], ]; diff --git a/console/migrations/m190914_181236_rework_oauth_related_tables.php b/console/migrations/m190914_181236_rework_oauth_related_tables.php index 2cb68ac..bf26038 100644 --- a/console/migrations/m190914_181236_rework_oauth_related_tables.php +++ b/console/migrations/m190914_181236_rework_oauth_related_tables.php @@ -34,9 +34,21 @@ 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->addColumn('oauth_sessions', 'revoked_at', $this->integer(11)->unsigned() . ' AFTER `created_at`'); + + $this->insert('oauth_clients', [ + 'id' => 'unauthorized_minecraft_game_launcher', + 'secret' => 'there_is_no_secret', + 'type' => 'minecraft-game-launcher', + 'name' => 'Unauthorized Minecraft game launcher', + 'created_at' => time(), + ]); } public function safeDown() { + $this->delete('oauth_clients', ['id' => 'unauthorized_minecraft_game_launcher']); + + $this->dropColumn('oauth_sessions', 'revoked_at'); $this->dropColumn('oauth_sessions', 'scopes'); $this->dropForeignKey('FK_oauth_session_to_oauth_client', 'oauth_sessions'); $this->dropForeignKey('FK_oauth_session_to_account', 'oauth_sessions'); From d27070630cdaf225365e3eb493f7d2920c3086bd Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Tue, 10 Dec 2019 22:51:11 +0300 Subject: [PATCH 23/28] Fix revokation validation. Add additional tests cases --- api/components/User/Component.php | 2 +- api/components/User/JwtIdentity.php | 28 +++++++++++------- .../authserver/models/AuthenticationForm.php | 16 +++++++++- .../authserver/models/RefreshTokenForm.php | 16 ++++++++++ api/modules/oauth/models/OauthProcess.php | 8 +---- .../unit/components/User/ComponentTest.php | 7 ++++- .../authentication/RefreshTokenFormTest.php | 7 ++--- .../models/AuthenticationFormTest.php | 29 +++++++++---------- common/tests/fixtures/data/oauth-clients.php | 13 +++++++++ 9 files changed, 85 insertions(+), 41 deletions(-) diff --git a/api/components/User/Component.php b/api/components/User/Component.php index 966601d..618ff78 100644 --- a/api/components/User/Component.php +++ b/api/components/User/Component.php @@ -81,7 +81,7 @@ class Component extends YiiUserComponent { if (!($mode & self::KEEP_MINECRAFT_SESSIONS)) { /** @var \common\models\OauthSession|null $minecraftSession */ - $minecraftSession = $account->getSessions() + $minecraftSession = $account->getOauthSessions() ->andWhere(['client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER]) ->one(); if ($minecraftSession !== null) { diff --git a/api/components/User/JwtIdentity.php b/api/components/User/JwtIdentity.php index 9fe214f..7917d46 100644 --- a/api/components/User/JwtIdentity.php +++ b/api/components/User/JwtIdentity.php @@ -54,23 +54,24 @@ class JwtIdentity implements IdentityInterface { $tokenReader = new TokenReader($token); $accountId = $tokenReader->getAccountId(); - $iat = $token->getClaim('iat'); - if ($tokenReader->getMinecraftClientToken() !== null && self::isRevoked($accountId, OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER, $iat)) { - throw new UnauthorizedHttpException('Token has been revoked'); - } + if ($accountId !== null) { + $iat = $token->getClaim('iat'); + if ($tokenReader->getMinecraftClientToken() !== null + && self::isRevoked($accountId, OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER, $iat) + ) { + throw new UnauthorizedHttpException('Token has been revoked'); + } - if ($tokenReader->getClientId() !== null && self::isRevoked($accountId, $tokenReader->getClientId(), $iat)) { - throw new UnauthorizedHttpException('Token has been revoked'); + if ($tokenReader->getClientId() !== null + && self::isRevoked($accountId, $tokenReader->getClientId(), $iat) + ) { + throw new UnauthorizedHttpException('Token has been revoked'); + } } return new self($token); } - private static function isRevoked(int $accountId, string $clientId, int $iat): bool { - $session = OauthSession::findOne(['account_id' => $accountId, 'client_id' => $clientId]); - return $session !== null && $session->revoked_at !== null && $session->revoked_at > $iat; - } - public function getToken(): Token { return $this->token; } @@ -100,6 +101,11 @@ class JwtIdentity implements IdentityInterface { throw new NotSupportedException('This method used for cookie auth, except we using Bearer auth'); } + private static function isRevoked(int $accountId, string $clientId, int $iat): bool { + $session = OauthSession::findOne(['account_id' => $accountId, 'client_id' => $clientId]); + return $session !== null && $session->revoked_at !== null && $session->revoked_at > $iat; + } + // @codeCoverageIgnoreEnd private function getReader(): TokenReader { diff --git a/api/modules/authserver/models/AuthenticationForm.php b/api/modules/authserver/models/AuthenticationForm.php index 8e37372..6af8e2a 100644 --- a/api/modules/authserver/models/AuthenticationForm.php +++ b/api/modules/authserver/models/AuthenticationForm.php @@ -9,8 +9,12 @@ use api\modules\authserver\exceptions\ForbiddenOperationException; use api\modules\authserver\Module as Authserver; use api\modules\authserver\validators\ClientTokenValidator; use api\modules\authserver\validators\RequiredValidator; +use api\rbac\Permissions as P; use common\helpers\Error as E; use common\models\Account; +use common\models\OauthClient; +use common\models\OauthSession; +use Webmozart\Assert\Assert; use Yii; class AuthenticationForm extends ApiForm { @@ -85,7 +89,17 @@ class AuthenticationForm extends ApiForm { $account = $loginForm->getAccount(); $token = Yii::$app->tokensFactory->createForMinecraftAccount($account, $this->clientToken); $dataModel = new AuthenticateData($account, (string)$token, $this->clientToken); - // TODO: issue session in the oauth_sessions + /** @var OauthSession|null $minecraftOauthSession */ + $hasMinecraftOauthSession = $account->getOauthSessions() + ->andWhere(['client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER]) + ->exists(); + if ($hasMinecraftOauthSession === false) { + $minecraftOauthSession = new OauthSession(); + $minecraftOauthSession->account_id = $account->id; + $minecraftOauthSession->client_id = OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER; + $minecraftOauthSession->scopes = [P::MINECRAFT_SERVER_SESSION]; + Assert::true($minecraftOauthSession->save()); + } Authserver::info("User with id = {$account->id}, username = '{$account->username}' and email = '{$account->email}' successfully logged in."); diff --git a/api/modules/authserver/models/RefreshTokenForm.php b/api/modules/authserver/models/RefreshTokenForm.php index f9adff1..fea2410 100644 --- a/api/modules/authserver/models/RefreshTokenForm.php +++ b/api/modules/authserver/models/RefreshTokenForm.php @@ -10,6 +10,9 @@ use api\modules\authserver\validators\AccessTokenValidator; use api\modules\authserver\validators\RequiredValidator; use common\models\Account; use common\models\MinecraftAccessKey; +use common\models\OauthClient; +use common\models\OauthSession; +use Webmozart\Assert\Assert; use Yii; class RefreshTokenForm extends ApiForm { @@ -68,6 +71,19 @@ class RefreshTokenForm extends ApiForm { $token = Yii::$app->tokensFactory->createForMinecraftAccount($account, $this->clientToken); + // TODO: This behavior duplicates with the AuthenticationForm. Need to find a way to avoid duplication. + /** @var OauthSession|null $minecraftOauthSession */ + $hasMinecraftOauthSession = $account->getOauthSessions() + ->andWhere(['client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER]) + ->exists(); + if ($hasMinecraftOauthSession === false) { + $minecraftOauthSession = new OauthSession(); + $minecraftOauthSession->account_id = $account->id; + $minecraftOauthSession->client_id = OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER; + $minecraftOauthSession->scopes = [P::MINECRAFT_SERVER_SESSION]; + Assert::true($minecraftOauthSession->save()); + } + return new AuthenticateData($account, (string)$token, $this->clientToken); } diff --git a/api/modules/oauth/models/OauthProcess.php b/api/modules/oauth/models/OauthProcess.php index 761c49d..be0dfc3 100644 --- a/api/modules/oauth/models/OauthProcess.php +++ b/api/modules/oauth/models/OauthProcess.php @@ -20,7 +20,6 @@ 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', @@ -325,12 +324,7 @@ class OauthProcess { } private function createAcceptRequiredException(): OAuthServerException { - return new OAuthServerException( - 'Client must accept authentication request.', - 0, - 'accept_required', - 401 - ); + return new OAuthServerException('Client must accept authentication request.', 0, 'accept_required', 401); } private function getScopesList(AuthorizationRequest $request): array { diff --git a/api/tests/unit/components/User/ComponentTest.php b/api/tests/unit/components/User/ComponentTest.php index 04d8a53..4f5fae5 100644 --- a/api/tests/unit/components/User/ComponentTest.php +++ b/api/tests/unit/components/User/ComponentTest.php @@ -9,9 +9,12 @@ use api\components\User\LegacyOAuth2Identity; use api\tests\unit\TestCase; use common\models\Account; use common\models\AccountSession; +use common\models\OauthClient; use common\tests\fixtures\AccountFixture; use common\tests\fixtures\AccountSessionFixture; use common\tests\fixtures\MinecraftAccessKeyFixture; +use common\tests\fixtures\OauthClientFixture; +use common\tests\fixtures\OauthSessionFixture; use Lcobucci\JWT\Claim\Basic; use Lcobucci\JWT\Token; @@ -32,6 +35,8 @@ class ComponentTest extends TestCase { 'accounts' => AccountFixture::class, 'sessions' => AccountSessionFixture::class, 'minecraftSessions' => MinecraftAccessKeyFixture::class, + 'oauthClients' => OauthClientFixture::class, + 'oauthSessions' => OauthSessionFixture::class, ]; } @@ -88,7 +93,7 @@ class ComponentTest extends TestCase { $component->terminateSessions($account, Component::KEEP_SITE_SESSIONS); $this->assertEmpty($account->getMinecraftAccessKeys()->all()); $this->assertNotEmpty($account->getSessions()->all()); - // TODO: write test about invalidating new minecraft access tokens based on JWT + $this->assertEqualsWithDelta(time(), $account->getOauthSessions()->andWhere(['client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER])->one()->revoked_at, 5); // All sessions should be removed except the current one $component->terminateSessions($account, Component::KEEP_CURRENT_SESSION); diff --git a/api/tests/unit/models/authentication/RefreshTokenFormTest.php b/api/tests/unit/models/authentication/RefreshTokenFormTest.php index 4d26c96..4c04a05 100644 --- a/api/tests/unit/models/authentication/RefreshTokenFormTest.php +++ b/api/tests/unit/models/authentication/RefreshTokenFormTest.php @@ -5,14 +5,12 @@ namespace codeception\api\unit\models\authentication; use api\models\authentication\RefreshTokenForm; use api\tests\unit\TestCase; -use Codeception\Specify; use common\models\AccountSession; use common\tests\fixtures\AccountSessionFixture; use Yii; use yii\web\Request; class RefreshTokenFormTest extends TestCase { - use Specify; public function _fixtures(): array { return [ @@ -21,9 +19,8 @@ class RefreshTokenFormTest extends TestCase { } public function testRenew() { - /** @var Request|\Mockery\MockInterface $request */ - $request = mock(Request::class . '[getUserIP]')->makePartial(); - $request->shouldReceive('getUserIP')->andReturn('10.1.2.3'); + $request = $this->createPartialMock(Request::class, ['getUserIP']); + $request->method('getUserIP')->willReturn('10.1.2.3'); Yii::$app->set('request', $request); $model = new RefreshTokenForm(); diff --git a/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php b/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php index 01c352f..778665e 100644 --- a/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php +++ b/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php @@ -6,7 +6,10 @@ namespace codeception\api\unit\modules\authserver\models; use api\modules\authserver\exceptions\ForbiddenOperationException; use api\modules\authserver\models\AuthenticationForm; use api\tests\unit\TestCase; +use common\models\OauthClient; +use common\models\OauthSession; use common\tests\fixtures\AccountFixture; +use common\tests\fixtures\OauthClientFixture; use Ramsey\Uuid\Uuid; class AuthenticationFormTest extends TestCase { @@ -14,6 +17,7 @@ class AuthenticationFormTest extends TestCase { public function _fixtures(): array { return [ 'accounts' => AccountFixture::class, + 'oauthClients' => OauthClientFixture::class, ]; } @@ -28,14 +32,18 @@ class AuthenticationFormTest extends TestCase { $this->assertSame('df936908-b2e1-544d-96f8-2977ec213022', $result['selectedProfile']['id']); $this->assertSame('Admin', $result['selectedProfile']['name']); $this->assertFalse($result['selectedProfile']['legacy']); + $this->assertTrue(OauthSession::find()->andWhere([ + 'account_id' => 1, + 'client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER, + ])->exists()); } /** * @dataProvider getInvalidCredentialsCases */ - public function testAuthenticateByWrongNicknamePass(string $expectedFieldError, string $login, string $password) { + public function testAuthenticateByWrongNicknamePass(string $expectedExceptionMessage, string $login, string $password) { $this->expectException(ForbiddenOperationException::class); - $this->expectExceptionMessage("Invalid credentials. Invalid {$expectedFieldError} or password."); + $this->expectExceptionMessage($expectedExceptionMessage); $authForm = new AuthenticationForm(); $authForm->username = $login; @@ -45,19 +53,10 @@ class AuthenticationFormTest extends TestCase { } public function getInvalidCredentialsCases() { - yield ['nickname', 'wrong-username', 'wrong-password']; - yield ['email', 'wrong-email@ely.by', 'wrong-password']; - } - - public function testAuthenticateByValidCredentialsIntoBlockedAccount() { - $this->expectException(ForbiddenOperationException::class); - $this->expectExceptionMessage('This account has been suspended.'); - - $authForm = new AuthenticationForm(); - $authForm->username = 'Banned'; - $authForm->password = 'password_0'; - $authForm->clientToken = Uuid::uuid4()->toString(); - $authForm->authenticate(); + yield ['Invalid credentials. Invalid nickname or password.', 'wrong-username', 'wrong-password']; + yield ['Invalid credentials. Invalid email or password.', 'wrong-email@ely.by', 'wrong-password']; + yield ['This account has been suspended.', 'Banned', 'password_0']; + yield ['Account protected with two factor auth.', 'AccountWithEnabledOtp', 'password_0']; } } diff --git a/common/tests/fixtures/data/oauth-clients.php b/common/tests/fixtures/data/oauth-clients.php index 02ec7e3..536c80d 100644 --- a/common/tests/fixtures/data/oauth-clients.php +++ b/common/tests/fixtures/data/oauth-clients.php @@ -14,6 +14,19 @@ return [ 'is_deleted' => 0, 'created_at' => 1455309271, ], + 'unauthorizedMinecraftGameLauncher' => [ + 'id' => 'unauthorized_minecraft_game_launcher', + 'secret' => 'there_is_no_secret', + 'type' => 'minecraft-game-launcher', + 'name' => 'Unauthorized Minecraft game launcher', + 'description' => '', + 'redirect_uri' => null, + 'website_url' => null, + 'minecraft_server_ip' => null, + 'is_trusted' => false, + 'is_deleted' => false, + 'created_at' => 1576003878, + ], 'tlauncher' => [ 'id' => 'tlauncher', 'secret' => 'HsX-xXzdGiz3mcsqeEvrKHF47sqiaX94', From 3c9050340ea93012d755edc72ed021cc120afd90 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Wed, 11 Dec 2019 01:29:57 +0300 Subject: [PATCH 24/28] Add console command to migrate all oauth sessions scopes data from redis to db --- .../controllers/ManualMigrateController.php | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 console/controllers/ManualMigrateController.php diff --git a/console/controllers/ManualMigrateController.php b/console/controllers/ManualMigrateController.php new file mode 100644 index 0000000..00960ed --- /dev/null +++ b/console/controllers/ManualMigrateController.php @@ -0,0 +1,51 @@ +redis->scan($cursor, 'MATCH', 'oauth:sessions:*:scopes', 'COUNT', 500); + $cursor = (int)$response[0]; + $keys = $response[1]; + if (!empty($keys)) { + $sessionsIds = array_map(function(string $key): int { + return (int)explode(':', $key)[2]; + }, $keys); + /** @var OauthSession[] $sessions */ + $sessions = OauthSession::find()->andWhere(['legacy_id' => $sessionsIds]); + foreach ($sessions as $session) { + if (!empty($session->scopes)) { + $session->scopes = Yii::$app->redis->smembers("oauth:sessions:{$session->legacy_id}:scopes"); + Assert::true($session->save()); + } + } + + if ($removeKeys) { + Yii::$app->redis->del(...$keys); + } + } + + $totalCount += count($keys); + Console::output("Processed {$totalCount} keys."); + + if ($cursor === 0) { + break; + } + } + + return ExitCode::OK; + } + +} From cda4be04a163eeea29da26df526e1037552120c0 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Wed, 11 Dec 2019 13:23:56 +0300 Subject: [PATCH 25/28] Fix condition when to store scopes from redis --- console/controllers/ManualMigrateController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/console/controllers/ManualMigrateController.php b/console/controllers/ManualMigrateController.php index 00960ed..6dcaf6e 100644 --- a/console/controllers/ManualMigrateController.php +++ b/console/controllers/ManualMigrateController.php @@ -26,7 +26,7 @@ class ManualMigrateController extends Controller { /** @var OauthSession[] $sessions */ $sessions = OauthSession::find()->andWhere(['legacy_id' => $sessionsIds]); foreach ($sessions as $session) { - if (!empty($session->scopes)) { + if (empty($session->scopes)) { $session->scopes = Yii::$app->redis->smembers("oauth:sessions:{$session->legacy_id}:scopes"); Assert::true($session->save()); } From 2caf0558de359b7dcb4607c4196a9bcdd277025c Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Wed, 11 Dec 2019 14:16:11 +0300 Subject: [PATCH 26/28] Use paragonie's Base64UrlSafe encoding library --- api/components/Tokens/Component.php | 14 +++----------- api/tests/functional/authserver/RefreshCest.php | 2 +- api/tests/unit/components/User/JwtIdentityTest.php | 2 +- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/api/components/Tokens/Component.php b/api/components/Tokens/Component.php index c5025a2..8d07e3f 100644 --- a/api/components/Tokens/Component.php +++ b/api/components/Tokens/Component.php @@ -8,6 +8,7 @@ use Exception; use Lcobucci\JWT\Builder; use Lcobucci\JWT\Parser; use Lcobucci\JWT\Token; +use ParagonIE\ConstantTime\Base64UrlSafe; use Webmozart\Assert\Assert; use yii\base\Component as BaseComponent; @@ -96,15 +97,14 @@ class Component extends BaseComponent { public function encryptValue(string $rawValue): string { /** @noinspection PhpUnhandledExceptionInspection */ $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); - $cipher = $this->base64UrlEncode($nonce . sodium_crypto_secretbox($rawValue, $nonce, $this->encryptionKey)); + $cipher = Base64UrlSafe::encodeUnpadded($nonce . sodium_crypto_secretbox($rawValue, $nonce, $this->encryptionKey)); sodium_memzero($rawValue); return $cipher; } public function decryptValue(string $encryptedValue): string { - $decoded = $this->base64UrlDecode($encryptedValue); - Assert::true($decoded !== false, 'passed value has an invalid base64 encoding'); + $decoded = Base64UrlSafe::decode($encryptedValue); Assert::true(mb_strlen($decoded, '8bit') >= (SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES)); $nonce = mb_substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit'); $cipherText = mb_substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, '8bit'); @@ -139,12 +139,4 @@ class Component extends BaseComponent { return $value; } - private function base64UrlEncode(string $rawValue): string { - return rtrim(strtr(base64_encode($rawValue), '+/', '-_'), '='); - } - - private function base64UrlDecode(string $encodedValue): string { - return base64_decode(str_pad(strtr($encodedValue, '-_', '+/'), strlen($encodedValue) % 4, '=', STR_PAD_RIGHT)); - } - } diff --git a/api/tests/functional/authserver/RefreshCest.php b/api/tests/functional/authserver/RefreshCest.php index b93525f..2f65350 100644 --- a/api/tests/functional/authserver/RefreshCest.php +++ b/api/tests/functional/authserver/RefreshCest.php @@ -54,7 +54,7 @@ class RefreshCest { } /** - * @example {"accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpYXQiOjE1NzU1NjE1MjgsImV4cCI6MTU3NTU2MTUyOCwiZWx5LXNjb3BlcyI6Im1pbmVjcmFmdF9zZXJ2ZXJfc2Vzc2lvbiIsImVseS1jbGllbnQtdG9rZW4iOiJZQU1YZ3kwQXBCOWdnVC9VWDYzSWk3SnBrTXdmcE5sWmhPMlVVRHhGd2ExZmdoOHZLMjdEbVdubzdsam5NaVlicENVbktPWFZ0dldWK1VYNXVkUFVRbCtOMWNwQWZSQS9hK2VtQWc9PSIsInN1YiI6ImVseXwxIn0.Yt3k9NpTthBVrrmcO6npd8n3zksolC2RI1m-NH2-_YEiaaCGC2vW8iszi3WB-g6f6Q64OYuQXxxXMl516PLTfA", "clientToken": "4f368b58-9097-4e56-80b1-f421ae4b53cf"} + * @example {"accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpYXQiOjE1NzU1NjE1MjgsImV4cCI6MTU3NTU2MTUyOCwiZWx5LXNjb3BlcyI6Im1pbmVjcmFmdF9zZXJ2ZXJfc2Vzc2lvbiIsImVseS1jbGllbnQtdG9rZW4iOiIydnByWnRVdk40VTVtSnZzc0ozaXNpekdVWFhQYnFsV1FsQjVVRWVfUV81bkxKYzlsbUJ3VU1hQWJ1MjBtZC1FNzNtengxNWFsZmRJSU1OMTV5YUpBalZOM29vQW9IRDctOWdOcmciLCJzdWIiOiJlbHl8MSJ9.vwjXzy0VtjJlP6B4RxqoE69yRSBsluZ29VELe4vDi8GCy487eC5cIf9hz9oxp5YcdE7uEJZeqX2yi3nk_0nCaA", "clientToken": "4f368b58-9097-4e56-80b1-f421ae4b53cf"} * @example {"accessToken": "6042634a-a1e2-4aed-866c-c661fe4e63e2", "clientToken": "47fb164a-2332-42c1-8bad-549e67bb210c"} */ public function refreshExpiredToken(AuthserverSteps $I, Example $example) { diff --git a/api/tests/unit/components/User/JwtIdentityTest.php b/api/tests/unit/components/User/JwtIdentityTest.php index 3428ccd..2488f69 100644 --- a/api/tests/unit/components/User/JwtIdentityTest.php +++ b/api/tests/unit/components/User/JwtIdentityTest.php @@ -55,7 +55,7 @@ class JwtIdentityTest extends TestCase { 'Token has been revoked', ]; yield 'revoked by unauthorized minecraft launcher' => [ - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoibWluZWNyYWZ0X3NlcnZlcl9zZXNzaW9uIiwiZWx5LWNsaWVudC10b2tlbiI6IllBTVhneTBBcEI5Z2dUL1VYNjNJaTdKcGtNd2ZwTmxaaE8yVVVEeEZ3YTFmZ2g4dksyN0RtV25vN2xqbk1pWWJwQ1VuS09YVnR2V1YrVVg1dWRQVVFsK04xY3BBZlJBL2ErZW1BZz09IiwiaWF0IjoxNTY0NjEwNTAwLCJzdWIiOiJlbHl8MSJ9.mxFgf4M1QSG4_Zd3sGoJUx9L9_XbjHd4T8-CWIVzmSPp2_9OHjq-CIFEwSwlfoz3QGN7NV0TpC8-PfRvjd93eQ', + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoibWluZWNyYWZ0X3NlcnZlcl9zZXNzaW9uIiwiZWx5LWNsaWVudC10b2tlbiI6IllBTVhneTBBcEI5Z2dUX1VYNjNJaTdKcGtNd2ZwTmxaaE8yVVVEeEZ3YTFmZ2g4dksyN0RtV25vN2xqbk1pWWJwQ1VuS09YVnR2V1YtVVg1dWRQVVFsLU4xY3BBZlJBX2EtZW1BZyIsImlhdCI6MTU2NDYxMDUwMCwic3ViIjoiZWx5fDEifQ.LtE9cQJ4z5dGVkDZl50M2HZH6kOYHgGz2RIycS_lzU9YLhosQ3ux7i2KI7qGI7BNuxO5zJ1OkxF2r9Qc240EpA', 'Token has been revoked', ]; yield 'invalid signature' => [ From 9da58beccf0347d6b620dc66956325e2180923ff Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Wed, 11 Dec 2019 14:24:31 +0300 Subject: [PATCH 27/28] Add deprecation notices --- api/components/Tokens/Component.php | 4 ++++ common/models/MinecraftAccessKey.php | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/api/components/Tokens/Component.php b/api/components/Tokens/Component.php index 8d07e3f..4342c89 100644 --- a/api/components/Tokens/Component.php +++ b/api/components/Tokens/Component.php @@ -18,6 +18,10 @@ class Component extends BaseComponent { /** * @var string + * @deprecated In earlier versions of the application, JWT were signed by a synchronous encryption algorithm. + * Now asynchronous encryption is used instead, and this logic is saved for a transitional period. + * I think it can be safely removed, but I'll not do it yet, because at the time of writing the comment + * there were enough changes in the code already. */ public $hmacKey; diff --git a/common/models/MinecraftAccessKey.php b/common/models/MinecraftAccessKey.php index 8703f60..d1a18fe 100644 --- a/common/models/MinecraftAccessKey.php +++ b/common/models/MinecraftAccessKey.php @@ -25,6 +25,10 @@ use yii\db\ActiveRecord; * Behaviors: * @mixin TimestampBehavior * @mixin PrimaryKeyValueBehavior + * + * @deprecated This table is no longer used to store authorization information in Minecraft. + * In time it will be empty (see the cleanup console command) and when it does, this model, + * the table in the database and everything related to the old logic can be removed. */ class MinecraftAccessKey extends ActiveRecord { From f06354638eae979db8ed72ce0a354a715c148546 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Wed, 11 Dec 2019 14:41:37 +0300 Subject: [PATCH 28/28] Disallow to perform oauth2 authentication for applications that have no corresponding type --- api/components/OAuth2/Repositories/ClientRepository.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/api/components/OAuth2/Repositories/ClientRepository.php b/api/components/OAuth2/Repositories/ClientRepository.php index abc0d43..eeb4bc6 100644 --- a/api/components/OAuth2/Repositories/ClientRepository.php +++ b/api/components/OAuth2/Repositories/ClientRepository.php @@ -37,7 +37,12 @@ class ClientRepository implements ClientRepositoryInterface { } private function findModel(string $id): ?OauthClient { - return OauthClient::findOne(['id' => $id]); + $client = OauthClient::findOne(['id' => $id]); + if ($client === null || $client->type !== OauthClient::TYPE_APPLICATION) { + return null; + } + + return $client; } }