Skip to content

Commit 72048bc

Browse files
ryanmitchellrobdekortjasonvarga
authored
[6.x] Passkeys 🔑 (#9239)
Co-authored-by: Rob de Kort <rob@studio1902.nl> Co-authored-by: Jason Varga <jason@pixelfear.com>
1 parent 25d9948 commit 72048bc

47 files changed

Lines changed: 2690 additions & 36 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎composer.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"symfony/yaml": "^7.0.3",
3838
"ueberdosis/tiptap-php": "^2.0",
3939
"voku/portable-ascii": "^2.0.2",
40+
"web-auth/webauthn-lib": "^5.2",
4041
"wilderborn/partyline": "^1.0"
4142
},
4243
"require-dev": {

‎config/users.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@
134134
'roles' => false,
135135
'group_user' => 'group_user',
136136
'groups' => false,
137+
'webauthn' => 'webauthn',
137138
],
138139

139140
/*

‎config/webauthn.php‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
return [
4+
5+
/*
6+
|--------------------------------------------------------------------------
7+
| Allow password logins to be used when user has a passkey
8+
|--------------------------------------------------------------------------
9+
|
10+
| Whether or not the password field should be shown to users that
11+
| have set up a passkey, or whether it should be hidden.
12+
|
13+
*/
14+
15+
'allow_password_login_with_passkey' => true,
16+
17+
/*
18+
|--------------------------------------------------------------------------
19+
| Remember Me
20+
|--------------------------------------------------------------------------
21+
|
22+
| Whether or not the "remember me" functionality should be used when
23+
| authenticating using WebAuthn. When enabled, the user will remain
24+
| logged in indefinitely, or until they manually log out.
25+
|
26+
*/
27+
28+
'remember_me' => true,
29+
30+
/*
31+
|--------------------------------------------------------------------------
32+
| Model
33+
|--------------------------------------------------------------------------
34+
|
35+
| When using eloquent passkeys you can specify the model you want to use
36+
|
37+
*/
38+
39+
'model' => \Statamic\Auth\Eloquent\WebAuthnModel::class,
40+
41+
];

‎package-lock.json‎

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"@inertiajs/vue3": "^2.1.11",
2323
"@internationalized/date": "^3.7.0",
2424
"@shopify/draggable": "^1.0.0-beta.12",
25+
"@simplewebauthn/browser": "^13.2.2",
2526
"@tiptap/core": "^3.0.0",
2627
"@tiptap/extension-blockquote": "^3.0.0",
2728
"@tiptap/extension-bold": "^3.0.0",

‎resources/js/components/users/PublishForm.vue‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
</template>
1919
<DropdownMenu>
2020
<DropdownItem :text="__('Edit Blueprint')" icon="blueprint-edit" v-if="canEditBlueprint" :href="actions.editBlueprint" />
21+
<DropdownItem :text="__('Passkeys')" icon="key" :href="cp_url('passkeys')" />
2122
<DropdownSeparator v-if="canEditBlueprint && itemActions.length" />
2223
<DropdownItem
2324
v-for="action in itemActions"
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { ref } from 'vue';
2+
import { startAuthentication, browserSupportsWebAuthn } from '@simplewebauthn/browser';
3+
import axios from 'axios';
4+
5+
export function usePasskey() {
6+
const error = ref(null);
7+
const waiting = ref(false);
8+
const supported = browserSupportsWebAuthn();
9+
10+
async function authenticate(optionsUrl, verifyUrl, onSuccess) {
11+
waiting.value = true;
12+
error.value = null;
13+
14+
try {
15+
const authOptionsResponse = await fetch(optionsUrl);
16+
const authOptionsJson = await authOptionsResponse.json();
17+
18+
let startAuthResponse;
19+
try {
20+
startAuthResponse = await startAuthentication(authOptionsJson);
21+
} catch (e) {
22+
console.error(e);
23+
error.value = __('Authentication failed.');
24+
waiting.value = false;
25+
return;
26+
}
27+
28+
const response = await axios.post(verifyUrl, startAuthResponse);
29+
30+
if (onSuccess) {
31+
onSuccess(response.data);
32+
}
33+
} catch (e) {
34+
handleError(e);
35+
} finally {
36+
waiting.value = false;
37+
}
38+
}
39+
40+
function handleError(e) {
41+
if (e.response) {
42+
const { message } = e.response.data;
43+
error.value = message;
44+
return;
45+
}
46+
47+
error.value = __('Something went wrong');
48+
}
49+
50+
return {
51+
error,
52+
waiting,
53+
supported,
54+
authenticate,
55+
};
56+
}

‎resources/js/pages/auth/ConfirmPassword.vue‎

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
<script setup>
22
import Head from '@/pages/layout/Head.vue';
3-
import { AuthCard, Input, Field, Button, Description } from '@ui';
3+
import { AuthCard, Input, Field, Button, Description, ErrorMessage, Separator } from '@ui';
44
import { computed } from 'vue';
5-
import { Form } from '@inertiajs/vue3';
5+
import { Form, router } from '@inertiajs/vue3';
6+
import { usePasskey } from '@/composables/passkey';
67
7-
const props = defineProps(['method', 'status', 'submitUrl', 'resendUrl']);
8+
const props = defineProps(['method', 'allowPasskey', 'status', 'submitUrl', 'resendUrl', 'passkeyOptionsUrl']);
89
const isConfirmingPassword = computed(() => props.method === 'password_confirmation');
910
const isUsingVerificationCode = computed(() => props.method === 'verification_code');
11+
const isOnlyUsingPasskey = computed(() => props.method === 'passkey');
12+
const passkey = usePasskey();
13+
14+
async function confirmWithPasskey() {
15+
await passkey.authenticate(
16+
props.passkeyOptionsUrl,
17+
props.submitUrl,
18+
(response) => router.get(response.redirect)
19+
);
20+
}
1021
</script>
1122

