Merge pull request #19858 from nextcloud/feature/webauthn

Add WebAuthn support
This commit is contained in:
Roeland Jago Douma 2020-03-31 22:55:13 +02:00 committed by GitHub
commit 590849e4d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
67 changed files with 2524 additions and 309 deletions

@ -1 +1 @@
Subproject commit 179b231245bbae294d021b7158f99c3ffe7e2cb6
Subproject commit 7375853f9f77a5c2a82a23bf7bbaf4217be92450

View File

@ -35,6 +35,7 @@
<personal>OCA\Settings\Settings\Personal\Security\Authtokens</personal>
<personal>OCA\Settings\Settings\Personal\Security\Password</personal>
<personal>OCA\Settings\Settings\Personal\Security\TwoFactor</personal>
<personal>OCA\Settings\Settings\Personal\Security\WebAuthn</personal>
<personal-section>OCA\Settings\Sections\Personal\PersonalInfo</personal-section>
<personal-section>OCA\Settings\Sections\Personal\Security</personal-section>
<personal-section>OCA\Settings\Sections\Personal\SyncClients</personal-section>

View File

@ -90,5 +90,9 @@ $application->registerRoutes($this, [
['name' => 'TwoFactorSettings#update', 'url' => '/settings/api/admin/twofactorauth', 'verb' => 'PUT'],
['name' => 'Help#help', 'url' => '/settings/help/{mode}', 'verb' => 'GET', 'defaults' => ['mode' => '']],
['name' => 'WebAuthn#startRegistration', 'url' => '/settings/api/personal/webauthn/registration', 'verb' => 'GET'],
['name' => 'WebAuthn#finishRegistration', 'url' => '/settings/api/personal/webauthn/registration', 'verb' => 'POST'],
['name' => 'WebAuthn#deleteRegistration', 'url' => '/settings/api/personal/webauthn/registration/{id}', 'verb' => 'DELETE'],
]
]);

View File

@ -28,6 +28,7 @@ return array(
'OCA\\Settings\\Controller\\PersonalSettingsController' => $baseDir . '/../lib/Controller/PersonalSettingsController.php',
'OCA\\Settings\\Controller\\TwoFactorSettingsController' => $baseDir . '/../lib/Controller/TwoFactorSettingsController.php',
'OCA\\Settings\\Controller\\UsersController' => $baseDir . '/../lib/Controller/UsersController.php',
'OCA\\Settings\\Controller\\WebAuthnController' => $baseDir . '/../lib/Controller/WebAuthnController.php',
'OCA\\Settings\\Hooks' => $baseDir . '/../lib/Hooks.php',
'OCA\\Settings\\Mailer\\NewUserMailHelper' => $baseDir . '/../lib/Mailer/NewUserMailHelper.php',
'OCA\\Settings\\Middleware\\SubadminMiddleware' => $baseDir . '/../lib/Middleware/SubadminMiddleware.php',
@ -50,5 +51,6 @@ return array(
'OCA\\Settings\\Settings\\Personal\\Security\\Authtokens' => $baseDir . '/../lib/Settings/Personal/Security/Authtokens.php',
'OCA\\Settings\\Settings\\Personal\\Security\\Password' => $baseDir . '/../lib/Settings/Personal/Security/Password.php',
'OCA\\Settings\\Settings\\Personal\\Security\\TwoFactor' => $baseDir . '/../lib/Settings/Personal/Security/TwoFactor.php',
'OCA\\Settings\\Settings\\Personal\\Security\\WebAuthn' => $baseDir . '/../lib/Settings/Personal/Security/WebAuthn.php',
'OCA\\Settings\\Settings\\Personal\\ServerDevNotice' => $baseDir . '/../lib/Settings/Personal/ServerDevNotice.php',
);

View File

@ -43,6 +43,7 @@ class ComposerStaticInitSettings
'OCA\\Settings\\Controller\\PersonalSettingsController' => __DIR__ . '/..' . '/../lib/Controller/PersonalSettingsController.php',
'OCA\\Settings\\Controller\\TwoFactorSettingsController' => __DIR__ . '/..' . '/../lib/Controller/TwoFactorSettingsController.php',
'OCA\\Settings\\Controller\\UsersController' => __DIR__ . '/..' . '/../lib/Controller/UsersController.php',
'OCA\\Settings\\Controller\\WebAuthnController' => __DIR__ . '/..' . '/../lib/Controller/WebAuthnController.php',
'OCA\\Settings\\Hooks' => __DIR__ . '/..' . '/../lib/Hooks.php',
'OCA\\Settings\\Mailer\\NewUserMailHelper' => __DIR__ . '/..' . '/../lib/Mailer/NewUserMailHelper.php',
'OCA\\Settings\\Middleware\\SubadminMiddleware' => __DIR__ . '/..' . '/../lib/Middleware/SubadminMiddleware.php',
@ -65,6 +66,7 @@ class ComposerStaticInitSettings
'OCA\\Settings\\Settings\\Personal\\Security\\Authtokens' => __DIR__ . '/..' . '/../lib/Settings/Personal/Security/Authtokens.php',
'OCA\\Settings\\Settings\\Personal\\Security\\Password' => __DIR__ . '/..' . '/../lib/Settings/Personal/Security/Password.php',
'OCA\\Settings\\Settings\\Personal\\Security\\TwoFactor' => __DIR__ . '/..' . '/../lib/Settings/Personal/Security/TwoFactor.php',
'OCA\\Settings\\Settings\\Personal\\Security\\WebAuthn' => __DIR__ . '/..' . '/../lib/Settings/Personal/Security/WebAuthn.php',
'OCA\\Settings\\Settings\\Personal\\ServerDevNotice' => __DIR__ . '/..' . '/../lib/Settings/Personal/ServerDevNotice.php',
);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -55,12 +55,13 @@ use Symfony\Component\EventDispatcher\GenericEvent;
class Application extends App {
const APP_ID = 'settings';
/**
* @param array $urlParams
*/
public function __construct(array $urlParams=[]){
parent::__construct('settings', $urlParams);
parent::__construct(self::APP_ID, $urlParams);
$container = $this->getContainer();

View File

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* 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/>.
*
*/
namespace OCA\Settings\Controller;
use OC\Authentication\WebAuthn\Manager;
use OCA\Settings\AppInfo\Application;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\ILogger;
use OCP\IRequest;
use OCP\ISession;
use OCP\IUserSession;
use Webauthn\PublicKeyCredentialCreationOptions;
class WebAuthnController extends Controller {
private const WEBAUTHN_REGISTRATION = 'webauthn_registration';
/** @var Manager */
private $manager;
/** @var IUserSession */
private $userSession;
/**
* @var ISession
*/
private $session;
/**
* @var ILogger
*/
private $logger;
public function __construct(IRequest $request, ILogger $logger, Manager $webAuthnManager, IUserSession $userSession, ISession $session) {
parent::__construct(Application::APP_ID, $request);
$this->manager = $webAuthnManager;
$this->userSession = $userSession;
$this->session = $session;
$this->logger = $logger;
}
/**
* @NoAdminRequired
* @PasswordConfirmationRequired
* @UseSession
* @NoCSRFRequired
*/
public function startRegistration(): JSONResponse {
$this->logger->debug('Starting WebAuthn registration');
$credentialOptions = $this->manager->startRegistration($this->userSession->getUser(), $this->request->getServerHost());
// Set this in the session since we need it on finish
$this->session->set(self::WEBAUTHN_REGISTRATION, $credentialOptions);
return new JSONResponse($credentialOptions);
}
/**
* @NoAdminRequired
* @PasswordConfirmationRequired
* @UseSession
*/
public function finishRegistration(string $name, string $data): JSONResponse {
$this->logger->debug('Finishing WebAuthn registration');
if (!$this->session->exists(self::WEBAUTHN_REGISTRATION)) {
$this->logger->debug('Trying to finish WebAuthn registration without session data');
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
}
// Obtain the publicKeyCredentialOptions from when we started the registration
$publicKeyCredentialCreationOptions = PublicKeyCredentialCreationOptions::createFromArray($this->session->get(self::WEBAUTHN_REGISTRATION));
$this->session->remove(self::WEBAUTHN_REGISTRATION);
return new JSONResponse($this->manager->finishRegister($publicKeyCredentialCreationOptions, $name, $data));
}
/**
* @NoAdminRequired
* @PasswordConfirmationRequired
*/
public function deleteRegistration(int $id): JSONResponse {
$this->logger->debug('Finishing WebAuthn registration');
$this->manager->deleteRegistration($this->userSession->getUser(), $id);
return new JSONResponse([]);
}
}

View File

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* 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/>.
*
*/
namespace OCA\Settings\Settings\Personal\Security;
use OC\Authentication\WebAuthn\Db\PublicKeyCredentialMapper;
use OC\Authentication\WebAuthn\Manager;
use OCA\Settings\AppInfo\Application;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IInitialStateService;
use OCP\Settings\ISettings;
class WebAuthn implements ISettings {
/** @var PublicKeyCredentialMapper */
private $mapper;
/** @var string */
private $uid;
/** @var IInitialStateService */
private $initialStateService;
/** @var Manager */
private $manager;
public function __construct(PublicKeyCredentialMapper $mapper,
string $UserId,
IInitialStateService $initialStateService,
Manager $manager) {
$this->mapper = $mapper;
$this->uid = $UserId;
$this->initialStateService = $initialStateService;
$this->manager = $manager;
}
public function getForm() {
$this->initialStateService->provideInitialState(
Application::APP_ID,
'webauthn-devices',
$this->mapper->findAllForUid($this->uid)
);
return new TemplateResponse('settings', 'settings/personal/security/webauthn', [
]);
}
public function getSection(): ?string {
if (!$this->manager->isWebAuthnAvailable()) {
return null;
}
return 'security';
}
public function getPriority(): int {
return 20;
}
}

View File

@ -0,0 +1,215 @@
<!--
- @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
-
- @author Roeland Jago Douma <roeland@famdouma.nl>
-
- @license GNU AGPL version 3 or any later version
-
- 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/>.
-->
<template>
<div v-if="!isHttps">
{{ t('settings', 'Passwordless authentication requires a secure connection.') }}
</div>
<div v-else>
<div v-if="step === RegistrationSteps.READY">
<button @click="start">
{{ t('settings', 'Add Webauthn device') }}
</button>
</div>
<div v-else-if="step === RegistrationSteps.REGISTRATION"
class="new-webauthn-device">
<span class="icon-loading-small webauthn-loading" />
{{ t('settings', 'Please authorize your WebAuthn device.') }}
</div>
<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">
<button @click="submit">
{{ t('settings', 'Add') }}
</button>
</div>
<div v-else-if="step === RegistrationSteps.PERSIST"
class="new-webauthn-device">
<span class="icon-loading-small webauthn-loading" />
{{ t('settings', 'Adding your device …') }}
</div>
<div v-else>
Invalid registration step. This should not have happened.
</div>
</div>
</template>
<script>
import confirmPassword from '@nextcloud/password-confirmation'
import logger from '../../logger'
import {
startRegistration,
finishRegistration,
} from '../../service/WebAuthnRegistrationSerice'
const logAndPass = (text) => (data) => {
logger.debug(text)
return data
}
const RegistrationSteps = Object.freeze({
READY: 1,
REGISTRATION: 2,
NAMING: 3,
PERSIST: 4,
})
export default {
name: 'AddDevice',
props: {
httpWarning: Boolean,
isHttps: {
type: Boolean,
default: false
}
},
data() {
return {
name: '',
credential: {},
RegistrationSteps,
step: RegistrationSteps.READY,
}
},
methods: {
arrayToBase64String(a) {
return btoa(String.fromCharCode(...a))
},
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)
}
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)),
},
}
})
},
submit() {
this.step = RegistrationSteps.PERSIST
return confirmPassword()
.then(logAndPass('confirmed password'))
.then(this.saveRegistrationData)
.then(logAndPass('registration data saved'))
.then(() => this.reset())
.then(logAndPass('app reset'))
.catch(console.error.bind(this))
},
async saveRegistrationData() {
try {
const device = await finishRegistration(this.name, JSON.stringify(this.credential))
logger.info('new device added', { device })
this.$emit('added', device)
} catch (err) {
logger.error('Error persisting webauthn registration', { error: err })
throw new Error(t('settings', 'Server error while trying to complete webauthn device registration'))
}
},
reset() {
this.name = ''
this.registrationData = {}
this.step = RegistrationSteps.READY
},
},
}
</script>
<style scoped>
.webauthn-loading {
display: inline-block;
vertical-align: sub;
margin-left: 2px;
margin-right: 2px;
}
.new-webauthn-device {
line-height: 300%;
}
</style>

