diff --git a/ecs.php b/ecs.php index 789982ee7a02d16392f8e954a1b27768ac35d665..700e64e1ae1e929affcea40cc2f18d2a950eb09d 100644 --- a/ecs.php +++ b/ecs.php @@ -5,12 +5,12 @@ declare(strict_types=1); use PhpCsFixer\Fixer\ArrayNotation\ArraySyntaxFixer; use PhpCsFixer\Fixer\FunctionNotation\FunctionTypehintSpaceFixer; use PhpCsFixer\Fixer\Operator\NotOperatorWithSuccessorSpaceFixer; -use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symplify\EasyCodingStandard\Config\ECSConfig; use Symplify\EasyCodingStandard\ValueObject\Option; use Symplify\EasyCodingStandard\ValueObject\Set\SetList; -return static function (ContainerConfigurator $containerConfigurator): void { - $parameters = $containerConfigurator->parameters(); +return static function (ECSConfig $ecsConfig): void { + $parameters = $ecsConfig->parameters(); $parameters->set(Option::PATHS, [ __DIR__ . '/ecs.php', __DIR__ . '/config-templates', @@ -23,22 +23,26 @@ return static function (ContainerConfigurator $containerConfigurator): void { $parameters->set(Option::PARALLEL, true); $parameters->set(Option::SKIP, [NotOperatorWithSuccessorSpaceFixer::class, FunctionTypehintSpaceFixer::class]); - $containerConfigurator->import(SetList::PHP_CS_FIXER); - $containerConfigurator->import(SetList::CLEAN_CODE); - $containerConfigurator->import(SetList::SYMPLIFY); - $containerConfigurator->import(SetList::ARRAY); - $containerConfigurator->import(SetList::COMMON); - $containerConfigurator->import(SetList::COMMENTS); - $containerConfigurator->import(SetList::CONTROL_STRUCTURES); - $containerConfigurator->import(SetList::DOCBLOCK); - $containerConfigurator->import(SetList::NAMESPACES); - $containerConfigurator->import(SetList::PHPUNIT); - $containerConfigurator->import(SetList::SPACES); - $containerConfigurator->import(SetList::STRICT); - $containerConfigurator->import(SetList::SYMFONY); - $containerConfigurator->import(SetList::PSR_12); + $ecsConfig->sets( + [ + SetList::PHP_CS_FIXER, + SetList::CLEAN_CODE, + SetList::SYMPLIFY, + SetList::ARRAY, + SetList::COMMON, + SetList::COMMENTS, + SetList::CONTROL_STRUCTURES, + SetList::DOCBLOCK, + SetList::NAMESPACES, + SetList::PHPUNIT, + SetList::SPACES, + SetList::STRICT, + SetList::SYMFONY, + SetList::PSR_12, + ] + ); - $services = $containerConfigurator->services(); + $services = $ecsConfig->services(); $services->set(ArraySyntaxFixer::class) ->call('configure', [[ 'syntax' => 'short', diff --git a/lib/Auth/Process/Statistics.php b/lib/Auth/Process/Statistics.php index 131d33e0842d368586f9524280797455df281be4..6ac981be8814d6ed14f28c347096b5581722ce1f 100644 --- a/lib/Auth/Process/Statistics.php +++ b/lib/Auth/Process/Statistics.php @@ -5,11 +5,17 @@ declare(strict_types=1); namespace SimpleSAML\Module\proxystatistics\Auth\Process; use DateTime; +use Exception; use SimpleSAML\Auth\ProcessingFilter; +use SimpleSAML\Logger; use SimpleSAML\Module\proxystatistics\DatabaseCommand; class Statistics extends ProcessingFilter { + private const STAGE = 'proxystatistics:Statistics'; + + private const DEBUG_PREFIX = self::STAGE . ' - '; + public function __construct($config, $reserved) { parent::__construct($config, $reserved); @@ -19,6 +25,12 @@ class Statistics extends ProcessingFilter { $dateTime = new DateTime(); $dbCmd = new DatabaseCommand(); - $dbCmd->insertLogin($request, $dateTime); + try { + $dbCmd->insertLogin($request, $dateTime); + } catch (Exception $ex) { + Logger::error( + self::DEBUG_PREFIX . 'Caught exception while inserting login into statistics: ' . $ex->getMessage() + ); + } } } diff --git a/lib/Config.php b/lib/Config.php index b0c5568045bdabef9f7eb96398b14af2c02f7983..4420cf77c060317826bc91fe9495bd04e8ad0376 100644 --- a/lib/Config.php +++ b/lib/Config.php @@ -20,6 +20,8 @@ class Config public const MODE_PROXY = 'PROXY'; + private const KNOWN_MODES = ['PROXY', 'IDP', 'SP', 'MULTI_IDP']; + private const STORE = 'store'; private const MODE = 'mode'; @@ -40,6 +42,14 @@ class Config private $sourceIdpEntityIdAttribute; + private $tables; + + private $keepPerUser; + + private $requiredAuthSource; + + private $idAttribute; + private static $instance; private function __construct() @@ -47,15 +57,18 @@ class Config $this->config = Configuration::getConfig(self::CONFIG_FILE_NAME); $this->store = $this->config->getConfigItem(self::STORE, null); $this->tables = $this->config->getArray('tables', []); - $this->mode = $this->config->getValueValidate(self::MODE, ['PROXY', 'IDP', 'SP', 'MULTI_IDP'], 'PROXY'); $this->sourceIdpEntityIdAttribute = $this->config->getString(self::SOURCE_IDP_ENTITY_ID_ATTRIBUTE, ''); + $this->mode = $this->config->getValueValidate(self::MODE, self::KNOWN_MODES, self::MODE_PROXY); + $this->keepPerUser = $this->config->getIntegerRange(self::KEEP_PER_USER, 31, 1827, 31); + $this->requiredAuthSource = $this->config->getString(self::REQUIRE_AUTH_SOURCE, ''); + $this->idAttribute = $this->config->getString(self::USER_ID_ATTRIBUTE, 'uid'); } private function __clone() { } - public static function getInstance() + public static function getInstance(): self { if (null === self::$instance) { self::$instance = new self(); @@ -81,7 +94,7 @@ class Config public function getIdAttribute() { - return $this->config->getString(self::USER_ID_ATTRIBUTE, 'uid'); + return $this->idAttribute; } public function getSourceIdpEntityIdAttribute() @@ -89,9 +102,11 @@ class Config return $this->sourceIdpEntityIdAttribute; } - public function getSideInfo($side) + public function getSideInfo(string $side) { - assert(in_array($side, [self::SIDES], true)); + if (!in_array($side, self::SIDES, true)) { + throw new \Exception('Unrecognized side parameter value passed \'' . $side . '\'.'); + } return array_merge([ 'name' => '', @@ -101,11 +116,11 @@ class Config public function getRequiredAuthSource() { - return $this->config->getString(self::REQUIRE_AUTH_SOURCE, ''); + return$this->requiredAuthSource; } public function getKeepPerUser() { - return $this->config->getIntegerRange(self::KEEP_PER_USER, 31, 1827, 31); + return $this->keepPerUser; } } diff --git a/lib/DatabaseCommand.php b/lib/DatabaseCommand.php index 3af3b34452a1a5e3a0797c8bae245e8c3a5fbeae..52be98befc840bd43945bbed6a7abbd33c808143 100644 --- a/lib/DatabaseCommand.php +++ b/lib/DatabaseCommand.php @@ -4,13 +4,16 @@ declare(strict_types=1); namespace SimpleSAML\Module\proxystatistics; +use Exception; use PDO; +use PDOStatement; use SimpleSAML\Database; use SimpleSAML\Logger; class DatabaseCommand { public const TABLE_SUM = 'statistics_sums'; + private const DEBUG_PREFIX = 'proxystatistics:DatabaseCommand - '; private const TABLE_PER_USER = 'statistics_per_user'; @@ -18,6 +21,10 @@ class DatabaseCommand private const TABLE_SP = 'statistics_sp'; + private const KEY_ID = 'id'; + + private const KEY_NAME = 'name'; + private const TABLE_SIDES = [ Config::MODE_IDP => self::TABLE_IDP, Config::MODE_SP => self::TABLE_SP, @@ -47,25 +54,26 @@ class DatabaseCommand { $this->config = Config::getInstance(); $this->conn = Database::getInstance($this->config->getStore()); - if ('pgsql' === $this->conn->getDriver()) { + if ($this->isPgsql()) { $this->escape_char = '"'; + } elseif ($this->isMysql()) { + $this->escape_char = '`'; + } else { + $this->unknownDriver(); } $this->tables = array_merge($this->tables, $this->config->getTables()); $this->mode = $this->config->getMode(); } - public static function prependColon($str) - { - return ':' . $str; - } - - public function insertLogin(&$request, &$date) + public function insertLogin($request, &$date) { - $entities = $this->getEntities($request); + $entities = $this->prepareEntitiesData($request); foreach (Config::SIDES as $side) { - if (empty($entities[$side]['id'])) { - Logger::error('idpEntityId or spEntityId is empty and login log was not inserted into the database.'); + if (empty($entities[$side][self::KEY_ID])) { + Logger::error( + self::DEBUG_PREFIX . 'idpEntityId or spEntityId is empty and login log was not inserted into the database.' + ); return; } @@ -76,15 +84,19 @@ class DatabaseCommand $ids = []; foreach (self::TABLE_SIDES as $side => $table) { $tableId = self::TABLE_IDS[$table]; - $ids[$tableId] = $this->getIdFromIdentifier($table, $entities[$side], $tableId); + $ids[$tableId] = $this->getEntityDbIdFromEntityIdentifier($table, $entities[$side], $tableId); } if (false === $this->writeLogin($date, $ids, $userId)) { - Logger::error('The login log was not inserted.'); + Logger::error(self::DEBUG_PREFIX . 'login record has not been inserted (data \'' . json_encode([ + 'user' => $userId, + 'ids' => $ids, + 'date' => $date, + ]) . '\'.'); } } - public function getNameById($side, $id) + public function getEntityNameByEntityIdentifier($side, $id) { $table = self::TABLE_SIDES[$side]; @@ -101,10 +113,12 @@ class DatabaseCommand public function getLoginCountPerDay($days, $where = []) { $params = []; - if ('pgsql' === $this->conn->getDriver()) { + if ($this->isPgsql()) { $query = "SELECT EXTRACT(epoch FROM TO_DATE(CONCAT(year,'-',month,'-',day), 'YYYY-MM-DD')) AS day, "; - } else { + } elseif ($this->isMysql()) { $query = "SELECT UNIX_TIMESTAMP(STR_TO_DATE(CONCAT(year,'-',month,'-',day), '%Y-%m-%d')) AS day, "; + } else { + $this->unknownDriver(); } $query .= 'logins AS count, users ' . 'FROM ' . $this->tables[self::TABLE_SUM] . ' ' . @@ -148,7 +162,7 @@ class DatabaseCommand foreach ([self::TABLE_IDS[self::TABLE_SP], null] as $sp_id) { $ids = [$idp_id, $sp_id]; $msg = 'Aggregating daily statistics per ' . implode(' and ', array_filter($ids)); - Logger::info($msg); + Logger::info(self::DEBUG_PREFIX . $msg); $query = 'INSERT INTO ' . $this->tables[self::TABLE_SUM] . ' ' . '(' . $this->escape_cols(['year', 'month', 'day', 'idp_id', 'sp_id', 'logins', 'users']) . ') ' . 'SELECT EXTRACT(YEAR FROM ' . $this->escape_col( @@ -163,16 +177,18 @@ class DatabaseCommand . 'FROM ' . $this->tables[self::TABLE_PER_USER] . ' ' . 'WHERE day<DATE(NOW()) ' . 'GROUP BY ' . $this->getAggregateGroupBy($ids) . ' '; - if ('pgsql' === $this->conn->getDriver()) { + if ($this->isPgsql()) { $query .= 'ON CONFLICT (' . $this->escape_cols( ['year', 'month', 'day', 'idp_id', 'sp_id'] ) . ') DO NOTHING;'; - } else { + } elseif ($this->isMysql()) { $query .= 'ON DUPLICATE KEY UPDATE id=id;'; + } else { + $this->unknownDriver(); } // do nothing if row already exists if (!$this->conn->write($query)) { - Logger::warning($msg . ' failed'); + Logger::warning(self::DEBUG_PREFIX . $msg . ' failed'); } } } @@ -180,12 +196,12 @@ class DatabaseCommand $keepPerUserDays = $this->config->getKeepPerUser(); $msg = 'Deleting detailed statistics'; - Logger::info($msg); - if ('pgsql' === $this->conn->getDriver()) { + Logger::info(self::DEBUG_PREFIX . $msg); + if ($this->isPgsql()) { $make_date = 'MAKE_DATE(' . $this->escape_cols(['year', 'month', 'day']) . ')'; $date_clause = sprintf('CURRENT_DATE - INTERVAL \'%s DAY\' ', $keepPerUserDays); $params = []; - } else { + } elseif ($this->isMysql()) { $make_date = 'STR_TO_DATE(CONCAT(' . $this->escape_col('year') . ",'-'," . $this->escape_col( 'month' ) . ",'-'," . $this->escape_col('day') . "), '%Y-%m-%d')"; @@ -193,6 +209,8 @@ class DatabaseCommand $params = [ 'days' => $keepPerUserDays, ]; + } else { + $this->unknownDriver(); } $query = 'DELETE FROM ' . $this->tables[self::TABLE_PER_USER] . ' WHERE ' . $this->escape_col( 'day' @@ -200,56 +218,33 @@ class DatabaseCommand . ' AND ' . $this->escape_col( 'day' ) . ' IN (SELECT ' . $make_date . ' FROM ' . $this->tables[self::TABLE_SUM] . ')'; - if ( - !$this->conn->write($query, $params) - ) { - Logger::warning($msg . ' failed'); + $written = $this->conn->write($query, $params); + if (is_bool($written) && !$written) { + Logger::warning(self::DEBUG_PREFIX . $msg . ' failed'); + } elseif (0 === $written) { + Logger::warning(self::DEBUG_PREFIX . $msg . ' completed, but updated 0 rows.'); + } else { + Logger::info(self::DEBUG_PREFIX . $msg . ' completed and updated ' . $written . ' rows.'); } } - private function escape_col($col_name) + public static function prependColon($str): string { - return $this->escape_char . $col_name . $this->escape_char; - } - - private function escape_cols($col_names) - { - return $this->escape_char . implode( - $this->escape_char . ',' . $this->escape_char, - $col_names - ) . $this->escape_char; + return ':' . $str; } - private function read($query, $params) + private function writeLogin($date, $ids, $user): bool { - return $this->conn->read($query, $params); - } + if (empty($user)) { + Logger::warning(self::DEBUG_PREFIX . 'user is unknown, cannot insert login. Ending prematurely.'); - private function addWhereId($where, &$query, &$params) - { - $parts = []; - foreach ($where as $side => $value) { - $table = self::TABLE_SIDES[$side]; - $column = self::TABLE_IDS[$table]; - $part = $column; - if (null === $value) { - $part .= '=0'; - } else { - $part .= '=:id'; - $params['id'] = $value; - } - $parts[] = $part; - } - if (empty($parts)) { - $parts[] = '1=1'; + return false; } - $query .= implode(' AND ', $parts); - $query .= ' '; - } + if (empty($ids[self::TABLE_IDS[self::TABLE_IDP]]) || empty($ids[self::TABLE_IDS[self::TABLE_SP]])) { + Logger::warning( + self::DEBUG_PREFIX . 'no IDP_ID or SP_ID has been provided, cannot insert login. Ending prematurely.' + ); - private function writeLogin($date, $ids, $user) - { - if (empty($user)) { return false; } $params = array_merge($ids, [ @@ -260,59 +255,75 @@ class DatabaseCommand $fields = array_keys($params); $placeholders = array_map(['self', 'prependColon'], $fields); $query = 'INSERT INTO ' . $this->tables[self::TABLE_PER_USER] . ' (' . $this->escape_cols($fields) . ')' . - ' VALUES (' . implode(', ', $placeholders) . ') '; - if ('pgsql' === $this->conn->getDriver()) { + ' VALUES (' . implode(', ', $placeholders) . ') '; + if ($this->isPgsql()) { $query .= 'ON CONFLICT (' . $this->escape_cols( ['day', 'idp_id', 'sp_id', 'user'] ) . ') DO UPDATE SET "logins" = ' . $this->tables[self::TABLE_PER_USER] . '.logins + 1;'; - } else { + } elseif ($this->isMysql()) { $query .= 'ON DUPLICATE KEY UPDATE logins = logins + 1;'; + } else { + $this->unknownDriver(); + } + + $written = $this->conn->write($query, $params); + if (is_bool($written) && !$written) { + Logger::debug(self::DEBUG_PREFIX . 'login entry write has failed.'); + + return false; } + if (0 === $written) { + Logger::debug(self::DEBUG_PREFIX . 'login entry has been inserted, but has updated 0 rows.'); - return $this->conn->write($query, $params); + return false; + } + + return true; } - private function getEntities($request): array + private function prepareEntitiesData($request): array { $entities = [ Config::MODE_IDP => [], Config::MODE_SP => [], ]; if (Config::MODE_IDP !== $this->mode && Config::MODE_MULTI_IDP !== $this->mode) { - $entities[Config::MODE_IDP]['id'] = $this->getIdpIdentifier($request); - $entities[Config::MODE_IDP]['name'] = $this->getIdpName($request); + $entities[Config::MODE_IDP][self::KEY_ID] = $this->getIdpIdentifier($request); + $entities[Config::MODE_IDP][self::KEY_NAME] = $this->getIdpName($request); } if (Config::MODE_SP !== $this->mode) { - $entities[Config::MODE_SP]['id'] = $this->getSpIdentifier($request); - $entities[Config::MODE_SP]['name'] = $this->getSpName($request); + $entities[Config::MODE_SP][self::KEY_ID] = $this->getSpIdentifier($request); + $entities[Config::MODE_SP][self::KEY_NAME] = $this->getSpName($request); } if (Config::MODE_PROXY !== $this->mode && Config::MODE_MULTI_IDP !== $this->mode) { $entities[$this->mode] = $this->config->getSideInfo($this->mode); - if (empty($entities[$this->mode]['id']) || empty($entities[$this->mode]['name'])) { - Logger::error('Invalid configuration (id, name) for ' . $this->mode); + if (empty($entities[$this->mode][self::KEY_ID]) || empty($entities[$this->mode][self::KEY_NAME])) { + Logger::error(self::DEBUG_PREFIX . 'Invalid configuration (id, name) for ' . $this->mode); } } if (Config::MODE_MULTI_IDP === $this->mode) { $entities[Config::MODE_IDP] = $this->config->getSideInfo(Config::MODE_IDP); - if (empty($entities[Config::MODE_IDP]['id']) || empty($entities[Config::MODE_IDP]['name'])) { - Logger::error('Invalid configuration (id, name) for ' . $this->mode); + if (empty($entities[Config::MODE_IDP][self::KEY_ID]) || empty($entities[Config::MODE_IDP][self::KEY_NAME])) { + Logger::error(self::DEBUG_PREFIX . 'Invalid configuration (id, name) for ' . $this->mode); } } return $entities; } - private function getIdFromIdentifier($table, $entity, $idColumn) + private function getEntityDbIdFromEntityIdentifier($table, $entity, $idColumn) { - $identifier = $entity['id']; - $name = $entity['name']; + $identifier = $entity[self::KEY_ID]; + $name = $entity[self::KEY_NAME]; $query = 'INSERT INTO ' . $this->tables[$table] . '(identifier, name) VALUES (:identifier, :name1) '; - if ('pgsql' === $this->conn->getDriver()) { + if ($this->isPgsql()) { $query .= 'ON CONFLICT (identifier) DO UPDATE SET name = :name2;'; - } else { + } elseif ($this->isMysql()) { $query .= 'ON DUPLICATE KEY UPDATE name = :name2'; + } else { + $this->unknownDriver(); } $this->conn->write($query, [ 'identifier' => $identifier, @@ -323,9 +334,31 @@ class DatabaseCommand return $this->read('SELECT ' . $idColumn . ' FROM ' . $this->tables[$table] . ' WHERE identifier=:identifier', [ 'identifier' => $identifier, - ]) - ->fetchColumn() - ; + ])->fetchColumn(); + } + + // Query construction helper methods + + private function addWhereId($where, &$query, &$params) + { + $parts = []; + foreach ($where as $side => $value) { + $table = self::TABLE_SIDES[$side]; + $column = self::TABLE_IDS[$table]; + $part = $column; + if (null === $value) { + $part .= '=0'; + } else { + $part .= '=:id'; + $params['id'] = $value; + } + $parts[] = $part; + } + if (empty($parts)) { + $parts[] = '1=1'; + } + $query .= implode(' AND ', $parts); + $query .= ' '; } private function addDaysRange($days, &$query, &$params, $not = false) @@ -336,17 +369,19 @@ class DatabaseCommand } else { $query .= 'AND'; } - if ('pgsql' === $this->conn->getDriver()) { + if ($this->isPgsql()) { $query .= ' MAKE_DATE(year,month,day) '; - } else { + } elseif ($this->isMysql()) { $query .= " CONCAT(year,'-',LPAD(month,2,'00'),'-',LPAD(day,2,'00')) "; + } else { + $this->unknownDriver(); } if ($not) { $query .= 'NOT '; } - if ('pgsql' === $this->conn->getDriver()) { + if ($this->isPgsql()) { if (!is_int($days) && !ctype_digit($days)) { - throw new \Exception('days have to be an integer'); + throw new Exception('days have to be an integer'); } $query .= sprintf('BETWEEN CURRENT_DATE - INTERVAL \'%s DAY\' AND CURRENT_DATE ', $days); } else { @@ -356,7 +391,20 @@ class DatabaseCommand } } - private function getAggregateGroupBy($ids) + private function escape_col($col_name): string + { + return $this->escape_char . $col_name . $this->escape_char; + } + + private function escape_cols($col_names): string + { + return $this->escape_char . implode( + $this->escape_char . ',' . $this->escape_char, + $col_names + ) . $this->escape_char; + } + + private function getAggregateGroupBy($ids): string { $columns = ['day']; foreach ($ids as $id) { @@ -402,6 +450,27 @@ class DatabaseCommand $displayName = $request['Destination']['name']['en'] ?? ''; } - return$displayName; + return $displayName; + } + + private function read($query, $params): PDOStatement + { + return $this->conn->read($query, $params); + } + + private function isPgsql(): bool + { + return 'pgsql' === $this->conn->getDriver(); + } + + private function isMysql(): bool + { + return 'mysql' === $this->conn->getDriver(); + } + + private function unknownDriver() + { + Logger::error(self::DEBUG_PREFIX . 'unsupported DB driver \'' . $this->conn->getDriver()); + throw new Exception('Unsupported DB driver'); } } diff --git a/lib/Templates.php b/lib/Templates.php index b64841dfa70e86c8619e9572241db3d06d082340..cc66dc2f36c484c0cfc98de426e5b0548fa9d30c 100644 --- a/lib/Templates.php +++ b/lib/Templates.php @@ -10,6 +10,8 @@ use SimpleSAML\XHTML\Template; class Templates { + private const DEBUG_PREFIX = 'proxystatistics:Templates - '; + private const INSTANCE_NAME = 'instance_name'; public static function showProviders($side, $tab) @@ -95,7 +97,7 @@ class Templates } $t->data['head'] .= Utils::metaData('translations', $translations); - $name = $dbCmd->getNameById($side, $id); + $name = $dbCmd->getEntityNameByEntityIdentifier($side, $id); $t->data['header'] = $t->t('{proxystatistics:stats:' . $side . 'Detail_header_name}') . $name; $t->data['htmlinject']['htmlContentPost'][] @@ -148,7 +150,9 @@ class Templates if (null !== $instanceName) { $t->data['header'] = $instanceName . ' ' . $t->data['header']; } else { - Logger::warning('Missing configuration: config.php - instance_name is not set.'); + Logger::warning( + self::DEBUG_PREFIX . 'missing configuration option in config.php - instance_name is not set.' + ); } self::headIncludes($t);