Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#prefix=/home/hubi/.npm-global
3 changes: 2 additions & 1 deletion apps/settings/lib/Controller/WebAuthnController.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ public function __construct(
public function startRegistration(): JSONResponse {
$this->logger->debug('Starting WebAuthn registration');

$credentialOptions = $this->manager->startRegistration($this->userSession->getUser(), $this->request->getServerHost());
$discoverable = $this->request->getParam('discoverable', '1') !== '0';
$credentialOptions = $this->manager->startRegistration($this->userSession->getUser(), $this->request->getServerHost(), $discoverable);

// Set this in the session since we need it on finish
$this->session->set(self::WEBAUTHN_REGISTRATION, $credentialOptions);
Expand Down
21 changes: 20 additions & 1 deletion apps/settings/src/components/WebAuthn/AddDevice.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@
{{ t('settings', 'Passwordless authentication requires a secure connection.') }}
</div>
<div v-else>
<div class="new-webauthn-device__option">
<NcCheckboxRadioSwitch
v-model="discoverable"
type="switch"
:disabled="step !== RegistrationSteps.READY">
{{ t('settings', 'Store passkey on this device (discoverable)') }}
</NcCheckboxRadioSwitch>
<p class="settings-hint">
{{ t('settings', 'Disable this option to use the classic flow that keeps the login name requirement.') }}
</p>
</div>

<NcButton v-if="step === RegistrationSteps.READY"
type="primary"
@click="start">
Expand Down Expand Up @@ -51,6 +63,7 @@
import { showError } from '@nextcloud/dialogs'
import { confirmPassword } from '@nextcloud/password-confirmation'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcTextField from '@nextcloud/vue/components/NcTextField'

import logger from '../../logger.ts'
Expand Down Expand Up @@ -78,6 +91,7 @@ export default {

components: {
NcButton,
NcCheckboxRadioSwitch,
NcTextField,
},

Expand Down Expand Up @@ -105,6 +119,7 @@ export default {
name: '',
credential: {},
step: RegistrationSteps.READY,
discoverable: true,
}
},

Expand All @@ -130,7 +145,7 @@ export default {

try {
await confirmPassword()
this.credential = await startRegistration()
this.credential = await startRegistration(this.discoverable)
this.step = RegistrationSteps.NAMING
} catch (err) {
showError(err)
Expand Down Expand Up @@ -191,4 +206,8 @@ export default {
max-width: min(100vw, 400px);
}
}

.new-webauthn-device__option {
margin-bottom: 1rem;
}
</style>
21 changes: 18 additions & 3 deletions apps/settings/src/service/WebAuthnRegistrationSerice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,19 @@ import logger from '../logger'
* Start registering a new device
* @return The device attributes
*/
export async function startRegistration() {
const url = generateUrl('/settings/api/personal/webauthn/registration')

export async function startRegistration(discoverable = true): Promise<RegistrationResponseJSON> {
const url = generateUrl('/settings/api/personal/webauthn/registration') + (discoverable ? '' : '?discoverable=0')
try {
logger.debug('Fetching webauthn registration data')
const { data } = await axios.get<PublicKeyCredentialCreationOptionsJSON>(url)
logger.debug('Start webauthn registration')
const attrs = await registerWebAuthn({ optionsJSON: data })
return attrs
} catch (e) {
if (shouldFallbackToLegacy(e) && discoverable) {
logger.debug('WebAuthn discoverable registration failed, falling back to legacy mode')
return await startRegistration(false)
}
logger.error(e as Error)
if (isAxiosError(e)) {
throw new Error(t('settings', 'Could not register device: Network error'))
Expand All @@ -36,6 +39,18 @@ export async function startRegistration() {
}
}

function shouldFallbackToLegacy(error: unknown): boolean {
if (error instanceof Error) {
if (error.name === 'ConstraintError' || error.name === 'NotSupportedError') {
return true
}
if (error.message.includes('Discoverable credentials were required')) {
return true
}
}
return false
}

/**
* @param name Name of the device
* @param data Device attributes
Expand Down
36 changes: 23 additions & 13 deletions core/Controller/WebAuthnController.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,28 @@ public function __construct(
#[PublicPage]
#[UseSession]
#[FrontpageRoute(verb: 'POST', url: 'login/webauthn/start')]
public function startAuthentication(string $loginName): JSONResponse {
public function startAuthentication(?string $loginName = null): 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',
['uid' => &$uid]
);
$this->logger->debug('Got UID: ' . $uid);
$uid = null;
if ($loginName !== null && $loginName !== '') {
$this->logger->debug('Converting login name to UID');
$uid = $loginName;
Util::emitHook(
'\OCA\Files_Sharing\API\Server2Server',
'preLoginNameUsedAsUserName',
['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);
if ($uid !== null && $uid !== '') {
$this->session->set(self::WEBAUTHN_LOGIN_UID, $uid);
} else {
$this->session->remove(self::WEBAUTHN_LOGIN_UID);
}

return new JSONResponse($publicKeyCredentialRequestOptions);
}
Expand All @@ -68,15 +75,18 @@ public function startAuthentication(string $loginName): JSONResponse {
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)) {
if (!$this->session->exists(self::WEBAUTHN_LOGIN)) {
$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);
$uidFromSession = $this->session->get(self::WEBAUTHN_LOGIN_UID);
$this->session->remove(self::WEBAUTHN_LOGIN);
$this->session->remove(self::WEBAUTHN_LOGIN_UID);
$publicKeyCredentialSource = $this->webAuthnManger->finishAuthentication($publicKeyCredentialRequestOptions, $data, $uidFromSession);
$uid = $uidFromSession ?? $publicKeyCredentialSource->getUserHandle();

//TODO: add other parameters
$loginData = new LoginData(
Expand Down
40 changes: 25 additions & 15 deletions core/src/components/login/PasswordLessLoginForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,16 @@
{{ t('core', 'Log in with a device') }}
</h2>

<NcTextField required
:value="user"
<NcTextField
:model-value="user"
:autocomplete="autoCompleteAllowed ? 'on' : 'off'"
:error="!validCredentials"
:label="t('core', 'Login or email')"
:placeholder="t('core', 'Login or email')"
:helper-text="!validCredentials ? t('core', 'Your account is not setup for passwordless login.') : ''"
:label="t('core', 'Login or email (optional)')"
:placeholder="t('core', 'Login or email (optional)')"
:helper-text="helperText"
@update:value="changeUsername" />

<LoginButton v-if="validCredentials"
<LoginButton
:loading="loading"
@click="authenticate" />
</form>
Expand Down Expand Up @@ -105,6 +105,7 @@ export default defineComponent({
user: this.username,
loading: false,
validCredentials: true,
helperText: this.t('core', 'Leave empty to use a discoverable credential.'),
}
},
methods: {
Expand All @@ -116,11 +117,15 @@ export default defineComponent({

console.debug('passwordless login initiated')

this.loading = true
try {
const params = await startAuthentication(this.user)
const trimmed = this.user.trim()
const params = await startAuthentication(trimmed !== '' ? trimmed : undefined)
await this.completeAuthentication(params)
} catch (error) {
if (error instanceof NoValidCredentials) {
this.loading = false
if (error instanceof NoValidCredentials && this.user.trim() === '') {
this.helperText = this.t('core', 'No discoverable credential found. Please enter your login or email and try again.')
this.validCredentials = false
return
}
Expand All @@ -129,6 +134,8 @@ export default defineComponent({
},
changeUsername(username) {
this.user = username
this.validCredentials = true
this.helperText = this.t('core', 'Leave empty to use a discoverable credential.')
this.$emit('update:username', this.user)
},
completeAuthentication(challenge) {
Expand All @@ -143,20 +150,23 @@ export default defineComponent({
.catch(error => {
console.debug('GOT AN ERROR WHILE SUBMITTING CHALLENGE!')
console.debug(error) // Example: timeout, interaction refused...
this.loading = false
})
},
submit() {
// noop
if (!this.loading) {
void this.authenticate()
}
},
},
})
</script>

<style lang="scss" scoped>
.password-less-login-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
}
.password-less-login-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
}
</style>
13 changes: 8 additions & 5 deletions core/src/services/WebAuthnAuthenticationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,20 @@ export class NoValidCredentials extends Error {}
* Start webautn authentication
* This loads the challenge, connects to the authenticator and returns the repose that needs to be sent to the server.
*
* @param loginName Name to login
* @param loginName Name to login (optional for discoverable credentials)
*/
export async function startAuthentication(loginName: string) {
export async function startAuthentication(loginName?: string) {
const url = generateUrl('/login/webauthn/start')

const { data } = await Axios.post<PublicKeyCredentialRequestOptionsJSON>(url, { loginName })
if (!data.allowCredentials || data.allowCredentials.length === 0) {
const body = loginName ? { loginName } : undefined
const { data } = await Axios.post<PublicKeyCredentialRequestOptionsJSON>(url, body)
if (loginName && (!data.allowCredentials || data.allowCredentials.length === 0)) {
logger.error('No valid credentials returned for webauthn')
throw new NoValidCredentials()
}
return await startWebauthnAuthentication({ optionsJSON: data })
return await startWebauthnAuthentication({
optionsJSON: data,
})
}

/**
Expand Down
4 changes: 2 additions & 2 deletions dist/core-login.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/core-login.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/settings-vue-settings-personal-webauthn.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/settings-vue-settings-personal-webauthn.js.map

Large diffs are not rendered by default.

53 changes: 32 additions & 21 deletions lib/private/Authentication/WebAuthn/Manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
use Webauthn\PublicKeyCredentialParameters;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialUserEntity;
use Webauthn\TokenBinding\TokenBindingNotSupportedHandler;

Expand Down Expand Up @@ -61,7 +62,7 @@ public function __construct(
$this->config = $config;
}

public function startRegistration(IUser $user, string $serverHost): PublicKeyCredentialCreationOptions {
public function startRegistration(IUser $user, string $serverHost, bool $requireDiscoverable = true): PublicKeyCredentialCreationOptions {
$rpEntity = new PublicKeyCredentialRpEntity(
'Nextcloud', //Name
$this->stripPort($serverHost), //ID
Expand All @@ -87,12 +88,20 @@ public function startRegistration(IUser $user, string $serverHost): PublicKeyCre
$excludedPublicKeyDescriptors = [
];

$authenticatorSelectionCriteria = new AuthenticatorSelectionCriteria(
AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_NO_PREFERENCE,
AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_PREFERRED,
null,
false,
);
if ($requireDiscoverable) {
$authenticatorSelectionCriteria = new AuthenticatorSelectionCriteria(
AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_NO_PREFERENCE,
AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED,
AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_REQUIRED,
);
} else {
$authenticatorSelectionCriteria = new AuthenticatorSelectionCriteria(
AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_NO_PREFERENCE,
AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_PREFERRED,
AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_NO_PREFERENCE,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RESIDENT_KEY_REQUIREMENT_DISCOURAGED recommended

false,
);
}

return new PublicKeyCredentialCreationOptions(
$rpEntity,
Expand Down Expand Up @@ -159,19 +168,21 @@ 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
public function startAuthentication(?string $uid, string $serverHost): PublicKeyCredentialRequestOptions {
$userVerificationRequirement = AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED;
$registeredPublicKeyCredentialDescriptors = array_map(function (PublicKeyCredentialEntity $entity) use (&$userVerificationRequirement) {
if ($entity->getUserVerification() !== true) {
$userVerificationRequirement = AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_DISCOURAGED;
}
$credential = $entity->toPublicKeyCredentialSource();
return new PublicKeyCredentialDescriptor(
$credential->type,
$credential->publicKeyCredentialId,
);
}, $this->credentialMapper->findAllForUid($uid));
$registeredPublicKeyCredentialDescriptors = [];
if ($uid !== null && $uid !== '') {
$registeredPublicKeyCredentialDescriptors = array_map(function (PublicKeyCredentialEntity $entity) use (&$userVerificationRequirement) {
if ($entity->getUserVerification() !== true) {
$userVerificationRequirement = AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_DISCOURAGED;
}
$credential = $entity->toPublicKeyCredentialSource();
return new PublicKeyCredentialDescriptor(
$credential->type,
$credential->publicKeyCredentialId,
);
}, $this->credentialMapper->findAllForUid($uid));
}

// Public Key Credential Request Options
return new PublicKeyCredentialRequestOptions(
Expand All @@ -183,7 +194,7 @@ public function startAuthentication(string $uid, string $serverHost): PublicKeyC
);
}

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

Expand Down Expand Up @@ -231,7 +242,7 @@ public function finishAuthentication(PublicKeyCredentialRequestOptions $publicKe
throw $e;
}

return true;
return $publicKeyCredentialSource;
}

public function deleteRegistration(IUser $user, int $id): void {
Expand Down
Loading