View File

@ -0,0 +1,65 @@
<!--
- @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
-
- @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
-
- @license GNU AGPL version 3 or any later version
-
- 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/>.
-->
<template>
<div class="webauthn-device">
<span class="icon-webauthn-device" />
{{ name || t('settings', 'Unnamed device') }}
<Actions :force-menu="true">
<ActionButton icon="icon-delete" @click="$emit('delete')">
{{ t('settings', 'Delete') }}
</ActionButton>
</Actions>
</div>
</template>
<script>
import Actions from '@nextcloud/vue/dist/Components/Actions'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
export default {
name: 'Device',
components: {
ActionButton,
Actions,
},
props: {
name: {
type: String,
required: true,
},
},
}
</script>
<style scoped>
.webauthn-device {
line-height: 300%;
display: flex;
}
.icon-webauthn-device {
display: inline-block;
background-size: 100%;
padding: 3px;
margin: 3px;
}
</style>

View File

@ -0,0 +1,109 @@
<!--
- @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
-
- @author Roeland Jago Douma <roeland@famdouma.nl>
-
- @license GNU AGPL version 3 or any later version
-
- 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/>.
-->
<template>
<div id="security-webauthn" class="section">
<h2>{{ t('settings', 'Passwordless Authentication') }}</h2>
<p class="settings-hint hidden-when-empty">
{{ t('settings', 'Set up your account for passwordless authentication following the FIDO2 standard.') }}
</p>
<p v-if="devices.length === 0">
{{ t('twofactor_u2f', 'No devices configured.') }}
</p>
<p v-else>
{{ t('twofactor_u2f', 'The following devices are configured for your account:') }}
</p>
<Device v-for="device in sortedDevices"
:key="device.id"
:name="device.name"
@delete="deleteDevice(device.id)" />
<p v-if="!hasPublicKeyCredential" class="warning">
{{ t('settings', 'Your browser does not support Webauthn.') }}
</p>
<AddDevice v-if="hasPublicKeyCredential" :isHttps="isHttps" @added="deviceAdded" />
</div>
</template>
<script>
import confirmPassword from '@nextcloud/password-confirmation'
import sortBy from 'lodash/fp/sortBy'
import AddDevice from './AddDevice'
import Device from './Device'
import logger from '../../logger'
import { removeRegistration } from '../../service/WebAuthnRegistrationSerice'
const sortByName = sortBy('name')
export default {
components: {
AddDevice,
Device,
},
props: {
initialDevices: {
type: Array,
required: true,
},
isHttps: {
type: Boolean,
default: false,
},
hasPublicKeyCredential: {
type: Boolean,
default: false,
},
},
data() {
return {
devices: this.initialDevices,
}
},
computed: {
sortedDevices() {
return sortByName(this.devices)
},
},
methods: {
deviceAdded(device) {
logger.debug(`adding new device to the list ${device.id}`)
this.devices.push(device)
},
async deleteDevice(id) {
logger.info(`deleting webauthn device ${id}`)
await confirmPassword()
await removeRegistration(id)
this.devices = this.devices.filter(d => d.id !== id)
logger.info(`webauthn device ${id} removed successfully`)
},
},
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,27 @@
/*
* @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @license GNU AGPL version 3 or any later version
*
* 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 { getLoggerBuilder } from '@nextcloud/logger'
export default getLoggerBuilder()
.setApp('settings')
.detectUser()
.build()

View File

@ -0,0 +1,40 @@
/**
* @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* 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 Vue from 'vue'
import { loadState } from '@nextcloud/initial-state'
import WebAuthnSection from './components/WebAuthn/Section'
// eslint-disable-next-line camelcase
__webpack_nonce__ = btoa(OC.requestToken)
Vue.prototype.t = t
const View = Vue.extend(WebAuthnSection)
const devices = loadState('settings', 'webauthn-devices')
new View({
propsData: {
initialDevices: devices,
isHttps: window.location.protocol === 'https:',
hasPublicKeyCredential: typeof (window.PublicKeyCredential) !== 'undefined',
},
}).$mount('#security-webauthn')

View File

@ -0,0 +1,43 @@
/**
* @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* 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'
export async function startRegistration() {
const url = generateUrl('/settings/api/personal/webauthn/registration')
const resp = await axios.get(url)
return resp.data
}
export async function finishRegistration(name, data) {
const url = generateUrl('/settings/api/personal/webauthn/registration')
const resp = await axios.post(url, { name, data })
return resp.data
}
export async function removeRegistration(id) {
const url = generateUrl(`/settings/api/personal/webauthn/registration/${id}`)
await axios.delete(url)
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* 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/>.
*
*/
script('settings', [
'vue-settings-personal-webauthn',
]);
?>
<div id="security-webauthn" class="section"></div>

View File

@ -4,7 +4,8 @@ module.exports = {
entry: {
'settings-apps-users-management': path.join(__dirname, 'src', 'main-apps-users-management'),
'settings-admin-security': path.join(__dirname, 'src', 'main-admin-security'),
'settings-personal-security': path.join(__dirname, 'src', 'main-personal-security')
'settings-personal-security': path.join(__dirname, 'src', 'main-personal-security'),
'settings-personal-webauthn': path.join(__dirname, 'src', 'main-personal-webauth')
},
output: {
path: path.resolve(__dirname, './js'),

View File

@ -269,6 +269,11 @@ $CONFIG = [
*/
'auth.bruteforce.protection.enabled' => true,
/**
* By default WebAuthn is available but it can be explicitly disabled by admins
*/
'auth.webauthn.enabled' => true,
/**
* The directory where the skeleton files are located. These files will be
* copied to the data directory of new users. Leave empty to not copy any

View File

@ -34,6 +34,7 @@ namespace OC\Core\Controller;
use OC\AppFramework\Http\Request;
use OC\Authentication\Login\Chain;
use OC\Authentication\Login\LoginData;
use OC\Authentication\WebAuthn\Manager as WebAuthnManager;
use OC\Security\Bruteforce\Throttler;
use OC\User\Session;
use OC_App;
@ -80,6 +81,8 @@ class LoginController extends Controller {
private $loginChain;
/** @var IInitialStateService */
private $initialStateService;
/** @var WebAuthnManager */
private $webAuthnManager;
public function __construct(?string $appName,
IRequest $request,
@ -92,7 +95,8 @@ class LoginController extends Controller {
Defaults $defaults,
Throttler $throttler,
Chain $loginChain,
IInitialStateService $initialStateService) {
IInitialStateService $initialStateService,
WebAuthnManager $webAuthnManager) {
parent::__construct($appName, $request);
$this->userManager = $userManager;
$this->config = $config;
@ -104,6 +108,7 @@ class LoginController extends Controller {
$this->throttler = $throttler;
$this->loginChain = $loginChain;
$this->initialStateService = $initialStateService;
$this->webAuthnManager = $webAuthnManager;
}
/**
@ -181,6 +186,8 @@ class LoginController extends Controller {
$this->setPasswordResetInitialState($user);
$this->initialStateService->provideInitialState('core', 'webauthn-available', $this->webAuthnManager->isWebAuthnAvailable());
// OpenGraph Support: http://ogp.me/
Util::addHeader('meta', ['property' => 'og:title', 'content' => Util::sanitizeHTML($this->defaults->getName())]);
Util::addHeader('meta', ['property' => 'og:description', 'content' => Util::sanitizeHTML($this->defaults->getSlogan())]);

View File

@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* 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/>.
*
*/
namespace OC\Core\Controller;
use OC\Authentication\Login\LoginData;
use OC\Authentication\Login\WebAuthnChain;
use OC\Authentication\WebAuthn\Manager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\ILogger;
use OCP\IRequest;
use OCP\ISession;
use OCP\Util;
use Webauthn\PublicKeyCredentialRequestOptions;
class WebAuthnController extends Controller {
private const WEBAUTHN_LOGIN = 'webauthn_login';
private const WEBAUTHN_LOGIN_UID = 'webauthn_login_uid';
/** @var Manager */
private $webAuthnManger;
/** @var ISession */
private $session;
/** @var ILogger */
private $logger;
/** @var WebAuthnChain */
private $webAuthnChain;
public function __construct($appName, IRequest $request, Manager $webAuthnManger, ISession $session, ILogger $logger, WebAuthnChain $webAuthnChain) {
parent::__construct($appName, $request);
$this->webAuthnManger = $webAuthnManger;
$this->session = $session;
$this->logger = $logger;
$this->webAuthnChain = $webAuthnChain;
}
/**
* @NoAdminRequired
* @PublicPage
* @UseSession
*/
public function startAuthentication(string $loginName): JSONResponse {
$this->logger->debug('Starting WebAuthn login');
$this->logger->debug('Converting login name to UID');
$uid = $loginName;
Util::emitHook(
'\OCA\Files_Sharing\API\Server2Server',
'preLoginNameUsedAsUserName',
array('uid' => &$uid)
);
$this->logger->debug('Got UID: ' . $uid);
$publicKeyCredentialRequestOptions = $this->webAuthnManger->startAuthentication($uid, $this->request->getServerHost());
$this->session->set(self::WEBAUTHN_LOGIN, json_encode($publicKeyCredentialRequestOptions));
$this->session->set(self::WEBAUTHN_LOGIN_UID, $uid);
return new JSONResponse($publicKeyCredentialRequestOptions);
}
/**
* @NoAdminRequired
* @PublicPage
* @UseSession
*/
public function finishAuthentication(string $data): JSONResponse {
$this->logger->debug('Validating WebAuthn login');
if (!$this->session->exists(self::WEBAUTHN_LOGIN) || !$this->session->exists(self::WEBAUTHN_LOGIN_UID)) {
$this->logger->debug('Trying to finish WebAuthn login without session data');
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
}
// Obtain the publicKeyCredentialOptions from when we started the registration
$publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::createFromString($this->session->get(self::WEBAUTHN_LOGIN));
$uid = $this->session->get(self::WEBAUTHN_LOGIN_UID);
$this->webAuthnManger->finishAuthentication($publicKeyCredentialRequestOptions, $data, $uid);
//TODO: add other parameters
$loginData = new LoginData(
$this->request,
$uid,
''
);
$this->webAuthnChain->process($loginData);
return new JSONResponse([]);
}
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace OC\Core\Migrations;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version19000Date20200211083441 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if (!$schema->hasTable('webauthn')) {
$table = $schema->createTable('webauthn');
$table->addColumn('id', 'integer', [
'autoincrement' => true,
'notnull' => true,
'length' => 64,
]);
$table->addColumn('uid', 'string', [
'notnull' => true,
'length' => 64,
]);
$table->addColumn('name', 'string', [
'notnull' => true,
'length' => 64,
]);
$table->addColumn('public_key_credential_id', 'string', [
'notnull' => true,
'length' => 255
]);
$table->addColumn('data', 'text', [
'notnull' => true,
]);
$table->setPrimaryKey(['id']);
$table->addIndex(['uid'], 'webauthn_uid');
$table->addIndex(['public_key_credential_id'], 'webauthn_publicKeyCredentialId');
}
return $schema;
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

30
core/js/dist/login.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

26
core/js/dist/main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -86,6 +86,10 @@ $application->registerRoutes($this, [
['name' => 'Wipe#checkWipe', 'url' => '/core/wipe/check', 'verb' => 'POST'],
['name' => 'Wipe#wipeDone', 'url' => '/core/wipe/success', 'verb' => 'POST'],
// Logins for passwordless auth
['name' => 'WebAuthn#startAuthentication', 'url' => 'login/webauthn/start', 'verb' => 'POST'],
['name' => 'WebAuthn#finishAuthentication', 'url' => 'login/webauthn/finish', 'verb' => 'POST'],
// Legacy routes that need to be globally available while they are handled by an app
['name' => 'viewcontroller#showFile', 'url' => '/f/{fileid}', 'verb' => 'GET', 'app' => 'files'],
['name' => 'sharecontroller#showShare', 'url' => '/s/{token}', 'verb' => 'GET', 'app' => 'files_sharing'],

View File

@ -0,0 +1,56 @@
<!--
- @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
-
- @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
-
- @license GNU AGPL version 3 or any later version
-
- 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/>.
-->
<template>
<div id="submit-wrapper" @click="$emit('click')">
<input id="submit-form"
type="submit"
class="login primary"
title=""
:value="!loading ? t('core', 'Log in') : t('core', 'Logging in …')">
<div class="submit-icon"
:class="{
'icon-confirm-white': !loading,
'icon-loading-small': loading && invertedColors,
'icon-loading-small-dark': loading && !invertedColors,
}" />
</div>
</template>
<script>
export default {
name: 'LoginButton',
props: {
loading: {
type: Boolean,
required: true,
},
invertedColors: {
type: Boolean,
default: false,
},
},
}
</script>
<style scoped>
</style>

View File

@ -20,7 +20,8 @@
-->
<template>
<form method="post"
<form ref="loginForm"
method="post"
name="login"
:action="OC.generateUrl('login')"
@submit="submit">
@ -84,19 +85,7 @@
</a>
</p>
<div id="submit-wrapper">
<input id="submit-form"
type="submit"
class="login primary"
title=""
:value="!loading ? t('core', 'Log in') : t('core', 'Logging in …')">
<div class="submit-icon"
:class="{
'icon-confirm-white': !loading,
'icon-loading-small': loading && invertedColors,
'icon-loading-small-dark': loading && !invertedColors,
}" />
</div>
<LoginButton :loading="loading" :inverted-colors="invertedColors" />
<p v-if="invalidPassword"
class="warning wrongPasswordMsg">
@ -135,9 +124,11 @@
<script>
import jstz from 'jstimezonedetect'
import LoginButton from './LoginButton'
export default {
name: 'LoginForm',
components: { LoginButton },
props: {
username: {
type: String,

View File

@ -0,0 +1,208 @@
<template>
<form v-if="isHttps && hasPublicKeyCredential"
ref="loginForm"
method="post"
name="login"
@submit.prevent="submit">
<fieldset>
<p class="grouptop groupbottom">
<input id="user"
ref="user"
v-model="user"
type="text"
name="user"
:autocomplete="autoCompleteAllowed ? 'on' : 'off'"
:placeholder="t('core', 'Username or email')"
:aria-label="t('core', 'Username or email')"
required
@change="$emit('update:username', user)">
<label for="user" class="infield">{{ t('core', 'Username or email') }}</label>
</p>
<div v-if="!validCredentials">
{{ t('core', 'Your account is not setup for passwordless login.') }}
</div>
<LoginButton v-if="validCredentials"
:loading="loading"
:inverted-colors="invertedColors"
@click="authenticate" />
</fieldset>
</form>
<div v-else-if="!hasPublicKeyCredential">
{{ t('core', 'Passwordless authentication is not supported in your browser.')}}
</div>
<div v-else-if="!isHttps">
{{ t('core', 'Passwordless authentication is only available over a secure connection.')}}
</div>
</template>
<script>
import {
startAuthentication,
finishAuthentication,
} from '../../service/WebAuthnAuthenticationService'
import LoginButton from './LoginButton'
class NoValidCredentials extends Error {
}
export default {
name: 'PasswordLessLoginForm',
components: {
LoginButton,
},
props: {
username: {
type: String,
default: '',
},
redirectUrl: {
type: String,
},
invertedColors: {
type: Boolean,
default: false,
},
autoCompleteAllowed: {
type: Boolean,
default: true,
},
isHttps: {
type: Boolean,
default: false,
},
hasPublicKeyCredential: {
type: Boolean,
default: false,
}
},
data() {
return {
user: this.username,
loading: false,
validCredentials: true,
}
},
methods: {
authenticate() {
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)
})
},
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))
}
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 ? arrayToBase64String(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 location = this.redirectUrl
return finishAuthentication(JSON.stringify(challenge))
.then(data => {
console.debug('Logged in redirecting')
window.location.href = location
})
.catch(error => {
console.debug('GOT AN ERROR WHILE SUBMITTING CHALLENGE!')
console.debug(error) // Example: timeout, interaction refused...
})
},
submit() {
// noop
},
},
}
</script>
<style scoped>
</style>

View File

@ -64,5 +64,8 @@ new View({
resetPasswordTarget: fromStateOr('resetPasswordTarget', ''),
resetPasswordUser: fromStateOr('resetPasswordUser', ''),
directLogin: query.direct === '1',
hasPasswordless: fromStateOr('webauthn-available', false),
isHttps: window.location.protocol === 'https:',
hasPublicKeyCredential: typeof (window.PublicKeyCredential) !== 'undefined',
},
}).$mount('#login')

View File

@ -0,0 +1,37 @@
/**
* @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* 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'
export function startAuthentication(loginName) {
const url = generateUrl('/login/webauthn/start')
return Axios.post(url, { loginName })
.then(resp => resp.data)
}
export function finishAuthentication(data) {
const url = generateUrl('/login/webauthn/finish')
return Axios.post(url, { data })
.then(resp => resp.data)
}

View File

@ -22,7 +22,7 @@
<template>
<div>
<transition name="fade" mode="out-in">
<div v-if="!resetPassword && resetPasswordTarget === ''"
<div v-if="!passwordlessLogin && !resetPassword && resetPasswordTarget === ''"
key="login">
<LoginForm
:username.sync="user"
@ -45,6 +45,25 @@
@click.prevent="resetPassword = true">
{{ t('core', 'Forgot password?') }}
</a>
<br>
<a v-if="hasPasswordless" @click.prevent="passwordlessLogin = true">
{{ t('core', 'Log in with a device') }}
</a>
</div>
<div v-else-if="!loading && passwordlessLogin"
key="reset"
class="login-additional">
<PasswordLessLoginForm
:username.sync="user"
:redirect-url="redirectUrl"
:inverted-colors="invertedColors"
:auto-complete-allowed="autoCompleteAllowed"
:isHttps="isHttps"
:hasPublicKeyCredential="hasPublicKeyCredential"
@submit="loading = true" />
<a @click.prevent="passwordlessLogin = false">
{{ t('core', 'Back') }}
</a>
</div>
<div v-else-if="!loading && canResetPassword"
key="reset"
@ -69,6 +88,7 @@
<script>
import LoginForm from '../components/login/LoginForm.vue'
import PasswordLessLoginForm from '../components/login/PasswordLessLoginForm.vue'
import ResetPassword from '../components/login/ResetPassword.vue'
import UpdatePassword from '../components/login/UpdatePassword.vue'
@ -76,6 +96,7 @@ export default {
name: 'Login',
components: {
LoginForm,
PasswordLessLoginForm,
ResetPassword,
UpdatePassword,
},
@ -120,11 +141,24 @@ export default {
type: Boolean,
default: false,
},
hasPasswordless: {
type: Boolean,
default: false,
},
isHttps: {
type: Boolean,
default: false,
},
hasPublicKeyCredential: {
type: Boolean,
default: false,
},
},
data() {
return {
loading: false,
user: this.username,
passwordlessLogin: false,
resetPassword: false,
}
},

View File

@ -629,6 +629,8 @@ return array(
'OC\\Authentication\\Login\\UidLoginCommand' => $baseDir . '/lib/private/Authentication/Login/UidLoginCommand.php',
'OC\\Authentication\\Login\\UpdateLastPasswordConfirmCommand' => $baseDir . '/lib/private/Authentication/Login/UpdateLastPasswordConfirmCommand.php',
'OC\\Authentication\\Login\\UserDisabledCheckCommand' => $baseDir . '/lib/private/Authentication/Login/UserDisabledCheckCommand.php',
'OC\\Authentication\\Login\\WebAuthnChain' => $baseDir . '/lib/private/Authentication/Login/WebAuthnChain.php',
'OC\\Authentication\\Login\\WebAuthnLoginCommand' => $baseDir . '/lib/private/Authentication/Login/WebAuthnLoginCommand.php',
'OC\\Authentication\\Notifications\\Notifier' => $baseDir . '/lib/private/Authentication/Notifications/Notifier.php',
'OC\\Authentication\\Token\\DefaultToken' => $baseDir . '/lib/private/Authentication/Token/DefaultToken.php',
'OC\\Authentication\\Token\\DefaultTokenCleanupJob' => $baseDir . '/lib/private/Authentication/Token/DefaultTokenCleanupJob.php',
@ -651,6 +653,10 @@ return array(
'OC\\Authentication\\TwoFactorAuth\\ProviderManager' => $baseDir . '/lib/private/Authentication/TwoFactorAuth/ProviderManager.php',
'OC\\Authentication\\TwoFactorAuth\\ProviderSet' => $baseDir . '/lib/private/Authentication/TwoFactorAuth/ProviderSet.php',
'OC\\Authentication\\TwoFactorAuth\\Registry' => $baseDir . '/lib/private/Authentication/TwoFactorAuth/Registry.php',
'OC\\Authentication\\WebAuthn\\CredentialRepository' => $baseDir . '/lib/private/Authentication/WebAuthn/CredentialRepository.php',
'OC\\Authentication\\WebAuthn\\Db\\PublicKeyCredentialEntity' => $baseDir . '/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialEntity.php',
'OC\\Authentication\\WebAuthn\\Db\\PublicKeyCredentialMapper' => $baseDir . '/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialMapper.php',
'OC\\Authentication\\WebAuthn\\Manager' => $baseDir . '/lib/private/Authentication/WebAuthn/Manager.php',
'OC\\Avatar\\Avatar' => $baseDir . '/lib/private/Avatar/Avatar.php',
'OC\\Avatar\\AvatarManager' => $baseDir . '/lib/private/Avatar/AvatarManager.php',
'OC\\Avatar\\GuestAvatar' => $baseDir . '/lib/private/Avatar/GuestAvatar.php',
@ -817,6 +823,7 @@ return array(
'OC\\Core\\Controller\\TwoFactorChallengeController' => $baseDir . '/core/Controller/TwoFactorChallengeController.php',
'OC\\Core\\Controller\\UserController' => $baseDir . '/core/Controller/UserController.php',
'OC\\Core\\Controller\\WalledGardenController' => $baseDir . '/core/Controller/WalledGardenController.php',
'OC\\Core\\Controller\\WebAuthnController' => $baseDir . '/core/Controller/WebAuthnController.php',
'OC\\Core\\Controller\\WhatsNewController' => $baseDir . '/core/Controller/WhatsNewController.php',
'OC\\Core\\Controller\\WipeController' => $baseDir . '/core/Controller/WipeController.php',
'OC\\Core\\Data\\LoginFlowV2Credentials' => $baseDir . '/core/Data/LoginFlowV2Credentials.php',
@ -850,6 +857,7 @@ return array(
'OC\\Core\\Migrations\\Version18000Date20190920085628' => $baseDir . '/core/Migrations/Version18000Date20190920085628.php',
'OC\\Core\\Migrations\\Version18000Date20191014105105' => $baseDir . '/core/Migrations/Version18000Date20191014105105.php',
'OC\\Core\\Migrations\\Version18000Date20191204114856' => $baseDir . '/core/Migrations/Version18000Date20191204114856.php',
'OC\\Core\\Migrations\\Version19000Date20200211083441' => $baseDir . '/core/Migrations/Version19000Date20200211083441.php',
'OC\\Core\\Notification\\RemoveLinkSharesNotifier' => $baseDir . '/core/Notification/RemoveLinkSharesNotifier.php',
'OC\\Core\\Service\\LoginFlowV2Service' => $baseDir . '/core/Service/LoginFlowV2Service.php',
'OC\\DB\\Adapter' => $baseDir . '/lib/private/DB/Adapter.php',

View File

@ -658,6 +658,8 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c
'OC\\Authentication\\Login\\UidLoginCommand' => __DIR__ . '/../../..' . '/lib/private/Authentication/Login/UidLoginCommand.php',
'OC\\Authentication\\Login\\UpdateLastPasswordConfirmCommand' => __DIR__ . '/../../..' . '/lib/private/Authentication/Login/UpdateLastPasswordConfirmCommand.php',
'OC\\Authentication\\Login\\UserDisabledCheckCommand' => __DIR__ . '/../../..' . '/lib/private/Authentication/Login/UserDisabledCheckCommand.php',
'OC\\Authentication\\Login\\WebAuthnChain' => __DIR__ . '/../../..' . '/lib/private/Authentication/Login/WebAuthnChain.php',
'OC\\Authentication\\Login\\WebAuthnLoginCommand' => __DIR__ . '/../../..' . '/lib/private/Authentication/Login/WebAuthnLoginCommand.php',
'OC\\Authentication\\Notifications\\Notifier' => __DIR__ . '/../../..' . '/lib/private/Authentication/Notifications/Notifier.php',
'OC\\Authentication\\Token\\DefaultToken' => __DIR__ . '/../../..' . '/lib/private/Authentication/Token/DefaultToken.php',
'OC\\Authentication\\Token\\DefaultTokenCleanupJob' => __DIR__ . '/../../..' . '/lib/private/Authentication/Token/DefaultTokenCleanupJob.php',
@ -680,6 +682,10 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c
'OC\\Authentication\\TwoFactorAuth\\ProviderManager' => __DIR__ . '/../../..' . '/lib/private/Authentication/TwoFactorAuth/ProviderManager.php',
'OC\\Authentication\\TwoFactorAuth\\ProviderSet' => __DIR__ . '/../../..' . '/lib/private/Authentication/TwoFactorAuth/ProviderSet.php',
'OC\\Authentication\\TwoFactorAuth\\Registry' => __DIR__ . '/../../..' . '/lib/private/Authentication/TwoFactorAuth/Registry.php',
'OC\\Authentication\\WebAuthn\\CredentialRepository' => __DIR__ . '/../../..' . '/lib/private/Authentication/WebAuthn/CredentialRepository.php',
'OC\\Authentication\\WebAuthn\\Db\\PublicKeyCredentialEntity' => __DIR__ . '/../../..' . '/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialEntity.php',
'OC\\Authentication\\WebAuthn\\Db\\PublicKeyCredentialMapper' => __DIR__ . '/../../..' . '/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialMapper.php',
'OC\\Authentication\\WebAuthn\\Manager' => __DIR__ . '/../../..' . '/lib/private/Authentication/WebAuthn/Manager.php',
'OC\\Avatar\\Avatar' => __DIR__ . '/../../..' . '/lib/private/Avatar/Avatar.php',
'OC\\Avatar\\AvatarManager' => __DIR__ . '/../../..' . '/lib/private/Avatar/AvatarManager.php',
'OC\\Avatar\\GuestAvatar' => __DIR__ . '/../../..' . '/lib/private/Avatar/GuestAvatar.php',
@ -846,6 +852,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c
'OC\\Core\\Controller\\TwoFactorChallengeController' => __DIR__ . '/../../..' . '/core/Controller/TwoFactorChallengeController.php',
'OC\\Core\\Controller\\UserController' => __DIR__ . '/../../..' . '/core/Controller/UserController.php',
'OC\\Core\\Controller\\WalledGardenController' => __DIR__ . '/../../..' . '/core/Controller/WalledGardenController.php',
'OC\\Core\\Controller\\WebAuthnController' => __DIR__ . '/../../..' . '/core/Controller/WebAuthnController.php',
'OC\\Core\\Controller\\WhatsNewController' => __DIR__ . '/../../..' . '/core/Controller/WhatsNewController.php',
'OC\\Core\\Controller\\WipeController' => __DIR__ . '/../../..' . '/core/Controller/WipeController.php',
'OC\\Core\\Data\\LoginFlowV2Credentials' => __DIR__ . '/../../..' . '/core/Data/LoginFlowV2Credentials.php',
@ -879,6 +886,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c
'OC\\Core\\Migrations\\Version18000Date20190920085628' => __DIR__ . '/../../..' . '/core/Migrations/Version18000Date20190920085628.php',
'OC\\Core\\Migrations\\Version18000Date20191014105105' => __DIR__ . '/../../..' . '/core/Migrations/Version18000Date20191014105105.php',
'OC\\Core\\Migrations\\Version18000Date20191204114856' => __DIR__ . '/../../..' . '/core/Migrations/Version18000Date20191204114856.php',
'OC\\Core\\Migrations\\Version19000Date20200211083441' => __DIR__ . '/../../..' . '/core/Migrations/Version19000Date20200211083441.php',
'OC\\Core\\Notification\\RemoveLinkSharesNotifier' => __DIR__ . '/../../..' . '/core/Notification/RemoveLinkSharesNotifier.php',
'OC\\Core\\Service\\LoginFlowV2Service' => __DIR__ . '/../../..' . '/core/Service/LoginFlowV2Service.php',
'OC\\DB\\Adapter' => __DIR__ . '/../../..' . '/lib/private/DB/Adapter.php',

View File

@ -51,6 +51,19 @@ class CreateSessionTokenCommand extends ALoginCommand {
$tokenType = IToken::DO_NOT_REMEMBER;
}
if ($loginData->getPassword() === '') {
$this->userSession->createSessionToken(
$loginData->getRequest(),
$loginData->getUser()->getUID(),
$loginData->getUsername(),
null,
$tokenType
);
$this->userSession->updateTokens(
$loginData->getUser()->getUID(),
''
);
} else {
$this->userSession->createSessionToken(
$loginData->getRequest(),
$loginData->getUser()->getUID(),
@ -62,6 +75,7 @@ class CreateSessionTokenCommand extends ALoginCommand {
$loginData->getUser()->getUID(),
$loginData->getPassword()
);
}
return $this->processNextOrFinishSuccessfully($loginData);
}

View File

@ -56,7 +56,7 @@ class LoginData {
public function __construct(IRequest $request,
string $username,
string $password,
?string $password,
string $redirectUrl = null,
string $timeZone = '',
string $timeZoneOffset = '') {
@ -80,7 +80,7 @@ class LoginData {
return $this->username;
}
public function getPassword(): string {
public function getPassword(): ?string {
return $this->password;
}

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* 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/>.
*
*/
namespace OC\Authentication\Login;
class WebAuthnChain {
/** @var UserDisabledCheckCommand */
private $userDisabledCheckCommand;
/** @var LoggedInCheckCommand */
private $loggedInCheckCommand;
/** @var CompleteLoginCommand */
private $completeLoginCommand;
/** @var CreateSessionTokenCommand */
private $createSessionTokenCommand;
/** @var ClearLostPasswordTokensCommand */
private $clearLostPasswordTokensCommand;
/** @var UpdateLastPasswordConfirmCommand */
private $updateLastPasswordConfirmCommand;
/** @var SetUserTimezoneCommand */
private $setUserTimezoneCommand;
/** @var TwoFactorCommand */
private $twoFactorCommand;
/** @var FinishRememberedLoginCommand */
private $finishRememberedLoginCommand;
/** @var WebAuthnLoginCommand */
private $webAuthnLoginCommand;
public function __construct(UserDisabledCheckCommand $userDisabledCheckCommand,
WebAuthnLoginCommand $webAuthnLoginCommand,
LoggedInCheckCommand $loggedInCheckCommand,
CompleteLoginCommand $completeLoginCommand,
CreateSessionTokenCommand $createSessionTokenCommand,
ClearLostPasswordTokensCommand $clearLostPasswordTokensCommand,
UpdateLastPasswordConfirmCommand $updateLastPasswordConfirmCommand,
SetUserTimezoneCommand $setUserTimezoneCommand,
TwoFactorCommand $twoFactorCommand,
FinishRememberedLoginCommand $finishRememberedLoginCommand
) {
$this->userDisabledCheckCommand = $userDisabledCheckCommand;
$this->webAuthnLoginCommand = $webAuthnLoginCommand;
$this->loggedInCheckCommand = $loggedInCheckCommand;
$this->completeLoginCommand = $completeLoginCommand;
$this->createSessionTokenCommand = $createSessionTokenCommand;
$this->clearLostPasswordTokensCommand = $clearLostPasswordTokensCommand;
$this->updateLastPasswordConfirmCommand = $updateLastPasswordConfirmCommand;
$this->setUserTimezoneCommand = $setUserTimezoneCommand;
$this->twoFactorCommand = $twoFactorCommand;
$this->finishRememberedLoginCommand = $finishRememberedLoginCommand;
}
public function process(LoginData $loginData): LoginResult {
$chain = $this->userDisabledCheckCommand;
$chain
->setNext($this->webAuthnLoginCommand)
->setNext($this->loggedInCheckCommand)
->setNext($this->completeLoginCommand)
->setNext($this->createSessionTokenCommand)
->setNext($this->clearLostPasswordTokensCommand)
->setNext($this->updateLastPasswordConfirmCommand)
->setNext($this->setUserTimezoneCommand)
->setNext($this->twoFactorCommand)
->setNext($this->finishRememberedLoginCommand);
return $chain->process($loginData);
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* 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/>.
*
*/
namespace OC\Authentication\Login;
use OCP\IUserManager;
class WebAuthnLoginCommand extends ALoginCommand {
/** @var IUserManager */
private $userManager;
public function __construct(IUserManager $userManager) {
$this->userManager = $userManager;
}
public function process(LoginData $loginData): LoginResult {
$user = $this->userManager->get($loginData->getUsername());
$loginData->setUser($user);
if ($user === null) {
$loginData->setUser(false);
}
return $this->processNextOrFinishSuccessfully($loginData);
}
}

View File

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* 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/>.
*
*/
namespace OC\Authentication\WebAuthn;
use OC\Authentication\WebAuthn\Db\PublicKeyCredentialEntity;
use OC\Authentication\WebAuthn\Db\PublicKeyCredentialMapper;
use OCP\AppFramework\Db\IMapperException;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialSourceRepository;
use Webauthn\PublicKeyCredentialUserEntity;
class CredentialRepository implements PublicKeyCredentialSourceRepository {
/** @var PublicKeyCredentialMapper */
private $credentialMapper;
public function __construct(PublicKeyCredentialMapper $credentialMapper) {
$this->credentialMapper = $credentialMapper;
}
public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource {
try {
$entity = $this->credentialMapper->findOneByCredentialId($publicKeyCredentialId);
return $entity->toPublicKeyCredentialSource();
} catch (IMapperException $e) {
return null;
}
}
/**
* @return PublicKeyCredentialSource[]
*/
public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array {
$uid = $publicKeyCredentialUserEntity->getId();
$entities = $this->credentialMapper->findAllForUid($uid);
return array_map(function (PublicKeyCredentialEntity $entity) {
return $entity->toPublicKeyCredentialSource();
}, $entities);
}
public function saveAndReturnCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource, string $name = null): PublicKeyCredentialEntity {
$oldEntity = null;
try {
$oldEntity = $this->credentialMapper->findOneByCredentialId($publicKeyCredentialSource->getPublicKeyCredentialId());
} catch (IMapperException $e) {
}
if ($name === null) {
$name = 'default';
}
$entity = PublicKeyCredentialEntity::fromPublicKeyCrendentialSource($name, $publicKeyCredentialSource);
if ($oldEntity) {
$entity->setId($oldEntity->getId());
if ($name === null) {
$entity->setName($oldEntity->getName());
}
}
return $this->credentialMapper->insertOrUpdate($entity);
}
public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource, string $name = null): void {
$this->saveAndReturnCredentialSource($publicKeyCredentialSource, $name);
}
}

View File

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* 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/>.
*
*/
namespace OC\Authentication\WebAuthn\Db;
use JsonSerializable;
use OCP\AppFramework\Db\Entity;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\TrustPath\TrustPathLoader;
/**
* @since 19.0.0
*
* @method string getUid();
* @method void setUid(string $uid)
* @method string getName();
* @method void setName(string $name);
* @method string getPublicKeyCredentialId();
* @method void setPublicKeyCredentialId(string $id);
* @method string getData();
* @method void setData(string $data);
*/
class PublicKeyCredentialEntity extends Entity implements JsonSerializable {
/** @var string */
protected $name;
/** @var string */
protected $uid;
/** @var string */
protected $publicKeyCredentialId;
/** @var string */
protected $data;
public function __construct() {
$this->addType('name', 'string');
$this->addType('uid', 'string');
$this->addType('publicKeyCredentialId', 'string');
$this->addType('data', 'string');
}
static function fromPublicKeyCrendentialSource(string $name, PublicKeyCredentialSource $publicKeyCredentialSource): PublicKeyCredentialEntity {
$publicKeyCredentialEntity = new self();
$publicKeyCredentialEntity->setName($name);
$publicKeyCredentialEntity->setUid($publicKeyCredentialSource->getUserHandle());
$publicKeyCredentialEntity->setPublicKeyCredentialId(base64_encode($publicKeyCredentialSource->getPublicKeyCredentialId()));
$publicKeyCredentialEntity->setData(json_encode($publicKeyCredentialSource));
return $publicKeyCredentialEntity;
}
function toPublicKeyCredentialSource(): PublicKeyCredentialSource {
return PublicKeyCredentialSource::createFromArray(
json_decode($this->getData(), true)
);
}
/**
* @inheritDoc
*/
public function jsonSerialize(): array {
return [
'id' => $this->getId(),
'name' => $this->getName(),
];
}
}

View File

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* 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/>.
*
*/
namespace OC\Authentication\WebAuthn\Db;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\IDBConnection;
class PublicKeyCredentialMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'webauthn', PublicKeyCredentialEntity::class);
}
public function findOneByCredentialId(string $publicKeyCredentialId): PublicKeyCredentialEntity {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('public_key_credential_id', $qb->createNamedParameter(base64_encode($publicKeyCredentialId)))
);
return $this->findEntity($qb);
}
/**
* @return PublicKeyCredentialEntity[]
*/
public function findAllForUid(string $uid): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('uid', $qb->createNamedParameter($uid))
);
return $this->findEntities($qb);
}
/**
* @param string $uid
* @param int $id
*
* @return PublicKeyCredentialEntity
* @throws DoesNotExistException
*/
public function findById(string $uid, int $id): PublicKeyCredentialEntity {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->andX(
$qb->expr()->eq('id', $qb->createNamedParameter($id)),
$qb->expr()->eq('uid', $qb->createNamedParameter($uid))
));
return $this->findEntity($qb);
}
}