1223
<template>
@@ -15,14 +26,12 @@ const isUsingVerificationCode = computed(() => props.method === 'verification_co
1526
<AuthCard
1627
icon="key"
1728
class="max-w-md mx-auto mt-8"
18-
:title="isConfirmingPassword ? __('Confirm Your Password') : __('Verification Code')"
19-
:description="isConfirmingPassword
20-
? __('statamic::messages.elevated_session_enter_password')
21-
: __('statamic::messages.elevated_session_enter_verification_code')"
29+
:title="__('Confirm Your Identity')"
30+
:description="__('statamic::messages.elevated_session_reauthenticate')"
2231
>
2332
<Description v-if="status" variant="success" :text="status" class="mb-6" />
2433

25-
<Form method="post" :action="submitUrl" class="flex flex-col gap-6" v-slot="{ errors }">
34+
<Form v-if="!isOnlyUsingPasskey" method="post" :action="submitUrl" class="flex flex-col gap-6" v-slot="{ errors }">
2635
<Field v-if="isConfirmingPassword" :label="__('Password')" :error="errors.password">
2736
<Input name="password" type="password" viewable autofocus />
2837
</Field>
@@ -43,5 +52,20 @@ const isUsingVerificationCode = computed(() => props.method === 'verification_co
4352
/>
4453
</div>
4554
</Form>
55+
56+
<template v-if="allowPasskey">
57+
<Separator v-if="!isOnlyUsingPasskey" variant="dots" :text="__('or')" class="py-3" />
58+
59+
<Button
60+
class="w-full"
61+
:variant="isOnlyUsingPasskey ? 'primary' : 'default'"
62+
:text="__('Confirm with Passkey')"
63+
:icon="passkey.waiting.value ? null : 'key'"
64+
:disabled="passkey.waiting.value"
65+
:loading="passkey.waiting.value"
66+
@click="confirmWithPasskey"
67+
/>
68+
<ErrorMessage v-if="passkey.error.value" :text="passkey.error.value" />
69+
</template>
4670
</AuthCard>
4771
</template>

‎resources/js/pages/auth/Login.vue‎

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
<script setup>
22
import Head from '@/pages/layout/Head.vue';
33
import Outside from '@/pages/layout/Outside.vue';
4-
import { AuthCard, Input, Field, Button, Separator, Checkbox } from '@ui';
4+
import { AuthCard, Input, Field, Button, Separator, Checkbox, ErrorMessage } from '@ui';
55
import { Link, router } from '@inertiajs/vue3';
66
import { computed, ref, watch } from 'vue';
7+
import { usePasskey } from '@/composables/passkey';
78
89
defineOptions({ layout: Outside });
910
1011
const props = defineProps([
1112
'errors',
1213
'emailLoginEnabled',
14+
'passkeyOptionsUrl',
15+
'passkeyVerifyUrl',
1316
'oauthEnabled',
1417
'providers',
1518
'referer',
@@ -25,6 +28,7 @@ const password = ref('');
2528
const remember = ref(false);
2629
const processing = ref(false);
2730
const shaking = computed(() => Object.keys(errors.value).length ? 'animation-shake' : '');
31+
const showOAuth = computed(() => props.oauthEnabled && props.providers.length > 0);
2832
2933
const submit = () => {
3034
processing.value = true;
@@ -41,6 +45,24 @@ const submit = () => {
4145
onError: () => processing.value = false
4246
});
4347
}
48+
49+
const passkey = usePasskey();
50+
51+
const showPasskeyLogin = computed(() => {
52+
return props.emailLoginEnabled && passkey.supported;
53+
})
54+
55+
async function loginWithPasskey() {
56+
await passkey.authenticate(
57+
props.passkeyOptionsUrl,
58+
props.passkeyVerifyUrl,
59+
(data) => {
60+
if (data.redirect) {
61+
window.location = data.redirect;
62+
}
63+
}
64+
);
65+
}
4466
</script>
4567

4668
<template>
@@ -78,17 +100,30 @@ const submit = () => {
78100
<Button type="submit" variant="primary" :disabled="processing" :text="__('Continue')" tabindex="5" />
79101
</form>
80102

81-
<template v-if="oauthEnabled">
103+
<template v-if="showOAuth || showPasskeyLogin">
82104
<Separator v-if="emailLoginEnabled" variant="dots" :text="__('Or sign in with')" class="py-3" />
83-
<div class="flex gap-4 justify-center items-center">
84-
<Button
85-
v-for="provider in providers"
86-
:key="provider.name"
87-
as="href"
88-
class="flex-1"
89-
:href="provider.url"
90-
:icon="provider.icon"
91-
/>
105+
<div class="flex flex-col gap-y-4">
106+
<template v-if="showPasskeyLogin">
107+
<Button
108+
:text="__('Passkey')"
109+
class="w-full"
110+
:icon="passkey.waiting.value ? null : 'key'"
111+
:disabled="passkey.waiting.value"
112+
:loading="passkey.waiting.value"
113+
@click="loginWithPasskey"
114+
/>
115+
<ErrorMessage v-if="passkey.error.value" :text="passkey.error.value" />
116+
</template>
117+
<div v-if="showOAuth" class="flex gap-4 justify-center items-center">
118+
<Button
119+
v-for="provider in providers"
120+
:key="provider.name"
121+
as="href"
122+
class="flex-1"
123+
:href="provider.url"
124+
:icon="provider.icon"
125+
/>
126+
</div>
92127
</div>
93128
</template>
94129
</div>

‎resources/js/pages/layout/architectural-background.js‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
import { onMounted, onUnmounted } from 'vue';
1+
import { nextTick, onMounted, onUnmounted } from 'vue';
22

33
const className = 'bg-architectural-lines';
44
const id = 'content-card';
55

66
function add() {
7-
document.getElementById(id).classList.add(className);
7+
nextTick(() => document.getElementById(id).classList.add(className));
88
}
99

1010
function remove() {
11-
document.getElementById(id).classList.remove(className);
11+
nextTick(() => document.getElementById(id).classList.remove(className));
1212
}
1313

1414
export default function useArchitecturalBackground() {

0 commit comments

Comments
 (0)