From ee4d3a2dbfa8fa1a7705d9a27e8bb735fb8439f1 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 24 Aug 2021 17:08:19 -0700 Subject: [PATCH] HttpTestTrait - Allow one to easily add Authx JWTs to each request ```php // Add JWT credentials to every Guzzle request $http = $this->createGuzzle([ 'authx_user' => 'admin', ]); $response = $http->get('route://civicrm/dashboard'); // Add JWT credentials to a single request $http = $this->createGuzzle(); $response = $http->get('route://civicrm/dashboard', [ 'authx_user' => 'demo', ]); // Perform a stateful login via JWT $http = $this->createGuzzle(['cookies' => new CookieJar()]); $response = $http->get('route://civicrm/authx/login', [ 'authx_contact_id' => 100, ]); ``` --- CRM/Utils/GuzzleMiddleware.php | 100 ++++++++++++++++++ Civi/Test/HttpTestTrait.php | 9 ++ .../tests/phpunit/Civi/Authx/AllFlowsTest.php | 40 +++++++ 3 files changed, 149 insertions(+) diff --git a/CRM/Utils/GuzzleMiddleware.php b/CRM/Utils/GuzzleMiddleware.php index b6e8fb7e2d..0c98153612 100644 --- a/CRM/Utils/GuzzleMiddleware.php +++ b/CRM/Utils/GuzzleMiddleware.php @@ -8,6 +8,106 @@ use Psr\Http\Message\RequestInterface; */ class CRM_Utils_GuzzleMiddleware { + /** + * The authx middleware sends authenticated requests via JWT. + * + * To add an authentication token to a specific request, the `$options` + * must specify `authx_user` or `authx_contact_id`. Examples: + * + * $http = new GuzzleHttp\Client(['authx_user' => 'admin']); + * $http->post('civicrm/admin', ['authx_user' => 'admin']); + * + * Supported options: + * - authx_ttl (int): Seconds of validity for JWT's + * - authx_host (string): Only send tokens for the given host. + * - authx_contact_id (int): The CiviCRM contact to authenticate with + * - authx_user (string): The CMS user to authenticate with + * - authx_flow (string): How to format the auth token. One of: 'param', 'xheader', 'header'. + * + * @return \Closure + */ + public static function authx($defaults = []) { + $defaults = array_merge([ + 'authx_ttl' => 60, + 'authx_host' => parse_url(CIVICRM_UF_BASEURL, PHP_URL_HOST), + 'authx_contact_id' => NULL, + 'authx_user' => NULL, + 'authx_flow' => 'param', + ], $defaults); + return function(callable $handler) use ($defaults) { + return function (RequestInterface $request, array $options) use ($handler, $defaults) { + if ($request->getUri()->getHost() !== $defaults['authx_host']) { + return $handler($request, $options); + } + + $options = array_merge($defaults, $options); + if (!empty($options['authx_contact_id'])) { + $cid = $options['authx_contact_id']; + } + elseif (!empty($options['authx_user'])) { + $r = civicrm_api3("Contact", "get", ["id" => "@user:" . $options['authx_user']]); + foreach ($r['values'] as $id => $value) { + $cid = $id; + break; + } + if (empty($cid)) { + throw new \RuntimeException("Failed to identify user ({$options['authx_user']})"); + } + } + else { + $cid = NULL; + } + + if ($cid) { + if (!CRM_Extension_System::singleton()->getMapper()->isActiveModule('authx')) { + throw new \RuntimeException("Authx is not enabled. Authenticated requests will not work."); + } + $tok = \Civi::service('crypto.jwt')->encode([ + 'exp' => time() + $options['authx_ttl'], + 'sub' => 'cid:' . $cid, + 'scope' => 'authx', + ]); + + switch ($options['authx_flow']) { + case 'header': + $request = $request->withHeader('Authorization', "Bearer $tok"); + break; + + case 'xheader': + $request = $request->withHeader('X-Civi-Auth', "Bearer $tok"); + break; + + case 'param': + if ($request->getMethod() === 'POST') { + if (!empty($request->getHeader('Content-Type')) && !preg_grep(';application/x-www-form-urlencoded;', $request->getHeader('Content-Type'))) { + throw new \RuntimeException("Cannot append authentication credentials to HTTP POST. Unrecognized content type."); + } + $query = (string) $request->getBody(); + $request = $request->withHeader('Content-Type', 'application/x-www-form-urlencoded'); + $request = new GuzzleHttp\Psr7\Request( + $request->getMethod(), + $request->getUri(), + $request->getHeaders(), + http_build_query(['_authx' => "Bearer $tok"]) . ($query ? '&' : '') . $query + ); + } + else { + $query = $request->getUri()->getQuery(); + $request = $request->withUri($request->getUri()->withQuery( + http_build_query(['_authx' => "Bearer $tok"]) . ($query ? '&' : '') . $query + )); + } + break; + + default: + throw new \RuntimeException("Unrecognized authx flow: {$options['authx_flow']}"); + } + } + return $handler($request, $options); + }; + }; + } + /** * Add this as a Guzzle handler/middleware if you wish to simplify * the construction of Civi-related URLs. It enables URL schemes for: diff --git a/Civi/Test/HttpTestTrait.php b/Civi/Test/HttpTestTrait.php index 80be44515d..18f1dad115 100644 --- a/Civi/Test/HttpTestTrait.php +++ b/Civi/Test/HttpTestTrait.php @@ -34,11 +34,20 @@ trait HttpTestTrait { /** * Create an HTTP client suitable for simulating AJAX requests. * + * The client may include some mix of these middlewares: + * + * @see \CRM_Utils_GuzzleMiddleware::authx() + * @see \CRM_Utils_GuzzleMiddleware::url() + * @see \CRM_Utils_GuzzleMiddleware::curlLog() + * @see Middleware::history() + * @see Middleware::log() + * * @param array $options * @return \GuzzleHttp\Client */ protected function createGuzzle($options = []) { $handler = HandlerStack::create(); + $handler->unshift(\CRM_Utils_GuzzleMiddleware::authx(), 'civi_authx'); $handler->unshift(\CRM_Utils_GuzzleMiddleware::url(), 'civi_url'); $handler->push(Middleware::history($this->httpHistory), 'history'); diff --git a/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php b/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php index d2e2ea5f61..e7792914f4 100644 --- a/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php +++ b/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php @@ -423,6 +423,46 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf $this->assertEquals($actualSteps, $planSteps); } + /** + * Civi's test suite includes middleware that will add JWT tokens to outgoing requests. + * + * This test tries a few permutations with different principals ("demo", "Lebowski"), + * different identifier fields (authx_user, authx_contact_id), and different + * flows (param/header/xheader). + * + * @throws \CiviCRM_API3_Exception + * @throws \GuzzleHttp\Exception\GuzzleException + */ + public function testJwtMiddleware() { + // HTTP GET with a specific user. Choose flow automatically. + $response = $this->createGuzzle()->get('civicrm/authx/id', [ + 'authx_user' => $GLOBALS['_CV']['DEMO_USER'], + ]); + $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), 'jwt', 'param', $response); + + // HTTP GET with a specific contact. Choose flow automatically. + $response = $this->createGuzzle()->get('civicrm/authx/id', [ + 'authx_contact_id' => $this->getDemoCID(), + ]); + $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), 'jwt', 'param', $response); + + // HTTP POST with a specific contact. Per-client default. + $response = $this->createGuzzle([ + 'authx_contact_id' => $this->getLebowskiCID(), + ])->post('civicrm/authx/id'); + $this->assertMyContact($this->getLebowskiCID(), NULL, 'jwt', 'param', $response); + + // Using explicit flow options... + foreach (['param', 'xheader', 'header'] as $flowType) { + \Civi::settings()->set("authx_{$flowType}_cred", ['jwt']); + $response = $this->createGuzzle()->get('civicrm/authx/id', [ + 'authx_contact_id' => $this->getDemoCID(), + 'authx_flow' => $flowType, + ]); + $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), 'jwt', $flowType, $response); + } + } + /** * Filter a request, applying the given authentication options * -- 2.25.1