mirror of
https://github.com/nextcloud/server.git
synced 2026-04-15 22:11:17 -04:00
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>
This commit is contained in:
parent
e8452d9ef1
commit
3880e4c8d7
11 changed files with 224 additions and 269 deletions
|
|
@ -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 {
|
||||
line-height: 300%;
|
||||
.new-webauthn-device {
|
||||
display: flex;
|
||||
gap: 22px;
|
||||
align-items: center;
|
||||
|
||||
&__name {
|
||||
max-width: min(100vw, 400px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<form v-if="(isHttps || isLocalhost) && hasPublicKeyCredential"
|
||||
<form v-if="(isHttps || isLocalhost) && supportsWebauthn"
|
||||
ref="loginForm"
|
||||
method="post"
|
||||
name="login"
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
@click="authenticate" />
|
||||
</fieldset>
|
||||
</form>
|
||||
<div v-else-if="!hasPublicKeyCredential" class="update">
|
||||
<div v-else-if="!supportsWebauthn" class="update">
|
||||
<InformationIcon size="70" />
|
||||
<h2>{{ t('core', 'Browser not supported') }}</h2>
|
||||
<p class="infogroup">
|
||||
|
|
@ -37,18 +37,16 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { browserSupportsWebAuthn } from '@simplewebauthn/browser'
|
||||
import {
|
||||
startAuthentication,
|
||||
finishAuthentication,
|
||||
} from '../../services/WebAuthnAuthenticationService.js'
|
||||
} from '../../services/WebAuthnAuthenticationService.ts'
|
||||
import LoginButton from './LoginButton.vue'
|
||||
import InformationIcon from 'vue-material-design-icons/Information.vue'
|
||||
import LockOpenIcon from 'vue-material-design-icons/LockOpen.vue'
|
||||
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
|
||||
|
||||
class NoValidCredentials extends Error {
|
||||
|
||||
}
|
||||
import logger from '../../logger'
|
||||
|
||||
export default {
|
||||
name: 'PasswordLessLoginForm',
|
||||
|
|
@ -79,11 +77,14 @@ export default {
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasPublicKeyCredential: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
return {
|
||||
supportsWebauthn: browserSupportsWebAuthn(),
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
user: this.username,
|
||||
|
|
@ -92,7 +93,7 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
authenticate() {
|
||||
async authenticate() {
|
||||
// check required fields
|
||||
if (!this.$refs.loginForm.checkValidity()) {
|
||||
return
|
||||
|
|
@ -100,112 +101,25 @@ export default {
|
|||
|
||||
console.debug('passwordless login initiated')
|
||||
|
||||
this.getAuthenticationData(this.user)
|
||||
.then(publicKey => {
|
||||
console.debug(publicKey)
|
||||
return publicKey
|
||||
})
|
||||
.then(this.sign)
|
||||
.then(this.completeAuthentication)
|
||||
.catch(error => {
|
||||
if (error instanceof NoValidCredentials) {
|
||||
this.validCredentials = false
|
||||
return
|
||||
}
|
||||
console.debug(error)
|
||||
})
|
||||
try {
|
||||
const params = await startAuthentication(this.user)
|
||||
await this.completeAuthentication(params)
|
||||
} catch (error) {
|
||||
if (error instanceof NoValidCredentials) {
|
||||
this.validCredentials = false
|
||||
return
|
||||
}
|
||||
logger.debug(error)
|
||||
}
|
||||
},
|
||||
changeUsername(username) {
|
||||
this.user = username
|
||||
this.$emit('update:username', this.user)
|
||||
},
|
||||
getAuthenticationData(uid) {
|
||||
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 startAuthentication(uid)
|
||||
.then(publicKey => {
|
||||
console.debug('Obtained PublicKeyCredentialRequestOptions')
|
||||
console.debug(publicKey)
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(publicKey, 'allowCredentials')) {
|
||||
console.debug('No credentials found.')
|
||||
throw new NoValidCredentials()
|
||||
}
|
||||
|
||||
publicKey.challenge = Uint8Array.from(base64urlDecode(publicKey.challenge), c => c.charCodeAt(0))
|
||||
publicKey.allowCredentials = publicKey.allowCredentials.map(function(data) {
|
||||
return {
|
||||
...data,
|
||||
id: Uint8Array.from(base64urlDecode(data.id), c => c.charCodeAt(0)),
|
||||
}
|
||||
})
|
||||
|
||||
console.debug('Converted PublicKeyCredentialRequestOptions')
|
||||
console.debug(publicKey)
|
||||
return publicKey
|
||||
})
|
||||
.catch(error => {
|
||||
console.debug('Error while obtaining data')
|
||||
throw error
|
||||
})
|
||||
},
|
||||
sign(publicKey) {
|
||||
const arrayToBase64String = function(a) {
|
||||
return window.btoa(String.fromCharCode(...a))
|
||||
}
|
||||
|
||||
const arrayToString = function(a) {
|
||||
return String.fromCharCode(...a)
|
||||
}
|
||||
|
||||
return navigator.credentials.get({ publicKey })
|
||||
.then(data => {
|
||||
console.debug(data)
|
||||
console.debug(new Uint8Array(data.rawId))
|
||||
console.debug(arrayToBase64String(new Uint8Array(data.rawId)))
|
||||
return {
|
||||
id: data.id,
|
||||
type: data.type,
|
||||
rawId: arrayToBase64String(new Uint8Array(data.rawId)),
|
||||
response: {
|
||||
authenticatorData: arrayToBase64String(new Uint8Array(data.response.authenticatorData)),
|
||||
clientDataJSON: arrayToBase64String(new Uint8Array(data.response.clientDataJSON)),
|
||||
signature: arrayToBase64String(new Uint8Array(data.response.signature)),
|
||||
userHandle: data.response.userHandle ? arrayToString(new Uint8Array(data.response.userHandle)) : null,
|
||||
},
|
||||
}
|
||||
})
|
||||
.then(challenge => {
|
||||
console.debug(challenge)
|
||||
return challenge
|
||||
})
|
||||
.catch(error => {
|
||||
console.debug('GOT AN ERROR!')
|
||||
console.debug(error) // Example: timeout, interaction refused...
|
||||
})
|
||||
},
|
||||
completeAuthentication(challenge) {
|
||||
console.debug('TIME TO COMPLETE')
|
||||
|
||||
const redirectUrl = this.redirectUrl
|
||||
|
||||
return finishAuthentication(JSON.stringify(challenge))
|
||||
return finishAuthentication(challenge)
|
||||
.then(({ defaultRedirectUrl }) => {
|
||||
console.debug('Logged in redirecting')
|
||||
// Redirect url might be false so || should be used instead of ??.
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
/**
|
||||
* @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
|
||||
*
|
||||
* @author Roeland Jago Douma <roeland@famdouma.nl>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* 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'
|
||||
|
||||
/**
|
||||
* @param {any} loginName -
|
||||
*/
|
||||
export function startAuthentication(loginName) {
|
||||
const url = generateUrl('/login/webauthn/start')
|
||||
|
||||
return Axios.post(url, { loginName })
|
||||
.then(resp => resp.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} data -
|
||||
*/
|
||||
export function finishAuthentication(data) {
|
||||
const url = generateUrl('/login/webauthn/finish')
|
||||
|
||||
return Axios.post(url, { data })
|
||||
.then(resp => resp.data)
|
||||
}
|
||||
59
core/src/services/WebAuthnAuthenticationService.ts
Normal file
59
core/src/services/WebAuthnAuthenticationService.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
|
||||
*
|
||||
* @author Roeland Jago Douma <roeland@famdouma.nl>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* 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 type { AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types'
|
||||
|
||||
import { startAuthentication as startWebauthnAuthentication } from '@simplewebauthn/browser'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
|
||||
import Axios from '@nextcloud/axios'
|
||||
import logger from '../logger'
|
||||
|
||||
export class NoValidCredentials extends Error {}
|
||||
|
||||
/**
|
||||
* Start webautn authentication
|
||||
* This loads the challenge, connects to the authenticator and returns the repose that needs to be sent to the server.
|
||||
*
|
||||
* @param loginName Name to login
|
||||
*/
|
||||
export async function startAuthentication(loginName: string) {
|
||||
const url = generateUrl('/login/webauthn/start')
|
||||
|
||||
const { data } = await Axios.post<PublicKeyCredentialRequestOptionsJSON>(url, { loginName })
|
||||
if (!data.allowCredentials || data.allowCredentials.length === 0) {
|
||||
logger.error('No valid credentials returned for webauthn')
|
||||
throw new NoValidCredentials()
|
||||
}
|
||||
return await startWebauthnAuthentication(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify webauthn authentication
|
||||
* @param authData The authentication data to sent to the server
|
||||
*/
|
||||
export async function finishAuthentication(authData: AuthenticationResponseJSON) {
|
||||
const url = generateUrl('/login/webauthn/finish')
|
||||
|
||||
const { data } = await Axios.post(url, { data: JSON.stringify(authData) })
|
||||
return data
|
||||
}
|
||||
|
|
@ -73,7 +73,6 @@
|
|||
:auto-complete-allowed="autoCompleteAllowed"
|
||||
:is-https="isHttps"
|
||||
:is-localhost="isLocalhost"
|
||||
:has-public-key-credential="hasPublicKeyCredential"
|
||||
@submit="loading = true" />
|
||||
<NcButton type="tertiary"
|
||||
:aria-label="t('core', 'Back to login form')"
|
||||
|
|
@ -178,7 +177,6 @@ export default {
|
|||
alternativeLogins: loadState('core', 'alternativeLogins', []),
|
||||
isHttps: window.location.protocol === 'https:',
|
||||
isLocalhost: window.location.hostname === 'localhost',
|
||||
hasPublicKeyCredential: typeof (window.PublicKeyCredential) !== 'undefined',
|
||||
hideLoginForm: loadState('core', 'hideLoginForm', false),
|
||||
emailStates: loadState('core', 'emailStates', []),
|
||||
}
|
||||
|
|
|
|||
15
package-lock.json
generated
15
package-lock.json
generated
|
|
@ -31,6 +31,7 @@
|
|||
"@nextcloud/sharing": "^0.1.0",
|
||||
"@nextcloud/upload": "^1.1.1",
|
||||
"@nextcloud/vue": "^8.11.2",
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@skjnldsv/sanitize-svg": "^1.0.2",
|
||||
"@vueuse/components": "^10.7.2",
|
||||
"@vueuse/core": "^10.7.2",
|
||||
|
|
@ -103,6 +104,7 @@
|
|||
"@nextcloud/typings": "^1.8.0",
|
||||
"@nextcloud/webpack-vue-config": "^6.0.1",
|
||||
"@pinia/testing": "^0.1.2",
|
||||
"@simplewebauthn/types": "^9.0.1",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@testing-library/vue": "^5.8.3",
|
||||
|
|
@ -5061,6 +5063,19 @@
|
|||
"integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-9.0.1.tgz",
|
||||
"integrity": "sha512-wD2WpbkaEP4170s13/HUxPcAV5y4ZXaKo1TfNklS5zDefPinIgXOpgz1kpEvobAsaLPa2KeH7AKKX/od1mrBJw==",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/types": "^9.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@simplewebauthn/types": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-9.0.1.tgz",
|
||||
"integrity": "sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w=="
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.27.8",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@
|
|||
"@nextcloud/sharing": "^0.1.0",
|
||||
"@nextcloud/upload": "^1.1.1",
|
||||
"@nextcloud/vue": "^8.11.2",
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@skjnldsv/sanitize-svg": "^1.0.2",
|
||||
"@vueuse/components": "^10.7.2",
|
||||
"@vueuse/core": "^10.7.2",
|
||||
|
|
@ -130,6 +131,7 @@
|
|||
"@nextcloud/typings": "^1.8.0",
|
||||
"@nextcloud/webpack-vue-config": "^6.0.1",
|
||||
"@pinia/testing": "^0.1.2",
|
||||
"@simplewebauthn/types": "^9.0.1",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@testing-library/vue": "^5.8.3",
|
||||
|
|
|
|||
Loading…
Reference in a new issue