diff --git a/README.md b/README.md index 81d8ee167478098db3e4b05643b5fb2e0faacacf..709800e43cb53635da90d5936683d6e1bcec3e02 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Any change that significantly changes behavior in a backward-incompatible way or - Auth Process filters: - computeloa - iscesneteligible + - iseinfraczeligible + - iseinfraassured ## Instalation diff --git a/composer.json b/composer.json index 8beedd642b2b989299eacbe657c76fa5bd1b712c..2e3d0a6a558fc83088cdf8442c7a15088411243b 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ "ext-curl": "*" }, "suggest": { - "cesnet/simplesamlphp-module-privacyidea": "included privacyIDEA template is for this module" + "cesnet/simplesamlphp-module-privacyidea": "included privacyIDEA template is for this module", + "ext-yaml": "needed to run IsEinfraCZEligible auth filter" }, "config": { "platform": { diff --git a/config-templates/processFilterConfigurations-example.md b/config-templates/processFilterConfigurations-example.md index 4abe073f3b72f57565696c87035ca69434ad5951..7fac57ab7810fe878da23b0d1efb0ff071a74590 100644 --- a/config-templates/processFilterConfigurations-example.md +++ b/config-templates/processFilterConfigurations-example.md @@ -27,3 +27,40 @@ Example how to configure IsCesnetEligible filter: 'LDAP.attributeName' => 'isCesnetEligible', ], ``` + +## IsEinfraCZEligible + +Example how to configure IsEinfraCZEligible filter: + +- Configuration file is saml20-idp-hosted.php +- User eligibilites and affiliations should be retrieved before this filter is run +- User ext. sources are not updated as part of this filter + + ```php + 33 => [ + 'class' => 'cesnet:IsEinfraCZEligible', + 'userEligibilityAttrName' => 'session_eligibilities', + 'userAffiliationAttrName' => 'externalAffiliation', + 'eligibilitiesAttrKey' => 'einfracz', + ], + ``` + +## IsEinfraAssured + +Example how to configure IsEinfraAssured filter: + +- Configuration file is saml20-idp-hosted.php +- Einfracz eligibility should be resolved before this filter is run +- User eligibilities and session eligibilities should be retrieved before this filter is run +- The result values are stored in attributes defined in `userAssuranceAttrNames` as value `assurancePrefix-1y` + + ```php + 34 => [ + 'class' => 'cesnet:IsEinfraAssured', + 'userAssuranceAttrNames' => ['eduPersonAssurance'], + 'userEligibilitiesAttrName' => 'eligibilities', + 'sessionEligibilitiesAttrName' => 'session_eligibilities' + 'eligibilitiesAttrKey' => 'einfracz', + 'assurancePrefix' => 'assuranceprefix', + ], + ``` diff --git a/lib/Auth/Process/IsEinfraAssured.php b/lib/Auth/Process/IsEinfraAssured.php new file mode 100644 index 0000000000000000000000000000000000000000..c3403008f2a0a791943449569e3b37722b638e00 --- /dev/null +++ b/lib/Auth/Process/IsEinfraAssured.php @@ -0,0 +1,98 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\cesnet\Auth\Process; + +use SimpleSAML\Auth\ProcessingFilter; +use SimpleSAML\Configuration; +use SimpleSAML\Logger; +use SimpleSAML\Error\Exception; + +/** + * Class IsEinfraAssured. + * + * This class puts assurance timestamp as {prefix}-1y representing + * user's einfra eligibility is not older than 1 year. + */ +class IsEinfraAssured extends ProcessingFilter +{ + public const LOGGER_PREFIX = 'cesnet:IsEinfraAssured - '; + + public const USER_ASSURANCE_ATTRS = 'userAssuranceAttrNames'; + + public const USER_ELIGIBILITIES_ATTR = 'userEligibilitiesAttrName'; + + public const SESSION_ELIGIBILITIES_ATTR = 'sessionEligibilitiesAttrName'; + + public const ELIGIBILITIES_ATTR_KEY = 'eligibilitiesAttrKey'; + + public const PREFIX = 'assurancePrefix'; + + private $userEligibilitiesAttr = 'eligibilities'; + + private $sessionEligibilitiesAttr = 'session_eligibilities'; + + private $assuranceAttrs = ['eduPersonAssurance']; + + private $prefix = 'prefix'; + + private const SUFFIX = '-1y'; + + private $eligibilityKey = 'einfracz'; + + public function __construct($config, $reserved) + { + parent::__construct($config, $reserved); + $config = Configuration::loadFromArray($config); + + if ($config === null) { + throw new Exception( + self::LOGGER_PREFIX . ' configuration is missing or invalid!' + ); + } + + $this->assuranceAttrs = $config->getArray(self::USER_ASSURANCE_ATTRS, $this->assuranceAttrs); + $this->userEligibilitiesAttr = $config->getString( + self::USER_ELIGIBILITIES_ATTR, + $this->userEligibilitiesAttr + ); + $this->sessionEligibilitiesAttr = $config->getString( + self::SESSION_ELIGIBILITIES_ATTR, + $this->sessionEligibilitiesAttr + ); + $this->prefix = $config->getString(self::PREFIX, $this->prefix); + $this->eligibilityKey = $config->getString(self::ELIGIBILITIES_ATTR_KEY, $this->eligibilityKey); + + if (empty($this->assuranceAttrs)) { + throw new Exception( + self::LOGGER_PREFIX . ' empty array detected !' + ); + } + } + + public function process(&$request) + { + $timestamp = 0; + if (isset($request['Attributes'][$this->sessionEligibilitiesAttr][$this->eligibilityKey])) { + $timestamp = $request['Attributes'][$this->sessionEligibilitiesAttr][$this->eligibilityKey]; + } elseif (isset($request['Attributes'][$this->userEligibilitiesAttr][$this->eligibilityKey])) { + $timestamp = $request['Attributes'][$this->userEligibilitiesAttr][$this->eligibilityKey]; + } + + if ($timestamp > strtotime('-1 year')) { + foreach ($this->assuranceAttrs as $attr) { + $request['Attributes'][$attr][] = $this->prefix . self::SUFFIX; + } + Logger::debug( + self::LOGGER_PREFIX . + 'Added assurance timestamp ' . $this->prefix . self::SUFFIX + ); + } else { + Logger::debug( + self::LOGGER_PREFIX . + 'User is not assured - assurance timestamp not added.' + ); + } + } +} diff --git a/lib/Auth/Process/IsEinfraCZEligible.php b/lib/Auth/Process/IsEinfraCZEligible.php new file mode 100644 index 0000000000000000000000000000000000000000..8ca1cf063e7cd760ae019e6e2003cf3fad310291 --- /dev/null +++ b/lib/Auth/Process/IsEinfraCZEligible.php @@ -0,0 +1,180 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\cesnet\Auth\Process; + +use SimpleSAML\Auth\ProcessingFilter; +use SimpleSAML\Configuration; +use SimpleSAML\Error\Exception; +use SimpleSAML\Logger; + +/** + * Class IsEinfraCZEligible. + * + * This class puts the current timestamp to attribute storing user eligibilities, + * when user's affiliations are sufficient to be eligible, which is checked with + * a configuration that is periodically fetched from configured URL. + */ +class IsEinfraCZEligible extends ProcessingFilter +{ + public const LOGGER_PREFIX = 'cesnet:IsEinfraCZEligible - '; + + public const IDP_ELIGIBLE_AFFILIATIONS_LIST_LINK = + 'https://gitlab.ics.muni.cz/perun-proxy-aai/e-infracz_idps_data/-/raw/main/einfra_isacademic/result.yaml'; + + public const LINK_REFRESH_RATE_MINS = 15; // older file will be refreshed + + public const FILE_MAX_AGE_HOURS = 24; // older file won't be considered valid + + public const DATA_FILEPATH = '/tmp/einfra_isacademic_eligible.yml'; // where file will be stored and loaded from + + public const USER_ELIGIBILITY_ATTR = 'userEligibilityAttrName'; + + public const USER_AFFILIATIONS_ATTR = 'userAffiliationAttrName'; + + public const USER_ELIGIBILITY_KEY = 'eligibilitiesAttrKey'; + + private $userAffiliationAttr = 'externalAffiliation'; + + private $userEligibilityAttr = 'session_eligibilities'; + + private $userEligibilityAttrKey = 'einfracz'; + + + public function __construct($config, $reserved) + { + parent::__construct($config, $reserved); + $config = Configuration::loadFromArray($config); + + if ($config === null) { + throw new Exception( + self::LOGGER_PREFIX . ' configuration is missing or invalid!' + ); + } + + $this->userEligibilityAttr = $config->getString(self::USER_ELIGIBILITY_ATTR, $this->userEligibilityAttr); + $this->userAffiliationAttr = $config->getString(self::USER_AFFILIATIONS_ATTR, $this->userAffiliationAttr); + $this->userEligibilityAttrKey = $config->getString(self::USER_ELIGIBILITY_KEY, $this->userEligibilityAttrKey); + } + + public function process(&$request) + { + $userScopedAffiliations = []; + if (isset($request['Attributes'][$this->userAffiliationAttr])) { + $userScopedAffiliations + = $request['Attributes'][$this->userAffiliationAttr]; + } else { + Logger::error( + self::LOGGER_PREFIX . + 'Attribute with name \'' . $this->userAffiliationAttr . '\' was not received from IdP!' + ); + } + + $isEligible = $this->isEligible($request['saml:sp:IdP'], $userScopedAffiliations); + + Logger::debug( + self::LOGGER_PREFIX . 'User is' . ($isEligible ? ' ' : ' not ') . 'eligible' + ); + + if ($isEligible) { + $request['Attributes'][$this->userEligibilityAttr][$this->userEligibilityAttrKey] = strval(time()); + } + Logger::debug( + self::LOGGER_PREFIX . 'Result eligibilities attribute: ' . + json_encode($request['Attributes'][$this->userEligibilityAttr]) + ); + } + + private function isEligible(string $idpEntityId, array $userScopedAffiliations): bool + { + $userAffiliations = array_map( + function ($item) { + return explode("@", $item)[0]; + }, + $userScopedAffiliations + ); + + $allowedAffiliations = $this->getAllowedAffiliations($idpEntityId); + $result = array_intersect($userAffiliations, $allowedAffiliations); + Logger::debug( + self::LOGGER_PREFIX . + 'Affiliations that make user eligible: ' . implode(",", $result) + ); + return !empty($result); + } + + + private function getAllowedAffiliations(string $idpEntityId): array + { + $allowedAffiliations = []; + $eligibilities = $this->getEligibilityData(); + + if (array_key_exists($idpEntityId, $eligibilities)) { + $allowedAffiliations = $eligibilities[$idpEntityId]; + } + + return $allowedAffiliations; + } + + + private function getEligibilityData(): array + { + $data = 0; + if (file_exists(self::DATA_FILEPATH)) { + $file_age = strtotime(date("F d Y H:i:s.", filectime(self::DATA_FILEPATH))); + if ($file_age < strtotime("-" . self::LINK_REFRESH_RATE_MINS . " minutes")) { + $data = $this->fetchAffiliationData(); + } + + if (empty($data) and $file_age > strtotime("-" . self::FILE_MAX_AGE_HOURS . " hours")) { + $data = $this->readDataFromCacheFile(); + } + } else { + $data = $this->fetchAffiliationData(); + } + return $data; + } + + private function readDataFromCacheFile(): array + { + try { + $fileSize = filesize(self::DATA_FILEPATH); + + $file = fopen(self::DATA_FILEPATH, "r"); + $sourceData = fread($file, $fileSize); + fclose($file); + + return yaml_parse($sourceData); + } catch (\Exception $exception) { + throw new \Exception( + self::LOGGER_PREFIX . "Cannot read local stored cache file: " + . self::DATA_FILEPATH + ); + } + } + + private function fetchAffiliationData(): array + { + $sourceData = file_get_contents(self::IDP_ELIGIBLE_AFFILIATIONS_LIST_LINK); + if (!$sourceData) { + Logger::debug( + self::LOGGER_PREFIX . "Cannot download data from " . self::IDP_ELIGIBLE_AFFILIATIONS_LIST_LINK + ); + return []; + } + + // Store to file + try { + $file = fopen(self::DATA_FILEPATH, "w"); + fwrite($file, $sourceData); + fclose($file); + } catch (\Exception $exception) { + Logger::debug( + self::LOGGER_PREFIX . "Cannot store data to file" . self::DATA_FILEPATH + ); + } + + return yaml_parse($sourceData); + } +}