Browse Source

Start with webauthn

Signed-off-by: Roeland Jago Douma <roeland@famdouma.nl>
Signed-off-by: npmbuildbot[bot] <npmbuildbot[bot]@users.noreply.github.com>
tags/v19.0.0beta1
Roeland Jago Douma 4 years ago
parent
commit
53db05a1f6
No account linked to committer's email address
66 changed files with 2522 additions and 307 deletions
  1. 1
    1
      3rdparty
  2. 1
    0
      apps/settings/appinfo/info.xml
  3. 4
    0
      apps/settings/appinfo/routes.php
  4. 2
    0
      apps/settings/composer/composer/autoload_classmap.php
  5. 2
    0
      apps/settings/composer/composer/autoload_static.php
  6. 16
    16
      apps/settings/js/vue-0.js
  7. 1
    1
      apps/settings/js/vue-0.js.map
  8. 70
    4
      apps/settings/js/vue-5.js
  9. 1
    1
      apps/settings/js/vue-5.js.map
  10. 3
    161
      apps/settings/js/vue-6.js
  11. 1
    1
      apps/settings/js/vue-6.js.map
  12. 181
    2
      apps/settings/js/vue-7.js
  13. 1
    1
      apps/settings/js/vue-7.js.map
  14. 2
    0
      apps/settings/js/vue-8.js
  15. 1
    0
      apps/settings/js/vue-8.js.map
  16. 17
    17
      apps/settings/js/vue-settings-admin-security.js
  17. 1
    1
      apps/settings/js/vue-settings-admin-security.js.map
  18. 6
    6
      apps/settings/js/vue-settings-apps-users-management.js
  19. 1
    1
      apps/settings/js/vue-settings-apps-users-management.js.map
  20. 17
    17
      apps/settings/js/vue-settings-personal-security.js
  21. 1
    1
      apps/settings/js/vue-settings-personal-security.js.map
  22. 162
    0
      apps/settings/js/vue-settings-personal-webauthn.js
  23. 1
    0
      apps/settings/js/vue-settings-personal-webauthn.js.map
  24. 2
    1
      apps/settings/lib/AppInfo/Application.php
  25. 114
    0
      apps/settings/lib/Controller/WebAuthnController.php
  26. 80
    0
      apps/settings/lib/Settings/Personal/Security/WebAuthn.php
  27. 215
    0
      apps/settings/src/components/WebAuthn/AddDevice.vue
  28. 65
    0
      apps/settings/src/components/WebAuthn/Device.vue
  29. 109
    0
      apps/settings/src/components/WebAuthn/Section.vue
  30. 27
    0
      apps/settings/src/logger.js
  31. 40
    0
      apps/settings/src/main-personal-webauth.js
  32. 43
    0
      apps/settings/src/service/WebAuthnRegistrationSerice.js
  33. 31
    0
      apps/settings/templates/settings/personal/security/webauthn.php
  34. 2
    1
      apps/settings/webpack.js
  35. 5
    0
      config/config.sample.php
  36. 8
    1
      core/Controller/LoginController.php
  37. 117
    0
      core/Controller/WebAuthnController.php
  38. 45
    0
      core/Migrations/Version19000Date20200211083441.php
  39. 2
    2
      core/js/dist/install.js
  40. 1
    1
      core/js/dist/install.js.map
  41. 15
    15
      core/js/dist/login.js
  42. 1
    1
      core/js/dist/login.js.map
  43. 13
    13
      core/js/dist/main.js
  44. 1
    1
      core/js/dist/main.js.map
  45. 8
    8
      core/js/dist/maintenance.js
  46. 1
    1
      core/js/dist/maintenance.js.map
  47. 2
    2
      core/js/dist/recommendedapps.js
  48. 1
    1
      core/js/dist/recommendedapps.js.map
  49. 4
    0
      core/routes.php
  50. 56
    0
      core/src/components/login/LoginButton.vue
  51. 5
    14
      core/src/components/login/LoginForm.vue
  52. 208
    0
      core/src/components/login/PasswordLessLoginForm.vue
  53. 3
    0
      core/src/login.js
  54. 37
    0
      core/src/service/WebAuthnAuthenticationService.js
  55. 35
    1
      core/src/views/Login.vue
  56. 8
    0
      lib/composer/composer/autoload_classmap.php
  57. 8
    0
      lib/composer/composer/autoload_static.php
  58. 25
    11
      lib/private/Authentication/Login/CreateSessionTokenCommand.php
  59. 96
    0
      lib/private/Authentication/Login/WebAuthnChain.php
  60. 48
    0
      lib/private/Authentication/Login/WebAuthnLoginCommand.php
  61. 93
    0
      lib/private/Authentication/WebAuthn/CredentialRepository.php
  62. 92
    0
      lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialEntity.php
  63. 86
    0
      lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialMapper.php
  64. 269
    0
      lib/private/Authentication/WebAuthn/Manager.php
  65. 7
    1
      tests/Core/Controller/LoginControllerTest.php
  66. 1
    1
      version.php

+ 1
- 1
3rdparty

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

+ 1
- 0
apps/settings/appinfo/info.xml View File

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

+ 4
- 0
apps/settings/appinfo/routes.php View File

@@ -90,5 +90,9 @@ $application->registerRoutes($this, [
['name' => 'TwoFactorSettings#update', 'url' => '/settings/api/admin/twofactorauth', 'verb' => 'PUT'],

['name' => 'Help#help', 'url' => '/settings/help/{mode}', 'verb' => 'GET', 'defaults' => ['mode' => '']],

['name' => 'WebAuthn#startRegistration', 'url' => '/settings/api/personal/webauthn/registration', 'verb' => 'GET'],
['name' => 'WebAuthn#finishRegistration', 'url' => '/settings/api/personal/webauthn/registration', 'verb' => 'POST'],
['name' => 'WebAuthn#deleteRegistration', 'url' => '/settings/api/personal/webauthn/registration/{id}', 'verb' => 'DELETE'],
]
]);

+ 2
- 0
apps/settings/composer/composer/autoload_classmap.php View File

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

+ 2
- 0
apps/settings/composer/composer/autoload_static.php View File

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


+ 16
- 16
apps/settings/js/vue-0.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/settings/js/vue-0.js.map
File diff suppressed because it is too large
View File


+ 70
- 4
apps/settings/js/vue-5.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/settings/js/vue-5.js.map
File diff suppressed because it is too large
View File


+ 3
- 161
apps/settings/js/vue-6.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/settings/js/vue-6.js.map
File diff suppressed because it is too large
View File


+ 181
- 2
apps/settings/js/vue-7.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/settings/js/vue-7.js.map
File diff suppressed because it is too large
View File


+ 2
- 0
apps/settings/js/vue-8.js
File diff suppressed because it is too large
View File


+ 1
- 0
apps/settings/js/vue-8.js.map
File diff suppressed because it is too large
View File


+ 17
- 17
apps/settings/js/vue-settings-admin-security.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/settings/js/vue-settings-admin-security.js.map
File diff suppressed because it is too large
View File


+ 6
- 6
apps/settings/js/vue-settings-apps-users-management.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/settings/js/vue-settings-apps-users-management.js.map
File diff suppressed because it is too large
View File


