From 233bf27afed66a34675c180301879beaa5870c8f Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Wed, 24 May 2023 16:02:07 +0200 Subject: [PATCH] add Froxlor.generateLoginLink() API call to allow generation of one-time-login links for customers, thx to INWX for supporting and sponsoring this feature Signed-off-by: Michael Kaufmann --- index.php | 55 +++++++++++++++- install/froxlor.sql.php | 11 +++- install/updates/froxlor/update_2.x.inc.php | 14 +++- lib/Froxlor/Api/Commands/Froxlor.php | 74 ++++++++++++++++++++++ lib/Froxlor/Api/FroxlorRPC.php | 4 +- lib/Froxlor/Cli/MasterCron.php | 3 + lib/Froxlor/Froxlor.php | 2 +- lib/tables.inc.php | 1 + lng/de.lng.php | 1 + lng/en.lng.php | 1 + 10 files changed, 160 insertions(+), 6 deletions(-) diff --git a/index.php b/index.php index d59146e3..de0b38f4 100644 --- a/index.php +++ b/index.php @@ -26,6 +26,7 @@ const AREA = 'login'; require __DIR__ . '/lib/init.php'; +use Froxlor\Api\FroxlorRPC; use Froxlor\CurrentUser; use Froxlor\Customer\Customer; use Froxlor\Database\Database; @@ -37,10 +38,10 @@ use Froxlor\PhpHelper; use Froxlor\Settings; use Froxlor\System\Crypt; use Froxlor\UI\Panel\UI; +use Froxlor\UI\Request; use Froxlor\UI\Response; use Froxlor\User; use Froxlor\Validate\Validate; -use Froxlor\Language; if ($action == '') { $action = 'login'; @@ -730,6 +731,58 @@ if ($action == 'resetpwd') { } } +// one-time link login +if ($action == 'll') { + if (!Froxlor::hasUpdates() && !Froxlor::hasDbUpdates()) { + $loginname = Request::get('ln'); + $hash = Request::get('h'); + if ($loginname && $hash) { + $sel_stmt = Database::prepare(" + SELECT * FROM `" . TABLE_PANEL_LOGINLINKS . "` + WHERE `loginname` = :loginname AND `hash` = :hash + "); + try { + $entry = Database::pexecute_first($sel_stmt, ['loginname' => $loginname, 'hash' => $hash]); + } catch (Exception $e) { + $entry = false; + } + if ($entry) { + // delete entry + $del_stmt = Database::prepare("DELETE FROM `" . TABLE_PANEL_LOGINLINKS . "` WHERE `loginname` = :loginname AND `hash` = :hash"); + Database::pexecute($del_stmt, ['loginname' => $loginname, 'hash' => $hash]); + if (time() <= $entry['valid_until']) { + $valid = true; + // validate source ip if specified + if (!empty($entry['allowed_from'])) { + $valid = false; + $ip_list = explode(",", $entry['allowed_from']); + if (FroxlorRPC::validateAllowedFrom($ip_list, $_SERVER['REMOTE_ADDR'])) { + $valid = true; + } + } + if ($valid) { + // login user / select only non-deactivated (in case the user got deactivated after generating the link) + $userinfo_stmt = Database::prepare("SELECT * FROM `" . TABLE_PANEL_CUSTOMERS . "` WHERE `loginname`= :loginname AND `deactivated` = 0"); + try { + $userinfo = Database::pexecute_first($userinfo_stmt, [ + "loginname" => $loginname + ]); + } catch (Exception $e) { + $userinfo = false; + } + if ($userinfo) { + $userinfo['userid'] = $userinfo['customerid']; + $userinfo['adminsession'] = 0; + finishLogin($userinfo); + } + } + } + } + } + } + Response::redirectTo('index.php'); +} + function finishLogin($userinfo) { if (isset($userinfo['userid']) && $userinfo['userid'] != '') { diff --git a/install/froxlor.sql.php b/install/froxlor.sql.php index a4cb7c5a..57901d03 100644 --- a/install/froxlor.sql.php +++ b/install/froxlor.sql.php @@ -744,7 +744,7 @@ opcache.validate_timestamps'), ('panel', 'logo_overridecustom', '0'), ('panel', 'settings_mode', '0'), ('panel', 'version', '2.0.19'), - ('panel', 'db_version', '202305230'); + ('panel', 'db_version', '202305240'); DROP TABLE IF EXISTS `panel_tasks`; @@ -1051,4 +1051,13 @@ CREATE TABLE `panel_usercolumns` ( KEY adminid (adminid), KEY customerid (customerid) ) ENGINE=InnoDB CHARSET=utf8 COLLATE=utf8_general_ci; + +DROP TABLE IF EXISTS `panel_loginlinks`; +CREATE TABLE `panel_loginlinks` ( + `hash` varchar(500) NOT NULL, + `loginname` varchar(50) NOT NULL, + `valid_until` int(15) NOT NULL, + `allowed_from` text NOT NULL, + UNIQUE KEY `loginname` (`loginname`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; FROXLORSQL; diff --git a/install/updates/froxlor/update_2.x.inc.php b/install/updates/froxlor/update_2.x.inc.php index 50f0f3cb..30eafde4 100644 --- a/install/updates/froxlor/update_2.x.inc.php +++ b/install/updates/froxlor/update_2.x.inc.php @@ -498,5 +498,17 @@ if (Froxlor::isDatabaseVersion('202304260')) { Database::query("ALTER TABLE `" . TABLE_PANEL_DOMAINS . "` DROP COLUMN `ismainbutsubto`;"); Update::lastStepStatus(0); - Froxlor::updateToDbVersion('202305230'); + Update::showUpdateStep("Creating new tables and fields"); + Database::query("DROP TABLE IF EXISTS `panel_loginlinks`;"); + $sql = "CREATE TABLE `panel_loginlinks` ( + `hash` varchar(500) NOT NULL, + `loginname` varchar(50) NOT NULL, + `valid_until` int(15) NOT NULL, + `allowed_from` text NOT NULL, + UNIQUE KEY `loginname` (`loginname`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;"; + Database::query($sql); + Update::lastStepStatus(0); + + Froxlor::updateToDbVersion('202305240'); } diff --git a/lib/Froxlor/Api/Commands/Froxlor.php b/lib/Froxlor/Api/Commands/Froxlor.php index 690b5880..d7cb4920 100644 --- a/lib/Froxlor/Api/Commands/Froxlor.php +++ b/lib/Froxlor/Api/Commands/Froxlor.php @@ -37,6 +37,7 @@ use Froxlor\Settings; use Froxlor\SImExporter; use Froxlor\System\Cronjob; use Froxlor\System\Crypt; +use Froxlor\Validate\Validate; use PDO; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -269,6 +270,79 @@ class Froxlor extends ApiCommand return $this->response(Crypt::generatePassword($length)); } + /** + * return a one-time login link URL for a given user + * + * @param int $customerid optional, required if $loginname is not specified, user to create link for + * @param string $loginname optional, required if $customerid is not specified, user to create link for + * @param int $valid_time optional, value in seconds how long the link will be valid, default is 10 seconds, valid values are numbers from 10 to 120 + * @param string $allowed_from optional, comma separated list of ip addresses or networks to allow login from via this link + * + * @access admin + * @return string json-encoded array [base => domain, uri => relative link] + * @throws Exception + */ + public function generateLoginLink() + { + if ($this->isAdmin()) { + $customer = $this->getCustomerData(); + + // cannot create link for deactivated users + if ((int)$customer['deactivated'] == 1) { + throw new Exception("Cannot generate link for deactivated user", 406); + } + + $valid_time = (int)$this->getParam('valid_time', true, 10); + $allowed_from = $this->getParam('allowed_from', true, ''); + + $valid_time = Validate::validate($valid_time, 'valid time', '/^(1[0-1][0-9]|120|[1-9][0-9])$/', 'invalid_validtime', [10], true); + + // validate allowed_from + if (!empty($allowed_from)) { + $ip_list = array_map('trim', explode(",", $allowed_from)); + $_check_list = $ip_list; + foreach ($_check_list as $idx => $ip) { + if (Validate::validate_ip2($ip, true, 'invalidip', true, true, true) == false) { + throw new Exception('Invalid ip address', 406); + } + // check for cidr + if (strpos($ip, '/') !== false) { + $ipparts = explode("/", $ip); + // shorten IP + $ip = inet_ntop(inet_pton($ipparts[0])); + // re-add cidr + $ip .= '/' . $ipparts[1]; + } else { + // shorten IP + $ip = inet_ntop(inet_pton($ip)); + } + $ip_list[$idx] = $ip; + } + $allowed_from = implode(",", array_unique($ip_list)); + } + + $hash = hash('sha256', openssl_random_pseudo_bytes(64 * 64)); + + $ins_stmt = Database::prepare(" + INSERT INTO `" . TABLE_PANEL_LOGINLINKS . "` + SET `hash` = :hash, `loginname` = :loginname, `valid_until` = :validuntil, `allowed_from` = :allowedfrom + ON DUPLICATE KEY UPDATE `hash` = :hash, `valid_until` = :validuntil, `allowed_from` = :allowedfrom + "); + Database::pexecute($ins_stmt, [ + 'hash' => $hash, + 'loginname' => $customer['loginname'], + 'validuntil' => time() + $valid_time, + 'allowedfrom' => $allowed_from + ]); + + return $this->response([ + 'base' => 'https://' . Settings::Get('system.hostname') . '/' . (Settings::Get('system.froxlordirectlyviahostname') != 1 ? basename(\Froxlor\Froxlor::getInstallDir()) . '/' : ''), + 'uri' => 'index.php?action=ll&ln=' . $customer['loginname'] . '&h=' . $hash + ]); + } + throw new Exception("Not allowed to execute given command.", 403); + } + /** * can be used to remotely run the integritiy checks froxlor implements * diff --git a/lib/Froxlor/Api/FroxlorRPC.php b/lib/Froxlor/Api/FroxlorRPC.php index 4a85872b..ed5398dd 100644 --- a/lib/Froxlor/Api/FroxlorRPC.php +++ b/lib/Froxlor/Api/FroxlorRPC.php @@ -112,11 +112,11 @@ class FroxlorRPC * * @return bool */ - private static function validateAllowedFrom(array $allowed_from, string $remote_addr): bool + public static function validateAllowedFrom(array $allowed_from, string $remote_addr): bool { // shorten IP for comparison $remote_addr = inet_ntop(inet_pton($remote_addr)); - // check for diret matches + // check for direct matches if (in_array($remote_addr, $allowed_from)) { return true; } diff --git a/lib/Froxlor/Cli/MasterCron.php b/lib/Froxlor/Cli/MasterCron.php index 72d43f70..ec85d230 100644 --- a/lib/Froxlor/Cli/MasterCron.php +++ b/lib/Froxlor/Cli/MasterCron.php @@ -166,6 +166,9 @@ final class MasterCron extends CliCommand FroxlorLogger::getInstanceOf()->setCronLog(0); } + // clean up possible old login-links + Database::query("DELETE FROM `" . TABLE_PANEL_LOGINLINKS . "` WHERE `valid_until` < UNIX_TIMESTAMP()"); + return $result; } diff --git a/lib/Froxlor/Froxlor.php b/lib/Froxlor/Froxlor.php index df17100a..39f4aab5 100644 --- a/lib/Froxlor/Froxlor.php +++ b/lib/Froxlor/Froxlor.php @@ -34,7 +34,7 @@ final class Froxlor const VERSION = '2.0.19'; // Database version (YYYYMMDDC where C is a daily counter) - const DBVERSION = '202305230'; + const DBVERSION = '202305240'; // Distribution branding-tag (used for Debian etc.) const BRANDING = ''; diff --git a/lib/tables.inc.php b/lib/tables.inc.php index 43c52b08..26214333 100644 --- a/lib/tables.inc.php +++ b/lib/tables.inc.php @@ -56,3 +56,4 @@ const TABLE_PANEL_FPMDAEMONS = 'panel_fpmdaemons'; const TABLE_PANEL_PLANS = 'panel_plans'; const TABLE_API_KEYS = 'api_keys'; const TABLE_PANEL_USERCOLUMNS = 'panel_usercolumns'; +const TABLE_PANEL_LOGINLINKS = 'panel_loginlinks'; diff --git a/lng/de.lng.php b/lng/de.lng.php index b899a717..6cf9fcf9 100644 --- a/lng/de.lng.php +++ b/lng/de.lng.php @@ -926,6 +926,7 @@ return [ '2fa_wrongcode' => 'Der angegebene Code ist nicht korrekt', 'gnupgextensionnotavailable' => 'Die PHP GnuPG Extension ist nicht verfügbar. PGP Schlüssel können nicht validiert werden.', 'invalidpgppublickey' => 'Der angegebene PGP Public Key ist ungültig', + 'invalid_validtime' => 'Wert der valid_time in Sekunden muss zwischen 10 und 120 liegen.', ], 'extras' => [ 'description' => 'Hier können Sie zusätzliche Extras einrichten, wie zum Beispiel einen Verzeichnisschutz.
Die Änderungen sind erst nach einer kurzen Zeit wirksam.', diff --git a/lng/en.lng.php b/lng/en.lng.php index d4cb30fa..9169ec37 100644 --- a/lng/en.lng.php +++ b/lng/en.lng.php @@ -995,6 +995,7 @@ return [ '2fa_wrongcode' => 'The code entered is not valid', 'gnupgextensionnotavailable' => 'The PHP GnuPG extension is not available. Unable to validate PGP Public Key', 'invalidpgppublickey' => 'The PGP Public Key is not valid', + 'invalid_validtime' => 'Valid time in seconds can only be between 10 and 120', ], 'extras' => [ 'description' => 'Here you can add some extras, for example directory protection.
The system will need some time to apply the new settings after every change.',