diff --git a/README.md b/README.md new file mode 100644 index 0000000..c05174c --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ + +Attributecollector +================== + +simplesamlphp auth proc filter, that get attributes from backend database and set to attributes array. + +This code is delivered from: +https://forja.rediris.es/svn/confia/attributecollector + +Basic configuration +=================== + +Configure this module as an Auth Proc Filter. More info at +http://rnd.feide.no/content/authentication-processing-filters-simplesamlphp + +Example +======= + +In the following example the filter is configured for only one hosted IdP +editing the file saml20-idp-hosted + +```php +$metadata = array( + + 'ssp-idp' => array( + + ... + + 'authproc' => array( + 10 => array( + 'existing' => 'preserve', + 'class' => 'attributecollector:AttributeCollector', + 'uidfield' => 'subject', + 'collector' => array( + 'class' => 'attributecollector:SQLCollector', + 'dsn' => 'pgsql:host=localhost;dbname=ssp-extra', + 'username' => 'ssp-extra', + 'password' => 'ssp-extra', + 'query' => 'SELECT * from extra where subject=:uidfield', + ) + ) + ), + + ... + + ) +); +``` + +Configuration Options explained +=============================== + +The filter needs the following options: + +- class: The filter class. Allways: 'attributecollector:AttributeCollector' +- uidfield: The name of the field used as an unique user identifier. The + configured collector recives this uid so it can search for extra + attributes. +- collector: The configuration of the collector used to retrieve the extra + attributes + +The following option is optional: + +- existing: Tell the filter what to do when a collected attribute already + exists in the user attributes. Values can be: + 'preserve': Ignore collected attribute and preserve the old one. + This one is the default behaviour. + 'replace': Ignore original attribute and replace it with the + collected one. + 'merge': Merge the collected attribute into the array of the + original one. + +Collector Configuration Options explained +========================================= + +The collector configuration array needs at least one option: + +- class: The collector class. + +Some other options may be needed by the collector, refer to the collector +documentation. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1e1d04d --- /dev/null +++ b/composer.json @@ -0,0 +1,8 @@ +{ + "name": "niif/simplesamlphp-module-attributecollector", + "description": "Collect attributes from backend databases like sql or ldap", + "type": "simplesamlphp-module", + "require": { + "simplesamlphp/composer-module-installer": "~1.0" + } +} diff --git a/default-enable b/default-enable new file mode 100644 index 0000000..e69de29 diff --git a/docs/ldapcollector.txt b/docs/ldapcollector.txt new file mode 100644 index 0000000..64dc18d --- /dev/null +++ b/docs/ldapcollector.txt @@ -0,0 +1,37 @@ +LDAP Attributes Collector + +This class implements a collector that retrieves attributes from a directory +server accessed via LDAP protocol. + +It has the following options: + + - host: LDAP server host + - port: LDAP server port + - protocol: LDAP protocol + - binddn: The username which should be used when connecting to the LDAP + server. + - password: The password which should be used when connecting to the LDAP + server. + - basedn: DN to start the LDAP search + - attrlist: An associative array of [LDAP attr1 => atr1, LDAP attr2 => atr2]. + This parameter is optional. Remove this param to get all attrs + - searchfilter: filter used to search the directory. You can use the special + :uidfield string to refer the value of the field specified as an uidfield in + the processor + + Example configuration: + + 'collector' => array( + 'class' => 'attributecollector:LDAPCollector', + 'host' => 'myldap.srv', + 'port' => 389, + 'binddn' => 'cn=myuser', + 'password' => 'yaco0909', + 'basedn' => 'dc=my,dc=org', + 'searchfilter' => 'uid=:uidfield', + 'protocol' => 3, + 'attrlist' => array( + // LDAP attr => real attr + 'objectClass' => 'myClasses', + ), + ), diff --git a/docs/sqlcollector.txt b/docs/sqlcollector.txt new file mode 100644 index 0000000..1438475 --- /dev/null +++ b/docs/sqlcollector.txt @@ -0,0 +1,44 @@ +SQL Attributes Collector + +This class implements a collector that retrieves attributes from a database. +It shoud word against both MySQL and PostgreSQL + +It has the following options: +- dsn: The DSN which should be used to connect to the database server. Check the various + database drivers in http://php.net/manual/en/pdo.drivers.php for a description of + the various DSN formats. +- username: The username which should be used when connecting to the database server. +- password: The password which should be used when connecting to the database server. +- query: The sql query for retrieve attributes. You can use the special :uidfield string + to refer the value of the field especified as an uidfield in the processor. + + +Example - with PostgreSQL database: + + 'collector' => array( + 'class' => 'attributecollector:SQLCollector', + 'dsn' => 'pgsql:host=localhost;dbname=simplesaml', + 'username' => 'simplesaml', + 'password' => 'secretpassword', + 'query' => array("SELECT address, phone, country from extraattributes where uid=:uidfield"), + 'get_all_query' => array("SELECT address, phone, country from extraattributes), + ) + +SQLCollector allows to specify several database connections which will +be used sequentially when a connection fails. This can be done +by defining each parameter by using an array. + +Example: + 'collector' => array( + 'class' => 'attributecollector:SQLCollector', + 'dsn' => array('oci:dbname=first', + 'mysql:host=localhost;dbname=second'), + 'username' => array('first', 'second'), + 'password' => array('first', 'second'), + 'query' => array("SELECT sid as SUBJECT from subjects where uid=:uidfield", + "SELECT sid as SUBJECT from subjects2 where uid=:uidfield AND status='OK'" + ), + 'get_all_query' => array("SELECT sid as SUBJECT from subjects", + "SELECT sid as SUBJECT from subjects2" + ), + ) diff --git a/lib/Auth/Process/AttributeCollector.php b/lib/Auth/Process/AttributeCollector.php new file mode 100644 index 0000000..b4cf8f3 --- /dev/null +++ b/lib/Auth/Process/AttributeCollector.php @@ -0,0 +1,83 @@ +uidfield = $config["uidfield"]; + $this->collector = $this->getCollector($config); + if (array_key_exists("existing", $config)) { + $this->existing = $config["existing"]; + } + } + + + /** + * Apply filter expand attributes with collected ones + * + * @param array &$request The current request + */ + public function process(&$request) { + assert('is_array($request)'); + assert('array_key_exists("Attributes", $request)'); + + if (array_key_exists($this->uidfield, $request['Attributes'])) { + + $newAttributes = $this->collector->getAttributes($request['Attributes'], $this->uidfield); + + if (is_array($newAttributes)) { + $attributes =& $request['Attributes']; + + foreach($newAttributes as $name => $values) { + if (!is_array($values)) { + $values = array($values); + } + if (!array_key_exists($name, $attributes) || $this->existing === 'replace') { + $attributes[$name] = $values; + } else { + if ($this->existing === 'merge') { + $attributes[$name] = array_merge($attributes[$name], $values); + } + } + } + } + } + } +} + +?> diff --git a/lib/Collector/LDAPCollector.php b/lib/Collector/LDAPCollector.php new file mode 100644 index 0000000..125f82e --- /dev/null +++ b/lib/Collector/LDAPCollector.php @@ -0,0 +1,274 @@ + LDAP attr1, Final attr2 + * => LDAP atr2] + * - searchfilter: filter used to search the directory. You can use the special + * :uidfield string to refer the value of the field specified as an uidfield in + * the processor + * + * Example configuration: + * + * + * 'collector' => array( + * 'class' => 'attributecollector:LDAPCollector', + * 'host' => 'myldap.srv', + * 'port' => 389, + * 'binddn' => 'cn=myuser', + * 'password' => 'yaco', + * 'basedn' => 'dc=my,dc=org', + * 'searchfilter' => 'uid=:uidfield', + * 'attrlist' => array( + * // Final attr => LDAP attr + * 'myClasses' => 'objectClass', + * ), + * ), + * + */ +class sspmod_attributecollector_Collector_LDAPCollector extends sspmod_attributecollector_SimpleCollector { + + + /** + * Host and port to connect to + */ + private $host; + private $port; + + /** + * Ldap Protocol + */ + private $protocol; + + /** + * Bind DN and password + */ + private $binddn; + private $password; + + /** + * Base DN to search LDAP + */ + private $basedn; + + + /** + * Attribute list to retrieve. Syntax: LDAPattr1 => Realattr1 + */ + private $attrlist; + + /** + * Search filter + */ + private $searchfilter; + + /** + * LDAP handler + */ + private $ds; + + + /* Initialize this collector. + * + * @param array $config Configuration information about this collector. + */ + public function __construct($config) { + + foreach (array('host', 'port', 'basedn', 'searchfilter') as $id) { + if (!array_key_exists($id, $config)) { + throw new Exception('attributecollector:LDAPCollector - Missing required option \'' . $id . '\'.'); + } + if ($id != 'port' && !is_string($config[$id])) { + throw new Exception('attributecollector:LDAPCollector - \'' . $id . '\' is supposed to be a string.'); + } + } + if (array_key_exists('attrlist', $config)) { + if (!is_array($config['attrlist'])) { + throw new Exception('attributecollector:LDAPCollector - \'' . $id . '\' is supposed to be an associative array.'); + } + $this->attrlist = $config['attrlist']; + } + + $this->host = $config['host']; + $this->port = $config['port']; + $this->basedn = $config['basedn']; + $this->searchfilter = $config['searchfilter']; + + if (!array_key_exists('protocol', $config)) { + $this->protocol = 3; + } else { + $this->protocol = (integer)$config['protocol']; + } + + if (array_key_exists('binddn', $config)) { + $this->binddn = $config['binddn']; + if (array_key_exists('password', $config)) { + $this->password = $config['password']; + } else { + throw new Exception('attributecollector:LDAPCollector - binddn is specified but no password is supplied'); + } + } else { + $this->binddn = NULL; + } + } + + + /* Get collected attributes + * + * @param array $originalAttributes Original attributes existing before this collector has been called + * @param string $uidfield Name of the field used as uid + * @return array Attributes collected + */ + public function getAttributes($originalAttributes, $uidfield) { + assert('array_key_exists($uidfield, $originalAttributes)'); + + // Bind to LDAP + $this->bindLdap(); + + $retattr = array(); + + $id = $originalAttributes[$uidfield][0]; + + // Prepare filter + $filter = preg_replace('/:uidfield/', $id, + $this->searchfilter); + + if ($this->attrlist) { + $fetch = array_unique(array_values($this->attrlist)); + $res = @ldap_search($this->ds, $this->basedn, $filter, $fetch); + } + else { + $res = @ldap_search($this->ds, $this->basedn, $filter); + } + + if ($res === FALSE) { + // Problem with LDAP search + throw new Exception('attributecollector:LDAPCollector - LDAP Error when trying to fetch attributes'); + } + + $entry = @ldap_first_entry($this->ds, $res); + $info = @ldap_get_attributes($this->ds, $entry); + + if ($info !== FALSE && is_array($info)) { + $retattr = $this->parse_ldap_result($info, $this->attrlist); + } + + return $retattr; + + } + + /** + * Connects and binds to the configured LDAP server. Stores LDAP + * handler in $this->ds + */ + private function bindLdap() { + // Bind to LDAP + $ds = ldap_connect($this->host, $this->port); + ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, $this->protocol); + if (is_null($ds)) { + throw new Exception('attributecollector:SQLCollector - Cannot connect to LDAP'); + } + + if ($this->binddn !== NULL) { + if (ldap_bind($ds, $this->binddn, $this->password) !== TRUE) { + throw new Exception('attributecollector:SQLCollector - Cannot bind to LDAP'); + } + } + + $this->ds = $ds; + } + + /* Get All entries + * + * @return array entries collected + */ + public function getAll($uidfield) { + $entries = array(); + + // Bind to LDAP + $this->bindLdap(); + + $filter = '('.$uidfield.'=*)'; + + if ($this->attrlist) { + $fetch = array_unique(array_values($this->attrlist)); + $res = ldap_search($this->ds, $this->basedn, $filter, $fetch); + } + else { + $res = ldap_search($this->ds, $this->basedn, $filter); + } + + + if ($res === FALSE) { + // Problem with LDAP search + throw new Exception('attributecollector:LDAPCollector - LDAP Error when trying to fetch attributes'); + } + + $info = ldap_get_entries($this->ds, $res); + + if ($info !== FALSE && is_array($info)) { + unset($info['count']); + foreach ($info as $entry) { + $result = $this->parse_ldap_result($entry, $this->attrlist); + + if (isset($result[$uidfield]) && !empty($result[$uidfield][0])) { + $id = $result[$uidfield][0]; + $entries[$id] = $result; + } else { + $entries[] = $result; + } + } + } + + return $entries; + } + + /** + * Retrieves attributes from a ldap_get_entries + * + * @param Array $entry LDAP result + * @param Array $attrlist Attribute list + * @access private + * @return Array Collected attributes for given entry + */ + private function parse_ldap_result($entry, $attrlist) { + $result = array(); + + // Assign values + if (is_array($this->attrlist)) { + // Take care of case sensitive + $entry = array_change_key_case($entry, CASE_LOWER); + foreach ($this->attrlist as $finalattr => $ldapattr) { + $ldapattr_lc = strtolower($ldapattr); + if (isset($entry[$ldapattr_lc]) && + $entry[$ldapattr_lc]['count'] > 0) { + unset ($entry[$ldapattr_lc]['count']); + $result[$finalattr] = $entry[$ldapattr_lc]; + } + } + } else { + foreach ($entry as $key => $value) { + if (!is_integer($key) && $entry[$key]['count'] > 0) { + $result[$key] = array($value[0]); + } + } + } + + return $result; + } + +} + +?> diff --git a/lib/Collector/SQLCollector.php b/lib/Collector/SQLCollector.php new file mode 100644 index 0000000..c3a3ab0 --- /dev/null +++ b/lib/Collector/SQLCollector.php @@ -0,0 +1,307 @@ + + * 'collector' => array( + * 'class' => 'attributecollector:SQLCollector', + * 'dsn' => 'pgsql:host=localhost;dbname=simplesaml', + * 'username' => 'simplesaml', + * 'password' => 'secretpassword', + * 'query' => 'select address, phone, country from extraattributes where uid=:uidfield', + * ), + * ), + * ), + * + * + * SQLCollector allows to specify several database connections which will + * be used sequentially when a connection fails. This can be done + * by defining each parameter by using an array. + * + * Example: + * 'collector' => array( + * 'class' => 'attributecollector:SQLCollector', + * 'dsn' => array('oci:dbname=first', + * 'mysql:host=localhost;dbname=second'), + * 'username' => array('first', 'second'), + * 'password' => array('first', 'second'), + * 'query' => array("SELECT sid as SUBJECT from subjects where uid=:uid", + * "SELECT sid as SUBJECT from subjects where uid=:uid AND status='OK'", + * ), + * ), + * ), + */ + +class sspmod_attributecollector_Collector_SQLCOllector extends sspmod_attributecollector_SimpleCollector { + + + /** + * DSN for the database. + */ + private $dsn; + + + /** + * Username for the database. + */ + private $username; + + + /** + * Password for the database; + */ + private $password; + + + /** + * Query for retrieving attributes + */ + private $query; + + + /** + * Query for retrieving all entries + */ + private $getAllQuery; + + /** + * Valid connection + */ + private $current; + + /** + * Total configured databases + */ + private $total; + + + /** + * Database handle. + * + * This variable can't be serialized. + */ + private $db; + + + /** + * Attribute name case. + * + * This is optional and by default is "natural" + */ + private $attrcase; + + + /* Initialize this collector. + * + * @param array $config Configuration information about this collector. + */ + public function __construct($config) { + $this->total = 0; + $this->current = 0; + + foreach (array('dsn', 'username', 'password', 'query', 'get_all_query') as $id) { + if (!array_key_exists($id, $config)) { + throw new Exception('attributecollector:SQLCollector - Missing required option \'' . $id . '\'.'); + } + + if (is_array($config[$id])) { + + // Check array size + if ($this->total == 0) { + $this->total = count($config[$id]); + } elseif (count($config[$id]) != $this->total) { + throw new Exception('attributecollector:SQLCollector - \'' . $id . '\' size != ' . $this->total); + } + + } elseif (is_string($config[$id])) { + // TODO: allow single values + // when using arrays on previous fields? + if ($this->total > 1) { + throw new Exception('attributecollector:SQLCollector - \'' . $id . '\' is supposed to be an array.'); + } + + $config[$id] = array($config[$id]); + $this->total = 1; + } else { + throw new Exception('attributecollector:SQLCollector - \'' . $id . '\' is supposed to be a string or array.'); + } + } + + $this->dsn = $config['dsn']; + $this->username = $config['username']; + $this->password = $config['password']; + $this->query = $config['query']; + $this->getAllQuery = $config['get_all_query']; + $this->current = 0; + + $case_options = array ("lower" => PDO::CASE_LOWER, + "natural" => PDO::CASE_NATURAL, + "upper" => PDO::CASE_UPPER); + // Default is 'natural' + $this->attrcase = $case_options["natural"]; + if (array_key_exists("attrcase", $config)) { + $attrcase = $config["attrcase"]; + if (in_array($attrcase, array_keys($case_options))) { + $this->attrcase = $case_options[$attrcase]; + } else { + throw new Exception("attributecollector:SQLCollector - Wrong case value: '" . $attrcase . "'"); + } + } + } + + + /* Get collected attributes + * + * @param array $originalAttributes Original attributes existing before this collector has been called + * @param string $uidfield Name of the field used as uid + * @return array Attributes collected + */ + public function getAttributes($originalAttributes, $uidfield) { + assert('array_key_exists($uidfield, $originalAttributes)'); + $db = $this->getDB(); + $st = $db->prepare($this->query[$this->current]); + if (FALSE === $st) { + $err = $st->errorInfo(); + $err_msg = 'attributecollector:SQLCollector - invalid query'; + if (isset($err[2])) { + $err_msg .= ': '.$err[2]; + } + throw new SimpleSAML_Error_Exception('attributecollector:SQLCollector - invalid query: '.$err[2]); + } + + $res = $st->execute(array(':uidfield' => $originalAttributes[$uidfield][0])); + + if (FALSE === $res){ + $err = $st->errorInfo(); + $err_msg = 'attributecollector:SQLCollector - invalid query execution'; + + if (isset($err[2])) { + $err_msg .= ': '.$err[2]; + } + else if (isset($err[0])) { + $err_msg .= ': SQLSTATE['.$err[0].']'; + } + throw new SimpleSAML_Error_Exception($err_msg); + } + + $db_res = $st->fetchAll(PDO::FETCH_ASSOC); + + $result = array(); + foreach($db_res as $tuple) { + foreach($tuple as $colum => $value) { + $result[$colum][] = $value; + } + } + foreach($result as $colum => $data) { + $result[$colum] = array_unique($data); + } + + return $result; + } + + + /** + * Get database handle. + * + * @return PDO|FALSE Database handle, or FALSE if we fail to connect. + */ + private function getDB() { + if ($this->db !== NULL) { + return $this->db; + } + + for ($i = 0; $i<$this->total; $i++) { + $this->current = $i; + try { + $this->db = new PDO($this->dsn[$i], $this->username[$i], $this->password[$i]); + } catch (PDOException $e) { + SimpleSAML_Logger::error('attributecollector:SQLCollector - skipping ' . $this->dsn[$i] . ': ' . $e->getMessage()); + // Error connecting to i-th database + continue; + } + break; + } + if ($this->db == NULL) { + throw new SimpleSAML_Error_Exception('attributecollector:SQLCollector - cannot connect to any database'); + } + $this->db->setAttribute(PDO::ATTR_CASE, $this->attrcase); + return $this->db; + } + + + /* Get All entries + * + * @return array entries collected + */ + public function getAll($uidfield) { + $entries = array(); + + $db = $this->getDB(); + + $query = $this->getAllQuery[$this->current]; + + $st = $db->prepare($query); + if (FALSE === $st) { + $err = $st->errorInfo(); + $err_msg = 'attributecollector:SQLCollector - invalid query'; + if (isset($err[2])) { + $err_msg .= ': '.$err[2]; + } + throw new SimpleSAML_Error_Exception('attributecollector:SQLCollector - invalid query: '.$err[2]); + } + + $res = $st->execute(array(':uidfield' => 'x')); + + if (FALSE === $res){ + $err = $st->errorInfo(); + $err_msg = 'attributecollector:SQLCollector - invalid query execution'; + + if (isset($err[2])) { + $err_msg .= ': '.$err[2]; + } + else if (isset($err[0])) { + $err_msg .= ': SQLSTATE['.$err[0].']'; + } + throw new SimpleSAML_Error_Exception($err_msg); + } + + $db_res = $st->fetchAll(PDO::FETCH_ASSOC); + + foreach($db_res as $entry) { + $info = array(); + foreach($entry as $colum => $value) { + $info[$colum][] = $value; + } + foreach($info as $colum => $data) { + $info[$colum] = array_unique($data); + } + if (isset($info[$uidfield]) && !empty($info[$uidfield][0])) { + $id = $info[$uidfield][0]; + $entries[$id] = $info; + } + else { + $entries[] = $info; + } + } + + return $entries; + } + +} + +?> diff --git a/lib/SimpleCollector.php b/lib/SimpleCollector.php new file mode 100644 index 0000000..1c11d98 --- /dev/null +++ b/lib/SimpleCollector.php @@ -0,0 +1,61 @@ + $values) { + if (!is_string($name)) { + throw new Exception('Invalid attribute name: ' . var_export($name, TRUE)); + } + + if (!is_array($values)) { + $values = array($values); + } + foreach($values as $value) { + if (!is_string($value)) { + throw new Exception('Invalid value for attribute ' . $name . ': ' . + var_export($values, TRUE)); + } + } + + $this->attributes[$name] = $values; + } + } + + /* Get collected attributes + * + * @param array $originalAttributes Original attributes existing before this collector has been called + * @param string $uidfield Name of the field used as uid + * @return array Attributes collected + */ + public function getAttributes($originalAttributes, $uidfield) { + return $this->attributes; + } + + /* Get users + * + * @return array Users collected + */ + public function getAll($uidfield) { + return array(); + } +} + +?>