mirror of https://github.com/nextcloud/server
Merge pull request #44761 from nextcloud/fix/deps-webauthn-lib
fix(deps): Bump web-auth/webauthn-lib from 3.3.9 to 4.8.5
This commit is contained in:
commit
7eec3b5a72
2
3rdparty
2
3rdparty
|
@ -1 +1 @@
|
|||
Subproject commit e2747858e408e4d9dde72a8a7cf99f2d7f750d98
|
||||
Subproject commit 202c6195d28ac55f08e5b3c31a95fff6a7093659
|
|
@ -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)
|
||||
}
|
|
@ -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', []),
|
||||
}
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -83,14 +83,14 @@ class Manager {
|
|||
public function startRegistration(IUser $user, string $serverHost): PublicKeyCredentialCreationOptions {
|
||||
$rpEntity = new PublicKeyCredentialRpEntity(
|
||||
'Nextcloud', //Name
|
||||
$this->stripPort($serverHost), //ID
|
||||
$this->stripPort($serverHost), //ID
|
||||
null //Icon
|
||||
);
|
||||
|
||||
$userEntity = new PublicKeyCredentialUserEntity(
|
||||
$user->getUID(), //Name
|
||||
$user->getUID(), //ID
|
||||
$user->getDisplayName() //Display name
|
||||
$user->getUID(), // Name
|
||||
$user->getUID(), // ID
|
||||
$user->getDisplayName() // Display name
|
||||
// 'https://foo.example.co/avatar/123e4567-e89b-12d3-a456-426655440000' //Icon
|
||||
);
|
||||
|
||||
|
@ -107,9 +107,10 @@ class Manager {
|
|||
];
|
||||
|
||||
$authenticatorSelectionCriteria = new AuthenticatorSelectionCriteria(
|
||||
null,
|
||||
AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_DISCOURAGED,
|
||||
null,
|
||||
false,
|
||||
AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_DISCOURAGED
|
||||
);
|
||||
|
||||
return new PublicKeyCredentialCreationOptions(
|
||||
|
@ -117,11 +118,10 @@ class Manager {
|
|||
$userEntity,
|
||||
$challenge,
|
||||
$publicKeyCredentialParametersList,
|
||||
$timeout,
|
||||
$excludedPublicKeyDescriptors,
|
||||
$authenticatorSelectionCriteria,
|
||||
PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
|
||||
null
|
||||
$excludedPublicKeyDescriptors,
|
||||
$timeout,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -149,7 +149,7 @@ class Manager {
|
|||
try {
|
||||
// Load the data
|
||||
$publicKeyCredential = $publicKeyCredentialLoader->load($data);
|
||||
$response = $publicKeyCredential->getResponse();
|
||||
$response = $publicKeyCredential->response;
|
||||
|
||||
// Check if the response is an Authenticator Attestation Response
|
||||
if (!$response instanceof AuthenticatorAttestationResponse) {
|
||||
|
@ -162,7 +162,9 @@ class Manager {
|
|||
$publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check(
|
||||
$response,
|
||||
$publicKeyCredentialCreationOptions,
|
||||
$request);
|
||||
$request,
|
||||
['localhost'],
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
throw $exception;
|
||||
}
|
||||
|
@ -180,18 +182,18 @@ class Manager {
|
|||
$registeredPublicKeyCredentialDescriptors = array_map(function (PublicKeyCredentialEntity $entity) {
|
||||
$credential = $entity->toPublicKeyCredentialSource();
|
||||
return new PublicKeyCredentialDescriptor(
|
||||
$credential->getType(),
|
||||
$credential->getPublicKeyCredentialId()
|
||||
$credential->type,
|
||||
$credential->publicKeyCredentialId,
|
||||
);
|
||||
}, $this->credentialMapper->findAllForUid($uid));
|
||||
|
||||
// Public Key Credential Request Options
|
||||
return new PublicKeyCredentialRequestOptions(
|
||||
random_bytes(32), // Challenge
|
||||
60000, // Timeout
|
||||
$this->stripPort($serverHost), // Relying Party ID
|
||||
$registeredPublicKeyCredentialDescriptors, // Registered PublicKeyCredentialDescriptor classes
|
||||
AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_DISCOURAGED
|
||||
random_bytes(32), // Challenge
|
||||
$this->stripPort($serverHost), // Relying Party ID
|
||||
$registeredPublicKeyCredentialDescriptors, // Registered PublicKeyCredentialDescriptor classes
|
||||
AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_DISCOURAGED,
|
||||
60000, // Timeout
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -213,16 +215,15 @@ class Manager {
|
|||
$tokenBindingHandler,
|
||||
$extensionOutputCheckerHandler,
|
||||
$algorithmManager,
|
||||
null,
|
||||
$this->logger,
|
||||
);
|
||||
$authenticatorAssertionResponseValidator->setLogger($this->logger);
|
||||
|
||||
try {
|
||||
$this->logger->debug('Loading publickey credentials from: ' . $data);
|
||||
|
||||
// Load the data
|
||||
$publicKeyCredential = $publicKeyCredentialLoader->load($data);
|
||||
$response = $publicKeyCredential->getResponse();
|
||||
$response = $publicKeyCredential->response;
|
||||
|
||||
// Check if the response is an Authenticator Attestation Response
|
||||
if (!$response instanceof AuthenticatorAssertionResponse) {
|
||||
|
@ -233,18 +234,17 @@ class Manager {
|
|||
$request = ServerRequest::fromGlobals();
|
||||
|
||||
$publicKeyCredentialSource = $authenticatorAssertionResponseValidator->check(
|
||||
$publicKeyCredential->getRawId(),
|
||||
$publicKeyCredential->rawId,
|
||||
$response,
|
||||
$publicKeyCredentialRequestOptions,
|
||||
$request,
|
||||
$uid
|
||||
$uid,
|
||||
['localhost'],
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ declare(strict_types=1);
|
|||
*
|
||||
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
* @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
|
||||
|
@ -31,6 +31,7 @@ use OCP\ILogger;
|
|||
use OCP\Log\IDataLogger;
|
||||
use Psr\Log\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Stringable;
|
||||
use Throwable;
|
||||
use function array_key_exists;
|
||||
use function array_merge;
|
||||
|
@ -52,19 +53,20 @@ final class PsrLoggerAdapter implements LoggerInterface, IDataLogger {
|
|||
/**
|
||||
* System is unusable.
|
||||
*
|
||||
* @param string $message
|
||||
* @param string|Stringable $message
|
||||
* @param mixed[] $context
|
||||
*/
|
||||
public function emergency($message, array $context = []): void {
|
||||
public function emergency(string|Stringable $message, array $context = []): void {
|
||||
if ($this->containsThrowable($context)) {
|
||||
$this->logger->logException($context['exception'], array_merge(
|
||||
[
|
||||
'message' => $message,
|
||||
'message' => (string)$message,
|
||||
'level' => ILogger::FATAL,
|
||||
],
|
||||
$context
|
||||
));
|
||||
} else {
|
||||
$this->logger->emergency($message, $context);
|
||||
$this->logger->emergency((string)$message, $context);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -74,19 +76,20 @@ final class PsrLoggerAdapter implements LoggerInterface, IDataLogger {
|
|||
* Example: Entire website down, database unavailable, etc. This should
|
||||
* trigger the SMS alerts and wake you up.
|
||||
*
|
||||
* @param string $message
|
||||
* @param string|Stringable $message
|
||||
* @param mixed[] $context
|
||||
*/
|
||||
public function alert($message, array $context = []): void {
|
||||
public function alert(string|Stringable $message, array $context = []): void {
|
||||
if ($this->containsThrowable($context)) {
|
||||
$this->logger->logException($context['exception'], array_merge(
|
||||
[
|
||||
'message' => $message,
|
||||
'message' => (string)$message,
|
||||
'level' => ILogger::ERROR,
|
||||
],
|
||||
$context
|
||||
));
|
||||
} else {
|
||||
$this->logger->alert($message, $context);
|
||||
$this->logger->alert((string)$message, $context);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -95,19 +98,20 @@ final class PsrLoggerAdapter implements LoggerInterface, IDataLogger {
|
|||
*
|
||||
* Example: Application component unavailable, unexpected exception.
|
||||
*
|
||||
* @param string $message
|
||||
* @param string|Stringable $message
|
||||
* @param mixed[] $context
|
||||
*/
|
||||
public function critical($message, array $context = []): void {
|
||||
public function critical(string|Stringable $message, array $context = []): void {
|
||||
if ($this->containsThrowable($context)) {
|
||||
$this->logger->logException($context['exception'], array_merge(
|
||||
[
|
||||
'message' => $message,
|
||||
'message' => (string)$message,
|
||||
'level' => ILogger::ERROR,
|
||||
],
|
||||
$context
|
||||
));
|
||||
} else {
|
||||
$this->logger->critical($message, $context);
|
||||
$this->logger->critical((string)$message, $context);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -115,19 +119,20 @@ final class PsrLoggerAdapter implements LoggerInterface, IDataLogger {
|
|||
* Runtime errors that do not require immediate action but should typically
|
||||
* be logged and monitored.
|
||||
*
|
||||
* @param string $message
|
||||
* @param string|Stringable $message
|
||||
* @param mixed[] $context
|
||||
*/
|
||||
public function error($message, array $context = []): void {
|
||||
public function error(string|Stringable $message, array $context = []): void {
|
||||
if ($this->containsThrowable($context)) {
|
||||
$this->logger->logException($context['exception'], array_merge(
|
||||
[
|
||||
'message' => $message,
|
||||
'message' => (string)$message,
|
||||
'level' => ILogger::ERROR,
|
||||
],
|
||||
$context
|
||||
));
|
||||
} else {
|
||||
$this->logger->error($message, $context);
|
||||
$this->logger->error((string)$message, $context);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -137,38 +142,40 @@ final class PsrLoggerAdapter implements LoggerInterface, IDataLogger {
|
|||
* Example: Use of deprecated APIs, poor use of an API, undesirable things
|
||||
* that are not necessarily wrong.
|
||||
*
|
||||
* @param string $message
|
||||
* @param string|Stringable $message
|
||||
* @param mixed[] $context
|
||||
*/
|
||||
public function warning($message, array $context = []): void {
|
||||
public function warning(string|Stringable $message, array $context = []): void {
|
||||
if ($this->containsThrowable($context)) {
|
||||
$this->logger->logException($context['exception'], array_merge(
|
||||
[
|
||||
'message' => $message,
|
||||
'message' => (string)$message,
|
||||
'level' => ILogger::WARN,
|
||||
],
|
||||
$context
|
||||
));
|
||||
} else {
|
||||
$this->logger->warning($message, $context);
|
||||
$this->logger->warning((string)$message, $context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normal but significant events.
|
||||
*
|
||||
* @param string $message
|
||||
* @param string|Stringable $message
|
||||
* @param mixed[] $context
|
||||
*/
|
||||
public function notice($message, array $context = []): void {
|
||||
public function notice(string|Stringable $message, array $context = []): void {
|
||||
if ($this->containsThrowable($context)) {
|
||||
$this->logger->logException($context['exception'], array_merge(
|
||||
[
|
||||
'message' => $message,
|
||||
'message' => (string)$message,
|
||||
'level' => ILogger::INFO,
|
||||
],
|
||||
$context
|
||||
));
|
||||
} else {
|
||||
$this->logger->notice($message, $context);
|
||||
$this->logger->notice((string)$message, $context);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -177,38 +184,40 @@ final class PsrLoggerAdapter implements LoggerInterface, IDataLogger {
|
|||
*
|
||||
* Example: User logs in, SQL logs.
|
||||
*
|
||||
* @param string $message
|
||||
* @param string|Stringable $message
|
||||
* @param mixed[] $context
|
||||
*/
|
||||
public function info($message, array $context = []): void {
|
||||
public function info(string|Stringable $message, array $context = []): void {
|
||||
if ($this->containsThrowable($context)) {
|
||||
$this->logger->logException($context['exception'], array_merge(
|
||||
[
|
||||
'message' => $message,
|
||||
'message' => (string)$message,
|
||||
'level' => ILogger::INFO,
|
||||
],
|
||||
$context
|
||||
));
|
||||
} else {
|
||||
$this->logger->info($message, $context);
|
||||
$this->logger->info((string)$message, $context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed debug information.
|
||||
*
|
||||
* @param string $message
|
||||
* @param string|Stringable $message
|
||||
* @param mixed[] $context
|
||||
*/
|
||||
public function debug($message, array $context = []): void {
|
||||
public function debug(string|Stringable $message, array $context = []): void {
|
||||
if ($this->containsThrowable($context)) {
|
||||
$this->logger->logException($context['exception'], array_merge(
|
||||
[
|
||||
'message' => $message,
|
||||
'message' => (string)$message,
|
||||
'level' => ILogger::DEBUG,
|
||||
],
|
||||
$context
|
||||
));
|
||||
} else {
|
||||
$this->logger->debug($message, $context);
|
||||
$this->logger->debug((string)$message, $context);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -216,24 +225,25 @@ final class PsrLoggerAdapter implements LoggerInterface, IDataLogger {
|
|||
* Logs with an arbitrary level.
|
||||
*
|
||||
* @param mixed $level
|
||||
* @param string $message
|
||||
* @param string|Stringable $message
|
||||
* @param mixed[] $context
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function log($level, $message, array $context = []): void {
|
||||
public function log($level, string|Stringable $message, array $context = []): void {
|
||||
if (!is_int($level) || $level < ILogger::DEBUG || $level > ILogger::FATAL) {
|
||||
throw new InvalidArgumentException('Nextcloud allows only integer log levels');
|
||||
}
|
||||
if ($this->containsThrowable($context)) {
|
||||
$this->logger->logException($context['exception'], array_merge(
|
||||
[
|
||||
'message' => $message,
|
||||
'message' => (string)$message,
|
||||
'level' => $level,
|
||||
],
|
||||
$context
|
||||
));
|
||||
} else {
|
||||
$this->logger->log($level, $message, $context);
|
||||
$this->logger->log($level, (string)$message, $context);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 New Issue