feat: passkeys (add/remove)

This commit is contained in:
NGPixel
2023-10-10 02:12:57 +00:00
parent a181579746
commit 4d285caaa7
19 changed files with 1034 additions and 512 deletions
+200
View File
@@ -3,6 +3,8 @@ 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'
export default {
Query: {
@@ -122,6 +124,52 @@ export default {
return generateError(err)
}
},
/**
* Setup TFA
*/
async setupTFA (obj, args, context) {
try {
const userId = context.req.user?.id
if (!userId) {
throw new Error('ERR_USER_NOT_AUTHENTICATED')
}
const usr = await WIKI.db.users.query().findById(userId)
if (!usr) {
throw new Error('ERR_INVALID_USER')
}
const str = WIKI.auth.strategies[args.strategyId]
if (!str) {
throw new Error('ERR_INVALID_STRATEGY')
}
if (!usr.auth[args.strategyId]) {
throw new Error('ERR_INVALID_STRATEGY')
}
if (usr.auth[args.strategyId].tfaIsActive) {
throw new Error('ERR_TFA_ALREADY_ACTIVE')
}
const tfaQRImage = await usr.generateTFA(args.strategyId, args.siteId)
const tfaToken = await WIKI.db.userKeys.generateToken({
kind: 'tfaSetup',
userId: usr.id,
meta: {
strategyId: args.strategyId
}
})
return {
operation: generateSuccess('TFA setup started'),
continuationToken: tfaToken,
tfaQRImage
}
} catch (err) {
return generateError(err)
}
},
/**
* Deactivate 2FA
*/
@@ -164,6 +212,158 @@ export default {
return generateError(err)
}
},
/**
* Setup Passkey
*/
async setupPasskey (obj, args, context) {
try {
const userId = context.req.user?.id
if (!userId) {
throw new Error('ERR_USER_NOT_AUTHENTICATED')
}
const usr = await WIKI.db.users.query().findById(userId)
if (!usr) {
throw new Error('ERR_INVALID_USER')
}
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 options = await generateRegistrationOptions({
rpName: site.config.title,
rpId: site.hostname,
userID: usr.id,
userName: usr.email,
userDisplayName: usr.name,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred'
},
excludeCredentials: usr.passkeys.authenticators?.map(authenticator => ({
id: new Uint8Array(authenticator.credentialID),
type: 'public-key',
transports: authenticator.transports
})) ?? []
})
usr.passkeys.reg = {
challenge: options.challenge,
rpId: site.hostname,
siteId: site.id
}
await usr.$query().patch({
passkeys: usr.passkeys
})
return {
operation: generateSuccess('Passkey registration options generated successfully.'),
registrationOptions: options
}
} catch (err) {
return generateError(err)
}
},
/**
* Finalize Passkey Registration
*/
async finalizePasskey (obj, args, context) {
try {
const userId = context.req.user?.id
if (!userId) {
throw new Error('ERR_USER_NOT_AUTHENTICATED')
}
const usr = await WIKI.db.users.query().findById(userId)
if (!usr) {
throw new Error('ERR_INVALID_USER')
} else if (!usr.passkeys?.reg) {
throw new Error('ERR_PASSKEY_NOT_SETUP')
}
if (!args.name || args.name.trim().length < 1 || args.name.length > 255) {
throw new Error('ERR_PK_NAME_MISSING_OR_INVALID')
}
const verification = await verifyRegistrationResponse({
response: args.registrationResponse,
expectedChallenge: usr.passkeys.reg.challenge,
expectedOrigin: `https://${usr.passkeys.reg.rpId}`,
expectedRPID: usr.passkeys.reg.rpId,
requireUserVerification: true
})
if (!verification.verified) {
throw new Error('ERR_PK_VERIFICATION_FAILED')
}
if (!usr.passkeys.authenticators) {
usr.passkeys.authenticators = []
}
usr.passkeys.authenticators.push({
...verification.registrationInfo,
id: uuid(),
createdAt: new Date(),
name: args.name,
siteId: usr.passkeys.reg.siteId,
transports: args.registrationResponse.response.transports
})
delete usr.passkeys.reg
await usr.$query().patch({
passkeys: JSON.stringify(usr.passkeys, (k, v) => {
if (v instanceof Uint8Array) {
return Array.apply([], v)
}
return v
})
})
return {
operation: generateSuccess('Passkey registered successfully.')
}
} catch (err) {
return generateError(err)
}
},
/**
* Deactivate a passkey
*/
async deactivatePasskey (obj, args, context) {
try {
const userId = context.req.user?.id
if (!userId) {
throw new Error('ERR_USER_NOT_AUTHENTICATED')
}
const usr = await WIKI.db.users.query().findById(userId)
if (!usr) {
throw new Error('ERR_INVALID_USER')
} else if (!usr.passkeys?.authenticators) {
throw new Error('ERR_PASSKEY_NOT_SETUP')
}
usr.passkeys.authenticators = usr.passkeys.authenticators.filter(a => a.id !== args.id)
await usr.$query().patch({
passkeys: usr.passkeys
})
return {
operation: generateSuccess('Passkey deactivated successfully.')
}
} catch (err) {
return generateError(err)
}
},
/**
* Perform Password Change
*/
+8
View File
@@ -2,6 +2,7 @@ import { generateError, generateSuccess } from '../../helpers/graph.mjs'
import _, { isNil } from 'lodash-es'
import path from 'node:path'
import fs from 'fs-extra'
import { DateTime } from 'luxon'
export default {
Query: {
@@ -59,6 +60,13 @@ export default {
return auth
})
usr.passkeys = usr.passkeys.authenticators?.map(a => ({
id: a.id,
createdAt: DateTime.fromISO(a.createdAt).toJSDate(),
name: a.name,
siteHostname: a.rpID
})) ?? []
return usr
},
// async profile (obj, args, context, info) {
@@ -41,10 +41,28 @@ extend type Mutation {
setup: Boolean
): AuthenticationAuthResponse @rateLimit(limit: 5, duration: 60)
setupTFA(
strategyId: UUID!
siteId: UUID!
): AuthenticationSetupTFAResponse
deactivateTFA(
strategyId: UUID!
): DefaultResponse
setupPasskey(
siteId: UUID!
): AuthenticationSetupPasskeyResponse
finalizePasskey(
registrationResponse: JSON!
name: String!
): DefaultResponse
deactivatePasskey(
id: UUID!
): DefaultResponse
changePassword(
continuationToken: String
currentPassword: String
@@ -135,6 +153,17 @@ type AuthenticationTokenResponse {
jwt: String
}
type AuthenticationSetupTFAResponse {
operation: Operation
continuationToken: String
tfaQRImage: String
}
type AuthenticationSetupPasskeyResponse {
operation: Operation
registrationOptions: JSON
}
input AuthenticationStrategyInput {
key: String!
strategyKey: String!
+8
View File
@@ -132,6 +132,7 @@ type User {
name: String
email: String
auth: [UserAuth]
passkeys: [UserPasskey]
hasAvatar: Boolean
isSystem: Boolean
isActive: Boolean
@@ -152,6 +153,13 @@ type UserAuth {
config: JSON
}
type UserPasskey {
id: UUID
name: String
createdAt: Date
siteHostname: String
}
type UserDefaults {
timezone: String
dateFormat: String