8000 feat: Hash user IDs by default for improved privacy by nickvergessen · Pull Request #1335 · nextcloud/guests · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

feat: Hash user IDs by default for improved privacy #1335

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? 8000 Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ js
.vscode
.php_cs.cache
.php-cs-fixer.cache
.phpunit.result.cache
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
Guests accounts can be created from the share menu by entering either the recipients email or name and choosing "create guest account", once the share is created the guest user will receive an email notification about the mail with a link to set their password.

Guests users can only access files shared to them and cannot create any files outside of shares, additionally, the apps accessible to guest accounts are whitelisted.]]></description>
<version>4.2.0</version>
<version>4.2.1</version>
<licence>agpl</licence>
<author>Nextcloud</author>
<types>
Expand Down
28 changes: 15 additions & 13 deletions lib/Command/AddCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

namespace OCA\Guests\Command;

use OCA\Guests\Config;
use OCA\Guests\GuestManager;
use OCP\IUser;
use OCP\IUserManager;
Expand All @@ -21,18 +22,13 @@
use Symfony\Component\Console\Question\Question;

class AddCommand extends Command {
/** @var IUserManager */
private $userManager;
/** @var GuestManager */
private $guestManager;
/** @var IMailer */
private $mailer;

public function __construct(IUserManager $userManager, IMailer $mailer, GuestManager $guestManager) {
public function __construct(
private IUserManager $userManager,
private IMailer $mailer,
private GuestManager $guestManager,
private Config $config,
) {
parent::__construct();
$this->userManager = $userManager;
$this->guestManager = $guestManager;
$this->mailer = $mailer;
}

protected function configure() {
Expand Down Expand Up @@ -85,14 +81,20 @@ protected function execute(InputInterface $input, OutputInterface $output) {
return 1;
}

$email = $input->getArgument('email');
if ($this->config->useHashedEmailAsUserID()) {
$email = strtolower($email);
Copy link
Contributor

Choose a reason for hiding this comment

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

Should that be mb_strtolower like in UserBackend?

Copy link
Member Author

Choose a reason for hiding this comment

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

mb is not allowed in user ids nor in emails, so not sure it's required?

$uid = hash('sha256', $email);
} else {
$uid = $email;
}

// same behavior like in the UsersController
$uid = $input->getArgument('email');
if ($this->userManager->userExists($uid)) {
$output->writeln('<error>The user "' . $uid . '" already exists.</error>');
return 1;
}

$email = $input->getArgument('email');
if (!$this->mailer->validateMailAddress($email)) {
$output->writeln('<error>Invalid email address "' . $email . '".</error>');
return 1;
Expand Down
2 changes: 1 addition & 1 deletion lib/Command/ListCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$this->writeArrayInOutputFormat($input, $output, $guests);
} else {
$table = new Table($output);
$table->setHeaders(['Email', 'Name', 'Invited By']);
$table->setHeaders(['Email', 'UserID', 'Name', 'Invited By']);
$table->setRows($guests);
$table->render();
}
Expand Down
10 changes: 9 additions & 1 deletion lib/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ public function setAllowExternalStorage($allow) {
$this->appConfig->setAppValueBool('allow_external_storage', $allow === true || $allow === 'true') ;
}

public function useHashedEmailAsUserID(): bool {
return $this->appConfig->getAppValueBool('hash_user_ids', true);
}

public function setUseHashedEmailAsUserID(bool $useHash): void {
$this->appConfig->setAppValueBool('hash_user_ids', $useHash) ;
}

public function hideOtherUsers(): bool {
return $this->appConfig->getAppValueBool('hide_users', true);
}
Expand Down Expand Up @@ -94,7 +102,7 @@ public function canCreateGuests(): bool {
|| $this->subAdmin->isSubAdmin($user)) {
return true;
}

// Check if we have a group restriction
// and if the user belong to that group
$groupRestriction = $this->getCreateRestrictedToGroup();
Expand Down
4 changes: 3 additions & 1 deletion lib/Controller/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public function getConfig(): DataResponse {
'useWhitelist' => $useWhitelist,
'whitelist' => $whitelist,
'allowExternalStorage' => $allowExternalStorage,
'useHashedEmailAsUserID' => $this->config->useHashedEmailAsUserID(),
'hideUsers' => $hideUsers,
'whiteListableApps' => $this->appWhitelist->getWhitelistAbleApps(),
'sharingRestrictedToGroup' => $this->config->isSharingRestrictedToGroup(),
Expand All @@ -59,14 +60,15 @@ public function getConfig(): DataResponse {
* @param $hideUsers bool
* @return DataResponse
*/
public function setConfig(bool $useWhitelist, array $whitelist, bool $allowExternalStorage, bool $hideUsers, array $createRestrictedToGroup): DataResponse {
public function setConfig(bool $useWhitelist, array $whitelist, bool $allowExternalStorage, bool $useHashedEmailAsUserID, bool $hideUsers, array $createRestrictedToGroup): DataResponse {
$newWhitelist = [];
foreach ($whitelist as $app) {
$newWhitelist[] = trim($app);
}
$this->config->setUseWhitelist($useWhitelist);
$this->config->setAppWhitelist($newWhitelist);
$this->config->setAllowExternalStorage($allowExternalStorage);
$this->config->setUseHashedEmailAsUserID($useHashedEmailAsUserID);
$this->config->setHideOtherUsers($hideUsers);
$this->config->setCreateRestrictedToGroup($createRestrictedToGroup);
return new DataResponse();
Expand Down
7 changes: 6 additions & 1 deletion lib/Controller/UsersController.php
F438
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,12 @@ public function create(string $email, string $displayName, string $language, arr
);
}

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

$existingUsers = $this->userManager->getByEmail($email);
if (count($existingUsers) > 0) {
Expand Down
12 changes: 7 additions & 5 deletions lib/GuestManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public function createGuest(?IUser $createdBy, string $userId, string $email, st
$this->userBackend
);

$this->userBackend->setInitialEmail($userId, $email);
$user->setSystemEMailAddress($email);
if ($createdBy) {
$this->config->setUserValue($userId, 'guests', 'created_by', $createdBy->getUID());
Expand Down Expand Up @@ -111,11 +112,11 @@ public function listGuests(): array {
}

public function getGuestsInfo(): array {
$displayNames = $this->userBackend->getDisplayNames();
$guests = array_keys($displayNames);
$guestsInfo = $this->userBackend->getAllGuestAccounts();
$guests = array_keys($guestsInfo);
$shareCounts = $this->getShareCountForUsers($guests);
$createdBy = $this->config->getUserValueForUsers('guests', 'created_by', $guests);
return array_map(function ($uid) use ($createdBy, $displayNames, $shareCounts) {
return array_map(function ($uid) use ($createdBy, $guestsInfo, $shareCounts) {
$allSharesCount = count(array_merge(
$this->shareManager->getSharedWith($uid, IShare::TYPE_USER, null, -1, 0),
$this->shareManager->getSharedWith($uid, IShare::TYPE_GROUP, null, -1, 0),
Expand All @@ -124,8 +125,9 @@ public function getGuestsInfo(): array {
$this->shareManager->getSharedWith($uid, IShare::TYPE_ROOM, null, -1, 0),
));
return [
'email' => $uid,
'display_name' => $displayNames[$uid] ?? $uid,
'email' => $guestsInfo[$uid]['email'] ?? $uid,
'uid' => $uid,
'display_name' => $guestsInfo[$uid]['displayname'] ?? $uid,
'created_by' => $createdBy[$uid] ?? '',
'share_count' => isset($shareCounts[$uid]) ? $shareCounts[$uid] : 0,
'share_count_with_circles' => $allSharesCount,
Expand Down
61 changes: 61 additions & 0 deletions lib/Migration/Version4002Date20250501195008.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

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

namespace OCA\Guests\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\IDBConnection;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

/**
* Add a column for the email so we know the originally invited email as well
*/
class Version4002Date20250501195008 extends SimpleMigrationStep {
public function __construct(
protected IDBConnection $db,
) {
}

/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

$table = $schema->getTable('guests_users');
if ($table->hasColumn('email')) {
return null;
}

$table->addColumn('email', 'string', [
'notnull' => false,
'length' => 64,
'default' => '',
]);
return $schema;
}

/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
$query = $this->db->getQueryBuilder();
$query->update('guests_users')
->set('email', 'uid_lower');
$query->executeStatement();
}
}
59 changes: 56 additions & 3 deletions lib/UserBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,14 @@ public function deleteUser($uid): bool {
return $result ? true : false;
}

public function setInitialEmail(string $uid, string $email): bool {
$query = $this->dbConn->getQueryBuilder();
$query->update('guests_users')
->set('email', $query->createNamedParameter($email))
->where($query->expr()->eq('uid_lower', $query->createNamedParameter(mb_strtolower($uid))));
return (bool)$query->executeStatement();
}

/**
* Set password
*
Expand Down Expand Up @@ -248,6 +256,35 @@ public function getDisplayNames($search = '', $limit = null, $offset = null): ar
}
}

/**
* Get a list of all users with their email and display name
*
* @return array<string, array{email: string, displayname: string}>
*/
public function getAllGuestAccounts(?int $limit = null, ?int $offset = null): array {
if (!$this->allowListing) {
return [];
}

$query = $this->dbConn->getQueryBuilder();
$query->select('uid', 'email', 'displayname')
->from('guests_users')
->orderBy('uid_lower', 'ASC')
->setMaxResults($limit)
->setFirstResult($offset);

$result = $query->execute();
$users = [];
while ($row = $result->fetch()) {
$users[(string)$row['uid']] = [
'email' => (string)$row['email'],
'displayname' => (string)$row['displayname'],
];
Comment on lines +279 to +282
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: a generator may make sense here: Do all guests fit in memory?

Copy link
Member Author

Choose a reason for hiding this comment

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

getDisplayNames() above is basically doing the same thing. Just had to do an array as value so we can have email + displayname

}

return $users;
}

/**
* Check if the password is correct
*
Expand All @@ -257,7 +294,7 @@ public function getDisplayNames($search = '', $limit = null, $offset = null): ar
* returns the user id or false
*/
public function checkPassword(string $loginName, string $password) {
if (strpos($loginName, '@') === false) {
if (!$this->potentialGuestUserId($loginName)) {
return false;
}

Expand Down Expand Up @@ -294,9 +331,13 @@ public function checkPassword(string $loginName, string $password) {
* @return bool true if user was found, false otherwise
*/
private function loadUser($uid): bool {
if (isset($this->cache[$uid]) && $this->cache[$uid] === false) {
return false;
}

// guests $uid could be NULL or ''
// or is not an email anyway
if (strpos($uid, '@') === false) {
if (!$this->potentialGuestUserId($uid)) {
$this->cache[$uid] = false;
return false;
}
Expand Down Expand Up @@ -416,7 +457,7 @@ public function getBackendName(): string {
}

public function getRealUID(string $uid): string {
if (strpos($uid, '@') === false) {
if (!$this->potentialGuestUserId($uid)) {
throw new \RuntimeException($uid . ' does not exist');
}

Expand All @@ -430,4 +471,16 @@ public function getRealUID(string $uid): string {

return $this->cache[$uid]['uid'];
}

/**
* 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
*/
protected function potentialGuestUserId(string $userId): bool {
return str_contains($userId, '@') || preg_match('/^[a-f0-9]{64}$/', $userId);
}
}
9 changes: 8 additions & 1 deletion src/components/GuestList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
<th :class="getSortClass('email')" @click="setSort('email')">
{{ t('guests', 'Email') }}
</th>
<th :class="getSortClass('uid')" @click="setSort('uid')">
{{ t('guests', 'User ID') }}
</th>
<th :class="getSortClass('display_name')" @click="setSort('display_name')">
{{ t('guests', 'Name') }}
</th>
Expand All @@ -31,6 +34,10 @@
:title="guest.email">
{{ guest.email }}
</td>
<td class="uid"
:title="guest.uid">
{{ guest.uid }}
</td>
<td class="display_name"
:title="guest.display_name">
{{ guest.display_name }}
Expand All @@ -46,7 +53,7 @@
<tr v-if="guest.email === details_for"
:key="guest.email + '-details'"
class="details">
<td colspan="4">
<td colspan="5">
<GuestDetails :guest-id="guest.email" />
</td>
</tr>
Expand Down
E69D
7 changes: 7 additions & 0 deletions src/views/GuestSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@
{{ t('guests', 'Guest accounts can access mounted external storages') }}
</NcCheckboxRadioSwitch>

<NcCheckboxRadioSwitch :checked.sync="config.useHashedEmailAsUserID"
type="switch"
@update:checked="saveConfig">
{{ t('guests', 'Use a hash of the email as user ID for improved privacy') }}
</NcCheckboxRadioSwitch>

<NcCheckboxRadioSwitch :checked.sync="config.hideUsers"
type="switch"
@update:checked="saveConfig">
Expand Down Expand Up @@ -94,7 +100,7 @@
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import NcSettingsSelectGroup from '@nextcloud/vue/components/NcSettingsSelectGroup'

Check warning on line 103 in src/views/GuestSettings.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Using exported name 'NcSettingsSelectGroup' as identifier for default import

import GuestList from '../components/GuestList.vue'
import { showError } from '@nextcloud/dialogs'
Expand All @@ -121,6 +127,7 @@
config: {
useWhitelist: false,
allowExternalStorage: false,
useHashedEmailAsUserID: true,
hideUsers: false,
whitelist: [],
whiteListableApps: [],
Expand Down
Loading
Loading
0