diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-04-10 05:30:10 +0200 |
---|---|---|
committer | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-04-16 11:51:03 +0200 |
commit | 3880e4c8d71feaf9d53d368058ef40ffaf616194 (patch) | |
tree | 27f0936ec890b67fad7b581bbefad689d3c18ecc /apps/settings/src | |
parent | e8452d9ef1c590c4a228d246e37178b687761d71 (diff) | |
download | nextcloud-server-3880e4c8d71feaf9d53d368058ef40ffaf616194.tar.gz nextcloud-server-3880e4c8d71feaf9d53d368058ef40ffaf616194.zip |
fix: Use `@simplewebauthn` for frontend logic
This simplifies the code a lot and fixes errors with the exisiting custom code,
where slightly different base64 values were emitted which are not valid according to the standard.
ref: https://github.com/web-auth/webauthn-framework/issues/510
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Diffstat (limited to 'apps/settings/src')
-rw-r--r-- | apps/settings/src/components/WebAuthn/AddDevice.vue | 157 | ||||
-rw-r--r-- | apps/settings/src/components/WebAuthn/Device.vue | 4 | ||||
-rw-r--r-- | apps/settings/src/components/WebAuthn/Section.vue | 34 | ||||
-rw-r--r-- | apps/settings/src/main-personal-webauth.js | 1 | ||||
-rw-r--r-- | apps/settings/src/service/WebAuthnRegistrationSerice.ts (renamed from apps/settings/src/service/WebAuthnRegistrationSerice.js) | 41 |
5 files changed, 124 insertions, 113 deletions
diff --git a/apps/settings/src/components/WebAuthn/AddDevice.vue b/apps/settings/src/components/WebAuthn/AddDevice.vue index b9e9d087a0b..72077003cdd 100644 --- a/apps/settings/src/components/WebAuthn/AddDevice.vue +++ b/apps/settings/src/components/WebAuthn/AddDevice.vue @@ -24,11 +24,11 @@ {{ t('settings', 'Passwordless authentication requires a secure connection.') }} </div> <div v-else> - <div v-if="step === RegistrationSteps.READY"> - <NcButton @click="start" type="primary"> - {{ t('settings', 'Add WebAuthn device') }} - </NcButton> - </div> + <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"> @@ -39,13 +39,14 @@ <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"> - <NcButton @click="submit" type="primary"> - {{ t('settings', 'Add') }} - </NcButton> + <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" /> </div> <div v-else-if="step === RegistrationSteps.PERSIST" @@ -61,15 +62,16 @@ </template> <script> +import { showError } from '@nextcloud/dialogs' import { confirmPassword } from '@nextcloud/password-confirmation' import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import '@nextcloud/password-confirmation/dist/style.css' +import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' import logger from '../../logger.ts' import { startRegistration, finishRegistration, -} from '../../service/WebAuthnRegistrationSerice.js' +} from '../../service/WebAuthnRegistrationSerice.ts' const logAndPass = (text) => (data) => { logger.debug(text) @@ -88,6 +90,7 @@ export default { components: { NcButton, + NcTextField, }, props: { @@ -101,83 +104,55 @@ export default { default: false, }, }, + + setup() { + // non reactive props + return { + RegistrationSteps, + } + }, + data() { return { name: '', credential: {}, - RegistrationSteps, step: RegistrationSteps.READY, } }, - methods: { - arrayToBase64String(a) { - return btoa(String.fromCharCode(...a)) + + watch: { + /** + * Auto focus the name input when naming a device + */ + step() { + if (this.step === RegistrationSteps.NAMING) { + this.$nextTick(() => this.$refs.nameInput?.focus()) + } }, - start() { + }, + + 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') - 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) + try { + await confirmPassword() + this.credential = await startRegistration() + this.step = RegistrationSteps.NAMING + } catch (err) { + showError(err) + this.step = RegistrationSteps.READY } - - 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)), - }, - } - }) }, + /** + * Save the new device with the given name on the server + */ submit() { this.step = RegistrationSteps.PERSIST @@ -187,12 +162,12 @@ export default { .then(logAndPass('registration data saved')) .then(() => this.reset()) .then(logAndPass('app reset')) - .catch(console.error.bind(this)) + .catch(console.error) }, async saveRegistrationData() { try { - const device = await finishRegistration(this.name, JSON.stringify(this.credential)) + const device = await finishRegistration(this.name, this.credential) logger.info('new device added', { device }) @@ -212,15 +187,21 @@ export default { } </script> -<style scoped> - .webauthn-loading { - display: inline-block; - vertical-align: sub; - margin-left: 2px; - margin-right: 2px; - } +<style scoped lang="scss"> +.webauthn-loading { + display: inline-block; + vertical-align: sub; + margin-left: 2px; + margin-right: 2px; +} + +.new-webauthn-device { + display: flex; + gap: 22px; + align-items: center; - .new-webauthn-device { - line-height: 300%; + &__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 index 1de2661b8dc..319c99c3184 100644 --- a/apps/settings/src/components/WebAuthn/Device.vue +++ b/apps/settings/src/components/WebAuthn/Device.vue @@ -20,7 +20,7 @@ --> <template> - <div class="webauthn-device"> + <li class="webauthn-device"> <span class="icon-webauthn-device" /> {{ name || t('settings', 'Unnamed device') }} <NcActions :force-menu="true"> @@ -28,7 +28,7 @@ {{ t('settings', 'Delete') }} </NcActionButton> </NcActions> - </div> + </li> </template> <script> diff --git a/apps/settings/src/components/WebAuthn/Section.vue b/apps/settings/src/components/WebAuthn/Section.vue index 2f5c840bdcc..7e9c75b9264 100644 --- a/apps/settings/src/components/WebAuthn/Section.vue +++ b/apps/settings/src/components/WebAuthn/Section.vue @@ -28,19 +28,22 @@ <NcNoteCard v-if="devices.length === 0" type="info"> {{ t('settings', 'No devices configured.') }} </NcNoteCard> - <h3 v-else> + + <h3 v-else id="security-webauthn__active-devices"> {{ t('settings', 'The following devices are configured for your account:') }} </h3> - <Device v-for="device in sortedDevices" - :key="device.id" - :name="device.name" - @delete="deleteDevice(device.id)" /> + <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="!hasPublicKeyCredential" type="warning"> + <NcNoteCard v-if="!supportsWebauthn" type="warning"> {{ t('settings', 'Your browser does not support WebAuthn.') }} </NcNoteCard> - <AddDevice v-if="hasPublicKeyCredential" + <AddDevice v-if="supportsWebauthn" :is-https="isHttps" :is-localhost="isLocalhost" @added="deviceAdded" /> @@ -48,6 +51,7 @@ </template> <script> +import { browserSupportsWebAuthn } from '@simplewebauthn/browser' import { confirmPassword } from '@nextcloud/password-confirmation' import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' import '@nextcloud/password-confirmation/dist/style.css' @@ -79,11 +83,15 @@ export default { type: Boolean, default: false, }, - hasPublicKeyCredential: { - type: Boolean, - default: false, - }, }, + + setup() { + // Non reactive properties + return { + supportsWebauthn: browserSupportsWebAuthn(), + } + }, + data() { return { devices: this.initialDevices, @@ -115,5 +123,7 @@ export default { </script> <style scoped> - +.security-webauthn__device-list { + margin-block: 12px 18px; +} </style> diff --git a/apps/settings/src/main-personal-webauth.js b/apps/settings/src/main-personal-webauth.js index dc11ecdbba2..edbdde6ea27 100644 --- a/apps/settings/src/main-personal-webauth.js +++ b/apps/settings/src/main-personal-webauth.js @@ -37,6 +37,5 @@ new View({ initialDevices: devices, isHttps: window.location.protocol === 'https:', isLocalhost: window.location.hostname === 'localhost', - hasPublicKeyCredential: typeof (window.PublicKeyCredential) !== 'undefined', }, }).$mount('#security-webauthn') diff --git a/apps/settings/src/service/WebAuthnRegistrationSerice.js b/apps/settings/src/service/WebAuthnRegistrationSerice.ts index 185dbd8cf28..f95395e865a 100644 --- a/apps/settings/src/service/WebAuthnRegistrationSerice.js +++ b/apps/settings/src/service/WebAuthnRegistrationSerice.ts @@ -20,34 +20,55 @@ * */ -import axios from '@nextcloud/axios' +import type { RegistrationResponseJSON } from '@simplewebauthn/types' + +import { translate as t } from '@nextcloud/l10n' import { generateUrl } from '@nextcloud/router' +import { startRegistration as registerWebAuthn } from '@simplewebauthn/browser' + +import Axios from 'axios' +import axios from '@nextcloud/axios' +import logger from '../logger' /** - * + * Start registering a new device + * @return The device attributes */ export async function startRegistration() { const url = generateUrl('/settings/api/personal/webauthn/registration') - const resp = await axios.get(url) - return resp.data + try { + logger.debug('Fetching webauthn registration data') + const { data } = await axios.get(url) + logger.debug('Start webauthn registration') + const attrs = await registerWebAuthn(data) + return attrs + } catch (e) { + logger.error(e as Error) + if (Axios.isAxiosError(e)) { + throw new Error(t('settings', 'Could not register device: Network error')) + } else if ((e as Error).name === 'InvalidStateError') { + throw new Error(t('settings', 'Could not register device: Probably already registered')) + } + throw new Error(t('settings', 'Could not register device')) + } } /** - * @param {any} name - - * @param {any} data - + * @param name Name of the device + * @param data Device attributes */ -export async function finishRegistration(name, data) { +export async function finishRegistration(name: string, data: RegistrationResponseJSON) { const url = generateUrl('/settings/api/personal/webauthn/registration') - const resp = await axios.post(url, { name, data }) + const resp = await axios.post(url, { name, data: JSON.stringify(data) }) return resp.data } /** - * @param {any} id - + * @param id Remove registered device with that id */ -export async function removeRegistration(id) { +export async function removeRegistration(id: string | number) { const url = generateUrl(`/settings/api/personal/webauthn/registration/${id}`) await axios.delete(url) |