<?php namespace SimpleSAML\Module\saml\SP; /** * A directory over logout information. * * @package SimpleSAMLphp */ class LogoutStore { /** * Create logout table in SQL, if it is missing. * * @param \SimpleSAML\Store\SQL $store The datastore. */ private static function createLogoutTable(\SimpleSAML\Store\SQL $store) { $tableVer = $store->getTableVersion('saml_LogoutStore'); if ($tableVer === 2) { return; } elseif ($tableVer === 1) { /* TableVersion 2 increased the column size to 255 which is the maximum length of a FQDN. */ switch ($store->driver) { case 'pgsql': // This does not affect the NOT NULL constraint $query = 'ALTER TABLE ' . $store->prefix . '_saml_LogoutStore ALTER COLUMN _authSource TYPE VARCHAR(255)'; break; default: $query = 'ALTER TABLE ' . $store->prefix . '_saml_LogoutStore MODIFY _authSource VARCHAR(255) NOT NULL'; break; } try { $store->pdo->exec($query); } catch (\Exception $e) { \SimpleSAML\Logger::warning($store->pdo->errorInfo()); return; } $store->setTableVersion('saml_LogoutStore', 2); return; } $query = 'CREATE TABLE ' . $store->prefix . '_saml_LogoutStore ( _authSource VARCHAR(255) NOT NULL, _nameId VARCHAR(40) NOT NULL, _sessionIndex VARCHAR(50) NOT NULL, _expire TIMESTAMP NOT NULL, _sessionId VARCHAR(50) NOT NULL, UNIQUE (_authSource, _nameID, _sessionIndex) )'; $store->pdo->exec($query); $query = 'CREATE INDEX ' . $store->prefix . '_saml_LogoutStore_expire ON ' . $store->prefix . '_saml_LogoutStore (_expire)'; $store->pdo->exec($query); $query = 'CREATE INDEX ' . $store->prefix . '_saml_LogoutStore_nameId ON ' . $store->prefix . '_saml_LogoutStore (_authSource, _nameId)'; $store->pdo->exec($query); $store->setTableVersion('saml_LogoutStore', 2); } /** * Clean the logout table of expired entries. * * @param \SimpleSAML\Store\SQL $store The datastore. */ private static function cleanLogoutStore(\SimpleSAML\Store\SQL $store) { \SimpleSAML\Logger::debug('saml.LogoutStore: Cleaning logout store.'); $query = 'DELETE FROM ' . $store->prefix . '_saml_LogoutStore WHERE _expire < :now'; $params = array('now' => gmdate('Y-m-d H:i:s')); $query = $store->pdo->prepare($query); $query->execute($params); } /** * Register a session in the SQL datastore. * * @param \SimpleSAML\Store\SQL $store The datastore. * @param string $authId The authsource ID. * @param string $nameId The hash of the users NameID. * @param string $sessionIndex The SessionIndex of the user. */ private static function addSessionSQL(\SimpleSAML\Store\SQL $store, $authId, $nameId, $sessionIndex, $expire, $sessionId) { assert(is_string($authId)); assert(is_string($nameId)); assert(is_string($sessionIndex)); assert(is_string($sessionId)); assert(is_int($expire)); self::createLogoutTable($store); if (rand(0, 1000) < 10) { self::cleanLogoutStore($store); } $data = array( '_authSource' => $authId, '_nameId' => $nameId, '_sessionIndex' => $sessionIndex, '_expire' => gmdate('Y-m-d H:i:s', $expire), '_sessionId' => $sessionId, ); $store->insertOrUpdate($store->prefix . '_saml_LogoutStore', array('_authSource', '_nameId', '_sessionIndex'), $data); } /** * Retrieve sessions from the SQL datastore. * * @param \SimpleSAML\Store\SQL $store The datastore. * @param string $authId The authsource ID. * @param string $nameId The hash of the users NameID. * @return array Associative array of SessionIndex => SessionId. */ private static function getSessionsSQL(\SimpleSAML\Store\SQL $store, $authId, $nameId) { assert(is_string($authId)); assert(is_string($nameId)); self::createLogoutTable($store); $params = array( '_authSource' => $authId, '_nameId' => $nameId, 'now' => gmdate('Y-m-d H:i:s'), ); // We request the columns in lowercase in order to be compatible with PostgreSQL $query = 'SELECT _sessionIndex AS _sessionindex, _sessionId AS _sessionid FROM ' . $store->prefix . '_saml_LogoutStore' . ' WHERE _authSource = :_authSource AND _nameId = :_nameId AND _expire >= :now'; $query = $store->pdo->prepare($query); $query->execute($params); $res = array(); while ( ($row = $query->fetch(\PDO::FETCH_ASSOC)) !== false) { $res[$row['_sessionindex']] = $row['_sessionid']; } return $res; } /** * Retrieve all session IDs from a key-value store. * * @param \SimpleSAML\Store $store The datastore. * @param string $authId The authsource ID. * @param string $nameId The hash of the users NameID. * @param array $sessionIndexes The session indexes. * @return array Associative array of SessionIndex => SessionId. */ private static function getSessionsStore(\SimpleSAML\Store $store, $authId, $nameId, array $sessionIndexes) { assert(is_string($authId)); assert(is_string($nameId)); $res = array(); foreach ($sessionIndexes as $sessionIndex) { $sessionId = $store->get('saml.LogoutStore', $nameId . ':' . $sessionIndex); if ($sessionId === null) { continue; } assert(is_string($sessionId)); $res[$sessionIndex] = $sessionId; } return $res; } /** * Register a new session in the datastore. * * Please observe the change of the signature in this method. Previously, the second parameter ($nameId) was forced * to be an array. However, it has no type restriction now, and the documentation states it must be a * \SAML2\XML\saml\NameID object. Currently, this function still accepts an array passed as $nameId, and will * silently convert it to a \SAML2\XML\saml\NameID object. This is done to keep backwards-compatibility, though will * no longer be possible in the future as the $nameId parameter will be required to be an object. * * @param string $authId The authsource ID. * @param \SAML2\XML\saml\NameID $nameId The NameID of the user. * @param string|null $sessionIndex The SessionIndex of the user. */ public static function addSession($authId, $nameId, $sessionIndex, $expire) { assert(is_string($authId)); assert(is_string($sessionIndex) || $sessionIndex === null); assert(is_int($expire)); if ($sessionIndex === null) { /* This IdP apparently did not include a SessionIndex, and thus probably does not * support SLO. We still want to add the session to the data store just in case * it supports SLO, but we don't want an LogoutRequest with a specific * SessionIndex to match this session. We therefore generate our own session index. */ $sessionIndex = \SimpleSAML\Utils\Random::generateID(); } $store = \SimpleSAML\Store::getInstance(); if ($store === false) { // We don't have a datastore. return; } // serialize and anonymize the NameID // TODO: remove this conditional statement if (is_array($nameId)) { $nameId = \SAML2\XML\saml\NameID::fromArray($nameId); } $strNameId = serialize($nameId); $strNameId = sha1($strNameId); /* Normalize SessionIndex. */ if (strlen($sessionIndex) > 50) { $sessionIndex = sha1($sessionIndex); } $session = \SimpleSAML\Session::getSessionFromRequest(); $sessionId = $session->getSessionId(); if ($store instanceof \SimpleSAML\Store\SQL) { self::addSessionSQL($store, $authId, $strNameId, $sessionIndex, $expire, $sessionId); } else { $store->set('saml.LogoutStore', $strNameId . ':' . $sessionIndex, $sessionId, $expire); } } /** * Log out of the given sessions. * * @param string $authId The authsource ID. * @param \SAML2\XML\saml\NameID $nameId The NameID of the user. * @param array $sessionIndexes The SessionIndexes we should log out of. Logs out of all if this is empty. * @returns int|false Number of sessions logged out, or FALSE if not supported. */ public static function logoutSessions($authId, $nameId, array $sessionIndexes) { assert(is_string($authId)); $store = \SimpleSAML\Store::getInstance(); if ($store === false) { /* We don't have a datastore. */ return false; } // serialize and anonymize the NameID // TODO: remove this conditional statement if (is_array($nameId)) { $nameId = \SAML2\XML\saml\NameID::fromArray($nameId); } $strNameId = serialize($nameId); $strNameId = sha1($strNameId); /* Normalize SessionIndexes. */ foreach ($sessionIndexes as &$sessionIndex) { assert(is_string($sessionIndex)); if (strlen($sessionIndex) > 50) { $sessionIndex = sha1($sessionIndex); } } unset($sessionIndex); // Remove reference if ($store instanceof \SimpleSAML\Store\SQL) { $sessions = self::getSessionsSQL($store, $authId, $strNameId); } elseif (empty($sessionIndexes)) { /* We cannot fetch all sessions without a SQL store. */ return false; } else { /** @var \SimpleSAML\Store $sessions At this point the store cannot be false */ $sessions = self::getSessionsStore($store, $authId, $strNameId, $sessionIndexes); } if (empty($sessionIndexes)) { $sessionIndexes = array_keys($sessions); } $numLoggedOut = 0; foreach ($sessionIndexes as $sessionIndex) { if (!isset($sessions[$sessionIndex])) { \SimpleSAML\Logger::info('saml.LogoutStore: Logout requested for unknown SessionIndex.'); continue; } $sessionId = $sessions[$sessionIndex]; $session = \SimpleSAML\Session::getSession($sessionId); if ($session === null) { \SimpleSAML\Logger::info('saml.LogoutStore: Skipping logout of missing session.'); continue; } if (!$session->isValid($authId)) { \SimpleSAML\Logger::info('saml.LogoutStore: Skipping logout of session because it isn\'t authenticated.'); continue; } \SimpleSAML\Logger::info('saml.LogoutStore: Logging out of session with trackId [' . $session->getTrackID() . '].'); $session->doLogout($authId); $numLoggedOut += 1; } return $numLoggedOut; } }