Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Guests may be deleted in the same way you would remove (or disable) regular user
The command `occ guests:add` can be used to create guest users on the command-line.

```
php occ guests:add [--generate-password] [--password-from-env] [--display-name [DISPLAY-NAME]] [--language [LANGUAGE]] [--] <created-by> <email>
php occ guests:add [--uid [UID]] [--generate-password] [--password-from-env] [--display-name [DISPLAY-NAME]] [--language [LANGUAGE]] [--] <created-by> <email>
```

For example:
Expand All @@ -70,6 +70,14 @@ OC_PASS=somepassword php occ guests:add --password-from-env --display-name "Max

The user will then be able to login with "maxmustermann@example.com" using the given password.

By default the guest's login name (user ID) is derived from the email address, or a hash of it when the *"Use a hash of the email as user ID for improved privacy"* setting is enabled. To give the guest a free-form login name instead, pass `--uid`:

```bash
OC_PASS=somepassword php occ guests:add --password-from-env --uid maxm admin maxmustermann@example.com
```

The guest can then log in with "maxm".

When using `--generate-password` instead of giving a password, a random password will be generated. The guest user should then use the "forgot password" link to reset it.

> [!NOTE]
Expand Down Expand Up @@ -168,6 +176,20 @@ Remove the override to fall back to the Quick presets default again:
occ config:app:delete guests guest_quota
```

### Converting accounts to guests

An administrator can convert a regular account into a guest account. In *Administration settings → Users*, open the account's `…` menu and choose **Convert to guest account**. The account keeps its login name and password but becomes a limited guest, restricted to the apps allowed for guests, and receives the [default guest quota](#default-quota-for-new-guests). The conversion cannot be undone automatically.

This is only possible for accounts that

* use the database backend,
* are not already a guest, and
* have **never logged in**.

A typical use case is self-registration through the [registration](https://apps.nextcloud.com/apps/registration) app with *"Require administrator approval"* enabled: those accounts are created disabled and never logged in, so instead of enabling them an administrator can downgrade them to guests. Once such an account has logged in, conversion is no longer possible.

If you would rather registered users keep their email address as login name, enable *"Force email as login name"* in the registration app settings.

### Converting guest users to full users

Guest users can be automatically converted into full users (provided by any other user back end like SAML, LDAP, OAuth, database...) on their **first** login. When this happens they will retain their shares.
Expand All @@ -185,4 +207,4 @@ By default the old (guest) account will be disabled after successful conversion.
- Enhancement ideas: https://github.com/nextcloud/guests/issues
- Pull requests: https://github.com/nextcloud/guests/pulls
- Troubleshooting assistance: https://help.nextcloud.com
- Code: https://github.com/nextcloud/guests/tree/master
- Code: https://github.com/nextcloud/guests/tree/main
5 changes: 5 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@
'url' => '/api/v1/transfer',
'verb' => 'POST',
],
[
'name' => 'users#convert',
'url' => '/api/v1/convert',
'verb' => 'POST',
],
[
'name' => 'API#languages',
'url' => '/api/v1/languages',
Expand Down
20 changes: 15 additions & 5 deletions lib/Command/AddCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ protected function configure(): void {
InputArgument::REQUIRED,
'Email address'
)
->addOption(
'uid',
null,
InputOption::VALUE_REQUIRED,
'Login name (user ID) for the guest. If omitted, the email address is used (hashed when the privacy setting is enabled).'
)
->addOption(
'generate-password',
null,
Expand Down Expand Up @@ -86,11 +92,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}

$email = $input->getArgument('email');
if ($this->config->useHashedEmailAsUserID()) {
$email = strtolower($email);
$uid = hash('sha256', $email);
} else {
$uid = $email;

$uid = $input->getOption('uid');
if ($uid === null || $uid === '') {
if ($this->config->useHashedEmailAsUserID()) {
$email = strtolower($email);
$uid = hash('sha256', $email);
} else {
$uid = $email;
}
}

// same behavior like in the UsersController
Expand Down
59 changes: 59 additions & 0 deletions lib/Controller/UsersController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use OCA\Guests\Db\Transfer;
use OCA\Guests\Db\TransferMapper;
use OCA\Guests\GuestManager;
use OCA\Guests\Service\ConversionService;
use OCA\Guests\Service\InviteService;
use OCA\Guests\TransferService;
use OCP\AppFramework\Db\DoesNotExistException;
Expand All @@ -29,6 +30,7 @@
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\Mail\IMailer;
use Psr\Log\LoggerInterface;

class UsersController extends OCSController {
public function __construct(
Expand All @@ -45,6 +47,8 @@ public function __construct(
private readonly TransferService $transferService,
private readonly TransferMapper $transferMapper,
private readonly InviteService $inviteService,
private readonly ConversionService $conversionService,
private readonly LoggerInterface $logger,
) {
parent::__construct($appName, $request);
}
Expand Down Expand Up @@ -245,4 +249,59 @@ public function transfer(string $guestUserId, string $targetUserId): DataRespons

return new DataResponse([], Http::STATUS_CREATED);
}

/**
* Convert a regular, never-logged-in account into a guest account
*/
public function convert(string $userId): DataResponse {
Comment thread
pringelmann marked this conversation as resolved.
$author = $this->userSession->getUser();
if (!($author instanceof IUser)) {
return new DataResponse([
'message' => $this->l10n->t('Failed to authorize')
], Http::STATUS_UNAUTHORIZED);
}

$user = $this->userManager->get($userId);
if (!($user instanceof IUser)) {
return new DataResponse([
'message' => $this->l10n->t('Account not found')
], Http::STATUS_NOT_FOUND);
}

if ($this->guestManager->isGuest($user)) {
return new DataResponse([
'message' => $this->l10n->t('Account is already a guest')
], Http::STATUS_CONFLICT);
}

if ($user->getBackendClassName() !== 'Database') {
return new DataResponse([
'message' => $this->l10n->t('Only regular accounts can be converted to guests')
], Http::STATUS_CONFLICT);
}

if ($user->getLastLogin() !== 0) {
return new DataResponse([
'message' => $this->l10n->t('Only accounts that have never logged in can be converted')
], Http::STATUS_CONFLICT);
}

if ($this->groupManager->isAdmin($userId) || $this->subAdmin->isSubAdmin($user)) {
return new DataResponse([
'message' => $this->l10n->t('Accounts with administrative privileges cannot be converted to guests')
], Http::STATUS_CONFLICT);
}

try {
$this->conversionService->convertToGuest($user, $author);
$this->guestManager->setGuestQuota($user);
} catch (\Throwable $e) {
$this->logger->error('Failed to convert account "' . $userId . '" to a guest', ['exception' => $e]);
return new DataResponse([
'message' => $this->l10n->t('An error occurred while converting the account')
], Http::STATUS_INTERNAL_SERVER_ERROR);
}

return new DataResponse([]);
}
}
9 changes: 8 additions & 1 deletion lib/GuestManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,18 @@ public function createGuest(?IUser $createdBy, string $userId, string $email, st
);
}

$user->setQuota($this->appConfig->getAppValueString(ConfigLexicon::GUEST_DISK_QUOTA));
$this->setGuestQuota($user);

return $user;
}

/**
* Apply the configured default guest quota to an account.
*/
public function setGuestQuota(IUser $user): void {
$user->setQuota($this->appConfig->getAppValueString(ConfigLexicon::GUEST_DISK_QUOTA));
}

/**
* @return list<string>
*/
Expand Down
81 changes: 81 additions & 0 deletions lib/Service/ConversionService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Guests\Service;

use OCA\Guests\AppInfo\Application;
use OCA\Guests\ConfigLexicon;
use OCP\Config\IUserConfig;
use OCP\IDBConnection;
use OCP\IUser;

/**
* Converts a regular account into a guest account by moving it from the core
* "Database" user backend into the guests backend.
*
* The user ID is preserved, so all existing account data, home storage and
* mounts stay valid, and the password hash is carried over unchanged. The
* caller is responsible for checking eligibility (database backend, never
* logged in, not already a guest).
*/
class ConversionService {
public function __construct(
private readonly IDBConnection $connection,
private readonly IUserConfig $userConfig,
) {
}

public function convertToGuest(IUser $user, IUser $createdBy): void {
$uid = $user->getUID();
$uidLower = mb_strtolower($uid);
$displayName = $user->getDisplayName();
$email = $user->getSystemEMailAddress() ?? '';

// Carry over the existing password hash from the database backend.
$query = $this->connection->getQueryBuilder();
$query->select('password')
->from('users')
->where($query->expr()->eq('uid_lower', $query->createNamedParameter($uidLower)));
$result = $query->executeQuery();
$passwordHash = $result->fetchOne();
$result->closeCursor();
if ($passwordHash === false) {
throw new \RuntimeException('No password hash found for "' . $uid . '"');
}

// Move the account between backends in a single transaction. The user ID
// is unchanged, so all data keyed by it (account, home storage, mounts,
// shares) stays valid.
$this->connection->beginTransaction();
try {
$insert = $this->connection->getQueryBuilder();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use:

		if ($this->config->useHashedEmailAsUserID()) {
			$email = strtolower($email);
			$username = hash('sha256', $email);
		} else {
			$username = $email;
		}

to create uid, same as in lib/Controller/UsersController.php and lib/Command/AddCommand.php

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answered in the thread on UserBackend.php, keeping the uid is deliberate, see my reasoning there.

$insert->insert('guests_users')
->values([
'uid' => $insert->createNamedParameter($uid),
'uid_lower' => $insert->createNamedParameter($uidLower),
'displayname' => $insert->createNamedParameter($displayName),
'password' => $insert->createNamedParameter($passwordHash),
'email' => $insert->createNamedParameter($email),
]);
$insert->executeStatement();

$delete = $this->connection->getQueryBuilder();
$delete->delete('users')
->where($delete->expr()->eq('uid_lower', $delete->createNamedParameter($uidLower)));
$delete->executeStatement();

$this->connection->commit();
} catch (\Throwable $e) {
$this->connection->rollBack();
throw $e;
}

$this->userConfig->setValueString($uid, Application::APP_ID, ConfigLexicon::USER_CREATED_BY, $createdBy->getUID());
}
}
13 changes: 4 additions & 9 deletions lib/UserBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -330,8 +330,7 @@ private function loadUser($uid): bool {
return false;
}

// guests $uid could be NULL or ''
// or is not an email anyway
// Skip empty IDs; any non-empty ID is resolved against the guests_users table.
if (!$this->potentialGuestUserId($uid)) {
$this->cache[$uid] = false;
return false;
Expand Down Expand Up @@ -473,14 +472,10 @@ public function getRealUID(string $uid): string {
}

/**
* Guest app user ids are:
* - either email addresses so they need to contain an @
* - lowercase sha256 hashes of email addresses, 64 characters of a-f and 0-9
*
* @param string $userId
* @return bool
* Guard against empty IDs only. Any non-empty ID may belong to a guest and is
* resolved against the guests_users table.
*/
protected function potentialGuestUserId(string $userId): bool {
return str_contains($userId, '@') || preg_match('/^[a-f0-9]{64}$/', $userId);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer if the userId when converting to a guest user is changed instead of changing this method

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @CarlSchwan , I considered exactly that route and decided against it deliberately, so let me defend the design.

Changing the uid would turn this PR into the opposite of what it closes. #333 (open since 2020, never declined) asks for guests whose login name is not their email address. The registration approval flow is the concrete case: a person signs up with a username the admin deliberately allowed, the admin approves the account and converts it to a guest. Renaming it to a sha256 hash at that moment silently discards the name the person just registered with, and they would have to log in with their email instead. Admins who want uniform email logins already have that choice today, the registration app has a "Force email as login name" setting and even a login name policy regex. I would not want the guests app to override a decision the admin already made there.

There is also a technical reason the uid is preserved: core deliberately offers no uid rename, because the uid is a foreign key in core and app tables alike, group memberships, incoming shares, preferences provisioned before first login, and any other app's tables keyed by uid. An app-side rename can only cover the tables it knows about, everything else keeps pointing at a uid that no longer exists. Guillaume ruled out the direct DB edit in #333 for exactly this reason. Keeping the uid is what makes the conversion a safe, atomic operation, one insert, one delete, one transaction.

On potentialGuestUserId(): the email-or-hash shape was never a correctness boundary, guests_users is. Any regular account with an @ in its uid already passes the check today and falls through to the table lookup, and the email convention stays untouched for invited guests, where the email is the only identity that exists at creation time. The loosened check costs one indexed SELECT per unknown uid, and the miss is cached per request.

So keeping the uid stable is not an implementation shortcut, it is the point of the feature, and I would like to keep it that way.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CarlSchwan I think this is a very good explanation for @ernolf's decision, wdyt?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means now all usage of IUserManager::getSeenUsers will query all the times the guest backend and various other methods in this manager will do the same

return $userId !== '';
}
}
Loading
Loading