Merge pull request #49317 from nextcloud/feat/edit-share-token

feat: Make it possible to customize share link tokens
pull/50206/head
Pytal 2025-01-15 16:25:35 +07:00 committed by GitHub
commit 76ed55ace1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 342 additions and 54 deletions

@ -55,6 +55,7 @@ class Capabilities implements ICapability {
* send_mail?: bool,
* upload?: bool,
* upload_files_drop?: bool,
* custom_tokens?: bool,
* },
* user: array{
* send_mail: bool,
@ -136,6 +137,7 @@ class Capabilities implements ICapability {
$public['send_mail'] = $this->config->getAppValue('core', 'shareapi_allow_public_notification', 'no') === 'yes';
$public['upload'] = $this->shareManager->shareApiLinkAllowPublicUpload();
$public['upload_files_drop'] = $public['upload'];
$public['custom_tokens'] = $this->shareManager->allowCustomTokens();
}
$res['public'] = $public;

@ -21,6 +21,7 @@ use OCA\Files_Sharing\SharedStorage;
use OCA\GlobalSiteSelector\Service\SlaveService;
use OCP\App\IAppManager;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\UserRateLimit;
use OCP\AppFramework\Http\DataResponse;
@ -52,6 +53,7 @@ use OCP\Lock\LockedException;
use OCP\Mail\IMailer;
use OCP\Server;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\Exceptions\ShareTokenException;
use OCP\Share\IManager;
use OCP\Share\IProviderFactory;
use OCP\Share\IShare;
@ -1164,6 +1166,7 @@ class ShareAPIController extends OCSController {
* Considering the share already exists, no mail will be send after the share is updated.
* You will have to use the sendMail action to send the mail.
* @param string|null $shareWith New recipient for email shares
* @param string|null $token New token
* @return DataResponse<Http::STATUS_OK, Files_SharingShare, array{}>
* @throws OCSBadRequestException Share could not be updated because the requested changes are invalid
* @throws OCSForbiddenException Missing permissions to update the share
@ -1184,6 +1187,7 @@ class ShareAPIController extends OCSController {
?string $hideDownload = null,
?string $attributes = null,
?string $sendMail = null,
?string $token = null,
): DataResponse {
try {
$share = $this->getShareById($id);
@ -1211,7 +1215,8 @@ class ShareAPIController extends OCSController {
$label === null &&
$hideDownload === null &&
$attributes === null &&
$sendMail === null
$sendMail === null &&
$token === null
) {
throw new OCSBadRequestException($this->l->t('Wrong or no update parameter given'));
}
@ -1324,6 +1329,16 @@ class ShareAPIController extends OCSController {
} elseif ($sendPasswordByTalk !== null) {
$share->setSendPasswordByTalk(false);
}
if ($token !== null) {
if (!$this->shareManager->allowCustomTokens()) {
throw new OCSForbiddenException($this->l->t('Custom share link tokens have been disabled by the administrator'));
}
if (!$this->validateToken($token)) {
throw new OCSBadRequestException($this->l->t('Tokens must contain at least 1 character and may only contain letters, numbers, or a hyphen'));
}
$share->setToken($token);
}
}
// NOT A LINK SHARE
@ -1357,6 +1372,16 @@ class ShareAPIController extends OCSController {
return new DataResponse($this->formatShare($share));
}
private function validateToken(string $token): bool {
if (mb_strlen($token) === 0) {
return false;
}
if (!preg_match('/^[a-z0-9-]+$/i', $token)) {
return false;
}
return true;
}
/**
* Get all shares that are still pending
*
@ -2152,4 +2177,26 @@ class ShareAPIController extends OCSController {
throw new OCSNotFoundException($this->l->t('Wrong share ID, share does not exist'));
}
}
/**
* Get a unique share token
*
* @throws OCSException Failed to generate a unique token
*
* @return DataResponse<Http::STATUS_OK, array{token: string}, array{}>
*
* 200: Token generated successfully
*/
#[ApiRoute(verb: 'GET', url: '/api/v1/token')]
#[NoAdminRequired]
public function generateToken(): DataResponse {
try {
$token = $this->shareManager->generateToken();
return new DataResponse([
'token' => $token,
]);
} catch (ShareTokenException $e) {
throw new OCSException($this->l->t('Failed to generate a unique token'));
}
}
}

@ -129,6 +129,9 @@
},
"upload_files_drop": {
"type": "boolean"
},
"custom_tokens": {
"type": "boolean"
}
}
},
@ -2313,6 +2316,11 @@
"type": "string",
"nullable": true,
"description": "if the share should be send by mail. Considering the share already exists, no mail will be send after the share is updated. You will have to use the sendMail action to send the mail."
},
"token": {
"type": "string",
"nullable": true,
"description": "New token"
}
}
}
@ -3833,6 +3841,75 @@
}
}
}
},
"/ocs/v2.php/apps/files_sharing/api/v1/token": {
"get": {
"operationId": "shareapi-generate-token",
"summary": "Get a unique share token",
"tags": [
"shareapi"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Token generated successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"token"
],
"properties": {
"token": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
}
}
}
},
"tags": []

