fix(deps): Bump web-auth/webauthn-lib from 3.3.9 to 4.8.5pull/44849/head
@@ -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 { | |||
display: flex; | |||
gap: 22px; | |||
align-items: center; | |||
.new-webauthn-device { | |||
line-height: 300%; | |||
&__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', []), | |||
} |
@@ -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", |