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');