aboutsummaryrefslogtreecommitdiffstats
path: root/core/Command/User
diff options
context:
space:
mode:
Diffstat (limited to 'core/Command/User')
-rw-r--r--core/Command/User/Add.php195
-rw-r--r--core/Command/User/AuthTokens/Add.php104
-rw-r--r--core/Command/User/AuthTokens/Delete.php104
-rw-r--r--core/Command/User/AuthTokens/ListCommand.php84
-rw-r--r--core/Command/User/ClearGeneratedAvatarCacheCommand.php35
-rw-r--r--core/Command/User/Delete.php63
-rw-r--r--core/Command/User/Disable.php65
-rw-r--r--core/Command/User/Enable.php65
-rw-r--r--core/Command/User/Info.php112
-rw-r--r--core/Command/User/Keys/Verify.php83
-rw-r--r--core/Command/User/LastSeen.php95
-rw-r--r--core/Command/User/ListCommand.php108
-rw-r--r--core/Command/User/Profile.php234
-rw-r--r--core/Command/User/Report.php89
-rw-r--r--core/Command/User/ResetPassword.php129
-rw-r--r--core/Command/User/Setting.php264
-rw-r--r--core/Command/User/SyncAccountDataCommand.php84
-rw-r--r--core/Command/User/Welcome.php78
18 files changed, 1991 insertions, 0 deletions
diff --git a/core/Command/User/Add.php b/core/Command/User/Add.php
new file mode 100644
index 00000000000..4de4e247991
--- /dev/null
+++ b/core/Command/User/Add.php
@@ -0,0 +1,195 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Core\Command\User;
+
+use OC\Files\Filesystem;
+use OCA\Settings\Mailer\NewUserMailHelper;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IAppConfig;
+use OCP\IGroup;
+use OCP\IGroupManager;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\Mail\IMailer;
+use OCP\Security\Events\GenerateSecurePasswordEvent;
+use OCP\Security\ISecureRandom;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\QuestionHelper;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\Question;
+
+class Add extends Command {
+ public function __construct(
+ protected IUserManager $userManager,
+ protected IGroupManager $groupManager,
+ protected IMailer $mailer,
+ private IAppConfig $appConfig,
+ private NewUserMailHelper $mailHelper,
+ private IEventDispatcher $eventDispatcher,
+ private ISecureRandom $secureRandom,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('user:add')
+ ->setDescription('adds an account')
+ ->addArgument(
+ 'uid',
+ InputArgument::REQUIRED,
+ 'Account ID used to login (must only contain a-z, A-Z, 0-9, -, _ and @)'
+ )
+ ->addOption(
+ 'password-from-env',
+ null,
+ InputOption::VALUE_NONE,
+ 'read password from environment variable NC_PASS/OC_PASS'
+ )
+ ->addOption(
+ 'generate-password',
+ null,
+ InputOption::VALUE_NONE,
+ 'Generate a secure password. A welcome email with a reset link will be sent to the user via an email if --email option and newUser.sendEmail config are set'
+ )
+ ->addOption(
+ 'display-name',
+ null,
+ InputOption::VALUE_OPTIONAL,
+ 'Login used in the web UI (can contain any characters)'
+ )
+ ->addOption(
+ 'group',
+ 'g',
+ InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY,
+ 'groups the account should be added to (The group will be created if it does not exist)'
+ )
+ ->addOption(
+ 'email',
+ null,
+ InputOption::VALUE_REQUIRED,
+ 'When set, users may register using the default email verification workflow'
+ );
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $uid = $input->getArgument('uid');
+ if ($this->userManager->userExists($uid)) {
+ $output->writeln('<error>The account "' . $uid . '" already exists.</error>');
+ return 1;
+ }
+
+ $password = '';
+
+ // Setup password.
+ if ($input->getOption('password-from-env')) {
+ $password = getenv('NC_PASS') ?: getenv('OC_PASS');
+
+ if (!$password) {
+ $output->writeln('<error>--password-from-env given, but NC_PASS/OC_PASS is empty!</error>');
+ return 1;
+ }
+ } elseif ($input->getOption('generate-password')) {
+ $passwordEvent = new GenerateSecurePasswordEvent();
+ $this->eventDispatcher->dispatchTyped($passwordEvent);
+ $password = $passwordEvent->getPassword() ?? $this->secureRandom->generate(20);
+ } elseif ($input->isInteractive()) {
+ /** @var QuestionHelper $helper */
+ $helper = $this->getHelper('question');
+
+ $question = new Question('Enter password: ');
+ $question->setHidden(true);
+ $password = $helper->ask($input, $output, $question);
+
+ $question = new Question('Confirm password: ');
+ $question->setHidden(true);
+ $confirm = $helper->ask($input, $output, $question);
+
+ if ($password !== $confirm) {
+ $output->writeln('<error>Passwords did not match!</error>');
+ return 1;
+ }
+ } else {
+ $output->writeln('<error>Interactive input or --password-from-env or --generate-password is needed for setting a password!</error>');
+ return 1;
+ }
+
+ try {
+ $user = $this->userManager->createUser(
+ $input->getArgument('uid'),
+ $password,
+ );
+ } catch (\Exception $e) {
+ $output->writeln('<error>' . $e->getMessage() . '</error>');
+ return 1;
+ }
+
+ if ($user instanceof IUser) {
+ $output->writeln('<info>The account "' . $user->getUID() . '" was created successfully</info>');
+ } else {
+ $output->writeln('<error>An error occurred while creating the account</error>');
+ return 1;
+ }
+
+ if ($input->getOption('display-name')) {
+ $user->setDisplayName($input->getOption('display-name'));
+ $output->writeln('Display name set to "' . $user->getDisplayName() . '"');
+ }
+
+ $groups = $input->getOption('group');
+
+ if (!empty($groups)) {
+ // Make sure we init the Filesystem for the user, in case we need to
+ // init some group shares.
+ Filesystem::init($user->getUID(), '');
+ }
+
+ foreach ($groups as $groupName) {
+ $group = $this->groupManager->get($groupName);
+ if (!$group) {
+ $this->groupManager->createGroup($groupName);
+ $group = $this->groupManager->get($groupName);
+ if ($group instanceof IGroup) {
+ $output->writeln('Created group "' . $group->getGID() . '"');
+ }
+ }
+ if ($group instanceof IGroup) {
+ $group->addUser($user);
+ $output->writeln('Account "' . $user->getUID() . '" added to group "' . $group->getGID() . '"');
+ }
+ }
+
+ $email = $input->getOption('email');
+ if (!empty($email)) {
+ if (!$this->mailer->validateMailAddress($email)) {
+ $output->writeln(\sprintf(
+ '<error>The given email address "%s" is invalid. Email not set for the user.</error>',
+ $email,
+ ));
+
+ return 1;
+ }
+
+ $user->setSystemEMailAddress($email);
+
+ if ($this->appConfig->getValueString('core', 'newUser.sendEmail', 'yes') === 'yes') {
+ try {
+ $this->mailHelper->sendMail($user, $this->mailHelper->generateTemplate($user, true));
+ $output->writeln('Welcome email sent to ' . $email);
+ } catch (\Exception $e) {
+ $output->writeln('Unable to send the welcome email to ' . $email);
+ }
+ }
+ }
+
+ return 0;
+ }
+}
diff --git a/core/Command/User/AuthTokens/Add.php b/core/Command/User/AuthTokens/Add.php
new file mode 100644
index 00000000000..89b20535c63
--- /dev/null
+++ b/core/Command/User/AuthTokens/Add.php
@@ -0,0 +1,104 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Core\Command\User\AuthTokens;
+
+use OC\Authentication\Events\AppPasswordCreatedEvent;
+use OC\Authentication\Token\IProvider;
+use OC\Authentication\Token\IToken;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IUserManager;
+use OCP\Security\ISecureRandom;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\QuestionHelper;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\Question;
+
+class Add extends Command {
+ public function __construct(
+ protected IUserManager $userManager,
+ protected IProvider $tokenProvider,
+ private ISecureRandom $random,
+ private IEventDispatcher $eventDispatcher,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure() {
+ $this
+ ->setName('user:auth-tokens:add')
+ ->setAliases(['user:add-app-password'])
+ ->setDescription('Add app password for the named account')
+ ->addArgument(
+ 'user',
+ InputArgument::REQUIRED,
+ 'Login to add app password for'
+ )
+ ->addOption(
+ 'password-from-env',
+ null,
+ InputOption::VALUE_NONE,
+ 'Read password from environment variable NC_PASS/OC_PASS. Alternatively it will be asked for interactively or an app password without the login password will be created.'
+ )
+ ;
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $username = $input->getArgument('user');
+ $password = null;
+
+ $user = $this->userManager->get($username);
+ if (is_null($user)) {
+ $output->writeln('<error>Account does not exist</error>');
+ return 1;
+ }
+
+ if ($input->getOption('password-from-env')) {
+ $password = getenv('NC_PASS') ?: getenv('OC_PASS');
+ if (!$password) {
+ $output->writeln('<error>--password-from-env given, but NC_PASS/OC_PASS is empty!</error>');
+ return 1;
+ }
+ } elseif ($input->isInteractive()) {
+ /** @var QuestionHelper $helper */
+ $helper = $this->getHelper('question');
+
+ $question = new Question('Enter the account password: ');
+ $question->setHidden(true);
+ /** @var null|string $password */
+ $password = $helper->ask($input, $output, $question);
+ }
+
+ if ($password === null) {
+ $output->writeln('<info>No password provided. The generated app password will therefore have limited capabilities. Any operation that requires the login password will fail.</info>');
+ }
+
+ $token = $this->random->generate(72, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS);
+ $generatedToken = $this->tokenProvider->generateToken(
+ $token,
+ $user->getUID(),
+ $user->getUID(),
+ $password,
+ 'cli',
+ IToken::PERMANENT_TOKEN,
+ IToken::DO_NOT_REMEMBER
+ );
+
+ $this->eventDispatcher->dispatchTyped(
+ new AppPasswordCreatedEvent($generatedToken)
+ );
+
+ $output->writeln('app password:');
+ $output->writeln($token);
+
+ return 0;
+ }
+}
diff --git a/core/Command/User/AuthTokens/Delete.php b/core/Command/User/AuthTokens/Delete.php
new file mode 100644
index 00000000000..2047d2eae2a
--- /dev/null
+++ b/core/Command/User/AuthTokens/Delete.php
@@ -0,0 +1,104 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Core\Command\User\AuthTokens;
+
+use DateTimeImmutable;
+use OC\Authentication\Token\IProvider;
+use OC\Core\Command\Base;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Exception\RuntimeException;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Delete extends Base {
+ public function __construct(
+ protected IProvider $tokenProvider,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('user:auth-tokens:delete')
+ ->setDescription('Deletes an authentication token')
+ ->addArgument(
+ 'uid',
+ InputArgument::REQUIRED,
+ 'ID of the user to delete tokens for'
+ )
+ ->addArgument(
+ 'id',
+ InputArgument::OPTIONAL,
+ 'ID of the auth token to delete'
+ )
+ ->addOption(
+ 'last-used-before',
+ null,
+ InputOption::VALUE_REQUIRED,
+ 'Delete tokens last used before a given date.'
+ );
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $uid = $input->getArgument('uid');
+ $id = (int)$input->getArgument('id');
+ $before = $input->getOption('last-used-before');
+
+ if ($before) {
+ if ($id) {
+ throw new RuntimeException('Option --last-used-before cannot be used with [<id>]');
+ }
+
+ return $this->deleteLastUsedBefore($uid, $before);
+ }
+
+ if (!$id) {
+ throw new RuntimeException('Not enough arguments. Specify the token <id> or use the --last-used-before option.');
+ }
+ return $this->deleteById($uid, $id);
+ }
+
+ protected function deleteById(string $uid, int $id): int {
+ $this->tokenProvider->invalidateTokenById($uid, $id);
+
+ return Command::SUCCESS;
+ }
+
+ protected function deleteLastUsedBefore(string $uid, string $before): int {
+ $date = $this->parseDateOption($before);
+ if (!$date) {
+ throw new RuntimeException('Invalid date format. Acceptable formats are: ISO8601 (w/o fractions), "YYYY-MM-DD" and Unix time in seconds.');
+ }
+
+ $this->tokenProvider->invalidateLastUsedBefore($uid, $date->getTimestamp());
+
+ return Command::SUCCESS;
+ }
+
+ /**
+ * @return \DateTimeImmutable|false
+ */
+ protected function parseDateOption(string $input) {
+ $date = false;
+
+ // Handle Unix timestamp
+ if (filter_var($input, FILTER_VALIDATE_INT)) {
+ return new DateTimeImmutable('@' . $input);
+ }
+
+ // ISO8601
+ $date = DateTimeImmutable::createFromFormat(DateTimeImmutable::ATOM, $input);
+ if ($date) {
+ return $date;
+ }
+
+ // YYYY-MM-DD
+ return DateTimeImmutable::createFromFormat('!Y-m-d', $input);
+ }
+}
diff --git a/core/Command/User/AuthTokens/ListCommand.php b/core/Command/User/AuthTokens/ListCommand.php
new file mode 100644
index 00000000000..b36aa717505
--- /dev/null
+++ b/core/Command/User/AuthTokens/ListCommand.php
@@ -0,0 +1,84 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Core\Command\User\AuthTokens;
+
+use OC\Authentication\Token\IProvider;
+use OC\Authentication\Token\IToken;
+use OC\Core\Command\Base;
+use OCP\IUserManager;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class ListCommand extends Base {
+ public function __construct(
+ protected IUserManager $userManager,
+ protected IProvider $tokenProvider,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ parent::configure();
+
+ $this
+ ->setName('user:auth-tokens:list')
+ ->setDescription('List authentication tokens of an user')
+ ->addArgument(
+ 'user',
+ InputArgument::REQUIRED,
+ 'User to list auth tokens for'
+ );
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $user = $this->userManager->get($input->getArgument('user'));
+
+ if (is_null($user)) {
+ $output->writeln('<error>user not found</error>');
+ return 1;
+ }
+
+ $tokens = $this->tokenProvider->getTokenByUser($user->getUID());
+
+ $tokens = array_map(function (IToken $token) use ($input): mixed {
+ $sensitive = [
+ 'password',
+ 'password_hash',
+ 'token',
+ 'public_key',
+ 'private_key',
+ ];
+ $data = array_diff_key($token->jsonSerialize(), array_flip($sensitive));
+
+ if ($input->getOption('output') === self::OUTPUT_FORMAT_PLAIN) {
+ $data = $this->formatTokenForPlainOutput($data);
+ }
+
+ return $data;
+ }, $tokens);
+
+ $this->writeTableInOutputFormat($input, $output, $tokens);
+
+ return 0;
+ }
+
+ public function formatTokenForPlainOutput(array $token): array {
+ $token['scope'] = implode(', ', array_keys(array_filter($token['scope'] ?? [])));
+
+ $token['lastActivity'] = date(DATE_ATOM, $token['lastActivity']);
+
+ $token['type'] = match ($token['type']) {
+ IToken::TEMPORARY_TOKEN => 'temporary',
+ IToken::PERMANENT_TOKEN => 'permanent',
+ IToken::WIPE_TOKEN => 'wipe',
+ default => $token['type'],
+ };
+
+ return $token;
+ }
+}
diff --git a/core/Command/User/ClearGeneratedAvatarCacheCommand.php b/core/Command/User/ClearGeneratedAvatarCacheCommand.php
new file mode 100644
index 00000000000..515b3a913b7
--- /dev/null
+++ b/core/Command/User/ClearGeneratedAvatarCacheCommand.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Core\Command\User;
+
+use OC\Avatar\AvatarManager;
+use OC\Core\Command\Base;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class ClearGeneratedAvatarCacheCommand extends Base {
+ public function __construct(
+ protected AvatarManager $avatarManager,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setDescription('clear avatar cache')
+ ->setName('user:clear-avatar-cache');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $output->writeln('Clearing avatar cache has started');
+ $this->avatarManager->clearCachedAvatars();
+ $output->writeln('Cleared avatar cache successfully');
+ return 0;
+ }
+}
diff --git a/core/Command/User/Delete.php b/core/Command/User/Delete.php
new file mode 100644
index 00000000000..c5d0578f5f8
--- /dev/null
+++ b/core/Command/User/Delete.php
@@ -0,0 +1,63 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Core\Command\User;
+
+use OC\Core\Command\Base;
+use OCP\IUser;
+use OCP\IUserManager;
+use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Delete extends Base {
+ public function __construct(
+ protected IUserManager $userManager,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure() {
+ $this
+ ->setName('user:delete')
+ ->setDescription('deletes the specified user')
+ ->addArgument(
+ 'uid',
+ InputArgument::REQUIRED,
+ 'the username'
+ );
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $user = $this->userManager->get($input->getArgument('uid'));
+ if (is_null($user)) {
+ $output->writeln('<error>User does not exist</error>');
+ return 0;
+ }
+
+ if ($user->delete()) {
+ $output->writeln('<info>The specified user was deleted</info>');
+ return 0;
+ }
+
+ $output->writeln('<error>The specified user could not be deleted. Please check the logs.</error>');
+ return 1;
+ }
+
+ /**
+ * @param string $argumentName
+ * @param CompletionContext $context
+ * @return string[]
+ */
+ public function completeArgumentValues($argumentName, CompletionContext $context) {
+ if ($argumentName === 'uid') {
+ return array_map(static fn (IUser $user) => $user->getUID(), $this->userManager->search($context->getCurrentWord()));
+ }
+ return [];
+ }
+}
diff --git a/core/Command/User/Disable.php b/core/Command/User/Disable.php
new file mode 100644
index 00000000000..4713950bf30
--- /dev/null
+++ b/core/Command/User/Disable.php
@@ -0,0 +1,65 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Core\Command\User;
+
+use OC\Core\Command\Base;
+use OCP\IUser;
+use OCP\IUserManager;
+use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Disable extends Base {
+ public function __construct(
+ protected IUserManager $userManager,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure() {
+ $this
+ ->setName('user:disable')
+ ->setDescription('disables the specified user')
+ ->addArgument(
+ 'uid',
+ InputArgument::REQUIRED,
+ 'the username'
+ );
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $user = $this->userManager->get($input->getArgument('uid'));
+ if (is_null($user)) {
+ $output->writeln('<error>User does not exist</error>');
+ return 1;
+ }
+
+ $user->setEnabled(false);
+ $output->writeln('<info>The specified user is disabled</info>');
+ return 0;
+ }
+
+ /**
+ * @param string $argumentName
+ * @param CompletionContext $context
+ * @return string[]
+ */
+ public function completeArgumentValues($argumentName, CompletionContext $context) {
+ if ($argumentName === 'uid') {
+ return array_map(
+ static fn (IUser $user) => $user->getUID(),
+ array_filter(
+ $this->userManager->search($context->getCurrentWord()),
+ static fn (IUser $user) => $user->isEnabled()
+ )
+ );
+ }
+ return [];
+ }
+}
diff --git a/core/Command/User/Enable.php b/core/Command/User/Enable.php
new file mode 100644
index 00000000000..23f56e5dd4f
--- /dev/null
+++ b/core/Command/User/Enable.php
@@ -0,0 +1,65 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Core\Command\User;
+
+use OC\Core\Command\Base;
+use OCP\IUser;
+use OCP\IUserManager;
+use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Enable extends Base {
+ public function __construct(
+ protected IUserManager $userManager,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure() {
+ $this
+ ->setName('user:enable')
+ ->setDescription('enables the specified user')
+ ->addArgument(
+ 'uid',
+ InputArgument::REQUIRED,
+ 'the username'
+ );
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $user = $this->userManager->get($input->getArgument('uid'));
+ if (is_null($user)) {
+ $output->writeln('<error>User does not exist</error>');
+ return 1;
+ }
+
+ $user->setEnabled(true);
+ $output->writeln('<info>The specified user is enabled</info>');
+ return 0;
+ }
+
+ /**
+ * @param string $argumentName
+ * @param CompletionContext $context
+ * @return string[]
+ */
+ public function completeArgumentValues($argumentName, CompletionContext $context) {
+ if ($argumentName === 'uid') {
+ return array_map(
+ static fn (IUser $user) => $user->getUID(),
+ array_filter(
+ $this->userManager->search($context->getCurrentWord()),
+ static fn (IUser $user) => !$user->isEnabled()
+ )
+ );
+ }
+ return [];
+ }
+}
diff --git a/core/Command/User/Info.php b/core/Command/User/Info.php
new file mode 100644
index 00000000000..e7fc9286e74
--- /dev/null
+++ b/core/Command/User/Info.php
@@ -0,0 +1,112 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Core\Command\User;
+
+use OC\Core\Command\Base;
+use OCP\Files\NotFoundException;
+use OCP\IGroupManager;
+use OCP\IUser;
+use OCP\IUserManager;
+use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Info extends Base {
+ public function __construct(
+ protected IUserManager $userManager,
+ protected IGroupManager $groupManager,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure() {
+ $this
+ ->setName('user:info')
+ ->setDescription('show user info')
+ ->addArgument(
+ 'user',
+ InputArgument::REQUIRED,
+ 'user to show'
+ )->addOption(
+ 'output',
+ null,
+ InputOption::VALUE_OPTIONAL,
+ 'Output format (plain, json or json_pretty, default is plain)',
+ $this->defaultOutputFormat
+ );
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $user = $this->userManager->get($input->getArgument('user'));
+ if (is_null($user)) {
+ $output->writeln('<error>user not found</error>');
+ return 1;
+ }
+ $groups = $this->groupManager->getUserGroupIds($user);
+ $data = [
+ 'user_id' => $user->getUID(),
+ 'display_name' => $user->getDisplayName(),
+ 'email' => (string)$user->getSystemEMailAddress(),
+ 'cloud_id' => $user->getCloudId(),
+ 'enabled' => $user->isEnabled(),
+ 'groups' => $groups,
+ 'quota' => $user->getQuota(),
+ 'storage' => $this->getStorageInfo($user),
+ 'first_seen' => $this->formatLoginDate($user->getFirstLogin()),
+ 'last_seen' => $this->formatLoginDate($user->getLastLogin()),
+ 'user_directory' => $user->getHome(),
+ 'backend' => $user->getBackendClassName()
+ ];
+ $this->writeArrayInOutputFormat($input, $output, $data);
+ return 0;
+ }
+
+ private function formatLoginDate(int $timestamp): string {
+ if ($timestamp < 0) {
+ return 'unknown';
+ } elseif ($timestamp === 0) {
+ return 'never';
+ } else {
+ return date(\DateTimeInterface::ATOM, $timestamp); // ISO-8601
+ }
+ }
+
+ /**
+ * @param IUser $user
+ * @return array
+ */
+ protected function getStorageInfo(IUser $user): array {
+ \OC_Util::tearDownFS();
+ \OC_Util::setupFS($user->getUID());
+ try {
+ $storage = \OC_Helper::getStorageInfo('/');
+ } catch (NotFoundException $e) {
+ return [];
+ }
+ return [
+ 'free' => $storage['free'],
+ 'used' => $storage['used'],
+ 'total' => $storage['total'],
+ 'relative' => $storage['relative'],
+ 'quota' => $storage['quota'],
+ ];
+ }
+
+ /**
+ * @param string $argumentName
+ * @param CompletionContext $context
+ * @return string[]
+ */
+ public function completeArgumentValues($argumentName, CompletionContext $context) {
+ if ($argumentName === 'user') {
+ return array_map(static fn (IUser $user) => $user->getUID(), $this->userManager->search($context->getCurrentWord()));
+ }
+ return [];
+ }
+}
diff --git a/core/Command/User/Keys/Verify.php b/core/Command/User/Keys/Verify.php
new file mode 100644
index 00000000000..024e9346072
--- /dev/null
+++ b/core/Command/User/Keys/Verify.php
@@ -0,0 +1,83 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\Core\Command\User\Keys;
+
+use OC\Security\IdentityProof\Manager;
+use OCP\IUser;
+use OCP\IUserManager;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Verify extends Command {
+ public function __construct(
+ protected IUserManager $userManager,
+ protected Manager $keyManager,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('user:keys:verify')
+ ->setDescription('Verify if the stored public key matches the stored private key')
+ ->addArgument(
+ 'user-id',
+ InputArgument::REQUIRED,
+ 'User ID of the user to verify'
+ )
+ ;
+ }
+
+ /**
+ * @param InputInterface $input
+ * @param OutputInterface $output
+ * @return int
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $userId = $input->getArgument('user-id');
+
+ $user = $this->userManager->get($userId);
+ if (!$user instanceof IUser) {
+ $output->writeln('Unknown user');
+ return static::FAILURE;
+ }
+
+ $key = $this->keyManager->getKey($user);
+ $publicKey = $key->getPublic();
+ $privateKey = $key->getPrivate();
+
+ $output->writeln('User public key size: ' . strlen($publicKey));
+ $output->writeln('User private key size: ' . strlen($privateKey));
+
+ // Derive the public key from the private key again to validate the stored public key
+ $opensslPrivateKey = openssl_pkey_get_private($privateKey);
+ $publicKeyDerived = openssl_pkey_get_details($opensslPrivateKey);
+ $publicKeyDerived = $publicKeyDerived['key'];
+ $output->writeln('User derived public key size: ' . strlen($publicKeyDerived));
+
+ $output->writeln('');
+
+ $output->writeln('Stored public key:');
+ $output->writeln($publicKey);
+ $output->writeln('Derived public key:');
+ $output->writeln($publicKeyDerived);
+
+ if ($publicKey != $publicKeyDerived) {
+ $output->writeln('<error>Stored public key does not match stored private key</error>');
+ return static::FAILURE;
+ }
+
+ $output->writeln('<info>Stored public key matches stored private key</info>');
+
+ return static::SUCCESS;
+ }
+}
diff --git a/core/Command/User/LastSeen.php b/core/Command/User/LastSeen.php
new file mode 100644
index 00000000000..984def72cd6
--- /dev/null
+++ b/core/Command/User/LastSeen.php
@@ -0,0 +1,95 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Core\Command\User;
+
+use OC\Core\Command\Base;
+use OCP\IUser;
+use OCP\IUserManager;
+use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class LastSeen extends Base {
+ public function __construct(
+ protected IUserManager $userManager,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('user:lastseen')
+ ->setDescription('shows when the user was logged in last time')
+ ->addArgument(
+ 'uid',
+ InputArgument::OPTIONAL,
+ 'the username'
+ )
+ ->addOption(
+ 'all',
+ null,
+ InputOption::VALUE_NONE,
+ 'shows a list of when all users were last logged in'
+ )
+ ;
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $singleUserId = $input->getArgument('uid');
+
+ if ($singleUserId) {
+ $user = $this->userManager->get($singleUserId);
+ if (is_null($user)) {
+ $output->writeln('<error>User does not exist</error>');
+ return 1;
+ }
+
+ $lastLogin = $user->getLastLogin();
+ if ($lastLogin === 0) {
+ $output->writeln($user->getUID() . ' has never logged in.');
+ } else {
+ $date = new \DateTime();
+ $date->setTimestamp($lastLogin);
+ $output->writeln($user->getUID() . "'s last login: " . $date->format('Y-m-d H:i:s T'));
+ }
+
+ return 0;
+ }
+
+ if (!$input->getOption('all')) {
+ $output->writeln('<error>Please specify a username, or "--all" to list all</error>');
+ return 1;
+ }
+
+ $this->userManager->callForAllUsers(static function (IUser $user) use ($output): void {
+ $lastLogin = $user->getLastLogin();
+ if ($lastLogin === 0) {
+ $output->writeln($user->getUID() . ' has never logged in.');
+ } else {
+ $date = new \DateTime();
+ $date->setTimestamp($lastLogin);
+ $output->writeln($user->getUID() . "'s last login: " . $date->format('Y-m-d H:i:s T'));
+ }
+ });
+ return 0;
+ }
+
+ /**
+ * @param string $argumentName
+ * @param CompletionContext $context
+ * @return string[]
+ */
+ public function completeArgumentValues($argumentName, CompletionContext $context) {
+ if ($argumentName === 'uid') {
+ return array_map(static fn (IUser $user) => $user->getUID(), $this->userManager->search($context->getCurrentWord()));
+ }
+ return [];
+ }
+}
diff --git a/core/Command/User/ListCommand.php b/core/Command/User/ListCommand.php
new file mode 100644
index 00000000000..66b831c793b
--- /dev/null
+++ b/core/Command/User/ListCommand.php
@@ -0,0 +1,108 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Core\Command\User;
+
+use OC\Core\Command\Base;
+use OCP\IGroupManager;
+use OCP\IUser;
+use OCP\IUserManager;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class ListCommand extends Base {
+ public function __construct(
+ protected IUserManager $userManager,
+ protected IGroupManager $groupManager,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure() {
+ $this
+ ->setName('user:list')
+ ->setDescription('list configured users')
+ ->addOption(
+ 'disabled',
+ 'd',
+ InputOption::VALUE_NONE,
+ 'List disabled users only'
+ )->addOption(
+ 'limit',
+ 'l',
+ InputOption::VALUE_OPTIONAL,
+ 'Number of users to retrieve',
+ '500'
+ )->addOption(
+ 'offset',
+ 'o',
+ InputOption::VALUE_OPTIONAL,
+ 'Offset for retrieving users',
+ '0'
+ )->addOption(
+ 'output',
+ null,
+ InputOption::VALUE_OPTIONAL,
+ 'Output format (plain, json or json_pretty, default is plain)',
+ $this->defaultOutputFormat
+ )->addOption(
+ 'info',
+ 'i',
+ InputOption::VALUE_NONE,
+ 'Show detailed info'
+ );
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ if ($input->getOption('disabled')) {
+ $users = $this->userManager->getDisabledUsers((int)$input->getOption('limit'), (int)$input->getOption('offset'));
+ } else {
+ $users = $this->userManager->searchDisplayName('', (int)$input->getOption('limit'), (int)$input->getOption('offset'));
+ }
+
+ $this->writeArrayInOutputFormat($input, $output, $this->formatUsers($users, (bool)$input->getOption('info')));
+ return 0;
+ }
+
+ /**
+ * @param IUser[] $users
+ * @return \Generator<string,string|array>
+ */
+ private function formatUsers(array $users, bool $detailed = false): \Generator {
+ foreach ($users as $user) {
+ if ($detailed) {
+ $groups = $this->groupManager->getUserGroupIds($user);
+ $value = [
+ 'user_id' => $user->getUID(),
+ 'display_name' => $user->getDisplayName(),
+ 'email' => (string)$user->getSystemEMailAddress(),
+ 'cloud_id' => $user->getCloudId(),
+ 'enabled' => $user->isEnabled(),
+ 'groups' => $groups,
+ 'quota' => $user->getQuota(),
+ 'first_seen' => $this->formatLoginDate($user->getFirstLogin()),
+ 'last_seen' => $this->formatLoginDate($user->getLastLogin()),
+ 'user_directory' => $user->getHome(),
+ 'backend' => $user->getBackendClassName()
+ ];
+ } else {
+ $value = $user->getDisplayName();
+ }
+ yield $user->getUID() => $value;
+ }
+ }
+
+ private function formatLoginDate(int $timestamp): string {
+ if ($timestamp < 0) {
+ return 'unknown';
+ } elseif ($timestamp === 0) {
+ return 'never';
+ } else {
+ return date(\DateTimeInterface::ATOM, $timestamp); // ISO-8601
+ }
+ }
+}
diff --git a/core/Command/User/Profile.php b/core/Command/User/Profile.php
new file mode 100644
index 00000000000..fd5fbed08cd
--- /dev/null
+++ b/core/Command/User/Profile.php
@@ -0,0 +1,234 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Core\Command\User;
+
+use OC\Core\Command\Base;
+use OCP\Accounts\IAccount;
+use OCP\Accounts\IAccountManager;
+use OCP\Accounts\PropertyDoesNotExistException;
+use OCP\IUser;
+use OCP\IUserManager;
+use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Profile extends Base {
+ public function __construct(
+ protected IUserManager $userManager,
+ protected IAccountManager $accountManager,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure() {
+ parent::configure();
+ $this
+ ->setName('user:profile')
+ ->setDescription('Read and modify user profile properties')
+ ->addArgument(
+ 'uid',
+ InputArgument::REQUIRED,
+ 'Account ID used to login'
+ )
+ ->addArgument(
+ 'key',
+ InputArgument::OPTIONAL,
+ 'Profile property to set, get or delete',
+ ''
+ )
+
+ // Get
+ ->addOption(
+ 'default-value',
+ null,
+ InputOption::VALUE_REQUIRED,
+ '(Only applicable on get) If no default value is set and the property does not exist, the command will exit with 1'
+ )
+
+ // Set
+ ->addArgument(
+ 'value',
+ InputArgument::OPTIONAL,
+ 'The new value of the property',
+ null
+ )
+ ->addOption(
+ 'update-only',
+ null,
+ InputOption::VALUE_NONE,
+ 'Only updates the value, if it is not set before, it is not being added'
+ )
+
+ // Delete
+ ->addOption(
+ 'delete',
+ null,
+ InputOption::VALUE_NONE,
+ 'Specify this option to delete the property value'
+ )
+ ->addOption(
+ 'error-if-not-exists',
+ null,
+ InputOption::VALUE_NONE,
+ 'Checks whether the property exists before deleting it'
+ )
+ ;
+ }
+
+ protected function checkInput(InputInterface $input): IUser {
+ $uid = $input->getArgument('uid');
+ $user = $this->userManager->get($uid);
+ if (!$user) {
+ throw new \InvalidArgumentException('The user "' . $uid . '" does not exist.');
+ }
+ // normalize uid
+ $input->setArgument('uid', $user->getUID());
+
+ $key = $input->getArgument('key');
+ if ($key === '') {
+ if ($input->hasParameterOption('--default-value')) {
+ throw new \InvalidArgumentException('The "default-value" option can only be used when specifying a key.');
+ }
+ if ($input->getArgument('value') !== null) {
+ throw new \InvalidArgumentException('The value argument can only be used when specifying a key.');
+ }
+ if ($input->getOption('delete')) {
+ throw new \InvalidArgumentException('The "delete" option can only be used when specifying a key.');
+ }
+ }
+
+ if ($input->getArgument('value') !== null && $input->hasParameterOption('--default-value')) {
+ throw new \InvalidArgumentException('The value argument can not be used together with "default-value".');
+ }
+ if ($input->getOption('update-only') && $input->getArgument('value') === null) {
+ throw new \InvalidArgumentException('The "update-only" option can only be used together with "value".');
+ }
+
+ if ($input->getOption('delete') && $input->hasParameterOption('--default-value')) {
+ throw new \InvalidArgumentException('The "delete" option can not be used together with "default-value".');
+ }
+ if ($input->getOption('delete') && $input->getArgument('value') !== null) {
+ throw new \InvalidArgumentException('The "delete" option can not be used together with "value".');
+ }
+ if ($input->getOption('error-if-not-exists') && !$input->getOption('delete')) {
+ throw new \InvalidArgumentException('The "error-if-not-exists" option can only be used together with "delete".');
+ }
+
+ return $user;
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ try {
+ $user = $this->checkInput($input);
+ } catch (\InvalidArgumentException $e) {
+ $output->writeln('<error>' . $e->getMessage() . '</error>');
+ return self::FAILURE;
+ }
+
+ $uid = $input->getArgument('uid');
+ $key = $input->getArgument('key');
+ $userAccount = $this->accountManager->getAccount($user);
+
+ if ($key === '') {
+ $settings = $this->getAllProfileProperties($userAccount);
+ $this->writeArrayInOutputFormat($input, $output, $settings);
+ return self::SUCCESS;
+ }
+
+ $value = $this->getStoredValue($userAccount, $key);
+ $inputValue = $input->getArgument('value');
+ if ($inputValue !== null) {
+ if ($input->hasParameterOption('--update-only') && $value === null) {
+ $output->writeln('<error>The property does not exist for user "' . $uid . '".</error>');
+ return self::FAILURE;
+ }
+
+ return $this->editProfileProperty($output, $userAccount, $key, $inputValue);
+ } elseif ($input->hasParameterOption('--delete')) {
+ if ($input->hasParameterOption('--error-if-not-exists') && $value === null) {
+ $output->writeln('<error>The property does not exist for user "' . $uid . '".</error>');
+ return self::FAILURE;
+ }
+
+ return $this->deleteProfileProperty($output, $userAccount, $key);
+ } elseif ($value !== null) {
+ $output->writeln($value);
+ } elseif ($input->hasParameterOption('--default-value')) {
+ $output->writeln($input->getOption('default-value'));
+ } else {
+ $output->writeln('<error>The property does not exist for user "' . $uid . '".</error>');
+ return self::FAILURE;
+ }
+
+ return self::SUCCESS;
+ }
+
+ private function deleteProfileProperty(OutputInterface $output, IAccount $userAccount, string $key): int {
+ return $this->editProfileProperty($output, $userAccount, $key, '');
+ }
+
+ private function editProfileProperty(OutputInterface $output, IAccount $userAccount, string $key, string $value): int {
+ try {
+ $userAccount->getProperty($key)->setValue($value);
+ } catch (PropertyDoesNotExistException $exception) {
+ $output->writeln('<error>' . $exception->getMessage() . '</error>');
+ return self::FAILURE;
+ }
+
+ $this->accountManager->updateAccount($userAccount);
+ return self::SUCCESS;
+ }
+
+ private function getStoredValue(IAccount $userAccount, string $key): ?string {
+ try {
+ $property = $userAccount->getProperty($key);
+ } catch (PropertyDoesNotExistException) {
+ return null;
+ }
+ return $property->getValue() === '' ? null : $property->getValue();
+ }
+
+ private function getAllProfileProperties(IAccount $userAccount): array {
+ $properties = [];
+
+ foreach ($userAccount->getAllProperties() as $property) {
+ if ($property->getValue() !== '') {
+ $properties[$property->getName()] = $property->getValue();
+ }
+ }
+
+ return $properties;
+ }
+
+ /**
+ * @param string $argumentName
+ * @param CompletionContext $context
+ * @return string[]
+ */
+ public function completeArgumentValues($argumentName, CompletionContext $context): array {
+ if ($argumentName === 'uid') {
+ return array_map(static fn (IUser $user) => $user->getUID(), $this->userManager->search($context->getCurrentWord()));
+ }
+ if ($argumentName === 'key') {
+ $userId = $context->getWordAtIndex($context->getWordIndex() - 1);
+ $user = $this->userManager->get($userId);
+ if (!($user instanceof IUser)) {
+ return [];
+ }
+
+ $account = $this->accountManager->getAccount($user);
+
+ $properties = $this->getAllProfileProperties($account);
+ return array_keys($properties);
+ }
+ return [];
+ }
+}
diff --git a/core/Command/User/Report.php b/core/Command/User/Report.php
new file mode 100644
index 00000000000..c0f054adb00
--- /dev/null
+++ b/core/Command/User/Report.php
@@ -0,0 +1,89 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Core\Command\User;
+
+use OC\Files\View;
+use OCP\IConfig;
+use OCP\IUserManager;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\Table;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Report extends Command {
+ public const DEFAULT_COUNT_DIRS_MAX_USERS = 500;
+
+ public function __construct(
+ protected IUserManager $userManager,
+ private IConfig $config,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('user:report')
+ ->setDescription('shows how many users have access')
+ ->addOption(
+ 'count-dirs',
+ null,
+ InputOption::VALUE_NONE,
+ 'Also count the number of user directories in the database (could time out on huge installations, therefore defaults to no with ' . self::DEFAULT_COUNT_DIRS_MAX_USERS . '+ users)'
+ )
+ ;
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $table = new Table($output);
+ $table->setHeaders(['Account Report', '']);
+ $userCountArray = $this->countUsers();
+ $total = 0;
+ if (!empty($userCountArray)) {
+ $rows = [];
+ foreach ($userCountArray as $classname => $users) {
+ $total += $users;
+ $rows[] = [$classname, $users];
+ }
+
+ $rows[] = [' '];
+ $rows[] = ['total users', $total];
+ } else {
+ $rows[] = ['No backend enabled that supports user counting', ''];
+ }
+ $rows[] = [' '];
+
+ if ($total <= self::DEFAULT_COUNT_DIRS_MAX_USERS || $input->getOption('count-dirs')) {
+ $userDirectoryCount = $this->countUserDirectories();
+ $rows[] = ['user directories', $userDirectoryCount];
+ }
+
+ $activeUsers = $this->userManager->countSeenUsers();
+ $rows[] = ['active users', $activeUsers];
+
+ $disabledUsers = $this->config->getUsersForUserValue('core', 'enabled', 'false');
+ $disabledUsersCount = count($disabledUsers);
+ $rows[] = ['disabled users', $disabledUsersCount];
+
+ $table->setRows($rows);
+ $table->render();
+ return 0;
+ }
+
+ private function countUsers(): array {
+ return $this->userManager->countUsers();
+ }
+
+ private function countUserDirectories(): int {
+ $dataview = new View('/');
+ $userDirectories = $dataview->getDirectoryContent('/', 'httpd/unix-directory');
+ return count($userDirectories);
+ }
+}
diff --git a/core/Command/User/ResetPassword.php b/core/Command/User/ResetPassword.php
new file mode 100644
index 00000000000..0e8b1325770
--- /dev/null
+++ b/core/Command/User/ResetPassword.php
@@ -0,0 +1,129 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Core\Command\User;
+
+use OC\Core\Command\Base;
+use OCP\App\IAppManager;
+use OCP\IUser;
+use OCP\IUserManager;
+use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
+use Symfony\Component\Console\Helper\QuestionHelper;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Question\ConfirmationQuestion;
+use Symfony\Component\Console\Question\Question;
+
+class ResetPassword extends Base {
+ public function __construct(
+ protected IUserManager $userManager,
+ private IAppManager $appManager,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure() {
+ $this
+ ->setName('user:resetpassword')
+ ->setDescription('Resets the password of the named user')
+ ->addArgument(
+ 'user',
+ InputArgument::REQUIRED,
+ 'Login to reset password'
+ )
+ ->addOption(
+ 'password-from-env',
+ null,
+ InputOption::VALUE_NONE,
+ 'read password from environment variable NC_PASS/OC_PASS'
+ )
+ ;
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $username = $input->getArgument('user');
+
+ $user = $this->userManager->get($username);
+ if (is_null($user)) {
+ $output->writeln('<error>User does not exist</error>');
+ return 1;
+ }
+
+ if ($input->getOption('password-from-env')) {
+ $password = getenv('NC_PASS') ?: getenv('OC_PASS');
+ if (!$password) {
+ $output->writeln('<error>--password-from-env given, but NC_PASS/OC_PASS is empty!</error>');
+ return 1;
+ }
+ } elseif ($input->isInteractive()) {
+ /** @var QuestionHelper $helper */
+ $helper = $this->getHelper('question');
+
+ if ($this->appManager->isEnabledForUser('encryption', $user)) {
+ $output->writeln(
+ '<error>Warning: Resetting the password when using encryption will result in data loss!</error>'
+ );
+
+ $question = new ConfirmationQuestion('Do you want to continue?');
+ if (!$helper->ask($input, $output, $question)) {
+ return 1;
+ }
+ }
+
+ $question = new Question('Enter a new password: ');
+ $question->setHidden(true);
+ $password = $helper->ask($input, $output, $question);
+
+ if ($password === null) {
+ $output->writeln('<error>Password cannot be empty!</error>');
+ return 1;
+ }
+
+ $question = new Question('Confirm the new password: ');
+ $question->setHidden(true);
+ $confirm = $helper->ask($input, $output, $question);
+
+ if ($password !== $confirm) {
+ $output->writeln('<error>Passwords did not match!</error>');
+ return 1;
+ }
+ } else {
+ $output->writeln('<error>Interactive input or --password-from-env is needed for entering a new password!</error>');
+ return 1;
+ }
+
+
+ try {
+ $success = $user->setPassword($password);
+ } catch (\Exception $e) {
+ $output->writeln('<error>' . $e->getMessage() . '</error>');
+ return 1;
+ }
+
+ if ($success) {
+ $output->writeln('<info>Successfully reset password for ' . $username . '</info>');
+ } else {
+ $output->writeln('<error>Error while resetting password!</error>');
+ return 1;
+ }
+ return 0;
+ }
+
+ /**
+ * @param string $argumentName
+ * @param CompletionContext $context
+ * @return string[]
+ */
+ public function completeArgumentValues($argumentName, CompletionContext $context) {
+ if ($argumentName === 'user') {
+ return array_map(static fn (IUser $user) => $user->getUID(), $this->userManager->search($context->getCurrentWord()));
+ }
+ return [];
+ }
+}
diff --git a/core/Command/User/Setting.php b/core/Command/User/Setting.php
new file mode 100644
index 00000000000..7fc5aab1dc7
--- /dev/null
+++ b/core/Command/User/Setting.php
@@ -0,0 +1,264 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Core\Command\User;
+
+use OC\Core\Command\Base;
+use OCP\IConfig;
+use OCP\IUser;
+use OCP\IUserManager;
+use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Setting extends Base {
+ public function __construct(
+ protected IUserManager $userManager,
+ protected IConfig $config,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure() {
+ parent::configure();
+ $this
+ ->setName('user:setting')
+ ->setDescription('Read and modify user settings')
+ ->addArgument(
+ 'uid',
+ InputArgument::REQUIRED,
+ 'Account ID used to login'
+ )
+ ->addArgument(
+ 'app',
+ InputArgument::OPTIONAL,
+ 'Restrict the settings to a given app',
+ ''
+ )
+ ->addArgument(
+ 'key',
+ InputArgument::OPTIONAL,
+ 'Setting key to set, get or delete',
+ ''
+ )
+ ->addOption(
+ 'ignore-missing-user',
+ null,
+ InputOption::VALUE_NONE,
+ 'Use this option to ignore errors when the user does not exist'
+ )
+
+ // Get
+ ->addOption(
+ 'default-value',
+ null,
+ InputOption::VALUE_REQUIRED,
+ '(Only applicable on get) If no default value is set and the config does not exist, the command will exit with 1'
+ )
+
+ // Set
+ ->addArgument(
+ 'value',
+ InputArgument::OPTIONAL,
+ 'The new value of the setting',
+ null
+ )
+ ->addOption(
+ 'update-only',
+ null,
+ InputOption::VALUE_NONE,
+ 'Only updates the value, if it is not set before, it is not being added'
+ )
+
+ // Delete
+ ->addOption(
+ 'delete',
+ null,
+ InputOption::VALUE_NONE,
+ 'Specify this option to delete the config'
+ )
+ ->addOption(
+ 'error-if-not-exists',
+ null,
+ InputOption::VALUE_NONE,
+ 'Checks whether the setting exists before deleting it'
+ )
+ ;
+ }
+
+ protected function checkInput(InputInterface $input) {
+ if (!$input->getOption('ignore-missing-user')) {
+ $uid = $input->getArgument('uid');
+ $user = $this->userManager->get($uid);
+ if (!$user) {
+ throw new \InvalidArgumentException('The user "' . $uid . '" does not exist.');
+ }
+ // normalize uid
+ $input->setArgument('uid', $user->getUID());
+ }
+
+ if ($input->getArgument('key') === '' && $input->hasParameterOption('--default-value')) {
+ throw new \InvalidArgumentException('The "default-value" option can only be used when specifying a key.');
+ }
+
+ if ($input->getArgument('key') === '' && $input->getArgument('value') !== null) {
+ throw new \InvalidArgumentException('The value argument can only be used when specifying a key.');
+ }
+ if ($input->getArgument('value') !== null && $input->hasParameterOption('--default-value')) {
+ throw new \InvalidArgumentException('The value argument can not be used together with "default-value".');
+ }
+ if ($input->getOption('update-only') && $input->getArgument('value') === null) {
+ throw new \InvalidArgumentException('The "update-only" option can only be used together with "value".');
+ }
+
+ if ($input->getArgument('key') === '' && $input->getOption('delete')) {
+ throw new \InvalidArgumentException('The "delete" option can only be used when specifying a key.');
+ }
+ if ($input->getOption('delete') && $input->hasParameterOption('--default-value')) {
+ throw new \InvalidArgumentException('The "delete" option can not be used together with "default-value".');
+ }
+ if ($input->getOption('delete') && $input->getArgument('value') !== null) {
+ throw new \InvalidArgumentException('The "delete" option can not be used together with "value".');
+ }
+ if ($input->getOption('error-if-not-exists') && !$input->getOption('delete')) {
+ throw new \InvalidArgumentException('The "error-if-not-exists" option can only be used together with "delete".');
+ }
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ try {
+ $this->checkInput($input);
+ } catch (\InvalidArgumentException $e) {
+ $output->writeln('<error>' . $e->getMessage() . '</error>');
+ return 1;
+ }
+
+ $uid = $input->getArgument('uid');
+ $app = $input->getArgument('app');
+ $key = $input->getArgument('key');
+
+ if ($key !== '') {
+ $value = $this->config->getUserValue($uid, $app, $key, null);
+ if ($input->getArgument('value') !== null) {
+ if ($input->hasParameterOption('--update-only') && $value === null) {
+ $output->writeln('<error>The setting does not exist for user "' . $uid . '".</error>');
+ return 1;
+ }
+
+ if ($app === 'settings' && in_array($key, ['email', 'display_name'])) {
+ $user = $this->userManager->get($uid);
+ if ($user instanceof IUser) {
+ if ($key === 'email') {
+ $email = $input->getArgument('value');
+ $user->setSystemEMailAddress(mb_strtolower(trim($email)));
+ } elseif ($key === 'display_name') {
+ if (!$user->setDisplayName($input->getArgument('value'))) {
+ if ($user->getDisplayName() === $input->getArgument('value')) {
+ $output->writeln('<error>New and old display name are the same</error>');
+ } elseif ($input->getArgument('value') === '') {
+ $output->writeln('<error>New display name can\'t be empty</error>');
+ } else {
+ $output->writeln('<error>Could not set display name</error>');
+ }
+ return 1;
+ }
+ }
+ // setEmailAddress and setDisplayName both internally set the value
+ return 0;
+ }
+ }
+
+ $this->config->setUserValue($uid, $app, $key, $input->getArgument('value'));
+ return 0;
+ } elseif ($input->hasParameterOption('--delete')) {
+ if ($input->hasParameterOption('--error-if-not-exists') && $value === null) {
+ $output->writeln('<error>The setting does not exist for user "' . $uid . '".</error>');
+ return 1;
+ }
+
+ if ($app === 'settings' && in_array($key, ['email', 'display_name'])) {
+ $user = $this->userManager->get($uid);
+ if ($user instanceof IUser) {
+ if ($key === 'email') {
+ $user->setEMailAddress('');
+ // setEmailAddress already deletes the value
+ return 0;
+ } elseif ($key === 'display_name') {
+ $output->writeln('<error>Display name can\'t be deleted.</error>');
+ return 1;
+ }
+ }
+ }
+
+ $this->config->deleteUserValue($uid, $app, $key);
+ return 0;
+ } elseif ($value !== null) {
+ $output->writeln($value);
+ return 0;
+ } elseif ($input->hasParameterOption('--default-value')) {
+ $output->writeln($input->getOption('default-value'));
+ return 0;
+ } else {
+ if ($app === 'settings' && $key === 'display_name') {
+ $user = $this->userManager->get($uid);
+ $output->writeln($user->getDisplayName());
+ return 0;
+ }
+ $output->writeln('<error>The setting does not exist for user "' . $uid . '".</error>');
+ return 1;
+ }
+ } else {
+ $settings = $this->getUserSettings($uid, $app);
+ $this->writeArrayInOutputFormat($input, $output, $settings);
+ return 0;
+ }
+ }
+
+ protected function getUserSettings(string $uid, string $app): array {
+ $settings = $this->config->getAllUserValues($uid);
+ if ($app !== '') {
+ if (isset($settings[$app])) {
+ $settings = [$app => $settings[$app]];
+ } else {
+ $settings = [];
+ }
+ }
+
+ $user = $this->userManager->get($uid);
+ if ($user !== null) {
+ // Only add the display name if the user exists
+ $settings['settings']['display_name'] = $user->getDisplayName();
+ }
+
+ return $settings;
+ }
+
+ /**
+ * @param string $argumentName
+ * @param CompletionContext $context
+ * @return string[]
+ */
+ public function completeArgumentValues($argumentName, CompletionContext $context) {
+ if ($argumentName === 'uid') {
+ return array_map(static fn (IUser $user) => $user->getUID(), $this->userManager->search($context->getCurrentWord()));
+ }
+ if ($argumentName === 'app') {
+ $userId = $context->getWordAtIndex($context->getWordIndex() - 1);
+ $settings = $this->getUserSettings($userId, '');
+ return array_keys($settings);
+ }
+ if ($argumentName === 'key') {
+ $userId = $context->getWordAtIndex($context->getWordIndex() - 2);
+ $app = $context->getWordAtIndex($context->getWordIndex() - 1);
+ $settings = $this->getUserSettings($userId, $app);
+ return array_keys($settings[$app]);
+ }
+ return [];
+ }
+}
diff --git a/core/Command/User/SyncAccountDataCommand.php b/core/Command/User/SyncAccountDataCommand.php
new file mode 100644
index 00000000000..c353df6fe9f
--- /dev/null
+++ b/core/Command/User/SyncAccountDataCommand.php
@@ -0,0 +1,84 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Core\Command\User;
+
+use OC\Core\Command\Base;
+use OCP\Accounts\IAccountManager;
+use OCP\Accounts\PropertyDoesNotExistException;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\User\Backend\IGetDisplayNameBackend;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class SyncAccountDataCommand extends Base {
+ public function __construct(
+ protected IUserManager $userManager,
+ protected IAccountManager $accountManager,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure() {
+ $this
+ ->setName('user:sync-account-data')
+ ->setDescription('sync user backend data to accounts table for configured users')
+ ->addOption(
+ 'limit',
+ 'l',
+ InputOption::VALUE_OPTIONAL,
+ 'Number of users to retrieve',
+ '500'
+ )->addOption(
+ 'offset',
+ 'o',
+ InputOption::VALUE_OPTIONAL,
+ 'Offset for retrieving users',
+ '0'
+ );
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $users = $this->userManager->searchDisplayName('', (int)$input->getOption('limit'), (int)$input->getOption('offset'));
+
+ foreach ($users as $user) {
+ $this->updateUserAccount($user, $output);
+ }
+ return 0;
+ }
+
+ private function updateUserAccount(IUser $user, OutputInterface $output): void {
+ $changed = false;
+ $account = $this->accountManager->getAccount($user);
+ if ($user->getBackend() instanceof IGetDisplayNameBackend) {
+ try {
+ $displayNameProperty = $account->getProperty(IAccountManager::PROPERTY_DISPLAYNAME);
+ } catch (PropertyDoesNotExistException) {
+ $displayNameProperty = null;
+ }
+ if (!$displayNameProperty || $displayNameProperty->getValue() !== $user->getDisplayName()) {
+ $output->writeln($user->getUID() . ' - updating changed display name');
+ $account->setProperty(
+ IAccountManager::PROPERTY_DISPLAYNAME,
+ $user->getDisplayName(),
+ $displayNameProperty ? $displayNameProperty->getScope() : IAccountManager::SCOPE_PRIVATE,
+ $displayNameProperty ? $displayNameProperty->getVerified() : IAccountManager::NOT_VERIFIED,
+ $displayNameProperty ? $displayNameProperty->getVerificationData() : ''
+ );
+ $changed = true;
+ }
+ }
+
+ if ($changed) {
+ $this->accountManager->updateAccount($account);
+ $output->writeln($user->getUID() . ' - account data updated');
+ } else {
+ $output->writeln($user->getUID() . ' - nothing to update');
+ }
+ }
+}
diff --git a/core/Command/User/Welcome.php b/core/Command/User/Welcome.php
new file mode 100644
index 00000000000..65637759689
--- /dev/null
+++ b/core/Command/User/Welcome.php
@@ -0,0 +1,78 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2023 FedericoHeichou <federicoheichou@gmail.com>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\Core\Command\User;
+
+use OC\Core\Command\Base;
+use OCA\Settings\Mailer\NewUserMailHelper;
+use OCP\IUserManager;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Welcome extends Base {
+ /**
+ * @param IUserManager $userManager
+ * @param NewUserMailHelper $newUserMailHelper
+ */
+ public function __construct(
+ protected IUserManager $userManager,
+ private NewUserMailHelper $newUserMailHelper,
+ ) {
+ parent::__construct();
+ }
+
+ /**
+ * @return void
+ */
+ protected function configure() {
+ $this
+ ->setName('user:welcome')
+ ->setDescription('Sends the welcome email')
+ ->addArgument(
+ 'user',
+ InputArgument::REQUIRED,
+ 'The user to send the email to'
+ )
+ ->addOption(
+ 'reset-password',
+ 'r',
+ InputOption::VALUE_NONE,
+ 'Add the reset password link to the email'
+ )
+ ;
+ }
+
+ /**
+ * @param InputInterface $input
+ * @param OutputInterface $output
+ * @return int
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $userId = $input->getArgument('user');
+ // check if user exists
+ $user = $this->userManager->get($userId);
+ if ($user === null) {
+ $output->writeln('<error>User does not exist</error>');
+ return 1;
+ }
+ $email = $user->getEMailAddress();
+ if ($email === '' || $email === null) {
+ $output->writeln('<error>User does not have an email address</error>');
+ return 1;
+ }
+ try {
+ $emailTemplate = $this->newUserMailHelper->generateTemplate($user, $input->getOption('reset-password'));
+ $this->newUserMailHelper->sendMail($user, $emailTemplate);
+ } catch (\Exception $e) {
+ $output->writeln('<error>Failed to send email: ' . $e->getMessage() . '</error>');
+ return 1;
+ }
+ return 0;
+ }
+}