-
-
Notifications
You must be signed in to change notification settings - Fork 825
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #30585 from totten/master-page-token
(dev/core#4462) Afform - Add support for page-level authentication links
- Loading branch information
Showing
12 changed files
with
618 additions
and
83 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
<?php | ||
/* | ||
+--------------------------------------------------------------------+ | ||
| Copyright CiviCRM LLC. All rights reserved. | | ||
| | | ||
| This work is published under the GNU AGPLv3 license with some | | ||
| permitted exceptions and without any warranty. For full license | | ||
| and copyright information, see https://civicrm.org/licensing | | ||
+--------------------------------------------------------------------+ | ||
*/ | ||
|
||
use CRM_Afform_ExtensionUtil as E; | ||
|
||
/** | ||
* | ||
*/ | ||
class CRM_Afform_Utils { | ||
|
||
/** | ||
* Get a list of authentication options for `afform_mail_auth_token`. | ||
* | ||
* @return array | ||
* Array (string $machineName => string $label). | ||
*/ | ||
public static function getMailAuthOptions(): array { | ||
return [ | ||
'session' => E::ts('Session-level authentication'), | ||
'page' => E::ts('Page-level authentication'), | ||
]; | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,216 @@ | ||
<?php | ||
|
||
namespace Civi\Afform; | ||
|
||
use Civi\Authx\CheckCredentialEvent; | ||
use Civi\Core\Event\GenericHookEvent; | ||
use Civi\Core\Service\AutoService; | ||
use Civi\Crypto\Exception\CryptoException; | ||
use Symfony\Component\EventDispatcher\EventSubscriberInterface; | ||
|
||
/** | ||
* Allow Afform-based pages to accept page-level access token | ||
* | ||
* Example: | ||
* - Create a JWT with `[scope => 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 "<script type='text/javascript'>\n$script\n</script>"; | ||
}, | ||
]); | ||
} | ||
|
||
/** | ||
* 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, | ||
// ], | ||
]; | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
<?php | ||
use CRM_Afform_ExtensionUtil as E; | ||
|
||
return [ | ||
'afform_mail_auth_token' => [ | ||
'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]], | ||
], | ||
]; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,14 +1,8 @@ | ||
<!-- This example is entirely public; anonymous users may use it to submit a `Contact`, but they cannot view or modify data. --> | ||
<af-form ctrl="afform"> | ||
<af-entity data="{contact_type: 'Individual', source: 'Hello'}" url-autofill="1" security="FBAC" actions="{create: true, update: false}" type="Contact" name="me" label="Myself" /> | ||
<fieldset af-fieldset="me"> | ||
<legend class="af-text">Individual 1</legend> | ||
<div class="af-container"> | ||
<div class="af-container af-layout-inline"> | ||
<af-field name="first_name" /> | ||
<af-field name="last_name" /> | ||
</div> | ||
</div> | ||
<af-entity data="{contact_type: 'Individual', source: 'Mock Public Form'}" type="Contact" name="me" label="Myself" actions="{create: true, update: true}" security="FBAC" url-autofill="0" autofill="user" /> | ||
<fieldset af-fieldset="me" class="af-container" af-title="Myself"> | ||
<afblock-name-individual></afblock-name-individual> | ||
</fieldset> | ||
<button class="af-button btn-primary" crm-icon="fa-check" ng-click="afform.submit()">Submit</button> | ||
</af-form> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.