@ -192,6 +192,13 @@ export default class Share {
return this._share.token
}
/**
* Set the public share token
*/
set token(token: string) {
this._share.token = token
}
/**
* Get the share note if any
*/

@ -34,7 +34,8 @@ type FileSharingCapabilities = {
},
send_mail: boolean,
upload: boolean,
upload_files_drop: boolean
upload_files_drop: boolean,
custom_tokens: boolean,
},
resharing: boolean,
user: {
@ -298,4 +299,11 @@ export default class Config {
return this._capabilities?.password_policy || {}
}
/**
* Returns true if custom tokens are allowed
*/
get allowCustomTokens(): boolean {
return this._capabilities?.files_sharing?.public?.custom_tokens
}
}

@ -0,0 +1,20 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
interface TokenData {
ocs: {
data: {
token: string,
}
}
}
export const generateToken = async (): Promise<string> => {
const { data } = await axios.get<TokenData>(generateOcsUrl('/apps/files_sharing/api/v1/token'))
return data.ocs.data.token
}

@ -105,9 +105,23 @@
role="region">
<section>
<NcInputField v-if="isPublicShare"
class="sharingTabDetailsView__label"
autocomplete="off"
:label="t('files_sharing', 'Share label')"
:value.sync="share.label" />
<NcInputField v-if="config.allowCustomTokens && isPublicShare && !isNewShare"
autocomplete="off"
:label="t('files_sharing', 'Share link token')"
:helper-text="t('files_sharing', 'Set the public share link token to something easy to remember or generate a new token. It is not recommended to use a guessable token for shares which contain sensitive information.')"
show-trailing-button
:trailing-button-label="loadingToken ? t('files_sharing', 'Generating…') : t('files_sharing', 'Generate new token')"
@trailing-button-click="generateNewToken"
:value.sync="share.token">
<template #trailing-button-icon>
<NcLoadingIcon v-if="loadingToken" />
<Refresh v-else :size="20" />
</template>
</NcInputField>
<template v-if="isPublicShare">
<NcCheckboxRadioSwitch :checked.sync="isPasswordProtected" :disabled="isPasswordEnforced">
{{ t('files_sharing', 'Set password') }}
@ -228,7 +242,7 @@
<div class="sharingTabDetailsView__footer">
<div class="button-group">
<NcButton data-cy-files-sharing-share-editor-action="cancel"
@click="$emit('close-sharing-details')">
@click="cancel">
{{ t('files_sharing', 'Cancel') }}
</NcButton>
<NcButton type="primary"
@ -248,6 +262,7 @@
import { emit } from '@nextcloud/event-bus'
import { getLanguage } from '@nextcloud/l10n'
import { ShareType } from '@nextcloud/sharing'
import { showError } from '@nextcloud/dialogs'
import moment from '@nextcloud/moment'
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
@ -272,6 +287,7 @@ import UploadIcon from 'vue-material-design-icons/Upload.vue'
import MenuDownIcon from 'vue-material-design-icons/MenuDown.vue'
import MenuUpIcon from 'vue-material-design-icons/MenuUp.vue'
import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue'
import Refresh from 'vue-material-design-icons/Refresh.vue'
import ExternalShareAction from '../components/ExternalShareAction.vue'
@ -279,6 +295,7 @@ import GeneratePassword from '../utils/GeneratePassword.ts'
import Share from '../models/Share.ts'
import ShareRequests from '../mixins/ShareRequests.js'
import SharesMixin from '../mixins/SharesMixin.js'
import { generateToken } from '../services/TokenService.ts'
import logger from '../services/logger.ts'
import {
@ -311,6 +328,7 @@ export default {
MenuDownIcon,
MenuUpIcon,
DotsHorizontalIcon,
Refresh,
},
mixins: [ShareRequests, SharesMixin],
props: {
@ -339,6 +357,8 @@ export default {
isFirstComponentLoad: true,
test: false,
creating: false,
initialToken: this.share.token,
loadingToken: false,
ExternalShareActions: OCA.Sharing.ExternalShareActions.state,
}
@ -766,6 +786,24 @@ export default {
},
methods: {
async generateNewToken() {
if (this.loadingToken) {
return
}
this.loadingToken = true
try {
this.share.token = await generateToken()
} catch (error) {
showError(t('files_sharing', 'Failed to generate a new token'))
}
this.loadingToken = false
},
cancel() {
this.share.token = this.initialToken
this.$emit('close-sharing-details')
},
updateAtomicPermissions({
isReadChecked = this.hasRead,
isEditChecked = this.canEdit,
@ -876,6 +914,9 @@ export default {
async saveShare() {
const permissionsAndAttributes = ['permissions', 'attributes', 'note', 'expireDate']
const publicShareAttributes = ['label', 'password', 'hideDownload']
if (this.config.allowCustomTokens) {
publicShareAttributes.push('token')
}
if (this.isPublicShare) {
permissionsAndAttributes.push(...publicShareAttributes)
}
@ -1174,6 +1215,10 @@ export default {
}
}
&__label {
padding-block-end: 6px;
}
&__delete {
> button:first-child {
color: rgb(223, 7, 7);

@ -13,6 +13,7 @@ use OCA\Files_Sharing\Capabilities;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\IRootFolder;
use OCP\Files\Mount\IMountManager;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IDateTimeZone;
use OCP\IGroupManager;
@ -75,6 +76,7 @@ class CapabilitiesTest extends \Test\TestCase {
$this->createMock(KnownUserService::class),
$this->createMock(ShareDisableChecker::class),
$this->createMock(IDateTimeZone::class),
$this->createMock(IAppConfig::class),
);
$cap = new Capabilities($config, $shareManager);
$result = $this->getFilesSharingPart($cap->getCapabilities());

@ -71,6 +71,7 @@ class Sharing implements IDelegatedSettings {
'defaultRemoteExpireDate' => $this->getHumanBooleanConfig('core', 'shareapi_default_remote_expire_date'),
'remoteExpireAfterNDays' => $this->config->getAppValue('core', 'shareapi_remote_expire_after_n_days', '7'),
'enforceRemoteExpireDate' => $this->getHumanBooleanConfig('core', 'shareapi_enforce_remote_expire_date'),
'allowCustomTokens' => $this->shareManager->allowCustomTokens(),
];
$this->initialState->provideInitialState('sharingAppEnabled', $this->appManager->isEnabledForUser('files_sharing'));

@ -59,6 +59,24 @@
</label>
</fieldset>
<NcCheckboxRadioSwitch type="switch"
aria-describedby="settings-sharing-custom-token-disable-hint settings-sharing-custom-token-access-hint"
:checked.sync="settings.allowCustomTokens">
{{ t('settings', 'Allow users to set custom share link tokens') }}
</NcCheckboxRadioSwitch>
<div class="sharing__sub-section">
<NcNoteCard id="settings-sharing-custom-token-disable-hint"
class="sharing__note"
type="info">
{{ t('settings', 'Shares with custom tokens will continue to be accessible after this setting has been disabled') }}
</NcNoteCard>
<NcNoteCard id="settings-sharing-custom-token-access-hint"
class="sharing__note"
type="warning">
{{ t('settings', 'Shares with guessable tokens may be accessed easily') }}
</NcNoteCard>
</div>
<label>{{ t('settings', 'Limit sharing based on groups') }}</label>
<div class="sharing__sub-section">
<NcCheckboxRadioSwitch :checked.sync="settings.excludeGroups"
@ -195,6 +213,7 @@
import {
NcCheckboxRadioSwitch,
NcSettingsSelectGroup,
NcNoteCard,
NcTextArea,
NcTextField,
} from '@nextcloud/vue'
@ -240,6 +259,7 @@ interface IShareSettings {
defaultRemoteExpireDate: boolean
remoteExpireAfterNDays: string
enforceRemoteExpireDate: boolean
allowCustomTokens: boolean
}
export default defineComponent({
@ -247,6 +267,7 @@ export default defineComponent({
components: {
NcCheckboxRadioSwitch,
NcSettingsSelectGroup,
NcNoteCard,
NcTextArea,
NcTextField,
SelectSharingPermissions,
@ -354,6 +375,10 @@ export default defineComponent({
width: 100%;
}
}
& &__note {
margin: 2px 0;
}
}
@media only screen and (max-width: 350px) {

2
dist/4253-4253.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -0,0 +1 @@
4253-4253.js.license

2
dist/467-467.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -1 +0,0 @@
467-467.js.license

4
dist/6244-6244.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -758,6 +758,7 @@ return array(
'OCP\\Share\\Exceptions\\GenericShareException' => $baseDir . '/lib/public/Share/Exceptions/GenericShareException.php',
'OCP\\Share\\Exceptions\\IllegalIDChangeException' => $baseDir . '/lib/public/Share/Exceptions/IllegalIDChangeException.php',
'OCP\\Share\\Exceptions\\ShareNotFound' => $baseDir . '/lib/public/Share/Exceptions/ShareNotFound.php',
'OCP\\Share\\Exceptions\\ShareTokenException' => $baseDir . '/lib/public/Share/Exceptions/ShareTokenException.php',
'OCP\\Share\\IAttributes' => $baseDir . '/lib/public/Share/IAttributes.php',
'OCP\\Share\\IManager' => $baseDir . '/lib/public/Share/IManager.php',
'OCP\\Share\\IProviderFactory' => $baseDir . '/lib/public/Share/IProviderFactory.php',

@ -799,6 +799,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OCP\\Share\\Exceptions\\GenericShareException' => __DIR__ . '/../../..' . '/lib/public/Share/Exceptions/GenericShareException.php',
'OCP\\Share\\Exceptions\\IllegalIDChangeException' => __DIR__ . '/../../..' . '/lib/public/Share/Exceptions/IllegalIDChangeException.php',
'OCP\\Share\\Exceptions\\ShareNotFound' => __DIR__ . '/../../..' . '/lib/public/Share/Exceptions/ShareNotFound.php',
'OCP\\Share\\Exceptions\\ShareTokenException' => __DIR__ . '/../../..' . '/lib/public/Share/Exceptions/ShareTokenException.php',
'OCP\\Share\\IAttributes' => __DIR__ . '/../../..' . '/lib/public/Share/IAttributes.php',
'OCP\\Share\\IManager' => __DIR__ . '/../../..' . '/lib/public/Share/IManager.php',
'OCP\\Share\\IProviderFactory' => __DIR__ . '/../../..' . '/lib/public/Share/IProviderFactory.php',

@ -21,6 +21,7 @@ use OCP\Files\Mount\IShareOwnerlessMount;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\HintException;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IDateTimeZone;
use OCP\IGroupManager;
@ -43,6 +44,7 @@ use OCP\Share\Events\ShareDeletedFromSelfEvent;
use OCP\Share\Exceptions\AlreadySharedException;
use OCP\Share\Exceptions\GenericShareException;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\Exceptions\ShareTokenException;
use OCP\Share\IManager;
use OCP\Share\IProviderFactory;
use OCP\Share\IShare;
@ -78,6 +80,7 @@ class Manager implements IManager {
private KnownUserService $knownUserService,
private ShareDisableChecker $shareDisableChecker,
private IDateTimeZone $dateTimeZone,
private IAppConfig $appConfig,
) {
$this->l = $this->l10nFactory->get('lib');
// The constructor of LegacyHooks registers the listeners of share events
@ -659,41 +662,7 @@ class Manager implements IManager {
$this->linkCreateChecks($share);
$this->setLinkParent($share);
// Initial token length
$tokenLength = \OC\Share\Helper::getTokenLength();
do {
$tokenExists = false;
for ($i = 0; $i <= 2; $i++) {
// Generate a new token
$token = $this->secureRandom->generate(
$tokenLength,
\OCP\Security\ISecureRandom::CHAR_HUMAN_READABLE
);
try {
// Try to fetch a share with the generated token
$this->getShareByToken($token);
$tokenExists = true; // Token exists, we need to try again
} catch (\OCP\Share\Exceptions\ShareNotFound $e) {
// Token is unique, exit the loop
$tokenExists = false;
break;
}
}
// If we've reached the maximum attempts and the token still exists, increase the token length
if ($tokenExists) {
$tokenLength++;
// Check if the token length exceeds the maximum allowed length
if ($tokenLength > \OC\Share\Constants::MAX_TOKEN_LENGTH) {
throw new \Exception('Unable to generate a unique share token. Maximum token length exceeded.');
}
}
} while ($tokenExists);
$token = $this->generateToken();
// Set the unique token
$share->setToken($token);
@ -1939,6 +1908,10 @@ class Manager implements IManager {
return $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_full_match_ignore_second_dn', 'no') === 'yes';
}
public function allowCustomTokens(): bool {
return $this->appConfig->getValueBool('core', 'shareapi_allow_custom_tokens', false);
}
public function currentUserCanEnumerateTargetUser(?IUser $currentUser, IUser $targetUser): bool {
if ($this->allowEnumerationFullMatch()) {
return true;
@ -2025,4 +1998,43 @@ class Manager implements IManager {
yield from $provider->getAllShares();
}
}
public function generateToken(): string {
// Initial token length
$tokenLength = \OC\Share\Helper::getTokenLength();
do {
$tokenExists = false;
for ($i = 0; $i <= 2; $i++) {
// Generate a new token
$token = $this->secureRandom->generate(
$tokenLength,
ISecureRandom::CHAR_HUMAN_READABLE,
);
try {
// Try to fetch a share with the generated token
$this->getShareByToken($token);
$tokenExists = true; // Token exists, we need to try again
} catch (ShareNotFound $e) {
// Token is unique, exit the loop
$tokenExists = false;
break;
}
}
// If we've reached the maximum attempts and the token still exists, increase the token length
if ($tokenExists) {
$tokenLength++;
// Check if the token length exceeds the maximum allowed length
if ($tokenLength > \OC\Share\Constants::MAX_TOKEN_LENGTH) {
throw new ShareTokenException('Unable to generate a unique share token. Maximum token length exceeded.');
}
}
} while ($tokenExists);
return $token;
}
}

@ -0,0 +1,16 @@
<?php
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\Share\Exceptions;
use Exception;
/**
* @since 31.0.0
*/
class ShareTokenException extends Exception {
}

@ -13,6 +13,7 @@ use OCP\Files\Node;
use OCP\IUser;
use OCP\Share\Exceptions\GenericShareException;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\Exceptions\ShareTokenException;
/**
* This interface allows to manage sharing files between users and groups.
@ -463,6 +464,14 @@ interface IManager {
*/
public function ignoreSecondDisplayName(): bool;
/**
* Check if custom tokens are allowed
*
* @since 31.0.0
*/
public function allowCustomTokens(): bool;
/**
* Check if the current user can enumerate the target user
*
@ -522,4 +531,12 @@ interface IManager {
* @since 18.0.0
*/
public function getAllShares(): iterable;
/**
* Generate a unique share token
*
* @throws ShareTokenException Failed to generate a unique token
* @since 31.0.0
*/
public function generateToken(): string;
}

@ -28,6 +28,7 @@ use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\Files\Storage\IStorage;
use OCP\HintException;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IDateTimeZone;
use OCP\IGroup;
@ -109,6 +110,8 @@ class ManagerTest extends \Test\TestCase {
private DateTimeZone $timezone;
/** @var IDateTimeZone|MockObject */
protected $dateTimeZone;
/** @var IAppConfig|MockObject */
protected $appConfig;
protected function setUp(): void {
$this->logger = $this->createMock(LoggerInterface::class);
@ -131,6 +134,8 @@ class ManagerTest extends \Test\TestCase {
$this->timezone = new \DateTimeZone('Pacific/Auckland');
$this->dateTimeZone->method('getTimeZone')->willReturnCallback(fn () => $this->timezone);
$this->appConfig = $this->createMock(IAppConfig::class);
$this->l10nFactory = $this->createMock(IFactory::class);
$this->l = $this->createMock(IL10N::class);
$this->l->method('t')
@ -172,6 +177,7 @@ class ManagerTest extends \Test\TestCase {
$this->knownUserService,
$this->shareDisabledChecker,
$this->dateTimeZone,
$this->appConfig,
);
}
@ -199,6 +205,7 @@ class ManagerTest extends \Test\TestCase {
$this->knownUserService,
$this->shareDisabledChecker,
$this->dateTimeZone,
$this->appConfig,
]);
}