View File

@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* 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/>.
*
*/
namespace OC\Authentication\WebAuthn;
use Cose\Algorithm\Signature\ECDSA\ES256;
use Cose\Algorithm\Signature\RSA\RS256;
use Cose\Algorithms;
use GuzzleHttp\Psr7\ServerRequest;
use OC\Authentication\WebAuthn\Db\PublicKeyCredentialEntity;
use OC\Authentication\WebAuthn\Db\PublicKeyCredentialMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IConfig;
use OCP\ILogger;
use OCP\IUser;
use Webauthn\AttestationStatement\AttestationObjectLoader;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\AuthenticatorAssertionResponseValidator;
use Webauthn\AuthenticatorAttestationResponse;
use Webauthn\AuthenticatorAttestationResponseValidator;
use Webauthn\AuthenticatorSelectionCriteria;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialDescriptor;
use Webauthn\PublicKeyCredentialLoader;
use Webauthn\PublicKeyCredentialParameters;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialUserEntity;
use Webauthn\TokenBinding\TokenBindingNotSupportedHandler;
class Manager {
/** @var CredentialRepository */
private $repository;
/** @var PublicKeyCredentialMapper */
private $credentialMapper;
/** @var ILogger */
private $logger;
/** @var IConfig */
private $config;
public function __construct(
CredentialRepository $repository,
PublicKeyCredentialMapper $credentialMapper,
ILogger $logger,
IConfig $config
) {
$this->repository = $repository;
$this->credentialMapper = $credentialMapper;
$this->logger = $logger;
$this->config = $config;
}
public function startRegistration(IUser $user, string $serverHost): PublicKeyCredentialCreationOptions {
$rpEntity = new PublicKeyCredentialRpEntity(
'Nextcloud', //Name
$this->stripPort($serverHost), //ID
null //Icon
);
$userEntity = new PublicKeyCredentialUserEntity(
$user->getUID(), //Name
$user->getUID(), //ID
$user->getDisplayName() //Display name
// 'https://foo.example.co/avatar/123e4567-e89b-12d3-a456-426655440000' //Icon
);
$challenge = random_bytes(32);
$publicKeyCredentialParametersList = [
new PublicKeyCredentialParameters('public-key', Algorithms::COSE_ALGORITHM_ES256),
new PublicKeyCredentialParameters('public-key', Algorithms::COSE_ALGORITHM_RS256),
];
$timeout = 60000;
$excludedPublicKeyDescriptors = [
];
$authenticatorSelectionCriteria = new AuthenticatorSelectionCriteria();
return new PublicKeyCredentialCreationOptions(
$rpEntity,
$userEntity,
$challenge,
$publicKeyCredentialParametersList,
$timeout,
$excludedPublicKeyDescriptors,
$authenticatorSelectionCriteria,
PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
null
);
}
public function finishRegister(PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions, string $name, string $data): PublicKeyCredentialEntity {
$tokenBindingHandler = new TokenBindingNotSupportedHandler();
$attestationStatementSupportManager = new AttestationStatementSupportManager();
$attestationStatementSupportManager->add(new NoneAttestationStatementSupport());
$attestationObjectLoader = new AttestationObjectLoader($attestationStatementSupportManager);
$publicKeyCredentialLoader = new PublicKeyCredentialLoader($attestationObjectLoader);
// Extension Output Checker Handler
$extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler();
// Authenticator Attestation Response Validator
$authenticatorAttestationResponseValidator = new AuthenticatorAttestationResponseValidator(
$attestationStatementSupportManager,
$this->repository,
$tokenBindingHandler,
$extensionOutputCheckerHandler
);
try {
// Load the data
$publicKeyCredential = $publicKeyCredentialLoader->load($data);
$response = $publicKeyCredential->getResponse();
// Check if the response is an Authenticator Attestation Response
if (!$response instanceof AuthenticatorAttestationResponse) {
throw new \RuntimeException('Not an authenticator attestation response');
}
// Check the response against the request
$request = ServerRequest::fromGlobals();
$publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check(
$response,
$publicKeyCredentialCreationOptions,
$request);
} catch (\Throwable $exception) {
throw $exception;
}
// Persist the data
return $this->repository->saveAndReturnCredentialSource($publicKeyCredentialSource, $name);
}
private function stripPort(string $serverHost): string {
return preg_replace('/(:\d+$)/', '', $serverHost);
}
public function startAuthentication(string $uid, string $serverHost): PublicKeyCredentialRequestOptions {
// List of registered PublicKeyCredentialDescriptor classes associated to the user
$registeredPublicKeyCredentialDescriptors = array_map(function (PublicKeyCredentialEntity $entity) {
$credential = $entity->toPublicKeyCredentialSource();
return new PublicKeyCredentialDescriptor(
$credential->getType(),
$credential->getPublicKeyCredentialId()
);
}, $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
);
}
public function finishAuthentication(PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions, string $data, string $uid) {
$attestationStatementSupportManager = new AttestationStatementSupportManager();
$attestationStatementSupportManager->add(new NoneAttestationStatementSupport());
$attestationObjectLoader = new AttestationObjectLoader($attestationStatementSupportManager);
$publicKeyCredentialLoader = new PublicKeyCredentialLoader($attestationObjectLoader);
$tokenBindingHandler = new TokenBindingNotSupportedHandler();
$extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler();
$algorithmManager = new \Cose\Algorithm\Manager();
$algorithmManager->add(new ES256());
$algorithmManager->add(new RS256());
$authenticatorAssertionResponseValidator = new AuthenticatorAssertionResponseValidator(
$this->repository,
$tokenBindingHandler,
$extensionOutputCheckerHandler,
$algorithmManager
);
try {
$this->logger->debug('Loading publickey credentials from: ' . $data);
// Load the data
$publicKeyCredential = $publicKeyCredentialLoader->load($data);
$response = $publicKeyCredential->getResponse();
// Check if the response is an Authenticator Attestation Response
if (!$response instanceof AuthenticatorAssertionResponse) {
throw new \RuntimeException('Not an authenticator attestation response');
}
// Check the response against the request
$request = ServerRequest::fromGlobals();
$publicKeyCredentialSource = $authenticatorAssertionResponseValidator->check(
$publicKeyCredential->getRawId(),
$response,
$publicKeyCredentialRequestOptions,
$request,
$uid
);
} catch (\Throwable $e) {
throw $e;
}
return true;
}
public function deleteRegistration(IUser $user, int $id): void {
try {
$entry = $this->credentialMapper->findById($user->getUID(), $id);
} catch (DoesNotExistException $e) {
$this->logger->warning("WebAuthn device $id does not exist, can't delete it");
return;
}
$this->credentialMapper->delete($entry);
}
public function isWebAuthnAvailable(): bool {
if (!extension_loaded('bcmath')) {
return false;
}
if (!extension_loaded('gmp')) {
return false;
}
if (!$this->config->getSystemValueBool('auth.webauthn.enabled', true)) {
return false;
}
return true;
}
}

