diff --git a/3rdparty b/3rdparty index 179b231245bbae294d021b7158f99c3ffe7e2cb6..7375853f9f77a5c2a82a23bf7bbaf4217be92450 160000 --- a/3rdparty +++ b/3rdparty @@ -1 +1 @@ -Subproject commit 179b231245bbae294d021b7158f99c3ffe7e2cb6 +Subproject commit 7375853f9f77a5c2a82a23bf7bbaf4217be92450 diff --git a/apps/settings/appinfo/info.xml b/apps/settings/appinfo/info.xml index f2d65c0f3e80d30344bead4355e58cace17e64a3..af28d19a9c95413a42ffffabec595e5684d0707a 100644 --- a/apps/settings/appinfo/info.xml +++ b/apps/settings/appinfo/info.xml @@ -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> diff --git a/apps/settings/appinfo/routes.php b/apps/settings/appinfo/routes.php index 89b6c86993c2a1e47afb1b307103e67d172287d2..f0cc30084eb6c2b1068d14ecba4aba2d7270a8c1 100644 --- a/apps/settings/appinfo/routes.php +++ b/apps/settings/appinfo/routes.php @@ -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'], ] ]); diff --git a/apps/settings/composer/composer/autoload_classmap.php b/apps/settings/composer/composer/autoload_classmap.php index 841a1aa21eb4378ae8c50b5e1d1471a0b7bf197d..9d89ec1a9995108da7909352a33671f3b8465b49 100644 --- a/apps/settings/composer/composer/autoload_classmap.php +++ b/apps/settings/composer/composer/autoload_classmap.php @@ -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', ); diff --git a/apps/settings/composer/composer/autoload_static.php b/apps/settings/composer/composer/autoload_static.php index bac850545f3d29bc2639f3412e160981d282b503..c1c1abb189f6a1221b734a8c12a0fdf9e98faa63 100644 --- a/apps/settings/composer/composer/autoload_static.php +++ b/apps/settings/composer/composer/autoload_static.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', ); diff --git a/apps/settings/js/vue-0.js b/apps/settings/js/vue-0.js index bfe8db11c0e2b3e6811fa7dbe0892fec03359f0f..5e9dfb62598618551e0e100620a4647153ae350f 100644 Binary files a/apps/settings/js/vue-0.js and b/apps/settings/js/vue-0.js differ diff --git a/apps/settings/js/vue-0.js.map b/apps/settings/js/vue-0.js.map index 72d812c5e2a313595b589b986db3e96e5526ed92..22bcfa3d672820fcb4bd6c26c49de5128a52bf8c 100644 Binary files a/apps/settings/js/vue-0.js.map and b/apps/settings/js/vue-0.js.map differ diff --git a/apps/settings/js/vue-5.js b/apps/settings/js/vue-5.js index c68c470e02dbe4fa0128fa93c4b90751411d7db3..58c856d1e9c34f5cadc7ef3667e1ff603f84a276 100644 Binary files a/apps/settings/js/vue-5.js and b/apps/settings/js/vue-5.js differ diff --git a/apps/settings/js/vue-5.js.map b/apps/settings/js/vue-5.js.map index 9ed7d6d8a40b6257724e35319fbf7d24a0d55f8d..fefe5e728ee8c5808a1e3efbd2a75a8292119464 100644 Binary files a/apps/settings/js/vue-5.js.map and b/apps/settings/js/vue-5.js.map differ diff --git a/apps/settings/js/vue-6.js b/apps/settings/js/vue-6.js index 0ade2c5e8a988d9ceb1e482cc0489138fc2199ef..8da3a9731554ecead700adf0df9b5725a289000d 100644 Binary files a/apps/settings/js/vue-6.js and b/apps/settings/js/vue-6.js differ diff --git a/apps/settings/js/vue-6.js.map b/apps/settings/js/vue-6.js.map index bca2a1556cf684bb0c0f395900f77943b472c3b0..6c51679baad2923fc77e9e0a480588028aef07bd 100644 Binary files a/apps/settings/js/vue-6.js.map and b/apps/settings/js/vue-6.js.map differ diff --git a/apps/settings/js/vue-7.js b/apps/settings/js/vue-7.js index 67e9105391bff67007f39ebfa34b0a400e2d2baa..53716b14ca4e501c833c7fc7d5fc07f73611e0c0 100644 Binary files a/apps/settings/js/vue-7.js and b/apps/settings/js/vue-7.js differ diff --git a/apps/settings/js/vue-7.js.map b/apps/settings/js/vue-7.js.map index 3ef0002e5c18ebda3a903e1cf686fe7c3612f55b..2ab3b17365e7079bf27b93728cfbce558e556d1c 100644 Binary files a/apps/settings/js/vue-7.js.map and b/apps/settings/js/vue-7.js.map differ diff --git a/apps/settings/js/vue-8.js b/apps/settings/js/vue-8.js new file mode 100644 index 0000000000000000000000000000000000000000..c0463c5496512bb534240f55c9b250b51fd0e9dc Binary files /dev/null and b/apps/settings/js/vue-8.js differ diff --git a/apps/settings/js/vue-8.js.map b/apps/settings/js/vue-8.js.map new file mode 100644 index 0000000000000000000000000000000000000000..2cf4c138a33d6941dac5222522048eae1350efd2 Binary files /dev/null and b/apps/settings/js/vue-8.js.map differ diff --git a/apps/settings/js/vue-settings-admin-security.js b/apps/settings/js/vue-settings-admin-security.js index 688da0ec399d071294cefc60112ac6419ed0c352..6d0ac833051bb85143375c0090174e5222cf1b64 100644 Binary files a/apps/settings/js/vue-settings-admin-security.js and b/apps/settings/js/vue-settings-admin-security.js differ diff --git a/apps/settings/js/vue-settings-admin-security.js.map b/apps/settings/js/vue-settings-admin-security.js.map index f36016b155246d4d8e2c2f8ad748642e645937ad..10cf2ad7b949297457c0bb51dd9a76c990c9d3bf 100644 Binary files a/apps/settings/js/vue-settings-admin-security.js.map and b/apps/settings/js/vue-settings-admin-security.js.map differ diff --git a/apps/settings/js/vue-settings-apps-users-management.js b/apps/settings/js/vue-settings-apps-users-management.js index abffc29851a9d43422e312125ff3ac987df215d4..6b1e543e7d20e76d1d48354a31b75370554b6c80 100644 Binary files a/apps/settings/js/vue-settings-apps-users-management.js and b/apps/settings/js/vue-settings-apps-users-management.js differ diff --git a/apps/settings/js/vue-settings-apps-users-management.js.map b/apps/settings/js/vue-settings-apps-users-management.js.map index 8a1a5c6342448f98962e192b0686923408dccee2..baa312b4b715ae1a6f8f194362ab2d5c29f79091 100644 Binary files a/apps/settings/js/vue-settings-apps-users-management.js.map and b/apps/settings/js/vue-settings-apps-users-management.js.map differ diff --git a/apps/settings/js/vue-settings-personal-security.js b/apps/settings/js/vue-settings-personal-security.js index 4b4ac275885b38b9b4075740818d6a20bf9503dc..b78390048a1d372a691404b033f23cc20f23980d 100644 Binary files a/apps/settings/js/vue-settings-personal-security.js and b/apps/settings/js/vue-settings-personal-security.js differ diff --git a/apps/settings/js/vue-settings-personal-security.js.map b/apps/settings/js/vue-settings-personal-security.js.map index 302561a771c3bd75483d1c779fb095dc8bc3e988..6085e92adc3516a95c58a9390625efb6ed9f4f36 100644 Binary files a/apps/settings/js/vue-settings-personal-security.js.map and b/apps/settings/js/vue-settings-personal-security.js.map differ diff --git a/apps/settings/js/vue-settings-personal-webauthn.js b/apps/settings/js/vue-settings-personal-webauthn.js new file mode 100644 index 0000000000000000000000000000000000000000..584006ee78b6b385956adeb114afdb7b15fe77cd Binary files /dev/null and b/apps/settings/js/vue-settings-personal-webauthn.js differ diff --git a/apps/settings/js/vue-settings-personal-webauthn.js.map b/apps/settings/js/vue-settings-personal-webauthn.js.map new file mode 100644 index 0000000000000000000000000000000000000000..b4d05aa10457f02d885ee1799d790af7b987e621 Binary files /dev/null and b/apps/settings/js/vue-settings-personal-webauthn.js.map differ diff --git a/apps/settings/lib/AppInfo/Application.php b/apps/settings/lib/AppInfo/Application.php index 96c8734129017d239f37c32b2fcf863192bd720c..230b2377cdac61e65b751696a53d92239a9acde2 100644 --- a/apps/settings/lib/AppInfo/Application.php +++ b/apps/settings/lib/AppInfo/Application.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(); diff --git a/apps/settings/lib/Controller/WebAuthnController.php b/apps/settings/lib/Controller/WebAuthnController.php new file mode 100644 index 0000000000000000000000000000000000000000..e9b45105a81b61ae6ab8c1376838bb5e87ff233a --- /dev/null +++ b/apps/settings/lib/Controller/WebAuthnController.php @@ -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([]); + } +} diff --git a/apps/settings/lib/Settings/Personal/Security/WebAuthn.php b/apps/settings/lib/Settings/Personal/Security/WebAuthn.php new file mode 100644 index 0000000000000000000000000000000000000000..e4e2b3fe9359d92c7d92c22f8acfc2142425a97d --- /dev/null +++ b/apps/settings/lib/Settings/Personal/Security/WebAuthn.php @@ -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; + } +} diff --git a/apps/settings/src/components/WebAuthn/AddDevice.vue b/apps/settings/src/components/WebAuthn/AddDevice.vue new file mode 100644 index 0000000000000000000000000000000000000000..05b649ec313427b31cfe273da7d456dedce1dfca --- /dev/null +++ b/apps/settings/src/components/WebAuthn/AddDevice.vue @@ -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> diff --git a/apps/settings/src/components/WebAuthn/Device.vue b/apps/settings/src/components/WebAuthn/Device.vue new file mode 100644 index 0000000000000000000000000000000000000000..fc1bab3c8b0c2ce248b6277b32a3aabf7f894360 --- /dev/null +++ b/apps/settings/src/components/WebAuthn/Device.vue @@ -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> diff --git a/apps/settings/src/components/WebAuthn/Section.vue b/apps/settings/src/components/WebAuthn/Section.vue new file mode 100644 index 0000000000000000000000000000000000000000..cd09ec43c1abd269f3754c95b7257e22760cf78c --- /dev/null +++ b/apps/settings/src/components/WebAuthn/Section.vue @@ -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> diff --git a/apps/settings/src/logger.js b/apps/settings/src/logger.js new file mode 100644 index 0000000000000000000000000000000000000000..275771ce4c5cea8c004abe1d0d8ad000e4066b91 --- /dev/null +++ b/apps/settings/src/logger.js @@ -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() diff --git a/apps/settings/src/main-personal-webauth.js b/apps/settings/src/main-personal-webauth.js new file mode 100644 index 0000000000000000000000000000000000000000..e6e302df5f8df65ffca672dc9846e5a170073732 --- /dev/null +++ b/apps/settings/src/main-personal-webauth.js @@ -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') diff --git a/apps/settings/src/service/WebAuthnRegistrationSerice.js b/apps/settings/src/service/WebAuthnRegistrationSerice.js new file mode 100644 index 0000000000000000000000000000000000000000..4c82c5b9fa75dc4dba3d319d69a09abda3ab32ae --- /dev/null +++ b/apps/settings/src/service/WebAuthnRegistrationSerice.js @@ -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) +} diff --git a/apps/settings/templates/settings/personal/security/webauthn.php b/apps/settings/templates/settings/personal/security/webauthn.php new file mode 100644 index 0000000000000000000000000000000000000000..268e06aef7dd6913736346c45d4c3f905c4dc489 --- /dev/null +++ b/apps/settings/templates/settings/personal/security/webauthn.php @@ -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> diff --git a/apps/settings/webpack.js b/apps/settings/webpack.js index 7c887861f1e05834061915f7fabe46d0347cac89..d8dbb722235fcd3c838541022f1d9490edb7f7f7 100644 --- a/apps/settings/webpack.js +++ b/apps/settings/webpack.js @@ -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'), diff --git a/config/config.sample.php b/config/config.sample.php index 0e19f69826149812dbeb13248bcd21b42c8649db..71d22fbe2b4007c71d2e8c490f649159f84b8451 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -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 diff --git a/core/Controller/LoginController.php b/core/Controller/LoginController.php index 13aef8f67ab0d8e3537d7dfe5769d1a3d5775aff..b3f7bb310ba05caf46b9a5b031412be4e30482a2 100644 --- a/core/Controller/LoginController.php +++ b/core/Controller/LoginController.php @@ -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())]); diff --git a/core/Controller/WebAuthnController.php b/core/Controller/WebAuthnController.php new file mode 100644 index 0000000000000000000000000000000000000000..0b98a58c1eb8b40c56d2bdcc7ea2953ac1d61134 --- /dev/null +++ b/core/Controller/WebAuthnController.php @@ -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([]); + } +} diff --git a/core/Migrations/Version19000Date20200211083441.php b/core/Migrations/Version19000Date20200211083441.php new file mode 100644 index 0000000000000000000000000000000000000000..ef4d4fde54f12ab8ab1f02ff081d3da4a6eadc02 --- /dev/null +++ b/core/Migrations/Version19000Date20200211083441.php @@ -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; + } +} diff --git a/core/js/dist/install.js b/core/js/dist/install.js index 09c56a913deccaf8128bd177996586f7762abf05..4133714ff75926ba10fc4bf1b9798f3cb95c6478 100644 Binary files a/core/js/dist/install.js and b/core/js/dist/install.js differ diff --git a/core/js/dist/install.js.map b/core/js/dist/install.js.map index 1662a495081607287b11732f2d37cc576030d1bb..14afc78c21e9b66b6ad3785365c65c48578e1bf5 100644 Binary files a/core/js/dist/install.js.map and b/core/js/dist/install.js.map differ diff --git a/core/js/dist/login.js b/core/js/dist/login.js index d2786d13f1d111581abb77ac6a3435bd432aaa46..216d1c59a385dfb6e4fc588fd4e532ec3acfc740 100644 Binary files a/core/js/dist/login.js and b/core/js/dist/login.js differ diff --git a/core/js/dist/login.js.map b/core/js/dist/login.js.map index 16dfe2ea984b0ea34b41dfb64f33c6a64eed78a8..3e558a8d81647e00a0d3e653ad6e2e7fb5573c37 100644 Binary files a/core/js/dist/login.js.map and b/core/js/dist/login.js.map differ diff --git a/core/js/dist/main.js b/core/js/dist/main.js index ac565391a66e57afaf5c3d9c85e0d9689cf17607..521e9eec116ecdc0dbfebe0609b8f91c31821d80 100644 Binary files a/core/js/dist/main.js and b/core/js/dist/main.js differ diff --git a/core/js/dist/main.js.map b/core/js/dist/main.js.map index a5ffba9f3b22b8b4e3d69c718886f832247b8aba..078bc851b4602361264a1dee081a9e5a41586d6c 100644 Binary files a/core/js/dist/main.js.map and b/core/js/dist/main.js.map differ diff --git a/core/js/dist/maintenance.js b/core/js/dist/maintenance.js index 5e579b3b3f8b06c2a7dcccc1fb0f704a7a58f158..3801d52c83792ee4f055f1723d2776f3478778cd 100644 Binary files a/core/js/dist/maintenance.js and b/core/js/dist/maintenance.js differ diff --git a/core/js/dist/maintenance.js.map b/core/js/dist/maintenance.js.map index 59cc4f65c41b8940d6ada2e6bdc22860ba90a102..e28537ba2e61052fc674f94847abef8375ba2465 100644 Binary files a/core/js/dist/maintenance.js.map and b/core/js/dist/maintenance.js.map differ diff --git a/core/js/dist/recommendedapps.js b/core/js/dist/recommendedapps.js index 65677ffab725ec981def930e0b4ac0eadb517f47..c682c09617550d3e76e280e82d03af4c75bb9a58 100644 Binary files a/core/js/dist/recommendedapps.js and b/core/js/dist/recommendedapps.js differ diff --git a/core/js/dist/recommendedapps.js.map b/core/js/dist/recommendedapps.js.map index de0903cf92d41cdc2e66cac8fda262cd0d70ab98..7d51add261f0e8ade6323ec9012043d58bea678a 100644 Binary files a/core/js/dist/recommendedapps.js.map and b/core/js/dist/recommendedapps.js.map differ diff --git a/core/routes.php b/core/routes.php index 5fb13bc298afccf1fc9addf73ec6a0f2a7bf5721..8d03be05bb3a219757e3e6a542c2ba3fe3c53489 100644 --- a/core/routes.php +++ b/core/routes.php @@ -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'], diff --git a/core/src/components/login/LoginButton.vue b/core/src/components/login/LoginButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..f7d426e6c63abd4dfb9ef5259924d5e9eb20e197 --- /dev/null +++ b/core/src/components/login/LoginButton.vue @@ -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> diff --git a/core/src/components/login/LoginForm.vue b/core/src/components/login/LoginForm.vue index 687896ceb5475ab8de2f2ee1cec2854d6d492496..a20ce6dc4c2302326e443f4745cc6c83753aeba8 100644 --- a/core/src/components/login/LoginForm.vue +++ b/core/src/components/login/LoginForm.vue @@ -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, diff --git a/core/src/components/login/PasswordLessLoginForm.vue b/core/src/components/login/PasswordLessLoginForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..028f7d547da72c4c931e7bd2b9b4de106ac505df --- /dev/null +++ b/core/src/components/login/PasswordLessLoginForm.vue @@ -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> diff --git a/core/src/login.js b/core/src/login.js index 7270442c83e1f9ff7dda9c9b6bacfa889506e48e..bfcfabf169f3f098ab6d39aed11fb715dba06144 100644 --- a/core/src/login.js +++ b/core/src/login.js @@ -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') diff --git a/core/src/service/WebAuthnAuthenticationService.js b/core/src/service/WebAuthnAuthenticationService.js new file mode 100644 index 0000000000000000000000000000000000000000..91f191770660fa69f6ecf47f513c0fa522197c09 --- /dev/null +++ b/core/src/service/WebAuthnAuthenticationService.js @@ -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) +} diff --git a/core/src/views/Login.vue b/core/src/views/Login.vue index baea18cbe3c3a376fa6bb0ce7e31cc64ab4896c1..a50e6c5c72cc3a56d2c16391ffd723cef892d192 100644 --- a/core/src/views/Login.vue +++ b/core/src/views/Login.vue @@ -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, } }, diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 388c7906eb81c7ae2311d76ec6e7aa0b3b93d4d5..3672146205f1cb9b3230ccd3e42727146ab2587e 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -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', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index cfc6d9842dfa765524222b1cc4f785a648ffd098..99fe8d8b4c7eb4f2945ab718ef4fcac0b67249c1 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.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', diff --git a/lib/private/Authentication/Login/CreateSessionTokenCommand.php b/lib/private/Authentication/Login/CreateSessionTokenCommand.php index fbc8215e67f0c75033d8a1312ec3fc9dcfd69a47..05b6c27f5652cf7118a13cb4408ee76d3036e1fd 100644 --- a/lib/private/Authentication/Login/CreateSessionTokenCommand.php +++ b/lib/private/Authentication/Login/CreateSessionTokenCommand.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); } diff --git a/lib/private/Authentication/Login/WebAuthnChain.php b/lib/private/Authentication/Login/WebAuthnChain.php new file mode 100644 index 0000000000000000000000000000000000000000..dfc6943e853fc169df01123aec47658f60327174 --- /dev/null +++ b/lib/private/Authentication/Login/WebAuthnChain.php @@ -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); + } +} diff --git a/lib/private/Authentication/Login/WebAuthnLoginCommand.php b/lib/private/Authentication/Login/WebAuthnLoginCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..e477a243c56397912b052f2924e850c5caa72ee0 --- /dev/null +++ b/lib/private/Authentication/Login/WebAuthnLoginCommand.php @@ -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); + } + +} diff --git a/lib/private/Authentication/WebAuthn/CredentialRepository.php b/lib/private/Authentication/WebAuthn/CredentialRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..c6f8cdfd8886d8c8e3f1ad506bf66420393b3d4f --- /dev/null +++ b/lib/private/Authentication/WebAuthn/CredentialRepository.php @@ -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); + } + +} diff --git a/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialEntity.php b/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialEntity.php new file mode 100644 index 0000000000000000000000000000000000000000..3b0413aef00644f20cd8385aa3875cd0f0dc260c --- /dev/null +++ b/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialEntity.php @@ -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(), + ]; + } + +} diff --git a/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialMapper.php b/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialMapper.php new file mode 100644 index 0000000000000000000000000000000000000000..c931ccbb3f0f0a5f27e6afd37702d8febd49ec66 --- /dev/null +++ b/lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialMapper.php @@ -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); + } + +} diff --git a/lib/private/Authentication/WebAuthn/Manager.php b/lib/private/Authentication/WebAuthn/Manager.php new file mode 100644 index 0000000000000000000000000000000000000000..32a90345b5c55bc9b19865da00fb20762119dddd --- /dev/null +++ b/lib/private/Authentication/WebAuthn/Manager.php @@ -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; + } +} diff --git a/tests/Core/Controller/LoginControllerTest.php b/tests/Core/Controller/LoginControllerTest.php index cddf89527db6b2999eaa8b888644d5cc1e8eb1fe..c1177d84e1ee645b1479f45af2165f8141823670 100644 --- a/tests/Core/Controller/LoginControllerTest.php +++ b/tests/Core/Controller/LoginControllerTest.php @@ -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 ); } diff --git a/version.php b/version.php index f39ef9497e9f0a9b328450f7bc2a44b2ec637617..4db60d087187b6d6f347655d0ef103e02299cbd8 100644 --- a/version.php +++ b/version.php @@ -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';