+ 17
- 17
apps/settings/js/vue-settings-personal-security.js
File diff suppressed because it is too large
View File


+ 1
- 1
apps/settings/js/vue-settings-personal-security.js.map
File diff suppressed because it is too large
View File


+ 162
- 0
apps/settings/js/vue-settings-personal-webauthn.js
File diff suppressed because it is too large
View File


+ 1
- 0
apps/settings/js/vue-settings-personal-webauthn.js.map
File diff suppressed because it is too large
View File


+ 2
- 1
apps/settings/lib/AppInfo/Application.php View File

@@ -55,12 +55,13 @@ use Symfony\Component\EventDispatcher\GenericEvent;

class Application extends App {

const APP_ID = 'settings';

/**
* @param array $urlParams
*/
public function __construct(array $urlParams=[]){
parent::__construct('settings', $urlParams);
parent::__construct(self::APP_ID, $urlParams);

$container = $this->getContainer();


+ 114
- 0
apps/settings/lib/Controller/WebAuthnController.php View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

namespace OCA\Settings\Controller;

use OC\Authentication\WebAuthn\Manager;
use OCA\Settings\AppInfo\Application;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\ILogger;
use OCP\IRequest;
use OCP\ISession;
use OCP\IUserSession;
use Webauthn\PublicKeyCredentialCreationOptions;

class WebAuthnController extends Controller {

private const WEBAUTHN_REGISTRATION = 'webauthn_registration';

/** @var Manager */
private $manager;

/** @var IUserSession */
private $userSession;
/**
* @var ISession
*/
private $session;
/**
* @var ILogger
*/
private $logger;

public function __construct(IRequest $request, ILogger $logger, Manager $webAuthnManager, IUserSession $userSession, ISession $session) {
parent::__construct(Application::APP_ID, $request);

$this->manager = $webAuthnManager;
$this->userSession = $userSession;
$this->session = $session;
$this->logger = $logger;
}

/**
* @NoAdminRequired
* @PasswordConfirmationRequired
* @UseSession
* @NoCSRFRequired
*/
public function startRegistration(): JSONResponse {
$this->logger->debug('Starting WebAuthn registration');

$credentialOptions = $this->manager->startRegistration($this->userSession->getUser(), $this->request->getServerHost());

// Set this in the session since we need it on finish
$this->session->set(self::WEBAUTHN_REGISTRATION, $credentialOptions);

return new JSONResponse($credentialOptions);
}

/**
* @NoAdminRequired
* @PasswordConfirmationRequired
* @UseSession
*/
public function finishRegistration(string $name, string $data): JSONResponse {
$this->logger->debug('Finishing WebAuthn registration');

if (!$this->session->exists(self::WEBAUTHN_REGISTRATION)) {
$this->logger->debug('Trying to finish WebAuthn registration without session data');
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
}

// Obtain the publicKeyCredentialOptions from when we started the registration
$publicKeyCredentialCreationOptions = PublicKeyCredentialCreationOptions::createFromArray($this->session->get(self::WEBAUTHN_REGISTRATION));

$this->session->remove(self::WEBAUTHN_REGISTRATION);

return new JSONResponse($this->manager->finishRegister($publicKeyCredentialCreationOptions, $name, $data));
}

/**
* @NoAdminRequired
* @PasswordConfirmationRequired
*/
public function deleteRegistration(int $id): JSONResponse {
$this->logger->debug('Finishing WebAuthn registration');

$this->manager->deleteRegistration($this->userSession->getUser(), $id);

return new JSONResponse([]);
}
}

+ 80
- 0
apps/settings/lib/Settings/Personal/Security/WebAuthn.php View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

namespace OCA\Settings\Settings\Personal\Security;

use OC\Authentication\WebAuthn\Db\PublicKeyCredentialMapper;
use OC\Authentication\WebAuthn\Manager;
use OCA\Settings\AppInfo\Application;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IInitialStateService;
use OCP\Settings\ISettings;

class WebAuthn implements ISettings {

/** @var PublicKeyCredentialMapper */
private $mapper;

/** @var string */
private $uid;

/** @var IInitialStateService */
private $initialStateService;

/** @var Manager */
private $manager;

public function __construct(PublicKeyCredentialMapper $mapper,
string $UserId,
IInitialStateService $initialStateService,
Manager $manager) {
$this->mapper = $mapper;
$this->uid = $UserId;
$this->initialStateService = $initialStateService;
$this->manager = $manager;
}

public function getForm() {
$this->initialStateService->provideInitialState(
Application::APP_ID,
'webauthn-devices',
$this->mapper->findAllForUid($this->uid)
);

return new TemplateResponse('settings', 'settings/personal/security/webauthn', [
]);
}

public function getSection(): ?string {
if (!$this->manager->isWebAuthnAvailable()) {
return null;
}

return 'security';
}

public function getPriority(): int {
return 20;
}
}

+ 215
- 0
apps/settings/src/components/WebAuthn/AddDevice.vue View File

@@ -0,0 +1,215 @@
<!--
- @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
-
- @author Roeland Jago Douma <roeland@famdouma.nl>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-->

<template>
<div v-if="!isHttps">
{{ t('settings', 'Passwordless authentication requires a secure connection.') }}
</div>
<div v-else>
<div v-if="step === RegistrationSteps.READY">
<button @click="start">
{{ t('settings', 'Add Webauthn device') }}
</button>
</div>

<div v-else-if="step === RegistrationSteps.REGISTRATION"
class="new-webauthn-device">
<span class="icon-loading-small webauthn-loading" />
{{ t('settings', 'Please authorize your WebAuthn device.') }}
</div>

<div v-else-if="step === RegistrationSteps.NAMING"
class="new-webauthn-device">
<span class="icon-loading-small webauthn-loading" />
<input v-model="name"
type="text"
:placeholder="t('settings', 'Name your device')"
@:keyup.enter="submit">
<button @click="submit">
{{ t('settings', 'Add') }}
</button>
</div>

<div v-else-if="step === RegistrationSteps.PERSIST"
class="new-webauthn-device">
<span class="icon-loading-small webauthn-loading" />
{{ t('settings', 'Adding your device …') }}
</div>

<div v-else>
Invalid registration step. This should not have happened.
</div>
</div>
</template>

<script>
import confirmPassword from '@nextcloud/password-confirmation'

import logger from '../../logger'
import {
startRegistration,
finishRegistration,
} from '../../service/WebAuthnRegistrationSerice'

const logAndPass = (text) => (data) => {
logger.debug(text)
return data
}

const RegistrationSteps = Object.freeze({
READY: 1,
REGISTRATION: 2,
NAMING: 3,
PERSIST: 4,
})

export default {
name: 'AddDevice',
props: {
httpWarning: Boolean,
isHttps: {
type: Boolean,
default: false
}
},
data() {
return {
name: '',
credential: {},
RegistrationSteps,
step: RegistrationSteps.READY,
}
},
methods: {
arrayToBase64String(a) {
return btoa(String.fromCharCode(...a))
},
start() {
this.step = RegistrationSteps.REGISTRATION
console.debug('Starting WebAuthn registration')

return confirmPassword()
.then(this.getRegistrationData)
.then(this.register.bind(this))
.then(() => { this.step = RegistrationSteps.NAMING })
.catch(err => {
console.error(err.name, err.message)
this.step = RegistrationSteps.READY
})
},

getRegistrationData() {
console.debug('Fetching webauthn registration data')

const base64urlDecode = function(input) {
// Replace non-url compatible chars with base64 standard chars
input = input
.replace(/-/g, '+')
.replace(/_/g, '/')

// Pad out with standard base64 required padding characters
const pad = input.length % 4
if (pad) {
if (pad === 1) {
throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding')
}
input += new Array(5 - pad).join('=')
}

return window.atob(input)
}

return startRegistration()
.then(publicKey => {
console.debug(publicKey)
publicKey.challenge = Uint8Array.from(base64urlDecode(publicKey.challenge), c => c.charCodeAt(0))
publicKey.user.id = Uint8Array.from(publicKey.user.id, c => c.charCodeAt(0))
return publicKey
})
.catch(err => {
console.error('Error getting webauthn registration data from server', err)
throw new Error(t('settings', 'Server error while trying to add webauthn device'))
})
},

register(publicKey) {
console.debug('starting webauthn registration')

return navigator.credentials.create({ publicKey })
.then(data => {
this.credential = {
id: data.id,
type: data.type,
rawId: this.arrayToBase64String(new Uint8Array(data.rawId)),
response: {
clientDataJSON: this.arrayToBase64String(new Uint8Array(data.response.clientDataJSON)),
attestationObject: this.arrayToBase64String(new Uint8Array(data.response.attestationObject)),
},
}
})
},

submit() {
this.step = RegistrationSteps.PERSIST

return confirmPassword()
.then(logAndPass('confirmed password'))
.then(this.saveRegistrationData)
.then(logAndPass('registration data saved'))
.then(() => this.reset())
.then(logAndPass('app reset'))
.catch(console.error.bind(this))
},

async saveRegistrationData() {
try {
const device = await finishRegistration(this.name, JSON.stringify(this.credential))

logger.info('new device added', { device })

this.$emit('added', device)
} catch (err) {
logger.error('Error persisting webauthn registration', { error: err })
throw new Error(t('settings', 'Server error while trying to complete webauthn device registration'))
}
},

reset() {
this.name = ''
this.registrationData = {}
this.step = RegistrationSteps.READY
},
},
}
</script>

<style scoped>
.webauthn-loading {
display: inline-block;
vertical-align: sub;
margin-left: 2px;
margin-right: 2px;
}

.new-webauthn-device {
line-height: 300%;
}
</style>

+ 65
- 0
apps/settings/src/components/WebAuthn/Device.vue View File

@@ -0,0 +1,65 @@
<!--
- @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
-
- @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-->

<template>
<div class="webauthn-device">
<span class="icon-webauthn-device" />
{{ name || t('settings', 'Unnamed device') }}
<Actions :force-menu="true">
<ActionButton icon="icon-delete" @click="$emit('delete')">
{{ t('settings', 'Delete') }}
</ActionButton>
</Actions>
</div>
</template>

<script>
import Actions from '@nextcloud/vue/dist/Components/Actions'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'

export default {
name: 'Device',
components: {
ActionButton,
Actions,
},
props: {
name: {
type: String,
required: true,
},
},
}
</script>

<style scoped>
.webauthn-device {
line-height: 300%;
display: flex;
}

.icon-webauthn-device {
display: inline-block;
background-size: 100%;
padding: 3px;
margin: 3px;
}
</style>

+ 109
- 0
apps/settings/src/components/WebAuthn/Section.vue View File

@@ -0,0 +1,109 @@
<!--
- @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
-
- @author Roeland Jago Douma <roeland@famdouma.nl>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-->

<template>
<div id="security-webauthn" class="section">
<h2>{{ t('settings', 'Passwordless Authentication') }}</h2>
<p class="settings-hint hidden-when-empty">
{{ t('settings', 'Set up your account for passwordless authentication following the FIDO2 standard.') }}
</p>
<p v-if="devices.length === 0">
{{ t('twofactor_u2f', 'No devices configured.') }}
</p>
<p v-else>
{{ t('twofactor_u2f', 'The following devices are configured for your account:') }}
</p>
<Device v-for="device in sortedDevices"
:key="device.id"
:name="device.name"
@delete="deleteDevice(device.id)" />

<p v-if="!hasPublicKeyCredential" class="warning">
{{ t('settings', 'Your browser does not support Webauthn.') }}
</p>

<AddDevice v-if="hasPublicKeyCredential" :isHttps="isHttps" @added="deviceAdded" />
</div>
</template>

<script>
import confirmPassword from '@nextcloud/password-confirmation'
import sortBy from 'lodash/fp/sortBy'

import AddDevice from './AddDevice'
import Device from './Device'
import logger from '../../logger'
import { removeRegistration } from '../../service/WebAuthnRegistrationSerice'

const sortByName = sortBy('name')

export default {
components: {
AddDevice,
Device,
},
props: {
initialDevices: {
type: Array,
required: true,
},
isHttps: {
type: Boolean,
default: false,
},
hasPublicKeyCredential: {
type: Boolean,
default: false,
},
},
data() {
return {
devices: this.initialDevices,
}
},
computed: {
sortedDevices() {
return sortByName(this.devices)
},
},
methods: {
deviceAdded(device) {
logger.debug(`adding new device to the list ${device.id}`)

this.devices.push(device)
},
async deleteDevice(id) {
logger.info(`deleting webauthn device ${id}`)

await confirmPassword()
await removeRegistration(id)

this.devices = this.devices.filter(d => d.id !== id)

logger.info(`webauthn device ${id} removed successfully`)
},
},
}
</script>

<style scoped>

</style>

+ 27
- 0
apps/settings/src/logger.js View File

@@ -0,0 +1,27 @@
/*
* @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { getLoggerBuilder } from '@nextcloud/logger'

export default getLoggerBuilder()
.setApp('settings')
.detectUser()
.build()

+ 40
- 0
apps/settings/src/main-personal-webauth.js View File

@@ -0,0 +1,40 @@
/**
* @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import Vue from 'vue'
import { loadState } from '@nextcloud/initial-state'

import WebAuthnSection from './components/WebAuthn/Section'

// eslint-disable-next-line camelcase
__webpack_nonce__ = btoa(OC.requestToken)

Vue.prototype.t = t

const View = Vue.extend(WebAuthnSection)
const devices = loadState('settings', 'webauthn-devices')
new View({
propsData: {
initialDevices: devices,
isHttps: window.location.protocol === 'https:',
hasPublicKeyCredential: typeof (window.PublicKeyCredential) !== 'undefined',
},
}).$mount('#security-webauthn')

+ 43
- 0
apps/settings/src/service/WebAuthnRegistrationSerice.js View File

@@ -0,0 +1,43 @@
/**
* @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'

export async function startRegistration() {
const url = generateUrl('/settings/api/personal/webauthn/registration')

const resp = await axios.get(url)
return resp.data
}

export async function finishRegistration(name, data) {
const url = generateUrl('/settings/api/personal/webauthn/registration')

const resp = await axios.post(url, { name, data })
return resp.data
}

export async function removeRegistration(id) {
const url = generateUrl(`/settings/api/personal/webauthn/registration/${id}`)

await axios.delete(url)
}

+ 31
- 0
apps/settings/templates/settings/personal/security/webauthn.php View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

script('settings', [
'vue-settings-personal-webauthn',
]);

?>

<div id="security-webauthn" class="section"></div>

+ 2
- 1
apps/settings/webpack.js View File

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

+ 5
- 0
config/config.sample.php View File

@@ -269,6 +269,11 @@ $CONFIG = [
*/
'auth.bruteforce.protection.enabled' => true,

/**
* By default WebAuthn is available but it can be explicitly disabled by admins
*/
'auth.webauthn.enabled' => true,

/**
* The directory where the skeleton files are located. These files will be
* copied to the data directory of new users. Leave empty to not copy any

+ 8
- 1
core/Controller/LoginController.php View File

@@ -34,6 +34,7 @@ namespace OC\Core\Controller;
use OC\AppFramework\Http\Request;
use OC\Authentication\Login\Chain;
use OC\Authentication\Login\LoginData;
use OC\Authentication\WebAuthn\Manager as WebAuthnManager;
use OC\Security\Bruteforce\Throttler;
use OC\User\Session;
use OC_App;
@@ -80,6 +81,8 @@ class LoginController extends Controller {
private $loginChain;
/** @var IInitialStateService */
private $initialStateService;
/** @var WebAuthnManager */
private $webAuthnManager;

public function __construct(?string $appName,
IRequest $request,
@@ -92,7 +95,8 @@ class LoginController extends Controller {
Defaults $defaults,
Throttler $throttler,
Chain $loginChain,
IInitialStateService $initialStateService) {
IInitialStateService $initialStateService,
WebAuthnManager $webAuthnManager) {
parent::__construct($appName, $request);
$this->userManager = $userManager;
$this->config = $config;
@@ -104,6 +108,7 @@ class LoginController extends Controller {
$this->throttler = $throttler;
$this->loginChain = $loginChain;
$this->initialStateService = $initialStateService;
$this->webAuthnManager = $webAuthnManager;
}

/**
@@ -181,6 +186,8 @@ class LoginController extends Controller {

$this->setPasswordResetInitialState($user);

$this->initialStateService->provideInitialState('core', 'webauthn-available', $this->webAuthnManager->isWebAuthnAvailable());

// OpenGraph Support: http://ogp.me/
Util::addHeader('meta', ['property' => 'og:title', 'content' => Util::sanitizeHTML($this->defaults->getName())]);
Util::addHeader('meta', ['property' => 'og:description', 'content' => Util::sanitizeHTML($this->defaults->getSlogan())]);

+ 117
- 0
core/Controller/WebAuthnController.php View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

namespace OC\Core\Controller;

use OC\Authentication\Login\LoginData;
use OC\Authentication\Login\WebAuthnChain;
use OC\Authentication\WebAuthn\Manager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\ILogger;
use OCP\IRequest;
use OCP\ISession;
use OCP\Util;
use Webauthn\PublicKeyCredentialRequestOptions;

class WebAuthnController extends Controller {

private const WEBAUTHN_LOGIN = 'webauthn_login';
private const WEBAUTHN_LOGIN_UID = 'webauthn_login_uid';

/** @var Manager */
private $webAuthnManger;

/** @var ISession */
private $session;

/** @var ILogger */
private $logger;

/** @var WebAuthnChain */
private $webAuthnChain;

public function __construct($appName, IRequest $request, Manager $webAuthnManger, ISession $session, ILogger $logger, WebAuthnChain $webAuthnChain) {
parent::__construct($appName, $request);

$this->webAuthnManger = $webAuthnManger;
$this->session = $session;
$this->logger = $logger;
$this->webAuthnChain = $webAuthnChain;
}

/**
* @NoAdminRequired
* @PublicPage
* @UseSession
*/
public function startAuthentication(string $loginName): JSONResponse {
$this->logger->debug('Starting WebAuthn login');

$this->logger->debug('Converting login name to UID');
$uid = $loginName;
Util::emitHook(
'\OCA\Files_Sharing\API\Server2Server',
'preLoginNameUsedAsUserName',
array('uid' => &$uid)
);
$this->logger->debug('Got UID: ' . $uid);

$publicKeyCredentialRequestOptions = $this->webAuthnManger->startAuthentication($uid, $this->request->getServerHost());
$this->session->set(self::WEBAUTHN_LOGIN, json_encode($publicKeyCredentialRequestOptions));
$this->session->set(self::WEBAUTHN_LOGIN_UID, $uid);

return new JSONResponse($publicKeyCredentialRequestOptions);
}

/**
* @NoAdminRequired
* @PublicPage
* @UseSession
*/
public function finishAuthentication(string $data): JSONResponse {
$this->logger->debug('Validating WebAuthn login');

if (!$this->session->exists(self::WEBAUTHN_LOGIN) || !$this->session->exists(self::WEBAUTHN_LOGIN_UID)) {
$this->logger->debug('Trying to finish WebAuthn login without session data');
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
}

// Obtain the publicKeyCredentialOptions from when we started the registration
$publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::createFromString($this->session->get(self::WEBAUTHN_LOGIN));
$uid = $this->session->get(self::WEBAUTHN_LOGIN_UID);
$this->webAuthnManger->finishAuthentication($publicKeyCredentialRequestOptions, $data, $uid);

//TODO: add other parameters
$loginData = new LoginData(
$this->request,
$uid,
''
);
$this->webAuthnChain->process($loginData);

return new JSONResponse([]);
}
}

+ 45
- 0
core/Migrations/Version19000Date20200211083441.php View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);

namespace OC\Core\Migrations;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

class Version19000Date20200211083441 extends SimpleMigrationStep {

public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

if (!$schema->hasTable('webauthn')) {
$table = $schema->createTable('webauthn');
$table->addColumn('id', 'integer', [
'autoincrement' => true,
'notnull' => true,
'length' => 64,
]);
$table->addColumn('uid', 'string', [
'notnull' => true,
'length' => 64,
]);
$table->addColumn('name', 'string', [
'notnull' => true,
'length' => 64,
]);
$table->addColumn('public_key_credential_id', 'string', [
'notnull' => true,
'length' => 255
]);
$table->addColumn('data', 'text', [
'notnull' => true,
]);
$table->setPrimaryKey(['id']);
$table->addIndex(['uid'], 'webauthn_uid');
$table->addIndex(['public_key_credential_id'], 'webauthn_publicKeyCredentialId');
}
return $schema;
}
}

+ 2
- 2
core/js/dist/install.js
File diff suppressed because it is too large
View File


+ 1
- 1
core/js/dist/install.js.map
File diff suppressed because it is too large
View File


+ 15
- 15
core/js/dist/login.js
File diff suppressed because it is too large
View File


+ 1
- 1
core/js/dist/login.js.map
File diff suppressed because it is too large
View File


+ 13
- 13
core/js/dist/main.js
File diff suppressed because it is too large
View File


+ 1
- 1
core/js/dist/main.js.map
File diff suppressed because it is too large
View File


+ 8
- 8
core/js/dist/maintenance.js
File diff suppressed because it is too large
View File


+ 1
- 1
core/js/dist/maintenance.js.map
File diff suppressed because it is too large
View File


+ 2
- 2
core/js/dist/recommendedapps.js
File diff suppressed because it is too large
View File


+ 1
- 1
core/js/dist/recommendedapps.js.map
File diff suppressed because it is too large
View File


+ 4
- 0
core/routes.php View File

@@ -86,6 +86,10 @@ $application->registerRoutes($this, [
['name' => 'Wipe#checkWipe', 'url' => '/core/wipe/check', 'verb' => 'POST'],
['name' => 'Wipe#wipeDone', 'url' => '/core/wipe/success', 'verb' => 'POST'],

// Logins for passwordless auth
['name' => 'WebAuthn#startAuthentication', 'url' => 'login/webauthn/start', 'verb' => 'POST'],
['name' => 'WebAuthn#finishAuthentication', 'url' => 'login/webauthn/finish', 'verb' => 'POST'],

// Legacy routes that need to be globally available while they are handled by an app
['name' => 'viewcontroller#showFile', 'url' => '/f/{fileid}', 'verb' => 'GET', 'app' => 'files'],
['name' => 'sharecontroller#showShare', 'url' => '/s/{token}', 'verb' => 'GET', 'app' => 'files_sharing'],

+ 56
- 0
core/src/components/login/LoginButton.vue View File

@@ -0,0 +1,56 @@
<!--
- @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
-
- @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-->

<template>
<div id="submit-wrapper" @click="$emit('click')">
<input id="submit-form"
type="submit"
class="login primary"
title=""
:value="!loading ? t('core', 'Log in') : t('core', 'Logging in …')">
<div class="submit-icon"
:class="{
'icon-confirm-white': !loading,
'icon-loading-small': loading && invertedColors,
'icon-loading-small-dark': loading && !invertedColors,
}" />
</div>
</template>

<script>
export default {
name: 'LoginButton',
props: {
loading: {
type: Boolean,
required: true,
},
invertedColors: {
type: Boolean,
default: false,
},
},
}
</script>

<style scoped>

</style>

+ 5
- 14
core/src/components/login/LoginForm.vue View File

@@ -20,7 +20,8 @@
-->

<template>
<form method="post"
<form ref="loginForm"
method="post"
name="login"
:action="OC.generateUrl('login')"
@submit="submit">
@@ -84,19 +85,7 @@
</a>
</p>

<div id="submit-wrapper">
<input id="submit-form"
type="submit"
class="login primary"
title=""
:value="!loading ? t('core', 'Log in') : t('core', 'Logging in …')">
<div class="submit-icon"
:class="{
'icon-confirm-white': !loading,
'icon-loading-small': loading && invertedColors,
'icon-loading-small-dark': loading && !invertedColors,
}" />
</div>
<LoginButton :loading="loading" :inverted-colors="invertedColors" />

<p v-if="invalidPassword"
class="warning wrongPasswordMsg">
@@ -135,9 +124,11 @@

<script>
import jstz from 'jstimezonedetect'
import LoginButton from './LoginButton'

export default {
name: 'LoginForm',
components: { LoginButton },
props: {
username: {
type: String,

+ 208
- 0
core/src/components/login/PasswordLessLoginForm.vue View File

@@ -0,0 +1,208 @@
<template>
<form v-if="isHttps && hasPublicKeyCredential"
ref="loginForm"
method="post"
name="login"
@submit.prevent="submit">
<fieldset>
<p class="grouptop groupbottom">
<input id="user"
ref="user"
v-model="user"
type="text"
name="user"
:autocomplete="autoCompleteAllowed ? 'on' : 'off'"
:placeholder="t('core', 'Username or email')"
:aria-label="t('core', 'Username or email')"
required
@change="$emit('update:username', user)">
<label for="user" class="infield">{{ t('core', 'Username or email') }}</label>
</p>

<div v-if="!validCredentials">
{{ t('core', 'Your account is not setup for passwordless login.') }}
</div>

<LoginButton v-if="validCredentials"
:loading="loading"
:inverted-colors="invertedColors"
@click="authenticate" />
</fieldset>
</form>
<div v-else-if="!hasPublicKeyCredential">
{{ t('core', 'Passwordless authentication is not supported in your browser.')}}
</div>
<div v-else-if="!isHttps">
{{ t('core', 'Passwordless authentication is only available over a secure connection.')}}
</div>
</template>

<script>
import {
startAuthentication,
finishAuthentication,
} from '../../service/WebAuthnAuthenticationService'
import LoginButton from './LoginButton'

class NoValidCredentials extends Error {

}

export default {
name: 'PasswordLessLoginForm',
components: {
LoginButton,
},
props: {
username: {
type: String,
default: '',
},
redirectUrl: {
type: String,
},
invertedColors: {
type: Boolean,
default: false,
},
autoCompleteAllowed: {
type: Boolean,
default: true,
},
isHttps: {
type: Boolean,
default: false,
},
hasPublicKeyCredential: {
type: Boolean,
default: false,
}
},
data() {
return {
user: this.username,
loading: false,
validCredentials: true,
}
},
methods: {
authenticate() {
console.debug('passwordless login initiated')

this.getAuthenticationData(this.user)
.then(publicKey => {
console.debug(publicKey)
return publicKey
})
.then(this.sign)
.then(this.completeAuthentication)
.catch(error => {
if (error instanceof NoValidCredentials) {
this.validCredentials = false
return
}
console.debug(error)
})
},
getAuthenticationData(uid) {
const base64urlDecode = function(input) {
// Replace non-url compatible chars with base64 standard chars
input = input
.replace(/-/g, '+')
.replace(/_/g, '/')

// Pad out with standard base64 required padding characters
const pad = input.length % 4
if (pad) {
if (pad === 1) {
throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding')
}
input += new Array(5 - pad).join('=')
}

return window.atob(input)
}

return startAuthentication(uid)
.then(publicKey => {
console.debug('Obtained PublicKeyCredentialRequestOptions')
console.debug(publicKey)

if (!Object.prototype.hasOwnProperty.call(publicKey, 'allowCredentials')) {
console.debug('No credentials found.')
throw new NoValidCredentials()
}

publicKey.challenge = Uint8Array.from(base64urlDecode(publicKey.challenge), c => c.charCodeAt(0))
publicKey.allowCredentials = publicKey.allowCredentials.map(function(data) {
return {
...data,
'id': Uint8Array.from(base64urlDecode(data.id), c => c.charCodeAt(0)),
}
})

console.debug('Converted PublicKeyCredentialRequestOptions')
console.debug(publicKey)
return publicKey
})
.catch(error => {
console.debug('Error while obtaining data')
throw error
})
},
sign(publicKey) {
const arrayToBase64String = function(a) {
return window.btoa(String.fromCharCode(...a))
}

return navigator.credentials.get({ publicKey })
.then(data => {
console.debug(data)
console.debug(new Uint8Array(data.rawId))
console.debug(arrayToBase64String(new Uint8Array(data.rawId)))
return {
id: data.id,
type: data.type,
rawId: arrayToBase64String(new Uint8Array(data.rawId)),
response: {
authenticatorData: arrayToBase64String(new Uint8Array(data.response.authenticatorData)),
clientDataJSON: arrayToBase64String(new Uint8Array(data.response.clientDataJSON)),
signature: arrayToBase64String(new Uint8Array(data.response.signature)),
userHandle: data.response.userHandle ? arrayToBase64String(new Uint8Array(data.response.userHandle)) : null,
},
}
})
.then(challenge => {
console.debug(challenge)
return challenge
})
.catch(error => {
console.debug('GOT AN ERROR!')
console.debug(error) // Example: timeout, interaction refused...
})
},
completeAuthentication(challenge) {
console.debug('TIME TO COMPLETE')

const location = this.redirectUrl

return finishAuthentication(JSON.stringify(challenge))
.then(data => {
console.debug('Logged in redirecting')
window.location.href = location
})
.catch(error => {
console.debug('GOT AN ERROR WHILE SUBMITTING CHALLENGE!')
console.debug(error) // Example: timeout, interaction refused...
})
},
submit() {
// noop
},
},
}
</script>

<style scoped>

</style>

+ 3
- 0
core/src/login.js View File

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

+ 37
- 0
core/src/service/WebAuthnAuthenticationService.js View File

@@ -0,0 +1,37 @@
/**
* @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import Axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'

export function startAuthentication(loginName) {
const url = generateUrl('/login/webauthn/start')

return Axios.post(url, { loginName })
.then(resp => resp.data)
}

export function finishAuthentication(data) {
const url = generateUrl('/login/webauthn/finish')

return Axios.post(url, { data })
.then(resp => resp.data)
}

+ 35
- 1
core/src/views/Login.vue View File

@@ -22,7 +22,7 @@
<template>
<div>
<transition name="fade" mode="out-in">
<div v-if="!resetPassword && resetPasswordTarget === ''"
<div v-if="!passwordlessLogin && !resetPassword && resetPasswordTarget === ''"
key="login">
<LoginForm
:username.sync="user"
@@ -45,6 +45,25 @@
@click.prevent="resetPassword = true">
{{ t('core', 'Forgot password?') }}
</a>
<br>
<a v-if="hasPasswordless" @click.prevent="passwordlessLogin = true">
{{ t('core', 'Log in with a device') }}
</a>
</div>
<div v-else-if="!loading && passwordlessLogin"
key="reset"
class="login-additional">
<PasswordLessLoginForm
:username.sync="user"
:redirect-url="redirectUrl"
:inverted-colors="invertedColors"
:auto-complete-allowed="autoCompleteAllowed"
:isHttps="isHttps"
:hasPublicKeyCredential="hasPublicKeyCredential"
@submit="loading = true" />
<a @click.prevent="passwordlessLogin = false">
{{ t('core', 'Back') }}
</a>
</div>
<div v-else-if="!loading && canResetPassword"
key="reset"
@@ -69,6 +88,7 @@

<script>
import LoginForm from '../components/login/LoginForm.vue'
import PasswordLessLoginForm from '../components/login/PasswordLessLoginForm.vue'
import ResetPassword from '../components/login/ResetPassword.vue'
import UpdatePassword from '../components/login/UpdatePassword.vue'

@@ -76,6 +96,7 @@ export default {
name: 'Login',
components: {
LoginForm,
PasswordLessLoginForm,
ResetPassword,
UpdatePassword,
},
@@ -120,11 +141,24 @@ export default {
type: Boolean,
default: false,
},
hasPasswordless: {
type: Boolean,
default: false,
},
isHttps: {
type: Boolean,
default: false,
},
hasPublicKeyCredential: {
type: Boolean,
default: false,
},
},
data() {
return {
loading: false,
user: this.username,
passwordlessLogin: false,
resetPassword: false,
}
},

+ 8
- 0
lib/composer/composer/autoload_classmap.php View File

@@ -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',

+ 8
- 0
lib/composer/composer/autoload_static.php View File

@@ -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',

+ 25
- 11
lib/private/Authentication/Login/CreateSessionTokenCommand.php View File

@@ -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);
}

+ 96
- 0
lib/private/Authentication/Login/WebAuthnChain.php View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

namespace OC\Authentication\Login;

class WebAuthnChain {
/** @var UserDisabledCheckCommand */
private $userDisabledCheckCommand;

/** @var LoggedInCheckCommand */
private $loggedInCheckCommand;

/** @var CompleteLoginCommand */
private $completeLoginCommand;

/** @var CreateSessionTokenCommand */
private $createSessionTokenCommand;

/** @var ClearLostPasswordTokensCommand */
private $clearLostPasswordTokensCommand;

/** @var UpdateLastPasswordConfirmCommand */
private $updateLastPasswordConfirmCommand;

/** @var SetUserTimezoneCommand */
private $setUserTimezoneCommand;

/** @var TwoFactorCommand */
private $twoFactorCommand;

/** @var FinishRememberedLoginCommand */
private $finishRememberedLoginCommand;

/** @var WebAuthnLoginCommand */
private $webAuthnLoginCommand;

public function __construct(UserDisabledCheckCommand $userDisabledCheckCommand,
WebAuthnLoginCommand $webAuthnLoginCommand,
LoggedInCheckCommand $loggedInCheckCommand,
CompleteLoginCommand $completeLoginCommand,
CreateSessionTokenCommand $createSessionTokenCommand,
ClearLostPasswordTokensCommand $clearLostPasswordTokensCommand,
UpdateLastPasswordConfirmCommand $updateLastPasswordConfirmCommand,
SetUserTimezoneCommand $setUserTimezoneCommand,
TwoFactorCommand $twoFactorCommand,
FinishRememberedLoginCommand $finishRememberedLoginCommand
) {
$this->userDisabledCheckCommand = $userDisabledCheckCommand;
$this->webAuthnLoginCommand = $webAuthnLoginCommand;
$this->loggedInCheckCommand = $loggedInCheckCommand;
$this->completeLoginCommand = $completeLoginCommand;
$this->createSessionTokenCommand = $createSessionTokenCommand;
$this->clearLostPasswordTokensCommand = $clearLostPasswordTokensCommand;
$this->updateLastPasswordConfirmCommand = $updateLastPasswordConfirmCommand;
$this->setUserTimezoneCommand = $setUserTimezoneCommand;
$this->twoFactorCommand = $twoFactorCommand;
$this->finishRememberedLoginCommand = $finishRememberedLoginCommand;
}

public function process(LoginData $loginData): LoginResult {
$chain = $this->userDisabledCheckCommand;
$chain
->setNext($this->webAuthnLoginCommand)
->setNext($this->loggedInCheckCommand)
->setNext($this->completeLoginCommand)
->setNext($this->createSessionTokenCommand)
->setNext($this->clearLostPasswordTokensCommand)
->setNext($this->updateLastPasswordConfirmCommand)
->setNext($this->setUserTimezoneCommand)
->setNext($this->twoFactorCommand)
->setNext($this->finishRememberedLoginCommand);

return $chain->process($loginData);
}
}

+ 48
- 0
lib/private/Authentication/Login/WebAuthnLoginCommand.php View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

namespace OC\Authentication\Login;

use OCP\IUserManager;

class WebAuthnLoginCommand extends ALoginCommand {

/** @var IUserManager */
private $userManager;

public function __construct(IUserManager $userManager) {
$this->userManager = $userManager;
}

public function process(LoginData $loginData): LoginResult {
$user = $this->userManager->get($loginData->getUsername());
$loginData->setUser($user);
if ($user === null) {
$loginData->setUser(false);
}

return $this->processNextOrFinishSuccessfully($loginData);
}

}

+ 93
- 0
lib/private/Authentication/WebAuthn/CredentialRepository.php View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

namespace OC\Authentication\WebAuthn;

use OC\Authentication\WebAuthn\Db\PublicKeyCredentialEntity;
use OC\Authentication\WebAuthn\Db\PublicKeyCredentialMapper;
use OCP\AppFramework\Db\IMapperException;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialSourceRepository;
use Webauthn\PublicKeyCredentialUserEntity;

class CredentialRepository implements PublicKeyCredentialSourceRepository {

/** @var PublicKeyCredentialMapper */
private $credentialMapper;

public function __construct(PublicKeyCredentialMapper $credentialMapper) {
$this->credentialMapper = $credentialMapper;
}

public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource {
try {
$entity = $this->credentialMapper->findOneByCredentialId($publicKeyCredentialId);
return $entity->toPublicKeyCredentialSource();
} catch (IMapperException $e) {
return null;
}
}

/**
* @return PublicKeyCredentialSource[]
*/
public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array {
$uid = $publicKeyCredentialUserEntity->getId();
$entities = $this->credentialMapper->findAllForUid($uid);

return array_map(function (PublicKeyCredentialEntity $entity) {
return $entity->toPublicKeyCredentialSource();
}, $entities);
}

public function saveAndReturnCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource, string $name = null): PublicKeyCredentialEntity {
$oldEntity = null;

try {
$oldEntity = $this->credentialMapper->findOneByCredentialId($publicKeyCredentialSource->getPublicKeyCredentialId());
} catch (IMapperException $e) {

}

if ($name === null) {
$name = 'default';
}

$entity = PublicKeyCredentialEntity::fromPublicKeyCrendentialSource($name, $publicKeyCredentialSource);

if ($oldEntity) {
$entity->setId($oldEntity->getId());
if ($name === null) {
$entity->setName($oldEntity->getName());
}
}

return $this->credentialMapper->insertOrUpdate($entity);
}

public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource, string $name = null): void {
$this->saveAndReturnCredentialSource($publicKeyCredentialSource, $name);
}

}

+ 92
- 0
lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialEntity.php View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

namespace OC\Authentication\WebAuthn\Db;

use JsonSerializable;
use OCP\AppFramework\Db\Entity;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\TrustPath\TrustPathLoader;

/**
* @since 19.0.0
*
* @method string getUid();
* @method void setUid(string $uid)
* @method string getName();
* @method void setName(string $name);
* @method string getPublicKeyCredentialId();
* @method void setPublicKeyCredentialId(string $id);
* @method string getData();
* @method void setData(string $data);
*/
class PublicKeyCredentialEntity extends Entity implements JsonSerializable {

/** @var string */
protected $name;

/** @var string */
protected $uid;

/** @var string */
protected $publicKeyCredentialId;

/** @var string */
protected $data;

public function __construct() {
$this->addType('name', 'string');
$this->addType('uid', 'string');
$this->addType('publicKeyCredentialId', 'string');
$this->addType('data', 'string');
}

static function fromPublicKeyCrendentialSource(string $name, PublicKeyCredentialSource $publicKeyCredentialSource): PublicKeyCredentialEntity {
$publicKeyCredentialEntity = new self();

$publicKeyCredentialEntity->setName($name);
$publicKeyCredentialEntity->setUid($publicKeyCredentialSource->getUserHandle());
$publicKeyCredentialEntity->setPublicKeyCredentialId(base64_encode($publicKeyCredentialSource->getPublicKeyCredentialId()));
$publicKeyCredentialEntity->setData(json_encode($publicKeyCredentialSource));

return $publicKeyCredentialEntity;
}

function toPublicKeyCredentialSource(): PublicKeyCredentialSource {
return PublicKeyCredentialSource::createFromArray(
json_decode($this->getData(), true)
);
}

/**
* @inheritDoc
*/
public function jsonSerialize(): array {
return [
'id' => $this->getId(),
'name' => $this->getName(),
];
}

}

+ 86
- 0
lib/private/Authentication/WebAuthn/Db/PublicKeyCredentialMapper.php View File

@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);

