Skip to content

Commit

Permalink
Merge pull request #30585 from totten/master-page-token
Browse files Browse the repository at this point in the history
(dev/core#4462) Afform - Add support for page-level authentication links
  • Loading branch information
totten authored Sep 20, 2024
2 parents 5688d8a + a345655 commit 60130c2
Show file tree
Hide file tree
Showing 12 changed files with 618 additions and 83 deletions.
2 changes: 1 addition & 1 deletion CRM/Utils/GuzzleMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
32 changes: 32 additions & 0 deletions ext/afform/core/CRM/Afform/Utils.php
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'),
];
}

}
216 changes: 216 additions & 0 deletions ext/afform/core/Civi/Afform/PageTokenCredential.php
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,
// ],
];
}

}
53 changes: 40 additions & 13 deletions ext/afform/core/Civi/Afform/Tokens.php
Original file line number Diff line number Diff line change
Expand Up @@ -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...
}

}
2 changes: 2 additions & 0 deletions ext/afform/core/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
<mixin>smarty@1.0.0</mixin>
<mixin>entity-types-php@2.0.0</mixin>
<mixin>menu-xml@1.0.0</mixin>
<mixin>setting-php@1.0.0</mixin>
<mixin>setting-admin@1.0.1</mixin>
</mixins>
<upgrader>CiviMix\Schema\Afform\AutomaticUpgrader</upgrader>
</extension>
27 changes: 27 additions & 0 deletions ext/afform/core/settings/Afform.setting.php
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]],
],
];
12 changes: 3 additions & 9 deletions ext/afform/mock/ang/mockPublicForm.aff.html
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>
2 changes: 2 additions & 0 deletions ext/afform/mock/ang/mockPublicForm.aff.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];
Loading

0 comments on commit 60130c2

Please sign in to comment.