@adminforth/two-factors-auth 1.9.2 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build.log +3 -2
- package/custom/TwoFactorsConfirmation.vue +241 -55
- package/custom/TwoFactorsPasskeysSettings.vue +334 -0
- package/custom/TwoFactorsSetup.vue +15 -5
- package/custom/package-lock.json +7 -0
- package/custom/package.json +1 -0
- package/dist/custom/TwoFactorsConfirmation.vue +241 -55
- package/dist/custom/TwoFactorsPasskeysSettings.vue +334 -0
- package/dist/custom/TwoFactorsSetup.vue +15 -5
- package/dist/custom/package-lock.json +7 -0
- package/dist/custom/package.json +1 -0
- package/dist/index.js +450 -36
- package/index.ts +463 -37
- package/package.json +3 -1
- package/types.ts +62 -1
package/build.log
CHANGED
|
@@ -6,10 +6,11 @@ sending incremental file list
|
|
|
6
6
|
custom/
|
|
7
7
|
custom/TwoFAModal.vue
|
|
8
8
|
custom/TwoFactorsConfirmation.vue
|
|
9
|
+
custom/TwoFactorsPasskeysSettings.vue
|
|
9
10
|
custom/TwoFactorsSetup.vue
|
|
10
11
|
custom/package-lock.json
|
|
11
12
|
custom/package.json
|
|
12
13
|
custom/tsconfig.json
|
|
13
14
|
|
|
14
|
-
sent
|
|
15
|
-
total size is
|
|
15
|
+
sent 57,630 bytes received 153 bytes 115,566.00 bytes/sec
|
|
16
|
+
total size is 57,057 speedup is 0.99
|
|
@@ -1,73 +1,102 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
<
|
|
33
|
-
|
|
2
|
+
<div class="relative flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-800"
|
|
3
|
+
:style="(coreStore.config?.loginBackgroundImage && coreStore.config?.loginBackgroundPosition === 'over') ? {
|
|
4
|
+
'background-image': 'url(' + loadFile(coreStore.config?.loginBackgroundImage) + ')',
|
|
5
|
+
'background-size': 'cover',
|
|
6
|
+
'background-position': 'center',
|
|
7
|
+
'background-blend-mode': coreStore.config?.removeBackgroundBlendMode ? 'normal' : 'darken'
|
|
8
|
+
}: {}"
|
|
9
|
+
>
|
|
10
|
+
|
|
11
|
+
<div id="authentication-modal" tabindex="-1" class="af-two-factors-confirmation overflow-y-auto overflow-x-hidden z-50 min-w-[00px] justify-center items-center md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
|
12
|
+
<div class="relative p-4 w-full max-w-md max-h-full">
|
|
13
|
+
<!-- Modal content -->
|
|
14
|
+
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700 dark:shadow-black text-gray-500" >
|
|
15
|
+
<div class="p-8 w-full max-w-md max-h-full custom-auth-wrapper" >
|
|
16
|
+
<div v-if="confirmationMode === 'code'">
|
|
17
|
+
<div id="mfaCode-label" class="m-4">{{$t('Please enter your authenticator code')}} </div>
|
|
18
|
+
<div class="my-4 w-full flex flex-col gap-4 justify-center" ref="otpRoot">
|
|
19
|
+
<v-otp-input
|
|
20
|
+
ref="code"
|
|
21
|
+
container-class="grid grid-cols-6 gap-3 w-full"
|
|
22
|
+
input-classes="bg-gray-50 text-center justify-center otp-input border leading-none border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-10 h-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
|
23
|
+
:num-inputs="6"
|
|
24
|
+
inputType="number"
|
|
25
|
+
inputmode="numeric"
|
|
26
|
+
:should-auto-focus="true"
|
|
27
|
+
:should-focus-order="true"
|
|
28
|
+
v-model:value="bindValue"
|
|
29
|
+
@on-complete="handleOnComplete"
|
|
30
|
+
/>
|
|
31
|
+
<div class="flex items-center justify-between w-full">
|
|
32
|
+
<Link v-if="confirmationMode === 'code' && doesUserHavePasskeys" :to="{ hash: '#passkey' }" class="w-max underline hover:no-underline hover:cursor-pointer text-lightPrimary whitespace-nowrap">Use passkey</Link>
|
|
33
|
+
<Link
|
|
34
|
+
v-if="confirmationMode === 'code'"
|
|
35
|
+
to="/login"
|
|
36
|
+
class="w-max"
|
|
37
|
+
>
|
|
38
|
+
{{$t('Back to login')}}
|
|
39
|
+
</Link>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
<div v-else class="flex flex-col items-center justify-center py-4 gap-6">
|
|
44
|
+
<IconShieldOutline class="w-16 h-16 text-lightPrimary dark:text-darkPrimary"/>
|
|
45
|
+
<p class="text-4xl font-semibold mb-4">Passkey</p>
|
|
46
|
+
<p class="mb-2 max-w-[300px]">When you are ready, authenticate using the button below</p>
|
|
47
|
+
<Button @click="usePasskeyButton" class="w-full mx-16">
|
|
48
|
+
Use passkey
|
|
49
|
+
</Button>
|
|
50
|
+
<div v-if="confirmationMode === 'passkey'" class="max-w-sm px-6 pt-3 w-full bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
|
51
|
+
<p class="mb-3 font-normal text-gray-700 dark:text-gray-400">
|
|
52
|
+
Have issues with passkey?
|
|
53
|
+
<div v-if="doesUserHavePasskeys" class="flex justify-start cursor-pointer gap-2" >
|
|
54
|
+
<Link v-if="confirmationMode === 'passkey'" :to="{ hash: '#code' }" class="underline hover:no-underline text-lightPrimary whitespace-nowrap">use TOTP</Link>
|
|
55
|
+
<p> or </p>
|
|
56
|
+
<Link
|
|
34
57
|
to="/login"
|
|
35
58
|
class="w-full"
|
|
36
59
|
>
|
|
37
|
-
{{$t('
|
|
38
|
-
</
|
|
60
|
+
{{$t('back to login')}}
|
|
61
|
+
</Link>
|
|
39
62
|
</div>
|
|
63
|
+
</p>
|
|
40
64
|
</div>
|
|
65
|
+
</div>
|
|
41
66
|
</div>
|
|
67
|
+
<ErrorMessage :error="codeError" />
|
|
42
68
|
</div>
|
|
43
|
-
</div>
|
|
44
|
-
|
|
45
69
|
</div>
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</template>
|
|
49
73
|
|
|
50
74
|
|
|
51
|
-
<script setup>
|
|
75
|
+
<script setup lang="ts">
|
|
52
76
|
|
|
53
|
-
import { onMounted, nextTick, onBeforeUnmount, ref,
|
|
77
|
+
import { onMounted, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
|
54
78
|
import { useCoreStore } from '@/stores/core';
|
|
55
79
|
import { useUserStore } from '@/stores/user';
|
|
56
80
|
import { callAdminForthApi, loadFile } from '@/utils';
|
|
57
81
|
import { showErrorTost } from '@/composables/useFrontendApi';
|
|
58
|
-
import {
|
|
82
|
+
import { Button, Link } from '@/afcl';
|
|
59
83
|
import VOtpInput from "vue3-otp-input";
|
|
60
84
|
import { useI18n } from 'vue-i18n';
|
|
85
|
+
import { useRoute } from 'vue-router'
|
|
86
|
+
import { useRouter } from 'vue-router';
|
|
87
|
+
import { IconShieldOutline } from '@iconify-prerendered/vue-flowbite';
|
|
61
88
|
import ErrorMessage from '@/components/ErrorMessage.vue';
|
|
62
89
|
|
|
63
90
|
const { t } = useI18n();
|
|
64
91
|
const code = ref(null);
|
|
65
92
|
const otpRoot = ref(null);
|
|
66
93
|
const bindValue = ref('');
|
|
94
|
+
const route = useRoute();
|
|
95
|
+
const router = useRouter();
|
|
67
96
|
const codeError = ref(null);
|
|
68
97
|
|
|
69
98
|
const handleOnComplete = (value) => {
|
|
70
|
-
sendCode(value);
|
|
99
|
+
sendCode(value, 'TOTP', null);
|
|
71
100
|
};
|
|
72
101
|
|
|
73
102
|
function tagOtpInputs() {
|
|
@@ -87,47 +116,78 @@
|
|
|
87
116
|
|
|
88
117
|
const coreStore = useCoreStore();
|
|
89
118
|
const user = useUserStore();
|
|
90
|
-
|
|
119
|
+
const doesUserHavePasskeys = ref(false);
|
|
120
|
+
const confirmationMode = ref("code");
|
|
121
|
+
const isPasskeysSupported = ref(false);
|
|
91
122
|
|
|
92
123
|
onMounted(async () => {
|
|
93
124
|
await nextTick();
|
|
125
|
+
await isCMAAvailable();
|
|
94
126
|
tagOtpInputs();
|
|
127
|
+
if (isPasskeysSupported.value === true) {
|
|
128
|
+
checkIfUserHasPasskeys();
|
|
129
|
+
}
|
|
95
130
|
document.addEventListener('focusin', handleGlobalFocusIn, true);
|
|
96
131
|
focusFirstAvailableOtpInput();
|
|
97
132
|
const rootEl = otpRoot.value;
|
|
98
133
|
rootEl && rootEl.addEventListener('focusout', handleFocusOut, true);
|
|
99
134
|
});
|
|
100
135
|
|
|
136
|
+
watch(route, (newRoute) => {
|
|
137
|
+
codeError.value = null;
|
|
138
|
+
if ( newRoute.hash === '#passkey' ) {
|
|
139
|
+
confirmationMode.value = 'passkey';
|
|
140
|
+
} else if ( newRoute.hash === '#code' ) {
|
|
141
|
+
confirmationMode.value = 'code';
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
async function isCMAAvailable() {
|
|
146
|
+
if (window.PublicKeyCredential &&
|
|
147
|
+
PublicKeyCredential.isConditionalMediationAvailable) {
|
|
148
|
+
const isCMA = await PublicKeyCredential.isConditionalMediationAvailable();
|
|
149
|
+
if (isCMA) {
|
|
150
|
+
isPasskeysSupported.value = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
101
155
|
onBeforeUnmount(() => {
|
|
102
156
|
window.removeEventListener('paste', handlePaste);
|
|
103
157
|
document.removeEventListener('focusin', handleGlobalFocusIn, true);
|
|
104
158
|
const rootEl = otpRoot.value;
|
|
105
159
|
rootEl && rootEl.removeEventListener('focusout', handleFocusOut, true);
|
|
106
160
|
});
|
|
107
|
-
|
|
108
|
-
async function sendCode (value) {
|
|
161
|
+
|
|
162
|
+
async function sendCode (value: any, factorMode: 'TOTP' | 'passkey', passkeyOptions: any) {
|
|
109
163
|
inProgress.value = true;
|
|
164
|
+
const usePasskey = factorMode === 'passkey';
|
|
110
165
|
const resp = await callAdminForthApi({
|
|
111
166
|
method: 'POST',
|
|
112
|
-
path: '/plugin/twofa/
|
|
167
|
+
path: '/plugin/twofa/confirmLogin',
|
|
113
168
|
body: {
|
|
114
169
|
code: value,
|
|
170
|
+
usePasskey: usePasskey,
|
|
171
|
+
passkeyOptions: passkeyOptions,
|
|
115
172
|
secret: null,
|
|
116
173
|
}
|
|
117
174
|
})
|
|
118
|
-
if (resp.allowedLogin){
|
|
175
|
+
if ( resp.allowedLogin ) {
|
|
176
|
+
if ( route.meta.isPasskeysEnabled && !doesUserHavePasskeys.value ) {
|
|
177
|
+
handlePasskeyAlert(route.meta.suggestionPeriod, router);
|
|
178
|
+
}
|
|
119
179
|
await user.finishLogin();
|
|
120
180
|
} else {
|
|
121
|
-
|
|
181
|
+
if (usePasskey) {
|
|
182
|
+
showErrorTost(t('Invalid passkey'));
|
|
183
|
+
codeError.value = 'Invalid passkey';
|
|
184
|
+
} else {
|
|
185
|
+
showErrorTost(t(resp.error));
|
|
186
|
+
codeError.value = resp.error;
|
|
187
|
+
}
|
|
122
188
|
}
|
|
123
189
|
}
|
|
124
190
|
|
|
125
|
-
// watch(code, async (nv)=>{
|
|
126
|
-
// if (nv){
|
|
127
|
-
// sendCode();
|
|
128
|
-
// }
|
|
129
|
-
// })
|
|
130
|
-
|
|
131
191
|
function handlePaste(event) {
|
|
132
192
|
event.preventDefault();
|
|
133
193
|
if (event.target.classList.contains('otp-input')) {
|
|
@@ -139,6 +199,132 @@
|
|
|
139
199
|
}
|
|
140
200
|
}
|
|
141
201
|
|
|
202
|
+
function checkIfUserHasPasskeys() {
|
|
203
|
+
callAdminForthApi({
|
|
204
|
+
method: 'POST',
|
|
205
|
+
path: '/plugin/passkeys/checkIfUserHasPasskeys',
|
|
206
|
+
}).then((response) => {
|
|
207
|
+
if (response.ok) {
|
|
208
|
+
doesUserHavePasskeys.value = response.hasPasskeys;
|
|
209
|
+
if ( doesUserHavePasskeys.value === true ) {
|
|
210
|
+
router.push({ hash: '#passkey' })
|
|
211
|
+
confirmationMode.value = 'passkey';
|
|
212
|
+
} else {
|
|
213
|
+
router.push({ hash: '#code' })
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function usePasskeyButton() {
|
|
220
|
+
const { _options, challengeId } = await createSignInRequest();
|
|
221
|
+
const options = PublicKeyCredential.parseRequestOptionsFromJSON(_options);
|
|
222
|
+
const credential = await authenticate(options);
|
|
223
|
+
if (!credential) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const result = JSON.stringify(credential);
|
|
227
|
+
const passkeyOptions = {
|
|
228
|
+
response: result,
|
|
229
|
+
challengeId: challengeId,
|
|
230
|
+
origin: window.location.origin,
|
|
231
|
+
};
|
|
232
|
+
sendCode('', 'passkey', passkeyOptions);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function createSignInRequest() {
|
|
236
|
+
let response;
|
|
237
|
+
try {
|
|
238
|
+
response = await callAdminForthApi({
|
|
239
|
+
path: `/plugin/passkeys/signInRequest`,
|
|
240
|
+
method: 'POST',
|
|
241
|
+
});
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.error('Error creating sign-in request:', error);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (response.ok === true) {
|
|
247
|
+
return { _options: response.data, challengeId: response.challengeId };
|
|
248
|
+
} else {
|
|
249
|
+
adminforth.alert({message: 'Error creating sign-in request.', variant: 'warning'});
|
|
250
|
+
codeError.value = 'Error creating sign-in request.';
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function authenticate(options) {
|
|
255
|
+
try {
|
|
256
|
+
const abortController = new AbortController();
|
|
257
|
+
const credential = await navigator.credentials.get({
|
|
258
|
+
publicKey: options,
|
|
259
|
+
signal: abortController.signal,
|
|
260
|
+
});
|
|
261
|
+
return credential;
|
|
262
|
+
} catch (error) {
|
|
263
|
+
adminforth.alert({message: 'Error during authentication', variant: 'warning'});
|
|
264
|
+
codeError.value = 'Error during authentication.';
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
</script>
|
|
269
|
+
|
|
270
|
+
<script lang="ts">
|
|
271
|
+
|
|
272
|
+
export function handlePasskeyAlert(propSuggestionPeriod, router) {
|
|
273
|
+
const currentDate = Date.now();
|
|
274
|
+
window.localStorage.removeItem('suggestionPeriod');
|
|
275
|
+
window.localStorage.setItem('suggestionPeriod', propSuggestionPeriod);
|
|
276
|
+
let suggestionPeriod = window.localStorage.getItem('suggestionPeriod');
|
|
277
|
+
let lastSuggestionDate = window.localStorage.getItem('lastSuggestionDate');
|
|
278
|
+
let suggestPasskey = window.localStorage.getItem('suggestPasskey');
|
|
279
|
+
if ( !lastSuggestionDate ) {
|
|
280
|
+
window.localStorage.setItem('lastSuggestionDate', currentDate.toString());
|
|
281
|
+
lastSuggestionDate = window.localStorage.getItem('lastSuggestionDate');
|
|
282
|
+
}
|
|
283
|
+
if ( !suggestPasskey ) {
|
|
284
|
+
window.localStorage.setItem('suggestPasskey', 'true');
|
|
285
|
+
suggestPasskey = window.localStorage.getItem('suggestPasskey');
|
|
286
|
+
}
|
|
287
|
+
if ( currentDate - parseInt(lastSuggestionDate) > parseInt(suggestionPeriod) ) {
|
|
288
|
+
suggestPasskey = window.localStorage.getItem('suggestPasskey');
|
|
289
|
+
if (suggestPasskey !== 'true'){
|
|
290
|
+
if ( suggestPasskey === 'false' || !suggestPasskey ) {
|
|
291
|
+
window.localStorage.setItem('suggestPasskey', 'true');
|
|
292
|
+
} else if ( suggestPasskey !== 'never' ) {
|
|
293
|
+
window.localStorage.setItem('suggestPasskey', 'false');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
suggestPasskey = window.localStorage.getItem('suggestPasskey');
|
|
298
|
+
|
|
299
|
+
if ( suggestPasskey === 'true' ) {
|
|
300
|
+
adminforth.alert({
|
|
301
|
+
message: 'Do you want to add passkey?',
|
|
302
|
+
variant: 'info',
|
|
303
|
+
buttons: [
|
|
304
|
+
{ value: 'yes', label: 'Add passkey' },
|
|
305
|
+
{ value: 'later', label: 'Later' },
|
|
306
|
+
{ value: 'never', label: 'Never' },
|
|
307
|
+
],
|
|
308
|
+
timeout: 'unlimited'
|
|
309
|
+
}).then((value) => {
|
|
310
|
+
switch (value) {
|
|
311
|
+
case 'yes':
|
|
312
|
+
router.push({ name: 'settings', params: { page: 'passkeys' } });
|
|
313
|
+
break;
|
|
314
|
+
case 'later':
|
|
315
|
+
window.localStorage.setItem('suggestPasskey', 'false');
|
|
316
|
+
break;
|
|
317
|
+
case 'never':
|
|
318
|
+
window.localStorage.setItem('suggestPasskey', 'never');
|
|
319
|
+
break;
|
|
320
|
+
default:
|
|
321
|
+
window.localStorage.setItem('suggestPasskey', 'false');
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
142
328
|
function getOtpInputs() {
|
|
143
329
|
const root = otpRoot.value;
|
|
144
330
|
if (!root) return [];
|