/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

namespace OC\Authentication\WebAuthn\Db;

use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\IDBConnection;

class PublicKeyCredentialMapper extends QBMapper {

public function __construct(IDBConnection $db) {
parent::__construct($db, 'webauthn', PublicKeyCredentialEntity::class);
}

public function findOneByCredentialId(string $publicKeyCredentialId): PublicKeyCredentialEntity {
$qb = $this->db->getQueryBuilder();

$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('public_key_credential_id', $qb->createNamedParameter(base64_encode($publicKeyCredentialId)))
);

return $this->findEntity($qb);
}

/**
* @return PublicKeyCredentialEntity[]
*/
public function findAllForUid(string $uid): array {
$qb = $this->db->getQueryBuilder();

$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('uid', $qb->createNamedParameter($uid))
);

return $this->findEntities($qb);
}

/**
* @param string $uid
* @param int $id
*
* @return PublicKeyCredentialEntity
* @throws DoesNotExistException
*/
public function findById(string $uid, int $id): PublicKeyCredentialEntity {
$qb = $this->db->getQueryBuilder();

$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->andX(
$qb->expr()->eq('id', $qb->createNamedParameter($id)),
$qb->expr()->eq('uid', $qb->createNamedParameter($uid))
));

return $this->findEntity($qb);
}

}

