瀏覽代碼

feat: passkeys login

NGPixel 1 年之前
父節點
當前提交
88197c174c

+ 119 - 3
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
      */

+ 14 - 0
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!

+ 1 - 1
server/graph/schemas/user.graphql

@@ -154,7 +154,7 @@ type UserAuth {
 }
 
 type UserPasskey {
-  id: UUID
+  id: String
   name: String
   createdAt: Date
   siteHostname: String

+ 2 - 0
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",

+ 1 - 0
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",

+ 3 - 0
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

+ 9 - 7
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
       },

+ 137 - 2
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
  */