Skip to content

Commit

Permalink
feat: passkeys login
Browse files Browse the repository at this point in the history
  • Loading branch information
NGPixel committed Oct 14, 2023
1 parent 4d285ca commit 88197c1
Show file tree
Hide file tree
Showing 8 changed files with 286 additions and 13 deletions.
122 changes: 119 additions & 3 deletions server/graph/resolvers/authentication.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
*/
Expand Down
14 changes: 14 additions & 0 deletions server/graph/schemas/authentication.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -164,6 +173,11 @@ type AuthenticationSetupPasskeyResponse {
registrationOptions: JSON
}

type AuthenticationPasskeyResponse {
operation: Operation
authOptions: JSON
}

input AuthenticationStrategyInput {
key: String!
strategyKey: String!
Expand Down
2 changes: 1 addition & 1 deletion server/graph/schemas/user.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ type UserAuth {
}

type UserPasskey {
id: UUID
id: String
name: String
createdAt: Date
siteHostname: String
Expand Down
2 changes: 2 additions & 0 deletions server/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions server/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 9 additions & 7 deletions ux/quasar.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
Loading

0 comments on commit 88197c1

Please sign in to comment.