diff --git a/README.md b/README.md index 397d59f17c0a41976d1a522d5a312e9f7152ad23..5b885c16c76bb76cc0fb3bf884d310cfcd654624 100644 --- a/README.md +++ b/README.md @@ -73,3 +73,26 @@ Once you have installed SimpleSAMLphp, installing this module is very simple. Fi ``` 'instance_name' => 'Instance name', ``` + +### Writing via API +#### Configuration +Add the following (and adjust the credentials) to enable writing via the API (example request following). Methods supported are `POST,PUT`. +``` + 'apiWriteEnabled' => true, + 'apiWriteUsername' => 'api_writer', + 'apiWritePasswordHash' => password_hash('ap1Wr1T3rP@S$'), +``` +#### Example request +``` +curl --request POST \ + --url https://proxy.com/proxy/module.php/proxystatistics/writeLoginApi.php \ + --header 'Authorization: Basic encodedCredentials' \ + --header 'Content-Type: application/json' \ + --data '{ + "userId": "user@somewhere.edu", + "serviceIdentifier": "https://service.com/shibboleth", + "serviceName": "TEST_SERVICE", + "idpIdentifier": "https://idp.org/simplesamlphp", + "idpName": "TEST_IDP" +}' +``` \ No newline at end of file diff --git a/composer.json b/composer.json index 7e2d4843b4dc5d4834f355165c7fa890a7ffba63..5162fe84930306e30d89f3a72517e1db16de2ea0 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ } }, "require": { - "php": "^7.1 || ^8", + "php": "^7.3 || ^8", "ext-ctype": "*", "ext-filter": "*", "ext-json": "*", diff --git a/composer.lock b/composer.lock index 6b4956be89d98d34371d92fd94197911b3f3e7ab..ba3813c2415dc77f5a796c0cf527e9dd06c6160f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c6cf0239d2544ae5fb18d3e67487d9e8", + "content-hash": "bf645ff7f6627d746e1d24538affe30a", "packages": [ { "name": "gettext/gettext", @@ -5064,7 +5064,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^7.1 || ^8", + "php": "^7.3 || ^8", "ext-ctype": "*", "ext-filter": "*", "ext-json": "*", diff --git a/config-templates/module_proxystatistics.php b/config-templates/module_proxystatistics.php index 7ba0c874490b3dc91528a9445a65fe608aa8ccce..bcd137fc182905e808fb34f17c5e8d1abc2ac7ab 100644 --- a/config-templates/module_proxystatistics.php +++ b/config-templates/module_proxystatistics.php @@ -87,4 +87,19 @@ $config = [ * For how many days should the detailed statistics be kept. Minimum is 31. */ //'keepPerUser' => 62, + + /* + * Enables ability to write via an API + */ + //'apiWriteEnabled' => true, + + /* + * Username to protect API write endpoint (has no effect if write is disabled) + */ + //'apiWriteUsername' => 'api_writer', + + /* + * Password to protect API write endpoint (has no effect if write is disabled) + */ + //'apiWritePasswordHash' => password_hash('ap1Wr1T3rP@S$'), ]; diff --git a/lib/Auth/Process/Statistics.php b/lib/Auth/Process/Statistics.php index 6ac981be8814d6ed14f28c347096b5581722ce1f..345b42f55d53cbadfae5acf35a672cbcb3df0d11 100644 --- a/lib/Auth/Process/Statistics.php +++ b/lib/Auth/Process/Statistics.php @@ -26,7 +26,7 @@ class Statistics extends ProcessingFilter $dateTime = new DateTime(); $dbCmd = new DatabaseCommand(); try { - $dbCmd->insertLogin($request, $dateTime); + $dbCmd->insertLoginFromFilter($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 f18609a7a89a746ccf4d9841de6afe941c92c72c..e2a9ea96e281cca7fd2764f84625d50872b3c8a2 100644 --- a/lib/Config.php +++ b/lib/Config.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace SimpleSAML\Module\proxystatistics; use SimpleSAML\Configuration; +use SimpleSAML\Error\Exception; class Config { @@ -34,6 +35,12 @@ class Config private const KEEP_PER_USER = 'keepPerUser'; + private const API_WRITE_ENABLED = 'apiWriteEnabled'; + + private const API_WRITE_USERNAME = 'apiWriteUsername'; + + private const API_WRITE_PASSWORD_HASH = 'apiWritePasswordHash'; + private $config; private $store; @@ -50,6 +57,12 @@ class Config private $idAttribute; + private $apiWriteEnabled; + + private $apiWriteUsername; + + private $apiWritePasswordHash; + private static $instance; private function __construct() @@ -62,6 +75,17 @@ class Config $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'); + $this->apiWriteEnabled = $this->config->getBoolean(self::API_WRITE_ENABLED, false); + if ($this->apiWriteEnabled) { + $this->apiWriteUsername = $this->config->getString(self::API_WRITE_USERNAME); + if (empty(trim($this->apiWriteUsername))) { + throw new Exception('Username for API write cannot be empty'); + } + $this->apiWritePasswordHash = $this->config->getString(self::API_WRITE_PASSWORD_HASH); + if (empty(trim($this->apiWritePasswordHash))) { + throw new Exception('Password for API write cannot be empty'); + } + } } private function __clone() @@ -123,4 +147,19 @@ class Config { return $this->keepPerUser; } + + public function isApiWriteEnabled() + { + return $this->apiWriteEnabled; + } + + public function getApiWriteUsername() + { + return $this->apiWriteUsername; + } + + public function getApiWritePasswordHash() + { + return $this->apiWritePasswordHash; + } } diff --git a/lib/DatabaseCommand.php b/lib/DatabaseCommand.php index 5d8ccc8946cdf2d2922fe29f5d8e45701ed7ec58..1607a275ce0282d0ca74e49d2cff6f21e0e347a7 100644 --- a/lib/DatabaseCommand.php +++ b/lib/DatabaseCommand.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace SimpleSAML\Module\proxystatistics; +use DateTime; use Exception; use PDO; use PDOStatement; @@ -14,6 +15,16 @@ class DatabaseCommand { public const TABLE_SUM = 'statistics_sums'; + public const API_USER_ID = 'userId'; + + public const API_SERVICE_NAME = 'serviceName'; + + public const API_SERVICE_IDENTIFIER = 'serviceIdentifier'; + + public const API_IDP_NAME = 'idpName'; + + public const API_IDP_IDENTIFIER = 'idpIdentifier'; + private const DEBUG_PREFIX = 'proxystatistics:DatabaseCommand - '; private const TABLE_PER_USER = 'statistics_per_user'; @@ -66,21 +77,11 @@ class DatabaseCommand $this->mode = $this->config->getMode(); } - public function insertLogin($request, &$date) + public function insertLoginFromFilter($request, $date) { - $entities = $this->prepareEntitiesData($request); - - foreach (Config::SIDES as $side) { - 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; - } - } - + $entities = $this->getEntities($request); $userId = $this->getUserId($request); + $this->insertLogin($entities, $userId, $date); $ids = []; foreach (self::TABLE_SIDES as $side => $table) { @@ -88,7 +89,7 @@ class DatabaseCommand $ids[$tableId] = $this->getEntityDbIdFromEntityIdentifier($table, $entities[$side], $tableId); } - if ($this->writeLogin($date, $ids, $userId) === false) { + if (!$this->writeLogin($date, $ids, $userId)) { Logger::error(self::DEBUG_PREFIX . 'login record has not been inserted (data \'' . json_encode([ 'user' => $userId, 'ids' => $ids, @@ -97,6 +98,18 @@ class DatabaseCommand } } + public function insertLoginFromApi($data, DateTime $date) + { + $userId = $data[self::API_USER_ID]; + $serviceIdentifier = $data[self::API_SERVICE_IDENTIFIER]; + $serviceName = $data[self::API_SERVICE_NAME]; + $idpIdentifier = $data[self::API_IDP_IDENTIFIER]; + $idpName = $data[self::API_IDP_NAME]; + + $entities = $this->prepareEntitiesStructure($idpIdentifier, $idpName, $serviceIdentifier, $serviceName); + $this->insertLogin($entities, $userId, $date); + } + public function getEntityNameByEntityIdentifier($side, $id) { $table = self::TABLE_SIDES[$side]; @@ -165,21 +178,21 @@ class DatabaseCommand $msg = 'Aggregating daily statistics per ' . implode(' and ', array_filter($ids)); 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( + . '(' . $this->escapeCols(['year', 'month', 'day', 'idp_id', 'sp_id', 'logins', 'users']) . ') ' + . 'SELECT EXTRACT(YEAR FROM ' . $this->escapeCol( 'day' - ) . '), EXTRACT(MONTH FROM ' . $this->escape_col( + ) . '), EXTRACT(MONTH FROM ' . $this->escapeCol( 'day' - ) . '), EXTRACT(DAY FROM ' . $this->escape_col('day') . '), '; + ) . '), EXTRACT(DAY FROM ' . $this->escapeCol('day') . '), '; foreach ($ids as $id) { $query .= ($id === null ? '0' : $id) . ','; } - $query .= 'SUM(logins), COUNT(DISTINCT ' . $this->escape_col('user') . ') ' + $query .= 'SUM(logins), COUNT(DISTINCT ' . $this->escapeCol('user') . ') ' . 'FROM ' . $this->tables[self::TABLE_PER_USER] . ' ' . 'WHERE day<DATE(NOW()) ' . 'GROUP BY ' . $this->getAggregateGroupBy($ids) . ' '; if ($this->isPgsql()) { - $query .= 'ON CONFLICT (' . $this->escape_cols( + $query .= 'ON CONFLICT (' . $this->escapeCols( ['year', 'month', 'day', 'idp_id', 'sp_id'] ) . ') DO NOTHING;'; } elseif ($this->isMysql()) { @@ -199,13 +212,13 @@ class DatabaseCommand $msg = 'Deleting detailed statistics'; Logger::info(self::DEBUG_PREFIX . $msg); if ($this->isPgsql()) { - $make_date = 'MAKE_DATE(' . $this->escape_cols(['year', 'month', 'day']) . ')'; + $make_date = 'MAKE_DATE(' . $this->escapeCols(['year', 'month', 'day']) . ')'; $date_clause = sprintf('CURRENT_DATE - INTERVAL \'%s DAY\' ', $keepPerUserDays); $params = []; } elseif ($this->isMysql()) { - $make_date = 'STR_TO_DATE(CONCAT(' . $this->escape_col('year') . ",'-'," . $this->escape_col( + $make_date = 'STR_TO_DATE(CONCAT(' . $this->escapeCol('year') . ",'-'," . $this->escapeCol( 'month' - ) . ",'-'," . $this->escape_col('day') . "), '%Y-%m-%d')"; + ) . ",'-'," . $this->escapeCol('day') . "), '%Y-%m-%d')"; $date_clause = 'CURDATE() - INTERVAL :days DAY'; $params = [ 'days' => $keepPerUserDays, @@ -213,10 +226,10 @@ class DatabaseCommand } else { $this->unknownDriver(); } - $query = 'DELETE FROM ' . $this->tables[self::TABLE_PER_USER] . ' WHERE ' . $this->escape_col( + $query = 'DELETE FROM ' . $this->tables[self::TABLE_PER_USER] . ' WHERE ' . $this->escapeCol( 'day' ) . ' < ' . $date_clause - . ' AND ' . $this->escape_col( + . ' AND ' . $this->escapeCol( 'day' ) . ' IN (SELECT ' . $make_date . ' FROM ' . $this->tables[self::TABLE_SUM] . ')'; $written = $this->conn->write($query, $params); @@ -229,9 +242,32 @@ class DatabaseCommand } } - public static function prependColon($str): string + private function insertLogin($entities, $userId, $date) { - return ':' . $str; + foreach (Config::SIDES as $side) { + if (empty($entities[$side]['id'])) { + Logger::error( + self::DEBUG_PREFIX . 'idpEntityId or spEntityId is empty and login log was not inserted into the database.' + ); + + return; + } + } + + $ids = []; + foreach (self::TABLE_SIDES as $side => $table) { + $tableIdColumn = self::TABLE_IDS[$table]; + $ids[$tableIdColumn] = $this->getIdFromIdentifier($table, $entities[$side], $tableIdColumn); + } + + if ($this->writeLogin($date, $ids, $userId) === false) { + Logger::error(self::DEBUG_PREFIX . 'The login log was not inserted.'); + } + } + + private function escapeCol($col_name): string + { + return $this->escape_char . $col_name . $this->escape_char; } private function writeLogin($date, $ids, $user): bool @@ -255,10 +291,10 @@ 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) . ')' . + $query = 'INSERT INTO ' . $this->tables[self::TABLE_PER_USER] . ' (' . $this->escapeCols($fields) . ')' . ' VALUES (' . implode(', ', $placeholders) . ') '; if ($this->isPgsql()) { - $query .= 'ON CONFLICT (' . $this->escape_cols( + $query .= 'ON CONFLICT (' . $this->escapeCols( ['day', 'idp_id', 'sp_id', 'user'] ) . ') DO UPDATE SET "logins" = ' . $this->tables[self::TABLE_PER_USER] . '.logins + 1;'; } elseif ($this->isMysql()) { @@ -282,38 +318,6 @@ class DatabaseCommand return true; } - private function prepareEntitiesData($request): array - { - $entities = [ - Config::MODE_IDP => [], - Config::MODE_SP => [], - ]; - if ($this->mode !== Config::MODE_IDP && $this->mode !== Config::MODE_MULTI_IDP) { - $entities[Config::MODE_IDP][self::KEY_ID] = $this->getIdpIdentifier($request); - $entities[Config::MODE_IDP][self::KEY_NAME] = $this->getIdpName($request); - } - if ($this->mode !== Config::MODE_SP) { - $entities[Config::MODE_SP][self::KEY_ID] = $this->getSpIdentifier($request); - $entities[Config::MODE_SP][self::KEY_NAME] = $this->getSpName($request); - } - - if ($this->mode !== Config::MODE_PROXY && $this->mode !== Config::MODE_MULTI_IDP) { - $entities[$this->mode] = $this->config->getSideInfo($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 ($this->mode === Config::MODE_MULTI_IDP) { - $entities[Config::MODE_IDP] = $this->config->getSideInfo(Config::MODE_IDP); - 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 getEntityDbIdFromEntityIdentifier($table, $entity, $idColumn) { $identifier = $entity[self::KEY_ID]; @@ -362,6 +366,80 @@ class DatabaseCommand $query .= ' '; } + private function getEntities($request): array + { + $idpIdentifier = null; + $idpName = null; + $spIdentifier = null; + $spName = null; + if ($this->mode !== Config::MODE_IDP && $this->mode !== Config::MODE_MULTI_IDP) { + $idpIdentifier = $this->getIdpIdentifier($request); + $idpName = $this->getIdpName($request); + } + if ($this->mode !== Config::MODE_SP) { + $spIdentifier = $this->getSpIdentifier($request); + $spName = $this->getSpName($request); + } + + return $this->prepareEntitiesStructure($idpIdentifier, $idpName, $spIdentifier, $spName); + } + + private function prepareEntitiesStructure($idpIdentifier, $idpName, $spName, $spIdentifier): array + { + $entities = [ + Config::MODE_IDP => [], + Config::MODE_SP => [], + ]; + if ($this->mode !== Config::MODE_IDP && $this->mode !== Config::MODE_MULTI_IDP) { + $entities[Config::MODE_IDP][self::KEY_ID] = $idpIdentifier; + $entities[Config::MODE_IDP][self::KEY_NAME] = $idpName; + } + if ($this->mode !== Config::MODE_SP) { + $entities[Config::MODE_SP][self::KEY_ID] = $spIdentifier; + $entities[Config::MODE_SP][self::KEY_NAME] = $spName; + } + + if ($this->mode !== Config::MODE_PROXY && $this->mode !== Config::MODE_MULTI_IDP) { + $entities[$this->mode] = $this->config->getSideInfo($this->mode); + if (empty($entities[$this->mode][self::KEY_ID]) || empty($entities[$this->mode][self::KEY_NAME])) { + Logger::error('Invalid configuration (id, name) for ' . $this->mode); + } + } + + if ($this->mode === Config::MODE_MULTI_IDP) { + $entities[Config::MODE_IDP] = $this->config->getSideInfo(Config::MODE_IDP); + if (empty($entities[Config::MODE_IDP][self::KEY_ID]) || empty($entities[Config::MODE_IDP][self::KEY_NAME])) { + Logger::error('Invalid configuration (id, name) for ' . $this->mode); + } + } + + return $entities; + } + + private function getIdFromIdentifier($table, $entity, $idColumn) + { + $identifier = $entity['id']; + $name = $entity['name']; + $query = 'INSERT INTO ' . $this->tables[$table] . '(identifier, name) VALUES (:identifier, :name1) '; + if ($this->conn->getDriver() === 'pgsql') { + $query .= 'ON CONFLICT (identifier) DO UPDATE SET name = :name2;'; + } else { + $query .= 'ON DUPLICATE KEY UPDATE name = :name2'; + } + $this->conn->write($query, [ + 'identifier' => $identifier, + 'name1' => $name, + 'name2' => $name, + ]); + + return $this->read('SELECT ' . $idColumn . ' FROM ' . $this->tables[$table] + . ' WHERE identifier=:identifier', [ + 'identifier' => $identifier, + ]) + ->fetchColumn() + ; + } + private function addDaysRange($days, &$query, &$params, $not = false) { if ($days !== 0) { // 0 = all time @@ -392,12 +470,12 @@ class DatabaseCommand } } - private function escape_col($col_name): string + private function prependColon($str): string { - return $this->escape_char . $col_name . $this->escape_char; + return ':' . $str; } - private function escape_cols($col_names): string + private function escapeCols($col_names): string { return $this->escape_char . implode( $this->escape_char . ',' . $this->escape_char, @@ -414,7 +492,7 @@ class DatabaseCommand } } - return $this->escape_cols($columns); + return $this->escapeCols($columns); } private function getIdpIdentifier($request) diff --git a/www/writeLoginApi.php b/www/writeLoginApi.php new file mode 100644 index 0000000000000000000000000000000000000000..3e89dabc0a9c143aab6220b1a2feb1a9aed3f196 --- /dev/null +++ b/www/writeLoginApi.php @@ -0,0 +1,65 @@ +<?php + +declare(strict_types=1); + +use SimpleSAML\Logger; +use SimpleSAML\Module\proxystatistics\Config; +use SimpleSAML\Module\proxystatistics\DatabaseCommand; + +if ($_SERVER['REQUEST_METHOD'] !== 'POST' && $_SERVER['REQUEST_METHOD'] !== 'PUT') { + Logger::info( + 'proxystatistics:writeLoginApi - API write called not using POST nor PUT, returning 405 response code' + ); + header('HTTP/1.0 405 Method Not Allowed'); + exit; +} +$config = Config::getInstance(); + +if (!$config->isApiWriteEnabled()) { + Logger::info( + 'proxystatistics:writeLoginApi - API write called, but disabled in config. Returning 501 response code' + ); + header('HTTP/1.0 501 Not Implemented'); + exit; +} + +$authUsername = $_SERVER['PHP_AUTH_USER'] ?? ''; +$authPass = $_SERVER['PHP_AUTH_PW'] ?? ''; + +$username = $config->getApiWriteUsername(); +$passwordHash = $config->getApiWritePasswordHash(); + +// If we get here, username was provided. Check password. +if ($authUsername !== $username || !password_verify($authPass, $passwordHash)) { + Logger::info( + 'proxystatistics:writeLoginApi - API write called with bad credentials (' . $authUsername . ':' . $authPass . ') returning 401 response code' + ); + header('HTTP/1.0 401 Unauthorized'); + exit; +} + +try { + $data = json_decode(file_get_contents('php://input'), true, 5, JSON_THROW_ON_ERROR); +} catch (JsonException $e) { + header('HTTP/1.0 400 Bad Request'); + exit; +} + +if (!empty(array_diff( + [ + DatabaseCommand::API_USER_ID, DatabaseCommand::API_SERVICE_IDENTIFIER, DatabaseCommand::API_SERVICE_NAME, DatabaseCommand::API_IDP_IDENTIFIER, DatabaseCommand::API_IDP_NAME, ], + array_keys($data) +))) { + header('HTTP/1.0 400 Bad Request'); + exit; +} + +$dateTime = new DateTime(); +$dbCmd = new DatabaseCommand(); +try { + $dbCmd->insertLoginFromApi($data, $dateTime); +} catch (Exception $ex) { + Logger::error( + 'proxystatistics:writeLoginApi - Caught exception while inserting login into statistics: ' . $ex->getMessage() + ); +}