diff --git a/server/graph/resolvers/authentication.mjs b/server/graph/resolvers/authentication.mjs index cf419b3910..9e3464878c 100644 --- a/server/graph/resolvers/authentication.mjs +++ b/server/graph/resolvers/authentication.mjs @@ -3,8 +3,13 @@ import { generateError, generateSuccess } from '../../helpers/graph.mjs' import jwt from 'jsonwebtoken' import ms from 'ms' import { DateTime } from 'luxon' -import { v4 as uuid } from 'uuid' -import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server' +import base64 from '@hexagon/base64' +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse +} from '@simplewebauthn/server' export default { Query: { @@ -309,7 +314,7 @@ export default { } usr.passkeys.authenticators.push({ ...verification.registrationInfo, - id: uuid(), + id: base64.fromArrayBuffer(verification.registrationInfo.credentialID, true), createdAt: new Date(), name: args.name, siteId: usr.passkeys.reg.siteId, @@ -364,6 +369,117 @@ export default { return generateError(err) } }, + /** + * Login via passkey - Generate challenge + */ + async authenticatePasskeyGenerate (obj, args, context) { + try { + const site = WIKI.sites[args.siteId] + if (!site) { + throw new Error('ERR_INVALID_SITE') + } else if (site.hostname === '*') { + WIKI.logger.warn('Cannot use passkeys with a wildcard site hostname. Enter a valid hostname under the Administration Area > General.') + throw new Error('ERR_PK_HOSTNAME_MISSING') + } + + const usr = await WIKI.db.users.query().findOne({ email: args.email }) + if (!usr || !usr.passkeys?.authenticators) { + // Fake success response to prevent email leaking + WIKI.logger.debug(`Cannot generate passkey challenge for ${args.email}... (non-existing or missing passkeys setup)`) + return { + operation: generateSuccess('Passkey challenge generated.'), + authOptions: await generateAuthenticationOptions({ + allowCredentials: [{ + id: new Uint8Array(Array(30).map(v => _.random(0, 254))), + type: 'public-key', + transports: ['internal'] + }], + userVerification: 'preferred', + rpId: site.hostname + }) + } + } + + const options = await generateAuthenticationOptions({ + allowCredentials: usr.passkeys.authenticators.map(authenticator => ({ + id: new Uint8Array(authenticator.credentialID), + type: 'public-key', + transports: authenticator.transports + })), + userVerification: 'preferred', + rpId: site.hostname + }) + + usr.passkeys.login = { + challenge: options.challenge, + rpId: site.hostname, + siteId: site.id + } + + await usr.$query().patch({ + passkeys: usr.passkeys + }) + + return { + operation: generateSuccess('Passkey challenge generated.'), + authOptions: options + } + } catch (err) { + return generateError(err) + } + }, + /** + * Login via passkey - Verify challenge + */ + async authenticatePasskeyVerify (obj, args, context) { + try { + if (!args.authResponse?.response?.userHandle) { + throw new Error('ERR_INVALID_PASSKEY_RESPONSE') + } + const usr = await WIKI.db.users.query().findById(args.authResponse.response.userHandle) + if (!usr) { + WIKI.logger.debug(`Passkey Login Failure: Cannot find user ${args.authResponse.response.userHandle}`) + throw new Error('ERR_LOGIN_FAILED') + } else if (!usr.passkeys?.login) { + WIKI.logger.debug(`Passkey Login Failure: Missing login auth generation step for user ${args.authResponse.response.userHandle}`) + throw new Error('ERR_LOGIN_FAILED') + } else if (!usr.passkeys.authenticators?.some(a => a.id === args.authResponse.id)) { + WIKI.logger.debug(`Passkey Login Failure: Authenticator provided is not registered for user ${args.authResponse.response.userHandle}`) + throw new Error('ERR_LOGIN_FAILED') + } + + const verification = await verifyAuthenticationResponse({ + response: args.authResponse, + expectedChallenge: usr.passkeys.login.challenge, + expectedOrigin: `https://${usr.passkeys.login.rpId}`, + expectedRPID: usr.passkeys.login.rpId, + requireUserVerification: true, + authenticator: _.find(usr.passkeys.authenticators, ['id', args.authResponse.id]) + }) + + if (!verification.verified) { + WIKI.logger.debug(`Passkey Login Failure: Challenge verification failed for user ${args.authResponse.response.userHandle}`) + throw new Error('ERR_LOGIN_FAILED') + } + + delete usr.passkeys.login + + await usr.$query().patch({ + passkeys: usr.passkeys + }) + + const jwtToken = await WIKI.db.users.refreshToken(usr) + + return { + operation: generateSuccess('Passkey challenge accepted.'), + nextAction: 'redirect', + jwt: jwtToken.token, + redirect: '/' + } + } catch (err) { + return generateError(err) + } + }, /** * Perform Password Change */ diff --git a/server/graph/schemas/authentication.graphql b/server/graph/schemas/authentication.graphql index b7fdca4c7c..13fcbae51a 100644 --- a/server/graph/schemas/authentication.graphql +++ b/server/graph/schemas/authentication.graphql @@ -63,6 +63,15 @@ extend type Mutation { id: UUID! ): DefaultResponse + authenticatePasskeyGenerate( + email: String! + siteId: UUID! + ): AuthenticationPasskeyResponse @rateLimit(limit: 5, duration: 60) + + authenticatePasskeyVerify( + authResponse: JSON! + ): AuthenticationAuthResponse @rateLimit(limit: 5, duration: 60) + changePassword( continuationToken: String currentPassword: String @@ -164,6 +173,11 @@ type AuthenticationSetupPasskeyResponse { registrationOptions: JSON } +type AuthenticationPasskeyResponse { + operation: Operation + authOptions: JSON +} + input AuthenticationStrategyInput { key: String! strategyKey: String! diff --git a/server/graph/schemas/user.graphql b/server/graph/schemas/user.graphql index 3473da3060..4045b044ca 100644 --- a/server/graph/schemas/user.graphql +++ b/server/graph/schemas/user.graphql @@ -154,7 +154,7 @@ type UserAuth { } type UserPasskey { - id: UUID + id: String name: String createdAt: Date siteHostname: String diff --git a/server/locales/en.json b/server/locales/en.json index 9120fe0e38..b869731208 100644 --- a/server/locales/en.json +++ b/server/locales/en.json @@ -1180,6 +1180,8 @@ "auth.nameTooLong": "Name is too long.", "auth.nameTooShort": "Name is too short.", "auth.orLoginUsingStrategy": "or login using...", + "auth.passkeys.signin": "Log In with a Passkey", + "auth.passkeys.signinHint": "Enter your email address to login with a passkey:", "auth.passwordNotMatch": "Both passwords do not match.", "auth.passwordTooShort": "Password is too short.", "auth.pleaseWait": "Please wait", diff --git a/server/package.json b/server/package.json index 4b299a23c0..3714eeb68c 100644 --- a/server/package.json +++ b/server/package.json @@ -41,6 +41,7 @@ "@exlinc/keycloak-passport": "1.0.2", "@graphql-tools/schema": "10.0.0", "@graphql-tools/utils": "10.0.6", + "@hexagon/base64": "1.1.28", "@joplin/turndown-plugin-gfm": "1.0.50", "@node-saml/passport-saml": "4.0.4", "@root/csr": "0.8.1", diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index d8e14137ae..2b18b85ad4 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -20,6 +20,9 @@ dependencies: '@graphql-tools/utils': specifier: 10.0.6 version: 10.0.6(graphql@16.8.1) + '@hexagon/base64': + specifier: 1.1.28 + version: 1.1.28 '@joplin/turndown-plugin-gfm': specifier: 1.0.50 version: 1.0.50 diff --git a/ux/quasar.config.js b/ux/quasar.config.js index 741100f4d2..bd7f7764f0 100644 --- a/ux/quasar.config.js +++ b/ux/quasar.config.js @@ -127,13 +127,15 @@ module.exports = configure(function (ctx) { // https: true open: false, // opens browser window automatically port: userConfig.dev?.port, - proxy: { - '/_graphql': `http://127.0.0.1:${userConfig.port}/_graphql`, - '/_blocks': `http://127.0.0.1:${userConfig.port}`, - '/_site': `http://127.0.0.1:${userConfig.port}`, - '/_thumb': `http://127.0.0.1:${userConfig.port}`, - '/_user': `http://127.0.0.1:${userConfig.port}` - }, + proxy: ['_graphql', '_blocks', '_site', '_thumb', '_user'].reduce((result, key) => { + result[`/${key}`] = { + target: { + host: '127.0.0.1', + port: userConfig.port + } + } + return result + }, {}), hmr: { clientPort: userConfig.dev?.hmrClientPort }, diff --git a/ux/src/components/AuthLoginPanel.vue b/ux/src/components/AuthLoginPanel.vue index f16b3d87d7..5ce02335af 100644 --- a/ux/src/components/AuthLoginPanel.vue +++ b/ux/src/components/AuthLoginPanel.vue @@ -51,6 +51,16 @@ no-caps icon='las la-sign-in-alt' ) + template(v-if='canUsePasskeys') + q-separator.q-my-md + q-btn.acrylic-btn.full-width( + flat + color='primary' + :label='t(`auth.passkeys.signin`)' + no-caps + icon='las la-key' + @click='switchTo(`passkey`)' + ) template(v-if='selectedStrategy.activeStrategy?.strategy?.key === `local`') q-separator.q-my-md q-btn.acrylic-btn.full-width.q-mb-sm( @@ -71,6 +81,40 @@ @click='switchTo(`forgot`)' ) + //- ----------------------------------------------------- + //- PASSKEY LOGIN SCREEN + //- ----------------------------------------------------- + template(v-else-if='state.screen === `passkey`') + p {{t('auth.passkeys.signinHint')}} + q-form(ref='passkeyForm', @submit='loginWithPasskey') + q-input( + ref='passkeyEmailIpt' + v-model='state.username' + outlined + hide-bottom-space + :label='t(`auth.fields.email`)' + autocomplete='webauthn' + ) + template(#prepend) + i.las.la-envelope + q-btn.full-width.q-mt-sm( + type='submit' + push + color='primary' + :label='t(`auth.actions.login`)' + no-caps + icon='las la-key' + ) + q-separator.q-my-md + q-btn.acrylic-btn.full-width( + flat + color='primary' + :label='t(`auth.forgotPasswordCancel`)' + no-caps + icon='las la-arrow-circle-left' + @click='switchTo(`login`)' + ) + //- ----------------------------------------------------- //- FORGOT PASSWORD SCREEN //- ----------------------------------------------------- @@ -298,10 +342,14 @@ import gql from 'graphql-tag' import { find } from 'lodash-es' import Cookies from 'js-cookie' import zxcvbn from 'zxcvbn' - import { useI18n } from 'vue-i18n' import { useQuasar } from 'quasar' import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue' +import { + browserSupportsWebAuthn, + browserSupportsWebAuthnAutofill, + startAuthentication +} from '@simplewebauthn/browser' import { useSiteStore } from 'src/stores/site' import { useUserStore } from 'src/stores/user' @@ -343,6 +391,7 @@ const state = reactive({ // REFS const loginEmailIpt = ref(null) +const passkeyEmailIpt = ref(null) const forgotEmailIpt = ref(null) const registerNameIpt = ref(null) const changePwdCurrentIpt = ref(null) @@ -395,6 +444,10 @@ const passwordStrength = computed(() => { } }) +const canUsePasskeys = computed(() => { + return browserSupportsWebAuthn() +}) + // VALIDATION RULES const loginUsernameValidation = [ @@ -436,6 +489,13 @@ function switchTo (screen) { }) break } + case 'passkey': { + state.screen = 'passkey' + nextTick(() => { + passkeyEmailIpt.value.focus() + }) + break + } case 'forgot': { state.screen = 'forgot' nextTick(() => { @@ -598,7 +658,7 @@ async function login () { }) if (resp.data?.login?.operation?.succeeded) { state.password = '' - await handleLoginResponse(resp.data.login) + handleLoginResponse(resp.data.login) } else { throw new Error(resp.data?.login?.operation?.message || t('auth.errors.loginError')) } @@ -611,6 +671,81 @@ async function login () { } } +/** + * LOGIN WITH PASSKEY + */ +async function loginWithPasskey () { + $q.loading.show({ + message: t('auth.signingIn') + }) + try { + const respGen = await APOLLO_CLIENT.mutate({ + mutation: gql` + mutation authenticatePasskeyGenerate ( + $email: String! + $siteId: UUID! + ) { + authenticatePasskeyGenerate ( + email: $email + siteId: $siteId + ) { + operation { + succeeded + message + } + authOptions + } + } + `, + variables: { + email: state.username, + siteId: siteStore.id + } + }) + if (respGen.data?.authenticatePasskeyGenerate?.operation?.succeeded) { + const authResp = await startAuthentication(respGen.data.authenticatePasskeyGenerate.authOptions, await browserSupportsWebAuthnAutofill()) + + const respVerif = await APOLLO_CLIENT.mutate({ + mutation: gql` + mutation authenticatePasskeyVerify ( + $authResponse: JSON! + ) { + authenticatePasskeyVerify ( + authResponse: $authResponse + ) { + operation { + succeeded + message + } + jwt + nextAction + continuationToken + redirect + tfaQRImage + } + } + `, + variables: { + authResponse: authResp + } + }) + if (respVerif.data?.authenticatePasskeyVerify?.operation?.succeeded) { + handleLoginResponse(respVerif.data.authenticatePasskeyVerify) + } else { + throw new Error(respVerif.data?.authenticatePasskeyVerify?.operation?.message || t('auth.errors.loginError')) + } + } else { + throw new Error(respGen.data?.authenticatePasskeyGenerate?.operation?.message || t('auth.errors.loginError')) + } + } catch (err) { + $q.loading.hide() + $q.notify({ + type: 'negative', + message: err.message + }) + } +} + /** * FORGOT PASSWORD */