diff --git a/Auth/OAuth2/Client.php b/Auth/OAuth2/Client.php deleted file mode 100644 index be7931409..000000000 --- a/Auth/OAuth2/Client.php +++ /dev/null @@ -1,27 +0,0 @@ -getName(); + } + + public function prepareRequestParamters(array $defaults, array $options) : array + { + $defaullts['grant_type'] = $this->getName(); + + $required = $this->getRequiredRequestParameters(); + $provided = \array_merge($defaults, $options); + + foreach ($required as $name) { + if (!isset($provided[$name])) { + throw new \Exception(); + } + } + + return $provided; + } +} diff --git a/Auth/OAuth2/Grant/GrantFactory.php b/Auth/OAuth2/Grant/GrantFactory.php new file mode 100644 index 000000000..624c4d061 --- /dev/null +++ b/Auth/OAuth2/Grant/GrantFactory.php @@ -0,0 +1,55 @@ +registry[$name] = $grant; + + return $this; + } + + public function getGrant(string $name) : AbstractGrant + { + if (!isset($this->registry[$name])) { + $this->registerDefaultGrant($name); + } + + return $this->registry[$name]; + } + + protected function registerDefaultGrant(string $name) : self + { + $class = \str_replace(' ', '', \ucwords(\str_replace(['-', '_', ' ', $name]))); + $class = 'phpOMS\\OAuth2\\Grant\\' . $class; + + $this->checkGrant($class); + + return $this->setGrant($name, new $class()); + } +} diff --git a/Auth/OAuth2/Grant/Password.php b/Auth/OAuth2/Grant/Password.php new file mode 100644 index 000000000..d260390d8 --- /dev/null +++ b/Auth/OAuth2/Grant/Password.php @@ -0,0 +1,40 @@ + ['content-type' => MimeType::M_POST] + ]; + + if ($method === RequestMethod::POST) { + $options['body'] = $this->getAccessTokenBody($params); + } + + return $options; + } + + protected function getAccessTokenBody(array $params) : string + { + return \http_build_query($params, null, '&', \PHP_QUERY_RFC3986); + } +} diff --git a/Auth/OAuth2/Provider/GeneralProvider.php b/Auth/OAuth2/Provider/GeneralProvider.php index dfbf6b6ca..9d8ff071c 100644 --- a/Auth/OAuth2/Provider/GeneralProvider.php +++ b/Auth/OAuth2/Provider/GeneralProvider.php @@ -25,26 +25,49 @@ use phpOMS\Auth\OAuth2\AccessToken; * @link https://orange-management.org * @since 1.0.0 */ -final class GeneralProvider +class GeneralProvider extends ProviderAbstract { - /** - * Authorization url - * - * @var string - * @since 1.0.0 - */ private string $urlAuthorize; - /** - * Access token url - * - * @var string - * @since 1.0.0 - */ private string $urlAccessToken; + private string $urlResourceOwnerDetails; + + private string $accessTokenMethod; + + private string $accessTokenResourceOwnerId; + + private ?array $scopes = null; + + private string $scopeSeparator; + + private string $responseCode; + + private string $responseResourceOwnerId = 'id'; + public function __construct(array $options = [], array $collaborators = []) { + if (!isset($options['urlAuthorize'], $options['urlAccessToken'], $options['urlResourceOwnerDetails'])) { + throw new \InvalidArgumentException(); + } + + foreach ($options as $key => $option) { + if (\property_exists($this, $key)) { + $this->{$key} = $option; + } + } + + parent::__construct([], $collaborators); + } + + public function getBaseAuthorizationUrl() : string + { + return $this->urlAuthorize; + } + + public function getBaseAccessTokenUrl(array $params = []) : string + { + return $this->urlAccessToken; } public function getDefaultScopes() : array @@ -67,7 +90,7 @@ final class GeneralProvider return $this->scopeSeparator ?: parent::getScopeSeparator(); } - private function createResourceOwner(array $reesponse, AccessToken $token) : GeneralResourceOwner + private function createResourceOwner(array $response, AccessToken $token) : GeneralResourceOwner { return new GeneralResourceOwner($response, $this->responseResourceOwnerId); } diff --git a/Auth/OAuth2/Provider/GeneralResourceOwner.php b/Auth/OAuth2/Provider/GeneralResourceOwner.php new file mode 100644 index 000000000..e69de29bb diff --git a/Auth/OAuth2/Provider/ProviderAbstract.php b/Auth/OAuth2/Provider/ProviderAbstract.php index a024a6595..71fe495f3 100644 --- a/Auth/OAuth2/Provider/ProviderAbstract.php +++ b/Auth/OAuth2/Provider/ProviderAbstract.php @@ -15,6 +15,11 @@ declare(strict_types=1); namespace phpOMS\Auth\OAuth2\Provider; +use phpOMS\Message\Http\HttpResponse; +use phpOMS\Message\Http\RequestMethod; +use phpOMS\Uri\UriFactory; +use phpOMS\Utils\ArrayUtils; + /** * Provider class. * @@ -25,7 +30,291 @@ namespace phpOMS\Auth\OAuth2\Provider; */ abstract class ProviderAbstract { + protected const ACCESS_TOKEN_RESOURCE_OWNER_ID = null; + + protected string $clientId; + + protected string $clientSecret; + + protected string $redirectUri; + + protected string $state; + + protected GrantFactory $grantFactory; + + protected ReuqestFactory $requestFactory; + + protected OptionProviderInterface $optionProvider; + public function __construct(array $options = [], array $collaborators = []) { + foreach ($options as $key => $option) { + if (\property_exists($this, $key)) { + $this->{$key} = $option; + } + } + + $this->setGrantFactory($collaborators['grantFactory'] ?? new GrantFactory()); + $this->setRequestFactory($collaborators['requestFactory'] ?? new RequestFactory()); + $this->setOptionProvider($collaborators['optionProvider'] ?? new PostAuthOptionProvider()); + } + + public function setGrantFactory(GrantFactory $factory) : self + { + $this->grantFactory = $factory; + + return $this; + } + + public function getGrantFactory() : GrantFactory + { + return $this->grantFactory; + } + + public function setRequestFactory(RequestFactory $factory) : self + { + $this->requestFactory = $factory; + + return $this; + } + + public function getRequestFactory() : RequestFactory + { + return $this->requestFactory; + } + + public function setOptionProvider(OptionProviderInterface $provider) : self + { + $this->optionProvider = $provider; + + return $this; + } + + public function getOptionProvider() : OptionProviderInterface + { + return $this->optionProvider; + } + + public function getState() : string + { + return $this->state; + } + + abstract public function getBaseAuthorizationUrl() : string; + + abstract public function getBaseAccessTokenUrl(array $params = []) : string; + + abstract public function getResourceOwnerDetailsUrl(AccessToken $token) : string; + + protected function getRandomState(int $length = 32) : string + { + return \bin2hex(\random_bytes($length / 2)); + } + + abstract protected function getDefaultScopes() : array; + + protected function getScopeSeparator() : string + { + return ','; + } + + protected function getAuthorizationParameters(array $options) : array + { + $options['state'] ??= $this->getRandomState(); + $options['scope'] ??= $this->getDefaultScopes(); + + $this->state = $options['state']; + + $options += [ + 'response_type' => 'code', + 'approval_prompt' => 'auto', + ]; + + if (\is_array($options['scope'])) { + $options['scope'] = implode($this->getScopeSeparator(), $options['scope']); + } + + $options['redirect_uri'] ??= $this->redirectUri; + $options['client_id'] = $this->clientId; + + return $options; + } + + protected function getAuthorizationQuery(array $params) : string + { + return \http_build_query($params, null, '&', \PHP_QUERY_RFC3986); + } + + public function getauthorizationUrl(array $options = []) : string + { + $base = $this->getBaseAuthorizationUrl(); + $params = $this->getAuthorizationParameters($options); + $query = $this->getAuthorizationQuery($params); + + return UriFactory::build($base . '?' . $query); + } + + public function authorize(array $options = [], callable $redirectHander = null) + { + $url = $this->getAuthorizationUrl($options); + if ($redirectHander !== null) { + return $redirectHandler($url, $this); + } + + // @codeCoverageIgnoreStart + header('Location: ' . $url); + exit; + // @codeCoverageIgnoreEnd + } + + protected function getAccessTokenMethod() : string + { + return RequestMethod::POST; + } + + protected function getAccessTokenResourceOwnerId() : ?string + { + return static::ACCESS_TOKEN_RESOURCE_OWNER_ID; + } + + protected function verifyGrant($grant) : AbstractGrant + { + $this->grantFactory->checkGrant($grant); + + return $grant; + } + + protected function getAccessTokenUrl(array $params) : string + { + $url = $this->getBaseAccessTokenUrl($params); + + if ($this->getAccessTokenMethod() === RequestMethod::GET) { + $query = $this->getAccessTokenQuery($params); + + return UriFactory::build($ur . '?' . $query); + } + + return $url; + } + + protected function getAccessTokenRequest(array $params) : HttpRequest + { + $method = $this->getAccessTokenMethod(); + $url = $this->getAccessTokenUrl($params); + $options = $this->getoptionProvider->getAccessTokenOptions($this->getAccessTokenMethod(), $params); + + return $this->createRequest($method, $url, null, $options); + } + + // string | Grant + public function getAccessToken($grant, array $options = []) : AccessTokenInterface + { + $grant = \is_string($grant) ? $this->grantFactory->getGrant($grant) : $this->verifyGrant(); + + $params = [ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'redirect_uri' => $this->redirectUri, + ]; + + $params = $grant->prepareRequestParameters($params, $options); + $request = $this->getAccessTokenRequest($params); + $response = $this->getParsedResponse($request); + + $prepared = $this->prepareAccessTokenResponse($response); + $token = $this->createAccessToken($prepared, $grant); + + return $token; + } + + public function createRequest(string $method, string $url, $token, array $options) : HttpRequest + { + $defaults = [ + 'headers' => $this->getHeaders($token), + ]; + + $options = \array_merge_recursive($defaults, $options); + $factory = $this->getRequestFactory(); + + return $factory->getRequestWithOptions($method, $url, $options); + } + + public function getParsedResponse(HttpRequest $request) + { + $response = $request->rest(); + $parsed = $this->parseResponse($response); + + $this->checkResponse($response, $parsed); + + return $parsed; + } + + protected function parseResponse(HttpResponse $response) : array + { + $content = $response->getBody(); + $type = \implode(';', (array) $response->getHeader()->get('Content-Type')); + + if (\stripos($type, 'urlencoded') !== false) { + \parse_str($content, $parsed); + + return $parsed; + } + + try { + return \json_decode($content, true); + } catch (\Throwable $t) { + return []; + } + } + + abstract protected function checkResponse(HttpResponse $response, $data) : void; // todo: consider to make bool + + protected function prepareAccessTokenResponse(array $result) : array + { + if (($id = $this->getAccesstokenResourceOwnerId()) !== null) { + $result['resource_owner_id'] = ArrayUtils::getArray($id, $result, '.'); + } + + return $result; + } + + protected function createAccessToken(array $response, AbstractGrant $grant) : AccessTokenInterface + { + return new AccessToken($response); + } + + abstract protected function createResourceOwner(array $response, AccessToken $token) : ResourceOwnerInterface; + + public function getResourceOwner(AccessToken $token) : ResourceOwnerInterface + { + $response = $this->fetchResourceOwnerDetails($token); + + return $this->createResourceOwner($response, $token); + } + + protected function fetchResourceOwnerDetails(AccessToken $token) + { + $url = $this->getResourceOwnerDetailsUrl($token); + $request = $this->getAuthenticatedRequest(RequestMethod::GET, $url, $token); + $response = $this->getParsedResponse($request); + + return $response; + } + + protected function getDefaultHeaders() : array + { + return []; + } + + protected function getAuthorizationHeaders($token = null) : array + { + return []; + } + + public function getHeaders($token = null) : array + { + return $token === null + ? $this->getDefaultHeaders() + : \array_merge($this->getDefaultHeaders(), $this->getAuthorizationHeaders()); } } diff --git a/Auth/OAuth2/Provider/ResourceOwnerInterface.php b/Auth/OAuth2/Provider/ResourceOwnerInterface.php new file mode 100644 index 000000000..e69de29bb diff --git a/Auth/OAuth2/Token/AccessToken.php b/Auth/OAuth2/Token/AccessToken.php new file mode 100644 index 000000000..d1213967a --- /dev/null +++ b/Auth/OAuth2/Token/AccessToken.php @@ -0,0 +1,120 @@ +accessToken = $options['access_token']; + + if (isset($options['resource_owner_id'])) { + $this->resourceOwnerId = $options['resource_owner_id']; + } + + if (isset($options['refresh_token'])) { + $this->refreshToken = $options['refresh_token']; + } + + if (isset($options['expires_in'])) { + $this->expires = $options['expires_in'] !== 0 ? \time() + $options['expires_in'] : 0; + } elseif (!empty($options['expires'])) { + $this->expires = $options['expires']; + } + + $this->values = \array_diff_key($options, \array_flip([ + 'access_token', + 'resource_owner_id', + 'refresh_token', + 'expires_in', + 'expires', + ])); + } + + public function getToken() : string + { + return $this->accessToken; + } + + public function getExpires() : ?int + { + return $this->expires; + } + + public function getRefreshToken() : ?string + { + return $this->refreshToken; + } + + public function getResourceOwnerId() : ?string + { + return $this->resourceOwnerId; + } + + public function hasExpired() : bool + { + return $this->expires < \time(); + } + + public function __toString() + { + return $this->getToken(); + } + + public function jsonSerialize() + { + $params = $this->values; + + if (isset($this->accessToken)) { + $params['access_token'] = $this->accessToken; + } + + if (isset($this->refreshToken)) { + $params['refresh_token'] = $this->refreshToken; + } + + if (isset($this->expires)) { + $params['expires'] = $this->expires; + } + + if (isset($this->resourceOwnerId)) { + $params['resource_owner_id'] = $this->resourceOwnerId; + } + + return $params; + } +} diff --git a/Auth/OAuth2/Token/AccessTokenInterface.php b/Auth/OAuth2/Token/AccessTokenInterface.php new file mode 100644 index 000000000..188a4a429 --- /dev/null +++ b/Auth/OAuth2/Token/AccessTokenInterface.php @@ -0,0 +1,41 @@ +