Skip to content

Commit

Permalink
fix: Use @simplewebauthn for frontend logic
Browse files Browse the repository at this point in the history
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: web-auth/webauthn-framework#510

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
  • Loading branch information
susnux committed Apr 10, 2024
1 parent 4b8b030 commit 6740b3f
Show file tree
Hide file tree
Showing 11 changed files with 224 additions and 269 deletions.
157 changes: 69 additions & 88 deletions apps/settings/src/components/WebAuthn/AddDevice.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand All @@ -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"
Expand All @@ -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)
Expand All @@ -88,6 +90,7 @@ export default {

components: {
NcButton,
NcTextField,
},

props: {
Expand All @@ -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

Expand All @@ -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 })

Expand All @@ -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>
4 changes: 2 additions & 2 deletions apps/settings/src/components/WebAuthn/Device.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@
-->

<template>
<div class="webauthn-device">
<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>
</div>
</li>
</template>

<script>
Expand Down
34 changes: 22 additions & 12 deletions apps/settings/src/components/WebAuthn/Section.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,26 +28,30 @@
<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" />
</div>
</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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -115,5 +123,7 @@ export default {
</script>

<style scoped>

.security-webauthn__device-list {
margin-block: 12px 18px;
}
</style>
1 change: 0 additions & 1 deletion apps/settings/src/main-personal-webauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 6740b3f

Please sign in to comment.