View File

@ -83,6 +83,9 @@ class LoginControllerTest extends TestCase {
/** @var IInitialStateService|MockObject */
private $initialStateService;
/** @var \OC\Authentication\WebAuthn\Manager|MockObject */
private $webAuthnManager;
protected function setUp(): void {
parent::setUp();
$this->request = $this->createMock(IRequest::class);
@ -97,6 +100,8 @@ class LoginControllerTest extends TestCase {
$this->throttler = $this->createMock(Throttler::class);
$this->chain = $this->createMock(LoginChain::class);
$this->initialStateService = $this->createMock(IInitialStateService::class);
$this->webAuthnManager = $this->createMock(\OC\Authentication\WebAuthn\Manager::class);
$this->request->method('getRemoteAddress')
->willReturn('1.2.3.4');
@ -118,7 +123,8 @@ class LoginControllerTest extends TestCase {
$this->defaults,
$this->throttler,
$this->chain,
$this->initialStateService
$this->initialStateService,
$this->webAuthnManager
);
}

View File

@ -29,7 +29,7 @@
// between betas, final and RCs. This is _not_ the public version number. Reset minor/patchlevel
// when updating major/minor version number.
$OC_Version = [19, 0, 0, 0];
$OC_Version = [19, 0, 0, 1];
// The human readable string
$OC_VersionString = '19.0.0 alpha';