Add passkey support

This commit is contained in:
Johannes Zellner
2026-02-12 21:10:51 +01:00
parent 3e09bef613
commit 5724ca73b4
16 changed files with 992 additions and 69 deletions
@@ -12,6 +12,7 @@ const dialog = useTemplateRef('dialog');
const formError = ref({});
const busy = ref (false);
const password = ref('');
const twoFAMethod = ref('totp'); // 'totp' or 'passkey'
const form = useTemplateRef('form');
const isFormValid = ref(false);
@@ -25,10 +26,19 @@ async function onSubmit() {
busy.value = true;
formError.value = {};
const [error] = await profileModel.disableTwoFA(password.value);
let error;
if (twoFAMethod.value === 'passkey') {
[error] = await profileModel.deletePasskey(password.value);
} else {
[error] = await profileModel.disableTwoFA(password.value);
}
if (error) {
if (error.status === 412) formError.value.password = error.body.message;
else {
if (error.status === 412) {
password.value = '';
formError.value.password = error.body.message;
setTimeout(() => document.getElementById('passwordInput')?.focus(), 0);
} else {
formError.value.generic = error.status ? error.body.message : 'Internal error';
console.error('Failed to disable 2fa', error);
}
@@ -46,7 +56,8 @@ async function onSubmit() {
}
defineExpose({
async open() {
async open(method = 'totp') {
twoFAMethod.value = method;
password.value = '';
busy.value = false;
formError.value = {};
@@ -64,7 +75,7 @@ defineExpose({
:confirm-label="$t('profile.disable2FA.disable')"
:confirm-active="!busy && isFormValid"
:confirm-busy="busy"
confirm-style="primary"
confirm-style="danger"
:reject-label="$t('main.dialog.cancel')"
:reject-active="!busy"
reject-style="secondary"
@@ -78,7 +89,7 @@ defineExpose({
<FormGroup :has-error="formError.password">
<label>{{ $t('profile.disable2FA.password') }}</label>
<PasswordInput v-model="password" required />
<PasswordInput v-model="password" required id="passwordInput" />
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
</FormGroup>
</fieldset>
+44
View File
@@ -234,6 +234,50 @@ function create() {
if (error || result.status !== 201) return [error || result];
return [null, result.body];
},
async getPasskey() {
let error, result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/profile/passkey`, { access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 200) return [error || result];
return [null, result.body.passkey];
},
async getPasskeyRegistrationOptions() {
let error, result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/passkey/register/options`, {}, { access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 200) return [error || result];
return [null, result.body];
},
async registerPasskey(credential, name) {
let error, result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/passkey/register`, { credential, name }, { access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 201) return [error || result];
return [null, result.body];
},
async deletePasskey(password) {
let error, result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/passkey/disable`, { password }, { access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 204) return [error || result];
return [null];
},
};
}
+83 -22
View File
@@ -2,18 +2,21 @@
import { ref, onMounted } from 'vue';
import { Button, PasswordInput, TextInput, fetcher, FormGroup } from '@cloudron/pankow';
import { startAuthentication } from '@simplewebauthn/browser';
import PublicPageLayout from '../components/PublicPageLayout.vue';
const ready = ref(false);
const busy = ref(false);
const passwordError = ref(null);
const totpError = ref(null);
const passkeyError = ref(null);
const internalError = ref(null);
const oidcError = ref('');
const username = ref('');
const password = ref('');
const totpToken = ref('');
const totpTokenRequired = ref(false);
const passkeyOptions = ref(null);
const twoFARequired = ref(false);
// coming from oidc_login.html template
const name = window.cloudron.name;
@@ -26,6 +29,7 @@ async function onSubmit() {
busy.value = true;
passwordError.value = false;
totpError.value = false;
passkeyError.value = false;
internalError.value = false;
oidcError.value = '';
@@ -41,20 +45,32 @@ async function onSubmit() {
// the oidc login session is old
window.location.reload();
} else if (res.status === 401) {
if (res.body.message.indexOf('totpToken') !== -1) {
totpError.value = totpTokenRequired.value; // only set on second try coming from login
totpTokenRequired.value = true;
totpToken.value = '';
setTimeout(() => document.getElementById('inputTotpToken').focus(), 0);
// Authentication failed (wrong password, invalid TOTP, or invalid passkey)
if (res.body.message && res.body.message.indexOf('passkey') !== -1) {
passkeyError.value = true;
} else if (res.body.message && res.body.message.indexOf('totpToken') !== -1) {
totpError.value = true;
} else {
password.value = '';
passwordError.value = true;
setTimeout(() => document.getElementById('inputPassword').focus(), 0);
setTimeout(() => document.getElementById('inputPassword')?.focus(), 0);
}
} else if (res.status === 200) {
// Check if 2FA is required
if (res.body.twoFactorRequired) {
twoFARequired.value = true;
passkeyOptions.value = res.body.passkeyOptions || null;
totpToken.value = '';
// If only passkeys are available (no TOTP), auto-trigger passkey flow
if (passkeyOptions.value) await onUsePasskey();
else setTimeout(() => document.getElementById('inputTotpToken')?.focus(), 0);
} else if (res.body.redirectTo) {
return window.location.href = res.body.redirectTo;
} else {
console.error('login success but missing redirectTo in data:', res.body);
internalError.value = true;
}
} else if (res.status === 200 ) {
if (res.body.redirectTo) return window.location.href = res.body.redirectTo;
console.error('login success but missing redirectTo in data:', res.body);
internalError.value = true;
} else if (res.status >= 400 && res.status < 500) {
oidcError.value = 'OpenID Error: ' + (res.body.message || '') + '. Will reload in 5 seconds';
setTimeout(() => window.location.href = '/', 5000);
@@ -69,6 +85,43 @@ async function onSubmit() {
busy.value = false;
}
async function onUsePasskey() {
if (!passkeyOptions.value) return;
busy.value = true;
passkeyError.value = false;
totpError.value = false;
internalError.value = false;
try {
// Start WebAuthn authentication ceremony
const credential = await startAuthentication({ optionsJSON: passkeyOptions.value });
// Submit with passkey response
const body = {
username: username.value,
password: password.value,
passkeyResponse: credential
};
const res = await fetcher.post(submitUrl, body);
if (res.status === 200 && res.body.redirectTo) {
window.location.href = res.body.redirectTo;
return;
} else if (res.status === 401) {
passkeyError.value = true;
} else {
internalError.value = true;
}
} catch (error) {
console.error('Passkey authentication failed', error);
passkeyError.value = true;
}
busy.value = false;
}
onMounted(async () => {
// placed optionally in local storage by setupaccount.js
const autoLoginToken = localStorage.cloudronFirstTimeToken;
@@ -94,7 +147,7 @@ onMounted(async () => {
<h2>{{ $t('login.loginAction') }}</h2>
<p v-html="note" style="margin-bottom: 8px"></p>
<form @submit.prevent="onSubmit" v-if="!totpTokenRequired">
<form @submit.prevent="onSubmit" v-if="!twoFARequired">
<fieldset :disabled="busy">
<input type="submit" style="display: none;"/>
@@ -119,21 +172,29 @@ onMounted(async () => {
</div>
</form>
<form @submit.prevent="onSubmit" v-if="totpTokenRequired" autocomplete="off">
<form @submit.prevent="onSubmit" v-if="twoFARequired" autocomplete="off">
<fieldset :disabled="busy">
<input type="submit" style="display: none;"/>
<FormGroup :has-error="totpError">
<label for="inputTotpToken">{{ $t('login.2faToken') }}</label>
<TextInput id="inputTotpToken" v-model="totpToken" required/>
</FormGroup>
<!-- Passkey or TOTP option -->
<div v-if="passkeyOptions">
<Button id="passkeyButton" @click="onUsePasskey" :disabled="busy" :loading="busy">{{ $t('login.usePasskeyAction') }}</Button>
<div class="error-label" v-if="passkeyError">{{ $t('login.errorPasskeyFailed') }}</div>
</div>
<div class="error-label" v-if="totpError">{{ $t('login.errorIncorrect2FAToken') }}</div>
<div v-else>
<FormGroup :has-error="totpError">
<label for="inputTotpToken">{{ $t('login.2faToken') }}</label>
<TextInput id="inputTotpToken" v-model="totpToken"/>
</FormGroup>
<div class="error-label" v-if="totpError">{{ $t('login.errorIncorrect2FAToken') }}</div>
<div class="actions">
<Button id="totpTokenSubmitButton" @click="onSubmit" :disabled="busy || !totpToken" :loading="busy">{{ $t('login.loginAction') }}</Button>
</div>
</div>
</fieldset>
<div class="actions">
<Button id="totpTokenSubmitButton" @click="onSubmit" :disabled="busy || !totpToken" :loading="busy">{{ $t('login.loginAction') }}</Button>
</div>
</form>
</div>
</PublicPageLayout>
+115 -35
View File
@@ -1,7 +1,8 @@
<script setup>
import { ref, onMounted, useTemplateRef, inject } from 'vue';
import { Button, SingleSelect, Dialog, TextInput, InputGroup, FormGroup } from '@cloudron/pankow';
import { ref, computed, onMounted, useTemplateRef, inject } from 'vue';
import { Button, ButtonGroup, SingleSelect, Dialog, TextInput, InputGroup, FormGroup } from '@cloudron/pankow';
import { startRegistration } from '@simplewebauthn/browser';
import { setLanguage } from '../i18n.js';
import { TOKEN_TYPES } from '../constants.js';
import AppPasswords from '../components/AppPasswords.vue';
@@ -99,24 +100,36 @@ async function onRevokeAllWebAndCliTokens() {
await profileModel.logout();
}
// 2fa
const userPasskey = ref(null);
const twoFASetupMode = ref(''); // 'totp' or 'passkey'
const twoFASecret = ref('');
const twoFATotpToken = ref('');
const twoFAQRCode = ref('');
const twoFAEnableError = ref('');
const passkeyRegisterError = ref('');
const passkeyRegisterBusy = ref(false);
const twoFADialog = useTemplateRef('twoFADialog');
const has2FA = computed(() => profile.value.twoFactorAuthenticationEnabled || !!userPasskey.value);
async function loadPasskey() {
const [error, result] = await profileModel.getPasskey();
if (error) return console.error('Failed to load passkey', error);
userPasskey.value = result;
}
async function onOpenTwoFASetupDialog() {
twoFASetupMode.value = 'passkey';
twoFAEnableError.value = '';
twoFATotpToken.value = '';
passkeyRegisterError.value = '';
twoFADialog.value.open();
const [error, result] = await profileModel.setTwoFASecret();
if (error) return console.error(error);
twoFAEnableError.value = '';
twoFATotpToken.value = '';
twoFASecret.value = result.secret;
twoFAQRCode.value = result.qrcode;
twoFADialog.value.open();
}
async function onTwoFAEnable() {
@@ -127,12 +140,51 @@ async function onTwoFAEnable() {
}
await refreshProfile();
twoFADialog.value.close();
}
async function onRegisterPasskey() {
passkeyRegisterBusy.value = true;
passkeyRegisterError.value = '';
try {
// Get registration options from server
const [optionsError, options] = await profileModel.getPasskeyRegistrationOptions();
if (optionsError) {
passkeyRegisterError.value = optionsError.body?.message || 'Failed to get registration options';
passkeyRegisterBusy.value = false;
return;
}
// Start WebAuthn registration ceremony
const credential = await startRegistration({ optionsJSON: options });
// Send credential to server
const [registerError] = await profileModel.registerPasskey(credential, 'Cloudron');
if (registerError) {
passkeyRegisterError.value = registerError.body?.message || 'Failed to register passkey';
passkeyRegisterBusy.value = false;
return;
}
await loadPasskey();
await refreshProfile();
twoFADialog.value.close();
} catch (error) {
passkeyRegisterError.value = error.message || 'Passkey registration failed';
}
passkeyRegisterBusy.value = false;
}
async function onTwoFADisable() {
disableTwoFADialog.value.open();
// Pass the current 2FA method to the disable dialog
disableTwoFADialog.value.open(userPasskey.value ? 'passkey' : 'totp');
}
async function onTwoFADisableSuccess() {
await refreshProfile();
await loadPasskey();
}
// Init
@@ -172,6 +224,9 @@ onMounted(async () => {
if (config.value.mandatory2FA && !profile.value.twoFactorAuthenticationEnabled) {
onOpenTwoFASetupDialog();
}
// Load passkey
await loadPasskey();
});
</script>
@@ -181,29 +236,44 @@ onMounted(async () => {
<PrimaryEmailDialog ref="primaryEmailDialog" @success="refreshProfile"/>
<FallbackEmailDialog ref="fallbackEmailDialog" @success="refreshProfile"/>
<PasswordChangeDialog ref="passwordChangeDialog" @success="refreshProfile"/>
<DisableTwoFADialog ref="disableTwoFADialog" @success="refreshProfile"/>
<DisableTwoFADialog ref="disableTwoFADialog" @success="onTwoFADisableSuccess"/>
<Dialog ref="twoFADialog" :title="$t('profile.enable2FA.title')" :dismissable="!config.mandatory2FA || profile.twoFactorAuthenticationEnabled">
<div>
<p class="text-warning" v-if="config.mandatory2FA && !profile.twoFactorAuthenticationEnabled">{{ $t('profile.enable2FA.mandatorySetup') }}</p>
<p v-html="$t('profile.enable2FA.authenticatorAppDescription', { googleAuthenticatorPlayStoreLink: 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2', googleAuthenticatorITunesLink: 'https://itunes.apple.com/us/app/google-authenticator/id388497605', freeOTPPlayStoreLink: 'https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp', freeOTPITunesLink: 'https://itunes.apple.com/us/app/freeotp-authenticator/id872559395'})"></p>
<div style="text-align: center;">
<img :src="twoFAQRCode" style="border-radius: 10px; margin-bottom: 10px"/>
<small>{{ twoFASecret }}</small>
<!-- 2FA setup Dialog -->
<Dialog ref="twoFADialog" :title="$t('profile.enable2FA.title')" :dismissable="!config.mandatory2FA || has2FA">
<div style="text-align: center;">
<p class="text-warning" v-if="config.mandatory2FA && !has2FA">{{ $t('profile.enable2FA.mandatorySetup') }}</p>
<ButtonGroup>
<Button secondary @click="twoFASetupMode = 'passkey'" :outline="twoFASetupMode !== 'passkey' || null">{{ $t('profile.enable2FA.passkeyOption') }}</Button>
<Button secondary @click="twoFASetupMode = 'totp'" :outline="twoFASetupMode !== 'totp' || null">{{ $t('profile.enable2FA.totpOption') }}</Button>
</ButtonGroup>
<!-- Passkey Setup -->
<div v-if="twoFASetupMode === 'passkey'" style="text-align: center; margin-top: 20px;">
<Button @click="onRegisterPasskey()" :loading="passkeyRegisterBusy" :disabled="passkeyRegisterBusy">{{ $t('profile.enable2FA.registerPasskey') }}</Button>
<div class="error-label" v-if="passkeyRegisterError">{{ passkeyRegisterError }}</div>
</div>
<!-- TOTP Setup -->
<div v-if="twoFASetupMode === 'totp'">
<p v-html="$t('profile.enable2FA.authenticatorAppDescription', { googleAuthenticatorPlayStoreLink: 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2', googleAuthenticatorITunesLink: 'https://itunes.apple.com/us/app/google-authenticator/id388497605', freeOTPPlayStoreLink: 'https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp', freeOTPITunesLink: 'https://itunes.apple.com/us/app/freeotp-authenticator/id872559395'})"></p>
<div style="text-align: center;">
<img :src="twoFAQRCode" style="border-radius: 10px; margin-bottom: 10px"/>
<small>{{ twoFASecret }}</small>
</div>
<br/>
<form @submit.prevent="onTwoFAEnable()">
<input type="submit" style="display: none;" :disabled="!twoFATotpToken"/>
<FormGroup style="text-align: left;">
<label for="totpTokenInput">{{ $t('profile.enable2FA.token') }}</label>
<InputGroup>
<TextInput v-model="twoFATotpToken" id="totpTokenInput" style="flex-grow: 1;"/>
<Button @click="onTwoFAEnable()" :disabled="!twoFATotpToken">{{ $t('profile.enable2FA.enable') }}</Button>
</InputGroup>
<div class="error-label" v-if="twoFAEnableError">{{ twoFAEnableError }}</div>
</FormGroup>
</form>
</div>
<br/>
<br/>
<form @submit.prevent="onTwoFAEnable()">
<input type="submit" style="display: none;" :disabled="!twoFATotpToken"/>
<FormGroup style="text-align: left;">
<label for="totpTokenInput">{{ $t('profile.enable2FA.token') }}</label>
<InputGroup>
<TextInput v-model="twoFATotpToken" id="totpTokenInput" style="flex-grow: 1;"/>
<Button @click="onTwoFAEnable()" :disabled="!twoFATotpToken">{{ $t('profile.enable2FA.enable') }}</Button>
</InputGroup>
<div class="error-label" v-if="twoFAEnableError">{{ twoFAEnableError }}</div>
</FormGroup>
</form>
</div>
</Dialog>
@@ -218,6 +288,9 @@ onMounted(async () => {
<label>{{ $t('main.username') }}</label>
<div>{{ profile.username }}</div>
</FormGroup>
<div style="display: flex; align-items: center">
<Button plain v-if="!profile.source" @click="onPasswordChange()">{{ $t('profile.changePasswordAction') }}</Button>
</div>
</SettingsItem>
<SettingsItem>
@@ -253,10 +326,17 @@ onMounted(async () => {
</div>
</SettingsItem>
<div style="display: flex; gap: 10px;">
<Button tool v-if="!profile.source" @click="onPasswordChange()">{{ $t('profile.changePasswordAction') }}</Button>
<Button tool v-if="!profile.source || !config.external2FA" @click="profile.twoFactorAuthenticationEnabled ? onTwoFADisable() : onOpenTwoFASetupDialog()">{{ $t(profile.twoFactorAuthenticationEnabled ? 'profile.disable2FAAction' : 'profile.enable2FAAction') }}</Button>
</div>
<SettingsItem v-if="!profile.source || !config.external2FA">
<FormGroup>
<label>{{ $t('profile.twoFactorAuth.title') }}</label>
<div v-if="!has2FA">{{ $t('profile.twoFactorAuth.disabled') }}</div>
<div v-else-if="profile.twoFactorAuthenticationEnabled">{{ $t('profile.twoFactorAuth.totpEnabled') }} <i class="fa-solid fa-check text-success"></i></div>
<div v-else-if="userPasskey">{{ $t('profile.twoFactorAuth.passkeyEnabled') }} <i class="fa-solid fa-check text-success"></i></div>
</FormGroup>
<div style="display: flex; align-items: center">
<Button tool plain @click="has2FA ? onTwoFADisable() : onOpenTwoFASetupDialog()">{{ $t(has2FA ? 'profile.disable2FAAction' : 'profile.enable2FAAction') }}</Button>
</div>
</SettingsItem>
</div>
</div>
</Section>