From 351eb01bfb1ae47a0414875383cf080d42a06e19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Bar=C3=A1nek?= <0Baranek.dominik0@gmail.com> Date: Fri, 5 Mar 2021 13:54:54 +0100 Subject: [PATCH] Security improvement of the call of updateUes.php script --- composer.json | 7 +- config-templates/tables.sql | 12 +- hooks/hook_cron.php | 51 +++++++++ lib/Auth/Process/UpdateUserExtSource.php | 66 ++++++++++- lib/ScriptsUtils.php | 133 +++++++++++++++++++++++ www/getChallenge.php | 51 +++++++++ www/updateUes.php | 92 +++++++++++++++- 7 files changed, 399 insertions(+), 13 deletions(-) create mode 100644 hooks/hook_cron.php create mode 100644 lib/ScriptsUtils.php create mode 100644 www/getChallenge.php diff --git a/composer.json b/composer.json index e99e612c..14bab4e2 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,11 @@ "cesnet/simplesamlphp-module-chartjs": "~2.8.0", "symfony/var-exporter": "^5.0", "ext-curl": "*", - "ext-json": "*" + "ext-json": "*", + "ext-openssl": "*", + "web-token/jwt-key-mgmt": "^2.2", + "web-token/jwt-signature": "^2.2", + "web-token/jwt-signature-algorithm-rsa": "^2.2", + "web-token/jwt-checker": "^2.2" } } diff --git a/config-templates/tables.sql b/config-templates/tables.sql index a0af0461..a5a19188 100644 --- a/config-templates/tables.sql +++ b/config-templates/tables.sql @@ -13,4 +13,14 @@ CREATE TABLE greyList ( reason VARCHAR(255), INDEX (entityId), PRIMARY KEY (entityId) -); \ No newline at end of file +); + +CREATE TABLE scriptChallenges ( + id VARCHAR(255) NOT NULL, + challenge VARCHAR(255) NOT NULL, + script VARCHAR(255) NOT NULL, + date DATETIME NOT NULL DEFAULT current_timestamp, + PRIMARY KEY (id) +); + +CREATE INDEX idx_date on scriptChallenges (date); diff --git a/hooks/hook_cron.php b/hooks/hook_cron.php new file mode 100644 index 00000000..081159cc --- /dev/null +++ b/hooks/hook_cron.php @@ -0,0 +1,51 @@ +<?php + +use SimpleSAML\Logger; +use SimpleSAML\Module\perun\DatabaseConnector; + +const TABLE_NAME = 'scriptChallenges'; +const DATE_COLUMN = 'date'; + +/** + * Hook to run a cron job. + * + * @param array &$croninfo Output + * @author Dominik Baranek <baranek@ics.muni.cz> + */ +function challenges_hook_cron(&$croninfo) +{ + if ($croninfo['tag'] !== 'hourly') { + Logger::debug('cron [perun]: Skipping cron in cron tag [' . $croninfo['tag'] . '] '); + return; + } + + Logger::info('cron [perun]: Running cron in cron tag [' . $croninfo['tag'] . '] '); + + try { + $databaseConnector = new DatabaseConnector(); + $conn = $databaseConnector->getConnection(); + + if ($conn !== null) { + $stmt = $conn->prepare( + 'DELETE FROM ' . TABLE_NAME . ' WHERE ' . DATE_COLUMN . ' < (NOW() - INTERVAL 5 MINUTE)' + ); + + if (!$stmt) { + $conn->close(); + Logger::error('cron [perun]: Error during preparing statement'); + return; + } + + $ex = $stmt->execute(); + + if ($ex === false) { + Logger::error('cron [perun]: Error while deleting old challenges from the database.'); + } + + $stmt->close(); + $conn->close(); + } + } catch (\Exception $e) { + $croninfo['summary'][] = 'Error while deleting old challenges from the database: ' . $e->getMessage(); + } +} diff --git a/lib/Auth/Process/UpdateUserExtSource.php b/lib/Auth/Process/UpdateUserExtSource.php index b99b22b0..9cf124a5 100644 --- a/lib/Auth/Process/UpdateUserExtSource.php +++ b/lib/Auth/Process/UpdateUserExtSource.php @@ -2,14 +2,16 @@ namespace SimpleSAML\Module\perun\Auth\Process; +use Jose\Component\KeyManagement\JWKFactory; +use Jose\Component\Core\AlgorithmManager; +use Jose\Component\Signature\JWSBuilder; +use Jose\Component\Signature\Serializer\CompactSerializer; +use Jose\Component\Signature\Algorithm\RS512; use SimpleSAML\Auth\ProcessingFilter; use SimpleSAML\Error\Exception; use SimpleSAML\Logger; use SimpleSAML\Module; -use SimpleSAML\Module\perun\AttributeUtils; use SimpleSAML\Module\perun\UpdateUESThread; -use SimpleSAML\Configuration; -use SimpleSAML\Module\perun; /** * Class sspmod_perun_Auth_Process_UpdateUserExtSource @@ -23,6 +25,9 @@ class UpdateUserExtSource extends ProcessingFilter { private $attrMap; private $attrsToConversion; + private $pathToKey; + + const SCRIPT_NAME = 'updateUes'; public function __construct($config, $reserved) { @@ -36,6 +41,12 @@ class UpdateUserExtSource extends ProcessingFilter ); } + if (!isset($config['pathToKey'])) { + throw new Exception( + 'perun:UpdateUserExtSource: missing mandatory configuration option \'pathToKey\'.' + ); + } + if (isset($config['arrayToStringConversion'])) { $this->attrsToConversion = (array)$config['arrayToStringConversion']; } else { @@ -43,10 +54,36 @@ class UpdateUserExtSource extends ProcessingFilter } $this->attrMap = (array)$config['attrMap']; + $this->pathToKey = $config['pathToKey']; } public function process(&$request) { + $id = uniqid("", true); + + $dataChallenge = [ + 'id' => $id, + 'scriptName' => self::SCRIPT_NAME + ]; + + $json = json_encode($dataChallenge); + + $curlChallenge = curl_init(); + curl_setopt($curlChallenge, CURLOPT_POSTFIELDS, $json); + curl_setopt($curlChallenge, CURLOPT_URL, Module::getModuleURL('perun/getChallenge.php')); + curl_setopt($curlChallenge, CURLOPT_RETURNTRANSFER, true); + + $challenge = curl_exec($curlChallenge); + + if (empty($challenge)) { + Logger::error('Retrieving the challenge was not successful.'); + return; + } + + $jwk = JWKFactory::createFromKeyFile($this->pathToKey); + $algorithmManager = new AlgorithmManager([new RS512()]); + $jwsBuilder = new JWSBuilder($algorithmManager); + $data = [ 'attributes' => $request['Attributes'], 'attrMap' => $this->attrMap, @@ -54,8 +91,27 @@ class UpdateUserExtSource extends ProcessingFilter 'perunUserId' => $request['perun']['user']->getId() ]; - $cmd = 'curl -X POST -H "Content-Type: application/json" -d \'' . json_encode($data) . '\' ' . - Module::getModuleURL('perun/updateUes.php') . ' > /dev/null &'; + $payload = json_encode([ + 'iat' => time(), + 'nbf' => time(), + 'exp' => time() + 300, + 'challenge' => $challenge, + 'id' => $id, + 'data' => $data + ]); + + $jws = $jwsBuilder + ->create() + ->withPayload($payload) + ->addSignature($jwk, ['alg' => 'RS512']) + ->build(); + + $serializer = new CompactSerializer(); + $token = $serializer->serialize($jws, 0); + + $cmd = 'curl -X POST -H "Content-Type: application/json" -d ' . escapeshellarg($token) . ' ' . + escapeshellarg(Module::getModuleURL('perun/updateUes.php')) . ' > /dev/null &'; + exec($cmd); } } diff --git a/lib/ScriptsUtils.php b/lib/ScriptsUtils.php new file mode 100644 index 00000000..4bc1547e --- /dev/null +++ b/lib/ScriptsUtils.php @@ -0,0 +1,133 @@ +<?php + + +namespace SimpleSAML\Module\perun; + + +class ScriptsUtils +{ + const CHALLENGES_TABLE_NAME = 'scriptChallenges'; + const CHALLENGE = 'challenge'; + + public static function generateChallenge($connection, $challenge, $id, $scriptName): bool + { + if ($connection === null || empty($challenge)) { + Logger::error('Perun:ScriptsUtils: Error while creating a challenge'); + http_response_code(500); + return false; + } + + $stmt = $connection->prepare( + 'INSERT INTO ' . self::CHALLENGES_TABLE_NAME . ' (id, challenge, script) VALUES (?, ?, ?)' + ); + + if ($stmt) { + $stmt->bind_param('sss', $id, $challenge, $scriptName); + $ex = $stmt->execute(); + + if ($ex === false) { + Logger::error('Perun:ScriptsUtils: Error while creating a challenge'); + http_response_code(500); + return false; + } + + $stmt->close(); + } else { + Logger::error('Perun:ScriptsUtils: Error during preparing statement'); + http_response_code(500); + return false; + } + + return true; + } + + public static function readChallengeFromDb($connection, $id) + { + if ($connection === null) { + http_response_code(500); + return null; + } + + if (empty($id)) { + http_response_code(400); + return null; + } + + $stmt = $connection->prepare('SELECT challenge FROM ' . self::CHALLENGES_TABLE_NAME . ' WHERE id=?'); + + if (!$stmt) { + Logger::error('Perun:ScriptsUtils: Error during preparing statement'); + http_response_code(500); + return null; + } + + $stmt->bind_param('s', $id); + $ex = $stmt->execute(); + + if ($ex === false) { + Logger::error('Perun:ScriptsUtils: Error while getting the challenge from the database.'); + http_response_code(500); + return null; + } + + $challengeDb = $stmt->get_result()->fetch_assoc()[self::CHALLENGE]; + $stmt->close(); + + return $challengeDb; + } + + public static function checkAccess($connection, $challenge, $challengeDb): bool + { + if ($connection === null) { + http_response_code(500); + return false; + } + + if (empty($challenge) || empty($challengeDb)) { + http_response_code(400); + return false; + } + + if (!hash_equals($challengeDb, $challenge)) { + Logger::error('Perun:ScriptsUtils: Hashes are not equal.'); + http_response_code(401); + return false; + } + + return true; + } + + public static function deleteChallengeFromDb($connection, $id): bool + { + if ($connection === null) { + http_response_code(500); + return false; + } + + if (empty($id)) { + http_response_code(400); + return false; + } + + $stmt = $connection->prepare('DELETE FROM ' . self::CHALLENGES_TABLE_NAME . ' WHERE id=?'); + + if ($stmt) { + $stmt->bind_param('s', $id); + $ex = $stmt->execute(); + + if ($ex === false) { + Logger::error('Perun:ScriptsUtils: Error while deleting the challenge from the database.'); + http_response_code(500); + return false; + } + + $stmt->close(); + } else { + Logger::error('Perun:ScriptsUtils: Error during preparing statement'); + http_response_code(500); + return false; + } + + return true; + } +} diff --git a/www/getChallenge.php b/www/getChallenge.php new file mode 100644 index 00000000..881c290d --- /dev/null +++ b/www/getChallenge.php @@ -0,0 +1,51 @@ +<?php + +use SimpleSAML\Module\perun\DatabaseConnector; +use SimpleSAML\Logger; +use SimpleSAML\Module\perun\ScriptsUtils; + +$entityBody = file_get_contents('php://input'); +$body = json_decode($entityBody, true); + +if ($body === false) { + Logger::error('Perun.getChallenge: Received invalid json.'); + http_response_code(400); + exit; +} + +if (empty($body['id'] || strlen($body['id']) > 30 || !ctype_print($body['id']))) { + Logger::error('Perun.getChallenge: Invalid id'); + http_response_code(400); + exit; +} + +if (empty($body['scriptName']) || strlen($body['scriptName']) > 255 || !ctype_print($body['scriptName'])) { + http_response_code(400); + Logger::error('Perun.getChallenge: Invalid scriptName'); + exit; +} + +$id = $body['id']; +$scriptName = $body['scriptName']; + +const RANDOM_BYTES_LENGTH = 32; +const TABLE_NAME = 'scriptChallenges'; + +try { + $challenge = hash('sha256', random_bytes(RANDOM_BYTES_LENGTH)); +} catch (Exception $ex) { + Logger::error('Perun.getChallenge: Error while generating a challenge'); + http_response_code(500); + exit; +} + +$databaseConnector = new DatabaseConnector(); +$conn = $databaseConnector->getConnection(); +$generateChallengeSucceeded = ScriptsUtils::generateChallenge($conn, $challenge, $id, $scriptName); +$conn->close(); + +if (!$generateChallengeSucceeded) { + exit; +} + +echo $challenge; diff --git a/www/updateUes.php b/www/updateUes.php index ec8af706..6ba041f1 100644 --- a/www/updateUes.php +++ b/www/updateUes.php @@ -4,22 +4,102 @@ * Script for updating UES in separate thread * * @author Pavel VyskoÄŤil <vyskocilpavel@muni.cz> + * @author Dominik Baranek <baranek@ics.muni.cz> */ +use Jose\Component\Checker\ClaimCheckerManager; +use Jose\Component\Checker; +use Jose\Component\Core\AlgorithmManager; +use Jose\Component\KeyManagement\JWKFactory; +use Jose\Component\Signature\Algorithm\RS512; +use Jose\Component\Signature\JWSVerifier; +use Jose\Component\Checker\HeaderCheckerManager; +use Jose\Component\Checker\AlgorithmChecker; +use Jose\Component\Signature\JWSTokenSupport; +use Jose\Component\Signature\Serializer\CompactSerializer; +use Jose\Component\Signature\Serializer\JWSSerializerManager; +use SimpleSAML\Configuration; use SimpleSAML\Logger; use SimpleSAML\Module\perun\Adapter; +use SimpleSAML\Module\perun\DatabaseConnector; +use SimpleSAML\Module\perun\ScriptsUtils; $adapter = Adapter::getInstance(Adapter::RPC); +$token = file_get_contents('php://input'); -$entityBody = file_get_contents('php://input'); -$body = json_decode($entityBody, true); +if (empty($token)) { + http_response_code(400); + exit('The entity body is empty'); +} -$attributesFromIdP = $body['attributes']; -$attrMap = $body['attrMap']; -$attrsToConversion = $body['attrsToConversion']; -$perunUserId = $body['perunUserId']; +$attributesFromIdP = null; +$attrMap = null; +$attrsToConversion = null; +$perunUserId = null; +$id = null; const UES_ATTR_NMS = 'urn:perun:ues:attribute-def:def'; +const CONFIG_FILE_NAME = 'keys.php'; + +try { + $config = Configuration::getConfig(CONFIG_FILE_NAME); + $keyPub = $config->getString('updateUes'); + + $algorithmManager = new AlgorithmManager([new RS512()]); + $jwsVerifier = new JWSVerifier($algorithmManager); + $jwk = JWKFactory::createFromKeyFile($keyPub); + + $serializerManager = new JWSSerializerManager([new CompactSerializer()]); + $jws = $serializerManager->unserialize($token); + + $headerCheckerManager = new HeaderCheckerManager([new AlgorithmChecker(['RS512'])], [new JWSTokenSupport()]); + $headerCheckerManager->check($jws, 0); + + $isVerified = $jwsVerifier->verifyWithKey($jws, $jwk, 0); + + if (!$isVerified) { + Logger::error('Perun.updateUes: The token signature is invalid!'); + http_response_code(401); + exit; + } + + $claimCheckerManager = new ClaimCheckerManager( + [ + new Checker\IssuedAtChecker(), + new Checker\NotBeforeChecker(), + new Checker\ExpirationTimeChecker(), + ] + ); + + $claims = json_decode($jws->getPayload(), true); + $claimCheckerManager->check($claims); + + $challenge = $claims['challenge']; + + $attributesFromIdP = $claims['data']['attributes']; + $attrMap = $claims['data']['attrMap']; + $attrsToConversion = $claims['data']['attrsToConversion']; + $perunUserId = $claims['data']['perunUserId']; + $id = $claims['id']; + + $databaseConnector = new DatabaseConnector(); + + $conn = $databaseConnector->getConnection(); + + $challengeDb = ScriptsUtils::readChallengeFromDb($conn, $id); + $checkAccessSucceeded = ScriptsUtils::checkAccess($conn, $challenge, $challengeDb); + $challengeSuccessfullyDeleted = ScriptsUtils::deleteChallengeFromDb($conn, $id); + + $conn->close(); + + if (!$checkAccessSucceeded || !$challengeSuccessfullyDeleted) { + exit; + } +} catch (Checker\InvalidClaimException | Checker\MissingMandatoryClaimException $ex) { + Logger::error('Perun.updateUes: An error occurred when the token was verifying.'); + http_response_code(400); + exit; +} try { $userExtSource = $adapter->getUserExtSource( -- GitLab