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 @@
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; + } + }