* [ADD] Master password update CLI command and tests.

* [MOD] Code refactoring.

Signed-off-by: Rubén D <nuxsmin@syspass.org>
This commit is contained in:
Rubén D
2021-09-27 00:20:24 +02:00
parent 9d103ecde4
commit e64897855a
11 changed files with 696 additions and 95 deletions

View File

@@ -0,0 +1,280 @@
<?php
/*
* sysPass
*
* @author nuxsmin
* @link https://syspass.org
* @copyright 2012-2021, Rubén Domínguez nuxsmin@$syspass.org
*
* This file is part of sysPass.
*
* sysPass is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* sysPass is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with sysPass. If not, see <http://www.gnu.org/licenses/>.
*/
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;
}
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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()
];

View File

@@ -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));

View File

@@ -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",

82
composer.lock generated
View File

@@ -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",

View File

@@ -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);

View File

@@ -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);
}
}
}
}

113
tests/SP/DatabaseTrait.php Normal file
View File

@@ -0,0 +1,113 @@
<?php
/*
* sysPass
*
* @author nuxsmin
* @link https://syspass.org
* @copyright 2012-2021, Rubén Domínguez nuxsmin@$syspass.org
*
* This file is part of sysPass.
*
* sysPass is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* sysPass is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with sysPass. If not, see <http://www.gnu.org/licenses/>.
*/
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);
}
}
}
}

View File

@@ -0,0 +1,202 @@
<?php
/*
* sysPass
*
* @author nuxsmin
* @link https://syspass.org
* @copyright 2012-2021, Rubén Domínguez nuxsmin@$syspass.org
*
* This file is part of sysPass.
*
* sysPass is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* sysPass is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with sysPass. If not, see <http://www.gnu.org/licenses/>.
*/
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();
}
}