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') }}

-
+
@@ -119,21 +172,29 @@ onMounted(async () => { -
+
- - - - + +
+ +
{{ $t('login.errorPasskeyFailed') }}
+
-
{{ $t('login.errorIncorrect2FAToken') }}
+
+ + + + + +
{{ $t('login.errorIncorrect2FAToken') }}
+ +
+ +
+
- -
- -
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 () => { - + - -
-

{{ $t('profile.enable2FA.mandatorySetup') }}

-

-
- - {{ twoFASecret }} + + +
+

{{ $t('profile.enable2FA.mandatorySetup') }}

+ + + + + + + +
+ +
{{ passkeyRegisterError }}
+
+ + +
+

+
+ + {{ twoFASecret }} +
+
+
+ + + + + + + +
{{ twoFAEnableError }}
+
+
-
-
-
- - - - - - - -
{{ twoFAEnableError }}
-
-
@@ -218,6 +288,9 @@ onMounted(async () => {
{{ profile.username }}
+
+ +
@@ -253,10 +326,17 @@ onMounted(async () => {
-
- - -
+ + + +
{{ $t('profile.twoFactorAuth.disabled') }}
+
{{ $t('profile.twoFactorAuth.totpEnabled') }}
+
{{ $t('profile.twoFactorAuth.passkeyEnabled') }}
+
+
+ +
+
diff --git a/migrations/20260212200000-passkeys-create-table.js b/migrations/20260212200000-passkeys-create-table.js new file mode 100644 index 000000000..ee1be25db --- /dev/null +++ b/migrations/20260212200000-passkeys-create-table.js @@ -0,0 +1,23 @@ +'use strict'; + +exports.up = async function(db) { + await db.runSql(` + CREATE TABLE IF NOT EXISTS passkeys( + id VARCHAR(128) NOT NULL UNIQUE, + userId VARCHAR(128) NOT NULL, + credentialId VARCHAR(512) NOT NULL UNIQUE, + publicKey TEXT NOT NULL, + counter BIGINT DEFAULT 0, + transports TEXT, + name VARCHAR(128) DEFAULT "", + creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + lastUsedTime TIMESTAMP NULL, + FOREIGN KEY(userId) REFERENCES users(id), + PRIMARY KEY(id) + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + `); +}; + +exports.down = async function(db) { + await db.runSql('DROP TABLE IF EXISTS passkeys;'); +}; diff --git a/migrations/schema.sql b/migrations/schema.sql index a51e0e722..7af3f24d3 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -381,3 +381,16 @@ CREATE TABLE IF NOT EXISTS locks( version INT DEFAULT 1 ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP); +CREATE TABLE IF NOT EXISTS passkeys( + id VARCHAR(128) NOT NULL UNIQUE, + userId VARCHAR(128) NOT NULL, + credentialId VARCHAR(512) NOT NULL UNIQUE, + publicKey TEXT NOT NULL, + counter BIGINT DEFAULT 0, + transports TEXT, + name VARCHAR(128) DEFAULT "", + creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + lastUsedTime TIMESTAMP NULL, + FOREIGN KEY(userId) REFERENCES users(id), + PRIMARY KEY(id)); + diff --git a/package-lock.json b/package-lock.json index d062715e5..346ad05b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@cloudron/superagent": "^1.0.1", "@google-cloud/dns": "^5.3.1", "@google-cloud/storage": "^7.19.0", + "@simplewebauthn/server": "^13.1.1", "@smithy/node-http-handler": "^4.4.9", "@smithy/util-retry": "^4.2.8", "@types/node": "^25.2.1", @@ -1829,6 +1830,12 @@ "node": ">=6" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2079,6 +2086,12 @@ "node": ">= 0.8" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@mswjs/interceptors": { "version": "0.39.5", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.5.tgz", @@ -2169,6 +2182,165 @@ "@otplib/plugin-thirty-two": "^12.0.1" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz", + "integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.0.tgz", + "integrity": "sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509-attr": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.0.tgz", + "integrity": "sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz", + "integrity": "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.0.tgz", + "integrity": "sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-pkcs8": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.0.tgz", + "integrity": "sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.0.tgz", + "integrity": "sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-pfx": "^2.6.0", + "@peculiar/asn1-pkcs8": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509-attr": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz", + "integrity": "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz", + "integrity": "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.0.tgz", + "integrity": "sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2244,6 +2416,25 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@simplewebauthn/server": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.2.tgz", + "integrity": "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.3.10", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@peculiar/x509": "^1.13.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@smithy/abort-controller": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", @@ -3186,6 +3377,20 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "license": "MIT", @@ -7192,6 +7397,24 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -7427,6 +7650,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/require-directory": { "version": "2.1.1", "license": "MIT", @@ -8213,6 +8442,24 @@ "node": ">=0.6.x" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/tunnel-ssh": { "version": "4.1.4", "license": "MIT", diff --git a/package.json b/package.json index 2a84c74c8..153f8d266 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "url": "https://git.cloudron.io/platform/box.git" }, "dependencies": { + "@simplewebauthn/server": "^13.1.1", "@aws-sdk/client-route-53": "^3.984.0", "@aws-sdk/client-s3": "^3.984.0", "@aws-sdk/lib-storage": "^3.984.0", diff --git a/src/oidcserver.js b/src/oidcserver.js index b4188dc1d..b912e3700 100644 --- a/src/oidcserver.js +++ b/src/oidcserver.js @@ -27,6 +27,7 @@ const assert = require('node:assert'), marked = require('marked'), middleware = require('./middleware'), oidcClients = require('./oidcclients.js'), + passkeys = require('./passkeys.js'), path = require('node:path'), paths = require('./paths.js'), http = require('node:http'), @@ -383,13 +384,50 @@ async function interactionLogin(req, res, next) { if (!req.body.username || typeof req.body.username !== 'string') return next(new HttpError(400, 'A username must be non-empty string')); if (!req.body.password || typeof req.body.password !== 'string') return next(new HttpError(400, 'A password must be non-empty string')); - if ('totpToken' in req.body && typeof req.body.totpToken !== 'string') return next(new HttpError(400, 'totpToken must be a string' )); + if ('totpToken' in req.body && typeof req.body.totpToken !== 'string') return next(new HttpError(400, 'totpToken must be a string')); + if ('passkeyResponse' in req.body && typeof req.body.passkeyResponse !== 'object') return next(new HttpError(400, 'passkeyResponse must be an object')); - const { username, password, totpToken } = req.body; + const { username, password, totpToken, passkeyResponse } = req.body; const verifyFunc = username.indexOf('@') === -1 ? users.verifyWithUsername : users.verifyWithEmail; - const [verifyError, user] = await safe(verifyFunc(username, password, users.AP_WEBADMIN, { totpToken, skipTotpCheck: false })); + // First verify password, skip 2FA check initially to determine what 2FA methods are available + const [verifyError, user] = await safe(verifyFunc(username, password, users.AP_WEBADMIN, { totpToken, passkeyResponse, skipTotpCheck: !totpToken && !passkeyResponse })); + + // Handle passkey verification if provided + if (!verifyError && user && !user.ghost && passkeyResponse && !totpToken) { + const userPasskeys = await passkeys.list(user.id); + if (userPasskeys.length > 0) { + const [passkeyError] = await safe(passkeys.verifyAuthentication(user, passkeyResponse)); + if (passkeyError) { + debug(`interactionLogin: passkey verification failed for ${username}: ${passkeyError.message}`); + return next(new HttpError(401, 'Invalid passkey')); + } + debug(`interactionLogin: passkey verified for ${username}`); + } + } + + // If password verified but 2FA is required and not provided, return challenge + if (!verifyError && user && !user.ghost && !totpToken && !passkeyResponse) { + const userPasskeys = await passkeys.list(user.id); + const has2FA = user.twoFactorAuthenticationEnabled || userPasskeys.length > 0; + + if (has2FA) { + // Generate passkey options if user has passkeys + let passkeyOptions = null; + if (userPasskeys.length > 0) { + const [optionsError, options] = await safe(passkeys.generateAuthenticationOptions(user)); + if (!optionsError) passkeyOptions = options; + } + + return res.status(200).send({ + twoFactorRequired: true, + totpRequired: user.twoFactorAuthenticationEnabled, + passkeyOptions + }); + } + } + if (verifyError && verifyError.reason === BoxError.INVALID_CREDENTIALS) return next(new HttpError(401, verifyError.message)); if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new HttpError(401, 'Username and password does not match')); if (verifyError) return next(new HttpError(500, verifyError)); diff --git a/src/passkeys.js b/src/passkeys.js new file mode 100644 index 000000000..93014c685 --- /dev/null +++ b/src/passkeys.js @@ -0,0 +1,316 @@ +'use strict'; + +exports = module.exports = { + list, + get, + getByCredentialId, + add, + del, + updateCounter, + + generateRegistrationOptions, + verifyRegistration, + generateAuthenticationOptions, + verifyAuthentication, + + removePrivateFields +}; + +const assert = require('node:assert'), + BoxError = require('./boxerror.js'), + crypto = require('node:crypto'), + dashboard = require('./dashboard.js'), + database = require('./database.js'), + debug = require('debug')('box:passkeys'), + safe = require('safetydance'), + { + generateRegistrationOptions: generateWebAuthnRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions: generateWebAuthnAuthenticationOptions, + verifyAuthenticationResponse + } = require('@simplewebauthn/server'), + _ = require('./underscore.js'); + +const PASSKEY_FIELDS = [ 'id', 'userId', 'credentialId', 'publicKey', 'counter', 'transports', 'name', 'creationTime', 'lastUsedTime' ].join(','); + +// In-memory challenge store with expiration (challenges are short-lived) +const gChallenges = new Map(); +const CHALLENGE_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes + +function removePrivateFields(passkey) { + return _.pick(passkey, ['id', 'name', 'creationTime', 'lastUsedTime']); +} + +function postProcess(passkey) { + if (!passkey) return null; + passkey.transports = passkey.transports ? JSON.parse(passkey.transports) : []; + return passkey; +} + +async function list(userId) { + assert.strictEqual(typeof userId, 'string'); + + const results = await database.query('SELECT ' + PASSKEY_FIELDS + ' FROM passkeys WHERE userId = ? ORDER BY creationTime', [ userId ]); + return results.map(postProcess); +} + +async function get(id) { + assert.strictEqual(typeof id, 'string'); + + const result = await database.query('SELECT ' + PASSKEY_FIELDS + ' FROM passkeys WHERE id = ?', [ id ]); + if (result.length === 0) return null; + return postProcess(result[0]); +} + +async function getByCredentialId(credentialId) { + assert.strictEqual(typeof credentialId, 'string'); + + const result = await database.query('SELECT ' + PASSKEY_FIELDS + ' FROM passkeys WHERE credentialId = ?', [ credentialId ]); + if (result.length === 0) return null; + return postProcess(result[0]); +} + +async function add(userId, credentialId, publicKey, counter, transports, name) { + assert.strictEqual(typeof userId, 'string'); + assert.strictEqual(typeof credentialId, 'string'); + assert.strictEqual(typeof publicKey, 'string'); + assert.strictEqual(typeof counter, 'number'); + assert(Array.isArray(transports)); + assert.strictEqual(typeof name, 'string'); + + const id = 'pk-' + crypto.randomUUID(); + + const query = 'INSERT INTO passkeys (id, userId, credentialId, publicKey, counter, transports, name) VALUES (?, ?, ?, ?, ?, ?, ?)'; + const args = [ id, userId, credentialId, publicKey, counter, JSON.stringify(transports), name ]; + + const [error] = await safe(database.query(query, args)); + if (error && error.sqlCode === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, 'Passkey already registered'); + if (error && error.sqlCode === 'ER_NO_REFERENCED_ROW_2') throw new BoxError(BoxError.NOT_FOUND, 'User not found'); + if (error) throw error; + + return { id }; +} + +async function del(id, userId) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof userId, 'string'); + + const result = await database.query('DELETE FROM passkeys WHERE id = ? AND userId = ?', [ id, userId ]); + if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Passkey not found'); +} + +async function updateCounter(id, counter) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof counter, 'number'); + + await database.query('UPDATE passkeys SET counter = ?, lastUsedTime = NOW() WHERE id = ?', [ counter, id ]); +} + +function storeChallenge(userId, challenge) { + const key = `${userId}`; + gChallenges.set(key, { + challenge, + expiresAt: Date.now() + CHALLENGE_EXPIRY_MS + }); + + // Clean up expired challenges periodically + for (const [k, v] of gChallenges) { + if (v.expiresAt < Date.now()) gChallenges.delete(k); + } +} + +function getAndDeleteChallenge(userId) { + const key = `${userId}`; + const entry = gChallenges.get(key); + gChallenges.delete(key); + + if (!entry) return null; + if (entry.expiresAt < Date.now()) return null; + + return entry.challenge; +} + +async function getRpInfo() { + const { fqdn } = await dashboard.getLocation(); + + return { + rpId: fqdn, + rpName: 'Cloudron', + origin: `https://${fqdn}` + }; +} + +async function generateRegistrationOptions(user) { + assert.strictEqual(typeof user, 'object'); + + // Only one passkey per user is allowed + const existingPasskeys = await list(user.id); + if (existingPasskeys.length > 0) { + throw new BoxError(BoxError.ALREADY_EXISTS, 'User already has a passkey registered'); + } + + // Cannot register passkey if TOTP is enabled (user must choose one or the other) + if (user.twoFactorAuthenticationEnabled) { + throw new BoxError(BoxError.ALREADY_EXISTS, 'Cannot register passkey when TOTP is enabled'); + } + + const { rpId, rpName } = await getRpInfo(); + + const options = await generateWebAuthnRegistrationOptions({ + rpName, + rpID: rpId, + userName: user.username || user.email, + userDisplayName: user.displayName || user.username || user.email, + userID: new TextEncoder().encode(user.id), + attestationType: 'none', + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred' + } + }); + + storeChallenge(user.id, options.challenge); + + debug(`generateRegistrationOptions: generated for user ${user.id}`); + + return options; +} + +async function verifyRegistration(user, response, name) { + assert.strictEqual(typeof user, 'object'); + assert.strictEqual(typeof response, 'object'); + assert.strictEqual(typeof name, 'string'); + + // Defense in depth: check again before adding + const existingPasskeys = await list(user.id); + if (existingPasskeys.length > 0) { + throw new BoxError(BoxError.ALREADY_EXISTS, 'User already has a passkey registered'); + } + + // Cannot register passkey if TOTP is enabled + if (user.twoFactorAuthenticationEnabled) { + throw new BoxError(BoxError.ALREADY_EXISTS, 'Cannot register passkey when TOTP is enabled'); + } + + const expectedChallenge = getAndDeleteChallenge(user.id); + if (!expectedChallenge) throw new BoxError(BoxError.BAD_FIELD, 'Challenge expired or not found'); + + const { rpId, origin } = await getRpInfo(); + + const [error, verification] = await safe(verifyRegistrationResponse({ + response, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpId + })); + + if (error) { + debug(`verifyRegistration: verification failed for user ${user.id}:`, error); + throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed'); + } + + if (!verification.verified || !verification.registrationInfo) { + throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed'); + } + + const { credential } = verification.registrationInfo; + + // credential.id is already base64url encoded by simplewebauthn + // credential.publicKey is a Uint8Array, store as base64 + const publicKeyBase64 = Buffer.from(credential.publicKey).toString('base64'); + + const result = await add( + user.id, + credential.id, // already base64url + publicKeyBase64, + credential.counter, + credential.transports || [], + name || 'Passkey' + ); + + debug(`verifyRegistration: passkey registered for user ${user.id}`); + + return result; +} + +async function generateAuthenticationOptions(user) { + assert.strictEqual(typeof user, 'object'); + + const { rpId } = await getRpInfo(); + const existingPasskeys = await list(user.id); + + if (existingPasskeys.length === 0) { + throw new BoxError(BoxError.NOT_FOUND, 'No passkeys registered'); + } + + const options = await generateWebAuthnAuthenticationOptions({ + rpID: rpId, + allowCredentials: existingPasskeys.map(pk => ({ + id: pk.credentialId, + transports: pk.transports + })), + userVerification: 'preferred' + }); + + storeChallenge(user.id, options.challenge); + + debug(`generateAuthenticationOptions: generated for user ${user.id}`); + + return options; +} + +async function verifyAuthentication(user, response) { + assert.strictEqual(typeof user, 'object'); + assert.strictEqual(typeof response, 'object'); + + const expectedChallenge = getAndDeleteChallenge(user.id); + if (!expectedChallenge) throw new BoxError(BoxError.BAD_FIELD, 'Challenge expired or not found'); + + const { rpId, origin } = await getRpInfo(); + + // Find the passkey being used - response.id is base64url encoded (matches our stored format) + const credentialIdBase64url = response.id; + const passkey = await getByCredentialId(credentialIdBase64url); + + if (!passkey) { + debug(`verifyAuthentication: passkey not found for credential ${credentialIdBase64url}`); + throw new BoxError(BoxError.NOT_FOUND, 'Passkey not found'); + } + + if (passkey.userId !== user.id) { + debug(`verifyAuthentication: passkey belongs to different user`); + throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey does not belong to this user'); + } + + // Convert stored base64 public key back to Uint8Array + const publicKey = new Uint8Array(Buffer.from(passkey.publicKey, 'base64')); + + const [error, verification] = await safe(verifyAuthenticationResponse({ + response, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpId, + credential: { + id: passkey.credentialId, + publicKey, + counter: passkey.counter, + transports: passkey.transports + } + })); + + if (error) { + debug(`verifyAuthentication: verification failed for user ${user.id}:`, error); + throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed'); + } + + if (!verification.verified) { + throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Passkey verification failed'); + } + + // Update the counter to prevent replay attacks + await updateCounter(passkey.id, verification.authenticationInfo.newCounter); + + debug(`verifyAuthentication: passkey verified for user ${user.id}`); + + return { verified: true, passkeyId: passkey.id }; +} diff --git a/src/routes/profile.js b/src/routes/profile.js index e644da346..2a2c209d9 100644 --- a/src/routes/profile.js +++ b/src/routes/profile.js @@ -18,7 +18,11 @@ exports = module.exports = { enableTwoFactorAuthentication, disableTwoFactorAuthentication, setNotificationConfig, - destroyUserSession + destroyUserSession, + getPasskey, + getPasskeyRegistrationOptions, + registerPasskey, + deletePasskey }; const assert = require('node:assert'), @@ -27,6 +31,7 @@ const assert = require('node:assert'), HttpError = require('@cloudron/connect-lastmile').HttpError, HttpSuccess = require('@cloudron/connect-lastmile').HttpSuccess, oidcServer = require('../oidcserver.js'), + passkeys = require('../passkeys.js'), safe = require('safetydance'), tokens = require('../tokens.js'), userDirectory = require('../user-directory.js'), @@ -252,3 +257,53 @@ async function destroyUserSession(req, res, next) { next(new HttpSuccess(204)); } + +async function getPasskey(req, res, next) { + assert.strictEqual(typeof req.user, 'object'); + + const [error, result] = await safe(passkeys.list(req.user.id)); + if (error) return next(BoxError.toHttpError(error)); + + // Only one passkey per user - return first one or null + const passkey = result.length > 0 ? passkeys.removePrivateFields(result[0]) : null; + next(new HttpSuccess(200, { passkey })); +} + +async function getPasskeyRegistrationOptions(req, res, next) { + assert.strictEqual(typeof req.user, 'object'); + + const [error, options] = await safe(passkeys.generateRegistrationOptions(req.user)); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, options)); +} + +async function registerPasskey(req, res, next) { + assert.strictEqual(typeof req.user, 'object'); + assert.strictEqual(typeof req.body, 'object'); + + if (!req.body.credential || typeof req.body.credential !== 'object') return next(new HttpError(400, 'credential must be an object')); + if (req.body.name && typeof req.body.name !== 'string') return next(new HttpError(400, 'name must be a string')); + + const name = req.body.name || 'Passkey'; + + const [error, result] = await safe(passkeys.verifyRegistration(req.user, req.body.credential, name)); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(201, { id: result.id })); +} + +async function deletePasskey(req, res, next) { + assert.strictEqual(typeof req.user, 'object'); + + // Get the user's passkey (only one allowed) + const [listError, result] = await safe(passkeys.list(req.user.id)); + if (listError) return next(BoxError.toHttpError(listError)); + + if (result.length === 0) return next(new HttpError(404, 'No passkey registered')); + + const [error] = await safe(passkeys.del(result[0].id, req.user.id)); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(204)); +} diff --git a/src/server.js b/src/server.js index bfe0589e8..8faf82385 100644 --- a/src/server.js +++ b/src/server.js @@ -206,6 +206,12 @@ async function initializeExpressSync() { router.post('/api/v1/profile/notification_config', json, token, authorizeUser, routes.profile.setNotificationConfig); // non-admins cannot get notifications anyway router.del ('/api/v1/profile/sessions', token, authorizeUser, routes.profile.destroyUserSession); + // passkey routes (singular - only one passkey per user) + router.get ('/api/v1/profile/passkey', token, authorizeUser, routes.profile.getPasskey); + router.post('/api/v1/profile/passkey/register/options', json, token, authorizeUser, routes.profile.getPasskeyRegistrationOptions); + router.post('/api/v1/profile/passkey/register', json, token, authorizeUser, routes.profile.registerPasskey); + router.post('/api/v1/profile/passkey/disable', json, token, authorizeUser, routes.users.verifyPassword, routes.profile.deletePasskey); + // app password routes router.get ('/api/v1/app_passwords', token, authorizeUser, routes.appPasswords.list); router.post('/api/v1/app_passwords', json, token, authorizeUser, routes.appPasswords.add); diff --git a/src/users.js b/src/users.js index 9fc5bfa20..bc28cf043 100644 --- a/src/users.js +++ b/src/users.js @@ -84,6 +84,7 @@ const appPasswords = require('./apppasswords.js'), mysql = require('mysql2'), notifications = require('./notifications'), oidcClients = require('./oidcclients.js'), + passkeys = require('./passkeys.js'), qrcode = require('qrcode'), safe = require('safetydance'), settings = require('./settings.js'), @@ -977,6 +978,10 @@ async function enableTwoFactorAuthentication(user, totpToken, auditSource) { const externalLdapConfig = await externalLdap.getConfig(); if (user.source === 'ldap' && externalLdap.supports2FA(externalLdapConfig)) throw new BoxError(BoxError.BAD_STATE, 'Cannot enable 2FA of external auth user'); + // Cannot enable TOTP if user has a passkey (user must choose one or the other) + const userPasskeys = await passkeys.list(user.id); + if (userPasskeys.length > 0) throw new BoxError(BoxError.ALREADY_EXISTS, 'Cannot enable TOTP when passkey is registered'); + const verified = speakeasy.totp.verify({ secret: user.twoFactorAuthenticationSecret, encoding: 'base32', token: totpToken, window: 2 }); if (!verified) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Invalid 2FA code');