diff --git a/CRM/Utils/GuzzleMiddleware.php b/CRM/Utils/GuzzleMiddleware.php index 0c9815361265..c8b76cce88ca 100644 --- a/CRM/Utils/GuzzleMiddleware.php +++ b/CRM/Utils/GuzzleMiddleware.php @@ -227,7 +227,7 @@ public static function curlLog(\Psr\Log\LoggerInterface $logger) { $curlFmt = new class() extends \GuzzleHttp\MessageFormatter { - public function format(RequestInterface $request, ResponseInterface $response = NULL, \Exception $error = NULL) { + public function format(RequestInterface $request, ?ResponseInterface $response = NULL, ?\Throwable $error = NULL): string { $cmd = '$ curl'; if ($request->getMethod() !== 'GET') { $cmd .= ' -X ' . escapeshellarg($request->getMethod()); diff --git a/ext/afform/core/CRM/Afform/Utils.php b/ext/afform/core/CRM/Afform/Utils.php new file mode 100644 index 000000000000..3057a7c197fb --- /dev/null +++ b/ext/afform/core/CRM/Afform/Utils.php @@ -0,0 +1,32 @@ + string $label). + */ + public static function getMailAuthOptions(): array { + return [ + 'session' => E::ts('Session-level authentication'), + 'page' => E::ts('Page-level authentication'), + ]; + } + +} diff --git a/ext/afform/core/Civi/Afform/PageTokenCredential.php b/ext/afform/core/Civi/Afform/PageTokenCredential.php new file mode 100644 index 000000000000..653d25c7a23f --- /dev/null +++ b/ext/afform/core/Civi/Afform/PageTokenCredential.php @@ -0,0 +1,216 @@ + afform, afform => MY_FORM_NAME, sub=>cid:123]`. + * This is defined to support "Afform.prefill" and "Afform.submit" on behalf of contact #123. + * - Navigate to `civicrm/my-form?_aff=Bearer+MY_JWT` + * - Within the page-view, each AJAX call sets `X-Civi-Auth: MY_JWT`. + * + * @service civi.afform.page_token + */ +class PageTokenCredential extends AutoService implements EventSubscriberInterface { + + public static function getSubscribedEvents(): array { + $events = []; + $events['civi.invoke.auth'][] = ['onInvoke', 105]; + $events['civi.authx.checkCredential'][] = ['afformPageToken', -400]; + return $events; + } + + /** + * If you visit a top-level page like "civicrm/my-custom-form?_aff=XXX", then + * all embedded AJAX calls should "_authx=XXX". + * + * @param \Civi\Core\Event\GenericHookEvent $e + * @return void + */ + public function onInvoke(GenericHookEvent $e) { + $token = $_REQUEST['_aff'] ?? NULL; + + if (empty($token)) { + return; + } + + if (!preg_match(';^[a-zA-Z0-9\.\-_ ]+$;', $token)) { + throw new \CRM_Core_Exception("Malformed page token"); + } + + // FIXME: This would authenticate requests to the main page, but it also has the side-effect + // of making the user login. + + // \CRM_Core_Session::useFakeSession(); + // $params = ($_SERVER['REQUEST_METHOD'] === 'GET') ? $_GET : $_POST; + // $authenticated = \Civi::service('authx.authenticator')->auth($e, ['flow' => 'param', 'cred' => $params['_aff'], 'siteKey' => NULL]); + // _authx_redact(['_aff']); + // if (!$authenticated) { + // return; + // } + + \CRM_Core_Region::instance('page-header')->add([ + 'callback' => function() use ($token) { + $ajaxSetup = [ + 'headers' => ['X-Civi-Auth' => $token], + + // Sending cookies is counter-productive. For same-origin AJAX, there doesn't seem to be an opt-out. + // The main mitigating factor is that AuthX calls useFakeSession() for our use-case. + // 'xhrFields' => ['withCredentials' => FALSE], + // 'crossDomain' => TRUE, + ]; + $script = sprintf('CRM.$.ajaxSetup(%s);', json_encode($ajaxSetup)); + return ""; + }, + ]); + } + + /** + * If we get a JWT with `[scope=>afform, afformName=>xyz]`, then setup + * the current fake-session to allow limited page-views. + * + * @param \Civi\Authx\CheckCredentialEvent $check + * + * @return void + */ + public function afformPageToken(CheckCredentialEvent $check) { + if ($check->credFormat === 'Bearer') { + try { + $claims = \Civi::service('crypto.jwt')->decode($check->credValue); + $scopes = isset($claims['scope']) ? explode(' ', $claims['scope']) : []; + if (!in_array('afform', $scopes)) { + // This is not an afform JWT. Proceed to check any other token sources. + return; + } + if (empty($claims['exp'])) { + $check->reject('Malformed JWT. Must specify an expiration time.'); + } + if (empty($claims['sub']) || substr($claims['sub'], 0, 4) !== 'cid:') { + $check->reject('Malformed JWT. Must specify the contact ID.'); + } + else { + $contactId = substr($claims['sub'], 4); + if ($this->checkAllowedRoute($check->getRequestPath(), $claims)) { + $check->accept(['contactId' => $contactId, 'credType' => 'jwt', 'jwt' => $claims]); + } + else { + $check->reject('JWT specifies a different form or route'); + } + } + } + catch (CryptoException $e) { + if (strpos($e->getMessage(), 'Expired token') !== FALSE) { + $check->reject('Expired token'); + } + + // Not a valid AuthX JWT. Proceed to check any other token sources. + } + } + + } + + /** + * When processing CRM_Core_Invoke, check to see if our token allows us to handle this request. + * + * @param string $route + * @param array $jwt + * @return bool + * @throws \CRM_Core_Exception + * @throws \Civi\API\Exception\UnauthorizedException + */ + public function checkAllowedRoute(string $route, array $jwt): bool { + $allowedForm = $jwt['afform']; + + $ajaxRoutes = $this->getAllowedRoutes(); + foreach ($ajaxRoutes as $regex => $routeInfo) { + if (preg_match($regex, $route)) { + $parsed = json_decode(\CRM_Utils_Request::retrieve('params', 'String'), 1); + if (empty($parsed)) { + \Civi::log()->warning("Malformed request. Routes matching $regex must submit params as JSON field."); + return FALSE; + } + + $extraFields = array_diff(array_keys($parsed), $routeInfo['allowFields']); + if (!empty($extraFields)) { + \Civi::log()->warning("Malformed request. Routes matching $regex only support these input fields: " . json_encode($routeInfo['allowFields'])); + return FALSE; + } + + if (empty($routeInfo['checkRequest'])) { + throw new \LogicException("Route ($regex) doesn't define checkRequest."); + } + $checkRequest = $routeInfo['checkRequest']; + if (!$checkRequest($parsed, $jwt)) { + \Civi::log()->warning("Malformed request. Requested form does not match allowed name ($allowedForm)."); + return FALSE; + } + + return TRUE; + } + } + + // Actually, we may not need this? aiming for model where top page-request auth is irrelevant to subrequests... + $allowedFormRoute = \Civi\Api4\Afform::get(FALSE)->addWhere('name', '=', $allowedForm) + ->addSelect('name', 'server_route') + ->execute() + ->single(); + if ($allowedFormRoute['server_route'] === $route) { + return TRUE; + } + + return FALSE; + } + + /** + * @return array[] + */ + protected function getAllowedRoutes(): array { + // These params are common to many Afform actions. + $abstractProcessorParams = ['name', 'args', 'fillMode']; + + return [ + // ';civicrm/path/to/some/page;' => [ + // + // // All the fields that are allowed for this API call. + // // N.B. Fields like "chain" are NOT allowed. + // 'allowFields' => ['field_1', 'field_2', ...] + // + // // Inspect the API-request and assert that the JWT allows these values. + // // Generally, check that the JWT's allowed-form-name matches REST's actual-form-name. + // 'checkRequest' => function(array $request, array $jwt): bool, + // + // ], + + ';^civicrm/ajax/api4/Afform/prefill$;' => [ + 'allowFields' => $abstractProcessorParams, + 'checkRequest' => fn($request, $jwt) => ($request['name'] === $jwt['afform']), + ], + ';^civicrm/ajax/api4/Afform/submit$;' => [ + 'allowFields' => [...$abstractProcessorParams, 'values'], + 'checkRequest' => fn($request, $jwt) => ($request['name'] === $jwt['afform']), + ], + ';^civicrm/ajax/api4/Afform/submitFile$;' => [ + 'allowFields' => $abstractProcessorParams, + 'checkRequest' => fn($request, $jwt) => ($request['name'] === $jwt['afform']), + ], + ';^civicrm/ajax/api4/\w+/autocomplete$;' => [ + 'allowFields' => ['fieldName', 'filters', 'formName', 'ids', 'input', 'page', 'values'], + 'checkRequest' => fn($request, $jwt) => ('afform:' . $jwt['afform']) === $request['formName'], + ], + // It's been hypothesized that we'll also need this. Haven't seen it yet. + // ';^civicrm/ajax/api4/Afform/getFields;' => [ + // 'allowFields' => [], + // 'checkRequest' => fn($expected, $request) => TRUE, + // ], + ]; + } + +} diff --git a/ext/afform/core/Civi/Afform/Tokens.php b/ext/afform/core/Civi/Afform/Tokens.php index c7b901f94428..d63a76c4d2a0 100644 --- a/ext/afform/core/Civi/Afform/Tokens.php +++ b/ext/afform/core/Civi/Afform/Tokens.php @@ -206,20 +206,47 @@ public static function createUrl($afform, $contactId): string { /** @var \Civi\Crypto\CryptoJwt $jwt */ $jwt = \Civi::service('crypto.jwt'); - $bearerToken = "Bearer " . $jwt->encode([ - 'exp' => $expires, - 'sub' => "cid:" . $contactId, - 'scope' => 'authx', - ]); - - $url = \CRM_Utils_System::url($afform['server_route'], - ['_authx' => $bearerToken, '_authxSes' => 1], - TRUE, - NULL, - FALSE, - $afform['is_public'] ?? TRUE - ); + $url = \Civi::url() + ->setScheme($afform['is_public'] ? 'frontend' : 'backend') + ->setPath($afform['server_route']) + ->setPreferFormat('absolute'); + + switch (static::getTokenType($afform, $contactId)) { + case 'session': + $bearerToken = "Bearer " . $jwt->encode([ + 'exp' => $expires, + 'sub' => "cid:" . $contactId, + 'scope' => 'authx', + ]); + return $url->addQuery(['_authx' => $bearerToken, '_authxSes' => 1]); + + case 'page': + $bearerToken = "Bearer " . $jwt->encode([ + 'exp' => $expires, + 'sub' => "cid:" . $contactId, + 'scope' => 'afform', + 'afform' => $afform['name'], + ]); + return $url->addQuery(['_aff' => $bearerToken]); + + default: + throw new \CRM_Core_Exception("Unrecognized authentication token type"); + } + return $url; } + /** + * Determine what kind of authentication-token to use for the given form/contact. + * + * @param array $afform + * @param int $contactId + * @return string + * One of: 'session', 'page' + */ + public static function getTokenType(array $afform, int $contactId): string { + return \Civi::settings()->get('afform_mail_auth_token'); + // Or maybe... read the $afform and determine its specific settings... + } + } diff --git a/ext/afform/core/info.xml b/ext/afform/core/info.xml index 8f7331699006..155a2c2caaeb 100644 --- a/ext/afform/core/info.xml +++ b/ext/afform/core/info.xml @@ -40,6 +40,8 @@ smarty@1.0.0 entity-types-php@2.0.0 menu-xml@1.0.0 + setting-php@1.0.0 + setting-admin@1.0.1 CiviMix\Schema\Afform\AutomaticUpgrader diff --git a/ext/afform/core/settings/Afform.setting.php b/ext/afform/core/settings/Afform.setting.php new file mode 100644 index 000000000000..ac1ac74bc266 --- /dev/null +++ b/ext/afform/core/settings/Afform.setting.php @@ -0,0 +1,27 @@ + [ + 'group_name' => 'Afform Preferences', + 'group' => 'afform', + 'name' => 'afform_mail_auth_token', + 'type' => 'String', + 'html_type' => 'select', + 'html_attributes' => [ + 'class' => 'crm-select2', + ], + 'pseudoconstant' => [ + 'callback' => 'CRM_Afform_Utils::getMailAuthOptions', + ], + // Traditional default. Might be nice to change, but need to tend to upgrade process. + 'default' => 'session', + 'add' => '4.7', + 'title' => E::ts('Mail Authentication Tokens'), + 'is_domain' => 1, + 'is_contact' => 0, + 'description' => E::ts('How do authenticated email hyperlinks work?'), + 'help_text' => NULL, + 'settings_pages' => ['afform' => ['weight' => 10]], + ], +]; diff --git a/ext/afform/mock/ang/mockPublicForm.aff.html b/ext/afform/mock/ang/mockPublicForm.aff.html index 593154b507be..f4908e25fcf2 100644 --- a/ext/afform/mock/ang/mockPublicForm.aff.html +++ b/ext/afform/mock/ang/mockPublicForm.aff.html @@ -1,14 +1,8 @@ - -
- Individual 1 -
-
- - -
-
+ +
+
diff --git a/ext/afform/mock/ang/mockPublicForm.aff.php b/ext/afform/mock/ang/mockPublicForm.aff.php index 533d45e70155..341629eea2b2 100644 --- a/ext/afform/mock/ang/mockPublicForm.aff.php +++ b/ext/afform/mock/ang/mockPublicForm.aff.php @@ -3,6 +3,8 @@ 'type' => 'form', 'title' => ts('My public form'), 'server_route' => 'civicrm/mock-public-form', + 'is_public' => TRUE, 'permission' => '*always allow*', 'is_token' => TRUE, + 'create_submission' => FALSE, ]; diff --git a/ext/afform/mock/tests/phpunit/E2E/AfformMock/MockPublicFormBrowserTest.php b/ext/afform/mock/tests/phpunit/E2E/AfformMock/MockPublicFormBrowserTest.php new file mode 100644 index 000000000000..2963f3d539a0 --- /dev/null +++ b/ext/afform/mock/tests/phpunit/E2E/AfformMock/MockPublicFormBrowserTest.php @@ -0,0 +1,111 @@ +install(['org.civicrm.afform', 'org.civicrm.afform-mock']) + ->apply(); + } + + protected function setUp(): void { + parent::setUp(); + } + + protected function tearDown(): void { + parent::tearDown(); + Civi::settings()->revert('afform_mail_auth_token'); + } + + /** + * Create a contact with middle name "Donald". Use the custom form to change the middle + * name to "Donny". + * + * @return void + * @throws \Behat\Mink\Exception\ElementNotFoundException + * @throws \CRM_Core_Exception + */ + public function testUpdateMiddleName() { + Civi::settings()->set('afform_mail_auth_token', 'page'); + + $donny = $this->initializeTheodoreKerabatsos(); + $this->assertEquals('Donald', $this->getContact($donny)['middle_name'], 'Middle name has original value'); + + $session = $this->mink->getSession(); + $url = $this->renderToken('{afform.mockPublicFormUrl}', $donny); + $this->visit($url); + + // Goal: Wait for the fields to be populated. But how...? + // $session->wait(5000, 'document.querySelectorAll("input#middle-name-1").length > 0'); + // $session->wait(5000, '!!document.querySelectorAll("input#first-name-0").length && !!document.querySelectorAll("input#first-name-0")[0].value'); + // $session->wait(5000, '!!document.querySelectorAll("input#middle-name-1").length && document.querySelectorAll("input#middle-name-1")[0].value.length > 0'); + // $session->wait(5000, 'CRM.$(\'#middle-name-1:contains("Donald")\').length > 0'); + // $session->wait(5000, 'window.CRM.$(\'#middle-name-1:contains("Donald")\').length > 0'); + $session->wait(2000); /* FIXME: Cannot get wait-condition to be meaningfully used */ + + $middleName = $this->assertSession()->elementExists('css', 'input#middle-name-1'); + $this->assertEquals('Donald', $middleName->getValue()); + $middleName->setValue('Donny'); + + $submit = $this->assertSession()->elementExists('css', 'button.af-button.btn-primary'); + $submit->click(); + + // Goal: Wait for the "Saved" status message. But how...? + // $session->wait(5000, 'document.querySelectorAll(".crm-status-box-msg").length > 0'); + // $session->wait(5000, 'CRM.$(\'.crm-status-box-msg:contains("Saved")\').length > 1'); + $session->wait(2000); /* FIXME: Cannot get wait-condition to be meaningfully used */ + + $this->assertEquals('Donny', $this->getContact($donny)['middle_name'], 'Middle name has been updated'); + } + + protected function renderToken(string $token, int $cid): string { + $tp = new \Civi\Token\TokenProcessor(\Civi::dispatcher(), []); + $tp->addRow()->context('contactId', $cid); + $tp->addMessage('example', $token, 'text/plain'); + $tp->evaluate(); + return $tp->getRow(0)->render('example'); + } + + protected function initializeTheodoreKerabatsos(): int { + $record = [ + 'contact_type' => 'Individual', + 'first_name' => 'Theodore', + 'middle_name' => 'Donald', + 'last_name' => 'Kerabatsos', + 'external_identifier' => __CLASS__, + ]; + $contact = \Civi\Api4\Contact::save(FALSE) + ->setRecords([$record]) + ->setMatch(['external_identifier']) + ->execute(); + return $contact[0]['id']; + } + + /** + * @param int $contactId + * @return string + * @throws \CRM_Core_Exception + */ + protected function getContact(int $contactId): array { + return Civi\Api4\Contact::get(FALSE) + ->addWhere('id', '=', $contactId) + ->addSelect('id', 'first_name', 'middle_name', 'last_name') + ->execute() + ->single(); + } + +} diff --git a/ext/afform/mock/tests/phpunit/E2E/AfformMock/MockPublicFormTest.php b/ext/afform/mock/tests/phpunit/E2E/AfformMock/MockPublicFormTest.php index d9fe9ac341ad..22f3c40f1812 100644 --- a/ext/afform/mock/tests/phpunit/E2E/AfformMock/MockPublicFormTest.php +++ b/ext/afform/mock/tests/phpunit/E2E/AfformMock/MockPublicFormTest.php @@ -2,9 +2,15 @@ namespace E2E\AfformMock; +use Civi; use CRM_Core_DAO; /** + * Perform some tests against `mockPublicForm.aff.html`. + * + * This test uses Guzzle and checks more low-level behaviors. For more comprehensive + * tests that also cover browser/Chrome/JS behaviors, see MockPublicFormBrowserTest. + * * @group e2e * @group ang */ @@ -12,6 +18,11 @@ class MockPublicFormTest extends \Civi\AfformMock\FormTestCase { protected $formName = 'mockPublicForm'; + protected function setUp(): void { + parent::setUp(); + Civi::settings()->revert('afform_mail_auth_token'); + } + public function testGetPage() { $r = $this->createGuzzle()->get('civicrm/mock-public-form'); $this->assertContentType('text/html', $r); @@ -67,80 +78,92 @@ public function testPublicEditDisallowed() { } /** - * The email token `{afform.mockPublicFormUrl}` should evaluate to an authenticated URL. + * There are two tokens ({afform.mockPublicFormUrl} and {afform.mockPublicFormLink}) + * which are rendered in two contexts (text and HTML). + * + * Make sure that the resulting URLs point to the same place, regardless of which + * variant or environment is used. + * + * @return void */ - public function testAuthenticatedUrlToken_Plain() { - if (!function_exists('authx_civicrm_config')) { - $this->fail('Cannot test without authx'); - } - + public function testWellFormedTokens() { $lebowski = $this->getLebowskiCID(); - $text = $this->renderTokens($lebowski, 'Please go to {afform.mockPublicFormUrl}', 'text/plain'); - if (!preg_match(';Please go to ([^\s]+);', $text, $m)) { - $this->fail('Plain text message did not have URL in expected place: ' . $text); - } - $url = $m[1]; - $this->assertMatchesRegularExpression(';^https?:.*civicrm/mock-public-form.*;', $url, "URL should look plausible"); + $messages = \CRM_Core_TokenSmarty::render([ + 'text' => 'url=({afform.mockPublicFormUrl}) link=({afform.mockPublicFormLink})', + 'html' => '

url=({afform.mockPublicFormUrl}) link=({afform.mockPublicFormLink})

', + ], ['contactId' => $lebowski]); - // Going to this page will cause us to authenticate as the target contact - $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => new \GuzzleHttp\Cookie\CookieJar()]); - $response = $http->get($url); - $r = (string) $response->getBody(); - $this->assertStatusCode(200, $response); - $response = $http->get('civicrm/authx/id'); - $this->assertContactJson($lebowski, $response); + $httpTextUrl = '(https?:[a-zA-Z0-9_/\.\?\-\+:=#&]+)'; + $httpHtmlUrl = '(https?:[a-zA-Z0-9_/\.\?\-\+:=#&\;]+)'; + $textPattern = ";url=\($httpTextUrl\) link=\(\[My public form\]\($httpTextUrl\)\); "; + $htmlPattern = ";\url=\($httpHtmlUrl\) link=\(My public form\)\;"; + + $this->assertMatchesRegularExpression($textPattern, $messages['text']); + $this->assertMatchesRegularExpression($htmlPattern, $messages['html']); + + preg_match($textPattern, $messages['text'], $textMatches); + preg_match($htmlPattern, $messages['html'], $htmlMatches); + + $this->assertEquals($textMatches[1], html_entity_decode($htmlMatches[1]), 'Text and HTML values of {afform.mockPublicFormUrl} should point to same place'); + $this->assertEquals($textMatches[2], html_entity_decode($htmlMatches[2]), 'Text and HTML values of {afform.mockPublicFormLink} should point to same place'); + + $this->assertMatchesRegularExpression(';^https?:.*civicrm/mock-public-form.*;', $textMatches[1], "URL should look plausible"); + $this->assertMatchesRegularExpression(';^https?:.*civicrm/mock-public-form.*;', $textMatches[2], "URL should look plausible"); } /** - * The email token `{afform.mockPublicFormUrl}` should evaluate to an authenticated URL. + * Evaluate the email token `{afform.mockPublicFormUrl}`. The output should be a session-level auth token. */ - public function testAuthenticatedUrlToken_Html() { - if (!function_exists('authx_civicrm_config')) { - $this->fail('Cannot test without authx'); - } + public function testAuthenticatedUrlToken_Session() { + $this->assertTrue(function_exists('authx_civicrm_config'), 'Cannot test without authx'); + Civi::settings()->set('afform_mail_auth_token', 'session'); $lebowski = $this->getLebowskiCID(); - $html = $this->renderTokens($lebowski, 'Please go to my form', 'text/html'); - - if (!preg_match(';a href="([^"]+)";', $html, $m)) { - $this->fail('HTML message did not have URL in expected place: ' . $html); - } - $url = html_entity_decode($m[1]); + $url = $this->renderTokens($lebowski, '{afform.mockPublicFormUrl}', 'text/plain'); $this->assertMatchesRegularExpression(';^https?:.*civicrm/mock-public-form.*;', $url, "URL should look plausible"); - // Going to this page will cause us to authenticate as the target contact - $http = $this->createGuzzle(['cookies' => new \GuzzleHttp\Cookie\CookieJar()]); - $response = $http->get($url); - $this->assertStatusCode(200, $response); - $response = $http->get('civicrm/authx/id'); - $this->assertContactJson($lebowski, $response); + $this->assertUrlStartsSession($url, $lebowski); } /** - * The email token `{afform.mockPublicFormLink}` should evaluate to an authenticated URL. + * Evaluate the email token `{afform.mockPublicFormUrl}`. The output should be a page-level auth token. */ - public function testAuthenticatedLinkToken_Html() { - if (!function_exists('authx_civicrm_config')) { - $this->fail('Cannot test without authx'); - } + public function testAuthenticatedUrlToken_Page() { + $this->assertTrue(function_exists('authx_civicrm_config'), 'Cannot test without authx'); + Civi::settings()->set('afform_mail_auth_token', 'page'); $lebowski = $this->getLebowskiCID(); - $html = $this->renderTokens($lebowski, 'Please go to {afform.mockPublicFormLink}', 'text/html'); - $doc = \phpQuery::newDocument($html, 'text/html'); - $this->assertEquals(1, $doc->find('a')->count(), 'Document should have hyperlink'); - foreach ($doc->find('a') as $item) { - /** @var \DOMElement $item */ - $this->assertMatchesRegularExpression(';^https?:.*civicrm/mock-public-form.*;', $item->getAttribute('href')); - $this->assertEquals('My public form', $item->firstChild->data); - $url = $item->getAttribute('href'); - } + $url = $this->renderTokens($lebowski, '{afform.mockPublicFormUrl}', 'text/plain'); + $this->assertMatchesRegularExpression(';^https?:.*civicrm/mock-public-form.*;', $url, "URL should look plausible"); - // Going to this page will cause us to authenticate as the target contact - $http = $this->createGuzzle(['cookies' => new \GuzzleHttp\Cookie\CookieJar()]); - $response = $http->get($url); - $this->assertStatusCode(200, $response); - $response = $http->get('civicrm/authx/id'); - $this->assertContactJson($lebowski, $response); + // This URL doesn't specifically log you in to a durable sesion. + // $this->assertUrlStartsSession($url, NULL); + + // However, there is an auth token. + $query = parse_url($url, PHP_URL_QUERY); + parse_str($query, $queryParams); + $token = $queryParams['_aff']; + $this->assertNotEmpty($token); + $auth = ['_authx' => $token]; + + // This token cannot be used for any random API... + $body = $this->callApi4AuthTokenFailure($auth, 'Contact', 'get', ['limit' => 5]); + $this->assertMatchesRegularExpression('/JWT specifies a different form or route/', $body, 'Response should have error message'); + + // The token can be used for Afform.prefill and Afform.submit... + $response = $this->callApi4AuthTokenSuccess($auth, 'Afform', 'prefill', [ + 'name' => $this->getFormName(), + ]); + $this->assertEquals('me', $response['values'][0]['name']); + $this->assertEquals($lebowski, $response['values'][0]['values'][0]['fields']['id'], 'Afform.prefill should return id'); + $this->assertEquals('Lebowski', $response['values'][0]['values'][0]['fields']['last_name'], 'Afform.prefill should return last_name'); + + // But the token cannot be used for Afform calls with sneaky params... + $body = $this->callApi4AuthTokenFailure($auth, 'Afform', 'prefill', [ + 'name' => $this->getFormName(), + 'chain' => ['name_me_0' => ['Contact', 'get', []]], + ]); + $this->assertMatchesRegularExpression('/JWT specifies a different form or route/', $body, 'Response should have error message'); } protected function renderTokens($cid, $body, $format) { @@ -151,7 +174,7 @@ protected function renderTokens($cid, $body, $format) { return $tp->getRow(0)->render('example'); } - protected function getLebowskiCID() { + protected function getLebowskiCID(): int { $contact = \civicrm_api3('Contact', 'create', [ 'contact_type' => 'Individual', 'first_name' => 'Jeffrey', @@ -179,4 +202,65 @@ public function assertContactJson($cid, $response) { $this->assertEquals($cid, $j['contact_id'], "Response did not give expected contact ID\n" . $formattedFailure); } + /** + * Opening $url may generate a session-cookie. Does that cookie authenticate you as $contactId? + * + * @param string $url + * @param int|null $contactId + * @return void + * @throws \GuzzleHttp\Exception\GuzzleException + */ + protected function assertUrlStartsSession(string $url, ?int $contactId): void { + $http = $this->createGuzzle([ + 'http_errors' => FALSE, + 'cookies' => new \GuzzleHttp\Cookie\CookieJar(), + ]); + $response = $http->get($url); + $r = (string) $response->getBody(); + $this->assertStatusCode(200, $response); + + // We make another request in the same session. Is it the expected contact? + $response = $http->get('civicrm/authx/id'); + $this->assertContactJson($contactId, $response); + } + + protected function callApi4AuthTokenSuccess(array $auth, string $entity, string $action, $params = []) { + $response = $this->callApi4AuthToken($auth, $entity, $action, $params); + $this->assertContentType('application/json', $response); + $this->assertStatusCode(200, $response); + $result = json_decode((string) $response->getBody(), 1); + if (json_last_error() !== JSON_ERROR_NONE) { + $this->fail("Failed to decode APIv4 JSON.\n" . $this->formatFailure($response)); + } + return $result; + } + + protected function callApi4AuthTokenFailure(array $auth, string $entity, string $action, $params = []): string { + $httpResponse = $this->callApi4AuthToken($auth, $entity, $action, $params); + $this->assertEquals(401, $httpResponse->getStatusCode(), "HTTP status code should be 401"); + return (string) $httpResponse->getBody(); + } + + /** + * @param array $auth + * @param string $entity + * @param string $action + * @param array $params + * + * @return \Psr\Http\Message\ResponseInterface + * @throws \GuzzleHttp\Exception\GuzzleException + */ + protected function callApi4AuthToken(array $auth, string $entity, string $action, array $params = []): \Psr\Http\Message\ResponseInterface { + $http = $this->createGuzzle(['http_errors' => FALSE]); + $method = str_starts_with($action, 'get') ? 'GET' : 'POST'; + + $response = $http->request($method, "civicrm/ajax/api4/$entity/$action", [ + 'headers' => ['X-Requested-With' => 'XMLHttpRequest'], + // This should probably be 'form_params', but 'query' is more representative of frontend. + ($method === 'GET' ? 'query' : 'form_params') => array_merge(['params' => json_encode($params)], $auth), + 'http_errors' => FALSE, + ]); + return $response; + } + } diff --git a/ext/authx/Civi/Authx/Authenticator.php b/ext/authx/Civi/Authx/Authenticator.php index 246d62bdff47..88fbe2751d58 100644 --- a/ext/authx/Civi/Authx/Authenticator.php +++ b/ext/authx/Civi/Authx/Authenticator.php @@ -125,6 +125,7 @@ public function auth($e, $details) { 'cred' => $details['cred'] ?? NULL, 'siteKey' => $details['siteKey'] ?? NULL, 'useSession' => $details['useSession'] ?? FALSE, + 'requestPath' => empty($e->args) ? '*' : implode('/', $e->args), ]); if (isset($tgt->cred)) { @@ -161,6 +162,7 @@ public function validate(array $details): array { 'cred' => $details['cred'] ?? NULL, 'siteKey' => $details['siteKey'] ?? NULL, 'useSession' => $details['useSession'] ?? FALSE, + 'requestPath' => $details['requestPath'] ?? '*', ]); if ($principal = $this->checkCredential($tgt)) { @@ -186,7 +188,7 @@ protected function checkCredential($tgt) { // 1. Accept the cred, which stops event propagation and further checks; // 2. Reject the cred, which stops event propagation and further checks; // 3. Neither accept nor reject, letting the event continue on to the next. - $checkEvent = new CheckCredentialEvent($tgt->cred); + $checkEvent = new CheckCredentialEvent($tgt->cred, $tgt->requestPath); \Civi::dispatcher()->dispatch('civi.authx.checkCredential', $checkEvent); if ($checkEvent->getRejection()) { @@ -343,6 +345,12 @@ class AuthenticatorTarget { */ public $flow; + /** + * @var string|null + * Ex: 'civicrm/dashboard' + */ + public $requestPath; + /** * @var bool */ @@ -396,7 +404,13 @@ class AuthenticatorTarget { * @return $this */ public static function create($args = []) { - return (new static())->set($args); + $tgt = (new static())->set($args); + if ($tgt->useSession || $tgt->requestPath === NULL) { + // If requesting access to a session (or using anything that isn't specifically tied + // to an HTTP route), then we are effectively asking for any/all routes. + $tgt->requestPath = '*'; + } + return $tgt; } /** @@ -470,6 +484,7 @@ public function createRedacted(): array { // omit: cred // omit: siteKey 'flow' => $this->flow, + 'requestPath' => $this->requestPath, 'credType' => $this->credType, 'jwt' => $this->jwt, 'useSession' => $this->useSession, diff --git a/ext/authx/Civi/Authx/CheckCredentialEvent.php b/ext/authx/Civi/Authx/CheckCredentialEvent.php index 87379f7e816e..a3475edbfcc1 100644 --- a/ext/authx/Civi/Authx/CheckCredentialEvent.php +++ b/ext/authx/Civi/Authx/CheckCredentialEvent.php @@ -31,6 +31,16 @@ class CheckCredentialEvent extends \Civi\Core\Event\GenericHookEvent { */ public $credValue; + /** + * @var string + * Ex: 'civicrm/dashboard' or '*' + * + * This identifies the path(s) that the requestor wants to access. + * For a stateless HTTP request, that's a specific path. + * For stateful HTTP session or CLI pipe, that's a wildcard. + */ + protected $requestPath; + /** * Authenticated principal. * @@ -49,9 +59,16 @@ class CheckCredentialEvent extends \Civi\Core\Event\GenericHookEvent { /** * @param string $cred * Ex: 'Basic ABCD1234' or 'Bearer ABCD1234' + * @param string $requestPath + * Ex: 'civicrm/dashboard' or '*' + * + * This identifies the path(s) that the requestor wants to access. + * For a stateless HTTP request, that's a specific path. + * For stateful HTTP session or CLI pipe, that's a wildcard. */ - public function __construct(string $cred) { + public function __construct(string $cred, string $requestPath) { [$this->credFormat, $this->credValue] = explode(' ', $cred, 2); + $this->requestPath = $requestPath; } /** @@ -123,4 +140,12 @@ public function getRejection(): ?string { return $this->rejection; } + /** + * @return string + * Ex: 'civicrm/dashboard' + */ + public function getRequestPath(): string { + return $this->requestPath; + } + }