+ 269
- 0
lib/private/Authentication/WebAuthn/Manager.php View File

@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

namespace OC\Authentication\WebAuthn;

use Cose\Algorithm\Signature\ECDSA\ES256;
use Cose\Algorithm\Signature\RSA\RS256;
use Cose\Algorithms;
use GuzzleHttp\Psr7\ServerRequest;
use OC\Authentication\WebAuthn\Db\PublicKeyCredentialEntity;
use OC\Authentication\WebAuthn\Db\PublicKeyCredentialMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IConfig;
use OCP\ILogger;
use OCP\IUser;
use Webauthn\AttestationStatement\AttestationObjectLoader;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\AuthenticatorAssertionResponseValidator;
use Webauthn\AuthenticatorAttestationResponse;
use Webauthn\AuthenticatorAttestationResponseValidator;
use Webauthn\AuthenticatorSelectionCriteria;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialDescriptor;
use Webauthn\PublicKeyCredentialLoader;
use Webauthn\PublicKeyCredentialParameters;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialUserEntity;
use Webauthn\TokenBinding\TokenBindingNotSupportedHandler;

class Manager {

/** @var CredentialRepository */
private $repository;

/** @var PublicKeyCredentialMapper */
private $credentialMapper;

/** @var ILogger */
private $logger;

/** @var IConfig */
private $config;

public function __construct(
CredentialRepository $repository,
PublicKeyCredentialMapper $credentialMapper,
ILogger $logger,
IConfig $config
) {
$this->repository = $repository;
$this->credentialMapper = $credentialMapper;
$this->logger = $logger;
$this->config = $config;
}

public function startRegistration(IUser $user, string $serverHost): PublicKeyCredentialCreationOptions {
$rpEntity = new PublicKeyCredentialRpEntity(
'Nextcloud', //Name
$this->stripPort($serverHost), //ID
null //Icon
);

$userEntity = new PublicKeyCredentialUserEntity(
$user->getUID(), //Name
$user->getUID(), //ID
$user->getDisplayName() //Display name
// 'https://foo.example.co/avatar/123e4567-e89b-12d3-a456-426655440000' //Icon
);

$challenge = random_bytes(32);

$publicKeyCredentialParametersList = [
new PublicKeyCredentialParameters('public-key', Algorithms::COSE_ALGORITHM_ES256),
new PublicKeyCredentialParameters('public-key', Algorithms::COSE_ALGORITHM_RS256),
];

$timeout = 60000;

$excludedPublicKeyDescriptors = [
];

$authenticatorSelectionCriteria = new AuthenticatorSelectionCriteria();

return new PublicKeyCredentialCreationOptions(
$rpEntity,
$userEntity,
$challenge,
$publicKeyCredentialParametersList,
$timeout,
$excludedPublicKeyDescriptors,
$authenticatorSelectionCriteria,
PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
null
);
}

public function finishRegister(PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions, string $name, string $data): PublicKeyCredentialEntity {
$tokenBindingHandler = new TokenBindingNotSupportedHandler();

$attestationStatementSupportManager = new AttestationStatementSupportManager();
$attestationStatementSupportManager->add(new NoneAttestationStatementSupport());

$attestationObjectLoader = new AttestationObjectLoader($attestationStatementSupportManager);
$publicKeyCredentialLoader = new PublicKeyCredentialLoader($attestationObjectLoader);

// Extension Output Checker Handler
$extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler();

// Authenticator Attestation Response Validator
$authenticatorAttestationResponseValidator = new AuthenticatorAttestationResponseValidator(
$attestationStatementSupportManager,
$this->repository,
$tokenBindingHandler,
$extensionOutputCheckerHandler
);

try {
// Load the data
$publicKeyCredential = $publicKeyCredentialLoader->load($data);
$response = $publicKeyCredential->getResponse();

// Check if the response is an Authenticator Attestation Response
if (!$response instanceof AuthenticatorAttestationResponse) {
throw new \RuntimeException('Not an authenticator attestation response');
}

// Check the response against the request
$request = ServerRequest::fromGlobals();

$publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check(
$response,
$publicKeyCredentialCreationOptions,
$request);
} catch (\Throwable $exception) {
throw $exception;
}

// Persist the data
return $this->repository->saveAndReturnCredentialSource($publicKeyCredentialSource, $name);
}

private function stripPort(string $serverHost): string {
return preg_replace('/(:\d+$)/', '', $serverHost);
}

public function startAuthentication(string $uid, string $serverHost): PublicKeyCredentialRequestOptions {
// List of registered PublicKeyCredentialDescriptor classes associated to the user
$registeredPublicKeyCredentialDescriptors = array_map(function (PublicKeyCredentialEntity $entity) {
$credential = $entity->toPublicKeyCredentialSource();
return new PublicKeyCredentialDescriptor(
$credential->getType(),
$credential->getPublicKeyCredentialId()
);
}, $this->credentialMapper->findAllForUid($uid));

// Public Key Credential Request Options
return new PublicKeyCredentialRequestOptions(
random_bytes(32), // Challenge
60000, // Timeout
$this->stripPort($serverHost), // Relying Party ID
$registeredPublicKeyCredentialDescriptors // Registered PublicKeyCredentialDescriptor classes
);
}

public function finishAuthentication(PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions, string $data, string $uid) {
$attestationStatementSupportManager = new AttestationStatementSupportManager();
$attestationStatementSupportManager->add(new NoneAttestationStatementSupport());

$attestationObjectLoader = new AttestationObjectLoader($attestationStatementSupportManager);
$publicKeyCredentialLoader = new PublicKeyCredentialLoader($attestationObjectLoader);

$tokenBindingHandler = new TokenBindingNotSupportedHandler();
$extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler();
$algorithmManager = new \Cose\Algorithm\Manager();
$algorithmManager->add(new ES256());
$algorithmManager->add(new RS256());

$authenticatorAssertionResponseValidator = new AuthenticatorAssertionResponseValidator(
$this->repository,
$tokenBindingHandler,
$extensionOutputCheckerHandler,
$algorithmManager
);

try {
$this->logger->debug('Loading publickey credentials from: ' . $data);

// Load the data
$publicKeyCredential = $publicKeyCredentialLoader->load($data);
$response = $publicKeyCredential->getResponse();

// Check if the response is an Authenticator Attestation Response
if (!$response instanceof AuthenticatorAssertionResponse) {
throw new \RuntimeException('Not an authenticator attestation response');
}

// Check the response against the request
$request = ServerRequest::fromGlobals();

$publicKeyCredentialSource = $authenticatorAssertionResponseValidator->check(
$publicKeyCredential->getRawId(),
$response,
$publicKeyCredentialRequestOptions,
$request,
$uid
);

} catch (\Throwable $e) {
throw $e;
}



return true;
}

public function deleteRegistration(IUser $user, int $id): void {
try {
$entry = $this->credentialMapper->findById($user->getUID(), $id);
} catch (DoesNotExistException $e) {
$this->logger->warning("WebAuthn device $id does not exist, can't delete it");
return;
}

$this->credentialMapper->delete($entry);
}

public function isWebAuthnAvailable(): bool {
if (!extension_loaded('bcmath')) {
return false;
}

if (!extension_loaded('gmp')) {
return false;
}

if (!$this->config->getSystemValueBool('auth.webauthn.enabled', true)) {
return false;
}

return true;
}
}

