diff --git a/database/DoctrineMigrations/Version20220503092351.php b/database/DoctrineMigrations/Version20220503092351.php new file mode 100644 index 0000000000..6218a8a936 --- /dev/null +++ b/database/DoctrineMigrations/Version20220503092351.php @@ -0,0 +1,30 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE consent ADD attribute_stable VARCHAR(80) NOT NULL, CHANGE attribute attribute VARCHAR(80) DEFAULT NULL'); + } + + public function down(Schema $schema) : void + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE consent DROP attribute_stable, CHANGE attribute attribute VARCHAR(80) CHARACTER SET utf8 NOT NULL COLLATE `utf8_unicode_ci`'); + } +} diff --git a/library/EngineBlock/Application/DiContainer.php b/library/EngineBlock/Application/DiContainer.php index bf08ac5797..8270b749f7 100644 --- a/library/EngineBlock/Application/DiContainer.php +++ b/library/EngineBlock/Application/DiContainer.php @@ -152,7 +152,7 @@ public function getAuthenticationLoopGuard() } /** - * @return OpenConext\EngineBlock\Service\ConsentService + * @return OpenConext\EngineBlock\Service\Consent\ConsentService */ public function getConsentService() { diff --git a/library/EngineBlock/Corto/Model/Consent.php b/library/EngineBlock/Corto/Model/Consent.php index 96c013357d..014e225e47 100644 --- a/library/EngineBlock/Corto/Model/Consent.php +++ b/library/EngineBlock/Corto/Model/Consent.php @@ -16,8 +16,10 @@ * limitations under the License. */ +use OpenConext\EngineBlock\Authentication\Value\ConsentVersion; use OpenConext\EngineBlock\Metadata\Entity\ServiceProvider; use OpenConext\EngineBlock\Authentication\Value\ConsentType; +use OpenConext\EngineBlock\Service\Consent\ConsentHashServiceInterface; class EngineBlock_Corto_Model_Consent { @@ -36,15 +38,10 @@ class EngineBlock_Corto_Model_Consent */ private $_response; /** - * @var array + * @var array All attributes as an associative array. */ private $_responseAttributes; - /** - * @var EngineBlock_Database_ConnectionFactory - */ - private $_databaseConnectionFactory; - /** * A reflection of the eb.run_all_manipulations_prior_to_consent feature flag * @@ -60,63 +57,78 @@ class EngineBlock_Corto_Model_Consent private $_consentEnabled; /** - * @param string $tableName - * @param bool $mustStoreValues - * @param EngineBlock_Saml2_ResponseAnnotationDecorator $response - * @param array $responseAttributes - * @param EngineBlock_Database_ConnectionFactory $databaseConnectionFactory + * @var ConsentHashServiceInterface + */ + private $_hashService; + + /** * @param bool $amPriorToConsentEnabled Is the run_all_manipulations_prior_to_consent feature enabled or not - * @param bool $consentEnabled Is the feature_enable_consent feature enabled or not */ public function __construct( - $tableName, - $mustStoreValues, + string $tableName, + bool $mustStoreValues, EngineBlock_Saml2_ResponseAnnotationDecorator $response, array $responseAttributes, - EngineBlock_Database_ConnectionFactory $databaseConnectionFactory, - $amPriorToConsentEnabled, - $consentEnabled - ) - { + bool $amPriorToConsentEnabled, + bool $consentEnabled, + ConsentHashServiceInterface $hashService + ) { $this->_tableName = $tableName; $this->_mustStoreValues = $mustStoreValues; $this->_response = $response; $this->_responseAttributes = $responseAttributes; - $this->_databaseConnectionFactory = $databaseConnectionFactory; $this->_amPriorToConsentEnabled = $amPriorToConsentEnabled; + $this->_hashService = $hashService; $this->_consentEnabled = $consentEnabled; } - public function explicitConsentWasGivenFor(ServiceProvider $serviceProvider) + public function explicitConsentWasGivenFor(ServiceProvider $serviceProvider): bool { - return !$this->_consentEnabled || - $this->_hasStoredConsent($serviceProvider, ConsentType::TYPE_EXPLICIT); + if (!$this->_consentEnabled) { + return true; + } + $consent = $this->_hasStoredConsent($serviceProvider, ConsentType::TYPE_EXPLICIT); + return $consent->given(); + } + + /** + * Although the user has given consent previously we want to upgrade the deprecated unstable consent + * to the new stable consent type. + * https://www.pivotaltracker.com/story/show/176513931 + */ + public function upgradeAttributeHashFor(ServiceProvider $serviceProvider, string $consentType): void + { + $consentVersion = $this->_hasStoredConsent($serviceProvider, $consentType); + if ($consentVersion->isUnstable()) { + $this->_updateConsent($serviceProvider, $consentType); + } } - public function implicitConsentWasGivenFor(ServiceProvider $serviceProvider) + public function implicitConsentWasGivenFor(ServiceProvider $serviceProvider): bool { - return !$this->_consentEnabled || - $this->_hasStoredConsent($serviceProvider, ConsentType::TYPE_IMPLICIT); + if (!$this->_consentEnabled) { + return true; + } + $consent = $this->_hasStoredConsent($serviceProvider, ConsentType::TYPE_IMPLICIT); + return $consent->given(); } - public function giveExplicitConsentFor(ServiceProvider $serviceProvider) + public function giveExplicitConsentFor(ServiceProvider $serviceProvider): bool { return !$this->_consentEnabled || $this->_storeConsent($serviceProvider, ConsentType::TYPE_EXPLICIT); } - public function giveImplicitConsentFor(ServiceProvider $serviceProvider) + public function giveImplicitConsentFor(ServiceProvider $serviceProvider): bool { return !$this->_consentEnabled || $this->_storeConsent($serviceProvider, ConsentType::TYPE_IMPLICIT); } - /** - * @return bool|PDO - */ - protected function _getConsentDatabaseConnection() + public function countTotalConsent(): int { - return $this->_databaseConnectionFactory->create(); + $consentUid = $this->_getConsentUid(); + return $this->_hashService->countTotalConsent($consentUid); } protected function _getConsentUid() @@ -128,98 +140,51 @@ protected function _getConsentUid() return $this->_response->getNameIdValue(); } - protected function _getAttributesHash($attributes) + protected function _getAttributesHash($attributes): string { - $hashBase = NULL; - if ($this->_mustStoreValues) { - ksort($attributes); - $hashBase = serialize($attributes); - } else { - $names = array_keys($attributes); - sort($names); - $hashBase = implode('|', $names); - } - return sha1($hashBase); + return $this->_hashService->getUnstableAttributesHash($attributes, $this->_mustStoreValues); } - private function _storeConsent(ServiceProvider $serviceProvider, $consentType) + protected function _getStableAttributesHash($attributes): string { - $dbh = $this->_getConsentDatabaseConnection(); - if (!$dbh) { - return false; - } + return $this->_hashService->getStableAttributesHash($attributes, $this->_mustStoreValues); + } - $query = "INSERT INTO consent (hashed_user_id, service_id, attribute, consent_type, consent_date, deleted_at) - VALUES (?, ?, ?, ?, NOW(), '0000-00-00 00:00:00') - ON DUPLICATE KEY UPDATE attribute=VALUES(attribute), consent_type=VALUES(consent_type), consent_date=NOW()"; + private function _storeConsent(ServiceProvider $serviceProvider, $consentType): bool + { $parameters = array( sha1($this->_getConsentUid()), $serviceProvider->entityId, - $this->_getAttributesHash($this->_responseAttributes), + $this->_getStableAttributesHash($this->_responseAttributes), $consentType, ); - $statement = $dbh->prepare($query); - if (!$statement) { - throw new EngineBlock_Exception( - "Unable to create a prepared statement to insert consent?!", - EngineBlock_Exception::CODE_CRITICAL - ); - } - - /** @var $statement PDOStatement */ - if (!$statement->execute($parameters)) { - throw new EngineBlock_Corto_Module_Services_Exception( - sprintf('Error storing consent: "%s"', var_export($statement->errorInfo(), true)), - EngineBlock_Exception::CODE_CRITICAL - ); - } - return true; + return $this->_hashService->storeConsentHash($parameters); } - private function _hasStoredConsent(ServiceProvider $serviceProvider, $consentType) + private function _updateConsent(ServiceProvider $serviceProvider, $consentType): bool { - try { - $dbh = $this->_getConsentDatabaseConnection(); - if (!$dbh) { - return false; - } - - $attributesHash = $this->_getAttributesHash($this->_responseAttributes); - - $query = " - SELECT * - FROM {$this->_tableName} - WHERE hashed_user_id = ? - AND service_id = ? - AND attribute = ? - AND consent_type = ? - AND deleted_at IS NULL - "; - $hashedUserId = sha1($this->_getConsentUid()); - $parameters = array( - $hashedUserId, - $serviceProvider->entityId, - $attributesHash, - $consentType, - ); - - /** @var $statement PDOStatement */ - $statement = $dbh->prepare($query); - $statement->execute($parameters); - $rows = $statement->fetchAll(); - - if (count($rows) < 1) { - // No stored consent found - return false; - } + $parameters = array( + $this->_getStableAttributesHash($this->_responseAttributes), + $this->_getAttributesHash($this->_responseAttributes), + sha1($this->_getConsentUid()), + $serviceProvider->entityId, + $consentType, + ); - return true; - } catch (PDOException $e) { - throw new EngineBlock_Corto_ProxyServer_Exception( - sprintf('Consent retrieval failed! Error: "%s"', $e->getMessage()), - EngineBlock_Exception::CODE_ALERT - ); - } + return $this->_hashService->updateConsentHash($parameters); + } + + private function _hasStoredConsent(ServiceProvider $serviceProvider, $consentType): ConsentVersion + { + $consentUuid = sha1($this->_getConsentUid()); + $parameters = [ + $consentUuid, + $serviceProvider->entityId, + $this->_getAttributesHash($this->_responseAttributes), + $this->_getStableAttributesHash($this->_responseAttributes), + $consentType, + ]; + return $this->_hashService->retrieveConsentHash($parameters); } } diff --git a/library/EngineBlock/Corto/Model/Consent/Factory.php b/library/EngineBlock/Corto/Model/Consent/Factory.php index 80be173e81..d046b5428b 100644 --- a/library/EngineBlock/Corto/Model/Consent/Factory.php +++ b/library/EngineBlock/Corto/Model/Consent/Factory.php @@ -16,6 +16,8 @@ * limitations under the License. */ +use OpenConext\EngineBlock\Service\Consent\ConsentHashService; + /** * @todo write a test */ @@ -24,21 +26,20 @@ class EngineBlock_Corto_Model_Consent_Factory /** @var EngineBlock_Corto_Filter_Command_Factory */ private $_filterCommandFactory; - /** @var EngineBlock_Database_ConnectionFactory */ - private $_databaseConnectionFactory; - + /** + * @var ConsentHashService + */ + private $_hashService; - /** + /** * @param EngineBlock_Corto_Filter_Command_Factory $filterCommandFactory - * @param EngineBlock_Database_ConnectionFactory $databaseConnectionFactory */ public function __construct( EngineBlock_Corto_Filter_Command_Factory $filterCommandFactory, - EngineBlock_Database_ConnectionFactory $databaseConnectionFactory - ) - { + ConsentHashService $hashService + ) { $this->_filterCommandFactory = $filterCommandFactory; - $this->_databaseConnectionFactory = $databaseConnectionFactory; + $this->_hashService = $hashService; } /** @@ -68,9 +69,9 @@ public function create( $proxyServer->getConfig('ConsentStoreValues', true), $response, $attributes, - $this->_databaseConnectionFactory, $amPriorToConsent, - $consentEnabled + $consentEnabled, + $this->_hashService ); } } diff --git a/library/EngineBlock/Corto/Module/Service/ProcessConsent.php b/library/EngineBlock/Corto/Module/Service/ProcessConsent.php index 97cf0300b6..4dc3381efb 100644 --- a/library/EngineBlock/Corto/Module/Service/ProcessConsent.php +++ b/library/EngineBlock/Corto/Module/Service/ProcessConsent.php @@ -16,6 +16,7 @@ * limitations under the License. */ +use OpenConext\EngineBlock\Authentication\Value\ConsentType; use OpenConext\EngineBlock\Service\AuthenticationStateHelperInterface; use OpenConext\EngineBlock\Service\ProcessingStateHelperInterface; use SAML2\Constants; @@ -102,6 +103,7 @@ public function serve($serviceName, Request $httpRequest) if (!$consentRepository->explicitConsentWasGivenFor($serviceProvider)) { $consentRepository->giveExplicitConsentFor($destinationMetadata); } + $consentRepository->upgradeAttributeHashFor($destinationMetadata, ConsentType::TYPE_EXPLICIT); $response->setConsent(Constants::CONSENT_OBTAINED); $response->setDestination($response->getReturn()); diff --git a/library/EngineBlock/Corto/Module/Service/ProvideConsent.php b/library/EngineBlock/Corto/Module/Service/ProvideConsent.php index c41cd9bec5..51c241a043 100644 --- a/library/EngineBlock/Corto/Module/Service/ProvideConsent.php +++ b/library/EngineBlock/Corto/Module/Service/ProvideConsent.php @@ -16,10 +16,11 @@ * limitations under the License. */ +use OpenConext\EngineBlock\Authentication\Value\ConsentType; use OpenConext\EngineBlock\Metadata\Entity\IdentityProvider; use OpenConext\EngineBlock\Metadata\Entity\ServiceProvider; use OpenConext\EngineBlock\Service\AuthenticationStateHelperInterface; -use OpenConext\EngineBlock\Service\ConsentServiceInterface; +use OpenConext\EngineBlock\Service\Consent\ConsentServiceInterface; use OpenConext\EngineBlock\Service\ProcessingStateHelperInterface; use SAML2\Constants; use Symfony\Component\HttpFoundation\Request; @@ -136,6 +137,7 @@ public function serve($serviceName, Request $httpRequest) if (!$consentRepository->implicitConsentWasGivenFor($serviceProviderMetadata)) { $consentRepository->giveImplicitConsentFor($serviceProviderMetadata); } + $consentRepository->upgradeAttributeHashFor($serviceProviderMetadata, ConsentType::TYPE_IMPLICIT); $response->setConsent(Constants::CONSENT_INAPPLICABLE); $response->setDestination($response->getReturn()); @@ -153,6 +155,8 @@ public function serve($serviceName, Request $httpRequest) $priorConsent = $consentRepository->explicitConsentWasGivenFor($serviceProviderMetadata); if ($priorConsent) { + $consentRepository->upgradeAttributeHashFor($serviceProviderMetadata, ConsentType::TYPE_EXPLICIT); + $response->setConsent(Constants::CONSENT_PRIOR); $response->setDestination($response->getReturn()); diff --git a/src/OpenConext/EngineBlock/Authentication/Repository/ConsentRepository.php b/src/OpenConext/EngineBlock/Authentication/Repository/ConsentRepository.php index 709ba9a7e7..72ceada16a 100644 --- a/src/OpenConext/EngineBlock/Authentication/Repository/ConsentRepository.php +++ b/src/OpenConext/EngineBlock/Authentication/Repository/ConsentRepository.php @@ -19,6 +19,7 @@ namespace OpenConext\EngineBlock\Authentication\Repository; use OpenConext\EngineBlock\Authentication\Model\Consent; +use OpenConext\EngineBlock\Authentication\Value\ConsentVersion; interface ConsentRepository { @@ -37,4 +38,22 @@ public function findAllFor($userId); public function deleteAllFor($userId); public function deleteOneFor(string $userId, string $serviceProviderEntityId): bool; + + /** + * Test if the consent row is set with an attribute hash either stable or unstable + */ + public function hasConsentHash(array $parameters): ConsentVersion; + + /** + * By default stores the stable consent hash. The legacy consent hash is left. + */ + public function storeConsentHash(array $parameters): bool; + + /** + * When a deprecated unstable consent hash is encoutered, we upgrade it to the new format using this + * update consent hash method. + */ + public function updateConsentHash(array $parameters): bool; + + public function countTotalConsent($consentUid): int; } diff --git a/src/OpenConext/EngineBlock/Authentication/Value/ConsentVersion.php b/src/OpenConext/EngineBlock/Authentication/Value/ConsentVersion.php new file mode 100644 index 0000000000..1257140e8c --- /dev/null +++ b/src/OpenConext/EngineBlock/Authentication/Value/ConsentVersion.php @@ -0,0 +1,77 @@ +consentVersion = $consentVersion; + } + + public function given(): bool + { + return $this->consentVersion !== self::NOT_GIVEN; + } + + /** + * @return string + */ + public function __toString() + { + return $this->consentVersion; + } + + public function isUnstable(): bool + { + return $this->consentVersion === self::UNSTABLE; + } +} diff --git a/src/OpenConext/EngineBlock/Service/Consent/ConsentHashService.php b/src/OpenConext/EngineBlock/Service/Consent/ConsentHashService.php new file mode 100644 index 0000000000..cfe25b286e --- /dev/null +++ b/src/OpenConext/EngineBlock/Service/Consent/ConsentHashService.php @@ -0,0 +1,207 @@ +consentRepository = $consentHashRepository; + } + + public function retrieveConsentHash(array $parameters): ConsentVersion + { + return $this->consentRepository->hasConsentHash($parameters); + } + + public function storeConsentHash(array $parameters): bool + { + return $this->consentRepository->storeConsentHash($parameters); + } + + public function updateConsentHash(array $parameters): bool + { + return $this->consentRepository->updateConsentHash($parameters); + } + + public function countTotalConsent($consentUid): int + { + return $this->consentRepository->countTotalConsent($consentUid); + } + + /** + * The old way of calculating the attribute hash, this is not stable as a change of the attribute order, + * change of case, stray/empty attributes, and renumbered indexes can cause the hash to change. Leaving the + * user to give consent once again for a service she previously gave consent for. + */ + public function getUnstableAttributesHash(array $attributes, bool $mustStoreValues): string + { + if ($mustStoreValues) { + ksort($attributes); + $hashBase = serialize($attributes); + } else { + $names = array_keys($attributes); + sort($names); + $hashBase = implode('|', $names); + } + return sha1($hashBase); + } + + public function getStableAttributesHash(array $attributes, bool $mustStoreValues) : string + { + $nameIdNormalizedAttributes = $this->nameIdNormalize($attributes); + $lowerCasedAttributes = $this->caseNormalizeStringArray($nameIdNormalizedAttributes); + $hashBase = $mustStoreValues + ? $this->createHashBaseWithValues($lowerCasedAttributes) + : $this->createHashBaseWithoutValues($lowerCasedAttributes); + + return sha1($hashBase); + } + + private function createHashBaseWithValues(array $lowerCasedAttributes): string + { + return serialize($this->sortArray($lowerCasedAttributes)); + } + + private function createHashBaseWithoutValues(array $lowerCasedAttributes): string + { + $noEmptyAttributes = $this->removeEmptyAttributes($lowerCasedAttributes); + $sortedAttributes = $this->sortArray(array_keys($noEmptyAttributes)); + return implode('|', $sortedAttributes); + } + + /** + * Lowercases all array keys and values. + */ + private function caseNormalizeStringArray(array $original): array + { + $serialized = serialize($original); + $lowerCased = strtolower($serialized); + $unserialized = unserialize($lowerCased); + return $unserialized; + } + + /** + * Recursively sorts an array via the ksort function. Performs the sort on a copy to avoid side-effects. + */ + private function sortArray(array $sortMe): array + { + $copy = unserialize(serialize($sortMe)); + $sortFunction = 'ksort'; + $copy = $this->removeEmptyAttributes($copy); + + if ($this->isSequentialArray($copy)) { + $sortFunction = 'sort'; + $copy = $this->renumberIndices($copy); + } + + $sortFunction($copy); + foreach ($copy as $key => $value) { + if (is_array($value)) { + $sortFunction($value); + $copy[$key] = $this->sortArray($value); + } + } + + return $copy; + } + + /** + * Determines whether an array is sequential, by checking to see if there's at no string keys in it. + */ + private function isSequentialArray(array $array): bool + { + return count(array_filter(array_keys($array), 'is_string')) === 0; + } + + /** + * Reindexes the values of the array so that any skipped numeric indexes are removed. + */ + private function renumberIndices(array $array): array + { + return array_values($array); + } + + /** + * Iterate over an array and unset any empty values. + */ + private function removeEmptyAttributes(array $array): array + { + $copy = unserialize(serialize($array)); + + foreach ($copy as $key => $value) { + if ($this->isBlank($value)) { + unset($copy[$key]); + } + } + + return $copy; + } + + /** + * Checks if a value is empty, but allowing 0 as an integer, float and string. This means the following are allowed: + * - 0 + * - 0.0 + * - "0" + * @param $value array|string|integer|float + */ + private function isBlank($value): bool + { + return empty($value) && !is_numeric($value); + } + + /** + * NameId objects can not be serialized/unserialized after being lower cased + * Thats why the object is converted to a simple array representation where only the + * relevant NameID aspects are stored. + */ + private function nameIdNormalize(array $attributes): array + { + array_walk_recursive($attributes, function (&$value) { + if ($value instanceof NameID) { + $value = ['value' => $value->getValue(), 'Format' => $value->getFormat()]; + } + }); + return $attributes; + } +} diff --git a/src/OpenConext/EngineBlock/Service/Consent/ConsentHashServiceInterface.php b/src/OpenConext/EngineBlock/Service/Consent/ConsentHashServiceInterface.php new file mode 100644 index 0000000000..99140782d1 --- /dev/null +++ b/src/OpenConext/EngineBlock/Service/Consent/ConsentHashServiceInterface.php @@ -0,0 +1,39 @@ +connection->executeQuery( @@ -161,4 +164,116 @@ public function deleteOneFor(string $userId, string $serviceProviderEntityId): b ); } } + + /** + * @throws RuntimeException + */ + public function hasConsentHash(array $parameters): ConsentVersion + { + try { + $query = " SELECT + * + FROM + consent + WHERE + hashed_user_id = ? + AND + service_id = ? + AND + (attribute = ? OR attribute_stable = ?) + AND + consent_type = ? + AND + deleted_at IS NULL + "; + + $statement = $this->connection->prepare($query); + $statement->execute($parameters); + $rows = $statement->fetchAll(); + + if (count($rows) < 1) { + // No stored consent found + return ConsentVersion::notGiven(); + } + + if ($rows[0]['attribute_stable'] !== '') { + return ConsentVersion::stable(); + } + return ConsentVersion::unstable(); + } catch (PDOException $e) { + throw new RuntimeException(sprintf('Consent retrieval failed! Error: "%s"', $e->getMessage())); + } + } + + /** + * @throws RuntimeException + */ + public function storeConsentHash(array $parameters): bool + { + $query = "INSERT INTO consent (hashed_user_id, service_id, attribute_stable, consent_type, consent_date, deleted_at) + VALUES (?, ?, ?, ?, NOW(), '0000-00-00 00:00:00') + ON DUPLICATE KEY UPDATE attribute_stable=VALUES(attribute_stable), consent_type=VALUES(consent_type), consent_date=NOW()"; + $statement = $this->connection->prepare($query); + if (!$statement) { + throw new RuntimeException("Unable to create a prepared statement to insert consent?!"); + } + + if (!$statement->execute($parameters)) { + throw new RuntimeException( + sprintf('Error storing consent: "%s"', var_export($statement->errorInfo(), true)) + ); + } + + return true; + } + + /** + * @throws RuntimeException + */ + public function updateConsentHash(array $parameters): bool + { + $query = " + UPDATE + consent + SET + attribute_stable = ? + WHERE + attribute = ? + AND + hashed_user_id = ? + AND + service_id = ? + AND + consent_type = ? + AND + deleted_at IS NULL + "; + $statement = $this->connection->prepare($query); + if (!$statement) { + throw new RuntimeException("Unable to create a prepared statement to update consent?!"); + } + + if (!$statement->execute($parameters)) { + throw new RuntimeException( + sprintf('Error storing updated consent: "%s"', var_export($statement->errorInfo(), true)) + ); + } + + return true; + } + + /** + * @throws RuntimeException + */ + public function countTotalConsent($consentUid): int + { + $query = "SELECT COUNT(*) FROM consent where hashed_user_id = ? AND deleted_at IS NULL"; + $parameters = array(sha1($consentUid)); + $statement = $this->connection->prepare($query); + if (!$statement) { + throw new RuntimeException("Unable to create a prepared statement to count consent?!"); + } + $statement->execute($parameters); + return (int)$statement->fetchColumn(); + } } diff --git a/src/OpenConext/EngineBlockBundle/Controller/Api/ConsentController.php b/src/OpenConext/EngineBlockBundle/Controller/Api/ConsentController.php index c1e4ef3d5e..4d44644a5c 100644 --- a/src/OpenConext/EngineBlockBundle/Controller/Api/ConsentController.php +++ b/src/OpenConext/EngineBlockBundle/Controller/Api/ConsentController.php @@ -19,7 +19,7 @@ namespace OpenConext\EngineBlockBundle\Controller\Api; use OpenConext\EngineBlock\Exception\RuntimeException; -use OpenConext\EngineBlock\Service\ConsentServiceInterface; +use OpenConext\EngineBlock\Service\Consent\ConsentServiceInterface; use OpenConext\EngineBlockBundle\Configuration\FeatureConfigurationInterface; use OpenConext\EngineBlockBundle\Exception\InvalidArgumentException as EngineBlockInvalidArgumentException; use OpenConext\EngineBlockBundle\Factory\CollabPersonIdFactory; diff --git a/src/OpenConext/EngineBlockBundle/Resources/config/compat.yml b/src/OpenConext/EngineBlockBundle/Resources/config/compat.yml index a9e13f431b..0e0d7301ca 100644 --- a/src/OpenConext/EngineBlockBundle/Resources/config/compat.yml +++ b/src/OpenConext/EngineBlockBundle/Resources/config/compat.yml @@ -47,7 +47,7 @@ services: class: EngineBlock_Corto_Model_Consent_Factory arguments: - "@engineblock.compat.corto_filter_command_factory" - - "@engineblock.compat.database_connection_factory" + - "@engineblock.service.consent.ConsentHashService" engineblock.compat.saml2_id_generator: class: EngineBlock_Saml2_IdGenerator_Default diff --git a/src/OpenConext/EngineBlockBundle/Resources/config/services.yml b/src/OpenConext/EngineBlockBundle/Resources/config/services.yml index e5ad223fbb..20ccb596c4 100644 --- a/src/OpenConext/EngineBlockBundle/Resources/config/services.yml +++ b/src/OpenConext/EngineBlockBundle/Resources/config/services.yml @@ -72,7 +72,7 @@ services: - "@engineblock.configuration.stepup.loa_repository" engineblock.service.consent: - class: OpenConext\EngineBlock\Service\ConsentService + class: OpenConext\EngineBlock\Service\Consent\ConsentService arguments: - "@engineblock.repository.consent" - "@engineblock.service.metadata" @@ -366,3 +366,8 @@ services: - "@translator" tags: - { name: 'twig.extension' } + + engineblock.service.consent.ConsentHashService: + class: OpenConext\EngineBlock\Service\Consent\ConsentHashService + arguments: + - "@engineblock.repository.consent" diff --git a/tests/functional/OpenConext/EngineBlockBundle/Controller/Api/ConsentControllerTest.php b/tests/functional/OpenConext/EngineBlockBundle/Controller/Api/ConsentControllerTest.php index 702a170d8f..aa426f122d 100644 --- a/tests/functional/OpenConext/EngineBlockBundle/Controller/Api/ConsentControllerTest.php +++ b/tests/functional/OpenConext/EngineBlockBundle/Controller/Api/ConsentControllerTest.php @@ -507,8 +507,14 @@ private function disableConsentApiFeatureFor(Client $client) $container->set('engineblock.features', $featureToggles); } - private function addConsentFixture($userId, $serviceId, $attributeHash, $consentType, $consentDate, $deletedAt) - { + private function addConsentFixture( + $userId, + $serviceId, + $attributeHash, + $consentType, + $consentDate, + $deletedAt + ) { $queryBuilder = $this->getContainer()->get('doctrine')->getConnection()->createQueryBuilder(); $queryBuilder ->insert('consent') @@ -516,6 +522,7 @@ private function addConsentFixture($userId, $serviceId, $attributeHash, $consent 'hashed_user_id' => ':user_id', 'service_id' => ':service_id', 'attribute' => ':attribute', + 'attribute_stable' => ':attribute_stable', 'consent_type' => ':consent_type', 'consent_date' => ':consent_date', 'deleted_at' => ':deleted_at', @@ -523,7 +530,8 @@ private function addConsentFixture($userId, $serviceId, $attributeHash, $consent ->setParameters([ ':user_id' => sha1($userId), ':service_id' => $serviceId, - ':attribute' => $attributeHash, + ':attribute' => '', + ':attribute_stable' => $attributeHash, ':consent_type' => $consentType, ':consent_date' => $consentDate, ':deleted_at' => $deletedAt, diff --git a/tests/functional/OpenConext/EngineBlockBundle/Controller/Api/DeprovisionControllerTest.php b/tests/functional/OpenConext/EngineBlockBundle/Controller/Api/DeprovisionControllerTest.php index 891ab4fc72..68699c6cf7 100644 --- a/tests/functional/OpenConext/EngineBlockBundle/Controller/Api/DeprovisionControllerTest.php +++ b/tests/functional/OpenConext/EngineBlockBundle/Controller/Api/DeprovisionControllerTest.php @@ -397,6 +397,7 @@ private function addConsentFixture($userId, $serviceId, $attributeHash, $consent 'hashed_user_id' => ':user_id', 'service_id' => ':service_id', 'attribute' => ':attribute', + 'attribute_stable' => ':attribute', 'consent_type' => ':consent_type', 'consent_date' => ':consent_date', 'deleted_at' => '"0000-00-00 00:00:00"', diff --git a/tests/library/EngineBlock/Test/Corto/Model/ConsentIntegrationTest.php b/tests/library/EngineBlock/Test/Corto/Model/ConsentIntegrationTest.php new file mode 100644 index 0000000000..a411cde945 --- /dev/null +++ b/tests/library/EngineBlock/Test/Corto/Model/ConsentIntegrationTest.php @@ -0,0 +1,243 @@ +response = Mockery::mock(EngineBlock_Saml2_ResponseAnnotationDecorator::class); + $this->consentRepository = Mockery::mock(ConsentRepository::class); + $this->consentService = new ConsentHashService($this->consentRepository); + + $this->consent = new EngineBlock_Corto_Model_Consent( + "consent", + true, + $this->response, + [], + false, + true, + $this->consentService + ); + } + + /** + * @dataProvider consentTypeProvider + */ + public function test_no_previous_consent_given($consentType) + { + $serviceProvider = new ServiceProvider("service-provider-entity-id"); + $this->response->shouldReceive('getNameIdValue') + ->once() + ->andReturn('collab:person:id:org-a:joe-a'); + // No consent is given previously + $this->consentRepository + ->shouldReceive('hasConsentHash') + ->once() + ->andReturn(ConsentVersion::notGiven()); + switch ($consentType) { + case ConsentType::TYPE_EXPLICIT: + $this->assertFalse($this->consent->explicitConsentWasGivenFor($serviceProvider)); + break; + case ConsentType::TYPE_IMPLICIT: + $this->assertFalse($this->consent->implicitConsentWasGivenFor($serviceProvider)); + break; + } + } + + /** + * @dataProvider consentTypeProvider + */ + public function test_unstable_previous_consent_given($consentType) + { + $serviceProvider = new ServiceProvider("service-provider-entity-id"); + $this->response->shouldReceive('getNameIdValue') + ->once() + ->andReturn('collab:person:id:org-a:joe-a'); + // Stable consent is not yet stored + $this->consentRepository + ->shouldReceive('hasConsentHash') + ->with(['0e54805079c56c2b1c1197a760af86ac337b7bac', 'service-provider-entity-id', '8739602554c7f3241958e3cc9b57fdecb474d508', '8739602554c7f3241958e3cc9b57fdecb474d508', $consentType]) + ->once() + ->andReturn(ConsentVersion::unstable()); + + switch ($consentType) { + case ConsentType::TYPE_EXPLICIT: + $this->assertTrue($this->consent->explicitConsentWasGivenFor($serviceProvider)); + break; + case ConsentType::TYPE_IMPLICIT: + $this->assertTrue($this->consent->implicitConsentWasGivenFor($serviceProvider)); + break; + } + } + + /** + * @dataProvider consentTypeProvider + */ + public function test_stable_consent_given($consentType) + { + $serviceProvider = new ServiceProvider("service-provider-entity-id"); + $this->response->shouldReceive('getNameIdValue') + ->once() + ->andReturn('collab:person:id:org-a:joe-a'); + // Stable consent is not yet stored + $this->consentRepository + ->shouldReceive('hasConsentHash') + ->with(['0e54805079c56c2b1c1197a760af86ac337b7bac', 'service-provider-entity-id', '8739602554c7f3241958e3cc9b57fdecb474d508', '8739602554c7f3241958e3cc9b57fdecb474d508', $consentType]) + ->once() + ->andReturn(ConsentVersion::stable()); + + switch ($consentType) { + case ConsentType::TYPE_EXPLICIT: + $this->assertTrue($this->consent->explicitConsentWasGivenFor($serviceProvider)); + break; + case ConsentType::TYPE_IMPLICIT: + $this->assertTrue($this->consent->implicitConsentWasGivenFor($serviceProvider)); + break; + } + } + + /** + * @dataProvider consentTypeProvider + */ + public function test_give_consent_no_unstable_consent_given($consentType) + { + $serviceProvider = new ServiceProvider("service-provider-entity-id"); + $this->response->shouldReceive('getNameIdValue') + ->once() + ->andReturn('collab:person:id:org-a:joe-a'); + // Now assert that the new stable consent hash is going to be set + $this->consentRepository + ->shouldReceive('storeConsentHash') + ->once() + ->with(['0e54805079c56c2b1c1197a760af86ac337b7bac', 'service-provider-entity-id', '8739602554c7f3241958e3cc9b57fdecb474d508', $consentType]) + ->andReturn(true); + + switch ($consentType) { + case ConsentType::TYPE_EXPLICIT: + $this->assertTrue($this->consent->giveExplicitConsentFor($serviceProvider)); + break; + case ConsentType::TYPE_IMPLICIT: + $this->assertTrue($this->consent->giveImplicitConsentFor($serviceProvider)); + break; + } + } + + /** + * @dataProvider consentTypeProvider + */ + public function test_give_consent_unstable_consent_given($consentType) + { + $serviceProvider = new ServiceProvider("service-provider-entity-id"); + $this->response->shouldReceive('getNameIdValue') + ->once() + ->andReturn('collab:person:id:org-a:joe-a'); + // Now assert that the new stable consent hash is going to be set + $this->consentRepository + ->shouldReceive('storeConsentHash') + ->once() + ->with(['0e54805079c56c2b1c1197a760af86ac337b7bac', 'service-provider-entity-id', '8739602554c7f3241958e3cc9b57fdecb474d508', $consentType]) + ->andReturn(true); + + switch ($consentType) { + case ConsentType::TYPE_EXPLICIT: + $this->assertTrue($this->consent->giveExplicitConsentFor($serviceProvider)); + break; + case ConsentType::TYPE_IMPLICIT: + $this->assertTrue($this->consent->giveImplicitConsentFor($serviceProvider)); + break; + } + } + + /** + * @dataProvider consentTypeProvider + */ + public function test_upgrade_to_stable_consent($consentType) + { + $serviceProvider = new ServiceProvider("service-provider-entity-id"); + $this->response->shouldReceive('getNameIdValue') + ->twice() + ->andReturn('collab:person:id:org-a:joe-a'); + // Old-style (unstable) consent was given previously + $this->consentRepository + ->shouldReceive('hasConsentHash') + ->with(['0e54805079c56c2b1c1197a760af86ac337b7bac', 'service-provider-entity-id', '8739602554c7f3241958e3cc9b57fdecb474d508', '8739602554c7f3241958e3cc9b57fdecb474d508', $consentType]) + ->once() + ->andReturn(ConsentVersion::unstable()); + // Now assert that the new stable consent hash is going to be set + $this->consentRepository + ->shouldReceive('updateConsentHash') + ->once() + ->with(['8739602554c7f3241958e3cc9b57fdecb474d508', '8739602554c7f3241958e3cc9b57fdecb474d508', '0e54805079c56c2b1c1197a760af86ac337b7bac', 'service-provider-entity-id', $consentType]) + ->andReturn(true); + + $this->assertNull($this->consent->upgradeAttributeHashFor($serviceProvider, $consentType)); + } + + /** + * @dataProvider consentTypeProvider + */ + public function test_upgrade_to_stable_consent_not_applied_when_stable($consentType) + { + $serviceProvider = new ServiceProvider("service-provider-entity-id"); + $this->response->shouldReceive('getNameIdValue') + ->once() + ->andReturn('collab:person:id:org-a:joe-a'); + // Stable consent is stored + $this->consentRepository + ->shouldReceive('hasConsentHash') + ->with(['0e54805079c56c2b1c1197a760af86ac337b7bac', 'service-provider-entity-id', '8739602554c7f3241958e3cc9b57fdecb474d508', '8739602554c7f3241958e3cc9b57fdecb474d508', $consentType]) + ->once() + ->andReturn(ConsentVersion::stable()); + // Now assert that the new stable consent hash is NOT going to be set + $this->consentRepository + ->shouldNotReceive('storeConsentHash'); + $this->assertNull($this->consent->upgradeAttributeHashFor($serviceProvider, $consentType)); + } + + public function consentTypeProvider() + { + yield [ConsentType::TYPE_IMPLICIT]; + yield [ConsentType::TYPE_EXPLICIT]; + } +} diff --git a/tests/library/EngineBlock/Test/Corto/Model/ConsentTest.php b/tests/library/EngineBlock/Test/Corto/Model/ConsentTest.php index 38a2bfaaea..96d03cf82d 100644 --- a/tests/library/EngineBlock/Test/Corto/Model/ConsentTest.php +++ b/tests/library/EngineBlock/Test/Corto/Model/ConsentTest.php @@ -16,28 +16,34 @@ * limitations under the License. */ +use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; +use OpenConext\EngineBlock\Authentication\Value\ConsentVersion; use OpenConext\EngineBlock\Metadata\Entity\ServiceProvider; +use OpenConext\EngineBlock\Service\Consent\ConsentHashServiceInterface; use PHPUnit\Framework\TestCase; class EngineBlock_Corto_Model_Consent_Test extends TestCase { + use MockeryPHPUnitIntegration; + private $consentDisabled; private $consent; - private $mockedDatabaseConnection; + private $consentService; public function setup() { - $this->mockedDatabaseConnection = Phake::mock('EngineBlock_Database_ConnectionFactory'); $mockedResponse = Phake::mock('EngineBlock_Saml2_ResponseAnnotationDecorator'); + $this->consentService = Mockery::mock(ConsentHashServiceInterface::class); + $this->consentDisabled = new EngineBlock_Corto_Model_Consent( "consent", true, $mockedResponse, [], - $this->mockedDatabaseConnection, false, - false + false, + $this->consentService ); $this->consent = new EngineBlock_Corto_Model_Consent( @@ -45,31 +51,40 @@ public function setup() true, $mockedResponse, [], - $this->mockedDatabaseConnection, false, - true + true, + $this->consentService ); } public function testConsentDisabledDoesNotWriteToDatabase() { $serviceProvider = new ServiceProvider("service-provider-entity-id"); + + $this->consentService->shouldReceive('getUnstableAttributesHash'); + $this->consentService->shouldReceive('getStableAttributesHash'); + $this->consentService->shouldReceive('retrieveStableConsentHash'); + $this->consentService->shouldReceive('retrieveConsentHash'); + $this->consentService->shouldReceive('storeConsentHash'); $this->consentDisabled->explicitConsentWasGivenFor($serviceProvider); $this->consentDisabled->implicitConsentWasGivenFor($serviceProvider); $this->consentDisabled->giveExplicitConsentFor($serviceProvider); $this->consentDisabled->giveImplicitConsentFor($serviceProvider); - - Phake::verify($this->mockedDatabaseConnection, Phake::times(0))->create(); } public function testConsentWriteToDatabase() { $serviceProvider = new ServiceProvider("service-provider-entity-id"); + $this->consentService->shouldReceive('getStableAttributesHash'); + $this->consentService->shouldReceive('getUnstableAttributesHash')->andReturn(sha1('unstable')); + $this->consentService->shouldReceive('retrieveConsentHash')->andReturn(ConsentVersion::stable()); $this->consent->explicitConsentWasGivenFor($serviceProvider); + + $this->consentService->shouldReceive('getStableAttributesHash')->andReturn(sha1('stable')); $this->consent->implicitConsentWasGivenFor($serviceProvider); + + $this->consentService->shouldReceive('storeConsentHash')->andReturn(true); $this->consent->giveExplicitConsentFor($serviceProvider); $this->consent->giveImplicitConsentFor($serviceProvider); - - Phake::verify($this->mockedDatabaseConnection, Phake::times(4))->create(); } } diff --git a/tests/library/EngineBlock/Test/Corto/Module/Service/ProvideConsentTest.php b/tests/library/EngineBlock/Test/Corto/Module/Service/ProvideConsentTest.php index 806a5cdaaa..a8aa0f3ffa 100644 --- a/tests/library/EngineBlock/Test/Corto/Module/Service/ProvideConsentTest.php +++ b/tests/library/EngineBlock/Test/Corto/Module/Service/ProvideConsentTest.php @@ -23,7 +23,7 @@ use OpenConext\EngineBlock\Metadata\Entity\ServiceProvider; use OpenConext\EngineBlock\Metadata\MetadataRepository\InMemoryMetadataRepository; use OpenConext\EngineBlock\Service\AuthenticationStateHelperInterface; -use OpenConext\EngineBlock\Service\ConsentServiceInterface; +use OpenConext\EngineBlock\Service\Consent\ConsentServiceInterface; use OpenConext\EngineBlock\Service\Dto\ProcessingStateStep; use OpenConext\EngineBlock\Service\ProcessingStateHelper; use OpenConext\EngineBlock\Service\ProcessingStateHelperInterface; diff --git a/tests/unit/OpenConext/EngineBlock/Service/Consent/ConsentHashServiceTest.php b/tests/unit/OpenConext/EngineBlock/Service/Consent/ConsentHashServiceTest.php new file mode 100644 index 0000000000..51464edda0 --- /dev/null +++ b/tests/unit/OpenConext/EngineBlock/Service/Consent/ConsentHashServiceTest.php @@ -0,0 +1,477 @@ +chs = new ConsentHashService($mockConsentHashRepository); + } + + public function test_stable_attribute_hash_switched_order_associative_array() + { + $attributes = [ + 'urn:mace:dir:attribute-def:displayName' => ['John Doe'], + 'urn:mace:dir:attribute-def:uid' => ['joe-f12'], + 'urn:mace:dir:attribute-def:cn' => ['John Doe'], + 'urn:mace:dir:attribute-def:sn' => ['Doe'], + 'urn:mace:dir:attribute-def:eduPersonPrincipalName' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-def:givenName' => ['John'], + 'urn:mace:dir:attribute-def:mail' => ['j.doe@example.com'], + 'urn:mace:terena.org:attribute-def:schacHomeOrganization' => ['example.com'], + 'urn:mace:dir:attribute-def:isMemberOf' => [ + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + ], + ]; + $attributesSwitchedOrder = [ + 'urn:mace:dir:attribute-def:uid' => ['joe-f12'], + 'urn:mace:dir:attribute-def:displayName' => ['John Doe'], + 'urn:mace:dir:attribute-def:cn' => ['John Doe'], + 'urn:mace:dir:attribute-def:sn' => ['Doe'], + 'urn:mace:dir:attribute-def:eduPersonPrincipalName' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-def:givenName' => ['John'], + 'urn:mace:dir:attribute-def:mail' => ['j.doe@example.com'], + 'urn:mace:terena.org:attribute-def:schacHomeOrganization' => ['example.com'], + 'urn:mace:dir:attribute-def:isMemberOf' => [ + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.ORG', + ], + ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesSwitchedOrder, false)); + $this->assertEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesSwitchedOrder, true)); + } + + public function test_stable_attribute_hash_switched_order_sequential_array() + { + $attributes = [ + ['John Doe'], + ['joe-f12'], + ['John Doe'], + ['Doe'], + ['j.doe@example.com'], + ['John'], + ['j.doe@example.com'], + ['example.com'], + [ + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + ], + ]; + $attributesSwitchedOrder = [ + ['John Doe'], + ['John Doe'], + ['joe-f12'], + ['Doe'], + ['j.doe@example.com'], + ['John'], + ['example.com'], + ['j.doe@example.com'], + [ + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.ORG', + ], + ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesSwitchedOrder, false)); + $this->assertEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesSwitchedOrder, true)); + } + + public function test_stable_attribute_hash_switched_order_and_different_casing_associative_array() + { + $attributes = [ + 'urn:mace:dir:attribute-def:displayName' => ['John Doe'], + 'urn:mace:dir:attribute-def:uid' => ['joe-f12'], + 'urn:mace:dir:attribute-def:cn' => ['John Doe'], + 'urn:mace:dir:attribute-def:sn' => ['Doe'], + 'urn:mace:dir:attribute-def:eduPersonPrincipalName' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-def:givenName' => ['John'], + 'urn:mace:dir:attribute-def:mail' => ['j.doe@example.com'], + 'urn:mace:terena.org:attribute-def:schacHomeOrganization' => ['example.com'], + 'urn:mace:dir:attribute-def:isMemberOf' => [ + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + ], + ]; + $attributesSwitchedOrderAndCasing = [ + 'urn:mace:dir:attribute-def:sn' => ['DOE'], + 'urn:mace:dir:attribute-def:eduPersonPrincipalName' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-def:givenName' => ['John'], + 'urn:mace:dir:attribute-def:CN' => ['John Doe'], + 'urn:mace:dir:attribute-def:mail' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-DEF:displayName' => ['John Doe'], + 'urn:mace:terena.org:attribute-def:schacHomeOrganization' => ['example.com'], + 'urn:mace:dir:attribute-def:isMemberOf' => [ + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + ], + 'urn:mace:dir:attribute-def:UID' => ['joe-f12'], + ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesSwitchedOrderAndCasing, false)); + $this->assertEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesSwitchedOrderAndCasing, true)); + } + + public function test_stable_attribute_hash_switched_order_and_different_casing_sequential_array() + { + $attributes = [ + ['John Doe'], + ['joe-f12'], + ['John Doe'], + ['Doe'], + ['j.doe@example.com'], + ['John'], + ['j.doe@example.com'], + ['example.com'], + [ + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab2:org:vm.openconext.ORG', + 'urn:collab3:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab1:org:vm.openconext.org', + 'urn:collab2:org:vm.openconext.org', + 'urn:collab3:org:vm.openconext.org', + ], + ]; + $attributesSwitchedOrderAndCasing = [ + ['joe-f12'], + ['John Doe'], + ['John Doe'], + ['j.doe@example.com'], + ['John'], + ['EXample.com'], + ['j.doe@example.com'], + [ + 'URN:collab2:org:vm.openconext.ORG', + 'urn:collab2:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.Org', + 'urn:collaboration:organisation:VM.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab3:org:vm.openconext.org', + 'urn:collab3:org:vm.openconext.ORG', + 'urn:collab1:org:vm.openconext.org', + ], + ['DOE'], + ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesSwitchedOrderAndCasing, false)); + $this->assertEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesSwitchedOrderAndCasing, true)); + } + + public function test_stable_attribute_hash_different_casing_associative_array() + { + $attributes = [ + 'urn:mace:dir:attribute-def:displayName' => ['John Doe'], + 'urn:mace:dir:attribute-def:uid' => ['joe-f12'], + 'urn:mace:dir:attribute-def:cn' => ['John Doe'], + 'urn:mace:dir:attribute-def:sn' => ['Doe'], + 'urn:mace:dir:attribute-def:eduPersonPrincipalName' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-def:givenName' => ['John'], + 'urn:mace:dir:attribute-def:mail' => ['j.doe@example.com'], + 'urn:mace:terena.org:attribute-def:schacHomeOrganization' => ['example.com'], + 'urn:mace:dir:attribute-def:isMemberOf' => [ + 'urn:collab:ORG:vm.openconext.ORG', + 'urn:collab:ORG:vm.openconext.ORG', + 'urn:collab:ORG:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + ], + ]; + $attributesDifferentCasing = [ + 'urn:mace:dir:attribute-def:DISPLAYNAME' => ['John Doe'], + 'urn:mace:dir:attribute-def:uid' => ['joe-f12'], + 'urn:mace:dir:attribute-def:cn' => ['John Doe'], + 'urn:mace:dir:attribute-def:sn' => ['DOE'], + 'urn:mace:dir:attribute-def:eduPersonPrincipalName' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-def:givenName' => ['John'], + 'urn:mace:dir:attribute-def:mail' => ['j.doe@example.com'], + 'urn:mace:terena.org:attribute-def:schacHomeOrganization' => ['example.com'], + 'urn:mace:dir:attribute-def:ISMemberOf' => [ + 'URN:collab:org:VM.openconext.org', + 'URN:collab:org:VM.openconext.org', + 'URN:collab:org:VM.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + ], + ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesDifferentCasing, false)); + $this->assertEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesDifferentCasing, true)); + } + + public function test_stable_attribute_hash_different_casing_sequential_array() + { + $attributes = [ + ['John Doe'], + ['joe-f12'], + ['John Doe'], + ['Doe'], + ['j.doe@example.com'], + ['John'], + ['j.doe@example.com'], + ['example.com'], + [ + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + ], + ]; + $attributesDifferentCasing = [ + ['JOHN Doe'], + ['joe-f12'], + ['John DOE'], + ['Doe'], + ['j.doe@example.com'], + ['John'], + ['j.doe@example.com'], + ['example.com'], + [ + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:VM.openconext.ORG', + 'urn:collab:org:VM.openconext.org', + 'urn:collaboration:organisation:VM.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:COLLAB:org:vm.openconext.org', + ], + ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesDifferentCasing, false)); + $this->assertEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesDifferentCasing, true)); + } + + public function test_stable_attribute_hash_reordering_sparse_sequential_arrays() + { + $attributes = [ "AttributeA" => [ 0 => "aap", 1 => "noot"] ]; + $attributesDifferentCasing = + [ "AttributeA" => [ 0 => "aap", 2 => "noot"] ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesDifferentCasing, false)); + $this->assertEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesDifferentCasing, true)); + } + + public function test_stable_attribute_hash_remove_empty_attributes() + { + $attributes = [ "AttributeA" => [ 0 => "aap", 1 => "noot"], "AttributeB" => [], "AttributeC" => 0 ]; + $attributesDifferentNoEmptyValues = + [ "AttributeA" => [ 0 => "aap", 2 => "noot"], "AttributeC" => 0 ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesDifferentNoEmptyValues, false)); + $this->assertEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesDifferentNoEmptyValues, true)); + } + + public function test_stable_attribute_hash_two_different_arrays_yield_different_hashes_associative() + { + $attributes = [ + 'a' => ['John Doe'], + 'b' => ['joe-f12'], + 'c' => ['John Doe'], + 'd' => ['Doe'], + 'e' => ['j.doe@example.com'], + 'f' => ['John'], + 'g' => ['j.doe@example.com'], + 'h' => ['example.com'], + ]; + $differentAttributes = [ + 'i' => 'urn:collab:org:vm.openconext.ORG', + 'j' => 'urn:collab:org:vm.openconext.ORG', + 'k' => 'urn:collab:org:vm.openconext.ORG', + 'l' => 'urn:collab:org:vm.openconext.org', + 'm' => 'urn:collaboration:organisation:vm.openconext.org', + 'n' => 'urn:collab:org:vm.openconext.org', + 'o' => 'urn:collab:org:vm.openconext.org', + 'p' => 'urn:collab:org:vm.openconext.org', + ]; + $this->assertNotEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($differentAttributes, false)); + $this->assertNotEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($differentAttributes, true)); + } + + public function test_stable_attribute_hash_two_different_arrays_yield_different_hashes_sequential() + { + $attributes = [ + ['John Doe'], + ['joe-f12'], + ['John Doe'], + ['Doe'], + ['j.doe@example.com'], + ['John'], + ['j.doe@example.com'], + ['example.com'], + ]; + $differentAttributes = [ + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + ]; + // two sequential arrays with the same amount of attributes will yield the exact same hash if no values must be stored. todo: check if we want this? + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($differentAttributes, false)); + $this->assertNotEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($differentAttributes, true)); + } + + public function test_stable_attribute_hash_multiple_value_vs_single_value_associative_array() + { + $attributes = [ + 'urn:mace:dir:attribute-def:displayName' => ['John Doe'], + 'urn:mace:dir:attribute-def:uid' => ['joe-f12'], + 'urn:mace:dir:attribute-def:cn' => ['John Doe'], + 'urn:mace:dir:attribute-def:sn' => ['Doe'], + 'urn:mace:dir:attribute-def:eduPersonPrincipalName' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-def:givenName' => ['John'], + 'urn:mace:dir:attribute-def:mail' => ['j.doe@example.com'], + 'urn:mace:terena.org:attribute-def:schacHomeOrganization' => ['example.com'], + 'urn:mace:dir:attribute-def:isMemberOf' => [ + 'urn:collab:ORG:vm.openconext.ORG', + 'urn:collab:ORG:vm.openconext.ORG', + 'urn:collab:ORG:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + ], + ]; + $attributesSingleValue = [ + 'urn:mace:dir:attribute-def:displayName' => ['John Doe'], + 'urn:mace:dir:attribute-def:uid' => ['joe-f12'], + 'urn:mace:dir:attribute-def:cn' => ['John Doe'], + 'urn:mace:dir:attribute-def:sn' => ['Doe'], + 'urn:mace:dir:attribute-def:eduPersonPrincipalName' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-def:givenName' => ['John'], + 'urn:mace:dir:attribute-def:mail' => ['j.doe@example.com'], + 'urn:mace:terena.org:attribute-def:schacHomeOrganization' => ['example.com'], + 'urn:mace:dir:attribute-def:isMemberOf' => [ + 'urn:collab:org:vm.openconext.org', + ], + ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesSingleValue, false)); + $this->assertNotEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesSingleValue, true)); + } + + public function test_stable_attribute_hash_multiple_value_vs_single_value_sequential_array() + { + $attributes = [ + ['John Doe'], + ['joe-f12'], + ['John Doe'], + ['Doe'], + ['j.doe@example.com'], + ['John'], + ['j.doe@example.com'], + ['example.com', 'j.doe@example.com', 'jane'], + ]; + $attributesSingleValue = [ + ['John Doe'], + ['joe-f12'], + ['John Doe'], + ['Doe'], + ['j.doe@example.com'], + ['John'], + ['j.doe@example.com'], + ['example.com'], + ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesSingleValue, false)); + $this->assertNotEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesSingleValue, true)); + } + + public function test_stable_attribute_hash_can_handle_nameid_objects() + { + $nameId = new NameID(); + $nameId->setValue('83aa0a79363edcf872c966b0d6eaf3f5e26a6a77'); + $nameId->setFormat('urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'); + + $attributes = [ + 'urn:mace:dir:attribute-def:displayName' => ['John Doe'], + 'urn:mace:dir:attribute-def:uid' => ['joe-f12'], + 'urn:mace:dir:attribute-def:cn' => ['John Doe'], + 'urn:mace:dir:attribute-def:sn' => ['Doe'], + 'urn:mace:dir:attribute-def:eduPersonPrincipalName' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-def:givenName' => ['John'], + 'urn:mace:dir:attribute-def:mail' => ['j.doe@example.com'], + 'urn:mace:terena.org:attribute-def:schacHomeOrganization' => ['example.com'], + 'nl:surf:test:something' => [0 => 'arbitrary-value'], + 'urn:mace:dir:attribute-def:eduPersonTargetedID' => [$nameId], + 'urn:oid:1.3.6.1.4.1.5923.1.1.1.10' => [$nameId], + ]; + + $hash = $this->chs->getStableAttributesHash($attributes, false); + $this->assertTrue(is_string($hash)); + } +} diff --git a/tests/unit/OpenConext/EngineBlock/Service/ConsentServiceTest.php b/tests/unit/OpenConext/EngineBlock/Service/ConsentServiceTest.php index 84e37e7dee..9c959ada67 100644 --- a/tests/unit/OpenConext/EngineBlock/Service/ConsentServiceTest.php +++ b/tests/unit/OpenConext/EngineBlock/Service/ConsentServiceTest.php @@ -24,6 +24,7 @@ use OpenConext\EngineBlock\Authentication\Repository\ConsentRepository; use OpenConext\EngineBlock\Authentication\Value\CollabPersonId; use OpenConext\EngineBlock\Authentication\Value\CollabPersonUuid; +use OpenConext\EngineBlock\Service\Consent\ConsentService; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface;