diff --git a/app/modules/cli/Commands/Crypt/UpdateMasterPasswordCommand.php b/app/modules/cli/Commands/Crypt/UpdateMasterPasswordCommand.php new file mode 100644 index 00000000..3647f72f --- /dev/null +++ b/app/modules/cli/Commands/Crypt/UpdateMasterPasswordCommand.php @@ -0,0 +1,280 @@ +. + */ + +namespace SP\Modules\Cli\Commands\Crypt; + +use Exception; +use Psr\Log\LoggerInterface; +use RuntimeException; +use SP\Config\Config; +use SP\Modules\Cli\Commands\CommandBase; +use SP\Modules\Cli\Commands\Validators; +use SP\Services\Config\ConfigService; +use SP\Services\Crypt\MasterPassService; +use SP\Services\Crypt\UpdateMasterPassRequest; +use SP\Util\Util; +use Symfony\Component\Console\Command\LockableTrait; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\StyleInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * Class CryptCommand + * + * @package SP\Modules\Cli\Commands\Crypt + */ +final class UpdateMasterPasswordCommand extends CommandBase +{ + use LockableTrait; + + /** + * @var string[] + */ + public static array $envVarsMapping = [ + 'currentMasterPassword' => 'CURRENT_MASTER_PASSWORD', + 'masterPassword' => 'MASTER_PASSWORD', + 'update' => 'UPDATE', + ]; + /** + * @var string + */ + protected static $defaultName = 'sp:crypt:update-master-password'; + /** + * @var MasterPassService + */ + private MasterPassService $masterPassService; + /** + * @var ConfigService + */ + private ConfigService $configService; + + public function __construct(MasterPassService $masterPassService, + ConfigService $configService, + LoggerInterface $logger, + Config $config) + { + $this->masterPassService = $masterPassService; + $this->configService = $configService; + + parent::__construct($logger, $config); + } + + protected function configure(): void + { + $this->setDescription(__('Update sysPass master password')) + ->setHelp(__('This command updates sysPass master password for all the encrypted data')) + ->addOption('masterPassword', + null, + InputOption::VALUE_REQUIRED, + __('The new master password to encrypt the data')) + ->addOption('currentMasterPassword', + null, + InputOption::VALUE_REQUIRED, + __('The current master password')) + ->addOption('update', + null, + InputOption::VALUE_NONE, + __('Skip asking to confirm the update')); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $style = new SymfonyStyle($input, $output); + + if (!$this->lock()) { + $style->warning(__('The command is already running in another process')); + + return self::SUCCESS; + } + + try { + $this->checkInstalled(); + $this->checkMaintenance(); + + $request = new UpdateMasterPassRequest( + $this->getCurrentMasterPassword($input, $style), + $this->getMasterPassword($input, $style), + $this->configService->getByParam(MasterPassService::PARAM_MASTER_PASS_HASH) + ); + + if (!$this->getUpdate($input, $style)) { + $this->logger->debug(__u('Master password update aborted')); + $style->info(__('Master password update aborted')); + + return self::FAILURE; + } + + $style->caution(__('This is a critical process, please do not cancel/close this CLI')); + + $style->ask(__('Please, press any key to continue')); + + $this->masterPassService->changeMasterPassword($request); + + $this->logger->info(__u('Master password updated')); + + $style->success(__('Master password updated')); + $style->info(__('Please, restart any browser session to update it')); + + return self::SUCCESS; + } catch (Exception $e) { + $this->logger->error($e->getTraceAsString()); + $this->logger->error($e->getMessage()); + + $style->error(__($e->getMessage())); + } finally { + $this->release(); + } + + return self::FAILURE; + } + + private function checkInstalled(): void + { + if (!defined('TEST_ROOT') + && !$this->configData->isInstalled()) { + throw new RuntimeException(__u('sysPass is not installed')); + } + } + + private function checkMaintenance(): void + { + if (!defined('TEST_ROOT') + && !$this->configData->isMaintenance()) { + throw new RuntimeException(__u('Maintenance mode not enabled')); + } + } + + /** + * @param InputInterface $input + * @param StyleInterface $style + * + * @return bool + */ + private function getUpdate(InputInterface $input, StyleInterface $style): bool + { + $option = 'update'; + + $envUpdate = self::getEnvVarForOption($option); + + $value = $envUpdate !== false + ? Util::boolval($envUpdate) + : $input->getOption($option); + + if ($value === false) { + return $style->confirm(__('Update master password? (This process cannot be undone)'), false); + } + + return true; + } + + /** + * @param InputInterface $input + * @param StyleInterface $style + * + * @return array|false|mixed|string + */ + private function getCurrentMasterPassword( + InputInterface $input, + StyleInterface $style + ) + { + $password = self::getEnvVarOrOption('currentMasterPassword', $input); + + if (empty($password)) { + $this->logger->debug(__u('Ask for current master password')); + + $password = $style->askHidden( + __('Please provide the current master password'), + fn($value) => Validators::valueNotEmpty( + $value, + sprintf(__u('%s cannot be blank'), 'Master password') + ) + ); + $passwordRepeat = $style->askHidden( + __('Please provide the current master password again'), + fn($value) => Validators::valueNotEmpty( + $value, + sprintf(__u('%s cannot be blank'), 'Master password') + ) + ); + + if ($password !== $passwordRepeat) { + throw new RuntimeException(__u('Passwords do not match')); + } elseif (null === $password || null === $passwordRepeat) { + throw new RuntimeException(sprintf(__u('%s cannot be blank'), 'Master password')); + } + } + + return $password; + } + + /** + * @param InputInterface $input + * @param StyleInterface $style + * + * @return array|false|mixed|string + */ + private function getMasterPassword( + InputInterface $input, + StyleInterface $style + ) + { + $password = self::getEnvVarOrOption('masterPassword', $input); + + if (empty($password)) { + $this->logger->debug(__u('Ask for master password')); + + $password = $style->askHidden( + __('Please provide the new master password'), + fn($value) => Validators::valueNotEmpty( + $value, + sprintf(__u('%s cannot be blank'), 'Master password') + ) + ); + $passwordRepeat = $style->askHidden( + __('Please provide the new master password again'), + fn($value) => Validators::valueNotEmpty( + $value, + sprintf(__u('%s cannot be blank'), 'Master password') + ) + ); + + if ($password !== $passwordRepeat) { + throw new RuntimeException(__u('Passwords do not match')); + } elseif (null === $password || null === $passwordRepeat) { + throw new RuntimeException(sprintf(__u('%s cannot be blank'), 'Master password')); + } + } + + return $password; + } +} \ No newline at end of file diff --git a/app/modules/cli/Commands/InstallCommand.php b/app/modules/cli/Commands/InstallCommand.php index 05bf48a5..24143458 100644 --- a/app/modules/cli/Commands/InstallCommand.php +++ b/app/modules/cli/Commands/InstallCommand.php @@ -24,7 +24,6 @@ namespace SP\Modules\Cli\Commands; -use Closure; use Exception; use Psr\Log\LoggerInterface; use SP\Config\Config; @@ -109,7 +108,7 @@ final class InstallCommand extends CommandBase ->addOption('masterPassword', null, InputOption::VALUE_OPTIONAL, - __('Master password to encrypt the passwords')) + __('Master password to encrypt the data')) ->addOption('hostingMode', null, InputOption::VALUE_NONE, diff --git a/app/modules/cli/Init.php b/app/modules/cli/Init.php index 32db8c6d..4540341d 100644 --- a/app/modules/cli/Init.php +++ b/app/modules/cli/Init.php @@ -33,6 +33,7 @@ use SP\Core\Context\StatelessContext; use SP\Core\Language; use SP\Core\ModuleBase; use SP\Modules\Cli\Commands\BackupCommand; +use SP\Modules\Cli\Commands\Crypt\UpdateMasterPasswordCommand; use SP\Modules\Cli\Commands\InstallCommand; use SP\Util\VersionUtil; use Symfony\Component\Console\Application; @@ -48,7 +49,8 @@ final class Init extends ModuleBase { private const CLI_COMMANDS = [ InstallCommand::class, - BackupCommand::class + BackupCommand::class, + UpdateMasterPasswordCommand::class ]; /** * @var StatelessContext diff --git a/app/modules/cli/definitions.php b/app/modules/cli/definitions.php index eadbb718..ed2fb7ca 100644 --- a/app/modules/cli/definitions.php +++ b/app/modules/cli/definitions.php @@ -26,6 +26,8 @@ use Monolog\Handler\StreamHandler; use Monolog\Logger; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; +use SP\Modules\Cli\Commands\BackupCommand; +use SP\Modules\Cli\Commands\Crypt\UpdateMasterPasswordCommand; use SP\Modules\Cli\Commands\InstallCommand; use Symfony\Component\Console\Application; use Symfony\Component\Console\Input\ArgvInput; @@ -46,5 +48,7 @@ return [ OutputInterface::class => create(ConsoleOutput::class) ->constructor(OutputInterface::VERBOSITY_NORMAL, true), InputInterface::class => create(ArgvInput::class), - InstallCommand::class => autowire() + InstallCommand::class => autowire(), + BackupCommand::class => autowire(), + UpdateMasterPasswordCommand::class => autowire() ]; diff --git a/app/modules/web/Controllers/ConfigEncryptionController.php b/app/modules/web/Controllers/ConfigEncryptionController.php index 29de2039..92263f68 100644 --- a/app/modules/web/Controllers/ConfigEncryptionController.php +++ b/app/modules/web/Controllers/ConfigEncryptionController.php @@ -28,6 +28,7 @@ use DI\DependencyException; use DI\NotFoundException; use Exception; use SP\Core\Acl\Acl; +use SP\Core\Acl\ActionsInterface; use SP\Core\Acl\UnauthorizedPageException; use SP\Core\Crypt\Hash; use SP\Core\Crypt\Session as CryptSession; @@ -135,7 +136,9 @@ final class ConfigEncryptionController extends SimpleControllerBase if (!$noAccountPassChange) { try { - $task = $taskId !== null ? TaskFactory::create(__FUNCTION__, $taskId) : null; + $task = $taskId !== null + ? TaskFactory::create(__FUNCTION__, $taskId) + : null; $request = new UpdateMasterPassRequest( $currentMasterPass, @@ -177,7 +180,7 @@ final class ConfigEncryptionController extends SimpleControllerBase return $this->returnJsonResponse( JsonResponse::JSON_SUCCESS_STICKY, __u('Master password updated'), - [__u('Please, restart the session for update it')] + [__u('Please, restart the session to update it')] ); } @@ -275,7 +278,7 @@ final class ConfigEncryptionController extends SimpleControllerBase { try { $this->checks(); - $this->checkAccess(Acl::CONFIG_CRYPT); + $this->checkAccess(ActionsInterface::CONFIG_CRYPT); } catch (UnauthorizedPageException $e) { $this->eventDispatcher->notifyEvent('exception', new Event($e)); diff --git a/composer.json b/composer.json index 7fdcff34..fb79ea49 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,8 @@ "ext-libxml": "*", "ext-mbstring": "*", "league/fractal": "^0.19.2", - "symfony/console": "^v5.1.2" + "symfony/console": "^v5.1.2", + "symfony/lock": "^v5.0" }, "require-dev": { "phpunit/phpunit": "^9", diff --git a/composer.lock b/composer.lock index a738eadb..d9d39db7 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": "64273b1e13feb822664429ef9e4c698d", + "content-hash": "e123614aaac6578d9025c736e63dcebb", "packages": [ { "name": "ademarre/binary-to-text-php", @@ -2756,6 +2756,86 @@ ], "time": "2021-03-23T23:28:01+00:00" }, + { + "name": "symfony/lock", + "version": "v5.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/lock.git", + "reference": "a78fda52b1b6f74d60e642e91d0e0133b08a8546" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/lock/zipball/a78fda52b1b6f74d60e642e91d0e0133b08a8546", + "reference": "a78fda52b1b6f74d60e642e91d0e0133b08a8546", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.1", + "symfony/polyfill-php80": "^1.16" + }, + "conflict": { + "doctrine/dbal": "<2.10" + }, + "require-dev": { + "doctrine/dbal": "^2.10|^3.0", + "mongodb/mongodb": "~1.1", + "predis/predis": "~1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Lock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jérémy Derussé", + "email": "jeremy@derusse.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Creates and manages locks, a mechanism to provide exclusive access to a shared resource", + "homepage": "https://symfony.com", + "keywords": [ + "cas", + "flock", + "locking", + "mutex", + "redlock", + "semaphore" + ], + "support": { + "source": "https://github.com/symfony/lock/tree/v5.3.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-07-23T15:55:36+00:00" + }, { "name": "symfony/polyfill-ctype", "version": "v1.23.0", diff --git a/lib/SP/Services/Account/AccountCryptService.php b/lib/SP/Services/Account/AccountCryptService.php index 2e312535..4a9cee02 100644 --- a/lib/SP/Services/Account/AccountCryptService.php +++ b/lib/SP/Services/Account/AccountCryptService.php @@ -198,12 +198,7 @@ final class AccountCryptService extends Service $accountsOk[] = $account->id; $counter++; - } catch (SPException $e) { - $errorCount++; - - $eventMessage->addDescription(__u('Error while updating the account\'s password')); - $eventMessage->addDetail($account->name, $account->id); - } catch (CryptoException $e) { + } catch (SPException | CryptoException $e) { $errorCount++; $eventMessage->addDescription(__u('Error while updating the account\'s password')); @@ -263,7 +258,7 @@ final class AccountCryptService extends Service throw new ServiceException( __u('Error while updating the accounts\' passwords in history'), - ServiceException::ERROR, + SPException::ERROR, null, $e->getCode(), $e); diff --git a/tests/SP/DatabaseTestCase.php b/tests/SP/DatabaseTestCase.php index 716ede0a..f72ee142 100644 --- a/tests/SP/DatabaseTestCase.php +++ b/tests/SP/DatabaseTestCase.php @@ -24,9 +24,7 @@ namespace SP\Tests; -use PDO; use PHPUnit\Framework\TestCase; -use SP\Storage\Database\DatabaseException; /** * Class DatabaseBaseTest @@ -37,30 +35,7 @@ use SP\Storage\Database\DatabaseException; */ abstract class DatabaseTestCase extends TestCase { - /** - * @var bool - */ - protected static $loadFixtures = false; - /** - * @var PDO - */ - private static $conn; - - /** - * @param string $table - * - * @return int - */ - protected static function getRowCount(string $table): int - { - if (!self::$conn) { - return 0; - } - - $sql = sprintf('SELECT count(*) FROM `%s`', $table); - - return (int)self::$conn->query($sql)->fetchColumn(); - } + use DatabaseTrait; protected function setUp(): void { @@ -70,57 +45,4 @@ abstract class DatabaseTestCase extends TestCase self::loadFixtures(); } } - - protected static function loadFixtures() - { - $dbServer = getenv('DB_SERVER'); - $dbUser = getenv('DB_USER'); - $dbPass = getenv('DB_PASS'); - $dbName = getenv('DB_NAME'); - - foreach (FIXTURE_FILES as $file) { - if (!empty($dbPass)) { - $cmd = sprintf( - 'mysql -h %s -u %s -p%s %s < %s', - $dbServer, - $dbUser, - $dbPass, - $dbName, - $file - ); - } else { - $cmd = sprintf( - 'mysql -h %s -u %s %s < %s', - $dbServer, - $dbUser, - $dbName, - $file - ); - } - - exec($cmd, $output, $res); - - if ($res !== 0) { - error_log(sprintf('Cannot load fixtures from: %s', $file)); - error_log(sprintf('CMD: %s', $cmd)); - error_log(print_r($output, true)); - - exit(1); - } - - printf('Fixtures loaded from: %s' . PHP_EOL, $file); - } - - if (!self::$conn) { - try { - self::$conn = getDbHandler()->getConnection(); - } catch (DatabaseException $e) { - processException($e); - - exit(1); - } - } - } - - } \ No newline at end of file diff --git a/tests/SP/DatabaseTrait.php b/tests/SP/DatabaseTrait.php new file mode 100644 index 00000000..dd87bb61 --- /dev/null +++ b/tests/SP/DatabaseTrait.php @@ -0,0 +1,113 @@ +. + */ + +namespace SP\Tests; + +use PDO; +use SP\Storage\Database\DatabaseException; + +/** + * + */ +trait DatabaseTrait +{ + /** + * @var bool + */ + protected static bool $loadFixtures = false; + /** + * @var PDO|null + */ + private static ?PDO $conn = null; + + /** + * @param string $table + * + * @return int + */ + protected static function getRowCount(string $table): int + { + if (!self::$conn) { + return 0; + } + + $sql = sprintf('SELECT count(*) FROM `%s`', $table); + + return (int)self::$conn->query($sql)->fetchColumn(); + } + + protected static function loadFixtures(): void + { + $dbServer = getenv('DB_SERVER'); + $dbUser = getenv('DB_USER'); + $dbPass = getenv('DB_PASS'); + $dbName = getenv('DB_NAME'); + + foreach (FIXTURE_FILES as $file) { + if (!empty($dbPass)) { + $cmd = sprintf( + 'mysql -h %s -u %s -p%s %s < %s', + $dbServer, + $dbUser, + $dbPass, + $dbName, + $file + ); + } else { + $cmd = sprintf( + 'mysql -h %s -u %s %s < %s', + $dbServer, + $dbUser, + $dbName, + $file + ); + } + + exec($cmd, $output, $res); + + if ($res !== 0) { + /** @noinspection ForgottenDebugOutputInspection */ + error_log(sprintf('Cannot load fixtures from: %s', $file)); + /** @noinspection ForgottenDebugOutputInspection */ + error_log(sprintf('CMD: %s', $cmd)); + /** @noinspection ForgottenDebugOutputInspection */ + error_log(print_r($output, true)); + + exit(1); + } + + printf('Fixtures loaded from: %s' . PHP_EOL, $file); + } + + if (!self::$conn) { + try { + self::$conn = getDbHandler()->getConnection(); + } catch (DatabaseException $e) { + processException($e); + + exit(1); + } + } + } +} \ No newline at end of file diff --git a/tests/SP/Modules/Cli/Commands/UpdateMasterPasswordCommandTest.php b/tests/SP/Modules/Cli/Commands/UpdateMasterPasswordCommandTest.php new file mode 100644 index 00000000..3b513395 --- /dev/null +++ b/tests/SP/Modules/Cli/Commands/UpdateMasterPasswordCommandTest.php @@ -0,0 +1,202 @@ +. + */ + +namespace SP\Tests\Modules\Cli\Commands; + +use DI\DependencyException; +use DI\NotFoundException; +use SP\Core\Exceptions\FileNotFoundException; +use SP\Modules\Cli\Commands\Crypt\UpdateMasterPasswordCommand; +use SP\Tests\DatabaseTrait; +use SP\Tests\Modules\Cli\CliTestCase; +use SP\Tests\Services\Account\AccountCryptServiceTest; +use function SP\Tests\recreateDir; + +/** + * + */ +class UpdateMasterPasswordCommandTest extends CliTestCase +{ + + use DatabaseTrait; + + /** + * @var string + */ + protected static string $currentConfig; + /** + * @var string[] + */ + protected static array $commandInputData = [ + '--currentMasterPassword' => AccountCryptServiceTest::CURRENT_MASTERPASS, + '--masterPassword' => AccountCryptServiceTest::NEW_MASTERPASS + ]; + + /** + * @throws DependencyException + * @throws NotFoundException + */ + public function testUpdateAborted(): void + { + $commandTester = $this->executeCommandTest( + UpdateMasterPasswordCommand::class + ); + + // the output of the command in the console + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Master password update aborted', $output); + } + + /** + * @throws DependencyException + * @throws FileNotFoundException + * @throws NotFoundException + */ + public function testUpdateIsSuccessful(): void + { + $inputData = array_merge( + self::$commandInputData, + [ + '--update' => null + ] + ); + + $commandTester = $this->executeCommandTest( + UpdateMasterPasswordCommand::class, + $inputData + ); + + // the output of the command in the console + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Master password updated', $output); + + // Recreate cache directory to avoid unwanted behavior + recreateDir(CACHE_PATH); + } + + /** + * @throws DependencyException + * @throws NotFoundException + */ + public function testUpdateFromEnvironmentVarIsAbort(): void + { + $this->setEnvironmentVariables(); + + $commandTester = $this->executeCommandTest( + UpdateMasterPasswordCommand::class, + null, + false + ); + + // the output of the command in the console + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Master password update aborted', $output); + } + + /** + * @throws DependencyException + * @throws NotFoundException + */ + public function testUpdateFromEnvironmentVarBlankCurrentMasterPassword(): void + { + putenv(sprintf('%s=', + UpdateMasterPasswordCommand::$envVarsMapping['masterPassword']) + ); + + $commandTester = $this->executeCommandTest( + UpdateMasterPasswordCommand::class, + null, + false + ); + + // the output of the command in the console + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Master password cannot be blank', $output); + } + + /** + * @throws DependencyException + * @throws NotFoundException + */ + public function testUpdateFromEnvironmentVarBlankMasterPassword(): void + { + putenv(sprintf('%s=', + UpdateMasterPasswordCommand::$envVarsMapping['currentMasterPassword']) + ); + + $commandTester = $this->executeCommandTest( + UpdateMasterPasswordCommand::class, + null, + false + ); + + // the output of the command in the console + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Master password cannot be blank', $output); + } + + private function setEnvironmentVariables(): void + { + putenv(sprintf('%s=%s', + UpdateMasterPasswordCommand::$envVarsMapping['currentMasterPassword'], + AccountCryptServiceTest::CURRENT_MASTERPASS) + ); + putenv(sprintf('%s=%s', + UpdateMasterPasswordCommand::$envVarsMapping['masterPassword'], + AccountCryptServiceTest::NEW_MASTERPASS) + ); + } + + /** + * @throws DependencyException + * @throws NotFoundException + */ + public function testUpdateFromEnvironmentVarIsSuccessful(): void + { + putenv(sprintf('%s=true', + UpdateMasterPasswordCommand::$envVarsMapping['update']) + ); + + $this->setEnvironmentVariables(); + + $commandTester = $this->executeCommandTest( + UpdateMasterPasswordCommand::class, + null, + false + ); + + // the output of the command in the console + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Master password updated', $output); + } + + protected function setUp(): void + { + $this->setupDatabase(); + + self::loadFixtures(); + + parent::setUp(); + } +}