diff --git a/classes/cliTool/CommandInterface.php b/classes/cliTool/CommandInterface.php new file mode 100644 index 00000000000..562a8d74ca7 --- /dev/null +++ b/classes/cliTool/CommandInterface.php @@ -0,0 +1,34 @@ +setOutput($output); + } + + public function errorBlock(array $messages = [], ?string $title = null): void + { + $this->getOutput()->block( + $messages, + $title, + 'fg=white;bg=red', + ' ', + true + ); + } +} diff --git a/classes/cliTool/traits/HasCommandInterface.php b/classes/cliTool/traits/HasCommandInterface.php new file mode 100644 index 00000000000..180f5767656 --- /dev/null +++ b/classes/cliTool/traits/HasCommandInterface.php @@ -0,0 +1,69 @@ +commandInterface = $commandInterface ?? new CommandInterface; + + return $this; + } + + /** + * Get the command interface + */ + public function getCommandInterface(): CommandInterface + { + return $this->commandInterface; + } + + /** + * Print given options in a pretty way. + */ + protected function printCommandList(array $options, bool $shouldTranslate = true): void + { + $width = (int)collect(array_keys($options)) + ->map(fn($command) => Helper::width($command)) + ->sort() + ->last() + 2; + + foreach ($options as $commandName => $description) { + $spacingWidth = $width - Helper::width($commandName); + $this->getCommandInterface()->line( + sprintf( + ' %s%s%s', + $commandName, + str_repeat(' ', $spacingWidth), + $shouldTranslate ? __($description) : $description + ) + ); + } + } +} diff --git a/classes/cliTool/traits/HasParameterList.php b/classes/cliTool/traits/HasParameterList.php new file mode 100644 index 00000000000..8199b3269ba --- /dev/null +++ b/classes/cliTool/traits/HasParameterList.php @@ -0,0 +1,78 @@ +parameterList = $parameters; + + return $this; + } + + /** + * Get the parameter list passed on CLI + */ + public function getParameterList(): ?array + { + return $this->parameterList; + } + + /** + * Get the value of a specific parameter + */ + protected function getParameterValue(string $parameter, mixed $default = null): mixed + { + if (!isset($this->getParameterList()[$parameter])) { + return $default; + } + + return $this->getParameterList()[$parameter]; + } + + /** + * Determined if the given flag set on CLI + */ + protected function hasFlagSet(string $flag): bool + { + return in_array($flag, $this->getParameterList()); + } +} diff --git a/classes/core/PKPAppKey.php b/classes/core/PKPAppKey.php new file mode 100644 index 00000000000..8d621596934 --- /dev/null +++ b/classes/core/PKPAppKey.php @@ -0,0 +1,183 @@ + ['size' => 16, 'aead' => false], + 'aes-256-cbc' => ['size' => 32, 'aead' => false], + 'aes-128-gcm' => ['size' => 16, 'aead' => true], + 'aes-256-gcm' => ['size' => 32, 'aead' => true], + ]; + + /** + * The default cipher algorithms + * + * @var string + */ + private static $defaultCipher = 'aes-256-cbc'; + + /** + * Get the list of supported ciphers + */ + public static function getSupportedCiphers(): array + { + return self::$supportedCiphers; + } + + /** + * Get the defined cipher + */ + public static function getCipher(): string + { + return Config::getVar('security', 'cipher', self::$defaultCipher); + } + + /** + * Has the app key defined in config file + */ + public static function hasKey(): bool + { + return !empty(Config::getVar('general', 'app_key', '')); + } + + /** + * Has the app key variable defined in config file + */ + public static function hasKeyVariable(): bool + { + return Config::hasVar('general', 'app_key'); + } + + /** + * Get the app key defined in config file + */ + public static function getKey(): string + { + return Config::getVar('general', 'app_key', ''); + } + + /** + * Validate a given cipher + */ + public static function validateCipher(string $cipher): string + { + $cipher = strtolower($cipher); + + if (!in_array($cipher, array_keys(static::getSupportedCiphers()))) { + $ciphers = implode(', ', array_keys(static::getSupportedCiphers())); + + throw new Exception( + sprintf( + 'Invalid cipher %s provided, must be among [%s]', + $cipher, + $ciphers + ) + ); + } + + return $cipher; + } + + /** + * Validate given or config defined app + */ + public static function validate(string $key = null, string $cipher = null): bool + { + $config = app('config')->get('app'); + + return Encrypter::supported( + static::parseKey($key ?? $config['key']), + static::validateCipher($cipher ?? $config['cipher']) + ); + } + + /** + * Generate a new app key + */ + public static function generate(string $cipher = null): string + { + $config = app('config')->get('app'); + + return 'base64:'.base64_encode( + Encrypter::generateKey(static::validateCipher($cipher ?? $config['cipher'])) + ); + } + + /** + * Write the given app key in the config file + */ + public static function writeToConfig(string $key): bool + { + if (!static::validate($key)) { + $ciphers = implode(', ', array_keys(static::getSupportedCiphers())); + + // Error invalid app key + throw new Exception( + "Unsupported cipher or incorrect key length. Supported ciphers are: {$ciphers}." + ); + } + + $configParser = new ConfigParser; + $configParams = [ + 'general' => [ + 'app_key' => $key, + ], + ]; + + if (!static::hasKeyVariable()) { + // Error if the config key `app_key` not defined under `general` section + throw new Exception('Config variable named `app_key` not defined in the `general` section'); + } + + if (!$configParser->updateConfig(Config::getConfigFileName(), $configParams)) { + // Error reading config file + throw new Exception('Unable to read the config file'); + } + + if (!$configParser->writeConfig(Config::getConfigFileName())) { + // Error writing config file + throw new Exception('Unable to write the app key in the config file'); + } + + return true; + } + + /** + * Parse the given app key and return the real key value + */ + public static function parseKey(string $key): string + { + if (Str::startsWith($key, $prefix = 'base64:')) { + $key = base64_decode(Str::after($key, $prefix)); + } + + return $key; + } +} diff --git a/classes/core/PKPApplication.php b/classes/core/PKPApplication.php index 5a49dc6aa83..9d9b5b33cd0 100644 --- a/classes/core/PKPApplication.php +++ b/classes/core/PKPApplication.php @@ -389,22 +389,6 @@ public static function getName() return 'pkp-lib'; } - /** - * Get the default cipher algorithm - * Available and Valid cipher algorithms are - * - aes-128-cbc - * - aes-256-cbc - * - aes-128-gcm - * - aes-256-gcm - * @see \Illuminate\Encryption\Encrypter::$supportedCiphers - * - * @return string - */ - public static function getDefaultCipher(): string - { - return 'AES-256-CBC'; - } - /** * Get the locale key for the name of this application. * diff --git a/classes/core/PKPContainer.php b/classes/core/PKPContainer.php index a29afcefe59..881dc8d21ef 100644 --- a/classes/core/PKPContainer.php +++ b/classes/core/PKPContainer.php @@ -29,6 +29,7 @@ use Illuminate\Log\LogServiceProvider; use Illuminate\Queue\Failed\DatabaseFailedJobProvider; use Illuminate\Support\Facades\Facade; +use PKP\core\PKPAppKey; use PKP\config\Config; use PKP\i18n\LocaleServiceProvider; use PKP\core\PKPUserProvider; @@ -319,8 +320,8 @@ protected function loadConfiguration() $_request = Application::get()->getRequest(); $items['app'] = [ - 'key' => Config::getVar('general', 'key', ''), - 'cipher' => Config::getVar('security', 'cipher', Application::getDefaultCipher()), + 'key' => PKPAppKey::getKey(), + 'cipher' => PKPAppKey::getCipher(), ]; // Database connection diff --git a/classes/core/PKPEncryptionServiceProvider.php b/classes/core/PKPEncryptionServiceProvider.php index c96b69f5da5..5e1e7d37e1d 100644 --- a/classes/core/PKPEncryptionServiceProvider.php +++ b/classes/core/PKPEncryptionServiceProvider.php @@ -20,9 +20,7 @@ class PKPEncryptionServiceProvider extends IlluminateEncryptionServiceProvider { /** - * Register the encrypter. - * - * @return void + * @copydoc Illuminate\Encryption\EncryptionServiceProvider::registerEncrypter() */ protected function registerEncrypter() { diff --git a/classes/install/PKPInstall.php b/classes/install/PKPInstall.php index e10f0199f2c..f382a01c7fe 100644 --- a/classes/install/PKPInstall.php +++ b/classes/install/PKPInstall.php @@ -188,6 +188,7 @@ public function createConfig() return $this->updateConfig( [ 'general' => [ + 'app_key' => \PKP\core\PKPAppKey::generate(), 'installed' => 'On', 'base_url' => $request->getBaseUrl(), 'enable_beacon' => $this->getParam('enableBeacon') ? 'On' : 'Off', diff --git a/locale/en/admin.po b/locale/en/admin.po index cf45ca86e9e..9e7a8e0e46f 100644 --- a/locale/en/admin.po +++ b/locale/en/admin.po @@ -933,3 +933,36 @@ msgstr "Dispatch a job to automatically remove expired Invitations" msgid "admin.settings.statistics.sushiPlatform.isSiteSushiPlatform" msgstr "Use the site as the platform for all journals." + +msgid "admin.cli.tool.appKey.options.usage.description" +msgstr "Display the AppKey usage parameters" + +msgid "admin.cli.tool.appKey.options.validate.description" +msgstr "Validate the current app key if any found in the config file" + +msgid "admin.cli.tool.appKey.options.generate.description" +msgstr "Generate a new app key and replace in the config file. pass with flag --show to only view and --force to overwrite an existing valid app key." + +msgid "admin.cli.tool.appKey.mean.those" +msgstr "Did you mean one of the following?" + +msgid "admin.cli.tool.appKey.show" +msgstr "Generated App Key: {$appKey}" + +msgid "admin.cli.tool.appKey.error.missingKeyVariable" +msgstr "No key variable named `app_key` defined in the `general` section of config file. Please update the config file's general section and add line `app_key = `" + +msgid "admin.cli.tool.appKey.error.missingAppKey" +msgstr "No app key set in the config file ." + +msgid "admin.cli.tool.appKey.error.InvalidAppKey" +msgstr "Invalid app key set, unsupported cipher or incorrect key length. Supported ciphers are: {$ciphers}." + +msgid "admin.cli.tool.appKey.warning.replaceValidKey" +msgstr "A valid APP Key already set in the config file. To overwrite, pass the flag --force with the command." + +msgid "admin.cli.tool.appKey.success.writtenToConfig" +msgstr "App key set successfully in the config file." + +msgid "admin.cli.tool.appKey.success.valid" +msgstr "A valid app key set in the config file" diff --git a/tools/appKey.php b/tools/appKey.php index e69de29bb2d..21a0225dac4 100644 --- a/tools/appKey.php +++ b/tools/appKey.php @@ -0,0 +1,188 @@ + 'admin.cli.tool.appKey.options.validate.description', + 'generate' => 'admin.cli.tool.appKey.options.generate.description', + 'usage' => 'admin.cli.tool.appKey.options.usage.description', + ]; + + /** + * Which option will be call? + */ + protected ?string $option; + + /** + * Constructor + */ + public function __construct($argv = []) + { + parent::__construct($argv); + + array_shift($argv); // Shift the tool name off the top + + $this->setParameterList($argv); + + if (!isset($this->getParameterList()[0])) { + throw new CommandNotFoundException( + __('admin.cli.tool.jobs.empty.option'), + array_keys(self::AVAILABLE_OPTIONS) + ); + } + + $this->option = $this->getParameterList()[0]; + + $this->setCommandInterface(); + } + + /** + * Parse and execute the command + */ + public function execute() + { + if (!isset(self::AVAILABLE_OPTIONS[$this->option])) { + throw new CommandNotFoundException( + __('admin.cli.tool.jobs.option.doesnt.exists', ['option' => $this->option]), + array_keys(self::AVAILABLE_OPTIONS) + ); + } + + $this->{$this->option}(); + } + + /** + * Print command usage information. + */ + public function usage(): void + { + $this->getCommandInterface()->line('' . __('admin.cli.tool.usage.title') . ''); + $this->getCommandInterface()->line(__('admin.cli.tool.usage.parameters') . PHP_EOL); + $this->getCommandInterface()->line('' . __('admin.cli.tool.available.commands', ['namespace' => 'appKey']) . ''); + + $this->printCommandList(self::AVAILABLE_OPTIONS); + } + + /** + * Generate the app key and write in the config file + */ + protected function generate(): void + { + $output = $this->getCommandInterface()->getOutput(); + + try { + $appKey = PKPAppKey::generate(); + } catch (Throwable $exception) { + $output->error($exception->getMessage()); + return; + } + + if ($this->hasFlagSet('--show')) { + $output->info(__('admin.cli.tool.appKey.show', ['appKey' => $appKey])); + return; + } + + if (!PKPAppKey::hasKeyVariable()) { + $output->error(__('admin.cli.tool.appKey.error.missingKeyVariable')); + return; + } + + if ((PKPAppKey::hasKey() && PKPAppKey::validate(PKPAppKey::getKey())) && + !$this->hasFlagSet('--force')) { + + $output->warning(__('admin.cli.tool.appKey.warning.replaceValidKey')); + return; + } + + try { + PKPAppKey::writeToConfig($appKey); + $output->success(__('admin.cli.tool.appKey.success.writtenToConfig')); + } catch (Throwable $exception) { + $this->getCommandInterface()->getOutput()->error($exception->getMessage()); + } finally { + return; + } + } + + /** + * Validate the app key from config file + */ + protected function validate(): void + { + $output = $this->getCommandInterface()->getOutput(); + + if (!PKPAppKey::hasKeyVariable()) { + $output->error(__('admin.cli.tool.appKey.error.missingKeyVariable')); + return; + } + + if (!PKPAppKey::hasKey()) { + $output->error(__('admin.cli.tool.appKey.error.missingAppKey')); + return; + } + + if (!PKPAppKey::validate(PKPAppKey::getKey())) { + $output->error(__('admin.cli.tool.appKey.error.InvalidAppKey', [ + 'ciphers' => implode(', ', array_keys(PKPAppKey::getSupportedCiphers())) + ])); + return; + } + + $output->success(__('admin.cli.tool.appKey.success.valid')); + } +} + +try { + $tool = new CommandAppKey($argv ?? []); + $tool->execute(); +} catch (\Throwable $exception) { + $output = new \PKP\cliTool\CommandInterface; + + if ($exception instanceof CommandInvalidArgumentException) { + $output->errorBlock([$exception->getMessage()]); + + return; + } + + if ($exception instanceof CommandNotFoundException) { + $alternatives = $exception->getAlternatives(); + + $message = __('admin.cli.tool.appKey.mean.those') . PHP_EOL . implode(PHP_EOL, $alternatives); + + $output->errorBlock([$exception->getMessage(), $message]); + + return; + } + + throw $exception; +} diff --git a/tools/jobs.php b/tools/jobs.php index 57b402d4fa0..aad8ba8129f 100644 --- a/tools/jobs.php +++ b/tools/jobs.php @@ -1,7 +1,5 @@ setOutput($output); - } - - public function errorBlock(array $messages = [], ?string $title = null): void - { - $this->getOutput()->block( - $messages, - $title, - 'fg=white;bg=red', - ' ', - true - ); - } -} - class commandJobs extends CommandLineTool { + use HasParameterList; + use HasCommandInterface; + protected const AVAILABLE_OPTIONS = [ 'list' => 'admin.cli.tool.jobs.available.options.list.description', 'purge' => 'admin.cli.tool.jobs.available.options.purge.description', @@ -96,16 +68,6 @@ class commandJobs extends CommandLineTool */ protected $option = null; - /** - * @var null|array Parameters and arguments from CLI - */ - protected $parameterList = null; - - /** - * CLI interface, this object should extends InteractsWithIO - */ - protected $commandInterface = null; - /** * Constructor */ @@ -126,68 +88,7 @@ public function __construct($argv = []) $this->option = $this->getParameterList()[0]; - $this->setCommandInterface(new commandInterface()); - } - - public function setCommandInterface(commandInterface $commandInterface): self - { - $this->commandInterface = $commandInterface; - - return $this; - } - - public function getCommandInterface(): commandInterface - { - return $this->commandInterface; - } - - /** - * Save the parameter list passed on CLI - * - * @param array $items Array with parameters and arguments passed on CLI - * - */ - public function setParameterList(array $items): self - { - $parameters = []; - - foreach ($items as $param) { - if (strpos($param, '=')) { - [$key, $value] = explode('=', ltrim($param, '-')); - $parameters[$key] = $value; - - continue; - } - - $parameters[] = $param; - } - - $this->parameterList = $parameters; - - return $this; - } - - /** - * Get the parameter list passed on CLI - * - */ - public function getParameterList(): ?array - { - return $this->parameterList; - } - - /** - * Get the value of a specific parameter - * - * - */ - protected function getParameterValue(string $parameter, mixed $default = null): mixed - { - if (!isset($this->getParameterList()[$parameter])) { - return $default; - } - - return $this->getParameterList()[$parameter]; + $this->setCommandInterface(); } /** @@ -199,7 +100,7 @@ public function usage() $this->getCommandInterface()->line(__('admin.cli.tool.usage.parameters') . PHP_EOL); $this->getCommandInterface()->line('' . __('admin.cli.tool.available.commands', ['namespace' => 'jobs']) . ''); - $this->printUsage(self::AVAILABLE_OPTIONS); + $this->printCommandList(self::AVAILABLE_OPTIONS); } /** @@ -210,20 +111,6 @@ public function help(): void $this->usage(); } - /** - * Retrieve the columnWidth based on the commands text size - */ - protected function getColumnWidth(array $commands): int - { - $widths = []; - - foreach ($commands as $command) { - $widths[] = Helper::width($command); - } - - return $widths ? max($widths) + 2 : 0; - } - /** * Failed jobs list/redispatch/remove */ @@ -632,27 +519,7 @@ protected function workerOptionsHelp(): void '--test' => __('admin.cli.tool.jobs.work.option.test.description'), ]; - $this->printUsage($options, false); - } - - /** - * Print given options in a pretty way. - */ - protected function printUsage(array $options, bool $shouldTranslate = true): void - { - $width = $this->getColumnWidth(array_keys($options)); - - foreach ($options as $commandName => $description) { - $spacingWidth = $width - Helper::width($commandName); - $this->getCommandInterface()->line( - sprintf( - ' %s%s%s', - $commandName, - str_repeat(' ', $spacingWidth), - $shouldTranslate ? __($description) : $description - ) - ); - } + $this->printCommandList($options, false); } /** @@ -697,7 +564,7 @@ public function execute() $tool = new commandJobs($argv ?? []); $tool->execute(); } catch (Throwable $e) { - $output = new commandInterface(); + $output = new \PKP\cliTool\CommandInterface; if ($e instanceof CommandInvalidArgumentException) { $output->errorBlock([$e->getMessage()]);