Signed-off-by: Roeland Jago Douma <roeland@famdouma.nl> Signed-off-by: npmbuildbot[bot] <npmbuildbot[bot]@users.noreply.github.com>tags/v19.0.0beta1
@@ -1 +1 @@ | |||
Subproject commit 179b231245bbae294d021b7158f99c3ffe7e2cb6 | |||
Subproject commit 7375853f9f77a5c2a82a23bf7bbaf4217be92450 |
@@ -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> |
@@ -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'], | |||
] | |||
]); |
@@ -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', | |||
); |
@@ -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', | |||
); | |||
@@ -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(); | |||
@@ -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([]); | |||
} | |||
} |
@@ -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; | |||
} | |||
} |
@@ -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> |
@@ -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> |
@@ -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> |
@@ -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() |
@@ -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') |
@@ -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) | |||
} |
@@ -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> |
@@ -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'), |
@@ -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 |
@@ -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())]); |
@@ -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([]); | |||
} | |||
} |
@@ -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; | |||
} | |||
} |
@@ -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'], |
@@ -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> |
@@ -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, |
@@ -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> |
@@ -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') |
@@ -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) | |||
} |
@@ -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, | |||
} | |||
}, |
@@ -626,6 +626,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', | |||
@@ -648,6 +650,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', | |||
@@ -814,6 +820,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', | |||
@@ -847,6 +854,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', |
@@ -655,6 +655,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', | |||
@@ -677,6 +679,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', | |||
@@ -843,6 +849,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', | |||
@@ -876,6 +883,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', |
@@ -51,17 +51,31 @@ class CreateSessionTokenCommand extends ALoginCommand { | |||
$tokenType = IToken::DO_NOT_REMEMBER; | |||
} | |||
$this->userSession->createSessionToken( | |||
$loginData->getRequest(), | |||
$loginData->getUser()->getUID(), | |||
$loginData->getUsername(), | |||
$loginData->getPassword(), | |||
$tokenType | |||
); | |||
$this->userSession->updateTokens( | |||
$loginData->getUser()->getUID(), | |||
$loginData->getPassword() | |||
); | |||
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(), | |||
$loginData->getUsername(), | |||
$loginData->getPassword(), | |||
$tokenType | |||
); | |||
$this->userSession->updateTokens( | |||
$loginData->getUser()->getUID(), | |||
$loginData->getPassword() | |||
); | |||
} | |||
return $this->processNextOrFinishSuccessfully($loginData); | |||
} |
@@ -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); | |||
} | |||
} |
@@ -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); | |||
} | |||
} |
@@ -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); | |||
} | |||
} |
@@ -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(), | |||
]; | |||
} | |||
} |
@@ -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); | |||
} | |||
} |
@@ -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; | |||
} | |||
} |
@@ -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 | |||
); | |||
} | |||
@@ -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'; |