Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
W
wiki-js
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
1
Issues
1
List
Board
Labels
Milestones
Merge Requests
1
Merge Requests
1
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Registry
Registry
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Jacklull
wiki-js
Commits
88197c17
Unverified
Commit
88197c17
authored
Oct 14, 2023
by
NGPixel
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: passkeys login
parent
4d285caa
Hide whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
286 additions
and
13 deletions
+286
-13
authentication.mjs
server/graph/resolvers/authentication.mjs
+119
-3
authentication.graphql
server/graph/schemas/authentication.graphql
+14
-0
user.graphql
server/graph/schemas/user.graphql
+1
-1
en.json
server/locales/en.json
+2
-0
package.json
server/package.json
+1
-0
pnpm-lock.yaml
server/pnpm-lock.yaml
+3
-0
quasar.config.js
ux/quasar.config.js
+9
-7
AuthLoginPanel.vue
ux/src/components/AuthLoginPanel.vue
+137
-2
No files found.
server/graph/resolvers/authentication.mjs
View file @
88197c17
...
...
@@ -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
,
...
...
@@ -365,6 +370,117 @@ export default {
}
},
/**
* 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
*/
async
changePassword
(
obj
,
args
,
context
)
{
...
...
server/graph/schemas/authentication.graphql
View file @
88197c17
...
...
@@ -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
!
...
...
server/graph/schemas/user.graphql
View file @
88197c17
...
...
@@ -154,7 +154,7 @@ type UserAuth {
}
type
UserPasskey
{
id
:
UUID
id
:
String
name
:
String
createdAt
:
Date
siteHostname
:
String
...
...
server/locales/en.json
View file @
88197c17
...
...
@@ -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"
,
...
...
server/package.json
View file @
88197c17
...
...
@@ -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"
,
...
...
server/pnpm-lock.yaml
View file @
88197c17
...
...
@@ -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
...
...
ux/quasar.config.js
View file @
88197c17
...
...
@@ -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
},
...
...
ux/src/components/AuthLoginPanel.vue
View file @
88197c17
...
...
@@ -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(
...
...
@@ -72,6 +82,40 @@
)
//- -----------------------------------------------------
//- 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
//- -----------------------------------------------------
template(v-else-if='state.screen === `forgot`')
...
...
@@ -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'
))
}
...
...
@@ -612,6 +672,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
*/
async
function
forgotPassword
()
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment