From 12256ed2023091ba35c73df5e7f2e79ba13275a0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pavel=20B=C5=99ou=C5=A1ek?=
 <melanger@users.noreply.github.com>
Date: Fri, 25 Oct 2019 16:16:08 +0200
Subject: [PATCH] add store option to SQLPersistentNameID (#1219)

add store option to SQLPersistentNameID
---
 modules/saml/docs/nameid.md                   |  11 +-
 .../lib/Auth/Process/SQLPersistentNameID.php  |  15 +-
 modules/saml/lib/IdP/SQLNameID.php            | 183 +++++++++++++-----
 tests/modules/saml/lib/IdP/SQLNameIDTest.php  | 110 +++++++++++
 4 files changed, 268 insertions(+), 51 deletions(-)
 create mode 100644 tests/modules/saml/lib/IdP/SQLNameIDTest.php

diff --git a/modules/saml/docs/nameid.md b/modules/saml/docs/nameid.md
index 2c719552c..bf3ae2b84 100644
--- a/modules/saml/docs/nameid.md
+++ b/modules/saml/docs/nameid.md
@@ -63,10 +63,11 @@ No extra options are available for this filter.
 `saml:SQLPersistentNameID`
 --------------------------
 
-Generates and stores persistent NameIDs in a SQL datastore.
+Generates and stores persistent NameIDs in a SQL database.
 
-This filter generates and stores a persistent NameID in a SQL datastore.
-To use this filter, SimpleSAMLphp must be configured to use a SQL datastore.
+This filter generates and stores a persistent NameID in a SQL database.
+To use this filter, either specify the `store` option and a database,
+or configure SimpleSAMLphp to use a SQL datastore.
 See the `store.type` configuration option in `config.php`.
 
 ### Options
@@ -86,6 +87,10 @@ See the `store.type` configuration option in `config.php`.
 :   Whether to ignore an explicit `AllowCreate="false"` in the authentication request's NameIDPolicy.
     The default is `FALSE`, which will only create new NameIDs when the SP specifies `AllowCreate="true"` in the authentication request.
 
+`store`
+:   An array of database options passed to `\SimpleSAML\Database`, keys prefixed with `database.`.
+    The default is `[]`, which uses the global SQL datastore.
+
 Setting both `allowUnspecified` and `alwaysCreate` to `TRUE` causes `saml:SQLPersistentNameID` to behave like `saml:PersistentNameID` (and other NameID generation filters), at the expense of creating unnecessary entries in the SQL datastore.
 
 
diff --git a/modules/saml/lib/Auth/Process/SQLPersistentNameID.php b/modules/saml/lib/Auth/Process/SQLPersistentNameID.php
index fc3decdbd..b6c0f48cd 100644
--- a/modules/saml/lib/Auth/Process/SQLPersistentNameID.php
+++ b/modules/saml/lib/Auth/Process/SQLPersistentNameID.php
@@ -42,6 +42,13 @@ class SQLPersistentNameID extends \SimpleSAML\Module\saml\BaseNameIDGenerator
      */
     private $alwaysCreate = false;
 
+    /**
+     * Database store configuration.
+     *
+     * @var array
+     */
+    private $storeConfig = [];
+
 
     /**
      * Initialize this filter, parse configuration.
@@ -74,6 +81,10 @@ class SQLPersistentNameID extends \SimpleSAML\Module\saml\BaseNameIDGenerator
         if (isset($config['alwaysCreate'])) {
             $this->alwaysCreate = (bool) $config['alwaysCreate'];
         }
+
+        if (isset($config['store'])) {
+            $this->storeConfig = (array) $config['store'];
+        }
     }
 
 
@@ -148,7 +159,7 @@ class SQLPersistentNameID extends \SimpleSAML\Module\saml\BaseNameIDGenerator
             return null;
         }
 
-        $value = \SimpleSAML\Module\saml\IdP\SQLNameID::get($idpEntityId, $spEntityId, $uid);
+        $value = \SimpleSAML\Module\saml\IdP\SQLNameID::get($idpEntityId, $spEntityId, $uid, $this->storeConfig);
         if ($value !== null) {
             Logger::debug(
                 'SQLPersistentNameID: Found persistent NameID ' . var_export($value, true) . ' for user ' .
@@ -172,7 +183,7 @@ class SQLPersistentNameID extends \SimpleSAML\Module\saml\BaseNameIDGenerator
             'SQLPersistentNameID: Created persistent NameID ' . var_export($value, true) . ' for user ' .
             var_export($uid, true) . '.'
         );
-        \SimpleSAML\Module\saml\IdP\SQLNameID::add($idpEntityId, $spEntityId, $uid, $value);
+        \SimpleSAML\Module\saml\IdP\SQLNameID::add($idpEntityId, $spEntityId, $uid, $value, $this->storeConfig);
 
         return $value;
     }
diff --git a/modules/saml/lib/IdP/SQLNameID.php b/modules/saml/lib/IdP/SQLNameID.php
index 69fb3911c..d4f91d9d3 100644
--- a/modules/saml/lib/IdP/SQLNameID.php
+++ b/modules/saml/lib/IdP/SQLNameID.php
@@ -5,6 +5,8 @@ namespace SimpleSAML\Module\saml\IdP;
 use PDO;
 use SimpleSAML\Error;
 use SimpleSAML\Store;
+use SimpleSAML\Database;
+use SimpleSAML\Configuration;
 
 /**
  * Helper class for working with persistent NameIDs stored in SQL datastore.
@@ -13,40 +15,140 @@ use SimpleSAML\Store;
  */
 class SQLNameID
 {
+    const TABLE_VERSION = 1;
+    const DEFAULT_TABLE_PREFIX = '';
+    const TABLE_SUFFIX = '_saml_PersistentNameID';
+
+
     /**
-     * Create NameID table in SQL, if it is missing.
-     *
-     * @param \SimpleSAML\Store\SQL $store  The datastore.
+     * @param string $query
+     * @param array $params Parameters
+     * @param array $config
+     * @return \PDOStatement object
+     */
+    private static function read($query, array $params = [], array $config = [])
+    {
+        if (!empty($config)) {
+            $database = Database::getInstance(Configuration::loadFromArray($config));
+            $stmt = $database->read($query, $params);
+        } else {
+            $store = self::getStore();
+            $stmt = $store->pdo->prepare($query);
+            $stmt->execute($params);
+        }
+        return $stmt;
+    }
+
+
+    /**
+     * @param string $query
+     * @param array $params Parameters
+     * @param array $config
+     * @return int|false The number of rows affected by the query or false on error.
+     */
+    private static function write($query, array $params = [], array $config = [])
+    {
+        if (!empty($config)) {
+            $database = Database::getInstance(Configuration::loadFromArray($config));
+            $res = $database->write($query, $params);
+        } else {
+            $store = self::getStore();
+            $query = $store->pdo->prepare($query);
+            $res = $query->execute($params);
+            if ($res) {
+                $res = $query->rowCount();
+            }
+        }
+        return $res;
+    }
+
+
+    /**
+     * @param array $config
+     * @return string
+     */
+    private static function tableName(array $config = [])
+    {
+        $store = empty($config) ? self::getStore() : null;
+        $prefix = $store === null ? self::DEFAULT_TABLE_PREFIX : $store->prefix;
+        $table = $prefix . self::TABLE_SUFFIX;
+        return $table;
+    }
+
+    /**
+     * @param array $config
      * @return void
      */
-    private static function createTable(Store\SQL $store)
+    private static function create(array $config = [])
     {
-        if ($store->getTableVersion('saml_PersistentNameID') === 1) {
-            return;
+        $store = empty($config) ? self::getStore() : null;
+        $table = self::tableName($config);
+        if ($store === null) {
+            try {
+                self::createTable($table, $config);
+            } catch (\Exception $e) {
+                \SimpleSAML\Logger::debug('SQL persistent NameID table already exists.');
+            }
+        } elseif ($store->getTableVersion('saml_PersistentNameID') !== self::TABLE_VERSION) {
+            self::createTable($table);
+            $store->setTableVersion('saml_PersistentNameID', self::TABLE_VERSION);
         }
+    }
+
 
-        $query = 'CREATE TABLE ' . $store->prefix . '_saml_PersistentNameID (
+    /**
+     * @param string $query
+     * @param array $params
+     * @param array $config
+     * @return \PDOStatement
+     */
+    private static function createAndRead($query, array $params = [], array $config = [])
+    {
+        self::create($config);
+        return self::read($query, $params, $config);
+    }
+
+
+    /**
+     * @param string $query
+     * @param array $params
+     * @param array $config
+     * @return int|false The number of rows affected by the query or false on error.
+     */
+    private static function createAndWrite($query, array $params = [], array $config = [])
+    {
+        self::create($config);
+        return self::write($query, $params, $config);
+    }
+
+
+    /**
+     * Create NameID table in SQL.
+     *
+     * @param string $table  The table name.
+     * @param array $config
+     * @return void
+     */
+    private static function createTable($table, array $config = [])
+    {
+        $query = 'CREATE TABLE ' . $table . ' (
             _idp VARCHAR(256) NOT NULL,
             _sp VARCHAR(256) NOT NULL,
             _user VARCHAR(256) NOT NULL,
             _value VARCHAR(40) NOT NULL,
             UNIQUE (_idp, _sp, _user)
         )';
-        $store->pdo->exec($query);
-
-        $query = 'CREATE INDEX ' . $store->prefix . '_saml_PersistentNameID_idp_sp ON ';
-        $query .= $store->prefix . '_saml_PersistentNameID (_idp, _sp)';
-        $store->pdo->exec($query);
+        self::write($query, [], $config);
 
-        $store->setTableVersion('saml_PersistentNameID', 1);
+        $query = 'CREATE INDEX ' . $table . '_idp_sp ON ';
+        $query .= $table . ' (_idp, _sp)';
+        self::write($query, [], $config);
     }
 
 
     /**
      * Retrieve the SQL datastore.
      *
-     * Will also ensure that the NameID table is present.
-     *
      * @return \SimpleSAML\Store\SQL  SQL datastore.
      */
     private static function getStore()
@@ -58,8 +160,6 @@ class SQLNameID
             );
         }
 
