From 6dff00f445c5868f4a6b0c7878f19f9afd66c1e9 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 28 Oct 2020 02:28:24 -0700 Subject: [PATCH] dev/core#2141 - APIv4 - Add `OAuthClient.authorizationCode` authentication --- ext/oauth-client/CRM/OAuth/Page/Return.php | 90 ++++++++++++++++ .../OAuthClient/AbstractGrantAction.php | 100 ++++++++++++++++++ .../Action/OAuthClient/AuthorizationCode.php | 87 +++++++++++++++ ext/oauth-client/Civi/Api4/OAuthClient.php | 11 ++ .../templates/CRM/OAuth/Page/Return.tpl | 10 ++ .../phpunit/api/v4/OAuthClientGrantTest.php | 71 +++++++++++++ ext/oauth-client/xml/Menu/oauth_client.xml | 9 ++ 7 files changed, 378 insertions(+) create mode 100644 ext/oauth-client/CRM/OAuth/Page/Return.php create mode 100644 ext/oauth-client/Civi/Api4/Action/OAuthClient/AbstractGrantAction.php create mode 100644 ext/oauth-client/Civi/Api4/Action/OAuthClient/AuthorizationCode.php create mode 100644 ext/oauth-client/templates/CRM/OAuth/Page/Return.tpl create mode 100644 ext/oauth-client/tests/phpunit/api/v4/OAuthClientGrantTest.php create mode 100644 ext/oauth-client/xml/Menu/oauth_client.xml diff --git a/ext/oauth-client/CRM/OAuth/Page/Return.php b/ext/oauth-client/CRM/OAuth/Page/Return.php new file mode 100644 index 0000000000..bf31de1482 --- /dev/null +++ b/ext/oauth-client/CRM/OAuth/Page/Return.php @@ -0,0 +1,90 @@ +addWhere('id', '=', $state['clientId'])->execute()->single(); + $tokenRecord = Civi::service('oauth2.token')->init([ + 'client' => $client, + 'scope' => $state['scopes'], + 'storage' => $state['storage'], + 'grant_type' => 'authorization_code', + 'cred' => ['code' => $authCode], + ]); + } + else { + throw new \Civi\OAuth\OAuthException("OAuth: Unrecognized return request"); + } + + $json = function ($d) { + return json_encode($d, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + }; + $this->assign('state', $json($state)); + $this->assign('token', $json($tokenRecord ?? NULL)); + $this->assign('error', $json($error ?? NULL)); + + parent::run(); + } + + /** + * @param array $stateData + * @return string + * State token / identifier + */ + public static function storeState($stateData):string { + $stateId = \CRM_Utils_String::createRandom(20, \CRM_Utils_String::ALPHANUMERIC); + + if (PHP_SAPI === 'cli') { + // CLI doesn't have a real session, so we can't defend as deeply. However, + // it's also quite uncommon to run authorizationCode in CLI. + \Civi::cache('session')->set('OAuthStates_' . $stateId, $stateData, self::TTL); + return 'c_' . $stateId; + } + else { + // Storing in the bona fide session binds us to the cookie + $session = \CRM_Core_Session::singleton(); + $session->createScope('OAuthStates'); + $session->set($stateId, $stateData, 'OAuthStates'); + return 'w_' . $stateId; + } + } + + /** + * Restore from the $stateId. + * + * @param string $stateId + * @return mixed + * @throws \Civi\OAuth\OAuthException + */ + public static function loadState($stateId) { + list ($type, $id) = explode('_', $stateId); + switch ($type) { + case 'w': + $state = \CRM_Core_Session::singleton()->get($id, 'OAuthStates'); + break; + + case 'c': + $state = \Civi::cache('session')->get('OAuthStates_' . $id); + break; + + default: + throw new \Civi\OAuth\OAuthException("OAuth: Received invalid or expired state"); + } + + if (!isset($state['time']) || $state['time'] + self::TTL < CRM_Utils_Time::getTimeRaw()) { + throw new \Civi\OAuth\OAuthException("OAuth: Received invalid or expired state"); + } + + return $state; + } + +} diff --git a/ext/oauth-client/Civi/Api4/Action/OAuthClient/AbstractGrantAction.php b/ext/oauth-client/Civi/Api4/Action/OAuthClient/AbstractGrantAction.php new file mode 100644 index 0000000000..0de3c9e728 --- /dev/null +++ b/ext/oauth-client/Civi/Api4/Action/OAuthClient/AbstractGrantAction.php @@ -0,0 +1,100 @@ +storage)) { + throw new \API_Exception("Invalid token storage ($this->storage)"); + } + } + + /** + * Look up the definition for the desired client. + * + * @return array + * The OAuthClient details + * @see \Civi\Api4\OAuthClient::get() + * @throws OAuthException + */ + protected function getClientDef():array { + if ($this->clientDef !== NULL) { + return $this->clientDef; + } + + $records = $this->getBatchRecords(); + if (count($records) !== 1) { + throw new OAuthException(sprintf("OAuth: Failed to locate client. Expected 1 client, but found %d clients.", count($records))); + } + + $this->clientDef = array_shift($records); + return $this->clientDef; + } + + /** + * @return \League\OAuth2\Client\Provider\AbstractProvider + */ + protected function createLeagueProvider() { + $localOptions = []; + if ($this->scopes !== NULL) { + $localOptions['scopes'] = $this->scopes; + } + return \Civi::service('oauth2.league')->createProvider($this->getClientDef(), $localOptions); + } + + /** + * @return array|null + */ + public function getScopes() { + return $this->scopes; + } + + /** + * @param array|string|null $scopes + */ + public function setScopes($scopes) { + $this->scopes = is_string($scopes) ? [$scopes] : $scopes; + } + +} diff --git a/ext/oauth-client/Civi/Api4/Action/OAuthClient/AuthorizationCode.php b/ext/oauth-client/Civi/Api4/Action/OAuthClient/AuthorizationCode.php new file mode 100644 index 0000000000..1f8c5171c0 --- /dev/null +++ b/ext/oauth-client/Civi/Api4/Action/OAuthClient/AuthorizationCode.php @@ -0,0 +1,87 @@ + [['id', '=', 123], + * ]); + * $startUrl = $result->first()['url']; + * CRM_Utils_System::redirect($startUrl); + * ``` + * + * @method $this setLandingUrl(string $landingUrl) + * @method string getLandingUrl() + * + * @link https://tools.ietf.org/html/rfc6749#section-4.1 + */ +class AuthorizationCode extends AbstractGrantAction { + + /** + * If a user successfully completes the authentication, where should they go? + * + * This value will be stored in a way that is bound to the user session and + * OAuth-request. + * + * @var string|null + */ + protected $landingUrl = NULL; + + /** + * Tee-up the authorization request. + * + * @param \Civi\Api4\Generic\Result $result + */ + public function _run(Result $result) { + $this->validate(); + + /** @var \League\OAuth2\Client\Provider\GenericProvider $provider */ + $provider = $this->createLeagueProvider(); + + // NOTE: If we don't set scopes, then getAuthorizationUrl() would implicitly use getDefaultScopes(). + // We aim to store the effective list, but the protocol doesn't guarantee a notification of + // effective list. + $scopes = $this->getScopes() ?: $this->callProtected($provider, 'getDefaultScopes'); + + $stateId = \CRM_OAuth_Page_Return::storeState([ + 'time' => \CRM_Utils_Time::getTimeRaw(), + 'clientId' => $this->getClientDef()['id'], + 'landingUrl' => $this->getLandingUrl(), + 'storage' => $this->getStorage(), + 'scopes' => $scopes, + ]); + $result[] = [ + 'url' => $provider->getAuthorizationUrl([ + 'state' => $stateId, + 'scope' => $scopes, + ]), + ]; + } + + /** + * Call a protected method. + * + * @param mixed $obj + * @param string $method + * @param array $args + * @return mixed + */ + protected function callProtected($obj, $method, $args = []) { + $r = new \ReflectionMethod(get_class($obj), $method); + $r->setAccessible(TRUE); + return $r->invokeArgs($obj, $args); + } + +} diff --git a/ext/oauth-client/Civi/Api4/OAuthClient.php b/ext/oauth-client/Civi/Api4/OAuthClient.php index ab34267e4f..f3858bba68 100644 --- a/ext/oauth-client/Civi/Api4/OAuthClient.php +++ b/ext/oauth-client/Civi/Api4/OAuthClient.php @@ -23,6 +23,17 @@ class OAuthClient extends Generic\DAOEntity { return $action->setCheckPermissions($checkPermissions); } + /** + * Initiate the "Authorization Code" workflow. + * + * @param bool $checkPermissions + * @return \Civi\Api4\Action\OAuthClient\AuthorizationCode + */ + public static function authorizationCode($checkPermissions = TRUE) { + $action = new \Civi\Api4\Action\OAuthClient\AuthorizationCode(static::class, __FUNCTION__); + return $action->setCheckPermissions($checkPermissions); + } + public static function permissions() { return [ 'meta' => ['access CiviCRM'], diff --git a/ext/oauth-client/templates/CRM/OAuth/Page/Return.tpl b/ext/oauth-client/templates/CRM/OAuth/Page/Return.tpl new file mode 100644 index 0000000000..30e6334f33 --- /dev/null +++ b/ext/oauth-client/templates/CRM/OAuth/Page/Return.tpl @@ -0,0 +1,10 @@ +

