From a4b79d33393c6faed79939fe7ee9e0d5273a572f Mon Sep 17 00:00:00 2001 From: Tony Murray Date: Fri, 15 Feb 2019 09:00:07 -0600 Subject: [PATCH] lnms user:add command (#9830) * Add lnms user:add command Uses events to mark past notifications as read (even for non-manually added users) * Filter out previous options from auto-completion * use validation to check cli input * Warn if using other auth * abstract LnmsCommand * Use setPassword helper for hashing instead of mutator * Extract validation function --- LibreNMS/Authentication/MysqlAuthorizer.php | 20 +--- app/Console/Commands/AddUserCommand.php | 101 ++++++++++++++++ .../Commands/BashCompletionCommand.php | 25 +++- app/Console/LnmsCommand.php | 113 ++++++++++++++++++ app/Events/UserCreated.php | 24 ++++ app/Listeners/MarkNotificationsRead.php | 48 ++++++++ app/Models/User.php | 14 +++ app/Providers/EventServiceProvider.php | 2 + resources/lang/en/commands.php | 20 ++++ 9 files changed, 345 insertions(+), 22 deletions(-) create mode 100644 app/Console/Commands/AddUserCommand.php create mode 100644 app/Console/LnmsCommand.php create mode 100644 app/Events/UserCreated.php create mode 100644 app/Listeners/MarkNotificationsRead.php create mode 100644 resources/lang/en/commands.php diff --git a/LibreNMS/Authentication/MysqlAuthorizer.php b/LibreNMS/Authentication/MysqlAuthorizer.php index bca4031fc7..6820b6cb1b 100644 --- a/LibreNMS/Authentication/MysqlAuthorizer.php +++ b/LibreNMS/Authentication/MysqlAuthorizer.php @@ -75,8 +75,7 @@ class MysqlAuthorizer extends AuthorizerBase $user = User::thisAuth()->where('username', $username)->first(); if ($user) { - $user->password = password_hash($password, PASSWORD_DEFAULT); - + $user->setPassword($password); return $user->save(); } @@ -97,7 +96,7 @@ class MysqlAuthorizer extends AuthorizerBase // only update new users if (!$new_user->user_id) { $new_user->auth_type = LegacyAuth::getType(); - $new_user->password = password_hash($password, PASSWORD_DEFAULT); + $new_user->setPassword($password); $new_user->email = (string)$new_user->email; $new_user->save(); @@ -108,21 +107,6 @@ class MysqlAuthorizer extends AuthorizerBase $new_user->save(); if ($user_id) { - // mark pre-existing notifications as read - Notification::whereNotExists(function ($query) use ($user_id) { - return $query->select(Eloquent::DB()->raw(1)) - ->from('notifications_attribs') - ->whereRaw('notifications.notifications_id = notifications_attribs.notifications_id') - ->where('notifications_attribs.user_id', $user_id); - })->get()->each(function ($notif) use ($user_id) { - NotificationAttrib::create([ - 'notifications_id' => $notif->notifications_id, - 'user_id' => $user_id, - 'key' => 'read', - 'value' => 1 - ]); - }); - return $user_id; } } diff --git a/app/Console/Commands/AddUserCommand.php b/app/Console/Commands/AddUserCommand.php new file mode 100644 index 0000000000..cb6c740ca0 --- /dev/null +++ b/app/Console/Commands/AddUserCommand.php @@ -0,0 +1,101 @@ +. + * + * @package LibreNMS + * @link http://librenms.org + * @copyright 2019 Tony Murray + * @author Tony Murray + */ + +namespace App\Console\Commands; + +use App\Console\LnmsCommand; +use App\Models\User; +use Illuminate\Validation\Rule; +use LibreNMS\Config; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputOption; + +class AddUserCommand extends LnmsCommand +{ + protected $name = 'user:add'; + + /** + * Create a new command instance. + * + * @return void + */ + public function __construct() + { + parent::__construct(); + + $this->setDescription(__('commands.user:add.description')); + + $this->addArgument('username', InputArgument::REQUIRED); + $this->addOption('password', 'p', InputOption::VALUE_REQUIRED); + $this->addOption('role', 'r', InputOption::VALUE_REQUIRED, __('commands.user:add.options.role', ['roles' => '[normal, global-read, admin]']), 'normal'); + $this->addOption('email', 'e', InputOption::VALUE_REQUIRED); + $this->addOption('full-name', 'l', InputOption::VALUE_REQUIRED); + $this->addOption('descr', 's', InputOption::VALUE_REQUIRED); + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + if (Config::get('auth_mechanism') != 'mysql') { + $this->warn(__('commands.user:add.wrong-auth')); + } + + $roles = [ + 'normal' => 1, + 'global-read' => 5, + 'admin' => 10 + ]; + + $this->validate([ + 'username' => ['required', Rule::unique('users', 'username')->where('auth_type', 'mysql')], + 'email' => 'email', + 'role' => Rule::in(array_keys($roles)) + ]); + + // set get password + $password = $this->option('password'); + if (!$password) { + $password = $this->secret(__('commands.user:add.password-request')); + } + + $user = new User([ + 'username' => $this->argument('username'), + 'level' => $roles[$this->option('role')], + 'descr' => (string)$this->option('descr'), + 'email' => (string)$this->option('email'), + 'realname' => (string)$this->option('full-name'), + 'auth_type' => 'mysql', + ]); + $user->setPassword($password); + $user->save(); + + $this->info(__('commands.user:add.success', ['username' => $user->username])); + return 0; + } +} diff --git a/app/Console/Commands/BashCompletionCommand.php b/app/Console/Commands/BashCompletionCommand.php index 8a1add484c..d795da1e70 100644 --- a/app/Console/Commands/BashCompletionCommand.php +++ b/app/Console/Commands/BashCompletionCommand.php @@ -54,7 +54,7 @@ class BashCompletionCommand extends Command if (!starts_with($previous, '-')) { $completions = $this->completeArguments($command, $current, end($words)); } - $completions = $completions->merge($this->completeOption($command_def, $current)); + $completions = $completions->merge($this->completeOption($command_def, $current, $this->getPreviousOptions($words))); } } } @@ -110,9 +110,10 @@ class BashCompletionCommand extends Command * * @param InputDefinition $command * @param string $partial + * @param array $prev_options Previous words in the command * @return \Illuminate\Support\Collection */ - private function completeOption($command, $partial) + private function completeOption($command, $partial, $prev_options) { // default options $options = collect([ @@ -132,8 +133,13 @@ class BashCompletionCommand extends Command if ($command) { $options = collect($command->getOptions()) - ->flatMap(function ($option) { - return $this->parseOption($option); + ->flatMap(function ($option) use ($prev_options) { + $option_flags = $this->parseOption($option); + // don't return previously specified options + if (array_intersect($option_flags, $prev_options)) { + return []; + } + return $option_flags; })->merge($options); } @@ -142,6 +148,17 @@ class BashCompletionCommand extends Command }); } + private function getPreviousOptions($words) + { + return array_reduce($words, function ($result, $word) { + if (starts_with($word, '-')) { + $split = explode('=', $word, 2); // users may use equals for values + $result[] = reset($split); + } + return $result; + }, []); + } + /** * Complete options with values (if a list is enumerate in the description) * diff --git a/app/Console/LnmsCommand.php b/app/Console/LnmsCommand.php new file mode 100644 index 0000000000..7c9140cb92 --- /dev/null +++ b/app/Console/LnmsCommand.php @@ -0,0 +1,113 @@ +. + * + * @package LibreNMS + * @link http://librenms.org + * @copyright 2019 Tony Murray + * @author Tony Murray + */ + +namespace App\Console; + +use Illuminate\Console\Command; +use Illuminate\Validation\ValidationException; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Validator; + +abstract class LnmsCommand extends Command +{ + protected $developer = false; + + public function isHidden() + { + return $this->hidden || ($this->developer && $this->getLaravel()->environment() !== 'production'); + } + + /** + * Adds an argument. If $description is null, translate commands.command-name.arguments.name + * If you want the description to be empty, just set an empty string + * + * @param string $name The argument name + * @param int|null $mode The argument mode: InputArgument::REQUIRED or InputArgument::OPTIONAL + * @param string $description A description text + * @param string|string[]|null $default The default value (for InputArgument::OPTIONAL mode only) + * + * @throws InvalidArgumentException When argument mode is not valid + * + * @return $this + */ + public function addArgument($name, $mode = null, $description = null, $default = null) + { + // use a generated translation location by default + if (is_null($description)) { + $description = __('commands.' . $this->getName() . '.arguments.' . $name); + } + + parent::addArgument($name, $mode, $description, $default); + + return $this; + } + + /** + * Adds an option. If $description is null, translate commands.command-name.arguments.name + * If you want the description to be empty, just set an empty string + * + * @param string $name The option name + * @param string|array|null $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts + * @param int|null $mode The option mode: One of the InputOption::VALUE_* constants + * @param string $description A description text + * @param string|string[]|int|bool|null $default The default value (must be null for InputOption::VALUE_NONE) + * + * @throws InvalidArgumentException If option mode is invalid or incompatible + * + * @return $this + */ + public function addOption($name, $shortcut = null, $mode = null, $description = null, $default = null) + { + // use a generated translation location by default + if (is_null($description)) { + $description = __('commands.' . $this->getName() . '.options.' . $name); + } + + parent::addOption($name, $shortcut, $mode, $description, $default); + + return $this; + } + + /** + * Validate the input of this command. Uses Laravel input validation + * merging the arguments and options together to check. + * + * @param array $rules + * @param array $messages + */ + protected function validate($rules, $messages = []) + { + $validator = Validator::make($this->arguments() + $this->options(), $rules, $messages); + + try { + $validator->validate(); + } catch (ValidationException $e) { + collect($validator->getMessageBag()->all())->each(function ($message) { + $this->error($message); + }); + exit(1); + } + } +} diff --git a/app/Events/UserCreated.php b/app/Events/UserCreated.php new file mode 100644 index 0000000000..e3a3011892 --- /dev/null +++ b/app/Events/UserCreated.php @@ -0,0 +1,24 @@ +user = $user; + } +} diff --git a/app/Listeners/MarkNotificationsRead.php b/app/Listeners/MarkNotificationsRead.php new file mode 100644 index 0000000000..28a105a0e5 --- /dev/null +++ b/app/Listeners/MarkNotificationsRead.php @@ -0,0 +1,48 @@ +user; + // mark pre-existing notifications as read + NotificationAttrib::query()->insert(Notification::whereNotExists(function ($query) use ($user) { + return $query->select(DB::raw(1)) + ->from('notifications_attribs') + ->whereRaw('notifications.notifications_id = notifications_attribs.notifications_id') + ->where('notifications_attribs.user_id', $user->user_id); + })->get()->map(function ($notif) use ($user) { + return [ + 'notifications_id' => $notif->notifications_id, + 'user_id' => $user->user_id, + 'key' => 'read', + 'value' => 1 + ]; + })->toArray()); + + \Log::info('Marked all notifications as read for user ' . $user->username); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 7db2b26756..b70c83d565 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Events\UserCreated; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -20,6 +21,9 @@ class User extends Authenticatable 'email' => '', 'can_modify_passwd' => 0, ]; + protected $dispatchesEvents = [ + 'created' => UserCreated::class, + ]; // ---- Helper Functions ---- @@ -76,6 +80,16 @@ class User extends Authenticatable return $this->hasGlobalRead() || $this->devices->contains($device); } + /** + * Helper function to hash passwords before setting + * + * @param string $password + */ + public function setPassword($password) + { + $this->attributes['password'] = $password ? password_hash($password, PASSWORD_DEFAULT) : null; + } + // ---- Query scopes ---- /** diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index d131d71965..2c7a889833 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Listeners\MarkNotificationsRead; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; class EventServiceProvider extends ServiceProvider @@ -14,6 +15,7 @@ class EventServiceProvider extends ServiceProvider protected $listen = [ \Illuminate\Auth\Events\Login::class => ['App\Listeners\AuthEventListener@login'], \Illuminate\Auth\Events\Logout::class => ['App\Listeners\AuthEventListener@logout'], + \App\Events\UserCreated::class => [MarkNotificationsRead::class] ]; /** diff --git a/resources/lang/en/commands.php b/resources/lang/en/commands.php new file mode 100644 index 0000000000..77d8491061 --- /dev/null +++ b/resources/lang/en/commands.php @@ -0,0 +1,20 @@ + [ + 'description' => 'Add a local user, you can only log in with this user if auth is set to mysql', + 'arguments' => [ + 'username' => 'The username the user will log in with', + ], + 'options' => [ + 'descr' => 'User description', + 'email' => 'Email to use for the user', + 'password' => 'Password for the user, if not given, you will be prompted', + 'full-name' => 'Full name for the user', + 'role' => 'Set the user to the desired role :roles', + ], + 'password-request' => "Please enter the user's password", + 'success' => 'Successfully added user: :username', + 'wrong-auth' => 'Warning! You will not be able to log in with this user because you are not using MySQL auth', + ], +];