feat: passkeys login

pull/6813/head
NGPixel 1 year ago
parent 4d285caaa7
commit 88197c174c
No known key found for this signature in database
GPG Key ID: B755FB6870B30F63

@ -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
*/

@ -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!

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

@ -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",

@ -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",

@ -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

@ -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
},

@ -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
*/

Loading…
Cancel
Save