diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json
index 317bc3b5e..1ab648089 100644
--- a/dashboard/package-lock.json
+++ b/dashboard/package-lock.json
@@ -9,6 +9,7 @@
"@cloudron/pankow": "^4.0.0",
"@fontsource/inter": "^5.2.8",
"@fortawesome/fontawesome-free": "^7.1.0",
+ "@simplewebauthn/browser": "^13.1.0",
"@vitejs/plugin-vue": "^6.0.4",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0",
@@ -1101,6 +1102,12 @@
"nanopop": "2.3.0"
}
},
+ "node_modules/@simplewebauthn/browser": {
+ "version": "13.2.2",
+ "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz",
+ "integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==",
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3765,6 +3772,11 @@
"nanopop": "2.3.0"
}
},
+ "@simplewebauthn/browser": {
+ "version": "13.2.2",
+ "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz",
+ "integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA=="
+ },
"@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
diff --git a/dashboard/package.json b/dashboard/package.json
index 51594946c..eccadcde6 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -7,6 +7,7 @@
},
"type": "module",
"dependencies": {
+ "@simplewebauthn/browser": "^13.1.0",
"@cloudron/pankow": "^4.0.0",
"@fontsource/inter": "^5.2.8",
"@fortawesome/fontawesome-free": "^7.1.0",
diff --git a/dashboard/public/translation/en.json b/dashboard/public/translation/en.json
index 8c3042e59..bf8e5725c 100644
--- a/dashboard/public/translation/en.json
+++ b/dashboard/public/translation/en.json
@@ -291,7 +291,10 @@
"authenticatorAppDescription": "Use Google Authenticator (Android, iOS), FreeOTP authenticator (Android, iOS) or a similar TOTP app to scan the secret.",
"token": "Token",
"enable": "Enable",
- "mandatorySetup": "2FA is required to access the dashboard. Please complete the setup to continue."
+ "mandatorySetup": "2FA is required to access the dashboard. Please complete the setup to continue.",
+ "passkeyOption": "Setup passkey",
+ "totpOption": "Setup TOTP",
+ "registerPasskey": "Register passkey"
},
"appPasswords": {
"title": "App Passwords",
@@ -356,6 +359,12 @@
"removeAppPassword": {
"title": "Remove App Password",
"description": "Remove app password \"{{ name }}\" ?"
+ },
+ "twoFactorAuth": {
+ "title": "Two-factor authentication",
+ "disabled": "Disabled",
+ "totpEnabled": "Using time-based one-time password (TOTP)",
+ "passkeyEnabled": "Using passkey"
}
},
"backups": {
@@ -1497,7 +1506,8 @@
"resetPasswordAction": "Reset password",
"errorIncorrect2FAToken": "2FA token is invalid",
"errorInternal": "Internal error, try again later",
- "loginAction": "Log in"
+ "loginAction": "Log in",
+ "usePasskeyAction": "Use passkey"
},
"passwordReset": {
"title": "Password reset",
diff --git a/dashboard/src/components/DisableTwoFADialog.vue b/dashboard/src/components/DisableTwoFADialog.vue
index ba31d315c..32ddb2416 100644
--- a/dashboard/src/components/DisableTwoFADialog.vue
+++ b/dashboard/src/components/DisableTwoFADialog.vue
@@ -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({
-
+
{{ formError.password }}
diff --git a/dashboard/src/models/ProfileModel.js b/dashboard/src/models/ProfileModel.js
index 20c466b69..26d610f53 100644
--- a/dashboard/src/models/ProfileModel.js
+++ b/dashboard/src/models/ProfileModel.js
@@ -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];
+ },
};
}
diff --git a/dashboard/src/views/LoginView.vue b/dashboard/src/views/LoginView.vue
index e0b209f8d..07a4b7c40 100644
--- a/dashboard/src/views/LoginView.vue
+++ b/dashboard/src/views/LoginView.vue
@@ -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 () => {
{{ $t('login.loginAction') }}
-
-
diff --git a/dashboard/src/views/ProfileView.vue b/dashboard/src/views/ProfileView.vue
index 2f9382c97..b68cef64a 100644
--- a/dashboard/src/views/ProfileView.vue
+++ b/dashboard/src/views/ProfileView.vue
@@ -1,7 +1,8 @@
@@ -181,29 +236,44 @@ onMounted(async () => {
-
+
-