diff options
Diffstat (limited to 'apps/settings/src/components/WebAuthn')
-rw-r--r-- | apps/settings/src/components/WebAuthn/AddDevice.vue | 194 | ||||
-rw-r--r-- | apps/settings/src/components/WebAuthn/Device.vue | 49 | ||||
-rw-r--r-- | apps/settings/src/components/WebAuthn/Section.vue | 114 |
3 files changed, 357 insertions, 0 deletions
diff --git a/apps/settings/src/components/WebAuthn/AddDevice.vue b/apps/settings/src/components/WebAuthn/AddDevice.vue new file mode 100644 index 00000000000..db00bae451a --- /dev/null +++ b/apps/settings/src/components/WebAuthn/AddDevice.vue @@ -0,0 +1,194 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div v-if="!isHttps && !isLocalhost"> + {{ t('settings', 'Passwordless authentication requires a secure connection.') }} + </div> + <div v-else> + <NcButton v-if="step === RegistrationSteps.READY" + type="primary" + @click="start"> + {{ t('settings', 'Add WebAuthn device') }} + </NcButton> + + <div v-else-if="step === RegistrationSteps.REGISTRATION" + class="new-webauthn-device"> + <span class="icon-loading-small webauthn-loading" /> + {{ t('settings', 'Please authorize your WebAuthn device.') }} + </div> + + <div v-else-if="step === RegistrationSteps.NAMING" + class="new-webauthn-device"> + <span class="icon-loading-small webauthn-loading" /> + <form @submit.prevent="submit"> + <NcTextField ref="nameInput" + class="new-webauthn-device__name" + :label="t('settings', 'Device name')" + :value.sync="name" + show-trailing-button + :trailing-button-label="t('settings', 'Add')" + trailing-button-icon="arrowRight" + @trailing-button-click="submit" /> + </form> + </div> + + <div v-else-if="step === RegistrationSteps.PERSIST" + class="new-webauthn-device"> + <span class="icon-loading-small webauthn-loading" /> + {{ t('settings', 'Adding your device …') }} + </div> + + <div v-else> + Invalid registration step. This should not have happened. + </div> + </div> +</template> + +<script> +import { showError } from '@nextcloud/dialogs' +import { confirmPassword } from '@nextcloud/password-confirmation' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcTextField from '@nextcloud/vue/components/NcTextField' + +import logger from '../../logger.ts' +import { + startRegistration, + finishRegistration, +} from '../../service/WebAuthnRegistrationSerice.ts' + +import '@nextcloud/password-confirmation/dist/style.css' + +const logAndPass = (text) => (data) => { + logger.debug(text) + return data +} + +const RegistrationSteps = Object.freeze({ + READY: 1, + REGISTRATION: 2, + NAMING: 3, + PERSIST: 4, +}) + +export default { + name: 'AddDevice', + + components: { + NcButton, + NcTextField, + }, + + props: { + httpWarning: Boolean, + isHttps: { + type: Boolean, + default: false, + }, + isLocalhost: { + type: Boolean, + default: false, + }, + }, + + setup() { + // non reactive props + return { + RegistrationSteps, + } + }, + + data() { + return { + name: '', + credential: {}, + step: RegistrationSteps.READY, + } + }, + + watch: { + /** + * Auto focus the name input when naming a device + */ + step() { + if (this.step === RegistrationSteps.NAMING) { + this.$nextTick(() => this.$refs.nameInput?.focus()) + } + }, + }, + + methods: { + /** + * Start the registration process by loading the authenticator parameters + * The next step is the naming of the device + */ + async start() { + this.step = RegistrationSteps.REGISTRATION + console.debug('Starting WebAuthn registration') + + try { + await confirmPassword() + this.credential = await startRegistration() + this.step = RegistrationSteps.NAMING + } catch (err) { + showError(err) + this.step = RegistrationSteps.READY + } + }, + + /** + * Save the new device with the given name on the server + */ + submit() { + this.step = RegistrationSteps.PERSIST + + return confirmPassword() + .then(logAndPass('confirmed password')) + .then(this.saveRegistrationData) + .then(logAndPass('registration data saved')) + .then(() => this.reset()) + .then(logAndPass('app reset')) + .catch(console.error) + }, + + async saveRegistrationData() { + try { + const device = await finishRegistration(this.name, this.credential) + + logger.info('new device added', { device }) + + this.$emit('added', device) + } catch (err) { + logger.error('Error persisting webauthn registration', { error: err }) + throw new Error(t('settings', 'Server error while trying to complete WebAuthn device registration')) + } + }, + + reset() { + this.name = '' + this.registrationData = {} + this.step = RegistrationSteps.READY + }, + }, +} +</script> + +<style scoped lang="scss"> +.webauthn-loading { + display: inline-block; + vertical-align: sub; + margin-inline: 2px; +} + +.new-webauthn-device { + display: flex; + gap: 22px; + align-items: center; + + &__name { + max-width: min(100vw, 400px); + } +} +</style> diff --git a/apps/settings/src/components/WebAuthn/Device.vue b/apps/settings/src/components/WebAuthn/Device.vue new file mode 100644 index 00000000000..4e10c1f234d --- /dev/null +++ b/apps/settings/src/components/WebAuthn/Device.vue @@ -0,0 +1,49 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <li class="webauthn-device"> + <span class="icon-webauthn-device" /> + {{ name || t('settings', 'Unnamed device') }} + <NcActions :force-menu="true"> + <NcActionButton icon="icon-delete" @click="$emit('delete')"> + {{ t('settings', 'Delete') }} + </NcActionButton> + </NcActions> + </li> +</template> + +<script> +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' + +export default { + name: 'Device', + components: { + NcActionButton, + NcActions, + }, + props: { + name: { + type: String, + required: true, + }, + }, +} +</script> + +<style scoped> + .webauthn-device { + line-height: 300%; + display: flex; + } + + .icon-webauthn-device { + display: inline-block; + background-size: 100%; + padding: 3px; + margin: 3px; + } +</style> diff --git a/apps/settings/src/components/WebAuthn/Section.vue b/apps/settings/src/components/WebAuthn/Section.vue new file mode 100644 index 00000000000..fa818c24355 --- /dev/null +++ b/apps/settings/src/components/WebAuthn/Section.vue @@ -0,0 +1,114 @@ +<!-- + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div id="security-webauthn" class="section"> + <h2>{{ t('settings', 'Passwordless Authentication') }}</h2> + <p class="settings-hint hidden-when-empty"> + {{ t('settings', 'Set up your account for passwordless authentication following the FIDO2 standard.') }} + </p> + <NcNoteCard v-if="devices.length === 0" type="info"> + {{ t('settings', 'No devices configured.') }} + </NcNoteCard> + + <h3 v-else id="security-webauthn__active-devices"> + {{ t('settings', 'The following devices are configured for your account:') }} + </h3> + <ul aria-labelledby="security-webauthn__active-devices" class="security-webauthn__device-list"> + <Device v-for="device in sortedDevices" + :key="device.id" + :name="device.name" + @delete="deleteDevice(device.id)" /> + </ul> + + <NcNoteCard v-if="!supportsWebauthn" type="warning"> + {{ t('settings', 'Your browser does not support WebAuthn.') }} + </NcNoteCard> + + <AddDevice v-if="supportsWebauthn" + :is-https="isHttps" + :is-localhost="isLocalhost" + @added="deviceAdded" /> + </div> +</template> + +<script> +import { browserSupportsWebAuthn } from '@simplewebauthn/browser' +import { confirmPassword } from '@nextcloud/password-confirmation' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import sortBy from 'lodash/fp/sortBy.js' + +import AddDevice from './AddDevice.vue' +import Device from './Device.vue' +import logger from '../../logger.ts' +import { removeRegistration } from '../../service/WebAuthnRegistrationSerice.js' + +import '@nextcloud/password-confirmation/dist/style.css' + +const sortByName = sortBy('name') + +export default { + components: { + AddDevice, + Device, + NcNoteCard, + }, + props: { + initialDevices: { + type: Array, + required: true, + }, + isHttps: { + type: Boolean, + default: false, + }, + isLocalhost: { + type: Boolean, + default: false, + }, + }, + + setup() { + // Non reactive properties + return { + supportsWebauthn: browserSupportsWebAuthn(), + } + }, + + data() { + return { + devices: this.initialDevices, + } + }, + computed: { + sortedDevices() { + return sortByName(this.devices) + }, + }, + methods: { + deviceAdded(device) { + logger.debug(`adding new device to the list ${device.id}`) + + this.devices.push(device) + }, + async deleteDevice(id) { + logger.info(`deleting webauthn device ${id}`) + + await confirmPassword() + await removeRegistration(id) + + this.devices = this.devices.filter(d => d.id !== id) + + logger.info(`webauthn device ${id} removed successfully`) + }, + }, +} +</script> + +<style scoped> +.security-webauthn__device-list { + margin-block: 12px 18px; +} +</style> |