fix(frontend): add strict password confirmation for sensitive admin actions

Register axios password confirmation interceptors in the apps
management, admin delegation, admin security, and OAuth2 settings
bundles, and pass PwdConfirmationMode.Strict on requests to endpoints
protected with #[PasswordConfirmationRequired(strict: true)], so that
the user password is verified via Basic auth on the request itself
rather than relying on the session timestamp.

Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
This commit is contained in:
Peter Ringelmann 2026-03-23 17:42:57 +01:00 committed by Côme Chilliet
parent 0ea55bad6f
commit 058a60774e
No known key found for this signature in database
GPG key ID: A3E2F658B28C760A
9 changed files with 90 additions and 71 deletions

View file

@ -73,6 +73,7 @@ import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
import { loadState } from '@nextcloud/initial-state'
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import { PwdConfirmationMode } from '@nextcloud/password-confirmation'
export default {
name: 'App',
@ -123,6 +124,7 @@ export default {
name: this.newClient.name,
redirectUri: this.newClient.redirectUri,
},
{ confirmPassword: PwdConfirmationMode.Strict },
).then(response => {
// eslint-disable-next-line vue/no-mutating-props
this.clients.push(response.data)

View file

@ -3,13 +3,17 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import axios from '@nextcloud/axios'
import Vue from 'vue'
import App from './App.vue'
import { loadState } from '@nextcloud/initial-state'
import { addPasswordConfirmationInterceptors } from '@nextcloud/password-confirmation'
Vue.prototype.t = t
Vue.prototype.OC = OC
addPasswordConfirmationInterceptors(axios)
const clients = loadState('oauth2', 'clients')
const View = Vue.extend(App)

View file

@ -18,6 +18,7 @@ import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
import { generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import { showError } from '@nextcloud/dialogs'
import { PwdConfirmationMode } from '@nextcloud/password-confirmation'
import logger from '../../logger.ts'
export default {
@ -59,7 +60,7 @@ export default {
class: this.setting.class,
}
try {
await axios.post(generateUrl('/apps/settings/') + '/settings/authorizedgroups/saveSettings', data)
await axios.post(generateUrl('/apps/settings/') + '/settings/authorizedgroups/saveSettings', data, { confirmPassword: PwdConfirmationMode.Strict })
} catch (e) {
showError(t('settings', 'Unable to modify setting'))
logger.error('Unable to modify setting', e)

View file

@ -76,6 +76,7 @@ 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 { loadState } from '@nextcloud/initial-state'
import { PwdConfirmationMode } from '@nextcloud/password-confirmation'
import sortedUniq from 'lodash/sortedUniq.js'
import uniq from 'lodash/uniq.js'
@ -156,7 +157,7 @@ export default {
enforcedGroups: this.enforcedGroups,
excludedGroups: this.excludedGroups,
}
axios.put(generateUrl('/settings/api/admin/twofactorauth'), data)
axios.put(generateUrl('/settings/api/admin/twofactorauth'), data, { confirmPassword: PwdConfirmationMode.Strict })
.then(resp => resp.data)
.then(state => {
this.state = state

View file

@ -3,9 +3,13 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import axios from '@nextcloud/axios'
import { addPasswordConfirmationInterceptors } from '@nextcloud/password-confirmation'
import Vue from 'vue'
import App from './components/AdminDelegating.vue'
addPasswordConfirmationInterceptors(axios)
// bind to window
Vue.prototype.OC = OC
Vue.prototype.t = t

View file

@ -1,15 +1,19 @@
import { getCSPNonce } from '@nextcloud/auth'
/**
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getCSPNonce } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { loadState } from '@nextcloud/initial-state'
import { addPasswordConfirmationInterceptors } from '@nextcloud/password-confirmation'
import Vue from 'vue'
import AdminTwoFactor from './components/AdminTwoFactor.vue'
import EncryptionSettings from './components/Encryption/EncryptionSettings.vue'
import store from './store/admin-security.js'
addPasswordConfirmationInterceptors(axios)
// eslint-disable-next-line camelcase
__webpack_nonce__ = getCSPNonce()

View file

@ -3,6 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import axios from '@nextcloud/axios'
import { addPasswordConfirmationInterceptors } from '@nextcloud/password-confirmation'
import Vue from 'vue'
import Vuex from 'vuex'
import VTooltipPlugin from 'v-tooltip'
@ -15,6 +17,8 @@ import { useStore } from './store/index.js'
import { getCSPNonce } from '@nextcloud/auth'
import { PiniaVuePlugin, createPinia } from 'pinia'
addPasswordConfirmationInterceptors(axios)
// CSP config for webpack dynamic chunk loading
// eslint-disable-next-line camelcase
__webpack_nonce__ = getCSPNonce()

View file

@ -50,8 +50,8 @@ export default {
get(url, options) {
return axios.get(sanitize(url), options)
},
post(url, data) {
return axios.post(sanitize(url), data)
post(url, data, options) {
return axios.post(sanitize(url), data, options)
},
patch(url, data) {
return axios.patch(sanitize(url), data)

View file

@ -9,6 +9,7 @@ import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { showError, showInfo } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { PwdConfirmationMode } from '@nextcloud/password-confirmation'
const state = {
apps: [],
@ -180,84 +181,82 @@ const actions = {
} else {
apps = [appId]
}
return api.requireAdmin().then((response) => {
context.commit('startLoading', apps)
context.commit('startLoading', 'install')
context.commit('startLoading', apps)
context.commit('startLoading', 'install')
const previousState = {}
apps.forEach((_appId) => {
const app = context.state.apps.find((app) => app.id === _appId)
if (app) {
previousState[_appId] = {
active: app.active,
groups: [...(app.groups || [])],
}
context.commit('enableApp', { appId: _appId, groups })
const previousState = {}
apps.forEach((_appId) => {
const app = context.state.apps.find((app) => app.id === _appId)
if (app) {
previousState[_appId] = {
active: app.active,
groups: [...(app.groups || [])],
}
})
context.commit('enableApp', { appId: _appId, groups })
}
})
return api.post(generateUrl('settings/apps/enable'), { appIds: apps, groups })
.then((response) => {
context.commit('stopLoading', apps)
context.commit('stopLoading', 'install')
apps.forEach(_appId => {
context.commit('enableApp', { appId: _appId, groups })
})
return api.post(generateUrl('settings/apps/enable'), { appIds: apps, groups }, { confirmPassword: PwdConfirmationMode.Strict })
.then((response) => {
context.commit('stopLoading', apps)
context.commit('stopLoading', 'install')
// check for server health
return axios.get(generateUrl('apps/files/'))
.then(() => {
if (response.data.update_required) {
showInfo(
t(
'settings',
'The app has been enabled but needs to be updated. You will be redirected to the update page in 5 seconds.',
),
{
onClick: () => window.location.reload(),
close: false,
// check for server health
return axios.get(generateUrl('apps/files/'))
.then(() => {
if (response.data.update_required) {
showInfo(
t(
'settings',
'The app has been enabled but needs to be updated. You will be redirected to the update page in 5 seconds.',
),
{
onClick: () => window.location.reload(),
close: false,
},
)
setTimeout(function() {
location.reload()
}, 5000)
}
})
.catch(() => {
if (!Array.isArray(appId)) {
showError(t('settings', 'Error: This app cannot be enabled because it makes the server unstable'))
context.commit('setError', {
appId: apps,
error: t('settings', 'Error: This app cannot be enabled because it makes the server unstable'),
})
context.dispatch('disableApp', { appId })
}
})
})
.catch((error) => {
context.commit('stopLoading', apps)
context.commit('stopLoading', 'install')
apps.forEach((_appId) => {
if (previousState[_appId]) {
context.commit('enableApp', {
appId: _appId,
groups: previousState[_appId].groups,
})
if (!previousState[_appId].active) {
context.commit('disableApp', _appId)
}
},
)
setTimeout(function() {
location.reload()
}, 5000)
}
})
.catch(() => {
if (!Array.isArray(appId)) {
showError(t('settings', 'Error: This app cannot be enabled because it makes the server unstable'))
context.commit('setError', {
appId: apps,
error: t('settings', 'Error: This app cannot be enabled because it makes the server unstable'),
})
context.dispatch('disableApp', { appId })
}
})
})
.catch((error) => {
context.commit('stopLoading', apps)
context.commit('stopLoading', 'install')
apps.forEach((_appId) => {
if (previousState[_appId]) {
context.commit('enableApp', {
appId: _appId,
groups: previousState[_appId].groups,
})
if (!previousState[_appId].active) {
context.commit('disableApp', _appId)
}
}
})
const message = error.response?.data?.data?.message
if (message) {
context.commit('setError', {
appId: apps,
error: error.response.data.data.message,
error: message,
})
context.commit('APPS_API_FAILURE', { appId, error })
})
}).catch((error) => context.commit('API_FAILURE', { appId, error }))
}
})
},
forceEnableApp(context, { appId, groups }) {
let apps