Skip to content

Commit 6606ccb

Browse files
WebAuthn / FIDO2 : allow discoverable or legacy passkeys + runtime choice
- Let users choose during registration whether a passkey is stored as a discoverable credential; retry with the legacy flow if the authenticator can't do resident keys. - Simplify “Log in with a device”: a single field now accepts an optional login/email, using discoverable credentials when left empty and falling back gracefully otherwise. - Backend/WebAuthn services updated to handle optional usernames and return the credential source so the UID can be derived from the authenticator. Signed-off-by: swissbit-eis-admin <[email protected]>
1 parent 89fcefb commit 6606ccb

File tree

14 files changed

+143
-67
lines changed

14 files changed

+143
-67
lines changed

apps/settings/lib/Controller/WebAuthnController.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,12 @@ public function __construct(
4848
public function startRegistration(): JSONResponse {
4949
$this->logger->debug('Starting WebAuthn registration');
5050

51-
$credentialOptions = $this->manager->startRegistration($this->userSession->getUser(), $this->request->getServerHost());
51+
$discoverable = $this->request->getParam('discoverable', '1') !== '0';
52+
$credentialOptions = $this->manager->startRegistration(
53+
$this->userSession->getUser(),
54+
$this->request->getServerHost(),
55+
$discoverable
56+
);
5257

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

apps/settings/src/components/WebAuthn/WebAuthnAddDevice.vue

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@
88
{{ t('settings', 'Passwordless authentication requires a secure connection.') }}
99
</div>
1010
<div v-else>
11+
<div class="new-webauthn-device__option">
12+
<NcCheckboxRadioSwitch
13+
v-model="discoverable"
14+
type="switch"
15+
:disabled="step !== RegistrationSteps.READY">
16+
{{ t('settings', 'Store passkey on this device (discoverable)') }}
17+
</NcCheckboxRadioSwitch>
18+
<p class="settings-hint">
19+
{{ t('settings', 'Disable this option if you prefer the classic flow that requires your login name before using the security key.') }}
20+
</p>
21+
</div>
22+
1123
<NcButton
1224
v-if="step === RegistrationSteps.READY"
1325
variant="primary"
@@ -56,6 +68,7 @@
5668
import { showError } from '@nextcloud/dialogs'
5769
import { confirmPassword } from '@nextcloud/password-confirmation'
5870
import NcButton from '@nextcloud/vue/components/NcButton'
71+
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
5972
import NcTextField from '@nextcloud/vue/components/NcTextField'
6073
import logger from '../../logger.ts'
6174
import {
@@ -85,6 +98,7 @@ export default {
8598
8699
components: {
87100
NcButton,
101+
NcCheckboxRadioSwitch,
88102
NcTextField,
89103
},
90104
@@ -113,6 +127,7 @@ export default {
113127
name: '',
114128
credential: {},
115129
step: RegistrationSteps.READY,
130+
discoverable: true,
116131
}
117132
},
118133
@@ -138,7 +153,7 @@ export default {
138153
139154
try {
140155
await confirmPassword()
141-
this.credential = await startRegistration()
156+
this.credential = await startRegistration(this.discoverable)
142157
this.step = RegistrationSteps.NAMING
143158
} catch (err) {
144159
showError(err)
@@ -199,4 +214,8 @@ export default {
199214
max-width: min(100vw, 400px);
200215
}
201216
}
217+
218+
.new-webauthn-device__option {
219+
margin-bottom: 12px;
220+
}
202221
</style>

apps/settings/src/service/WebAuthnRegistrationSerice.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,19 @@ import logger from '../logger.ts'
1616
*
1717
* @return The device attributes
1818
*/
19-
export async function startRegistration() {
20-
const url = generateUrl('/settings/api/personal/webauthn/registration')
21-
19+
export async function startRegistration(discoverable = true): Promise<RegistrationResponseJSON> {
20+
const url = generateUrl('/settings/api/personal/webauthn/registration') + (discoverable ? '' : '?discoverable=0')
2221
try {
2322
logger.debug('Fetching webauthn registration data')
2423
const { data } = await axios.get<PublicKeyCredentialCreationOptionsJSON>(url)
2524
logger.debug('Start webauthn registration')
2625
const attrs = await registerWebAuthn({ optionsJSON: data })
2726
return attrs
2827
} catch (e) {
28+
if (shouldFallbackToLegacy(e) && discoverable) {
29+
logger.debug('WebAuthn discoverable registration failed, falling back to legacy mode')
30+
return await startRegistration(false)
31+
}
2932
logger.error(e as Error)
3033
if (isAxiosError(e)) {
3134
throw new Error(t('settings', 'Could not register device: Network error'))
@@ -36,6 +39,18 @@ export async function startRegistration() {
3639
}
3740
}
3841

42+
function shouldFallbackToLegacy(error: unknown): boolean {
43+
if (error instanceof Error) {
44+
if (error.name === 'ConstraintError' || error.name === 'NotSupportedError') {
45+
return true
46+
}
47+
if (error.message.includes('Discoverable credentials were required')) {
48+
return true
49+
}
50+
}
51+
return false
52+
}
53+
3954
/**
4055
* @param name Name of the device
4156
* @param data Device attributes

core/Controller/WebAuthnController.php

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,21 +43,28 @@ public function __construct(
4343
#[PublicPage]
4444
#[UseSession]
4545
#[FrontpageRoute(verb: 'POST', url: 'login/webauthn/start')]
46-
public function startAuthentication(string $loginName): JSONResponse {
46+
public function startAuthentication(?string $loginName = null): JSONResponse {
4747
$this->logger->debug('Starting WebAuthn login');
4848

49-
$this->logger->debug('Converting login name to UID');
50-
$uid = $loginName;
51-
Util::emitHook(
52-
'\OCA\Files_Sharing\API\Server2Server',
53-
'preLoginNameUsedAsUserName',
54-
['uid' => &$uid]
55-
);
56-
$this->logger->debug('Got UID: ' . $uid);
49+
$uid = null;
50+
if ($loginName !== null && $loginName !== '') {
51+
$this->logger->debug('Converting login name to UID');
52+
$uid = $loginName;
53+
Util::emitHook(
54+
'\OCA\Files_Sharing\API\Server2Server',
55+
'preLoginNameUsedAsUserName',
56+
['uid' => &$uid]
57+
);
58+
$this->logger->debug('Got UID: ' . $uid);
59+
}
5760

5861
$publicKeyCredentialRequestOptions = $this->webAuthnManger->startAuthentication($uid, $this->request->getServerHost());
5962
$this->session->set(self::WEBAUTHN_LOGIN, json_encode($publicKeyCredentialRequestOptions));
60-
$this->session->set(self::WEBAUTHN_LOGIN_UID, $uid);
63+
if ($uid !== null && $uid !== '') {
64+
$this->session->set(self::WEBAUTHN_LOGIN_UID, $uid);
65+
} else {
66+
$this->session->remove(self::WEBAUTHN_LOGIN_UID);
67+
}
6168

6269
return new JSONResponse($publicKeyCredentialRequestOptions);
6370
}
@@ -68,15 +75,18 @@ public function startAuthentication(string $loginName): JSONResponse {
6875
public function finishAuthentication(string $data): JSONResponse {
6976
$this->logger->debug('Validating WebAuthn login');
7077

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

7683
// Obtain the publicKeyCredentialOptions from when we started the registration
7784
$publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::createFromString($this->session->get(self::WEBAUTHN_LOGIN));
78-
$uid = $this->session->get(self::WEBAUTHN_LOGIN_UID);
79-
$this->webAuthnManger->finishAuthentication($publicKeyCredentialRequestOptions, $data, $uid);
85+
$uidFromSession = $this->session->get(self::WEBAUTHN_LOGIN_UID);
86+
$this->session->remove(self::WEBAUTHN_LOGIN);
87+
$this->session->remove(self::WEBAUTHN_LOGIN_UID);
88+
$publicKeyCredentialSource = $this->webAuthnManger->finishAuthentication($publicKeyCredentialRequestOptions, $data, $uidFromSession);
89+
$uid = $uidFromSession ?? $publicKeyCredentialSource->getUserHandle();
8090

8191
//TODO: add other parameters
8292
$loginData = new LoginData(

core/src/components/login/PasswordLessLoginForm.vue

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,15 @@
1616
</h2>
1717

1818
<NcTextField
19-
required
2019
:model-value="user"
2120
:autocomplete="autoCompleteAllowed ? 'on' : 'off'"
2221
:error="!validCredentials"
23-
:label="t('core', 'Login or email')"
24-
:placeholder="t('core', 'Login or email')"
25-
:helper-text="!validCredentials ? t('core', 'Your account is not setup for passwordless login.') : ''"
22+
:label="t('core', 'Login or email (optional)')"
23+
:placeholder="t('core', 'Login or email (optional)')"
24+
:helper-text="helperText"
2625
@update:value="changeUsername" />
2726

2827
<LoginButton
29-
v-if="validCredentials"
3028
:loading="loading"
3129
@click="authenticate" />
3230
</form>
@@ -113,6 +111,7 @@ export default defineComponent({
113111
user: this.username,
114112
loading: false,
115113
validCredentials: true,
114+
helperText: this.t('core', 'Leave empty to use a discoverable credential.'),
116115
}
117116
},
118117
@@ -125,11 +124,15 @@ export default defineComponent({
125124
126125
logger.debug('passwordless login initiated')
127126
127+
this.loading = true
128128
try {
129-
const params = await startAuthentication(this.user)
129+
const trimmed = this.user.trim()
130+
const params = await startAuthentication(trimmed !== '' ? trimmed : undefined)
130131
await this.completeAuthentication(params)
131132
} catch (error) {
132-
if (error instanceof NoValidCredentials) {
133+
this.loading = false
134+
if (error instanceof NoValidCredentials && this.user.trim() === '') {
135+
this.helperText = this.t('core', 'No discoverable credential found. Please enter your login or email and try again.')
133136
this.validCredentials = false
134137
return
135138
}
@@ -139,6 +142,8 @@ export default defineComponent({
139142
140143
changeUsername(username) {
141144
this.user = username
145+
this.validCredentials = true
146+
this.helperText = this.t('core', 'Leave empty to use a discoverable credential.')
142147
this.$emit('update:username', this.user)
143148
},
144149
@@ -152,21 +157,28 @@ export default defineComponent({
152157
})
153158
.catch((error) => {
154159
logger.debug('GOT AN ERROR WHILE SUBMITTING CHALLENGE!', { error }) // Example: timeout, interaction refused...
160+
this.loading = false
155161
})
156162
},
157163
158164
submit() {
159-
// noop
165+
if (!this.loading) {
166+
void this.authenticate()
167+
}
160168
},
161169
},
162170
})
163171
</script>
164172

165173
<style lang="scss" scoped>
166-
.password-less-login-form {
167-
display: flex;
168-
flex-direction: column;
169-
gap: 0.5rem;
170-
margin: 0;
171-
}
174+
.password-less-login-form {
175+
display: flex;
176+
flex-direction: column;
177+
gap: 0.5rem;
178+
margin: 0;
179+
180+
&__discoverable {
181+
align-self: flex-start;
182+
}
183+
}
172184
</style>

core/src/services/WebAuthnAuthenticationService.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,20 @@ export class NoValidCredentials extends Error {}
1616
* Start webautn authentication
1717
* This loads the challenge, connects to the authenticator and returns the repose that needs to be sent to the server.
1818
*
19-
* @param loginName Name to login
19+
* @param loginName Name to login (optional for discoverable credentials)
2020
*/
21-
export async function startAuthentication(loginName: string) {
21+
export async function startAuthentication(loginName?: string) {
2222
const url = generateUrl('/login/webauthn/start')
2323

24-
const { data } = await Axios.post<PublicKeyCredentialRequestOptionsJSON>(url, { loginName })
25-
if (!data.allowCredentials || data.allowCredentials.length === 0) {
24+
const body = loginName ? { loginName } : undefined
25+
const { data } = await Axios.post<PublicKeyCredentialRequestOptionsJSON>(url, body)
26+
if (loginName && (!data.allowCredentials || data.allowCredentials.length === 0)) {
2627
logger.error('No valid credentials returned for webauthn')
2728
throw new NoValidCredentials()
2829
}
29-
return await startWebauthnAuthentication({ optionsJSON: data })
30+
return await startWebauthnAuthentication({
31+
optionsJSON: data,
32+
})
3033
}
3134

3235
/**

dist/core-common.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/core-common.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/core-login.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)