feat: Implement settings frontend for allowed CORS domains

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2023-09-20 12:28:33 +02:00
parent e608e5d145
commit 33ae58e221
10 changed files with 395 additions and 10 deletions

View file

@ -76,6 +76,8 @@ return [
['name' => 'TwoFactorSettings#index', 'url' => '/settings/api/admin/twofactorauth', 'verb' => 'GET' , 'root' => ''],
['name' => 'TwoFactorSettings#update', 'url' => '/settings/api/admin/twofactorauth', 'verb' => 'PUT' , 'root' => ''],
['name' => 'AISettings#update', 'url' => '/settings/api/admin/ai', 'verb' => 'PUT' , 'root' => ''],
['name' => 'CORSSettings#updateUserEnabled', 'url' => '/settings/api/admin/cors/allowusers', 'verb' => 'PUT' , 'root' => ''],
['name' => 'CORSSettings#allowedDomains', 'url' => '/settings/api/admin/cors/domains', 'verb' => 'PUT' , 'root' => ''],
['name' => 'Help#help', 'url' => '/settings/help/{mode}', 'verb' => 'GET', 'defaults' => ['mode' => ''] , 'root' => ''],

View file

@ -21,6 +21,7 @@ return array(
'OCA\\Settings\\Controller\\AppSettingsController' => $baseDir . '/../lib/Controller/AppSettingsController.php',
'OCA\\Settings\\Controller\\AuthSettingsController' => $baseDir . '/../lib/Controller/AuthSettingsController.php',
'OCA\\Settings\\Controller\\AuthorizedGroupController' => $baseDir . '/../lib/Controller/AuthorizedGroupController.php',
'OCA\\Settings\\Controller\\CORSSettingsController' => $baseDir . '/../lib/Controller/CORSSettingsController.php',
'OCA\\Settings\\Controller\\ChangePasswordController' => $baseDir . '/../lib/Controller/ChangePasswordController.php',
'OCA\\Settings\\Controller\\CheckSetupController' => $baseDir . '/../lib/Controller/CheckSetupController.php',
'OCA\\Settings\\Controller\\CommonSettingsTrait' => $baseDir . '/../lib/Controller/CommonSettingsTrait.php',

View file

@ -36,6 +36,7 @@ class ComposerStaticInitSettings
'OCA\\Settings\\Controller\\AppSettingsController' => __DIR__ . '/..' . '/../lib/Controller/AppSettingsController.php',
'OCA\\Settings\\Controller\\AuthSettingsController' => __DIR__ . '/..' . '/../lib/Controller/AuthSettingsController.php',
'OCA\\Settings\\Controller\\AuthorizedGroupController' => __DIR__ . '/..' . '/../lib/Controller/AuthorizedGroupController.php',
'OCA\\Settings\\Controller\\CORSSettingsController' => __DIR__ . '/..' . '/../lib/Controller/CORSSettingsController.php',
'OCA\\Settings\\Controller\\ChangePasswordController' => __DIR__ . '/..' . '/../lib/Controller/ChangePasswordController.php',
'OCA\\Settings\\Controller\\CheckSetupController' => __DIR__ . '/..' . '/../lib/Controller/CheckSetupController.php',
'OCA\\Settings\\Controller\\CommonSettingsTrait' => __DIR__ . '/..' . '/../lib/Controller/CommonSettingsTrait.php',

View file

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessend.de>
*
* @license AGPL-3.0-or-later
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License
* as published by the Free Software Foundation,
* either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*/
namespace OCA\Settings\Controller;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\IConfig;
use OCP\IRequest;
use OCP\Util;
class CORSSettingsController extends Controller {
/**
* @param string $appName
* @param IRequest $request
* @param IConfig $config
*/
public function __construct(
$appName,
IRequest $request,
private IConfig $config,
) {
parent::__construct($appName, $request);
}
/**
* Set whether users can configure their own list of allowed CORS domains
*
* @AuthorizedAdminSetting(settings=OCA\Settings\Settings\Admin\Security)
*
* @param bool $value
* @return DataResponse
*/
public function updateUserEnabled($value) {
if (!is_bool($value)) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
$this->config->setSystemValue('cors.allow-user-domains', $value);
return new DataResponse();
}
/**
* Set list of globally allowed CORS domains
*
* @AuthorizedAdminSetting(settings=OCA\Settings\Settings\Admin\Security)
*
* @param array $value
* @return DataResponse
*/
public function allowedDomains(array $value) {
try {
foreach ($value as $entry) {
if (!is_string($entry) || $entry === '' || Util::getFullDomain($entry) === '') {
return new DataResponse([], HTTP::STATUS_BAD_REQUEST);
}
}
} catch (\InvalidArgumentException $e) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
$this->config->setSystemValue('cors.allowed-domains', $value);
return new DataResponse();
}
}

View file

@ -30,6 +30,7 @@ use OC\Authentication\TwoFactorAuth\MandatoryTwoFactor;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\Encryption\IManager;
use OCP\IConfig;
use OCP\IUserManager;
use OCP\IURLGenerator;
use OCP\Settings\ISettings;
@ -40,17 +41,20 @@ class Security implements ISettings {
private MandatoryTwoFactor $mandatoryTwoFactor;
private IInitialState $initialState;
private IURLGenerator $urlGenerator;
private IConfig $config;
public function __construct(IManager $manager,
IUserManager $userManager,
MandatoryTwoFactor $mandatoryTwoFactor,
IInitialState $initialState,
IURLGenerator $urlGenerator) {
IURLGenerator $urlGenerator,
IConfig $config) {
$this->manager = $manager;
$this->userManager = $userManager;
$this->mandatoryTwoFactor = $mandatoryTwoFactor;
$this->initialState = $initialState;
$this->urlGenerator = $urlGenerator;
$this->config = $config;
}
/**
@ -76,6 +80,11 @@ class Security implements ISettings {
$this->initialState->provideInitialState('encryption-modules', $encryptionModuleList);
$this->initialState->provideInitialState('encryption-admin-doc', $this->urlGenerator->linkToDocs('admin-encryption'));
$this->initialState->provideInitialState('cors-allowed-domains', $this->config->getSystemValue('cors.allowed-domains', []));
$this->initialState->provideInitialState('cors-allow-user-domains', $this->config->getSystemValue('cors.allow-user-domains', false));
$this->initialState->provideInitialState('cors-settings-admin-docs', $this->urlGenerator->linkToDocs('admin-cors'));
return new TemplateResponse('settings', 'settings/admin/security', [], '');
}

View file

@ -0,0 +1,273 @@
<!--
- @copyright 2023 Ferdinand Thiessen <opensource@fthiessen.de>
-
- @author Ferdinand Thiessen <opensource@fthiessen.de>
-
- @license AGPL-3.0-or-later
-
- This code is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License
- as published by the Free Software Foundation,
- either version 3 of the License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License, version 3,
- along with this program. If not, see <http://www.gnu.org/licenses/>
-
-->
<template>
<NcSettingsSection :name="t('settings', 'CORS allowed domains')"
:description="t('settings', 'Cross-origin resource sharing (CORS) allows restricted resources (API) to be accessed from another external domain. The enabled domains will be allowed to access the DAV resources and CORS enabled API routes.')"
:doc-url="corsSettingsAdminDoc">
<NcCheckboxRadioSwitch :checked.sync="userCorsDomainsEnabled"
type="switch"
@update:checked="updateUserCorsDomains">
{{ t('settings', 'Allow users to define a custom list of CORS enabled domains for their resources') }}
</NcCheckboxRadioSwitch>
<section>
<h3>{{ t('settings', 'CORS enabled external domains') }}</h3>
<ul class="cors-domain__list">
<li v-for="domain in allowedCorsDomains" :key="domain" class="cors-domain">
<IconDomain :size="20" />
<span class="cors-domain__text">{{ domain }}</span>
<NcButton class="cors-domain__delete" type="tertiary" @click="removeCorsDomain(domain)">
<template #icon>
<IconTrashCan :size="20" />
</template>
Delete
</NcButton>
</li>
</ul>
</section>
<div class="cors-domain-input__wrapper">
<NcTextField :error="inputError"
:helper-text="inputErrorText"
:label="t('settings', 'New CORS enabled domain')"
:show-trailing-button="inputValue !== ''"
:trailing-button-label="t('settings', 'Add CORS domain')"
:value.sync="inputValue"
class="cors-domain-input"
placeholder="http://some.example.com"
@keydown="onKeydownDomain"
@trailing-button-click="inputValue = ''">
<IconDomainPlus :size="20" />
</NcTextField>
<NcButton class="cors-domain-input__submit" @click="addCorsDomain(inputValue)">
<template #icon>
<IconCheck :size="20" />
</template>
{{ t('setting', 'Add domain') }}
</NcButton>
</div>
</NcSettingsSection>
</template>
<script>
import axios from '@nextcloud/axios'
import IconCheck from 'vue-material-design-icons/Check.vue'
import IconDomain from 'vue-material-design-icons/Domain.vue'
import IconDomainPlus from 'vue-material-design-icons/DomainPlus.vue'
import IconTrashCan from 'vue-material-design-icons/TrashCan.vue'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import { loadState } from '@nextcloud/initial-state'
import { generateOcsUrl, generateUrl } from '@nextcloud/router'
import { confirmPassword } from '@nextcloud/password-confirmation'
import { showError } from '@nextcloud/dialogs'
import { logger } from '../utils/logger.ts'
export default {
name: 'CORS',
components: {
IconCheck,
IconDomain,
IconDomainPlus,
IconTrashCan,
NcButton,
NcCheckboxRadioSwitch,
NcSettingsSection,
NcTextField,
},
data() {
return {
allowedCorsDomains: loadState('settings', 'cors-allowed-domains', []),
userCorsDomainsEnabled: loadState('settings', 'cors-allow-user-domains', false),
corsSettingsAdminDoc: loadState('settings', 'cors-settings-admin-docs', ''),
inputValue: '',
inputError: false,
inputErrorText: '',
}
},
watch: {
/**
* Ensure errors are cleared on empty input or if the input is valid again
*/
inputValue() {
if (this.inputValue === '' || (this.inputError && this.validateCorsDomain(this.inputValue))) {
this.inputError = false
this.inputErrorText = ''
}
},
},
methods: {
/**
* Add a new trusted CORS domain
* @param {string} newDomain New domain to add as CORS enabled domain
*/
addCorsDomain(newDomain) {
const domain = this.validateCorsDomain(newDomain)
if (domain !== false) {
const backup = [...this.allowedCorsDomains]
this.allowedCorsDomains = [...this.allowedCorsDomains, domain]
this.update('domains', this.allowedCorsDomains).then(() => {
this.inputValue = ''
}).catch(() => {
this.allowedCorsDomains = backup
})
}
},
/**
* Remove a domain from the list of CORS enabled domains
* @param {string} domain Domain to remove from allowed domains
*/
removeCorsDomain(domain) {
const backup = [...this.allowedCorsDomains]
this.allowedCorsDomains = [...this.allowedCorsDomains.filter((entry) => entry !== domain)]
this.update('domains', this.allowedCorsDomains).catch(() => {
this.allowedCorsDomains = backup
})
},
/**
* Handle Enter press on the CORS domain input field
* @param {KeyboardEvent} event The keyboard event
*/
onKeydownDomain(event) {
if (event.key === 'Enter') {
this.addCorsDomain(this.inputValue)
}
},
/**
* Save user defined CORS domain
* @param {boolean} checked Whether user defined lists are enabled
*/
updateUserCorsDomains(checked) {
const backup = this.userCorsDomainsEnabled
this.update('allowusers', checked).catch(() => {
this.userCorsDomainsEnabled = backup
})
},
/**
* Validate if a given string is a valid domain for the CORS headers
* @param {string} domain A URL to validate
* @return {string|false} Either the validated domain or false if invalid
*/
validateCorsDomain(domain) {
try {
const url = new URL(domain)
if (url.hash !== '' || url.search !== '' || (url.pathname !== '' && url.pathname !== '/')) {
this.inputError = true
this.inputErrorText = t('settings', 'The domain must not contain any additional path or query parameters.')
} else if (url.password !== '' || url.username !== '') {
this.inputError = true
this.inputErrorText = t('settings', 'The domain must not contain user and / or password.')
} else if (url.origin === window.location.origin) {
this.inputError = true
this.inputErrorText = t('settings', 'The domain must not be the same like the current domain.')
} else if (this.allowedCorsDomains.includes(url.origin)) {
this.inputError = true
this.inputErrorText = t('settings', 'The domain is already included.')
} else {
return url.origin
}
} catch (e) {
this.inputError = true
this.inputErrorText = t('settings', 'Invalid entered domain is not valid, please include protocol and hostname.')
logger.debug('Invalid URL passed as CORS domain', { error: e })
}
return false
},
async update(key, value) {
await confirmPassword()
const url = generateUrl('/settings/api/admin/cors/{key}', {
key,
})
let reject = false
try {
const { status } = await axios.put(url, {
value,
})
if (status !== 200) {
showError(errorMessage)
logger.error(errorMessage, { error })
reject = true
}
} catch (error) {
const errorMessage = t('settings', 'Unable to update CORS config')
showError(errorMessage)
logger.error(errorMessage, { error })
}
if (reject) {
throw new Error()
}
},
},
}
</script>
<style lang="scss" scoped>
.cors-domain-input {
margin-inline-start: 12px;
&__wrapper {
display: flex;
align-items: center;
gap: 12px;
}
&__submit {
flex-shrink: 0;
}
}
.cors-domain {
display: flex;
align-items: center;
border-radius: var(--border-radius-large);
padding: 4px;
padding-inline-start: 12px;
&:hover {
background-color: var(--color-background-hover);
}
&__list {
margin-block: 6px 12px;
margin-inline-start: 12px;
}
&__text {
font-family: monospace;
padding-inline: 24px 12px;
}
&__delete {
margin-inline-start: auto;
}
}
</style>

View file

@ -80,18 +80,13 @@ import axios from '@nextcloud/axios'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
import { loadState } from '@nextcloud/initial-state'
import { getLoggerBuilder } from '@nextcloud/logger'
import { loadState } from '@nextcloud/initial-state'
import { generateOcsUrl } from '@nextcloud/router'
import { confirmPassword } from '@nextcloud/password-confirmation'
import '@nextcloud/password-confirmation/dist/style.css'
import { showError } from '@nextcloud/dialogs'
const logger = getLoggerBuilder()
.setApp('settings')
.detectUser()
.build()
import { logger } from '../utils/logger.ts'
export default {
name: 'Encryption',

View file

@ -4,6 +4,7 @@
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Roeland Jago Douma <roeland@famdouma.nl>
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
@ -22,13 +23,16 @@
*
*/
import { loadState } from '@nextcloud/initial-state'
import Vue from 'vue'
import AdminTwoFactor from './components/AdminTwoFactor.vue'
import Encryption from './components/Encryption.vue'
import CORS from './components/CORS.vue'
import store from './store/admin-security.js'
import { loadState } from '@nextcloud/initial-state'
import '@nextcloud/password-confirmation/dist/style.css'
// eslint-disable-next-line camelcase
__webpack_nonce__ = btoa(OC.requestToken)
@ -49,3 +53,6 @@ new View({
const EncryptionView = Vue.extend(Encryption)
new EncryptionView().$mount('#vue-admin-encryption')
const CORSView = Vue.extend(CORS)
new CORSView().$mount('#vue-admin-cors-settings')

View file

@ -0,0 +1,6 @@
import { getLoggerBuilder } from '@nextcloud/logger'
export const logger = getLoggerBuilder()
.setApp('settings')
.detectUser()
.build()

View file

@ -4,6 +4,7 @@
* @copyright 2022 Carl Schwan <carl@carlschwan.eu>
*
* @author Arthur Schiwon <blizzz@arthur-schiwon.de>
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license GNU AGPL version 3 or any later version
*
@ -28,3 +29,5 @@
<div id="two-factor-auth-settings"></div>
<div id="vue-admin-encryption"></div>
<div id="vue-admin-cors-settings"></div>