From 080c756042b49fec9a11b0078e32913abf907333 Mon Sep 17 00:00:00 2001 From: Philipp Date: Mon, 5 May 2025 00:55:56 +0200 Subject: [PATCH 1/6] Refactor auth_ejabberd.php into a Console command - Transforming ExAuth into a daemon - Replace PID Locking with Lock class - Replace CURL operations with guzzle HTTP client - Code hardening (type-save, argument validation, ...) - Bugfixing - Added a lot of unit tests --- bin/auth_ejabberd.php | 9 +- src/App.php | 44 --- src/Console/AuthEJabberd.php | 414 +++++++++++++++++++++ src/Core/Console.php | 2 + src/Security/ExAuth.php | 403 --------------------- src/Util/PidFile.php | 116 ------ tests/CacheTestCase.php | 4 +- tests/ConsoleTestCase.php | 9 +- tests/datasets/ejabberd/fixture.php | 30 ++ tests/src/Console/EjabberdAuthTest.php | 481 +++++++++++++++++++++++++ 10 files changed, 940 insertions(+), 572 deletions(-) create mode 100644 src/Console/AuthEJabberd.php delete mode 100644 src/Security/ExAuth.php delete mode 100644 src/Util/PidFile.php create mode 100644 tests/datasets/ejabberd/fixture.php create mode 100644 tests/src/Console/EjabberdAuthTest.php diff --git a/bin/auth_ejabberd.php b/bin/auth_ejabberd.php index 595ccf204a..f162039fe4 100755 --- a/bin/auth_ejabberd.php +++ b/bin/auth_ejabberd.php @@ -17,7 +17,7 @@ * * Installation: * - * - Change it's owner to whichever user is running the server, ie. ejabberd + * - Change its owner to whichever user is running the server, ie. ejabberd * $ chown ejabberd:ejabberd /path/to/friendica/bin/auth_ejabberd.php * * - Change the access mode so it is readable only to the user ejabberd and has exec @@ -48,8 +48,13 @@ chdir(dirname(__DIR__)); require dirname(__DIR__) . '/vendor/autoload.php'; +// BC: Add console command as second argument +$argv = $_SERVER['argv'] ?? []; +array_splice($argv, 1, 0, "auth_ejabberd"); +$_SERVER['argv'] = $argv; + $container = \Friendica\Core\DiceContainer::fromBasePath(dirname(__DIR__)); $app = \Friendica\App::fromContainer($container); -$app->processEjabberd($_SERVER); +$app->processConsole($_SERVER); diff --git a/src/App.php b/src/App.php index ffcbfd1544..5d72849228 100644 --- a/src/App.php +++ b/src/App.php @@ -42,9 +42,7 @@ use Friendica\Module\Special\HTTPException as ModuleHTTPException; use Friendica\Network\HTTPException; use Friendica\Protocol\ATProtocol\DID; use Friendica\Security\Authentication; -use Friendica\Security\ExAuth; use Friendica\Security\OpenWebAuth; -use Friendica\Util\BasePath; use Friendica\Util\DateTimeFormat; use Friendica\Util\HTTPInputData; use Friendica\Util\HTTPSignature; @@ -234,48 +232,6 @@ class App (\Friendica\Core\Console::create($this->container, $argv))->execute(); } - /** - * @internal - */ - public function processEjabberd(array $serverParams): void - { - $this->setupContainerForAddons(); - - $this->setupLogChannel(LogChannel::AUTH_JABBERED); - - $this->setupLegacyServiceLocator(); - - $this->registerErrorHandler(); - - $this->registerEventDispatcher(); - - $this->load( - $serverParams, - $this->container->create(DbaDefinition::class), - $this->container->create(ViewDefinition::class), - $this->container->create(Mode::class), - $this->container->create(IManageConfigValues::class), - $this->container->create(Profiler::class), - $this->container->create(EventDispatcherInterface::class), - $this->container->create(AppHelper::class), - $this->container->create(AddonHelper::class), - ); - - /** @var BasePath */ - $basePath = $this->container->create(BasePath::class); - - // Check the database structure and possibly fixes it - Update::check($basePath->getPath(), true); - - $appMode = $this->container->create(Mode::class); - - if ($appMode->isNormal()) { - /** @var ExAuth $oAuth */ - $oAuth = $this->container->create(ExAuth::class); - $oAuth->readStdin(); - } - } - private function setupContainerForAddons(): void { /** @var ICanLoadAddons $addonLoader */ diff --git a/src/Console/AuthEJabberd.php b/src/Console/AuthEJabberd.php new file mode 100644 index 0000000000..fbaf3c0de1 --- /dev/null +++ b/src/Console/AuthEJabberd.php @@ -0,0 +1,414 @@ + + * modified for Friendica by Michael Vogel + * published under GPL + * + * Latest version of the original script for joomla is available at: + * http://87.230.15.86/~dado/ejabberd/joomla-login + */ + +declare(strict_types=1); + +namespace Friendica\Console; + +use Asika\SimpleConsole\Console; +use Friendica\App\BaseURL; +use Friendica\App\Mode; +use Friendica\Core\Config\Capability\IManageConfigValues; +use Friendica\Core\Lock\Capability\ICanLock; +use Friendica\Core\PConfig\Capability\IManagePersonalConfigValues; +use Friendica\Database\Database; +use Friendica\Model\User; +use Friendica\Network\HTTPClient\Capability\ICanSendHttpRequests; +use Friendica\Network\HTTPClient\Client\HttpClientAccept; +use Friendica\Network\HTTPClient\Client\HttpClientOptions; +use Friendica\Network\HTTPClient\Client\HttpClientRequest; +use Friendica\Network\HTTPException\ForbiddenException; + +/** + * ejabberd supports external authentication via a small daemon (script or binary) + * that communicates with the ejabberd server using STDIN and STDOUT and a binary protocol. + */ +final class AuthEJabberd extends Console +{ + /** @var string Command to authenticate user */ + private const COMMAND_AUTH = 'auth'; + /** @var string Command to check if user exists */ + private const COMMAND_IS_USER = 'isuser'; + /** @var string Command to set user password */ + private const COMMAND_SET_PASS = 'setpass'; + + /** @var string The name of the lock for the host lock */ + private const LOCK_EX_AUTH_HOST = 'ex_auth_host'; + + private Mode $mode; + private IManageConfigValues $config; + private Database $dba; + private BaseURL $baseURL; + private ICanSendHttpRequests $httpClient; + + private int $debugMode = 0; + private string $host; + private IManagePersonalConfigValues $pConfig; + private ICanLock $lock; + + private $input; + private $output; + + public function __construct( + Mode $mode, + IManageConfigValues $config, + IManagePersonalConfigValues $pConfig, + Database $dba, + BaseURL $baseUrl, + ICanLock $lock, + ICanSendHttpRequests $httpClient, + array $argv = null, + $input = null, + $output = null + ) { + parent::__construct($argv); + + $this->mode = $mode; + $this->config = $config; + $this->pConfig = $pConfig; + $this->dba = $dba; + $this->baseURL = $baseUrl; + $this->httpClient = $httpClient; + $this->lock = $lock; + + $this->input = $input ?? fopen('php://stdin', 'rb'); + $this->output = $output ?? fopen('php://stdout', 'wb'); + } + + protected function getHelp(): string + { + return <<debugMode = (int)$this->config->get('jabber', 'debug'); + + openlog('auth_ejabberd', LOG_PID, LOG_USER); + + $this->writeLog(LOG_NOTICE, 'start'); + + if (!$this->mode->isNormal()) { + $this->writeFailed('The node isn\'t ready.'); + throw new \RuntimeException('The node isn\'t ready.'); + } + + $this->readStdin(); + + return 0; + } + + /** + * Standard input reading function, executes the auth with the provided + * parameters + * + * @throws \Exception + */ + private function readStdin() + { + while (!feof($this->input)) { + $meta = stream_get_meta_data($this->input); + if ($meta['eof']) { + $this->writeFailed('we got no data, quitting'); + break; + } + + // Quit if the database connection went down + if (!$this->dba->isConnected()) { + $this->writeFailed('the database connection went down'); + throw new \RuntimeException('the database connection went down'); + } + + $iHeader = fgets($this->input, 3); + if (empty($iHeader)) { + $this->writeFailed('empty stdin'); + break; + } + + $aLength = unpack('n', $iHeader); + $iLength = $aLength['1']; + + // No data? Then quit + if ($iLength == 0) { + $this->writeFailed('we got no data, quitting'); + break; + } + + // Fetching the data + $sData = fgets($this->input, $iLength + 1); + $this->writeLog(LOG_DEBUG, 'received data: ' . $sData); + $aCommand = explode(':', $sData); + + if (!is_array($aCommand)) { + $this->writeFailed('invalid command string ' . $sData); + break; + } + + $cmd = $aCommand[0]; + switch ($cmd) { + case self::COMMAND_IS_USER: + // Check the existence of a given username + if (count($aCommand) < 3) { + $this->writeFailed('Invalid data.'); + break; + }; + list($cmd, $username, $server) = $aCommand; + $this->isUser($username, $server); + break; + case self::COMMAND_AUTH: + case self::COMMAND_SET_PASS: + if (count($aCommand) < 4) { + $this->writeFailed('Invalid data.'); + break; + }; + list($cmd, $username, $server, $password) = $aCommand; + switch ($cmd) { + case self::COMMAND_AUTH: + // Check if the given password is correct + $this->auth($username, $server, $password); + break; + case self::COMMAND_SET_PASS: + // We don't accept the setting of passwords here + $this->writeFailed('setpass command disabled'); + break; + } + break; + default: + // We don't know the given command + $this->writeFailed('unknown command ' . $cmd); + break; + } + } + } + + /** + * Check if the given username exists + * + * @throws \Exception + */ + private function isUser(string $username, string $server) + { + // We only allow one process per hostname. So we set a lock file + // Problem: We get the firstname after the first auth - not before + if ($this->lock->acquire(self::LOCK_EX_AUTH_HOST . ':' . $server, 10, 20)) { + // Now we check if the given user is valid + $username = str_replace(['%20', '(a)'], [' ', '@'], $username); + + // Does the hostname match? So we try directly + if ($this->baseURL->getHost() == $server) { + $this->writeLog(LOG_INFO, 'internal user check for ' . $username . '@' . $server); + $found = $this->dba->exists('user', ['nickname' => $username]); + } else { + $found = false; + } + + // If the hostnames doesn't match or there is some failure, we try to check remotely + if (!$found) { + $found = $this->checkUser($username, $server); + } + + if ($found) { + // The user is okay + $this->writeSuccess('valid user: ' . $username); + } else { + // The user isn't okay + $this->writeFailed('invalid user: ' . $username); + } + $this->lock->release(self::LOCK_EX_AUTH_HOST . ':' . $server); + } + } + + /** + * Check remote user existence via HTTP(S) + * + * @param string $user Username + * @param string $host The hostname + * + * @return bool Was the user found? + */ + private function checkUser(string $user, string $host): bool + { + $this->writeLog(LOG_INFO, 'external user check for ' . $user . '@' . $host); + + $url = 'https://' . $host . '/noscrape/' . $user; + + try { + $curlResult = $this->httpClient->get($url, HttpClientAccept::JSON, [HttpClientOptions::REQUEST => HttpClientRequest::CONTACTVERIFIER]); + } catch (\Throwable $th) { + return false; + } + + if (!$curlResult->isSuccess()) { + return false; + } + + if ($curlResult->getReturnCode() != 200) { + return false; + } + + $json = @json_decode($curlResult->getBodyString()); + if (!is_object($json)) { + return false; + } + + return $json->nick == $user; + } + + /** + * Authenticate the given user and password + * + * @param string $username + * @param string $server + * @param string $password + * + * @throws \Exception + */ + private function auth(string $username, string $server, string $password) + { + // We only allow one process per hostname. So we set a lock file + // Problem: We get the firstname after the first auth - not before + if ($this->lock->acquire(self::LOCK_EX_AUTH_HOST . ':' . $server, 10, 20)) { + + // We now check if the password match + $username = str_replace(['%20', '(a)'], [' ', '@'], $username); + + $Error = false; + // Does the hostname match? So we try directly + if ($this->baseURL->getHost() == $server) { + try { + $this->writeLog(LOG_INFO, 'internal auth for ' . $username . '@' . $server); + User::getIdFromPasswordAuthentication($username, $password, true); + } catch (ForbiddenException $ex) { + // User exists, authentication failed + $this->writeLog(LOG_INFO, 'check against alternate password for ' . $username . '@' . $server); + $aUser = User::getByNickname($username, ['uid']); + if (!empty($aUser['uid'])) { + $sPassword = $this->pConfig->get($aUser['uid'], 'xmpp', 'password', null, true); + $Error = ($password != $sPassword); + } else { + $Error = true; + } + } catch (\Throwable $ex) { + // User doesn't exist and any other failure case + $this->writeLog(LOG_WARNING, $ex->getMessage() . ': ' . $username); + $Error = true; + } + } else { + $Error = true; + } + + // If the hostnames doesn't match or there is some failure, we try to check remotely + if ($Error && !$this->checkCredentials($server, $username, $password)) { + $this->writeFailed('authentication failed for user ' . $username . '@' . $server); + } else { + $this->writeSuccess('authenticated user ' . $username . '@' . $server); + } + $this->lock->release(self::LOCK_EX_AUTH_HOST . ':' . $server); + } + } + + /** + * Check remote credentials via HTTP(S) + * + * @param string $host The hostname + * @param string $user Username + * @param string $password Password + * + * @return bool Are the credentials okay? + */ + private function checkCredentials(string $host, string $user, string $password): bool + { + $this->writeLog(LOG_INFO, 'external credential check for ' . $user . '@' . $host); + + $url = 'https://' . $host . '/api/account/verify_credentials.json?skip_status=true'; + + try { + $curlResult = $this->httpClient->head($url, [ + HttpClientOptions::REQUEST => HttpClientRequest::CONTACTVERIFIER, + HttpClientOptions::TIMEOUT => 5, + HttpClientOptions::AUTH => [ + $user, + $password, + ], + ]); + } catch (\Throwable $th) { + return false; + } + + return ($curlResult->isSuccess() && $curlResult->getReturnCode() == 200); + } + + /** + * Returns a binary "success" to the output stream + * + * @return void + */ + private function writeSuccess(string $message = ''): void + { + if (!empty($message)) { + $this->writeLog(LOG_NOTICE, $message); + } + fwrite($this->output, pack('nn', 2, 1)); + } + + /** + * Returns a binary "failed" to the output stream + * + * @return void + */ + protected function writeFailed(string $message = '') + { + if (!empty($message)) { + $this->writeLog(LOG_ERR, $message); + } + fwrite($this->output, pack('nn', 2, 0)); + } + + /** + * write data to the syslog + * + * @param int $loglevel The syslog loglevel + * @param string $message The syslog message + */ + private function writeLog(int $loglevel, string $message) + { + if (!$this->debugMode && ($loglevel >= LOG_DEBUG)) { + return; + } + syslog($loglevel, $message); + } +} diff --git a/src/Core/Console.php b/src/Core/Console.php index 5dec5be257..15314f3a51 100644 --- a/src/Core/Console.php +++ b/src/Core/Console.php @@ -71,6 +71,7 @@ Commands: serverblock Manage blocked servers storage Manage storage backend relay Manage ActivityPub relay servers + auth_jabberd Daemon that communicates with the ejabberd server Options: -h|--help|-? Show help information @@ -83,6 +84,7 @@ HELP; 'addon' => Friendica\Console\Addon::class, 'archivecontact' => Friendica\Console\ArchiveContact::class, 'autoinstall' => Friendica\Console\AutomaticInstallation::class, + 'auth_ejabberd' => Friendica\Console\AuthEJabberd::class, 'cache' => Friendica\Console\Cache::class, 'clearavatarcache' => Friendica\Console\ClearAvatarCache::class, 'config' => Friendica\Console\Config::class, diff --git a/src/Security/ExAuth.php b/src/Security/ExAuth.php deleted file mode 100644 index 43aff95487..0000000000 --- a/src/Security/ExAuth.php +++ /dev/null @@ -1,403 +0,0 @@ - - * modified for Friendica by Michael Vogel - * published under GPL - * - * Latest version of the original script for joomla is available at: - * http://87.230.15.86/~dado/ejabberd/joomla-login - * - * Installation: - * - * - Change it's owner to whichever user is running the server, ie. ejabberd - * $ chown ejabberd:ejabberd /path/to/friendica/bin/auth_ejabberd.php - * - * - Change the access mode so it is readable only to the user ejabberd and has exec - * $ chmod 700 /path/to/friendica/bin/auth_ejabberd.php - * - * - Edit your ejabberd.cfg file, comment out your auth_method and add: - * {auth_method, external}. - * {extauth_program, "/path/to/friendica/bin/auth_ejabberd.php"}. - * - * - Restart your ejabberd service, you should be able to login with your friendica auth info - * - * Other hints: - * - if your users have a space or a @ in their nickname, they'll run into trouble - * registering with any client so they should be instructed to replace these chars - * " " (space) is replaced with "%20" - * "@" is replaced with "(a)" - * - */ - -namespace Friendica\Security; - -use Exception; -use Friendica\App; -use Friendica\Core\Config\Capability\IManageConfigValues; -use Friendica\Core\PConfig\Capability\IManagePersonalConfigValues; -use Friendica\Database\Database; -use Friendica\DI; -use Friendica\Model\User; -use Friendica\Network\HTTPClient\Client\HttpClientAccept; -use Friendica\Network\HTTPClient\Client\HttpClientOptions; -use Friendica\Network\HTTPClient\Client\HttpClientRequest; -use Friendica\Network\HTTPException; -use Friendica\Util\PidFile; - -class ExAuth -{ - private $bDebug; - private $host; - - /** - * @var App\Mode - */ - private $appMode; - /** - * @var IManageConfigValues - */ - private $config; - /** - * @var IManagePersonalConfigValues - */ - private $pConfig; - /** - * @var Database - */ - private $dba; - /** - * @var App\BaseURL - */ - private $baseURL; - - /** - * @param App\Mode $appMode - * @param IManageConfigValues $config - * @param IManagePersonalConfigValues $pConfig - * @param Database $dba - * @param App\BaseURL $baseURL - * - * @throws Exception - */ - public function __construct(App\Mode $appMode, IManageConfigValues $config, IManagePersonalConfigValues $pConfig, Database $dba, App\BaseURL $baseURL) - { - $this->appMode = $appMode; - $this->config = $config; - $this->pConfig = $pConfig; - $this->dba = $dba; - $this->baseURL = $baseURL; - - $this->bDebug = (int)$config->get('jabber', 'debug'); - - openlog('auth_ejabberd', LOG_PID, LOG_USER); - - $this->writeLog(LOG_NOTICE, 'start'); - } - - /** - * Standard input reading function, executes the auth with the provided - * parameters - * - * @throws HTTPException\InternalServerErrorException - */ - public function readStdin() - { - if (!$this->appMode->isNormal()) { - $this->writeLog(LOG_ERR, 'The node isn\'t ready.'); - return; - } - - while (!feof(STDIN)) { - // Quit if the database connection went down - if (!$this->dba->isConnected()) { - $this->writeLog(LOG_ERR, 'the database connection went down'); - return; - } - - $iHeader = fgets(STDIN, 3); - if (empty($iHeader)) { - $this->writeLog(LOG_ERR, 'empty stdin'); - return; - } - - $aLength = unpack('n', $iHeader); - $iLength = $aLength['1']; - - // No data? Then quit - if ($iLength == 0) { - $this->writeLog(LOG_ERR, 'we got no data, quitting'); - return; - } - - // Fetching the data - $sData = fgets(STDIN, $iLength + 1); - $this->writeLog(LOG_DEBUG, 'received data: ' . $sData); - $aCommand = explode(':', $sData); - if (is_array($aCommand)) { - switch ($aCommand[0]) { - case 'isuser': - // Check the existence of a given username - $this->isUser($aCommand); - break; - case 'auth': - // Check if the given password is correct - $this->auth($aCommand); - break; - case 'setpass': - // We don't accept the setting of passwords here - $this->writeLog(LOG_NOTICE, 'setpass command disabled'); - fwrite(STDOUT, pack('nn', 2, 0)); - break; - default: - // We don't know the given command - $this->writeLog(LOG_NOTICE, 'unknown command ' . $aCommand[0]); - fwrite(STDOUT, pack('nn', 2, 0)); - break; - } - } else { - $this->writeLog(LOG_NOTICE, 'invalid command string ' . $sData); - fwrite(STDOUT, pack('nn', 2, 0)); - } - } - } - - /** - * Check if the given username exists - * - * @param array $aCommand The command array - * @throws HTTPException\InternalServerErrorException - */ - private function isUser(array $aCommand) - { - // Check if there is a username - if (!isset($aCommand[1])) { - $this->writeLog(LOG_NOTICE, 'invalid isuser command, no username given'); - fwrite(STDOUT, pack('nn', 2, 0)); - return; - } - - // We only allow one process per hostname. So we set a lock file - // Problem: We get the firstname after the first auth - not before - $this->setHost($aCommand[2]); - - // Now we check if the given user is valid - $sUser = str_replace(['%20', '(a)'], [' ', '@'], $aCommand[1]); - - // Does the hostname match? So we try directly - if ($this->baseURL->getHost() == $aCommand[2]) { - $this->writeLog(LOG_INFO, 'internal user check for ' . $sUser . '@' . $aCommand[2]); - $found = $this->dba->exists('user', ['nickname' => $sUser]); - } else { - $found = false; - } - - // If the hostnames doesn't match or there is some failure, we try to check remotely - if (!$found) { - $found = $this->checkUser($aCommand[2], $aCommand[1], true); - } - - if ($found) { - // The user is okay - $this->writeLog(LOG_NOTICE, 'valid user: ' . $sUser); - fwrite(STDOUT, pack('nn', 2, 1)); - } else { - // The user isn't okay - $this->writeLog(LOG_WARNING, 'invalid user: ' . $sUser); - fwrite(STDOUT, pack('nn', 2, 0)); - } - } - - /** - * Check remote user existence via HTTP(S) - * - * @param string $host The hostname - * @param string $user Username - * @param boolean $ssl Should the check be done via SSL? - * - * @return boolean Was the user found? - * @throws HTTPException\InternalServerErrorException - */ - private function checkUser($host, $user, $ssl) - { - $this->writeLog(LOG_INFO, 'external user check for ' . $user . '@' . $host); - - $url = ($ssl ? 'https' : 'http') . '://' . $host . '/noscrape/' . $user; - - try { - $curlResult = DI::httpClient()->get($url, HttpClientAccept::JSON, [HttpClientOptions::REQUEST => HttpClientRequest::CONTACTVERIFIER]); - } catch (\Throwable $th) { - return false; - } - - if (!$curlResult->isSuccess()) { - return false; - } - - if ($curlResult->getReturnCode() != 200) { - return false; - } - - $json = @json_decode($curlResult->getBodyString()); - if (!is_object($json)) { - return false; - } - - return $json->nick == $user; - } - - /** - * Authenticate the given user and password - * - * @param array $aCommand The command array - * @throws Exception - */ - private function auth(array $aCommand) - { - // check user authentication - if (sizeof($aCommand) != 4) { - $this->writeLog(LOG_NOTICE, 'invalid auth command, data missing'); - fwrite(STDOUT, pack('nn', 2, 0)); - return; - } - - // We only allow one process per hostname. So we set a lock file - // Problem: We get the firstname after the first auth - not before - $this->setHost($aCommand[2]); - - // We now check if the password match - $sUser = str_replace(['%20', '(a)'], [' ', '@'], $aCommand[1]); - - $Error = false; - // Does the hostname match? So we try directly - if ($this->baseURL->getHost() == $aCommand[2]) { - try { - $this->writeLog(LOG_INFO, 'internal auth for ' . $sUser . '@' . $aCommand[2]); - User::getIdFromPasswordAuthentication($sUser, $aCommand[3], true); - } catch (HTTPException\ForbiddenException $ex) { - // User exists, authentication failed - $this->writeLog(LOG_INFO, 'check against alternate password for ' . $sUser . '@' . $aCommand[2]); - $aUser = User::getByNickname($sUser, ['uid']); - $sPassword = $this->pConfig->get($aUser['uid'], 'xmpp', 'password', null, true); - $Error = ($aCommand[3] != $sPassword); - } catch (\Throwable $ex) { - // User doesn't exist and any other failure case - $this->writeLog(LOG_WARNING, $ex->getMessage() . ': ' . $sUser); - $Error = true; - } - } else { - $Error = true; - } - - // If the hostnames doesn't match or there is some failure, we try to check remotely - if ($Error && !$this->checkCredentials($aCommand[2], $aCommand[1], $aCommand[3], true)) { - $this->writeLog(LOG_WARNING, 'authentication failed for user ' . $sUser . '@' . $aCommand[2]); - fwrite(STDOUT, pack('nn', 2, 0)); - } else { - $this->writeLog(LOG_NOTICE, 'authenticated user ' . $sUser . '@' . $aCommand[2]); - fwrite(STDOUT, pack('nn', 2, 1)); - } - } - - /** - * Check remote credentials via HTTP(S) - * - * @param string $host The hostname - * @param string $user Username - * @param string $password Password - * @param boolean $ssl Should the check be done via SSL? - * - * @return boolean Are the credentials okay? - */ - private function checkCredentials($host, $user, $password, $ssl) - { - $this->writeLog(LOG_INFO, 'external credential check for ' . $user . '@' . $host); - - $url = ($ssl ? 'https' : 'http') . '://' . $host . '/api/account/verify_credentials.json?skip_status=true'; - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); - curl_setopt($ch, CURLOPT_HEADER, true); - curl_setopt($ch, CURLOPT_NOBODY, true); - curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); - curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $password); - - curl_exec($ch); - $curl_info = @curl_getinfo($ch); - $http_code = $curl_info['http_code']; - curl_close($ch); - - $this->writeLog(LOG_INFO, 'external auth for ' . $user . '@' . $host . ' returned ' . $http_code); - - return $http_code == 200; - } - - /** - * Set the hostname for this process - * - * @param string $host The hostname - */ - private function setHost($host) - { - if (!empty($this->host)) { - return; - } - - $this->writeLog(LOG_INFO, 'Hostname for process ' . getmypid() . ' is ' . $host); - - $this->host = $host; - - $lockpath = $this->config->get('jabber', 'lockpath'); - if (is_null($lockpath)) { - $this->writeLog(LOG_INFO, 'No lockpath defined.'); - return; - } - - $file = $lockpath . DIRECTORY_SEPARATOR . $host; - if (PidFile::isRunningProcess($file)) { - if (PidFile::killProcess($file)) { - $this->writeLog(LOG_INFO, 'Old process was successfully killed'); - } else { - $this->writeLog(LOG_ERR, "The old Process wasn't killed in time. We now quit our process."); - die(); - } - } - - // Now it is safe to create the pid file - PidFile::create($file); - if (!file_exists($file)) { - $this->writeLog(LOG_WARNING, 'Logfile ' . $file . " couldn't be created."); - } - } - - /** - * write data to the syslog - * - * @param integer $loglevel The syslog loglevel - * @param string $sMessage The syslog message - */ - private function writeLog($loglevel, $sMessage) - { - if (!$this->bDebug && ($loglevel >= LOG_DEBUG)) { - return; - } - syslog($loglevel, $sMessage); - } - - /** - * destroy the class, close the syslog connection. - */ - public function __destruct() - { - $this->writeLog(LOG_NOTICE, 'stop'); - closelog(); - } -} diff --git a/src/Util/PidFile.php b/src/Util/PidFile.php deleted file mode 100644 index 2aaf5238a7..0000000000 --- a/src/Util/PidFile.php +++ /dev/null @@ -1,116 +0,0 @@ - ['data' => false], 'float' => ['data' => 4.6634234], 'array' => ['data' => ['1', '2', '3', '4', '5']], - 'object' => ['data' => new PidFile()], + 'object' => ['data' => new \stdClass()], 'null' => ['data' => null], ]; } diff --git a/tests/ConsoleTestCase.php b/tests/ConsoleTestCase.php index 05febcd735..b6a95fdf76 100644 --- a/tests/ConsoleTestCase.php +++ b/tests/ConsoleTestCase.php @@ -8,7 +8,6 @@ namespace Friendica\Test; use Asika\SimpleConsole\Console; -use Friendica\Test\MockedTestCase; use Friendica\Test\Util\Intercept; abstract class ConsoleTestCase extends MockedTestCase @@ -18,7 +17,9 @@ abstract class ConsoleTestCase extends MockedTestCase */ protected $consoleArgv = [ 'consoleTest.php' ]; - protected function setUp() : void + protected ?int $consoleExecReturn = null; + + protected function setUp(): void { parent::setUp(); @@ -35,8 +36,8 @@ abstract class ConsoleTestCase extends MockedTestCase protected function dumpExecute(Console $console) { Intercept::reset(); - $console->execute(); - $returnStr = Intercept::$cache; + $this->consoleExecReturn = $console->execute(); + $returnStr = Intercept::$cache; Intercept::reset(); return $returnStr; diff --git a/tests/datasets/ejabberd/fixture.php b/tests/datasets/ejabberd/fixture.php new file mode 100644 index 0000000000..f604ade4b0 --- /dev/null +++ b/tests/datasets/ejabberd/fixture.php @@ -0,0 +1,30 @@ + [ + [ + 'uid' => 51, + 'nickname' => 'admin', + 'username' => 'admin', + 'email' => 'admin@friendica.local', + 'password' => '$2y$10$QgqQemXm/MKxa30aoEqxBen.nsG7JMsyPFP/pTrtHqEq0GDpsWEsK', + 'verified' => true, + 'account_removed' => false, + 'account_expired' => false, + ], + [ + 'uid' => 52, + 'nickname' => 'user', + 'username' => 'user', + 'email' => 'user@friendica.local', + 'password' => '$2y$10$uR7EwWwaal/2kGx87As1X.iktGDeX25tU486gbtpN/PQ2FL0TNYam', + 'verified' => true, + 'account_removed' => false, + 'account_expired' => false, + ], + ], +]; diff --git a/tests/src/Console/EjabberdAuthTest.php b/tests/src/Console/EjabberdAuthTest.php new file mode 100644 index 0000000000..3bac98a282 --- /dev/null +++ b/tests/src/Console/EjabberdAuthTest.php @@ -0,0 +1,481 @@ +setUpFixtures(); + $this->mode = \Mockery::mock(Mode::class); + $this->httpClient = \Mockery::mock(ICanSendHttpRequests::class); + + $this->loadFixture(__DIR__ . '/../../datasets/ejabberd/fixture.php', DI::dba()); + + $this->inputStream = fopen('php://memory', 'r+'); + $this->outputStream = fopen('php://memory', 'w+'); + } + + protected function tearDown(): void + { + DI::lock()->releaseAll(); + $this->tearDownFixtures(); + + parent::tearDown(); + } + + private function sendInput(string $payload, bool $rewind = true) + { + $bin = pack('n', strlen($payload)) . $payload; + + fwrite($this->inputStream, $bin); + } + + private function assertSuccess(): void + { + $this->assertEquals(pack('nn', 2, 1), fread($this->outputStream, 4), 'Expected success'); // 2-byte length + 2-byte response + } + + private function assertFailed(): void + { + $this->assertEquals(pack('nn', 2, 0), fread($this->outputStream, 4), 'Expected success'); // 2-byte length + 2-byte response + } + + public function testWrongMode(): void + { + $this->mode->shouldReceive('isNormal')->andReturn(false)->once(); + + $console = new AuthEJabberd( + $this->mode, + DI::config(), + DI::pConfig(), + DI::dba(), + DI::baseUrl(), + DI::lock(), + $this->httpClient, + $this->consoleArgv, + $this->inputStream, + $this->outputStream + ); + + $txt = $this->dumpExecute($console); + rewind($this->outputStream); + $this->assertFailed(); + $this->assertSame(1, $this->consoleExecReturn); + $this->assertEquals("[Error] The node isn't ready.\n", $txt); + } + + public function dataAuth(): array + { + return [ + 'empty' => [ + 'input' => [ + '', + ], + 'assertion' => [ + false, + ], + ], + 'wrongCommand' => [ + 'input' => [ + 'wrong:command:so', + ], + 'assertion' => [ + false, + ], + ], + 'isuserValid' => [ + 'input' => [ + "isuser:admin:friendica.local", + ], + 'assertion' => [ + true, + ], + ], + 'isuserThreeDifferent' => [ + 'input' => [ + "isuser:admin:friendica.local", + "isuser:wrong:friendica.local", + "isuser:user:friendica.local", + ], + 'assertion' => [ + true, + false, + true, + ], + 'httpHandlers' => [ + new Response(404), + ], + ], + 'isuserTooShort' => [ + 'input' => [ + 'isuser', + 'isuser:admin', + 'isuser:admin:friendica.local', + ], + 'assertion' => [ + false, + false, + true, + ], + ], + 'authValid' => [ + 'input' => [ + "auth:admin:friendica.local:admin", + ], + 'assertion' => [ + true, + ], + ], + 'authThreeDifferent' => [ + 'input' => [ + "auth:admin:friendica.local:admin", + "auth:admin:friendica.local:wrong", + "auth:user:friendica.local:user", + ], + 'assertion' => [ + true, + false, + true, + ], + ], + 'authWrongPassword' => [ + 'input' => [ + "auth:admin:friendica.local:wrong", + ], + 'assertion' => [ + false, + ], + ], + 'authTooShort' => [ + 'input' => [ + 'auth', + 'auth:admin', + 'auth:admin:friendica.local', + 'auth:admin:friendica.local:admin', + ], + 'assertion' => [ + false, + false, + false, + true, + ], + ], + ]; + } + + /** + * Assert different kind of data, but shouldn't fail the daemon + * + * @dataProvider dataAuth + */ + public function testData(array $input, array $assertion, array $handlers = []): void + { + $this->mode->shouldReceive('isNormal')->andReturn(true)->once(); + + DI::config()->set('jabber', 'debug', 1); + + foreach ($input as $payload) { + $this->sendInput($payload); + } + rewind($this->inputStream); + + $mockHandler = new MockHandler(); + + foreach ($handlers as $handler) { + if (empty($handler)) { + continue; + } + + $mockHandler->append($handler); + } + + $console = new AuthEJabberd( + $this->mode, + DI::config(), + DI::pConfig(), + DI::dba(), + DI::baseUrl(), + DI::lock(), + $this->httpClient, + $this->consoleArgv, + $this->inputStream, + $this->outputStream + ); + + $txt = $this->dumpExecute($console); + print_r($txt); + $this->assertSame(0, $this->consoleExecReturn); + $this->assertEmpty($txt); + + rewind($this->outputStream); + foreach ($assertion as $assertType) { + if ($assertType) { + $this->assertSuccess(); + } else { + $this->assertFailed(); + + } + } + } + + public function dataCheckCredentialsExternal(): array + { + return [ + 'authWrongUser' => [ + 'payload' => 'auth:wrong:friendica.local:someelse', + // Only 200 is valid - not even 202 + 'assertUrl' => 'https://friendica.local/api/account/verify_credentials.json?skip_status=true', + 'isSuccess' => true, + 'returnCode' => 202, + 'assertion' => false, + ], + 'authRightUser' => [ + 'payload' => 'auth:wrong:friendica.local:someelse', + 'assertUrl' => 'https://friendica.local/api/account/verify_credentials.json?skip_status=true', + 'isSuccess' => true, + 'returnCode' => 200, + 'assertion' => true, + ], + ]; + } + + /** + * Tests, if the check user endpoint is correctly built + * + * @dataProvider dataCheckCredentialsExternal + * + * @return void + */ + public function testCheckCredentialsExternal(string $payload, string $assertUrl, bool $isSuccess, int $returnCode, bool $assertion): void + { + $this->mode->shouldReceive('isNormal')->andReturn(true)->once(); + $response = Mockery::mock(ICanHandleHttpResponses::class); + $response->shouldReceive('isSuccess')->andReturn($isSuccess); + $response->shouldReceive('getReturnCode')->andReturn($returnCode); + $this->httpClient->shouldReceive('head')->with($assertUrl, [ + HttpClientOptions::REQUEST => HttpClientRequest::CONTACTVERIFIER, + HttpClientOptions::TIMEOUT => 5, + HttpClientOptions::AUTH => [ + 'wrong', + 'someelse', + ], + ])->andReturn($response)->once(); + + $this->sendInput($payload); + rewind($this->inputStream); + + $console = new AuthEJabberd( + $this->mode, + DI::config(), + DI::pConfig(), + DI::dba(), + DI::baseUrl(), + DI::lock(), + $this->httpClient, + $this->consoleArgv, + $this->inputStream, + $this->outputStream + ); + + $txt = $this->dumpExecute($console); + print_r($txt); + + $this->assertSame(0, $this->consoleExecReturn); + $this->assertEmpty($txt); + + rewind($this->outputStream); + if ($assertion) { + $this->assertSuccess(); + } else { + $this->assertFailed(); + } + } + + + public function dataCheckUserExternal(): array + { + return [ + 'isuserWrongUser' => [ + 'payload' => 'isuser:wrong:friendica.local', + // Only 200 is valid - not even 202 + 'assertUrl' => 'https://friendica.local/noscrape/wrong', + 'isSuccess' => true, + 'returnCode' => 202, + 'bodyString' => '', + 'assertion' => false, + ], + 'isuserRightUser' => [ + 'payload' => 'isuser:wrong:friendica.local', + 'assertUrl' => 'https://friendica.local/noscrape/wrong', + 'isSuccess' => true, + 'returnCode' => 200, + 'bodyString' => json_encode(['nick' => 'wrong']), + 'assertion' => true, + ], + 'isuserEmptyBody' => [ + 'payload' => 'isuser:wrong:friendica.local', + 'assertUrl' => 'https://friendica.local/noscrape/wrong', + 'isSuccess' => true, + 'returnCode' => 200, + 'bodyString' => json_encode([]), + 'assertion' => false + ], + ]; + } + + /** + * Tests, if the check user endpoint is correctly built + * + * @dataProvider dataCheckUserExternal + * + * @return void + */ + public function testCheckUserExternal(string $payload, string $assertUrl, bool $isSuccess, int $returnCode, string $bodyString, bool $assertion): void + { + $this->mode->shouldReceive('isNormal')->andReturn(true)->once(); + $response = Mockery::mock(ICanHandleHttpResponses::class); + $response->shouldReceive('isSuccess')->andReturn($isSuccess); + $response->shouldReceive('getReturnCode')->andReturn($returnCode); + $response->shouldReceive('getBodyString')->andReturn($bodyString); + $this->httpClient->shouldReceive('get') + ->with($assertUrl, HttpClientAccept::JSON, [HttpClientOptions::REQUEST => HttpClientRequest::CONTACTVERIFIER] ) + ->andReturn($response)->once(); + + $this->sendInput($payload); + rewind($this->inputStream); + + $console = new AuthEJabberd( + $this->mode, + DI::config(), + DI::pConfig(), + DI::dba(), + DI::baseUrl(), + DI::lock(), + $this->httpClient, + $this->consoleArgv, + $this->inputStream, + $this->outputStream + ); + + $txt = $this->dumpExecute($console); + print_r($txt); + + $this->assertSame(0, $this->consoleExecReturn); + $this->assertEmpty($txt); + + rewind($this->outputStream); + if ($assertion) { + $this->assertSuccess(); + } else { + $this->assertFailed(); + } + } + + /** + * Tests, if the auth per pConfig works + * + * @return void + */ + public function testAuthForbidden(): void + { + $this->mode->shouldReceive('isNormal')->andReturn(true)->once(); + + DI::pConfig()->set(51, 'xmpp', 'password', 'pConfigPW'); + + $this->sendInput('auth:admin:friendica.local:pConfigPW'); + rewind($this->inputStream); + + $console = new AuthEJabberd( + $this->mode, + DI::config(), + DI::pConfig(), + DI::dba(), + DI::baseUrl(), + DI::lock(), + $this->httpClient, + $this->consoleArgv, + $this->inputStream, + $this->outputStream + ); + + $txt = $this->dumpExecute($console); + print_r($txt); + $this->assertEmpty($txt); + + rewind($this->outputStream); + $this->assertSuccess(); + } + + /** + * Just tests the help output + * + * @return void + */ + public function testGetHelp() + { + // Usable to purposely fail if new commands are added without taking tests into account + $theHelp = <<mode, + DI::config(), + DI::pConfig(), + DI::dba(), + DI::baseUrl(), + DI::lock(), + $this->httpClient, + $this->consoleArgv, + $this->inputStream, + $this->outputStream + ); + $console->setOption('help', true); + + $txt = $this->dumpExecute($console); + + self::assertEquals($txt, $theHelp); + } +} From c6ec9727c52404333b5ef719c5357390bad7cbdf Mon Sep 17 00:00:00 2001 From: Philipp Date: Mon, 5 May 2025 02:10:12 +0200 Subject: [PATCH 2/6] Remove impossible code --- src/Console/AuthEJabberd.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Console/AuthEJabberd.php b/src/Console/AuthEJabberd.php index fbaf3c0de1..c5cdd383ee 100644 --- a/src/Console/AuthEJabberd.php +++ b/src/Console/AuthEJabberd.php @@ -173,11 +173,6 @@ HELP; $this->writeLog(LOG_DEBUG, 'received data: ' . $sData); $aCommand = explode(':', $sData); - if (!is_array($aCommand)) { - $this->writeFailed('invalid command string ' . $sData); - break; - } - $cmd = $aCommand[0]; switch ($cmd) { case self::COMMAND_IS_USER: From b18d3aea2d238a637419a0c51f1a776e0b5b151f Mon Sep 17 00:00:00 2001 From: Philipp Date: Mon, 5 May 2025 02:18:58 +0200 Subject: [PATCH 3/6] Add more tests :) --- tests/src/Console/EjabberdAuthTest.php | 74 ++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/src/Console/EjabberdAuthTest.php b/tests/src/Console/EjabberdAuthTest.php index 3bac98a282..d0e38942d6 100644 --- a/tests/src/Console/EjabberdAuthTest.php +++ b/tests/src/Console/EjabberdAuthTest.php @@ -188,6 +188,20 @@ class EjabberdAuthTest extends ConsoleTestCase true, ], ], + 'setpassNotSupported' => [ + 'input' => [ + 'setpass', + 'setpass:admin', + 'setpass:admin:friendica.local', + 'setpass:admin:friendica.local:admin', + ], + 'assertion' => [ + false, + false, + false, + false, + ], + ], ]; } @@ -264,6 +278,13 @@ class EjabberdAuthTest extends ConsoleTestCase 'returnCode' => 200, 'assertion' => true, ], + 'authRightUserOtherDomain' => [ + 'payload' => 'auth:wrong:friendi.ca:someelse', + 'assertUrl' => 'https://friendi.ca/api/account/verify_credentials.json?skip_status=true', + 'isSuccess' => true, + 'returnCode' => 200, + 'assertion' => true, + ], ]; } @@ -332,6 +353,15 @@ class EjabberdAuthTest extends ConsoleTestCase 'bodyString' => '', 'assertion' => false, ], + 'isuserNoSuccess' => [ + 'payload' => 'isuser:wrong:friendica.local', + // Only 200 is valid - not even 202 + 'assertUrl' => 'https://friendica.local/noscrape/wrong', + 'isSuccess' => false, + 'returnCode' => 202, + 'bodyString' => '', + 'assertion' => false, + ], 'isuserRightUser' => [ 'payload' => 'isuser:wrong:friendica.local', 'assertUrl' => 'https://friendica.local/noscrape/wrong', @@ -340,6 +370,14 @@ class EjabberdAuthTest extends ConsoleTestCase 'bodyString' => json_encode(['nick' => 'wrong']), 'assertion' => true, ], + 'isuserRightUserOtherDomain' => [ + 'payload' => 'isuser:wrong:friendi.ca', + 'assertUrl' => 'https://friendi.ca/noscrape/wrong', + 'isSuccess' => true, + 'returnCode' => 200, + 'bodyString' => json_encode(['nick' => 'wrong']), + 'assertion' => true, + ], 'isuserEmptyBody' => [ 'payload' => 'isuser:wrong:friendica.local', 'assertUrl' => 'https://friendica.local/noscrape/wrong', @@ -434,6 +472,42 @@ class EjabberdAuthTest extends ConsoleTestCase $this->assertSuccess(); } + /** + * Test if the database is gone + * + * @return void + */ + public function testDbaDisconnected(): void + { + $this->mode->shouldReceive('isNormal')->andReturn(true)->once(); + + DI::dba()->disconnect(); + + $this->sendInput('auth:admin:friendica.local:pConfigPW'); + rewind($this->inputStream); + + $console = new AuthEJabberd( + $this->mode, + DI::config(), + DI::pConfig(), + DI::dba(), + DI::baseUrl(), + DI::lock(), + $this->httpClient, + $this->consoleArgv, + $this->inputStream, + $this->outputStream + ); + + $txt = $this->dumpExecute($console); + print_r($txt); + $this->assertSame(1, $this->consoleExecReturn); + $this->assertEquals("[Error] the database connection went down\n", $txt); + + rewind($this->outputStream); + $this->assertFailed(); + } + /** * Just tests the help output * From 18537b79a791573bf28f6f46583ca146af46d33a Mon Sep 17 00:00:00 2001 From: Philipp Date: Sun, 18 May 2025 00:44:53 +0200 Subject: [PATCH 4/6] Add LogChannel for auth_ejabberd --- src/App.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/App.php b/src/App.php index 5d72849228..b7c881cc9f 100644 --- a/src/App.php +++ b/src/App.php @@ -254,6 +254,10 @@ class App return LogChannel::WORKER; } + if ($command === 'auth_ejabberd') { + return LogChannel::AUTH_JABBERED; + } + // @TODO Add support for jetstream return LogChannel::CONSOLE; From b9361a68f427def517b45f592590cb8fd4d29956 Mon Sep 17 00:00:00 2001 From: Philipp Date: Sun, 18 May 2025 00:52:52 +0200 Subject: [PATCH 5/6] kick --- bin/auth_ejabberd.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/auth_ejabberd.php b/bin/auth_ejabberd.php index f162039fe4..fabceb8491 100755 --- a/bin/auth_ejabberd.php +++ b/bin/auth_ejabberd.php @@ -17,7 +17,7 @@ * * Installation: * - * - Change its owner to whichever user is running the server, ie. ejabberd + * - Change its owner to whichever user is running the server, ie. ejabberd * $ chown ejabberd:ejabberd /path/to/friendica/bin/auth_ejabberd.php * * - Change the access mode so it is readable only to the user ejabberd and has exec From 1e9f8c0fb2d5c153abe6ea8d9ea2cefbe242e006 Mon Sep 17 00:00:00 2001 From: Philipp Date: Sun, 25 May 2025 22:35:49 +0200 Subject: [PATCH 6/6] Remove debugging output --- tests/src/Console/EjabberdAuthTest.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/src/Console/EjabberdAuthTest.php b/tests/src/Console/EjabberdAuthTest.php index d0e38942d6..139312fbbd 100644 --- a/tests/src/Console/EjabberdAuthTest.php +++ b/tests/src/Console/EjabberdAuthTest.php @@ -245,7 +245,6 @@ class EjabberdAuthTest extends ConsoleTestCase ); $txt = $this->dumpExecute($console); - print_r($txt); $this->assertSame(0, $this->consoleExecReturn); $this->assertEmpty($txt); @@ -327,7 +326,6 @@ class EjabberdAuthTest extends ConsoleTestCase ); $txt = $this->dumpExecute($console); - print_r($txt); $this->assertSame(0, $this->consoleExecReturn); $this->assertEmpty($txt); @@ -424,7 +422,6 @@ class EjabberdAuthTest extends ConsoleTestCase ); $txt = $this->dumpExecute($console); - print_r($txt); $this->assertSame(0, $this->consoleExecReturn); $this->assertEmpty($txt); @@ -465,7 +462,6 @@ class EjabberdAuthTest extends ConsoleTestCase ); $txt = $this->dumpExecute($console); - print_r($txt); $this->assertEmpty($txt); rewind($this->outputStream); @@ -500,7 +496,6 @@ class EjabberdAuthTest extends ConsoleTestCase ); $txt = $this->dumpExecute($console); - print_r($txt); $this->assertSame(1, $this->consoleExecReturn); $this->assertEquals("[Error] the database connection went down\n", $txt);