diff --git a/composer.json b/composer.json index 0801632ab68e49005dfccc7bbff7d23a7a23d6e9..11f8232b6be2f3e4142b4ac822c00b95c9d4e760 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,9 @@ "phpunit/phpunit": "~4.8", "satooshi/php-coveralls": "^1.0" }, + "suggests": { + "predis/predis": "1.1.1" + }, "support": { "issues": "https://github.com/simplesamlphp/simplesamlphp/issues", "source": "https://github.com/simplesamlphp/simplesamlphp" diff --git a/config-templates/config.php b/config-templates/config.php index 65f9c1fe15f8b9a2f315ac3495c1d7c23bcd8577..9b69ee3ac74ef811277a80afaece4844dace97d7 100644 --- a/config-templates/config.php +++ b/config-templates/config.php @@ -975,6 +975,7 @@ $config = array( * - 'phpsession': Limited datastore, which uses the PHP session. * - 'memcache': Key-value datastore, based on memcache. * - 'sql': SQL datastore, using PDO. + * - 'redis': Key-value datastore, based on redis. * * The default datastore is 'phpsession'. * @@ -1000,4 +1001,15 @@ $config = array( * The prefix we should use on our tables. */ 'store.sql.prefix' => 'SimpleSAMLphp', + + /* + * The hostname and port of the Redis datastore instance. + */ + 'store.redis.host' => 'localhost', + 'store.redis.port' => 6379, + + /* + * The prefix we should use on our Redis datastore. + */ + 'store.redis.prefix' => 'simpleSAMLphp', ); diff --git a/lib/SimpleSAML/Store.php b/lib/SimpleSAML/Store.php index 5ee6e43760d93df31f4d2651cbbd6c857bac4cc8..8f25b59c9194f2fc20843e7c30a428e33aa5f01e 100644 --- a/lib/SimpleSAML/Store.php +++ b/lib/SimpleSAML/Store.php @@ -51,6 +51,9 @@ abstract class Store case 'sql': self::$instance = new Store\SQL(); break; + case 'redis': + self::$instance = new Store\Redis(); + break; default: // datastore from module try { diff --git a/lib/SimpleSAML/Store/Redis.php b/lib/SimpleSAML/Store/Redis.php new file mode 100644 index 0000000000000000000000000000000000000000..579724b9561295c4a8fbb0a8c62babe68938abf1 --- /dev/null +++ b/lib/SimpleSAML/Store/Redis.php @@ -0,0 +1,112 @@ +<?php + +namespace SimpleSAML\Store; + +use \SimpleSAML_Configuration as Configuration; +use \SimpleSAML\Store; + +/** + * A data store using Redis to keep the data. + * + * @package SimpleSAMLphp + */ +class Redis extends Store +{ + /** + * Initialize the Redis data store. + */ + public function __construct($redis = null) + { + assert('is_null($redis) || is_subclass_of($redis, "Predis\\Client")'); + + if (is_null($redis)) { + $config = Configuration::getInstance(); + + $host = $config->getString('store.redis.host', 'localhost'); + $port = $config->getInteger('store.redis.port', 6379); + $prefix = $config->getString('store.redis.prefix', 'simpleSAMLphp'); + + $redis = new \Predis\Client( + array( + 'scheme' => 'tcp', + 'host' => $host, + 'post' => $port, + ), + array( + 'prefix' => $prefix, + ) + ); + } + + $this->redis = $redis; + } + + /** + * Deconstruct the Redis data store. + */ + public function __destruct() + { + if (method_exists($this->redis, 'disconnect')) { + $this->redis->disconnect(); + } + } + + /** + * Retrieve a value from the data store. + * + * @param string $type The type of the data. + * @param string $key The key to retrieve. + * + * @return mixed|null The value associated with that key, or null if there's no such key. + */ + public function get($type, $key) + { + assert('is_string($type)'); + assert('is_string($key)'); + + $result = $this->redis->get("{$type}.{$key}"); + + if ($result === false) { + return null; + } + + return unserialize($result); + } + + /** + * Save a value in the data store. + * + * @param string $type The type of the data. + * @param string $key The key to insert. + * @param mixed $value The value itself. + * @param int|null $expire The expiration time (unix timestamp), or null if it never expires. + */ + public function set($type, $key, $value, $expire = null) + { + assert('is_string($type)'); + assert('is_string($key)'); + assert('is_null($expire) || (is_int($expire) && $expire > 2592000)'); + + $serialized = serialize($value); + + if (is_null($expire)) { + $this->redis->set("{$type}.{$key}", $serialized); + } else { + $this->redis->setex("{$type}.{$key}", $expire, $serialized); + } + } + + /** + * Delete an entry from the data store. + * + * @param string $type The type of the data + * @param string $key The key to delete. + */ + public function delete($type, $key) + { + assert('is_string($type)'); + assert('is_string($key)'); + + $this->redis->del("{$type}.{$key}"); + } +} diff --git a/tests/lib/SimpleSAML/Store/RedisTest.php b/tests/lib/SimpleSAML/Store/RedisTest.php new file mode 100644 index 0000000000000000000000000000000000000000..7eecdf310b86e757b1fabff755036954470d9a10 --- /dev/null +++ b/tests/lib/SimpleSAML/Store/RedisTest.php @@ -0,0 +1,174 @@ +<?php + +namespace SimpleSAML\Test\Store; + +use \SimpleSAML_Configuration as Configuration; +use \SimpleSAML\Store; + +/** + * Tests for the Redis store. + * + * For the full copyright and license information, please view the LICENSE file that was distributed with this source + * code. + * + * @package simplesamlphp/simplesamlphp + */ +class RedisTest extends \PHPUnit_Framework_TestCase +{ + protected function setUp() + { + $this->config = array(); + + $this->mocked_redis = $this->getMockBuilder('Predis\Client') + ->setMethods(array('get', 'set', 'setex', 'del', 'disconnect')) + ->disableOriginalConstructor() + ->getMock(); + + $this->mocked_redis->method('get') + ->will($this->returnCallback(array($this, 'getMocked'))); + + $this->mocked_redis->method('set') + ->will($this->returnCallback(array($this, 'setMocked'))); + + $this->mocked_redis->method('setex') + ->will($this->returnCallback(array($this, 'setexMocked'))); + + $this->mocked_redis->method('del') + ->will($this->returnCallback(array($this, 'delMocked'))); + + $nop = function () { + return; + }; + + $this->mocked_redis->method('disconnect') + ->will($this->returnCallback($nop)); + + $this->redis = new Store\Redis($this->mocked_redis); + } + + public function getMocked($key) + { + return array_key_exists($key, $this->config) ? $this->config[$key] : false; + } + + public function setMocked($key, $value) + { + $this->config[$key] = $value; + } + + public function setexMocked($key, $expire, $value) + { + // Testing expiring data is more trouble than it's worth for now + $this->setMocked($key, $value); + } + + public function delMocked($key) + { + unset($this->config[$key]); + } + + /** + * @covers \SimpleSAML\Store::getInstance + * @covers \SimpleSAML\Store\Redis::__construct + * @test + */ + public function testRedisInstance() + { + $config = Configuration::loadFromArray(array( + 'store.type' => 'redis', + 'store.redis.prefix' => 'phpunit_', + ), '[ARRAY]', 'simplesaml'); + + $store = Store::getInstance(); + + $this->assertInstanceOf('SimpleSAML\Store\Redis', $store); + + $this->clearInstance($config, '\SimpleSAML_Configuration'); + $this->clearInstance($store, '\SimpleSAML\Store'); + } + + /** + * @covers \SimpleSAML\Store\Redis::get + * @covers \SimpleSAML\Store\Redis::set + * @test + */ + public function testInsertData() + { + $value = 'TEST'; + + $this->redis->set('test', 'key', $value); + $res = $this->redis->get('test', 'key'); + $expected = $value; + + $this->assertEquals($expected, $res); + } + + /** + * @covers \SimpleSAML\Store\Redis::get + * @covers \SimpleSAML\Store\Redis::set + * @test + */ + public function testInsertExpiringData() + { + $value = 'TEST'; + + $this->redis->set('test', 'key', $value, $expire = 80808080); + $res = $this->redis->get('test', 'key'); + $expected = $value; + + $this->assertEquals($expected, $res); + } + + /** + * @covers \SimpleSAML\Store\Redis::get + * @test + */ + public function testGetEmptyData() + { + $res = $this->redis->get('test', 'key'); + + $this->assertNull($res); + } + + /** + * @covers \SimpleSAML\Store\Redis::get + * @covers \SimpleSAML\Store\Redis::set + * @test + */ + public function testOverwriteData() + { + $value1 = 'TEST1'; + $value2 = 'TEST2'; + + $this->redis->set('test', 'key', $value1); + $this->redis->set('test', 'key', $value2); + $res = $this->redis->get('test', 'key'); + $expected = $value2; + + $this->assertEquals($expected, $res); + } + + /** + * @covers \SimpleSAML\Store\Redis::get + * @covers \SimpleSAML\Store\Redis::set + * @covers \SimpleSAML\Store\Redis::delete + * @test + */ + public function testDeleteData() + { + $this->redis->set('test', 'key', 'TEST'); + $this->redis->delete('test', 'key'); + $res = $this->redis->get('test', 'key'); + + $this->assertNull($res); + } + + protected function clearInstance($service, $className) + { + $reflectedClass = new \ReflectionClass($className); + $reflectedInstance = $reflectedClass->getProperty('instance'); + $reflectedInstance->setAccessible(true); + $reflectedInstance->setValue($service, null); + $reflectedInstance->setAccessible(false); + } +}