Welcome back

+ +

State:

+
{$state}
+ +

Token:

+
{$token}
+ +

Error

+
{$error}
\ No newline at end of file diff --git a/ext/oauth-client/tests/phpunit/api/v4/OAuthClientGrantTest.php b/ext/oauth-client/tests/phpunit/api/v4/OAuthClientGrantTest.php new file mode 100644 index 0000000000..9cc4a1fe82 --- /dev/null +++ b/ext/oauth-client/tests/phpunit/api/v4/OAuthClientGrantTest.php @@ -0,0 +1,71 @@ +install('oauth-client')->apply(); + } + + public function setUp() { + parent::setUp(); + $this->assertEquals(0, CRM_Core_DAO::singleValueQuery('SELECT count(*) FROM civicrm_oauth_client')); + } + + public function tearDown() { + parent::tearDown(); + } + + /** + * Basic sanity check - create, read, and delete a client. + */ + public function testAuthorizationCode() { + $usePerms = function($ps) { + $base = ['access CiviCRM']; + \CRM_Core_Config::singleton()->userPermissionClass->permissions = array_merge($base, $ps); + }; + + $usePerms(['manage OAuth client']); + $client = $this->createClient(); + + $usePerms(['manage OAuth client']); + $result = Civi\Api4\OAuthClient::authorizationCode()->addWhere('id', '=', $client['id'])->execute(); + $this->assertEquals(1, $result->count()); + foreach ($result as $ac) { + $url = parse_url($ac['url']); + $this->assertEquals('example.com', $url['host']); + $this->assertEquals('/one/auth', $url['path']); + \parse_str($url['query'], $actualQuery); + $this->assertEquals('code', $actualQuery['response_type']); + $this->assertRegExp(';^[cs]_[a-zA-Z0-9]+$;', $actualQuery['state']); + $this->assertEquals('scope-1-foo,scope-1-bar', $actualQuery['scope']); + // ? // $this->assertEquals('auto', $actualQuery['approval_prompt']); + $this->assertEquals('example-id', $actualQuery['client_id']); + $this->assertRegExp(';civicrm/oauth-client/return;', $actualQuery['redirect_uri']); + } + } + + private function createClient(): array { + $create = Civi\Api4\OAuthClient::create()->setValues([ + 'provider' => 'test_example_1', + 'guid' => "example-id", + 'secret' => "example-secret", + ])->execute(); + $this->assertEquals(1, $create->count()); + $client = $create->first(); + $this->assertTrue(!empty($client['id'])); + return $client; + } + +} diff --git a/ext/oauth-client/xml/Menu/oauth_client.xml b/ext/oauth-client/xml/Menu/oauth_client.xml new file mode 100644 index 0000000000..1039478f63 --- /dev/null +++ b/ext/oauth-client/xml/Menu/oauth_client.xml @@ -0,0 +1,9 @@ + + + + civicrm/oauth-client/return + CRM_OAuth_Page_Return + Return + access CiviCRM + + -- 2.25.1