-        self::createTable($store);
-
         return $store;
     }
 
@@ -67,22 +167,20 @@ class SQLNameID
     /**
      * Add a NameID into the database.
      *
-     * @param \SimpleSAML\Store\SQL $store  The data store.
      * @param string $idpEntityId  The IdP entityID.
      * @param string $spEntityId  The SP entityID.
      * @param string $user  The user's unique identificator (e.g. username).
      * @param string $value  The NameID value.
+     * @param array $config
      * @return void
      */
-    public static function add($idpEntityId, $spEntityId, $user, $value)
+    public static function add($idpEntityId, $spEntityId, $user, $value, array $config = [])
     {
         assert(is_string($idpEntityId));
         assert(is_string($spEntityId));
         assert(is_string($user));
         assert(is_string($value));
 
-        $store = self::getStore();
-
         $params = [
             '_idp' => $idpEntityId,
             '_sp' => $spEntityId,
@@ -90,10 +188,9 @@ class SQLNameID
             '_value' => $value,
         ];
 
-        $query = 'INSERT INTO ' . $store->prefix;
-        $query .= '_saml_PersistentNameID (_idp, _sp, _user, _value) VALUES(:_idp, :_sp, :_user, :_value)';
-        $query = $store->pdo->prepare($query);
-        $query->execute($params);
+        $query = 'INSERT INTO ' . self::tableName($config);
+        $query .= ' (_idp, _sp, _user, _value) VALUES(:_idp, :_sp, :_user, :_value)';
+        self::createAndWrite($query, $params, $config);
     }
 
 
