Unverified Commit 4d285caa authored by NGPixel's avatar NGPixel

feat: passkeys (add/remove)

parent a1815797
......@@ -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)
......
......@@ -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: {
......@@ -123,6 +125,52 @@ export default {
}
},
/**
* 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
*/
async deactivateTFA (obj, args, context) {
......@@ -165,6 +213,158 @@ export default {
}
},
/**
* 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
*/
async changePassword (obj, args, context) {
......
......@@ -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!
......
......@@ -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
......
......@@ -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",
......@@ -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.",
......@@ -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.",
......
......@@ -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
......@@ -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
......
const request = require('request-promise')
// TODO: refactor to use fetch()
const prefetch = async (element) => {
const url = element.attr(`src`)
let response
try {
response = await request({
method: `GET`,
url,
resolveWithFullResponse: true
})
} catch (err) {
WIKI.logger.warn(`Failed to prefetch ${url}`)
WIKI.logger.warn(err)
return
}
const contentType = response.headers[`content-type`]
const image = Buffer.from(response.body).toString('base64')
element.attr('src', `data:${contentType};base64,${image}`)
element.removeClass('prefetch-candidate')
}
// const prefetch = async (element) => {
// const url = element.attr(`src`)
// let response
// try {
// response = await request({
// method: `GET`,
// url,
// resolveWithFullResponse: true
// })
// } catch (err) {
// WIKI.logger.warn(`Failed to prefetch ${url}`)
// WIKI.logger.warn(err)
// return
// }
// const contentType = response.headers[`content-type`]
// const image = Buffer.from(response.body).toString('base64')
// element.attr('src', `data:${contentType};base64,${image}`)
// element.removeClass('prefetch-candidate')
// }
module.exports = {
async init($) {
const promises = $('img.prefetch-candidate').map((index, element) => {
return prefetch($(element))
}).toArray()
await Promise.all(promises)
// const promises = $('img.prefetch-candidate').map((index, element) => {
// return prefetch($(element))
// }).toArray()
// await Promise.all(promises)
}
}
......@@ -42,9 +42,11 @@
"@graphql-tools/schema": "10.0.0",
"@graphql-tools/utils": "10.0.6",
"@joplin/turndown-plugin-gfm": "1.0.50",
"@node-saml/passport-saml": "4.0.4",
"@root/csr": "0.8.1",
"@root/keypairs": "0.10.3",
"@root/pem": "1.0.4",
"@simplewebauthn/server": "8.2.0",
"acme": "3.0.3",
"akismet-api": "6.0.0",
"aws-sdk": "2.1472.0",
......@@ -83,8 +85,6 @@
"graphql-upload": "16.0.2",
"he": "1.2.0",
"highlight.js": "11.8.0",
"i18next": "23.5.1",
"i18next-node-fs-backend": "2.1.3",
"image-size": "1.0.2",
"js-base64": "3.7.5",
"js-binary": "1.2.0",
......@@ -138,7 +138,6 @@
"passport-oauth2": "1.7.0",
"passport-okta-oauth": "0.0.1",
"passport-openidconnect": "0.1.1",
"passport-saml": "3.2.4",
"passport-slack-oauth2": "1.2.0",
"passport-twitch-strategy": "2.2.0",
"pem-jwk": "2.0.0",
......@@ -152,8 +151,6 @@
"puppeteer-core": "21.3.8",
"qr-image": "3.2.0",
"remove-markdown": "0.5.0",
"request": "2.88.2",
"request-promise": "4.2.6",
"safe-regex": "2.1.1",
"sanitize-filename": "1.6.3",
"scim-query-filter-parser": "2.0.4",
......@@ -179,7 +176,6 @@
"eslint-plugin-import": "2.28.1",
"eslint-plugin-node": "11.1.0",
"eslint-plugin-promise": "6.1.1",
"eslint-plugin-standard": "5.0.0",
"nodemon": "3.0.1"
},
"overrides": {
......
......@@ -17,6 +17,7 @@
"@lezer/common": "1.1.0",
"@mdi/font": "7.3.67",
"@quasar/extras": "1.16.7",
"@simplewebauthn/browser": "8.3.1",
"@tiptap/core": "2.1.11",
"@tiptap/extension-code-block": "2.1.11",
"@tiptap/extension-code-block-lowlight": "2.1.11",
......
......@@ -17,6 +17,9 @@ dependencies:
'@quasar/extras':
specifier: 1.16.7
version: 1.16.7
'@simplewebauthn/browser':
specifier: 8.3.1
version: 8.3.1
'@tiptap/core':
specifier: 2.1.11
version: 2.1.11(@tiptap/pm@2.1.11)
......@@ -709,6 +712,16 @@ packages:
picomatch: 2.3.1
dev: true
/@simplewebauthn/browser@8.3.1:
resolution: {integrity: sha512-bMW7oOkxX4ydRAkkPtJ1do2k9yOoIGc/hZYebcuEOVdJoC6wwVpu97mYY7Mz8B9hLlcaR5WFgBsLl5tSJVzm8A==}
dependencies:
'@simplewebauthn/typescript-types': 8.0.0
dev: false
/@simplewebauthn/typescript-types@8.0.0:
resolution: {integrity: sha512-d7Izb2H+LZJteXMkS8DmpAarD6mZdpIOu/av/yH4/u/3Pd6DKFLyBM3j8BMmUvUqpzvJvHARNrRfQYto58mtTQ==}
dev: false
/@socket.io/component-emitter@3.1.0:
resolution: {integrity: sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==}
dev: false
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><linearGradient id="3pqdDc9GpRiC~~RXNJCBxa" x1="20.035" x2="4.818" y1="13.721" y2="29.585" gradientTransform="translate(0 14)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e5a505"/><stop offset=".01" stop-color="#e9a804"/><stop offset=".06" stop-color="#f4b102"/><stop offset=".129" stop-color="#fbb600"/><stop offset=".323" stop-color="#fdb700"/></linearGradient><path fill="url(#3pqdDc9GpRiC~~RXNJCBxa)" d="M12,41.5c0-1.381,1.119-2.5,2.5-2.5c0.156,0,0.307,0.019,0.454,0.046l1.186-1.186 C16.058,37.586,16,37.301,16,37c0-1.657,1.343-3,3-3c0.301,0,0.586,0.058,0.86,0.14L24,30l-6-6L4.586,37.414 C4.211,37.789,4,38.298,4,38.828v1.343c0,0.53,0.211,1.039,0.586,1.414l1.828,1.828C6.789,43.789,7.298,44,7.828,44h1.343 c0.53,0,1.039-0.211,1.414-0.586l1.46-1.46C12.019,41.807,12,41.656,12,41.5z"/><linearGradient id="3pqdDc9GpRiC~~RXNJCBxb" x1="21.64" x2="36.971" y1="-6.927" y2="15.362" gradientTransform="translate(0 14)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fede00"/><stop offset="1" stop-color="#ffd000"/></linearGradient><path fill="url(#3pqdDc9GpRiC~~RXNJCBxb)" d="M29.5,5C22.044,5,16,11.044,16,18.5S22.044,32,29.5,32S43,25.956,43,18.5S36.956,5,29.5,5z M33,19c-2.209,0-4-1.791-4-4s1.791-4,4-4s4,1.791,4,4S35.209,19,33,19z"/><path d="M39.86,27.16c-0.12,0.15-0.25,0.3-0.38,0.44c-2.47,2.7-6.03,4.4-9.98,4.4 h-0.11c-0.2,0-0.39-0.01-0.59-0.02c1.96-3,5.35-4.98,9.2-4.98C38.63,27,39.25,27.05,39.86,27.16z" opacity=".05"/><path d="M39.48,27.6c-2.47,2.7-6.03,4.4-9.98,4.4h-0.11 c1.9-2.72,5.05-4.5,8.61-4.5C38.5,27.5,39,27.54,39.48,27.6z" opacity=".07"/><linearGradient id="3pqdDc9GpRiC~~RXNJCBxc" x1="33.304" x2="42.696" y1="88.831" y2="71.169" gradientTransform="matrix(1 0 0 -1 0 118)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#21ad64"/><stop offset="1" stop-color="#088242"/></linearGradient><circle cx="38" cy="38" r="10" fill="url(#3pqdDc9GpRiC~~RXNJCBxc)"/><path fill="#fff" d="M38.5,43h-1c-0.276,0-0.5-0.224-0.5-0.5v-9c0-0.276,0.224-0.5,0.5-0.5h1c0.276,0,0.5,0.224,0.5,0.5v9 C39,42.776,38.776,43,38.5,43z"/><path fill="#fff" d="M33,38.5v-1c0-0.276,0.224-0.5,0.5-0.5h9c0.276,0,0.5,0.224,0.5,0.5v1c0,0.276-0.224,0.5-0.5,0.5h-9 C33.224,39,33,38.776,33,38.5z"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><path fill="#35c1f1" d="M21,23c-0.553,0-1,0.447-1,1v3c0,0.553,0.447,1,1,1s1-0.447,1-1v-3C22,23.447,21.553,23,21,23z"/><path fill="#35c1f1" d="M21,30c-0.553,0-1,0.447-1,1v12c0,0.553,0.447,1,1,1s1-0.447,1-1V31C22,30.447,21.553,30,21,30z"/><path fill="#35c1f1" d="M20.501,19.024C17.903,19.278,16,21.613,16,24.223V42c0,0.553,0.447,1,1,1s1-0.447,1-1V24.119 c0-1.451,0.977-2.784,2.402-3.061C22.317,20.686,24,22.15,24,24v18c0,0.553,0.447,1,1,1s1-0.447,1-1V24 C26,21.078,23.481,18.734,20.501,19.024z"/><path fill="#35c1f1" d="M21.252,15.003c-1.336-0.037-2.636,0.218-3.86,0.755c-0.506,0.221-0.737,0.811-0.515,1.317 c0.222,0.505,0.81,0.737,1.317,0.515c1.1-0.483,2.278-0.671,3.484-0.558C25.313,17.374,28,20.603,28,24.254V38c0,0.553,0.447,1,1,1 s1-0.447,1-1V24.345C30,19.414,26.182,15.139,21.252,15.003z"/><path fill="#35c1f1" d="M13.848,18.552C12.639,20.137,12,22.021,12,24v17c0,0.553,0.447,1,1,1s1-0.447,1-1V24 c0-1.538,0.497-3.002,1.437-4.235c0.335-0.439,0.251-1.066-0.188-1.401C14.81,18.027,14.182,18.114,13.848,18.552z"/><path fill="#35c1f1" d="M20.345,11.016C13.362,11.361,8,17.386,8,24.377V28c0,0.553,0.447,1,1,1s1-0.447,1-1v-3.677 c0-5.724,4.24-10.736,9.939-11.273C26.479,12.434,32,17.585,32,24v14.678c0,0.553,0.447,1,1,1s1-0.448,1-1V24 C34,16.615,27.809,10.648,20.345,11.016z"/><path fill="#35c1f1" d="M9,31c-0.553,0-1,0.447-1,1v5v1.678V39c0,0.552,0.448,1,1,1s1-0.448,1-1v-0.322V37v-5 C10,31.447,9.553,31,9,31z"/><path fill="#35c1f1" d="M30.958,11.458c-0.37,0.41-0.338,1.043,0.072,1.412C34.188,15.719,36,19.775,36,24v10 c0,0.553,0.447,1,1,1s1-0.447,1-1V24c0-4.79-2.052-9.388-5.63-12.614C31.96,11.016,31.328,11.048,30.958,11.458z"/><path fill="#35c1f1" d="M21,9c2.422,0,4.745,0.569,6.904,1.693c0.49,0.255,1.094,0.063,1.349-0.425 c0.255-0.491,0.064-1.094-0.425-1.349C26.38,7.646,23.747,7,21,7c-6.048,0-11.689,3.267-14.724,8.525 C6,16.004,6.164,16.615,6.643,16.891c0.157,0.091,0.329,0.134,0.499,0.134c0.345,0,0.681-0.179,0.867-0.5 C10.687,11.884,15.665,9,21,9z"/><path fill="#35c1f1" d="M5.841,18.824c-0.531-0.149-1.082,0.17-1.228,0.702C4.206,21.023,4,22.528,4,24v10 c0,0.553,0.447,1,1,1s1-0.447,1-1V24c0-1.294,0.183-2.623,0.543-3.948C6.688,19.518,6.373,18.969,5.841,18.824z"/><path fill="#35c1f1" d="M41.808,21.301c-0.07-0.548-0.565-0.927-1.119-0.865c-0.548,0.07-0.935,0.571-0.865,1.119 C39.944,22.488,40,23.265,40,24v3v2.45c0,0.552,0.448,1,1,1l0,0c0.552,0,1-0.448,1-1V27v-3C42,23.178,41.939,22.319,41.808,21.301z"/><path fill="#35c1f1" d="M5.695,11.122c-0.439-0.334-1.066-0.249-1.401,0.19C1.484,15.004,0,19.392,0,24v6 c0,0.553,0.447,1,1,1s1-0.447,1-1v-6c0-4.168,1.344-8.136,3.885-11.477C6.219,12.084,6.134,11.457,5.695,11.122z"/><path fill="#35c1f1" d="M40.22,18.432c0.521-0.179,0.799-0.749,0.619-1.271C37.919,8.691,29.946,3,21,3 C15.915,3,10.999,4.859,7.155,8.236C6.74,8.601,6.699,9.232,7.065,9.647c0.364,0.414,0.996,0.455,1.411,0.091 C11.953,6.683,16.401,5,21,5c8.093,0,15.306,5.149,17.949,12.813c0.142,0.414,0.53,0.674,0.945,0.674 C40.002,18.487,40.111,18.47,40.22,18.432z"/><path d="M28,33.41c0.5-1.09,1.18-2.09,2-2.96V38c0,0.55-0.45,1-1,1s-1-0.45-1-1 V33.41z" opacity=".05"/><path d="M32,28.78c0.62-0.41,1.29-0.75,2-1.03v8.93c0,0.55-0.45,1-1,1s-1-0.45-1-1 V28.78z" opacity=".05"/><path d="M38,27v5c0,0.55-0.45,1-1,1s-1-0.45-1-1v-4.82C36.65,27.06,37.32,27,38,27 z" opacity=".05"/><path d="M28,34.79c0.43-1.33,1.12-2.54,2-3.58V38c0,0.55-0.45,1-1,1s-1-0.45-1-1 V34.79z" opacity=".07"/><path d="M32,29.39c0.62-0.44,1.29-0.8,2-1.09v8.38c0,0.55-0.45,1-1,1s-1-0.45-1-1 V29.39z" opacity=".07"/><path d="M38,27.5V32c0,0.55-0.45,1-1,1s-1-0.45-1-1v-4.31 C36.65,27.57,37.32,27.5,38,27.5z" opacity=".07"/><path d="M42,27.75v1.7c0,0.55-0.45,1-1,1s-1-0.45-1-1v-2.27 C40.69,27.31,41.36,27.5,42,27.75z" opacity=".05"/><path d="M42,28.3v1.15c0,0.55-0.45,1-1,1s-1-0.45-1-1v-1.76 C40.69,27.83,41.37,28.03,42,28.3z" opacity=".07"/><linearGradient id="hns_QSPvAuABanH5z5S5qa" x1="33.304" x2="42.696" y1="90.831" y2="73.169" gradientTransform="matrix(1 0 0 -1 0 120)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#21ad64"/><stop offset="1" stop-color="#088242"/></linearGradient><circle cx="38" cy="38" r="10" fill="url(#hns_QSPvAuABanH5z5S5qa)"/><path fill="#fff" d="M38.5,43h-1c-0.276,0-0.5-0.224-0.5-0.5v-9c0-0.276,0.224-0.5,0.5-0.5h1c0.276,0,0.5,0.224,0.5,0.5v9 C39,42.776,38.776,43,38.5,43z"/><path fill="#fff" d="M33,38.5v-1c0-0.276,0.224-0.5,0.5-0.5h9c0.276,0,0.5,0.224,0.5,0.5v1c0,0.276-0.224,0.5-0.5,0.5h-9 C33.224,39,33,38.776,33,38.5z"/></svg>
\ No newline at end of file
<template lang="pug">
q-dialog(ref='dialogRef', @hide='onDialogHide', persistent)
q-card(style='min-width: 650px;')
q-card-section.card-header
q-icon(name='img:/_assets/icons/fluent-add-key.svg', left, size='sm')
span {{t(`profile.passkeysAdd`)}}
.q-py-sm
.text-body2.q-px-md.q-py-sm {{t(`profile.passkeysNameHint`)}}
q-item
blueprint-icon(icon='key')
q-item-section
q-input(
outlined
v-model='state.name'
dense
hide-bottom-space
:label='t(`profile.passkeysName`)'
:aria-label='t(`profile.passkeysName`)'
autofocus
@keyup.enter='save'
)
q-card-actions.card-actions
q-space
q-btn.acrylic-btn(
flat
:label='t(`common.actions.cancel`)'
color='grey'
padding='xs md'
@click='onDialogCancel'
)
q-btn(
unelevated
:label='t(`common.actions.save`)'
color='primary'
padding='xs md'
@click='save'
)
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { useDialogPluginComponent, useQuasar } from 'quasar'
import { reactive } from 'vue'
// EMITS
defineEmits([
...useDialogPluginComponent.emits
])
// QUASAR
const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
const $q = useQuasar()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
name: ''
})
// METHODS
async function save () {
try {
if (!state.name || state.name.trim().length < 1 || state.name.length > 255) {
throw new Error(t('profile.passkeysInvalidName'))
}
onDialogOK({
name: state.name
})
} catch (err) {
$q.notify({
type: 'negative',
message: err.message
})
}
}
</script>
/**
* Parse an error message for an error code and translate
*
* @param {String} val Value to parse
* @param {Function} t vue-i18n translation method
*/
export function localizeError (val, t) {
if (val?.startsWith('ERR_')) {
return t(`error.${val}`)
} else {
return val
}
}
......@@ -98,6 +98,7 @@ q-page.admin-locale
icon='las la-trash'
color='negative'
@click='deleteSite(site)'
:aria-label='t(`common.actions.delete`)'
)
</template>
......
......@@ -47,6 +47,46 @@ q-page.q-py-md(:style-fn='pageStyle')
@click='changePassword(auth.authId)'
)
.text-header.q-mt-md {{t('profile.passkeys')}}
.q-pa-md
.text-body2 {{ t('profile.passkeysIntro') }}
q-list.q-mt-lg(
v-if="state.passkeys?.length > 0"
bordered
separator
)
q-item(
v-for='pkey of state.passkeys'
:key='pkey.id'
)
q-item-section(avatar)
q-avatar(
color='secondary'
text-color='white'
rounded
)
q-icon(name='las la-key')
q-item-section
strong {{pkey.name}}
.text-caption {{ pkey.siteHostname }}
.text-caption.text-grey-7 {{ humanizeDate(pkey.createdAt) }}
q-item-section(side)
q-btn.acrylic-btn(
flat
icon='las la-trash'
:aria-label='t(`common.actions.delete`)'
color='negative'
@click='deactivatePasskey(pkey)'
)
.q-mt-md
q-btn(
icon='las la-plus'
unelevated
:label='t(`profile.passkeysAdd`)'
color='primary'
@click='setupPasskey'
)
q-inner-loading(:showing='state.loading > 0')
</template>
......@@ -55,11 +95,16 @@ import gql from 'graphql-tag'
import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar'
import { onMounted, reactive } from 'vue'
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser'
import { localizeError } from 'src/helpers/localization'
import { DateTime } from 'luxon'
import { useSiteStore } from 'src/stores/site'
import { useUserStore } from 'src/stores/user'
import ChangePwdDialog from 'src/components/ChangePwdDialog.vue'
import SetupTfaDialog from 'src/components/SetupTfaDialog.vue'
import PasskeyCreateDialog from 'src/components/PasskeyCreateDialog.vue'
// QUASAR
......@@ -67,6 +112,7 @@ const $q = useQuasar()
// STORES
const siteStore = useSiteStore()
const userStore = useUserStore()
// I18N
......@@ -83,6 +129,7 @@ useMeta({
const state = reactive({
authMethods: [],
passkeys: [],
loading: 0
})
......@@ -94,6 +141,10 @@ function pageStyle (offset, height) {
}
}
function humanizeDate (val) {
return DateTime.fromISO(val).toLocaleString(DateTime.DATETIME_MED)
}
async function fetchAuthMethods () {
state.loading++
try {
......@@ -113,6 +164,12 @@ async function fetchAuthMethods () {
strategyIcon
config
}
passkeys {
id
name
createdAt
siteHostname
}
}
}
`,
......@@ -122,6 +179,7 @@ async function fetchAuthMethods () {
fetchPolicy: 'network-only'
})
state.authMethods = respRaw.data?.userById?.auth ?? []
state.passkeys = respRaw.data?.userById?.passkeys ?? []
} catch (err) {
$q.notify({
type: 'negative',
......@@ -189,12 +247,166 @@ function disableTfa (strategyId) {
}
function setupTfa (strategyId) {
// $q.dialog({
// component: SetupTfaDialog,
// componentProps: {
// strategyId
// }
// })
$q.dialog({
component: SetupTfaDialog,
componentProps: {
strategyId
}
}).onOk(() => {
fetchAuthMethods()
})
}
async function setupPasskey () {
try {
if (!browserSupportsWebAuthn()) {
throw new Error(t('profile.passkeysUnsupported'))
}
$q.loading.show()
// -> Generation registration options
const genResp = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation setupPasskey (
$siteId: UUID!
) {
setupPasskey(
siteId: $siteId
) {
operation {
succeeded
message
}
registrationOptions
}
}
`,
variables: {
siteId: siteStore.id
}
})
if (genResp?.data?.setupPasskey?.operation?.succeeded) {
state.registrationOptions = genResp.data.setupPasskey.registrationOptions
} else {
throw new Error(localizeError(genResp?.data?.setupPasskey?.operation?.message, t))
}
// -> Start registration on the authenticator
let attResp
try {
attResp = await startRegistration(state.registrationOptions)
} catch (err) {
if (err.name === 'InvalidStateError') {
throw new Error(t('error.ERR_PK_ALREADY_REGISTERED'))
} else {
throw err
}
}
// -> Prompt for passkey name
$q.loading.hide()
const passkeyName = await new Promise((resolve, reject) => {
$q.dialog({
component: PasskeyCreateDialog
}).onOk(({ name }) => {
resolve(name)
}).onCancel(() => {
reject(new Error(t('error.ERR_PK_USER_CANCELLED')))
})
})
$q.loading.show()
// -> Verify the authenticator response
const resp = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation finalizePasskey (
$registrationResponse: JSON!
$name: String!
) {
finalizePasskey(
registrationResponse: $registrationResponse
name: $name
) {
operation {
succeeded
message
}
}
}
`,
variables: {
registrationResponse: attResp,
name: passkeyName
}
})
if (resp?.data?.finalizePasskey?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('profile.passkeysSetupSuccess')
})
} else {
throw new Error(resp?.data?.finalizePasskey?.operation?.message)
}
} catch (err) {
$q.notify({
type: 'negative',
message: t('profile.passkeysSetupFailed'),
caption: err.message ?? 'An unexpected error occured.'
})
}
await fetchAuthMethods()
$q.loading.hide()
}
async function deactivatePasskey (pkey) {
$q.dialog({
title: t('common.actions.confirm'),
message: t('profile.passkeysDeactivateConfirm'),
cancel: true
}).onOk(async () => {
$q.loading.show()
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation deactivatePasskey (
$id: UUID!
) {
deactivatePasskey(
id: $id
) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: pkey.id
}
})
if (resp?.data?.deactivatePasskey?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('profile.passkeysDeactivateSuccess')
})
} else {
throw new Error(resp?.data?.deactivatePasskey?.operation?.message)
}
} catch (err) {
$q.notify({
type: 'negative',
message: t('profile.passkeysDeactivateFailed'),
caption: err.message ?? 'An unexpected error occured.'
})
}
await fetchAuthMethods()
$q.loading.hide()
})
}
// MOUNTED
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment