@adminforth/two-factors-auth 1.9.2 → 1.10.0

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.
@@ -1,73 +1,102 @@
1
1
  <template>
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 id="mfaCode-label" class="m-4">{{$t('Please enter your authenticator code')}} </div>
17
- <div class="my-4 w-full flex justify-center" ref="otpRoot">
18
- <v-otp-input
19
- ref="code"
20
- container-class="grid grid-cols-6 gap-3 w-full"
21
- 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"
22
- :num-inputs="6"
23
- inputType="number"
24
- inputmode="numeric"
25
- :should-auto-focus="true"
26
- :should-focus-order="true"
27
- v-model:value="bindValue"
28
- @on-complete="handleOnComplete"
29
- />
30
- </div>
31
- <ErrorMessage :error="codeError" />
32
- <div class="mt-6 flex justify-center">
33
- <LinkButton
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('Back to login')}}
38
- </LinkButton>
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
- </template>
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, watchEffect,computed,watch } from 'vue';
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 { LinkButton } from '@/afcl';
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/confirmSetup',
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
- codeError.value = 'Invalid code';
181
+ if (usePasskey) {
182
+ showErrorTost(t('Invalid passkey'));
183
+ codeError.value = 'Invalid passkey';
184
+ } else {
185
+ showErrorTost(t('Invalid code'));
186
+ codeError.value = 'Invalid code';
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 [];