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 0fa3e4f..dd962a8 100644 --- a/api/components/OAuth2/Component.php +++ b/api/components/OAuth2/Component.php @@ -1,15 +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 - - $authServer->addGrantType(new Grants\AuthCodeGrant()); - $authServer->addGrantType(new Grants\RefreshTokenGrant()); - $authServer->addGrantType(new Grants\ClientCredentialsGrant()); - - $this->_authServer = $authServer; + $this->_authServer = $this->createAuthServer(); } return $this->_authServer; } - public function getAccessTokenStorage(): AccessTokenInterface { - return $this->getAuthServer()->getAccessTokenStorage(); - } + 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(); - public function getRefreshTokenStorage(): RefreshTokenInterface { - return $this->getAuthServer()->getRefreshTokenStorage(); - } + $accessTokenTTL = CarbonInterval::create(-1); // Set negative value to make tokens non expiring - public function getSessionStorage(): SessionInterface { - return $this->getAuthServer()->getSessionStorage(); + $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/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/Entities/AccessTokenEntity.php b/api/components/OAuth2/Entities/AccessTokenEntity.php index c489281..04c34e7 100644 --- a/api/components/OAuth2/Entities/AccessTokenEntity.php +++ b/api/components/OAuth2/Entities/AccessTokenEntity.php @@ -1,44 +1,24 @@ sessionId; + public function __toString(): string { + return (string)Yii::$app->tokensFactory->createForOAuthClient($this); } - public function setSessionId($sessionId) { - $this->sessionId = $sessionId; - } - - /** - * @inheritdoc - * @return static - */ - public function setSession(OriginalSessionEntity $session) { - parent::setSession($session); - $this->sessionId = $session->getId(); - - return $this; - } - - public function getSession(): ?OriginalSessionEntity { - 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 setPrivateKey(CryptKeyInterface $privateKey): void { + // We use a general-purpose component to build JWT tokens, so there is no need to keep the key } } diff --git a/api/components/OAuth2/Entities/AuthCodeEntity.php b/api/components/OAuth2/Entities/AuthCodeEntity.php index 28bfc2b..1db3362 100644 --- a/api/components/OAuth2/Entities/AuthCodeEntity.php +++ b/api/components/OAuth2/Entities/AuthCodeEntity.php @@ -1,29 +1,16 @@ 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; - } +class AuthCodeEntity implements AuthCodeEntityInterface { + use EntityTrait; + use AuthCodeTrait; + use TokenEntityTrait; } diff --git a/api/components/OAuth2/Entities/ClientEntity.php b/api/components/OAuth2/Entities/ClientEntity.php index e88f424..36374ad 100644 --- a/api/components/OAuth2/Entities/ClientEntity.php +++ b/api/components/OAuth2/Entities/ClientEntity.php @@ -1,28 +1,30 @@ id = $id; - } - - public function setName(string $name) { + public function __construct(string $id, string $name, $redirectUri, bool $isTrusted) { + $this->identifier = $id; $this->name = $name; - } - - public function setSecret(string $secret) { - $this->secret = $secret; - } - - public function setRedirectUri($redirectUri) { $this->redirectUri = $redirectUri; + $this->isTrusted = $isTrusted; } - public function setIsTrusted(bool $isTrusted) { - $this->isTrusted = $isTrusted; + public function isConfidential(): bool { + return true; } public function isTrusted(): bool { diff --git a/api/components/OAuth2/Entities/RefreshTokenEntity.php b/api/components/OAuth2/Entities/RefreshTokenEntity.php deleted file mode 100644 index eec5987..0000000 --- a/api/components/OAuth2/Entities/RefreshTokenEntity.php +++ /dev/null @@ -1,45 +0,0 @@ -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; - } - -} 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/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 new file mode 100644 index 0000000..d8a2bca --- /dev/null +++ b/api/components/OAuth2/Entities/UserEntity.php @@ -0,0 +1,16 @@ +identifier = $id; + } + +} diff --git a/api/components/OAuth2/Events/RequestedRefreshToken.php b/api/components/OAuth2/Events/RequestedRefreshToken.php new file mode 100644 index 0000000..e6ddbe5 --- /dev/null +++ b/api/components/OAuth2/Events/RequestedRefreshToken.php @@ -0,0 +1,10 @@ +redirectUri = $redirectUri; - } - -} diff --git a/api/components/OAuth2/Grants/AuthCodeGrant.php b/api/components/OAuth2/Grants/AuthCodeGrant.php index 069dfa2..52fd1a4 100644 --- a/api/components/OAuth2/Grants/AuthCodeGrant.php +++ b/api/components/OAuth2/Grants/AuthCodeGrant.php @@ -1,239 +1,43 @@ authTokenTTL = $authTokenTTL; - } - - public function setRequireClientSecret(bool $required): void { - $this->requireClientSecret = $required; - } - - public function shouldRequireClientSecret(): bool { - return $this->requireClientSecret; - } +class AuthCodeGrant extends BaseAuthCodeGrant { + use CryptTrait; /** - * Check authorize parameters + * @param DateInterval $accessTokenTTL + * @param ClientEntityInterface $client + * @param string|null $userIdentifier + * @param \League\OAuth2\Server\Entities\ScopeEntityInterface[] $scopes * - * @return AuthorizeParams Authorize request parameters - * @throws Exception\OAuthException - * - * @throws + * @return AccessTokenEntityInterface + * @throws \League\OAuth2\Server\Exception\OAuthServerException + * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException */ - 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 issueAccessToken( + DateInterval $accessTokenTTL, + ClientEntityInterface $client, + $userIdentifier, + array $scopes = [] + ): AccessTokenEntityInterface { + foreach ($scopes as $i => $scope) { + if ($scope->getIdentifier() === PublicScopeRepository::OFFLINE_ACCESS) { + unset($scopes[$i]); + $this->getEmitter()->emit(new RequestedRefreshToken()); + } } - $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 parent::issueAccessToken($accessTokenTTL, $client, $userIdentifier, $scopes); } } 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 index 06db2d5..fa72668 100644 --- a/api/components/OAuth2/Grants/ClientCredentialsGrant.php +++ b/api/components/OAuth2/Grants/ClientCredentialsGrant.php @@ -1,86 +1,12 @@ 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); - } +class ClientCredentialsGrant extends BaseClientCredentialsGrant { + use CryptTrait; } diff --git a/api/components/OAuth2/Grants/RefreshTokenGrant.php b/api/components/OAuth2/Grants/RefreshTokenGrant.php index 71b265b..675d388 100644 --- a/api/components/OAuth2/Grants/RefreshTokenGrant.php +++ b/api/components/OAuth2/Grants/RefreshTokenGrant.php @@ -1,187 +1,123 @@ getRequestParameter('refresh_token', $request); + if ($refreshToken !== null && mb_strlen($refreshToken) === 40) { + return $this->validateLegacyRefreshToken($refreshToken); + } - protected $refreshTokenTTL = 604800; - - protected $refreshTokenRotate = false; - - protected $requireClientSecret = true; - - public function setRefreshTokenTTL($refreshTokenTTL): void { - $this->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; + return $this->validateAccessToken($refreshToken); } /** - * 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); + protected function issueRefreshToken(AccessTokenEntityInterface $accessToken): ?RefreshTokenEntityInterface { + return null; } /** - * 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 + * @param string $refreshToken + * @return array + * @throws OAuthServerException */ - 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'); + 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'); } - $clientSecret = $this->server->getRequest()->request->get( - 'client_secret', - $this->server->getRequest()->getPassword() - ); - if ($clientSecret === null && $this->shouldRequireClientSecret()) { - throw new Exception\InvalidRequestException('client_secret'); + 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); } - // 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(); + /** @var OauthSession|null $relatedSession */ + $relatedSession = OauthSession::findOne(['legacy_id' => $sessionId]); + if ($relatedSession === null) { + throw OAuthServerException::invalidRefreshToken('Token has been revoked'); } - $oldRefreshTokenParam = $this->server->getRequest()->request->get('refresh_token'); - if ($oldRefreshTokenParam === null) { - throw new Exception\InvalidRequestException('refresh_token'); + 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, + ]; + } + + /** + * @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); } - // Validate refresh token - $oldRefreshToken = $this->server->getRefreshTokenStorage()->get($oldRefreshTokenParam); - if (($oldRefreshToken instanceof BaseRefreshTokenEntity) === false) { - throw new Exception\InvalidRefreshException(); + if (!Yii::$app->tokens->verify($token)) { + throw OAuthServerException::invalidRefreshToken('Cannot decrypt the refresh token'); } - // Ensure the old refresh token hasn't expired - if ($oldRefreshToken->isExpired()) { - throw new Exception\InvalidRefreshException(); + if (!$token->validate(new ValidationData(Carbon::now()->getTimestamp()))) { + throw OAuthServerException::invalidRefreshToken('Token has expired'); } - /** @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); - } + $reader = new TokenReader($token); - $session = $oldRefreshToken->getSession(); - } - - if ($session === null) { - throw new Exception\InvalidRefreshException(); - } - - $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(); + 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/Keys/EmptyKey.php b/api/components/OAuth2/Keys/EmptyKey.php new file mode 100644 index 0000000..bf2f8f8 --- /dev/null +++ b/api/components/OAuth2/Keys/EmptyKey.php @@ -0,0 +1,18 @@ +setClient($clientEntity); + array_map([$accessToken, 'addScope'], $scopes); + if ($userIdentifier !== null) { + $accessToken->setUserIdentifier($userIdentifier); + } + + return $accessToken; + } + + public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity): void { + // We don't store access tokens, so there's no need to do anything here + } + + public function revokeAccessToken($tokenId): void { + // We don't store access tokens, so there's no need to do anything here + } + + public function isAccessTokenRevoked($tokenId): bool { + return false; + } + +} diff --git a/api/components/OAuth2/Repositories/AuthCodeRepository.php b/api/components/OAuth2/Repositories/AuthCodeRepository.php new file mode 100644 index 0000000..4a71f9e --- /dev/null +++ b/api/components/OAuth2/Repositories/AuthCodeRepository.php @@ -0,0 +1,26 @@ +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 ($client->type !== OauthClient::TYPE_APPLICATION) { + return false; + } + + if ($clientSecret !== null && $clientSecret !== $client->secret) { + return false; + } + + return true; + } + + private function findModel(string $id): ?OauthClient { + $client = OauthClient::findOne(['id' => $id]); + if ($client === null || $client->type !== OauthClient::TYPE_APPLICATION) { + return null; + } + + return $client; + } + +} 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 @@ + P::OBTAIN_EXTENDED_ACCOUNT_INFO, + ]; + + 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 $client, + $userIdentifier = null + ): array { + /** @var ClientEntity $client */ + Assert::isInstanceOf($client, ClientEntity::class); + + if (empty($scopes)) { + return $scopes; + } + + // Right now we have no available scopes for the client_credentials grant + if (!$client->isTrusted()) { + throw OAuthServerException::invalidScope($scopes[0]->getIdentifier()); + } + + return $scopes; + } + + private function convertToInternalPermission(string $publicScope): string { + return self::PUBLIC_SCOPES_TO_INTERNAL_PERMISSIONS[$publicScope] ?? $publicScope; + } + +} diff --git a/api/components/OAuth2/Repositories/PublicScopeRepository.php b/api/components/OAuth2/Repositories/PublicScopeRepository.php new file mode 100644 index 0000000..16991fb --- /dev/null +++ b/api/components/OAuth2/Repositories/PublicScopeRepository.php @@ -0,0 +1,55 @@ + 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/components/OAuth2/Repositories/RefreshTokenRepository.php b/api/components/OAuth2/Repositories/RefreshTokenRepository.php new file mode 100644 index 0000000..199e342 --- /dev/null +++ b/api/components/OAuth2/Repositories/RefreshTokenRepository.php @@ -0,0 +1,27 @@ +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/OAuth2/Storage/AuthCodeStorage.php b/api/components/OAuth2/Storage/AuthCodeStorage.php deleted file mode 100644 index 681ec57..0000000 --- a/api/components/OAuth2/Storage/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/Storage/ClientStorage.php b/api/components/OAuth2/Storage/ClientStorage.php deleted file mode 100644 index fa1aae4..0000000 --- a/api/components/OAuth2/Storage/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/Storage/RefreshTokenStorage.php b/api/components/OAuth2/Storage/RefreshTokenStorage.php deleted file mode 100644 index 6764b2d..0000000 --- a/api/components/OAuth2/Storage/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/components/OAuth2/Storage/ScopeStorage.php b/api/components/OAuth2/Storage/ScopeStorage.php deleted file mode 100644 index 2ff847a..0000000 --- a/api/components/OAuth2/Storage/ScopeStorage.php +++ /dev/null @@ -1,93 +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/Storage/SessionStorage.php b/api/components/OAuth2/Storage/SessionStorage.php deleted file mode 100644 index 02ac0ba..0000000 --- a/api/components/OAuth2/Storage/SessionStorage.php +++ /dev/null @@ -1,108 +0,0 @@ -getSessionModel($sessionId); - if ($session === null) { - return null; - } - - return $this->hydrate($session); - } - - 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 $entity) { - $session = $this->getSessionModel($entity->getId()); - if ($session === null) { - return []; - } - - $result = []; - foreach ($session->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 $sessionEntity, ScopeEntity $scopeEntity): void { - $session = $this->getSessionModel($sessionEntity->getId()); - if ($session === null) { - throw new ThisShouldNotHappenException('Cannot find oauth session'); - } - - $session->getScopes()->add($scopeEntity->getId()); - } - - private function getSessionModel(string $sessionId): ?OauthSession { - return OauthSession::findOne(['id' => $sessionId]); - } - - private function hydrate(OauthSession $sessionModel): SessionEntity { - $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/Utils/Scopes.php b/api/components/OAuth2/Utils/Scopes.php deleted file mode 100644 index b2ef88c..0000000 --- a/api/components/OAuth2/Utils/Scopes.php +++ /dev/null @@ -1,28 +0,0 @@ -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 { $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, $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 +98,28 @@ class Component extends BaseComponent { } } + public function encryptValue(string $rawValue): string { + /** @noinspection PhpUnhandledExceptionInspection */ + $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $cipher = Base64UrlSafe::encodeUnpadded($nonce . sodium_crypto_secretbox($rawValue, $nonce, $this->encryptionKey)); + sodium_memzero($rawValue); + + return $cipher; + } + + public function decryptValue(string $encryptedValue): string { + $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'); + + $rawValue = sodium_crypto_secretbox_open($cipherText, $nonce, $this->encryptionKey); + Assert::true($rawValue !== false); + sodium_memzero($cipherText); + + return $rawValue; + } + private function getAlgorithmManager(): AlgorithmsManager { if ($this->algorithmManager === null) { $this->algorithmManager = new AlgorithmsManager([ @@ -100,4 +135,12 @@ class Component extends BaseComponent { return $this->algorithmManager; } + private function prepareValue($value) { + if ($value instanceof EncryptedValue) { + return $this->encryptValue($value->getValue()); + } + + 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/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 00fee41..0b7837c 100644 --- a/api/components/Tokens/TokensFactory.php +++ b/api/components/Tokens/TokensFactory.php @@ -3,20 +3,28 @@ 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; +use DateTime; 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::SUB_ACCOUNT_PREFIX . $account->id, + '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 @@ -29,4 +37,52 @@ class TokensFactory { return Yii::$app->tokens->create($payloads); } + public function createForOAuthClient(AccessTokenEntityInterface $accessToken): Token { + $payloads = [ + 'aud' => $this->buildAud($accessToken->getClient()->getIdentifier()), + '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()); + } + + return Yii::$app->tokens->create($payloads); + } + + public function createForMinecraftAccount(Account $account, string $clientToken): Token { + return Yii::$app->tokens->create([ + '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(), + ]); + } + + /** + * @param ScopeEntityInterface[]|string[] $scopes + * + * @return string + */ + private function prepareScopes(array $scopes): string { + return implode(',', array_map(function($scope): string { // TODO: replace to the space if it's possible + if ($scope instanceof ScopeEntityInterface) { + return $scope->getIdentifier(); + } + + return $scope; + }, $scopes)); + } + + private function buildSub(int $accountId): string { + return self::SUB_ACCOUNT_PREFIX . $accountId; + } + + private function buildAud(string $clientId): string { + return self::AUD_CLIENT_PREFIX . $clientId; + } + } diff --git a/api/components/User/Component.php b/api/components/User/Component.php index bf00f9c..618ff78 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->getOauthSessions() + ->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/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/JwtIdentity.php b/api/components/User/JwtIdentity.php index e327e32..7917d46 100644 --- a/api/components/User/JwtIdentity.php +++ b/api/components/User/JwtIdentity.php @@ -3,13 +3,14 @@ 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 common\models\OauthClient; +use common\models\OauthSession; 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 +22,11 @@ class JwtIdentity implements IdentityInterface { */ private $token; + /** + * @var TokenReader|null + */ + private $reader; + private function __construct(Token $token) { $this->token = $token; } @@ -46,9 +52,21 @@ 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'); + $tokenReader = new TokenReader($token); + $accountId = $tokenReader->getAccountId(); + 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'); + } } return new self($token); @@ -59,24 +77,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 { @@ -96,6 +101,19 @@ 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 { + if ($this->reader === null) { + $this->reader = new TokenReader($this->token); + } + + return $this->reader; + } + } diff --git a/api/components/User/LegacyOAuth2Identity.php b/api/components/User/LegacyOAuth2Identity.php new file mode 100644 index 0000000..44b5023 --- /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 1b8b69b..0000000 --- a/api/components/User/OAuth2Identity.php +++ /dev/null @@ -1,82 +0,0 @@ -_accessToken = $accessToken; - } - - /** - * @inheritdoc - * @throws UnauthorizedHttpException - * @return IdentityInterface - */ - public static function findIdentityByAccessToken($token, $type = null): IdentityInterface { - /** @var AccessTokenEntity|null $model */ - $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 { - $session = $this->getSession(); - if ($session === null) { - return null; - } - - 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/config/config-test.php b/api/config/config-test.php index 8f0952d..ae91b69 100644 --- a/api/config/config-test.php +++ b/api/config/config-test.php @@ -6,6 +6,7 @@ return [ 'privateKeyPath' => codecept_data_dir('certs/private.pem'), 'privateKeyPass' => null, 'publicKeyPath' => codecept_data_dir('certs/public.pem'), + 'encryptionKey' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', ], 'reCaptcha' => [ 'public' => 'public-key', diff --git a/api/config/config.php b/api/config/config.php index b51df70..0b3dda6 100644 --- a/api/config/config.php +++ b/api/config/config.php @@ -7,16 +7,31 @@ 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, ], + 'oauth' => [ + 'class' => api\components\OAuth2\Component::class, + ], 'tokens' => [ 'class' => api\components\Tokens\Component::class, 'hmacKey' => getenv('JWT_USER_SECRET'), '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, @@ -83,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/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/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..6af8e2a 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 +85,25 @@ 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); + /** @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."); 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..fea2410 100644 --- a/api/modules/authserver/models/RefreshTokenForm.php +++ b/api/modules/authserver/models/RefreshTokenForm.php @@ -1,48 +1,90 @@ 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(); + $account = null; + if (mb_strlen($this->accessToken) === 36) { + /** @var MinecraftAccessKey $token */ + $token = MinecraftAccessKey::findOne([ + 'access_token' => $this->accessToken, + 'client_token' => $this->clientToken, + ]); + if ($token !== null) { + $account = $token->account; + } + } else { + $token = Yii::$app->tokens->parse($this->accessToken); + $tokenReader = new TokenReader($token); + if ($tokenReader->getMinecraftClientToken() !== $this->clientToken) { + throw new ForbiddenOperationException('Invalid token.'); + } - /** @var MinecraftAccessKey|null $accessToken */ - $accessToken = MinecraftAccessKey::findOne([ - 'access_token' => $this->accessToken, - 'client_token' => $this->clientToken, - ]); - if ($accessToken === null) { + $account = Account::findOne(['id' => $tokenReader->getAccountId()]); + } + + if ($account === null) { throw new ForbiddenOperationException('Invalid token.'); } - if ($accessToken->account->status === Account::STATUS_BANNED) { + if ($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); + // 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/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 @@ 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 { - $server = Yii::$app->oauth->authServer; - $server->setRequest(null); // Enforce request recreation (test environment bug) + return new OauthProcess(Yii::$app->oauth->getAuthServer()); + } - return new OauthProcess($server); + private function getServerRequest(): ServerRequestInterface { + return ServerRequest::fromGlobals(); } } diff --git a/api/modules/oauth/models/OauthProcess.php b/api/modules/oauth/models/OauthProcess.php index ee0ebd6..be0dfc3 100644 --- a/api/modules/oauth/models/OauthProcess.php +++ b/api/modules/oauth/models/OauthProcess.php @@ -1,19 +1,22 @@ getAuthorizationCodeGrant()->checkAuthorizeParams(); - $client = $authParams->getClient(); + $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) { - $response = $this->buildErrorResponse($e); + $clientModel = $this->findClient($client->getIdentifier()); + $response = $this->buildSuccessResponse($request, $clientModel, $authRequest->getScopes()); + } catch (OAuthServerException $e) { + $response = $this->buildCompleteErrorResponse($e); } return $response; @@ -83,45 +83,53 @@ 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'); - $grant = $this->getAuthorizationCodeGrant(); - $authParams = $grant->checkAuthorizeParams(); + + $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 $client */ + $client = $this->findClient($authRequest->getClient()->getIdentifier()); - if (!$this->canAutoApprove($account, $clientModel, $authParams)) { + $approved = $this->canAutoApprove($account, $client, $authRequest); + if (!$approved) { Yii::$app->statsd->inc('oauth.complete.approve_required'); - $isAccept = Yii::$app->request->post('accept'); - if ($isAccept === null) { - throw new AcceptRequiredException(); + + $acceptParam = ((array)$request->getParsedBody())['accept'] ?? null; + if ($acceptParam === null) { + throw $this->createAcceptRequiredException(); } - if (!$isAccept) { - throw new AccessDeniedException($authParams->getRedirectUri()); + $approved = in_array($acceptParam, [1, '1', true, 'true'], true); + if ($approved) { + $this->storeOauthSession($account, $client, $authRequest); } } - $redirectUri = $grant->newAuthorizeRequest('user', $account->id, $authParams); - $response = [ + $authRequest->setUser(new UserEntity($account->id)); + $authRequest->setAuthorizationApproved($approved); + $response = $this->server->completeAuthorizationRequest($authRequest, new Response(200)); + + $result = [ 'success' => true, - 'redirectUri' => $redirectUri, + 'redirectUri' => $response->getHeaderLine('Location'), ]; + 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'); } - $response = $this->buildErrorResponse($e); + $result = $this->buildCompleteErrorResponse($e); } - return $response; + return $result; } /** @@ -143,30 +151,44 @@ class OauthProcess { * grant_type, * ] * + * @param ServerRequestInterface $request * @return array */ - public function getToken(): array { - $grantType = Yii::$app->request->post('grant_type', 'null'); + 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"); - $response = $this->server->issueAccessToken(); - $clientId = Yii::$app->request->post('client_id'); + + $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"); - } catch (OAuthException $e) { + } catch (OAuthServerException $e) { Yii::$app->statsd->inc("oauth.issueToken_{$grantType}.fail"); - Yii::$app->response->statusCode = $e->httpStatusCode; - $response = [ - 'error' => $e->errorType, - 'message' => $e->getMessage(), - ]; + Yii::$app->response->statusCode = $e->getHttpStatusCode(); + + $result = $this->buildIssueErrorResponse($e); } - return $response; + return $result; } private function findClient(string $clientId): ?OauthClient { - return OauthClient::findOne($clientId); + return OauthClient::findOne(['id' => $clientId]); } /** @@ -175,39 +197,48 @@ 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; } - /** @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($oauthParams->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 array $queryParams + * @param ServerRequestInterface $request * @param OauthClient $client - * @param \api\components\OAuth2\Entities\ScopeEntity[] $scopes + * @param 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 +248,94 @@ 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 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 buildCompleteErrorResponse(OAuthServerException $e): array { + $hint = $e->getPayload()['hint'] ?? ''; + if (preg_match('/the `(\w+)` scope/', $hint, $matches)) { + $parameter = $matches[1]; + } + $response = [ 'success' => false, - 'error' => $e->errorType, - 'parameter' => $e->parameter, - 'statusCode' => $e->httpStatusCode, + 'error' => $e->getErrorType(), + 'parameter' => $parameter ?? null, + '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 getAuthorizationCodeGrant(): AuthCodeGrant { - /** @var GrantTypeInterface $grantType */ - $grantType = $this->getGrant('authorization_code'); - if (!$grantType instanceof AuthCodeGrant) { - throw new InvalidGrantException('authorization_code grant have invalid realisation'); + /** + * 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 are the 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.'; + break; } - return $grantType; + return [ + 'error' => $errorType, + 'message' => $message, + ]; + } + + private function createAcceptRequiredException(): OAuthServerException { + return new OAuthServerException('Client must accept authentication request.', 0, 'accept_required', 401); + } + + private function getScopesList(AuthorizationRequest $request): array { + 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/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'); } diff --git a/api/tests/_pages/AuthserverRoute.php b/api/tests/_pages/AuthserverRoute.php deleted file mode 100644 index 90649c1..0000000 --- a/api/tests/_pages/AuthserverRoute.php +++ /dev/null @@ -1,26 +0,0 @@ -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/_pages/OauthRoute.php b/api/tests/_pages/OauthRoute.php index a72aeef..564d7d6 100644 --- a/api/tests/_pages/OauthRoute.php +++ b/api/tests/_pages/OauthRoute.php @@ -1,40 +1,52 @@ getActor()->sendGET('/api/oauth2/v1/validate', $queryParams); - } - - public function complete(array $queryParams = [], array $postParams = []): void { - $this->getActor()->sendPOST('/api/oauth2/v1/complete?' . http_build_query($queryParams), $postParams); - } - - 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/_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.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/_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/_steps/OauthSteps.php b/api/tests/functional/_steps/OauthSteps.php index da3b9a6..6c09d4a 100644 --- a/api/tests/functional/_steps/OauthSteps.php +++ b/api/tests/functional/_steps/OauthSteps.php @@ -1,62 +1,60 @@ 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=([^&$]+)/', $redirectUri, $matches); return $matches[1]; } - public function getAccessToken(array $permissions = []) { - $authCode = $this->getAuthCode($permissions); + public function getAccessToken(array $permissions = []): string { + $authCode = $this->obtainAuthCode($permissions); $response = $this->issueToken($authCode); return $response['access_token']; } - public function getRefreshToken(array $permissions = []) { - $authCode = $this->getAuthCode(array_merge([S::OFFLINE_ACCESS], $permissions)); + public function getRefreshToken(array $permissions = []): string { + $authCode = $this->obtainAuthCode(array_merge([PublicScopeRepository::OFFLINE_ACCESS], $permissions)); $response = $this->issueToken($authCode); return $response['refresh_token']; } - public function issueToken($authCode) { - $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) { - $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), + 'scope' => implode(' ', $permissions), ]); $response = json_decode($this->grabResponse(), true); 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..2f65350 100644 --- a/api/tests/functional/authserver/RefreshCest.php +++ b/api/tests/functional/authserver/RefreshCest.php @@ -1,42 +1,74 @@ 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); + } + + 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.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) { + $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 +81,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 +95,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 +106,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/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' - )); + $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(401); $I->canSeeResponseContainsJson([ 'error' => 'invalid_client', - 'message' => 'Client authentication failed.', + '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 d0c4efa..b5b9fb8 100644 --- a/api/tests/functional/oauth/AuthCodeCest.php +++ b/api/tests/functional/oauth/AuthCodeCest.php @@ -1,155 +1,21 @@ route = new OauthRoute($I); - } - - public function testValidateRequest(FunctionalTester $I) { - $this->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) { - $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, @@ -157,26 +23,98 @@ class AuthCodeCest { $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); } - public function testAcceptRequiredOnNewScope(FunctionalTester $I) { + /** + * @before completeSuccess + */ + public function completeSuccessWithLessScopes(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->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' => '', + 'parameter' => null, + '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' => null, + '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'); + $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, + 'error' => 'accept_required', + 'parameter' => null, 'statusCode' => 401, ]); } @@ -184,72 +122,30 @@ 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, 'error' => 'access_denied', - 'parameter' => '', + 'parameter' => null, 'statusCode' => 401, ]); $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([ @@ -257,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(); @@ -284,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', + ]), ['accept' => true]); // TODO: maybe remove? $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/ClientCredentialsCest.php b/api/tests/functional/oauth/ClientCredentialsCest.php index 5a5ff37..8712803 100644 --- a/api/tests/functional/oauth/ClientCredentialsCest.php +++ b/api/tests/functional/oauth/ClientCredentialsCest.php @@ -1,120 +1,86 @@ 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(FunctionalTester $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 480e2d9..7b5997c 100644 --- a/api/tests/functional/oauth/RefreshTokenCest.php +++ b/api/tests/functional/oauth/RefreshTokenCest.php @@ -1,83 +1,96 @@ 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', + ]); + $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 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', [ + '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 +98,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) { + private function canSeeRefreshTokenSuccess(FunctionalTester $I) { $I->canSeeResponseCodeIs(200); - $I->canSeeResponseIsJson(); $I->canSeeResponseContainsJson([ 'token_type' => 'Bearer', ]); diff --git a/api/tests/functional/oauth/ValidateCest.php b/api/tests/functional/oauth/ValidateCest.php new file mode 100644 index 0000000..9769f92 --- /dev/null +++ b/api/tests/functional/oauth/ValidateCest.php @@ -0,0 +1,113 @@ +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', + ], + ]); + } + + 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'); + } + +} 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..83a8af7 --- /dev/null +++ b/api/tests/unit/components/OAuth2/Entities/AccessTokenEntityTest.php @@ -0,0 +1,38 @@ +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')); + + $token = (string)$entity; + $payloads = json_decode(base64_decode(explode('.', $token)[1]), true); + $this->assertSame('first,second', $payloads['ely-scopes']); + } + + 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; + } + +} 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 cd54c00..36e536a 100644 --- a/api/tests/unit/components/Tokens/TokensFactoryTest.php +++ b/api/tests/unit/components/Tokens/TokensFactoryTest.php @@ -5,16 +5,24 @@ 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; - $token = TokensFactory::createForAccount($account); + // Create for account + + $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 +32,9 @@ class TokensFactoryTest extends TestCase { $session = new AccountSession(); $session->id = 2; - $token = TokensFactory::createForAccount($account, $session); + // 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); $this->assertSame('ely|1', $token->getClaim('sub')); @@ -32,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/components/User/ComponentTest.php b/api/tests/unit/components/User/ComponentTest.php index e9ca1cc..4f5fae5 100644 --- a/api/tests/unit/components/User/ComponentTest.php +++ b/api/tests/unit/components/User/ComponentTest.php @@ -5,13 +5,16 @@ 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; +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, ]; } @@ -41,7 +46,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 @@ -88,6 +93,7 @@ class ComponentTest extends TestCase { $component->terminateSessions($account, Component::KEEP_SITE_SESSIONS); $this->assertEmpty($account->getMinecraftAccessKeys()->all()); $this->assertNotEmpty($account->getSessions()->all()); + $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/components/User/IdentityFactoryTest.php b/api/tests/unit/components/User/IdentityFactoryTest.php index b3d6851..73bb6a4 100644 --- a/api/tests/unit/components/User/IdentityFactoryTest.php +++ b/api/tests/unit/components/User/IdentityFactoryTest.php @@ -3,41 +3,31 @@ 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\OAuth2Identity; +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'); - $this->assertInstanceOf(OAuth2Identity::class, $identity); + // Find identity by the legacy OAuth2 token + $identity = IdentityFactory::findIdentityByAccessToken('ZZQP8sS9urzriy8N9h6FwFNMOH3PkZ5T5PLqS6SX'); + $this->assertInstanceOf(LegacyOAuth2Identity::class, $identity); } public function testFindIdentityByAccessTokenWithEmptyValue() { diff --git a/api/tests/unit/components/User/JwtIdentityTest.php b/api/tests/unit/components/User/JwtIdentityTest.php index ef8c082..2488f69 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,14 +50,18 @@ 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.eyJlbHktc2NvcGVzIjoibWluZWNyYWZ0X3NlcnZlcl9zZXNzaW9uIiwiZWx5LWNsaWVudC10b2tlbiI6IllBTVhneTBBcEI5Z2dUX1VYNjNJaTdKcGtNd2ZwTmxaaE8yVVVEeEZ3YTFmZ2g4dksyN0RtV25vN2xqbk1pWWJwQ1VuS09YVnR2V1YtVVg1dWRQVVFsLU4xY3BBZlJBX2EtZW1BZyIsImlhdCI6MTU2NDYxMDUwMCwic3ViIjoiZWx5fDEifQ.LtE9cQJ4z5dGVkDZl50M2HZH6kOYHgGz2RIycS_lzU9YLhosQ3ux7i2KI7qGI7BNuxO5zJ1OkxF2r9Qc240EpA', + 'Token has been revoked', + ]; yield 'invalid signature' => [ 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoiZWx5fDEifQ.yth31f2PyhUkYSfBlizzUXWIgOvxxk8gNP-js0z8g1OT5rig40FPTIkgsZRctAwAAlj6QoIWW7-hxLTcSb2vmw', 'Incorrect token', ]; - yield 'invalid sub' => [ - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJlbHktc2NvcGVzIjoiYWNjb3VudHNfd2ViX3VzZXIiLCJpYXQiOjE1NjQ2MTA1NDIsImV4cCI6MTU2NDYxNDE0Miwic3ViIjoxMjM0fQ.yigP5nWFdX0ktbuZC_Unb9bWxpAVd7Nv8Fb1Vsa0t5WkVA88VbhPi2P-CenbDOy8ngwoGV9m3c3upMs2V3gqvw', - 'Incorrect token', - ]; yield 'empty token' => ['', 'Incorrect token']; } @@ -66,6 +74,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/api/tests/unit/components/User/LegacyOAuth2IdentityTest.php b/api/tests/unit/components/User/LegacyOAuth2IdentityTest.php new file mode 100644 index 0000000..9c37d29 --- /dev/null +++ b/api/tests/unit/components/User/LegacyOAuth2IdentityTest.php @@ -0,0 +1,39 @@ +assertSame('ZZQP8sS9urzriy8N9h6FwFNMOH3PkZ5T5PLqS6SX', $identity->getId()); + } + + public function testFindIdentityByAccessTokenWithNonExistsToken() { + $this->expectException(UnauthorizedHttpException::class); + $this->expectExceptionMessage('Incorrect token'); + + LegacyOAuth2Identity::findIdentityByAccessToken('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'); + } + + public function testFindIdentityByAccessTokenWithExpiredToken() { + $this->expectException(UnauthorizedHttpException::class); + $this->expectExceptionMessage('Token expired'); + + LegacyOAuth2Identity::findIdentityByAccessToken('rc0sOF1SLdOxuD3bJcCQENmGTeYrGgy12qJScMx4'); + } + +} diff --git a/api/tests/unit/components/User/OAuth2IdentityTest.php b/api/tests/unit/components/User/OAuth2IdentityTest.php deleted file mode 100644 index 790f139..0000000 --- a/api/tests/unit/components/User/OAuth2IdentityTest.php +++ /dev/null @@ -1,56 +0,0 @@ -setExpireTime(time() + 3600); - $accessToken->setId('mock-token'); - $this->mockFoundedAccessToken($accessToken); - - $identity = OAuth2Identity::findIdentityByAccessToken('mock-token'); - $this->assertSame('mock-token', $identity->getId()); - } - - public function testFindIdentityByAccessTokenWithNonExistsToken() { - $this->expectException(UnauthorizedHttpException::class); - $this->expectExceptionMessage('Incorrect token'); - - OAuth2Identity::findIdentityByAccessToken('not exists token'); - } - - public function testFindIdentityByAccessTokenWithExpiredToken() { - $this->expectException(UnauthorizedHttpException::class); - $this->expectExceptionMessage('Token expired'); - - $accessToken = new AccessTokenEntity(mock(AbstractServer::class)); - $accessToken->setExpireTime(time() - 3600); - $this->mockFoundedAccessToken($accessToken); - - OAuth2Identity::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); - } - -} 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']); } 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, 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 35e2db2..778665e 100644 --- a/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php +++ b/api/tests/unit/modules/authserver/models/AuthenticationFormTest.php @@ -3,139 +3,60 @@ 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\models\OauthClient; +use common\models\OauthSession; use common\tests\fixtures\AccountFixture; -use common\tests\fixtures\MinecraftAccessKeyFixture; +use common\tests\fixtures\OauthClientFixture; use Ramsey\Uuid\Uuid; class AuthenticationFormTest extends TestCase { - use ProtectedCaller; public function _fixtures(): array { return [ 'accounts' => AccountFixture::class, - 'minecraftAccessKeys' => MinecraftAccessKeyFixture::class, + 'oauthClients' => OauthClientFixture::class, ]; } - public function testAuthenticateByWrongNicknamePass() { - $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(); - - $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 testAuthenticateByValidCredentialsIntoBlockedAccount() { - $this->expectException(ForbiddenOperationException::class); - $this->expectExceptionMessage('This account has been suspended.'); - - $authForm = $this->createAuthForm(Account::STATUS_BANNED); - - $authForm->username = 'dummy'; - $authForm->password = 'password_0'; - $authForm->clientToken = Uuid::uuid4(); - - $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 = new AuthenticationForm(); + $authForm->username = 'admin'; $authForm->password = 'password_0'; - $authForm->clientToken = Uuid::uuid4(); - - $result = $authForm->authenticate(); - $this->assertInstanceOf(AuthenticateData::class, $result); - $this->assertSame($minecraftAccessKey->access_token, $result->getMinecraftAccessKey()->access_token); + $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']); + $this->assertTrue(OauthSession::find()->andWhere([ + 'account_id' => 1, + 'client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER, + ])->exists()); } - public function testCreateMinecraftAccessToken() { + /** + * @dataProvider getInvalidCredentialsCases + */ + public function testAuthenticateByWrongNicknamePass(string $expectedExceptionMessage, string $login, string $password) { + $this->expectException(ForbiddenOperationException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + $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)); + $authForm->username = $login; + $authForm->password = $password; + $authForm->clientToken = Uuid::uuid4()->toString(); + $authForm->authenticate(); } - 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; + public function getInvalidCredentialsCases() { + 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/autocompletion.php b/autocompletion.php index fa14d25..8e62e76 100644 --- a/autocompletion.php +++ b/autocompletion.php @@ -22,10 +22,8 @@ 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 */ abstract class BaseApplication extends yii\base\Application { } @@ -34,8 +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\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/common/components/Redis/Key.php b/common/components/Redis/Key.php deleted file mode 100644 index d48f420..0000000 --- a/common/components/Redis/Key.php +++ /dev/null @@ -1,60 +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 b6a07ec..0000000 --- a/common/components/Redis/Set.php +++ /dev/null @@ -1,47 +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()); - } - -} 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', 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..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 @@ -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/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 { diff --git a/common/models/OauthClient.php b/common/models/OauthClient.php index 5a856b0..260272f 100644 --- a/common/models/OauthClient.php +++ b/common/models/OauthClient.php @@ -1,4 +1,6 @@ TimestampBehavior::class, @@ -49,38 +44,48 @@ 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()); + } + + return (array)$this->scopes; } - public function getAccessTokens() { - throw new NotSupportedException('This method is possible, but not implemented'); + /** + * 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 { + // TODO: it seems that this method isn't used anywhere + if ($this->legacy_id === null) { + return []; + } + + return Yii::$app->redis->smembers($this->getLegacyRedisRefreshTokensKey()); } 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()); + Yii::$app->redis->del($this->getLegacyRedisRefreshTokensKey()); + } return true; } - public function removeRefreshToken(): void { - /** @var \api\components\OAuth2\Storage\RefreshTokenStorage $refreshTokensStorage */ - $refreshTokensStorage = Yii::$app->oauth->getRefreshTokenStorage(); - $refreshTokensSet = $refreshTokensStorage->sessionHash($this->id); - foreach ($refreshTokensSet->members() as $refreshTokenId) { - $refreshTokensStorage->delete($refreshTokensStorage->get($refreshTokenId)); - } - - $refreshTokensSet->delete(); + private function getLegacyRedisScopesKey(): string { + return "oauth:sessions:{$this->legacy_id}:scopes"; } - public function clearScopes(): void { - $this->getScopes()->delete(); + private function getLegacyRedisRefreshTokensKey(): string { + return "oauth:sessions:{$this->legacy_id}:refresh:tokens"; } } 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/_support/FixtureHelper.php b/common/tests/_support/FixtureHelper.php index 17914cd..4c7f6a3 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, + 'legacyOauthSessionsScopes' => fixtures\LegacyOauthSessionScopeFixtures::class, + 'legacyOauthAccessTokens' => fixtures\LegacyOauthAccessTokenFixture::class, + 'legacyOauthAccessTokensScopes' => fixtures\LegacyOauthAccessTokenScopeFixture::class, + 'legacyOauthRefreshTokens' => fixtures\LegacyOauthRefreshTokenFixture::class, + 'minecraftAccessKeys' => fixtures\MinecraftAccessKeyFixture::class, ]; } 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); 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-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', diff --git a/common/tests/fixtures/data/oauth-refresh-tokens.php b/common/tests/fixtures/data/oauth-refresh-tokens.php new file mode 100644 index 0000000..881ab67 --- /dev/null +++ b/common/tests/fixtures/data/oauth-refresh-tokens.php @@ -0,0 +1,2 @@ + [ - 'id' => 1, - 'owner_type' => 'user', - 'owner_id' => 1, + 'account_id' => 1, 'client_id' => 'test1', - 'client_redirect_uri' => 'http://test1.net/oauth', + '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' => [ - 'id' => 2, - 'owner_type' => 'user', - 'owner_id' => 10, + 'account_id' => 10, 'client_id' => 'test1', - 'client_redirect_uri' => 'http://test1.net/oauth', + 'legacy_id' => 2, + 'scopes' => null, 'created_at' => 1481421663, + 'revoked_at' => null, ], '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', + 'legacy_id' => 3, + 'scopes' => null, 'created_at' => 1519510065, + 'revoked_at' => null, ], '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', + 'legacy_id' => 4, + 'scopes' => null, 'created_at' => 1519511568, + 'revoked_at' => 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'; diff --git a/composer.json b/composer.json index 6f06d85..921d41b 100644 --- a/composer.json +++ b/composer.json @@ -5,13 +5,14 @@ "type": "project", "minimum-stability": "stable", "require": { - "php": "^7.2", + "php": "^7.3", "ext-intl": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", "ext-pdo": "*", "ext-simplexml": "*", + "ext-sodium": "*", "bacon/bacon-qr-code": "^1.0", "domnikl/statsd": "^2.6", "ely/mojang-api": "^0.2.0", @@ -19,11 +20,12 @@ "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", "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", @@ -54,6 +56,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..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": "35095ab389bcc73cacbafceffa74fb71", + "content-hash": "95971ae8836e4d182aae9e5c44021321", "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", @@ -1683,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", @@ -1916,60 +2038,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,18 +6721,20 @@ "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": "*", "ext-mbstring": "*", "ext-pdo": "*", - "ext-simplexml": "*" + "ext-simplexml": "*", + "ext-sodium": "*" }, "platform-dev": [] } diff --git a/console/controllers/ManualMigrateController.php b/console/controllers/ManualMigrateController.php new file mode 100644 index 0000000..6dcaf6e --- /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; + } + +} diff --git a/console/migrations/m190914_181236_rework_oauth_related_tables.php b/console/migrations/m190914_181236_rework_oauth_related_tables.php new file mode 100644 index 0000000..bf26038 --- /dev/null +++ b/console/migrations/m190914_181236_rework_oauth_related_tables.php @@ -0,0 +1,71 @@ +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->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'); + $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'); + $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); + $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'); + } + +}