Skip to content

Commit

Permalink
feat: passkeys (add/remove)
Browse files Browse the repository at this point in the history
  • Loading branch information
NGPixel committed Oct 10, 2023
1 parent a181579 commit 4d285ca
Show file tree
Hide file tree
Showing 19 changed files with 1,032 additions and 510 deletions.
1 change: 1 addition & 0 deletions server/db/migrations/3.0.0.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ export async function up (knex) {
table.string('name').notNullable()
table.jsonb('auth').notNullable().defaultTo('{}')
table.jsonb('meta').notNullable().defaultTo('{}')
table.jsonb('passkeys').notNullable().defaultTo('{}')
table.jsonb('prefs').notNullable().defaultTo('{}')
table.boolean('hasAvatar').notNullable().defaultTo(false)
table.boolean('isSystem').notNullable().defaultTo(false)
Expand Down
200 changes: 200 additions & 0 deletions server/graph/resolvers/authentication.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
*/
Expand Down
8 changes: 8 additions & 0 deletions server/graph/resolvers/user.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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) {
Expand Down
29 changes: 29 additions & 0 deletions server/graph/schemas/authentication.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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!
Expand Down
8 changes: 8 additions & 0 deletions server/graph/schemas/user.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ type User {
name: String
email: String
auth: [UserAuth]
passkeys: [UserPasskey]
hasAvatar: Boolean
isSystem: Boolean
isActive: Boolean
Expand All @@ -152,6 +153,13 @@ type UserAuth {
config: JSON
}

type UserPasskey {
id: UUID
name: String
createdAt: Date
siteHostname: String
}

type UserDefaults {
timezone: String
dateFormat: String
Expand Down
16 changes: 16 additions & 0 deletions server/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1613,6 +1613,9 @@
"editor.unsaved.body": "You have unsaved changes. Are you sure you want to leave the editor and discard any modifications you made since the last save?",
"editor.unsaved.title": "Discard Unsaved Changes?",
"editor.unsavedWarning": "You have unsaved edits. Are you sure you want to leave the editor?",
"error.ERR_PK_ALREADY_REGISTERED": "It looks like this authenticator is already registered.",
"error.ERR_PK_HOSTNAME_MISSING": "Your administrator must set a valid site hostname before passkeys can be used.",
"error.ERR_PK_USER_CANCELLED": "Passkey registration aborted. Make sure to remove the key from your device.",
"fileman.7zFileType": "7zip Archive",
"fileman.aacFileType": "AAC Audio File",
"fileman.aiFileType": "Adobe Illustrator Document",
Expand Down Expand Up @@ -1755,6 +1758,7 @@
"profile.authLoadingFailed": "Failed to load authentication methods.",
"profile.authModifyTfa": "Modify 2FA",
"profile.authSetTfa": "Set 2FA",
"profile.authSetTfaLoading": "Setting up 2FA... Please wait",
"profile.avatar": "Avatar",
"profile.avatarClearFailed": "Failed to clear profile picture.",
"profile.avatarClearSuccess": "Profile picture cleared successfully.",
Expand Down Expand Up @@ -1798,6 +1802,18 @@
"profile.pages.refreshSuccess": "Page list has been refreshed.",
"profile.pages.subtitle": "List of pages I created or last modified",
"profile.pages.title": "Pages",
"profile.passkeys": "Passkeys",
"profile.passkeysAdd": "Add Passkey",
"profile.passkeysDeactivateConfirm": "Are you sure you want to deactivate this passkey?",
"profile.passkeysDeactivateFailed": "Failed to deactivate the passkey.",
"profile.passkeysDeactivateSuccess": "Passkey deactivated successfully. You may still need to remove the passkey from your device.",
"profile.passkeysIntro": "Passkeys are a replacement for passwords for a faster, easier and more secure login. It relies on your device existing biometrics (phone, computer, security key) to validate your identity.",
"profile.passkeysInvalidName": "Passkey name is missing or invalid.",
"profile.passkeysName": "Passkey Name",
"profile.passkeysNameHint": "Enter a name for your passkey:",
"profile.passkeysSetupFailed": "Failed to setup new passkey.",
"profile.passkeysSetupSuccess": "Passkey registered successfully.",
"profile.passkeysUnsupported": "Passkeys are not supported on your device.",
"profile.preferences": "Preferences",
"profile.pronouns": "Pronouns",
"profile.pronounsHint": "Let people know which pronouns should they use when referring to you.",
Expand Down
4 changes: 2 additions & 2 deletions server/models/users.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export class User extends Model {

async generateTFA(strategyId, siteId) {
WIKI.logger.debug(`Generating new TFA secret for user ${this.id}...`)
const site = WIKI.sites[siteId] ?? WIKI.sites[0] ?? { config: { title: 'Wiki' }}
const site = WIKI.sites[siteId] ?? WIKI.sites[0] ?? { config: { title: 'Wiki' } }
const tfaInfo = tfa.generateSecret({
name: site.config.title,
account: this.email
Expand Down Expand Up @@ -485,7 +485,7 @@ export class User extends Model {
}

if (user) {
user.auth[strategyId].password = await bcrypt.hash(newPassword, 12),
user.auth[strategyId].password = await bcrypt.hash(newPassword, 12)
user.auth[strategyId].mustChangePwd = false
await user.$query().patch({
auth: user.auth
Expand Down
Loading

0 comments on commit 4d285ca

Please sign in to comment.