diff --git a/Civi/Core/AssetBuilder.php b/Civi/Core/AssetBuilder.php index c799200d0f18..69a4a57d7850 100644 --- a/Civi/Core/AssetBuilder.php +++ b/Civi/Core/AssetBuilder.php @@ -7,6 +7,7 @@ /** * Class AssetBuilder * @package Civi\Core + * @service asset_builder * * The AssetBuilder is used to manage semi-dynamic assets. * In normal production use, these assets are built on first @@ -70,7 +71,7 @@ * secure it (e.g. alternative digest() calculations), but the * current implementation is KISS. */ -class AssetBuilder { +class AssetBuilder extends \Civi\Core\Service\AutoService { /** * @return array @@ -137,9 +138,14 @@ public function getUrl($name, $params = []) { } else { return \CRM_Utils_System::url('civicrm/asset/builder', [ + // The 'an' and 'ad' provide hints for cache lifespan and debugging/inspection. 'an' => $name, - 'ap' => $this->encode($params), 'ad' => $this->digest($name, $params), + 'aj' => \Civi::service('crypto.jwt')->encode([ + 'asset' => [$name, $params], + 'exp' => 86400 * (floor(\CRM_Utils_Time::time() / 86400) + 2), + // Caching-friendly TTL -- We want the URL to be stable for a decent amount of time. + ], ['SIGN', 'WEAK_SIGN']), ], TRUE, NULL, FALSE); } } @@ -280,7 +286,6 @@ protected function getCacheUrl($fileName = NULL) { * @return string */ protected function digest($name, $params) { - // WISHLIST: For secure digest, generate+persist privatekey & call hash_hmac. ksort($params); $digest = md5( $name . @@ -291,40 +296,6 @@ protected function digest($name, $params) { return $digest; } - /** - * Encode $params in a format that's optimized for shorter URLs. - * - * @param array $params - * @return string - */ - protected function encode($params) { - if (empty($params)) { - return ''; - } - - $str = json_encode($params); - if (function_exists('gzdeflate')) { - $str = gzdeflate($str); - } - return base64_encode($str); - } - - /** - * @param string $str - * @return array - */ - protected function decode($str) { - if ($str === NULL || $str === FALSE || $str === '') { - return []; - } - - $str = base64_decode($str); - if (function_exists('gzdeflate')) { - $str = gzinflate($str); - } - return json_decode($str, TRUE); - } - /** * @return bool */ @@ -371,16 +342,9 @@ public static function pageRender($get) { /** @var Assetbuilder $assets */ $assets = \Civi::service('asset_builder'); - $expectDigest = $assets->digest($get['an'], $assets->decode($get['ap'])); - if ($expectDigest !== $get['ad']) { - return [ - 'statusCode' => 500, - 'mimeType' => 'text/plain', - 'content' => 'Invalid digest', - ]; - } - - return $assets->render($get['an'], $assets->decode($get['ap'])); + $obj = \Civi::service('crypto.jwt')->decode($get['aj'], ['SIGN', 'WEAK_SIGN']); + $arr = json_decode(json_encode($obj), TRUE); + return $assets->render($arr['asset'][0], $arr['asset'][1]); } catch (UnknownAssetException $e) { return [ diff --git a/Civi/Core/Compiler/AutoServiceScannerPass.php b/Civi/Core/Compiler/AutoServiceScannerPass.php new file mode 100644 index 000000000000..957a50ef2d85 --- /dev/null +++ b/Civi/Core/Compiler/AutoServiceScannerPass.php @@ -0,0 +1,25 @@ + AutoServiceInterface::class]); + foreach ($autoServices as $autoService) { + $autoService::buildContainer($container); + } + } + +} diff --git a/Civi/Core/Container.php b/Civi/Core/Container.php index 6da903c0d551..8e060b3c4617 100644 --- a/Civi/Core/Container.php +++ b/Civi/Core/Container.php @@ -1,12 +1,14 @@ set(self::SELF, $this); $container->addResource(new \Symfony\Component\Config\Resource\FileResource(__FILE__)); + $container->addCompilerPass(new AutoServiceScannerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 1000); $container->setDefinition(self::SELF, new Definition( 'Civi\Core\Container', diff --git a/Civi/Core/Service/AutoDefinition.php b/Civi/Core/Service/AutoDefinition.php new file mode 100644 index 000000000000..5ab8c02d5b26 --- /dev/null +++ b/Civi/Core/Service/AutoDefinition.php @@ -0,0 +1,199 @@ + new Definition('My\Class')] + */ + public static function scan(string $className): array { + $class = new \ReflectionClass($className); + $result = []; + + $classDoc = ReflectionUtils::parseDocBlock($class->getDocComment()); + if (!empty($classDoc['service'])) { + $serviceName = static::pickName($classDoc, $class->getName()); + $def = static::createBaseline($class, $classDoc); + self::applyConstructor($def, $class); + $result[$serviceName] = $def; + } + + foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC | \ReflectionMethod::IS_STATIC) as $method) { + /** @var \ReflectionMethod $method */ + $methodDoc = ReflectionUtils::parseDocBlock($method->getDocComment()); + if (!empty($methodDoc['service'])) { + $serviceName = static::pickName($methodDoc, $class->getName() . '::' . $method->getName()); + $returnClass = isset($methodDoc['return'][0]) ? new \ReflectionClass($methodDoc['return'][0]) : $class; + $def = static::createBaseline($returnClass, $methodDoc); + $def->setFactory($class->getName() . '::' . $method->getName()); + $def->setArguments(static::toReferences($methodDoc['inject'] ?? '')); + $result[$serviceName] = $def; + } + } + + if (count($result) === 0) { + error_log("WARNING: Class {$class->getName()} was expected to have a service definition, but it did not. Perhaps it needs service name."); + } + + return $result; + } + + /** + * Create a basic definition for an unnamed service. + * + * @param string $className + * The name of the class to scan. Look for `@inject` and `@service` annotations. + * @return \Symfony\Component\DependencyInjection\Definition + */ + public static function create(string $className): Definition { + $class = new \ReflectionClass($className); + $classDoc = ReflectionUtils::parseDocBlock($class->getDocComment()); + $def = static::createBaseline($class, $classDoc); + static::applyConstructor($def, $class); + return $def; + } + + protected static function pickName(array $docBlock, string $internalDefault): string { + if (is_string($docBlock['service'])) { + return $docBlock['service']; + } + if (!empty($docBlock['internal']) && $internalDefault) { + return $internalDefault; + } + throw new \RuntimeException("Error: Failed to determine service name ($internalDefault). Please specify '@service NAME' or '@internal'."); + } + + protected static function createBaseline(\ReflectionClass $class, ?array $docBlock = []): Definition { + $class = is_string($class) ? new \ReflectionClass($class) : $class; + $def = new Definition($class->getName()); + $def->setPublic(TRUE); + self::applyTags($def, $class, $docBlock); + self::applyObjectProperties($def, $class); + self::applyObjectMethods($def, $class); + return $def; + } + + protected static function toReferences(string $injectExpr): array { + return array_map( + function (string $part) { + return new Reference($part); + }, + static::splitSymbols($injectExpr) + ); + } + + protected static function splitSymbols(string $expr): array { + if ($expr === '') { + return []; + } + $extraTags = explode(',', $expr); + return array_map('trim', $extraTags); + } + + /** + * @param \Symfony\Component\DependencyInjection\Definition $def + * @param \ReflectionClass $class + * @param array $docBlock + */ + protected static function applyTags(Definition $def, \ReflectionClass $class, array $docBlock): void { + if (!empty($docBlock['internal'])) { + $def->addTag('internal'); + } + if ($class->implementsInterface(HookInterface::class) || $class->implementsInterface(EventSubscriberInterface::class)) { + $def->addTag('event_subscriber'); + } + if ($class->implementsInterface(SpecProviderInterface::class)) { + $def->addTag('spec_provider'); + } + + if (!empty($classDoc['serviceTags'])) { + foreach (static::splitSymbols($classDoc['serviceTags']) as $extraTag) { + $def->addTag($extraTag); + } + } + } + + /** + * @param \Symfony\Component\DependencyInjection\Definition $def + * @param \ReflectionClass $class + */ + protected static function applyConstructor(Definition $def, \ReflectionClass $class): void { + if ($construct = $class->getConstructor()) { + $constructAnno = ReflectionUtils::parseDocBlock($construct->getDocComment() ?? ''); + if (!empty($constructAnno['inject'])) { + $def->setArguments(static::toReferences($constructAnno['inject'])); + } + } + } + + /** + * Scan for any methods with `@inject`. They should be invoked via `$def->addMethodCall()`. + * + * @param \Symfony\Component\DependencyInjection\Definition $def + * @param \ReflectionClass $class + */ + protected static function applyObjectMethods(Definition $def, \ReflectionClass $class): void { + foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + /** @var \ReflectionMethod $method */ + if ($method->isStatic()) { + continue; + } + + $anno = ReflectionUtils::parseDocBlock($method->getDocComment()); + if (!empty($anno['inject'])) { + $def->addMethodCall($method->getName(), static::toReferences($anno['inject'])); + } + } + } + + /** + * Scan for any properties with `@inject`. They should be configured via `$def->setProperty()` + * or via `injectPrivateProperty()`. + * + * @param \Symfony\Component\DependencyInjection\Definition $def + * @param \ReflectionClass $class + * @throws \Exception + */ + protected static function applyObjectProperties(Definition $def, \ReflectionClass $class): void { + foreach ($class->getProperties() as $property) { + /** @var \ReflectionProperty $property */ + if ($property->isStatic()) { + continue; + } + + $propDoc = ReflectionUtils::getCodeDocs($property); + if (!empty($propDoc['inject'])) { + if ($propDoc['inject'] === TRUE) { + $propDoc['inject'] = $property->getName(); + } + if ($property->isPublic()) { + $def->setProperty($property->getName(), new Reference($propDoc['inject'])); + } + elseif ($class->hasMethod('injectPrivateProperty')) { + $def->addMethodCall('injectPrivateProperty', [$property->getName(), new Reference($propDoc['inject'])]); + } + else { + throw new \Exception(sprintf('Property %s::$%s is marked private. To inject services into private properties, you must implement method "injectPrivateProperty($key, $value)".', + $class->getName(), $property->getName() + )); + } + } + } + } + +} diff --git a/Civi/Core/Service/AutoService.php b/Civi/Core/Service/AutoService.php new file mode 100644 index 000000000000..cc0a3a54964f --- /dev/null +++ b/Civi/Core/Service/AutoService.php @@ -0,0 +1,41 @@ +`: Customize the service name. + * - `@serviceTags `: Declare additional tags for the service. + * - Property annotations + * - `@inject []`: Inject another service automatically (by assigning this property). + * If the '' is blank, then it loads an eponymous service. + * - Method annotations + * - (TODO) `@inject `: Inject another service automatically (by calling the setter-method). + * + * Note: Like other services in the container, AutoService cannot meaningfully subscribe to + * early/boot-critical events such as `hook_entityTypes` or `hook_container`. However, you may + * get a similar effect by customizing the `buildContainer()` method. + */ +abstract class AutoService implements AutoServiceInterface { + + use AutoServiceTrait; + +} diff --git a/Civi/Core/Service/AutoServiceInterface.php b/Civi/Core/Service/AutoServiceInterface.php new file mode 100644 index 000000000000..b1319e975e2f --- /dev/null +++ b/Civi/Core/Service/AutoServiceInterface.php @@ -0,0 +1,31 @@ +getFileName(); + $container->addResource(new \Symfony\Component\Config\Resource\FileResource($file)); + foreach (AutoDefinition::scan(static::class) as $id => $definition) { + $container->setDefinition($id, $definition); + } + } + + /** + * (Internal) Utility method used to `@inject` data into private properties. + * + * @param string $key + * @param mixed $value + * @internal + */ + final public function injectPrivateProperty(string $key, $value): void { + // "final": There is no need to override. If you want a custom assignment logic, then put `@inject` on your setter method. + + $this->{$key} = $value; + } + +} diff --git a/Civi/Crypto/CryptoRegistry.php b/Civi/Crypto/CryptoRegistry.php index 5c6fd958da83..b45927c8fcb5 100644 --- a/Civi/Crypto/CryptoRegistry.php +++ b/Civi/Crypto/CryptoRegistry.php @@ -84,6 +84,31 @@ public static function createDefaultRegistry(): CryptoRegistry { $registry->addSymmetricKey($registry->parseKey($keyExpr) + $key); } } + else { + // If you are upgrading an old site that does not have a signing key, then there is a status-check advising you to fix it. + // But apparently the current site hasn't fixed it yet. The UI+AssetBuilder need to work long enough for sysadmin to discover/resolve. + // This fallback is sufficient for short-term usage in limited scenarios (AssetBuilder=>OK; AuthX=>No). + // In a properly configured system, the WEAK_SIGN key is strictly unavailable - s.t. a normal site never uses WEAK_SIGN. + $registry->addSymmetricKey([ + 'tags' => ['WEAK_SIGN'], + 'suite' => 'jwt-hs256', + 'key' => hash_hkdf('sha256', + json_encode([ + // DSN's and site-keys should usually be sufficient, but it's not strongly guaranteed, + // so we'll toss in more spaghetti. (At a minimum, this should mitigate bots/crawlers.) + \CRM_Utils_Constant::value('CIVICRM_DSN'), + \CRM_Utils_Constant::value('CIVICRM_UF_DSN'), + \CRM_Utils_Constant::value('CIVICRM_SITE_KEY') ?: $GLOBALS['civicrm_root'], + \CRM_Utils_Constant::value('CIVICRM_UF_BASEURL'), + \CRM_Utils_Constant::value('CIVICRM_DB_CACHE_PASSWORD'), + \CRM_Utils_System::getSiteID(), + \CRM_Utils_System::version(), + \CRM_Core_Config::singleton()->userSystem->getVersion(), + $_SERVER['HTTP_HOST'] ?? '', + ]) + ), + ]); + } //if (isset($_COOKIE['CIVICRM_FORM_KEY'])) { // $crypto->addSymmetricKey([ @@ -243,14 +268,15 @@ public function findKey($keyIds) { /** * Find all the keys that apply to a tag. * - * @param string $keyTag + * @param string|string[] $keyTag * * @return array * List of keys, indexed by id, ordered by weight. */ public function findKeysByTag($keyTag) { + $keyTag = (array) $keyTag; $keys = array_filter($this->keys, function ($key) use ($keyTag) { - return in_array($keyTag, $key['tags'] ?? []); + return !empty(array_intersect($keyTag, $key['tags'] ?? [])); }); uasort($keys, function($a, $b) { return ($a['weight'] ?? 0) - ($b['weight'] ?? 0); @@ -287,7 +313,7 @@ public function findSuite($name) { * @throws CryptoException */ public function parseKey($keyExpr) { - list($suite, $keyFunc, $keyVal) = explode(':', $keyExpr); + [$suite, $keyFunc, $keyVal] = explode(':', $keyExpr); if ($suite === '') { $suite = self::DEFAULT_SUITE; }