+ 7
- 1
tests/Core/Controller/LoginControllerTest.php View File

@@ -83,6 +83,9 @@ class LoginControllerTest extends TestCase {
/** @var IInitialStateService|MockObject */
private $initialStateService;

/** @var \OC\Authentication\WebAuthn\Manager|MockObject */
private $webAuthnManager;

protected function setUp(): void {
parent::setUp();
$this->request = $this->createMock(IRequest::class);
@@ -97,6 +100,8 @@ class LoginControllerTest extends TestCase {
$this->throttler = $this->createMock(Throttler::class);
$this->chain = $this->createMock(LoginChain::class);
$this->initialStateService = $this->createMock(IInitialStateService::class);
$this->webAuthnManager = $this->createMock(\OC\Authentication\WebAuthn\Manager::class);


$this->request->method('getRemoteAddress')
->willReturn('1.2.3.4');
@@ -118,7 +123,8 @@ class LoginControllerTest extends TestCase {
$this->defaults,
$this->throttler,
$this->chain,
$this->initialStateService
$this->initialStateService,
$this->webAuthnManager
);
}


+ 1
- 1
version.php View File

@@ -29,7 +29,7 @@
// between betas, final and RCs. This is _not_ the public version number. Reset minor/patchlevel
// when updating major/minor version number.

$OC_Version = [19, 0, 0, 0];
$OC_Version = [19, 0, 0, 1];

// The human readable string
$OC_VersionString = '19.0.0 alpha';

Loading…
Cancel
Save