Merge remote-tracking branch 'ejegg/API4'

This commit is contained in:
Jens Schuppe 2024-06-18 12:08:30 +02:00
commit acaf9e7477
6 changed files with 375 additions and 56 deletions

6
proxy/.htaccess Normal file
View File

@ -0,0 +1,6 @@
# Serve
<IfModule mod_rewrite.c>
RewriteEngine on
RewriteCond %{REQUEST_URI} ^/civicrm/ajax/api4
RewriteRule ^civicrm/ajax/api4/([^/]*)/([^/]*) rest4.php?entity=$1&action=$2 [QSA,B]
</IfModule>

82
proxy/checks.php Normal file
View File

@ -0,0 +1,82 @@
<?php
/**
* generates a CiviCRM REST API compliant error
* and ends processing
*/
function civiproxy_rest_error($message) {
$error = array( 'is_error' => 1,
'error_message' => $message);
// TODO: Implement header();
print json_encode($error);
exit();
}
/**
* Updates $credentials['api_key'] in-place, or displays an error if api key
* is missing or does not correspond to an entry in $api_key_map (which should
* be set in config.php).
* @param array $credentials
* @param array $api_key_map
*/
function civiproxy_map_api_key(array &$credentials, array $api_key_map) {
if (empty($credentials['api_key'])) {
civiproxy_rest_error("No API key given");
}
else {
if (isset($api_key_map[$credentials['api_key']])) {
$credentials['api_key'] = $api_key_map[$credentials['api_key']];
}
else {
civiproxy_rest_error("Invalid api key");
}
}
}
/**
* Updates $credentials['key'] in-place, or displays an error if site key
* is missing or does not correspond to an entry in $sys_key_map (which should
* be set in config.php).
* @param array $credentials
* @param array $sys_key_map
*/
function civiproxy_map_site_key(array &$credentials, array $sys_key_map) {
if (empty($credentials['key'])) {
civiproxy_rest_error("No site key given");
}
else {
if (isset($sys_key_map[$credentials['key']])) {
$credentials['key'] = $sys_key_map[$credentials['key']];
}
else {
civiproxy_rest_error("Invalid site key");
}
}
}
/**
* @param array $action should have both 'entity' and 'action' keys set
* @param array $rest_allowed_actions from config.php
* @return array
*/
function civiproxy_get_valid_parameters(array $action, array $rest_allowed_actions) {
// in release 0.4, allowed entity/actions per IP were introduced. To introduce backward compatibility,
// the previous test is still used when no 'all' key is found in the array
if (isset($rest_allowed_actions['all'])) {
// get valid key for the rest_allowed_actions
$valid_allowed_key = civiproxy_get_valid_allowed_actions_key($action, $rest_allowed_actions);
$valid_parameters = civiproxy_retrieve_api_parameters($valid_allowed_key, $action['entity'], $action['action'], $rest_allowed_actions);
if (!$valid_parameters) {
civiproxy_rest_error("Invalid entity/action.");
}
}
else {
if (isset($rest_allowed_actions[$action['entity']]) && isset($rest_allowed_actions[$action['entity']][$action['action']])) {
$valid_parameters = $rest_allowed_actions[$action['entity']][$action['action']];
}
else {
civiproxy_rest_error("Invalid entity/action.");
}
}
return $valid_parameters;
}

View File

@ -41,6 +41,8 @@ $target_civicrm = 'https://your.civicrm.installation.org';
// default paths, override if you want. Set to NULL to disable
$target_rest = $target_civicrm . '/sites/all/modules/civicrm/extern/rest.php';
// base URL for api4 calls. Will append entity and action path segments
$target_rest4 = $target_civicrm . '/civicrm/ajax/api4/';
$target_file = $target_civicrm . '/sites/default/files/civicrm/persist/';
$target_mosaico = NULL; // (disabled by default): $target_civicrm . '/civicrm/mosaico/img?src=';
$target_mosaico_template_url = NULL; // (disabled by default): $target_civicrm . '/wp-content/uploads/civicrm/ext/uk.co.vedaconsulting.mosaico/packages/mosaico/templates/';
@ -75,6 +77,10 @@ $debug = NULL; //'LUXFbiaoz4dVWuAHEcuBAe7YQ4YP96rN4MCDmKj89
// This is useful in some VPN configurations (see CURLOPT_INTERFACE)
$target_interface = NULL;
/***************************************************************
** Authentication Options **
***************************************************************/
// API and SITE keys (you may add keys here)
$api_key_map = [
'my_api_key' => 'my_api_key', // use this to allow API key
@ -91,6 +97,19 @@ if (file_exists(dirname(__FILE__)."/secrets.php")) {
require "secrets.php";
}
// CiviCRM's API can authenticate with different flows
// https://docs.civicrm.org/dev/en/latest/framework/authx/#flows
// CiviProxy supports 'header', 'xheader', 'legacyrest', and 'param'.
// These flows are supported for API4 but could be extended to API3.
// $authx_internal_flow controls how CiviProxy sends credentials to CiviCRM, and
// $authx_external_flow where CiviProxy looks for credentials on incoming requests.
// The internal setting needs to have a single scalar value, but the
// external setting can be an array of accepted flows.
// There is no standard header for site key, so in both header and xheader
// flows it uses X-Civi-Key
$authx_internal_flow = 'header';
$authx_external_flow = ['legacyrest'];
/****************************************************************
** File Caching Options **

View File

@ -90,6 +90,148 @@ function civiproxy_redirect($url_requested, $parameters) {
curl_close ($curlSession);
}
/**
* this will redirect the request to an API4 URL,
* i.e. will pass the reply on to this request
*
* @see losely based on https://code.google.com/p/php-proxy/
*
* @param $url_requested string the URL to which the request should be sent
* @param $parameters array
* @param $credentials array
*/
function civiproxy_redirect4($url_requested, $parameters, $credentials) {
global $target_interface, $authx_internal_flow;
$url = $url_requested;
$curlSession = curl_init();
$credential_params = civiproxy_build_credential_params($credentials, $authx_internal_flow);
$credential_headers = civiproxy_build_credential_headers($credentials, $authx_internal_flow);
if ($_SERVER['REQUEST_METHOD'] == 'POST'){
// POST requests should be passed on as POST
curl_setopt($curlSession, CURLOPT_POST, 1);
$urlparams = 'params=' . urlencode(json_encode($parameters)) . $credential_params;
curl_setopt($curlSession, CURLOPT_POSTFIELDS, $urlparams);
} else {
// GET requests will get the parameters as url params
if (!empty($parameters)) {
$url .= '?params=' . urlencode(json_encode($parameters)) . $credential_params;
}
}
curl_setopt($curlSession, CURLOPT_HTTPHEADER, array_merge([
'Content-Type: application/x-www-form-urlencoded'
], $credential_headers));
curl_setopt($curlSession, CURLOPT_URL, $url);
curl_setopt($curlSession, CURLOPT_HEADER, 1);
curl_setopt($curlSession, CURLOPT_RETURNTRANSFER,1);
curl_setopt($curlSession, CURLOPT_TIMEOUT, 30);
curl_setopt($curlSession, CURLOPT_SSL_VERIFYHOST, 2);
if (!empty($target_interface)) {
curl_setopt($curlSession, CURLOPT_INTERFACE, $target_interface);
}
if (file_exists(dirname(__FILE__).'/target.pem')) {
curl_setopt($curlSession, CURLOPT_CAINFO, dirname(__FILE__).'/target.pem');
}
//Send the request and store the result in an array
$response = curl_exec($curlSession);
// Check that a connection was made
if (curl_error($curlSession)){
civiproxy_http_error(curl_error($curlSession), curl_errno($curlSession));
} else {
//clean duplicate header that seems to appear on fastcgi with output buffer on some servers!!
$response = str_replace("HTTP/1.1 100 Continue\r\n\r\n","",$response);
// split header / content
$content = explode("\r\n\r\n", $response, 2);
$header = $content[0];
$body = $content[1];
// handle headers - simply re-outputing them
$header_ar = explode(chr(10), $header);
foreach ($header_ar as $header_line){
if (!preg_match("/^Transfer-Encoding/", $header_line)){
civiproxy_mend_URLs($header_line);
header(trim($header_line));
}
}
//rewrite all hard coded urls to ensure the links still work!
civiproxy_mend_URLs($body);
print $body;
}
curl_close($curlSession);
}
/**
* Creates a string with the API credentials to be appended to an API4 GET or POST request.
* When $api4_internal_auth_flow is 'header' or 'xheader', returns a blank string
*
* @param array $credentials
* @param string $authx_internal_flow
* @return string credential string, including leading '&'
*/
function civiproxy_build_credential_params(array $credentials, string $authx_internal_flow): string {
switch($authx_internal_flow) {
case 'legacyrest':
$map = ['api_key' => 'api_key', 'key' => 'key'];
break;
case 'param':
$map = ['api_key' => '_authx', 'key' => '_authxSiteKey'];
break;
default:
return '';
}
$params = [];
foreach($map as $credential_key => $param_name) {
if (isset($credentials[$credential_key])) {
$credential_value = $credentials[$credential_key];
if ($param_name === '_authx') {
$credential_value = 'Bearer ' . $credential_value;
}
$params[$param_name] = $credential_value;
}
}
$param_string = http_build_query($params);
if (!empty($param_string)) {
$param_string = '&' . $param_string;
}
return $param_string;
}
/**
* Builds an array of headers to send on an API4 request. When $api4_internal_auth_flow
* is 'param' or 'legacyrest', will always return an empty array.
*
* @param array $credentials
* @param string $authx_internal_flow
* @return array
*/
function civiproxy_build_credential_headers(array $credentials, string $authx_internal_flow): array {
switch($authx_internal_flow) {
case 'header':
$map = ['api_key' => 'Authorization: Bearer', 'key' => 'X-Civi-Key:'];
break;
case 'xheader':
$map = ['api_key' => 'X-Civi-Auth: Bearer', 'key' => 'X-Civi-Key:'];
break;
default:
return [];
}
$headers = [];
foreach($map as $credential_key => $header_prefix) {
if (isset($credentials[$credential_key])) {
$headers[] = $header_prefix . ' ' . $credentials[$credential_key];
}
}
return $headers;
}
/**
* Will mend all the URLs in the string that point to the target,
@ -131,11 +273,12 @@ function civiproxy_mend_URLs(&$string) {
* unauthorized access quantities, etc.
*
* @param $target
* @param $quit if TRUE, quit immediately if access denied
* @param $quit bool if TRUE, quit immediately if access denied
* @param $log_headers array add these headers (sanitized) to log data
*
* @return TRUE if allowed, FALSE if not (or quits if $quit is set)
*/
function civiproxy_security_check($target, $quit=TRUE) {
function civiproxy_security_check($target, $quit=TRUE, $log_headers = []) {
// verify that we're SSL encrypted
if ($_SERVER['HTTPS'] != "on") {
civiproxy_http_error("This CiviProxy installation requires SSL encryption.", 400);
@ -145,11 +288,16 @@ function civiproxy_security_check($target, $quit=TRUE) {
if (!empty($debug)) {
// filter log data
$log_data = $_REQUEST;
if (isset($log_data['api_key'])) {
$log_data['api_key'] = substr($log_data['api_key'], 0, 4) . '...';
$sanitize_params = ['api_key', 'key', '_authxSiteKey', '_authx'];
foreach ($sanitize_params as $param) {
if (isset($log_data[$param])) {
$log_data[$param] = substr($log_data[$param], 0, 4) . '...';
}
}
if (isset($log_data['key'])) {
$log_data['key'] = substr($log_data['key'], 0, 4) . '...';
foreach($log_headers as $header) {
if (!empty($_SERVER[$header]))
$log_data[$header] = substr($_SERVER[$header], 0, 4) . '...';
}
// log
@ -205,7 +353,7 @@ function civiproxy_get_parameters($valid_parameters, $request = NULL) {
// process wildcard elements
if ($default_sanitation !== NULL) {
// i.e. we want the others too
$remove_parameters = array('key', 'api_key', 'version', 'entity', 'action');
$remove_parameters = array('key', 'api_key', '_authx', '_authxSiteKey', 'version', 'entity', 'action');
foreach ($request as $name => $value) {
if (!in_array($name, $remove_parameters) && !isset($valid_parameters[$name])) {
$result[$name] = civiproxy_sanitise($value, $default_sanitation);
@ -216,6 +364,26 @@ function civiproxy_get_parameters($valid_parameters, $request = NULL) {
return $result;
}
/**
* Get the value of a header on the incoming request
*
* @param string $header name of the header, in all uppercase
* @param string $prefix to be stripped off the value of the header
* @return string|null value of the header, or null if not found.
*/
function civiproxy_get_header($header, $prefix = ''): ?string {
if (!empty($_SERVER['HTTP_' . $header])) {
$value = $_SERVER['HTTP_' . $header];
if ($prefix === '') {
return $value;
}
if (strpos($value, $prefix) === 0) {
return trim(substr($value, strlen($prefix)));
}
}
return NULL;
}
/**
* sanitise the given value with the given sanitiation type
*/

View File

@ -9,11 +9,11 @@
require_once "config.php";
require_once "proxy.php";
require_once "checks.php";
// see if REST API is enabled
if (!$target_rest) civiproxy_http_error("Feature disabled", 405);
// basic check
if (!civiproxy_security_check('rest')) {
civiproxy_rest_error("Access denied.");
@ -21,25 +21,9 @@ if (!civiproxy_security_check('rest')) {
// check credentials
$credentials = civiproxy_get_parameters(array('key' => 'string', 'api_key' => 'string'));
if (empty($credentials['key'])) {
civiproxy_rest_error("No site key given");
} else {
if (isset($sys_key_map[$credentials['key']])) {
$credentials['key'] = $sys_key_map[$credentials['key']];
} else {
civiproxy_rest_error("Invalid site key");
}
}
if (empty($credentials['api_key'])) {
civiproxy_rest_error("No API key given");
} else {
if (isset($api_key_map[$credentials['api_key']])) {
$credentials['api_key'] = $api_key_map[$credentials['api_key']];
} else {
civiproxy_rest_error("Invalid api key");
}
}
civiproxy_map_site_key($credentials, $sys_key_map);
civiproxy_map_api_key($credentials, $api_key_map);
// check if the call itself is allowed
$action = civiproxy_get_parameters(array('entity' => 'string', 'action' => 'string', 'version' => 'int', 'json' => 'int', 'sequential' => 'int'));
@ -47,22 +31,7 @@ if (!isset($action['version']) || $action['version'] != 3) {
civiproxy_rest_error("API 'version' information missing.");
}
// in release 0.4, allowed entity/actions per IP were introduced. To introduce backward compatibility,
// the previous test is still used when no 'all' key is found in the array
if (isset($rest_allowed_actions['all'])) {
// get valid key for the rest_allowed_actions
$valid_allowed_key = civiproxy_get_valid_allowed_actions_key($action, $rest_allowed_actions);
$valid_parameters = civiproxy_retrieve_api_parameters($valid_allowed_key, $action['entity'], $action['action'], $rest_allowed_actions);
if (!$valid_parameters) {
civiproxy_rest_error("Invalid entity/action.");
}
} else {
if (isset($rest_allowed_actions[$action['entity']]) && isset($rest_allowed_actions[$action['entity']][$action['action']])) {
$valid_parameters = $rest_allowed_actions[$action['entity']][$action['action']];
} else {
civiproxy_rest_error("Invalid entity/action.");
}
}
$valid_parameters= civiproxy_get_valid_parameters($action, $rest_allowed_actions);
// extract parameters and add credentials and action data
$parameters = civiproxy_get_parameters($valid_parameters);
@ -88,17 +57,3 @@ if ($rest_evaluate_json_parameter) {
// finally execute query
civiproxy_log($target_rest);
civiproxy_redirect($target_rest, $parameters);
/**
* generates a CiviCRM REST API compliant error
* and ends processing
*/
function civiproxy_rest_error($message) {
$error = array( 'is_error' => 1,
'error_message' => $message);
// TODO: Implement
//header();
print json_encode($error);
exit();
}

89
proxy/rest4.php Normal file
View File

@ -0,0 +1,89 @@
<?php
/*--------------------------------------------------------+
| SYSTOPIA CiviProxy |
| a simple proxy solution for external access to CiviCRM |
| Copyright (C) 2015-2021 SYSTOPIA |
| Author: B. Endres (endres -at- systopia.de) |
| http://www.systopia.de/ |
+---------------------------------------------------------*/
require_once "config.php";
require_once "proxy.php";
require_once "checks.php";
// see if REST API is enabled
if (!$target_rest4) {
civiproxy_http_error("Feature disabled");
}
$valid_flows = ['header', 'xheader', 'legacyrest', 'param'];
$headers_by_flow = [
'header' => ['HTTP_AUTHORIZATION', 'HTTP_X_CIVI_KEY'],
'xheader' => ['HTTP_X_CIVI_AUTH', 'HTTP_X_CIVI_KEY'],
'legacyrest' => [],
'param' => [],
];
if (!in_array($authx_internal_flow, $valid_flows)) {
civiproxy_http_error("Invalid internal auth flow '$authx_internal_flow'", 500);
}
$headers_to_log = [];
foreach ($authx_external_flow as $external_flow) {
if (!in_array($external_flow, $valid_flows)) {
civiproxy_http_error("Invalid external auth flow '$external_flow'", 500);
}
$headers_to_log = array_merge($headers_to_log, $headers_by_flow[$external_flow]);
}
// basic check
if (!civiproxy_security_check('rest', TRUE, $headers_to_log)) {
civiproxy_rest_error("Access denied.");
}
$credentials = [];
// Find credentials on the incoming request
foreach ($authx_external_flow as $external_flow) {
switch($external_flow) {
case 'header':
$credentials['api_key'] = civiproxy_get_header('AUTHORIZATION', 'Bearer ');
$credentials['key'] = civiproxy_get_header('HTTP_X_CIVI_KEY');
break;
case 'xheader':
$credentials['api_key'] = civiproxy_get_header('X_CIVI_AUTH', 'Bearer ');
$credentials['key'] = civiproxy_get_header('HTTP_X_CIVI_KEY');
break;
case 'legacyrest':
$credentials = civiproxy_get_parameters(array('api_key' => 'string', 'key' => 'string'));
break;
case 'param':
$authx_credentials = civiproxy_get_parameters(array('_authx' => 'string', '_authxSiteKey' => 'string'));
if (!empty($authx_credentials['_authx'])) {
// Snip off leading 'Bearer ' or 'Bearer+'
if (substr($authx_credentials['_authx'], 0, 6) === 'Bearer') {
$credentials['api_key'] = substr($authx_credentials['_authx'], 7);
}
}
if (!empty($authx_credentials['_authxSiteKey'])) {
$credentials['key'] = $authx_credentials['_authxSiteKey'];
}
break;
}
if (!empty($credentials['api_key'])) {
break;
}
}
civiproxy_map_api_key($credentials, $api_key_map);
if (!empty($credentials['key'])) {
civiproxy_map_site_key( $credentials, $sys_key_map);
}
// check if the call itself is allowed
$action = civiproxy_get_parameters(array('entity' => 'string', 'action' => 'string'));
$valid_parameters = civiproxy_get_valid_parameters($action, $rest_allowed_actions);
// extract parameters and add action data
$parameters = civiproxy_get_parameters($valid_parameters, json_decode($_REQUEST['params'], true));
// finally execute query
civiproxy_log($target_rest4);
civiproxy_redirect4($target_rest4 . $action['entity'] . '/' . $action['action'] , $parameters, $credentials);