diff options
author | Roeland Jago Douma <roeland@famdouma.nl> | 2020-02-09 20:06:08 +0100 |
---|---|---|
committer | Roeland Jago Douma <roeland@famdouma.nl> | 2020-03-31 22:17:07 +0200 |
commit | 53db05a1f67fc974dba904ec158b2d67fa72df95 (patch) | |
tree | cc306fb0b96ccb8ee057af4a86be161aa1b76e2a /apps/settings/src | |
parent | f04f34b94b7e61f9d11fc07608d7eb2ae2163de8 (diff) | |
download | nextcloud-server-53db05a1f67fc974dba904ec158b2d67fa72df95.tar.gz nextcloud-server-53db05a1f67fc974dba904ec158b2d67fa72df95.zip |
Start with webauthn
Signed-off-by: Roeland Jago Douma <roeland@famdouma.nl>
Signed-off-by: npmbuildbot[bot] <npmbuildbot[bot]@users.noreply.github.com>
Diffstat (limited to 'apps/settings/src')
-rw-r--r-- | apps/settings/src/components/WebAuthn/AddDevice.vue | 215 | ||||
-rw-r--r-- | apps/settings/src/components/WebAuthn/Device.vue | 65 | ||||
-rw-r--r-- | apps/settings/src/components/WebAuthn/Section.vue | 109 | ||||
-rw-r--r-- | apps/settings/src/logger.js | 27 | ||||
-rw-r--r-- | apps/settings/src/main-personal-webauth.js | 40 | ||||
-rw-r--r-- | apps/settings/src/service/WebAuthnRegistrationSerice.js | 43 |
6 files changed, 499 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..05b649ec313 --- /dev/null +++ b/apps/settings/src/components/WebAuthn/AddDevice.vue @@ -0,0 +1,215 @@ +<!-- + - @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl> + - + - @author Roeland Jago Douma <roeland@famdouma.nl> + - + - @license GNU AGPL version 3 or any later version + - + - This program 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 + - along with this program. If not, see <http://www.gnu.org/licenses/>. + --> + +<template> + <div v-if="!isHttps"> + {{ t('settings', 'Passwordless authentication requires a secure connection.') }} + </div> + <div v-else> + <div v-if="step === RegistrationSteps.READY"> + <button @click="start"> + {{ t('settings', 'Add Webauthn device') }} + </button> + </div> + + <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" /> + <input v-model="name" + type="text" + :placeholder="t('settings', 'Name your device')" + @:keyup.enter="submit"> + <button @click="submit"> + {{ t('settings', 'Add') }} + </button> + </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 confirmPassword from '@nextcloud/password-confirmation' + +import logger from '../../logger' +import { + startRegistration, + finishRegistration, +} from '../../service/WebAuthnRegistrationSerice' + +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', + props: { + httpWarning: Boolean, + isHttps: { + type: Boolean, + default: false + } + }, + data() { + return { + name: '', + credential: {}, + RegistrationSteps, + step: RegistrationSteps.READY, + } + }, + methods: { + arrayToBase64String(a) { + return btoa(String.fromCharCode(...a)) + }, + start() { + this.step = RegistrationSteps.REGISTRATION + console.debug('Starting WebAuthn registration') + + return confirmPassword() + .then(this.getRegistrationData) + .then(this.register.bind(this)) + .then(() => { this.step = RegistrationSteps.NAMING }) + .catch(err => { + console.error(err.name, err.message) + this.step = RegistrationSteps.READY + }) + }, + + getRegistrationData() { + console.debug('Fetching webauthn registration data') + + const base64urlDecode = function(input) { + // Replace non-url compatible chars with base64 standard chars + input = input + .replace(/-/g, '+') + .replace(/_/g, '/') + + // Pad out with standard base64 required padding characters + const pad = input.length % 4 + if (pad) { + if (pad === 1) { + throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding') + } + input += new Array(5 - pad).join('=') + } + + return window.atob(input) + } + + return startRegistration() + .then(publicKey => { + console.debug(publicKey) + publicKey.challenge = Uint8Array.from(base64urlDecode(publicKey.challenge), c => c.charCodeAt(0)) + publicKey.user.id = Uint8Array.from(publicKey.user.id, c => c.charCodeAt(0)) + return publicKey + }) + .catch(err => { + console.error('Error getting webauthn registration data from server', err) + throw new Error(t('settings', 'Server error while trying to add webauthn device')) + }) + }, + + register(publicKey) { + console.debug('starting webauthn registration') + + return navigator.credentials.create({ publicKey }) + .then(data => { + this.credential = { + id: data.id, + type: data.type, + rawId: this.arrayToBase64String(new Uint8Array(data.rawId)), + response: { + clientDataJSON: this.arrayToBase64String(new Uint8Array(data.response.clientDataJSON)), + attestationObject: this.arrayToBase64String(new Uint8Array(data.response.attestationObject)), + }, + } + }) + }, + + 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.bind(this)) + }, + + async saveRegistrationData() { + try { + const device = await finishRegistration(this.name, JSON.stringify(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> + .webauthn-loading { + display: inline-block; + vertical-align: sub; + margin-left: 2px; + margin-right: 2px; + } + + .new-webauthn-device { + line-height: 300%; + } +</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..fc1bab3c8b0 --- /dev/null +++ b/apps/settings/src/components/WebAuthn/Device.vue @@ -0,0 +1,65 @@ +<!-- + - @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @license GNU AGPL version 3 or any later version + - + - This program 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 + - along with this program. If not, see <http://www.gnu.org/licenses/>. + --> + +<template> + <div class="webauthn-device"> + <span class="icon-webauthn-device" /> + {{ name || t('settings', 'Unnamed device') }} + <Actions :force-menu="true"> + <ActionButton icon="icon-delete" @click="$emit('delete')"> + {{ t('settings', 'Delete') }} + </ActionButton> + </Actions> + </div> +</template> + +<script> +import Actions from '@nextcloud/vue/dist/Components/Actions' +import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' + +export default { + name: 'Device', + components: { + ActionButton, + Actions, + }, + 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..cd09ec43c1a --- /dev/null +++ b/apps/settings/src/components/WebAuthn/Section.vue @@ -0,0 +1,109 @@ +<!-- + - @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl> + - + - @author Roeland Jago Douma <roeland@famdouma.nl> + - + - @license GNU AGPL version 3 or any later version + - + - This program 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 + - along with this program. If not, see <http://www.gnu.org/licenses/>. + --> + +<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> + <p v-if="devices.length === 0"> + {{ t('twofactor_u2f', 'No devices configured.') }} + </p> + <p v-else> + {{ t('twofactor_u2f', 'The following devices are configured for your account:') }} + </p> + <Device v-for="device in sortedDevices" + :key="device.id" + :name="device.name" + @delete="deleteDevice(device.id)" /> + + <p v-if="!hasPublicKeyCredential" class="warning"> + {{ t('settings', 'Your browser does not support Webauthn.') }} + </p> + + <AddDevice v-if="hasPublicKeyCredential" :isHttps="isHttps" @added="deviceAdded" /> + </div> +</template> + +<script> +import confirmPassword from '@nextcloud/password-confirmation' +import sortBy from 'lodash/fp/sortBy' + +import AddDevice from './AddDevice' +import Device from './Device' +import logger from '../../logger' +import { removeRegistration } from '../../service/WebAuthnRegistrationSerice' + +const sortByName = sortBy('name') + +export default { + components: { + AddDevice, + Device, + }, + props: { + initialDevices: { + type: Array, + required: true, + }, + isHttps: { + type: Boolean, + default: false, + }, + hasPublicKeyCredential: { + type: Boolean, + default: false, + }, + }, + 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> + +</style> diff --git a/apps/settings/src/logger.js b/apps/settings/src/logger.js new file mode 100644 index 00000000000..275771ce4c5 --- /dev/null +++ b/apps/settings/src/logger.js @@ -0,0 +1,27 @@ +/* + * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import { getLoggerBuilder } from '@nextcloud/logger' + +export default getLoggerBuilder() + .setApp('settings') + .detectUser() + .build() diff --git a/apps/settings/src/main-personal-webauth.js b/apps/settings/src/main-personal-webauth.js new file mode 100644 index 00000000000..e6e302df5f8 --- /dev/null +++ b/apps/settings/src/main-personal-webauth.js @@ -0,0 +1,40 @@ +/** + * @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl> + * + * @author Roeland Jago Douma <roeland@famdouma.nl> + * + * @license GNU AGPL version 3 or any later version + * + * This program 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import Vue from 'vue' +import { loadState } from '@nextcloud/initial-state' + +import WebAuthnSection from './components/WebAuthn/Section' + +// eslint-disable-next-line camelcase +__webpack_nonce__ = btoa(OC.requestToken) + +Vue.prototype.t = t + +const View = Vue.extend(WebAuthnSection) +const devices = loadState('settings', 'webauthn-devices') +new View({ + propsData: { + initialDevices: devices, + isHttps: window.location.protocol === 'https:', + hasPublicKeyCredential: typeof (window.PublicKeyCredential) !== 'undefined', + }, +}).$mount('#security-webauthn') diff --git a/apps/settings/src/service/WebAuthnRegistrationSerice.js b/apps/settings/src/service/WebAuthnRegistrationSerice.js new file mode 100644 index 00000000000..4c82c5b9fa7 --- /dev/null +++ b/apps/settings/src/service/WebAuthnRegistrationSerice.js @@ -0,0 +1,43 @@ +/** + * @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl> + * + * @author Roeland Jago Douma <roeland@famdouma.nl> + * + * @license GNU AGPL version 3 or any later version + * + * This program 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' + +export async function startRegistration() { + const url = generateUrl('/settings/api/personal/webauthn/registration') + + const resp = await axios.get(url) + return resp.data +} + +export async function finishRegistration(name, data) { + const url = generateUrl('/settings/api/personal/webauthn/registration') + + const resp = await axios.post(url, { name, data }) + return resp.data +} + +export async function removeRegistration(id) { + const url = generateUrl(`/settings/api/personal/webauthn/registration/${id}`) + + await axios.delete(url) +} |