diff --git a/composer.json b/composer.json index efb4692..c2ae16e 100644 --- a/composer.json +++ b/composer.json @@ -1,17 +1,30 @@ + + { - "name": "mimaen/simplesamlphp-module-hubandspoke", - "description": "SimpleSAMLphp utilities for Hub & Spoke federations", + "name": "simplesamlphp/simplesamlphp-module-hubandspoke", + "description": "Obtención atributo eduPersonTargetedID.", "type": "simplesamlphp-module", + "keywords": ["simplesamlphp", "hubandspoke", "SAML", "SimpleSAMLphp", "Hub-and-Spoke", "IdP", "SP", "eduPersonTargetedId"], + "version": "2.0.0", "license": "LGPL-2.1", "authors": [ { "name": "Miguel Macías Enguídanos", "email": "mimaen@inf.upv.es" + }, + { + "name": "Miguel Milla Arregui", + "email": "miguel@ual.es" } ], - "keywords": ["SAML", "SimpleSAMLphp", "Hub-and-Spoke", "IdP", "SP", "eduPersonTargetedId"], - "minimum-stability": "dev", "require": { - "simplesamlphp/composer-module-installer": "~1.0" + "php": "^8.0", + "simplesamlphp/simplesamlphp": "^2.0" + }, + "autoload": { + "psr-4": { + "SimpleSAML\\Module\\hubandspoke\\": "src/" + } } } + diff --git a/docs/TargetedID.md b/docs/TargetedID.md new file mode 100644 index 0000000..82732a6 --- /dev/null +++ b/docs/TargetedID.md @@ -0,0 +1,194 @@ +# Hub & Spoke: TargetedID + +A flexible way for generate one or more values for the +eduPersonTargetedId attribute. + +## Configuration samples + + +### eduPersonTargetedId with one unique standard value: + +```php + 'authproc' => array( + 50 => 'hubandspoke:TargetedID', + ), + + => sha256(userID + '@@' + targetID + '@@' + sourceID) +``` + +### eduPersonTargetedId obfuscated with a salt: + +```php + 'authproc' => array( + 50 => array( + 'class' => 'hubandspoke:TargetedID', + 'salt' => 'randomString', + ), + ), + + => sha256(salt + '@@' + userID + '@@' + targetID + '@@' + sourceID + '@@' + salt) +``` + +### eduPersonTargetedId with a different formula: + +```php + 'authproc' => array( + 50 => array( + 'class' => 'hubandspoke:TargetedID', + 'userID' => 'Attributes/mail', + 'fields' => array('salt', 'userID', 'targetID'), + 'salt' => 'randomString', + ), + ), + + => sha256(salt + '@@' + mail + '@@' + targetID) +``` + +### eduPersonTargetedId with two values: + +```php + 'authproc' => array( + 50 => array( + 'class' => 'hubandspoke:TargetedID', + 'salt' => 'randomString', + 'values' => array( + 'new' => array( + 'fieldSeparator' => '//', + ), + 'old' => array( + 'hashFunction' => 'md5', + 'fields' => array('userID'), + ), + ), + ), + ), + + => sha256(salt + '//' + userID + '//' + targetID + '//' + sourceID + '//' + salt) + => md5(userID) +``` + +### eduPersonTargetedId with two values prefixed: + - one of them only for a specific SP (http://*.example.com) + - the other one for all SP, but considering the same SP + all URL https://*.blogs.example.com (same eduPersonTargetedId) + +```php + 'authproc' => array( + 50 => array( + 'class' => 'hubandspoke:TargetedID', + 'salt' => 'randomString', + 'values' => array( + 'new' => array( + 'prefix' => '{new}', + 'targetTransform' => array( + '#^(https?://)[^./]+\.(blogs\.example\.com)(/|$).*$#' => '$1$2/', + ), + ), + 'old' => array( + 'prefix' => '{old}', + 'hashFunction' => 'md5', + 'userID' => array('Attributes/mail', 'UserID'), + 'fields' => 'userID', + 'ifTarget' => '#^https?://([^./]+\.)*example\.com(/|$)#', + ), + ), + ), + ), + + => '{new}' + sha256(salt + '@@' + userID + '@@' + targetID* + '@@' + sourceID + '@@' + salt) + => '{old}' + md5(userID) only for *.example.com +``` + +## Description + +hubandspoke:TargetedID is an Authentication Processing Filter for SimpleSAMLphp +(https://simplesamlphp.org/docs/stable/simplesamlphp-authproc). + +Based on core:TargetedID (by Olav Morken, UNINETT AS), it allows: + + - generate one or more values for the eduPersonTargetdId attribute + - feed the values with user, source and/or destination identifiers + - use any of the hash algorithms supported by PHP + - generate some values only for selected users/destinations + - processing destination identifiers before using them + - add a prefix for further processing + - avoid dependencies (all configuration is contained at IdP level) + + +## Configuration + +The best place to configure this filter is in the saml20-idp-hosted file. Thus, +if you move the IdP to another SSP instance, the eduPersonTargetedId obtained +will not change. + +There are 3 levels of configuration: + +1. **defaults**: parameters hard-coded at module +2. **filter**: parameters set at first level on configuration file +3. **value**: parameters set inside the 'values' switch + +For each value of the eduPersonTargetedId attribute, these configurations are +applied in order, so 'defaults' has the lowest priority and 'value' has the +highest priority. If a parameter is not set on a level, it inherits the value +of previous levels. + +Configuration is based on the following parameters: + +| Parámetro | Descripción | +|-----------|-------------| +| userID | Array of attributes (in order of preference) for identify the user. It's the most important parameter to obtain a quality eduPersonTargetedId attribute. | +| ifUser | Array of regular expressions to check the user identifier. Only users matching one of these patterns will obtain the value generated. | +| targetID | Array of attributes (in order of preference) for identify the target. On Hub & Spoke federations the target would be the SP, not the hub. | +| targetTransform | Array of transformations to apply to a target identifier. It allows uniform process of a same SP with different URL entries. Keys are regular expressions and Values are string replacements (following the preg_replace syntax) | +| ifTarget | Array of regular expressions to check the target identifier. Only targets matching one of these patterns will obtain the value generated. This check is applied after transformations, if any. | +| sourceID | List of attributes (in order of preference) for identify the ource. | +| salt | A random string to add entropy to the generated values. | +| hashFunction | The hash function used to obtain an opaque attribute. | +| fields | Array of fields to combine to generate each value. Each field has to be a parameter of the configuration (userID, targetID...). A field can be inserted more than one time. | +| fieldSeparator | The string used to glue all the fields. | +| prefix | A string that will be prefixed to the hash value obtained. On Hub & Spoke federations it allows further processing of the attribute (on the hub). | +| nameId | A boolean indicating if we want SAML 2.0 name identifier elements. | +| values | Array of specific configurations, one for each value of the ttribute. If this switch is omitted, an unique value will be generated. Parameters on this array override generic parameters. | + + + +For parameters containing a single value, you can write directly a string, +instead of an array with that only value. + +The filter can search on all the attributes set after authentication (SSP state). +When more than one level is used, the character '/' sets the level change: + +```php + 'core:SP' references to state['core:SP'] + 'Attributes/uid' references to state['Attributes']['uid'] +``` + + +### Default values + +```php + userID: 'UserID' + ifUser: NULL + targetID: array('saml:RequesterID', 'core:SP') + targetTransform: NULL + ifTarget: NULL + sourceID: array('Attributes/schacHomeOrganization', 'core:IdP') + salt: NULL + hashFunction: 'sha256' + fields: array('salt', 'userID', 'targetID', 'sourceID', 'salt') + fieldSeparator: '@@' + prefix: NULL + nameId: false +``` + +## Contenido directorio modulo hubandspoke +```bash +hubandspoke +├── composer.json +├── docs +│   └── TargetedID.md +└── src + └── Auth + └── Process + └── TargetedID.php +``` diff --git a/src/Auth/Process/TargetedID.php b/src/Auth/Process/TargetedID.php new file mode 100644 index 0000000..d8f4513 --- /dev/null +++ b/src/Auth/Process/TargetedID.php @@ -0,0 +1,283 @@ + 'UserID', + 'ifUser' => NULL, + 'targetID' => array('saml:RequesterID', 'core:SP'), + 'targetTransform' => NULL, + 'ifTarget' => NULL, + 'sourceID' => array('Attributes/schacHomeOrganization', 'core:IdP'), + 'salt' => NULL, + 'hashFunction' => 'sha256', + 'fields' => array('salt', 'userID', 'targetID', 'sourceID', 'salt'), + 'fieldSeparator' => '@@', + 'prefix' => NULL, + 'nameId' => false + ); + + /** + * Configuration for the values to be generated + */ + private $confValues = array(); + + + + /** + * Initialize this filter. + * + * @param array $config Configuration information about this filter. + * @param mixed $reserved For future use. + */ + public function __construct(array &$config, $reserved) { + parent::__construct($config, $reserved); + + assert('is_array($config)'); + + // the 'values' array sets configuration for each value of the attribute + if (!array_key_exists('values', $config)) + $config['values']= array('default' => array()); + + foreach ($config['values'] as $name => $parameters) { + // order of preference: (hard-coded) defaults, first level configuration, specific configuration + // if a parameter is missing -> use the same parameter configured at a higher level + // to 'remove' a parameter -> specify an empty value (NULL, array(), '') to disable + $this->confValues[$name]= array_merge($this->defaults, $config, $parameters); + // checking hash algorithm is valid + $hashAlg= $this->confValues[$name]['hashFunction']; + if (!empty($hashAlg) && !in_array($hashAlg, hash_algos())) + throw new Exception('eduPersonTargetedId: hash algorithm (' . $hashAlg . ') not supported'); + } + } + + /** + * Apply filter to add the targeted ID. + * + * @param array &$state The current state. + */ + public function process(array &$state): void { + assert('is_array($state)'); + assert('array_key_exists("Attributes", $state)'); + + $state['Attributes']['eduPersonTargetedID'] = array(); + foreach ($this->confValues as $name => $parameters) { + $dataRetrieved= array(); + + // get the identifier of the authenticated user (mandatory) + $data= (empty($parameters['userID']))? + '': + self::getValue ($state, (array) $parameters['userID'], 'eduPersonTargetedId: not user id found'); + // check that user is not filtered out for this value of the attribute + if (!empty($parameters['ifUser']) && !self::someMatch($data, (array) $parameters['ifUser'])) { + Logger::debug('eduPersonTargetedId, ' . $name . ' skipped: userID = ' . $data); + continue; + } + $dataRetrieved['userID']= $data; + // log + Logger::notice('userID: ' . $dataRetrieved['userID']); + + + // get the identifier of the destination (optional) + $data= (empty($parameters['targetID']))? + '': + self::getValue ($state, (array) $parameters['targetID'], NULL); + // transform, if needed, the identifier + if (is_array($parameters['targetTransform'])) { + foreach($parameters['targetTransform'] as $pattern => $replacement) + $data= preg_replace($pattern, $replacement, $data); + } + // check that destination is not filtered out for this value of the attribute + if (!empty($parameters['ifTarget']) && !self::someMatch($data, (array) $parameters['ifTarget'])) { + Logger::debug('eduPersonTargetedId, ' . $name . ' skipped: targetID = ' . $data); + continue; + } + $dataRetrieved['targetID']= $data; + // log + Logger::notice('targetID: ' . $dataRetrieved['targetID']); + + // get the identifier of the source (optional) + $data= (empty($parameters['sourceID']))? + '': + self::getValue ($state, (array) $parameters['sourceID'], NULL); + $dataRetrieved['sourceID']= $data; + // log + Logger::notice('sourceID: ' . $dataRetrieved['sourceID']); + + + // get a salt for obfuscating the hash + $data= (empty($parameters['salt']))? + '': + $parameters['salt']; + $dataRetrieved['salt']= $data; + + // generate the value applying the hash function + $dataRaw= array(); + foreach((array) $parameters['fields'] as $field) { + $data= $dataRetrieved[$field]; + if (!empty($data)) + $dataRaw[]= $data; + } + $dataRaw= implode($parameters['fieldSeparator'], $dataRaw); + $eduPersonTargetedId= hash($parameters['hashFunction'], $dataRaw); + + // set a prefix, if needed + if (!empty($parameters['prefix'])) + $eduPersonTargetedId= $parameters['prefix'] . $eduPersonTargetedId; + + // log + Logger::debug('eduPersonTargetedId, ' . $name . ' function: ' . $parameters['hashFunction'] . '(' . $dataRaw . ')'); + Logger::debug('eduPersonTargetedId, ' . $name . ' value: ' . $eduPersonTargetedId); + + // Convert to a name identifier element + if ($parameters['nameId']) + $eduPersonTargetedId= self::toNameId($eduPersonTargetedId, $dataRetrieved['sourceID'], $dataRetrieved['targetID']); + + // add the attribute + $state['Attributes']['eduPersonTargetedID'][]= $eduPersonTargetedId; + } + } + + /** + * Get a value from a set of alternative options (in order of preference) + * + * @param array &$state The current state. + * @param array $options The list of options to retrieve the value. + * @param string $error Message if not found. + * @return string The value obtained + */ + private static function getValue(&$state, $options, $error) { + assert('is_array($options)'); + + $value= ''; + foreach($options as $attributeName) { + // separe with / for enter next level + // example: Attributes/uid => $state['Attributes']['uid'] + list($level1, $level2)= explode('/', $attributeName . '/'); + if (array_key_exists($level1, $state)) { + $data= $state[$level1]; + if (!empty($level2)) { + if (is_array($data) && array_key_exists($level2, $data)) + $data= $data[$level2]; + else + $data= null; + } + if (!empty($data)) { + $data= (array) $data; // if value is string => insert into a new array + $value= $data[0]; // first value selected + break; + } + } + } + if (empty($value)) { + if (!empty($error)) { + Logger::warning($error); + } + return ''; + } + + return $value; +} + +/** + * Check that a string matches one (or more) of a list of regular expressions + * + * @param string $value The string to check. + * @param string $listRegExp Array with all the regular expressions to check. + * @return boolean True if there is at least one regular expression matching the string + */ +private static function someMatch($value, $listRegExp) { + assert('is_array($listRegExp)'); + + foreach($listRegExp as $regExp) { + if (preg_match($regExp, $value)) { + return true; + } + } + + return false; +} + +/** + * Convert the targeted ID to a SAML 2.0 name identifier element + * + * @param string $value The value of the attribute. + * @param string $source Identifier of the IdP. + * @param string $destination Identifier of the SP. + * @return string The XML representing the element + */ +private static function toNameId($value, $source, $destination) { + $nameId = array( + 'Format' => SAML2_Const::NAMEID_PERSISTENT, + 'Value' => $value, + ); + + if (!empty($source)) { + $nameId['NameQualifier'] = $source; + } + if (!empty($destination)) { + $nameId['SPNameQualifier'] = $destination; + } + + $doc = SAML2_DOMDocumentFactory::create(); + $root = $doc->createElement('root'); + $doc->appendChild($root); + + SAML2_Utils::addNameId($root, $nameId); + + return $doc->saveXML($root->firstChild); +} + +}