@@ -103,26 +200,24 @@ class SQLNameID
      * @param string $idpEntityId  The IdP entityID.
      * @param string $spEntityId  The SP entityID.
      * @param string $user  The user's unique identificator (e.g. username).
+     * @param array $config
      * @return string|null $value  The NameID value, or NULL of no NameID value was found.
      */
-    public static function get($idpEntityId, $spEntityId, $user)
+    public static function get($idpEntityId, $spEntityId, $user, array $config = [])
     {
         assert(is_string($idpEntityId));
         assert(is_string($spEntityId));
         assert(is_string($user));
 
-        $store = self::getStore();
-
         $params = [
             '_idp' => $idpEntityId,
             '_sp' => $spEntityId,
             '_user' => $user,
         ];
 
-        $query = 'SELECT _value FROM ' . $store->prefix;
-        $query .= '_saml_PersistentNameID WHERE _idp = :_idp AND _sp = :_sp AND _user = :_user';
-        $query = $store->pdo->prepare($query);
-        $query->execute($params);
+        $query = 'SELECT _value FROM ' . self::tableName($config);
+        $query .= ' WHERE _idp = :_idp AND _sp = :_sp AND _user = :_user';
+        $query = self::createAndRead($query, $params, $config);
 
         $row = $query->fetch(PDO::FETCH_ASSOC);
         if ($row === false) {
@@ -140,26 +235,24 @@ class SQLNameID
      * @param string $idpEntityId  The IdP entityID.
      * @param string $spEntityId  The SP entityID.
      * @param string $user  The user's unique identificator (e.g. username).
+     * @param array $config
      * @return void
      */
-    public static function delete($idpEntityId, $spEntityId, $user)
+    public static function delete($idpEntityId, $spEntityId, $user, array $config = [])
     {
         assert(is_string($idpEntityId));
         assert(is_string($spEntityId));
         assert(is_string($user));
 
-        $store = self::getStore();
-
         $params = [
             '_idp' => $idpEntityId,
             '_sp' => $spEntityId,
             '_user' => $user,
         ];
 
-        $query = 'DELETE FROM ' . $store->prefix;
-        $query .= '_saml_PersistentNameID WHERE _idp = :_idp AND _sp = :_sp AND _user = :_user';
-        $query = $store->pdo->prepare($query);
-        $query->execute($params);
+        $query = 'DELETE FROM ' . self::tableName($config);
+        $query .= ' WHERE _idp = :_idp AND _sp = :_sp AND _user = :_user';
+        self::createAndWrite($query, $params, $config);
     }
 
 
@@ -168,24 +261,22 @@ class SQLNameID
      *
      * @param string $idpEntityId  The IdP entityID.
      * @param string $spEntityId  The SP entityID.
+     * @param array $config
      * @return array  Array of userid => NameID.
      */
-    public static function getIdentities($idpEntityId, $spEntityId)
+    public static function getIdentities($idpEntityId, $spEntityId, array $config = [])
     {
         assert(is_string($idpEntityId));
         assert(is_string($spEntityId));
 
-        $store = self::getStore();
-
         $params = [
             '_idp' => $idpEntityId,
             '_sp' => $spEntityId,
         ];
 
-        $query = 'SELECT _user, _value FROM ' . $store->prefix;
-        $query .= '_saml_PersistentNameID WHERE _idp = :_idp AND _sp = :_sp';
-        $query = $store->pdo->prepare($query);
-        $query->execute($params);
+        $query = 'SELECT _user, _value FROM ' . self::tableName($config);
+        $query .= ' WHERE _idp = :_idp AND _sp = :_sp';
+        $query = self::createAndRead($query, $params, $config);
 
         $res = [];
         while (($row = $query->fetch(PDO::FETCH_ASSOC)) !== false) {
diff --git a/tests/modules/saml/lib/IdP/SQLNameIDTest.php b/tests/modules/saml/lib/IdP/SQLNameIDTest.php
new file mode 100644
index 000000000..5aacfcf59
--- /dev/null
+++ b/tests/modules/saml/lib/IdP/SQLNameIDTest.php
@@ -0,0 +1,110 @@
+<?php
+
+namespace SimpleSAML\Test\Module\saml\IdP;
+
+/**
+ * Test for the SQLNameID helper class.
+ *
+ * @author Pavel Brousek <brousek@ics.muni.cz>
+ * @package SimpleSAMLphp
+ */
+
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Configuration;
+use SimpleSAML\Error;
+use SimpleSAML\Module\saml\IdP\SQLNameID;
+use SimpleSAML\Store;
+
+class SQLNameIDTest extends TestCase
+{
+    /**
+     * @param array $config
+     * @return void
+     */
+    private function addGetDelete(array $config = [])
+    {
+        SQLNameID::add('idp', 'sp', 'user', 'value', $config);
+        $this->assertEquals('value', SQLNameID::get('idp', 'sp', 'user', $config));
+        SQLNameID::delete('idp', 'sp', 'user', $config);
+        $this->assertNull(SQLNameID::get('idp', 'sp', 'user', $config));
+    }
+
+    /**
+     * Test Store.
+     * @test
+     * @return void
+     */
+    public function testSQLStore()
+    {
+        Configuration::loadFromArray([
+            'store.type'                    => 'sql',
+            'store.sql.dsn'                 => 'sqlite::memory:',
+            'store.sql.prefix'              => 'phpunit_',
+        ], '[ARRAY]', 'simplesaml');
+        $this->addGetDelete();
+        $config = Configuration::getInstance();
+        /** @var \SimpleSAML\Store $store */
+        $store = Store::getInstance();
+        $this->clearInstance($config, Configuration::class);
+        $this->clearInstance($store, Store::class);
+    }
+
+    /**
+     * Test incompatible Store.
+     * @test
+     * @return void
+     */
+    public function testIncompatibleStore()
+    {
+        Configuration::loadFromArray([
+            'store.type'                    => 'memcache',
+        ], '[ARRAY]', 'simplesaml');
+        $store = Store::getInstance();
+        $this->assertInstanceOf(Store\Memcache::class, $store);
+        $this->expectException(Error\Exception::class);
+        $this->addGetDelete();
+        $config = Configuration::getInstance();
+        /** @var \SimpleSAML\Store $store */
+        $store = Store::getInstance();
+        $this->clearInstance($config, Configuration::class);
+        $this->clearInstance($store, Store::class);
+    }
+
+    /**
+     * Test Database.
+     * @test
+     * @return void
+     */
+    public function testDatabase()
+    {
+        $config = [
+            'database.dsn'        => 'sqlite::memory:',
+            'database.username'   => null,
+            'database.password'   => null,
+            'database.prefix'     => 'phpunit_',
+            'database.persistent' => true,
+            'database.slaves'     => [
+                [
+                    'dsn'      => 'sqlite::memory:',
+                    'username' => null,
+                    'password' => null,
+                ],
+            ],
+        ];
+        $this->addGetDelete($config);
+    }
+
+    /**
+     * @param \SimpleSAML\Configuration|\SimpleSAML\Store $service
+     * @param string $className
+     * @return void
+     */
+    protected function clearInstance($service, $className)
+    {
+        $reflectedClass = new \ReflectionClass($className);
+        $reflectedInstance = $reflectedClass->getProperty('instance');
+        $reflectedInstance->setAccessible(true);
+        $reflectedInstance->setValue($service, null);
+        $reflectedInstance->setAccessible(false);
+    }
+}
-- 
GitLab