@adminforth/two-factors-auth 4.7.0 → 4.8.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.
package/build.log CHANGED
@@ -17,5 +17,5 @@ custom/tsconfig.json
17
17
  custom/use2faApi.ts
18
18
  custom/utils.js
19
19
 
20
- sent 311,411 bytes received 248 bytes 623,318.00 bytes/sec
21
- total size is 310,438 speedup is 1.00
20
+ sent 315,618 bytes received 248 bytes 631,732.00 bytes/sec
21
+ total size is 314,645 speedup is 1.00
@@ -1,58 +1,87 @@
1
1
  <template>
2
- <div class="af-two-factors-modal fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 top-0 bottom-0 left-0 right-0"
2
+ <div class="af-two-factors-modal fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 top-0 bottom-0 left-0 right-0 px-4"
3
3
  v-show ="modelShow && (isLoading === false)">
4
- <div :class="`af-two-factor-modal-${modalMode}`" class="flex flex-col gap-4 relative bg-white dark:bg-gray-700 rounded-lg shadow p-6 w-full max-w-md">
5
- <div class="af-2fa-header flex flex-col items-center justify-center gap-3">
6
- <div class="af-2fa-icon-wrap w-14 h-14 shrink-0 flex items-center justify-center rounded-full bg-lightPrimary dark:bg-darkPrimary">
7
- <IconShieldOutline class="af-2fa-shield-icon w-7 h-7 text-white" />
4
+ <div :class="`af-two-factor-modal-${modalMode}`" class="relative flex flex-col w-full max-w-[460px] overflow-hidden rounded-xl border border-lightDialogBreakLine bg-lightDialogBackgorund shadow-2xl dark:border-darkDialogBreakLine dark:bg-darkDialogBackgorund">
5
+ <div class="af-2fa-header flex items-start gap-3.5 border-b border-lightDialogBreakLine py-5 pl-6 pr-4 dark:border-darkDialogBreakLine">
6
+ <div class="af-2fa-icon-wrap flex w-10 h-10 shrink-0 items-center justify-center rounded-[10px] bg-lightPrimary/10 text-lightPrimary dark:bg-darkPrimary/20 dark:text-darkPrimary">
7
+ <IconShieldCheckOutline class="af-2fa-shield-icon w-6 h-6" />
8
8
  </div>
9
- <div id="mfaCode-label" class="af-2fa-title-wrap">
10
- <p class="af-2fa-title text-xl text-center font-medium text-gray-900 dark:text-white">{{ dialogTitle }}</p>
11
- <p class="af-2fa-subtitle text-xs text-center text-gray-500 dark:text-gray-400 mt-1">{{ subtitles[modalMode] }}</p>
9
+ <div class="af-2fa-title-wrap flex-1 min-w-0">
10
+ <p class="af-2fa-title text-[17px] leading-6 font-semibold text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ dialogTitle }}</p>
11
+ <p class="af-2fa-subtitle mt-0.5 text-[13px] leading-[18px] text-gray-500 dark:text-gray-400">{{ subtitles[modalMode] }}</p>
12
12
  </div>
13
+ <button type="button" :aria-label="$t('Cancel')" class="af-2fa-close-btn -mt-1 inline-flex w-9 h-9 shrink-0 items-center justify-center rounded-lg text-lightDialogCloseButton hover:bg-lightDialogCloseButtonHoverBackground hover:text-lightDialogCloseButtonHover dark:text-darkDialogCloseButton dark:hover:bg-darkDialogCloseButtonHoverBackground dark:hover:text-darkDialogCloseButtonHover" @click="onCancel">
14
+ <IconCloseOutline class="w-5 h-5" />
15
+ </button>
13
16
  </div>
14
17
 
15
- <pre v-if="dialogDetails" class="af-2fa-details max-h-60 overflow-auto rounded-lg bg-gray-100 p-3 text-xs text-gray-800 dark:bg-gray-900 dark:text-gray-100">{{ dialogDetails }}</pre>
16
-
17
- <ul v-if="confirmItems.length > 1" class="af-2fa-actions-list max-h-60 overflow-auto list-disc space-y-1 pl-5 text-sm text-gray-700 dark:text-gray-200">
18
- <li v-for="(item, i) in confirmItems" :key="i">{{ item }}</li>
19
- </ul>
20
-
21
- <template v-if="modalMode === 'totp'">
22
- <div class="af-2fa-otp-root flex justify-center" ref="otpRoot">
23
- <v-otp-input
24
- ref="confirmationResult"
25
- container-class="grid grid-cols-6 gap-3"
26
- input-classes="bg-gray-50 text-center flex 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-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
27
- :num-inputs="6"
28
- inputType="number"
29
- inputmode="numeric"
30
- :should-auto-focus="true"
31
- :should-focus-order="true"
32
- v-model:value="bindValue"
33
- @on-complete="handleOnComplete"
34
- />
18
+ <div class="af-2fa-body flex flex-col gap-5 px-6 pt-5 pb-6">
19
+ <pre v-if="dialogDetails" class="af-2fa-details max-h-60 overflow-auto rounded-[10px] border border-lightDialogBreakLine bg-gray-50 p-3 text-xs text-gray-800 dark:border-darkDialogBreakLine dark:bg-gray-800 dark:text-gray-100">{{ dialogDetails }}</pre>
20
+
21
+ <div v-if="confirmItems.length" class="af-2fa-actions">
22
+ <p class="mb-2 text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ $t('You are approving') }}</p>
23
+ <ul class="af-2fa-actions-list max-h-60 overflow-auto divide-y divide-lightDialogBreakLine rounded-[10px] border border-lightDialogBreakLine bg-gray-50 dark:divide-darkDialogBreakLine dark:border-darkDialogBreakLine dark:bg-gray-800">
24
+ <li v-for="(item, i) in confirmItems" :key="i" class="flex gap-3 px-3.5 py-3">
25
+ <span aria-hidden="true" class="mt-2 w-1.5 h-1.5 shrink-0 rounded-full bg-lightPrimary dark:bg-darkPrimary"></span>
26
+ <span class="text-[15px] leading-[22px] break-words text-gray-800 dark:text-gray-100">{{ item }}</span>
27
+ </li>
28
+ </ul>
35
29
  </div>
36
- <p v-if="codeError" class="af-2fa-code-error text-center text-xs text-red-600 dark:text-red-400">{{ codeError }}</p>
37
- </template>
38
-
39
- <div class="af-2fa-buttons flex gap-2">
40
- <Button variant="secondary" class="af-2fa-cancel-btn flex-1" @click="onCancel">{{ $t('Cancel') }}</Button>
41
- <Button v-if="modalMode === 'passkey'" @click="usePasskeyButtonClick" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="af-2fa-passkey-btn flex-1 flex items-center justify-center gap-2">
42
- <IconShieldOutline class="w-4 h-4" />
43
- {{ $t('Use passkey') }}
44
- </Button>
45
- <Button v-else-if="modalMode === 'grace'" class="af-2fa-approve-btn flex-1" @click="approveInGracePeriod">{{ $t('Approve') }}</Button>
30
+
31
+ <div v-if="modalMode === 'totp'">
32
+ <p id="mfaCode-label" class="mb-2 text-[13px] font-medium text-gray-700 dark:text-gray-300">{{ $t('Authenticator code') }}</p>
33
+ <div class="af-2fa-otp-root text-gray-300 dark:text-gray-500" ref="otpRoot">
34
+ <v-otp-input
35
+ ref="confirmationResult"
36
+ container-class="flex items-center gap-2"
37
+ :input-classes="otpInputClasses"
38
+ :num-inputs="6"
39
+ inputType="number"
40
+ inputmode="numeric"
41
+ :should-auto-focus="true"
42
+ :should-focus-order="true"
43
+ v-model:value="bindValue"
44
+ @on-complete="handleOnComplete"
45
+ />
46
+ </div>
47
+ <p v-if="codeError" role="alert" class="af-2fa-code-error mt-2.5 flex items-center gap-1.5 text-[13px] text-red-600 dark:text-red-400">
48
+ <IconExclamationCircleOutline class="w-4 h-4 shrink-0" />
49
+ {{ codeError }}
50
+ </p>
51
+ </div>
52
+ <div v-else-if="modalMode === 'passkey'" class="af-2fa-passkey-hint flex items-center gap-3.5 rounded-[10px] border border-lightDialogBreakLine bg-gray-50 px-4 py-3.5 dark:border-darkDialogBreakLine dark:bg-gray-800">
53
+ <span class="flex w-11 h-11 shrink-0 items-center justify-center rounded-full bg-lightPrimary/10 text-lightPrimary dark:bg-darkPrimary/20 dark:text-darkPrimary">
54
+ <IconFingerprintOutline class="w-6 h-6" />
55
+ </span>
56
+ <p class="text-[13px] leading-[19px] text-gray-500 dark:text-gray-400">{{ $t('Your browser will ask for Touch ID, Windows Hello or a security key.') }}</p>
57
+ </div>
58
+ <p v-else role="status" class="af-2fa-grace-notice flex items-start gap-2.5 rounded-[10px] border border-green-200 bg-green-50 px-3.5 py-3 text-[13px] leading-[19px] text-green-800 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-300">
59
+ <IconCheckCircleOutline class="w-[18px] h-[18px] shrink-0" />
60
+ {{ $t('You verified recently, so no code is needed.') }}
61
+ </p>
46
62
  </div>
47
63
 
48
- <p v-if="modalMode === 'totp' && doesUserHavePasskeys" class="af-2fa-totp-footer text-center text-xs text-gray-500 dark:text-gray-400">
49
- {{$t('Having trouble?')}}
50
- <button type="button" class="af-2fa-switch-to-passkey text-lightPrimary dark:text-white hover:underline cursor-pointer" @click="modalMode = 'passkey'">{{$t('Use passkey instead')}}</button>
51
- </p>
52
- <p v-else-if="modalMode === 'passkey'" class="af-2fa-passkey-footer text-center text-xs text-gray-500 dark:text-gray-400">
53
- {{$t('Having trouble?')}}
54
- <button type="button" class="af-2fa-switch-to-totp text-lightPrimary dark:text-white hover:underline cursor-pointer" @click="modalMode = 'totp'">{{$t('Use TOTP instead')}}</button>
55
- </p>
64
+ <div class="af-2fa-footer flex items-center gap-3 border-t border-lightDialogBreakLine bg-gray-50 py-3 pl-6 pr-5 dark:border-darkDialogBreakLine dark:bg-gray-800/50">
65
+ <button v-if="modalMode === 'totp' && doesUserHavePasskeys" type="button" class="af-2fa-switch-to-passkey inline-flex h-11 items-center gap-1.5 text-[13px] font-medium text-lightPrimary hover:underline dark:text-darkPrimary" @click="modalMode = 'passkey'">
66
+ <IconFingerprintOutline class="w-4 h-4" />
67
+ {{ $t('Use passkey instead') }}
68
+ </button>
69
+ <button v-else-if="modalMode === 'passkey'" type="button" class="af-2fa-switch-to-totp inline-flex h-11 items-center gap-1.5 text-[13px] font-medium text-lightPrimary hover:underline dark:text-darkPrimary" @click="modalMode = 'totp'">
70
+ <IconMobilePhoneOutline class="w-4 h-4" />
71
+ {{ $t('Use code instead') }}
72
+ </button>
73
+ <div class="af-2fa-buttons ms-auto flex gap-2">
74
+ <Button variant="secondary" class="af-2fa-cancel-btn" @click="onCancel">{{ $t('Cancel') }}</Button>
75
+ <Button v-if="modalMode === 'passkey'" @click="usePasskeyButtonClick" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="af-2fa-passkey-btn gap-2">
76
+ <IconFingerprintOutline class="w-4 h-4" />
77
+ {{ $t('Use passkey') }}
78
+ </Button>
79
+ <Button v-else-if="modalMode === 'grace'" class="af-2fa-approve-btn gap-2" @click="approveInGracePeriod">
80
+ <IconCheckOutline class="w-4 h-4" />
81
+ {{ approveLabel }}
82
+ </Button>
83
+ </div>
84
+ </div>
56
85
  </div>
57
86
  </div>
58
87
  </template>
@@ -65,7 +94,15 @@
65
94
  import { useI18n } from 'vue-i18n';
66
95
  import { callAdminForthApi } from '@/utils';
67
96
  import { Link, Button } from '@/afcl';
68
- import { IconShieldOutline } from '@iconify-prerendered/vue-flowbite';
97
+ import {
98
+ IconCheckCircleOutline,
99
+ IconCheckOutline,
100
+ IconCloseOutline,
101
+ IconExclamationCircleOutline,
102
+ IconFingerprintOutline,
103
+ IconMobilePhoneOutline,
104
+ IconShieldCheckOutline,
105
+ } from '@iconify-prerendered/vue-flowbite';
69
106
  import { getPasskey } from './utils.js'
70
107
  import { useAdminforth } from '@/adminforth';
71
108
  import websocket from '@/websocket';
@@ -139,7 +176,7 @@
139
176
  let confirmationResult;
140
177
  try {
141
178
  isAwaiting2FAResult.value = true;
142
- confirmationResult = await twoFactorsAuthApi.get2FaConfirmationResult(sessionsToResolve.value[0].actionDescription);
179
+ confirmationResult = await twoFactorsAuthApi.get2FaConfirmationResult();
143
180
  } catch (error) {
144
181
  if (!isTwoFaCancel(error)) {
145
182
  console.error('[AdminForth 2FA] Error during 2FA confirmation', error);
@@ -251,18 +288,41 @@
251
288
  const customDialogTitle = ref("");
252
289
  const dialogDetails = ref("");
253
290
  const dialogItems = ref<string[]>([]);
254
- // verifyAuto calls that arrive together are confirmed with one code.
255
- const confirmItems = computed(() => sessionsToResolve.value.length > 1
256
- ? sessionsToResolve.value.map((session) => session.actionDescription ?? t('Action without a description'))
257
- : dialogItems.value);
258
- const dialogTitle = computed(() => confirmItems.value.length > 1
259
- ? t('Confirm {count} actions', { count: confirmItems.value.length })
260
- : customDialogTitle.value || t("Confirm it's you"));
291
+ // verifyAuto calls that arrive together are confirmed with one code. A single call lists only its own description.
292
+ const confirmItems = computed(() => {
293
+ const sessions = sessionsToResolve.value;
294
+ if (!sessions.length) {
295
+ return dialogItems.value;
296
+ }
297
+ if (sessions.length === 1) {
298
+ return sessions[0].actionDescription ? [sessions[0].actionDescription] : [];
299
+ }
300
+ return sessions.map((session) => session.actionDescription ?? t('Action without a description'));
301
+ });
302
+ const dialogTitle = computed(() => {
303
+ const count = confirmItems.value.length;
304
+ if (customDialogTitle.value) {
305
+ return customDialogTitle.value;
306
+ }
307
+ if (count > 1) {
308
+ return t('Confirm {count} actions', { count });
309
+ }
310
+ return count ? t('Confirm action') : t("Confirm it's you");
311
+ });
312
+ const approveLabel = computed(() => confirmItems.value.length > 1
313
+ ? t('Approve {count} actions', { count: confirmItems.value.length })
314
+ : t('Approve'));
261
315
  const codeError = ref("");
316
+ const otpInputClasses = computed(() => [
317
+ 'otp-input w-12 h-14 p-0 rounded-[10px] border-[1.5px] bg-gray-50 text-center text-2xl font-medium leading-none tabular-nums text-gray-900 outline-none focus:ring-4 dark:bg-gray-800 dark:text-white',
318
+ codeError.value
319
+ ? 'border-red-500 focus:border-red-500 focus:ring-red-500/25 dark:border-red-400 dark:focus:border-red-400'
320
+ : 'border-gray-300 focus:border-lightPrimary focus:ring-lightPrimary/25 dark:border-gray-600 dark:focus:border-darkPrimary dark:focus:ring-darkPrimary/25',
321
+ ].join(' '));
262
322
  const subtitles = computed(() => ({
263
- totp: t('Please enter your authenticator code'),
323
+ totp: t('Enter the 6-digit code from your authenticator app'),
264
324
  passkey: t('Confirm with your passkey'),
265
- grace: t('You verified recently, so no code is needed'),
325
+ grace: t('Review and approve'),
266
326
  }));
267
327
 
268
328
  function approveInGracePeriod() {
@@ -476,8 +536,13 @@
476
536
  </script>
477
537
 
478
538
  <style scoped>
479
- :deep(.otp-input-container) {
480
- display: flex;
481
- gap: 0.75rem;
539
+ /* a dash after the 3rd digit splits the code into two groups of three */
540
+ :deep(.otp-input-container > div:nth-child(3))::after {
541
+ content: '';
542
+ width: 0.75rem;
543
+ height: 2px;
544
+ margin-left: 0.5rem;
545
+ border-radius: 1px;
546
+ background: currentColor;
482
547
  }
483
548
  </style>
@@ -1,58 +1,87 @@
1
1
  <template>
2
- <div class="af-two-factors-modal fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 top-0 bottom-0 left-0 right-0"
2
+ <div class="af-two-factors-modal fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 top-0 bottom-0 left-0 right-0 px-4"
3
3
  v-show ="modelShow && (isLoading === false)">
4
- <div :class="`af-two-factor-modal-${modalMode}`" class="flex flex-col gap-4 relative bg-white dark:bg-gray-700 rounded-lg shadow p-6 w-full max-w-md">
5
- <div class="af-2fa-header flex flex-col items-center justify-center gap-3">
6
- <div class="af-2fa-icon-wrap w-14 h-14 shrink-0 flex items-center justify-center rounded-full bg-lightPrimary dark:bg-darkPrimary">
7
- <IconShieldOutline class="af-2fa-shield-icon w-7 h-7 text-white" />
4
+ <div :class="`af-two-factor-modal-${modalMode}`" class="relative flex flex-col w-full max-w-[460px] overflow-hidden rounded-xl border border-lightDialogBreakLine bg-lightDialogBackgorund shadow-2xl dark:border-darkDialogBreakLine dark:bg-darkDialogBackgorund">
5
+ <div class="af-2fa-header flex items-start gap-3.5 border-b border-lightDialogBreakLine py-5 pl-6 pr-4 dark:border-darkDialogBreakLine">
6
+ <div class="af-2fa-icon-wrap flex w-10 h-10 shrink-0 items-center justify-center rounded-[10px] bg-lightPrimary/10 text-lightPrimary dark:bg-darkPrimary/20 dark:text-darkPrimary">
7
+ <IconShieldCheckOutline class="af-2fa-shield-icon w-6 h-6" />
8
8
  </div>
9
- <div id="mfaCode-label" class="af-2fa-title-wrap">
10
- <p class="af-2fa-title text-xl text-center font-medium text-gray-900 dark:text-white">{{ dialogTitle }}</p>
11
- <p class="af-2fa-subtitle text-xs text-center text-gray-500 dark:text-gray-400 mt-1">{{ subtitles[modalMode] }}</p>
9
+ <div class="af-2fa-title-wrap flex-1 min-w-0">
10
+ <p class="af-2fa-title text-[17px] leading-6 font-semibold text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ dialogTitle }}</p>
11
+ <p class="af-2fa-subtitle mt-0.5 text-[13px] leading-[18px] text-gray-500 dark:text-gray-400">{{ subtitles[modalMode] }}</p>
12
12
  </div>
13
+ <button type="button" :aria-label="$t('Cancel')" class="af-2fa-close-btn -mt-1 inline-flex w-9 h-9 shrink-0 items-center justify-center rounded-lg text-lightDialogCloseButton hover:bg-lightDialogCloseButtonHoverBackground hover:text-lightDialogCloseButtonHover dark:text-darkDialogCloseButton dark:hover:bg-darkDialogCloseButtonHoverBackground dark:hover:text-darkDialogCloseButtonHover" @click="onCancel">
14
+ <IconCloseOutline class="w-5 h-5" />
15
+ </button>
13
16
  </div>
14
17
 
15
- <pre v-if="dialogDetails" class="af-2fa-details max-h-60 overflow-auto rounded-lg bg-gray-100 p-3 text-xs text-gray-800 dark:bg-gray-900 dark:text-gray-100">{{ dialogDetails }}</pre>
16
-
17
- <ul v-if="confirmItems.length > 1" class="af-2fa-actions-list max-h-60 overflow-auto list-disc space-y-1 pl-5 text-sm text-gray-700 dark:text-gray-200">
18
- <li v-for="(item, i) in confirmItems" :key="i">{{ item }}</li>
19
- </ul>
20
-
21
- <template v-if="modalMode === 'totp'">
22
- <div class="af-2fa-otp-root flex justify-center" ref="otpRoot">
23
- <v-otp-input
24
- ref="confirmationResult"
25
- container-class="grid grid-cols-6 gap-3"
26
- input-classes="bg-gray-50 text-center flex 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-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
27
- :num-inputs="6"
28
- inputType="number"
29
- inputmode="numeric"
30
- :should-auto-focus="true"
31
- :should-focus-order="true"
32
- v-model:value="bindValue"
33
- @on-complete="handleOnComplete"
34
- />
18
+ <div class="af-2fa-body flex flex-col gap-5 px-6 pt-5 pb-6">
19
+ <pre v-if="dialogDetails" class="af-2fa-details max-h-60 overflow-auto rounded-[10px] border border-lightDialogBreakLine bg-gray-50 p-3 text-xs text-gray-800 dark:border-darkDialogBreakLine dark:bg-gray-800 dark:text-gray-100">{{ dialogDetails }}</pre>
20
+
21
+ <div v-if="confirmItems.length" class="af-2fa-actions">
22
+ <p class="mb-2 text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ $t('You are approving') }}</p>
23
+ <ul class="af-2fa-actions-list max-h-60 overflow-auto divide-y divide-lightDialogBreakLine rounded-[10px] border border-lightDialogBreakLine bg-gray-50 dark:divide-darkDialogBreakLine dark:border-darkDialogBreakLine dark:bg-gray-800">
24
+ <li v-for="(item, i) in confirmItems" :key="i" class="flex gap-3 px-3.5 py-3">
25
+ <span aria-hidden="true" class="mt-2 w-1.5 h-1.5 shrink-0 rounded-full bg-lightPrimary dark:bg-darkPrimary"></span>
26
+ <span class="text-[15px] leading-[22px] break-words text-gray-800 dark:text-gray-100">{{ item }}</span>
27
+ </li>
28
+ </ul>
35
29
  </div>
36
- <p v-if="codeError" class="af-2fa-code-error text-center text-xs text-red-600 dark:text-red-400">{{ codeError }}</p>
37
- </template>
38
-
39
- <div class="af-2fa-buttons flex gap-2">
40
- <Button variant="secondary" class="af-2fa-cancel-btn flex-1" @click="onCancel">{{ $t('Cancel') }}</Button>
41
- <Button v-if="modalMode === 'passkey'" @click="usePasskeyButtonClick" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="af-2fa-passkey-btn flex-1 flex items-center justify-center gap-2">
42
- <IconShieldOutline class="w-4 h-4" />
43
- {{ $t('Use passkey') }}
44
- </Button>
45
- <Button v-else-if="modalMode === 'grace'" class="af-2fa-approve-btn flex-1" @click="approveInGracePeriod">{{ $t('Approve') }}</Button>
30
+
31
+ <div v-if="modalMode === 'totp'">
32
+ <p id="mfaCode-label" class="mb-2 text-[13px] font-medium text-gray-700 dark:text-gray-300">{{ $t('Authenticator code') }}</p>
33
+ <div class="af-2fa-otp-root text-gray-300 dark:text-gray-500" ref="otpRoot">
34
+ <v-otp-input
35
+ ref="confirmationResult"
36
+ container-class="flex items-center gap-2"
37
+ :input-classes="otpInputClasses"
38
+ :num-inputs="6"
39
+ inputType="number"
40
+ inputmode="numeric"
41
+ :should-auto-focus="true"
42
+ :should-focus-order="true"
43
+ v-model:value="bindValue"
44
+ @on-complete="handleOnComplete"
45
+ />
46
+ </div>
47
+ <p v-if="codeError" role="alert" class="af-2fa-code-error mt-2.5 flex items-center gap-1.5 text-[13px] text-red-600 dark:text-red-400">
48
+ <IconExclamationCircleOutline class="w-4 h-4 shrink-0" />
49
+ {{ codeError }}
50
+ </p>
51
+ </div>
52
+ <div v-else-if="modalMode === 'passkey'" class="af-2fa-passkey-hint flex items-center gap-3.5 rounded-[10px] border border-lightDialogBreakLine bg-gray-50 px-4 py-3.5 dark:border-darkDialogBreakLine dark:bg-gray-800">
53
+ <span class="flex w-11 h-11 shrink-0 items-center justify-center rounded-full bg-lightPrimary/10 text-lightPrimary dark:bg-darkPrimary/20 dark:text-darkPrimary">
54
+ <IconFingerprintOutline class="w-6 h-6" />
55
+ </span>
56
+ <p class="text-[13px] leading-[19px] text-gray-500 dark:text-gray-400">{{ $t('Your browser will ask for Touch ID, Windows Hello or a security key.') }}</p>
57
+ </div>
58
+ <p v-else role="status" class="af-2fa-grace-notice flex items-start gap-2.5 rounded-[10px] border border-green-200 bg-green-50 px-3.5 py-3 text-[13px] leading-[19px] text-green-800 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-300">
59
+ <IconCheckCircleOutline class="w-[18px] h-[18px] shrink-0" />
60
+ {{ $t('You verified recently, so no code is needed.') }}
61
+ </p>
46
62
  </div>
47
63
 
48
- <p v-if="modalMode === 'totp' && doesUserHavePasskeys" class="af-2fa-totp-footer text-center text-xs text-gray-500 dark:text-gray-400">
49
- {{$t('Having trouble?')}}
50
- <button type="button" class="af-2fa-switch-to-passkey text-lightPrimary dark:text-white hover:underline cursor-pointer" @click="modalMode = 'passkey'">{{$t('Use passkey instead')}}</button>
51
- </p>
52
- <p v-else-if="modalMode === 'passkey'" class="af-2fa-passkey-footer text-center text-xs text-gray-500 dark:text-gray-400">
53
- {{$t('Having trouble?')}}
54
- <button type="button" class="af-2fa-switch-to-totp text-lightPrimary dark:text-white hover:underline cursor-pointer" @click="modalMode = 'totp'">{{$t('Use TOTP instead')}}</button>
55
- </p>
64
+ <div class="af-2fa-footer flex items-center gap-3 border-t border-lightDialogBreakLine bg-gray-50 py-3 pl-6 pr-5 dark:border-darkDialogBreakLine dark:bg-gray-800/50">
65
+ <button v-if="modalMode === 'totp' && doesUserHavePasskeys" type="button" class="af-2fa-switch-to-passkey inline-flex h-11 items-center gap-1.5 text-[13px] font-medium text-lightPrimary hover:underline dark:text-darkPrimary" @click="modalMode = 'passkey'">
66
+ <IconFingerprintOutline class="w-4 h-4" />
67
+ {{ $t('Use passkey instead') }}
68
+ </button>
69
+ <button v-else-if="modalMode === 'passkey'" type="button" class="af-2fa-switch-to-totp inline-flex h-11 items-center gap-1.5 text-[13px] font-medium text-lightPrimary hover:underline dark:text-darkPrimary" @click="modalMode = 'totp'">
70
+ <IconMobilePhoneOutline class="w-4 h-4" />
71
+ {{ $t('Use code instead') }}
72
+ </button>
73
+ <div class="af-2fa-buttons ms-auto flex gap-2">
74
+ <Button variant="secondary" class="af-2fa-cancel-btn" @click="onCancel">{{ $t('Cancel') }}</Button>
75
+ <Button v-if="modalMode === 'passkey'" @click="usePasskeyButtonClick" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="af-2fa-passkey-btn gap-2">
76
+ <IconFingerprintOutline class="w-4 h-4" />
77
+ {{ $t('Use passkey') }}
78
+ </Button>
79
+ <Button v-else-if="modalMode === 'grace'" class="af-2fa-approve-btn gap-2" @click="approveInGracePeriod">
80
+ <IconCheckOutline class="w-4 h-4" />
81
+ {{ approveLabel }}
82
+ </Button>
83
+ </div>
84
+ </div>
56
85
  </div>
57
86
  </div>
58
87
  </template>
@@ -65,7 +94,15 @@
65
94
  import { useI18n } from 'vue-i18n';
66
95
  import { callAdminForthApi } from '@/utils';
67
96
  import { Link, Button } from '@/afcl';
68
- import { IconShieldOutline } from '@iconify-prerendered/vue-flowbite';
97
+ import {
98
+ IconCheckCircleOutline,
99
+ IconCheckOutline,
100
+ IconCloseOutline,
101
+ IconExclamationCircleOutline,
102
+ IconFingerprintOutline,
103
+ IconMobilePhoneOutline,
104
+ IconShieldCheckOutline,
105
+ } from '@iconify-prerendered/vue-flowbite';
69
106
  import { getPasskey } from './utils.js'
70
107
  import { useAdminforth } from '@/adminforth';
71
108
  import websocket from '@/websocket';
@@ -139,7 +176,7 @@
139
176
  let confirmationResult;
140
177
  try {
141
178
  isAwaiting2FAResult.value = true;
142
- confirmationResult = await twoFactorsAuthApi.get2FaConfirmationResult(sessionsToResolve.value[0].actionDescription);
179
+ confirmationResult = await twoFactorsAuthApi.get2FaConfirmationResult();
143
180
  } catch (error) {
144
181
  if (!isTwoFaCancel(error)) {
145
182
  console.error('[AdminForth 2FA] Error during 2FA confirmation', error);
@@ -251,18 +288,41 @@
251
288
  const customDialogTitle = ref("");
252
289
  const dialogDetails = ref("");
253
290
  const dialogItems = ref<string[]>([]);
254
- // verifyAuto calls that arrive together are confirmed with one code.
255
- const confirmItems = computed(() => sessionsToResolve.value.length > 1
256
- ? sessionsToResolve.value.map((session) => session.actionDescription ?? t('Action without a description'))
257
- : dialogItems.value);
258
- const dialogTitle = computed(() => confirmItems.value.length > 1
259
- ? t('Confirm {count} actions', { count: confirmItems.value.length })
260
- : customDialogTitle.value || t("Confirm it's you"));
291
+ // verifyAuto calls that arrive together are confirmed with one code. A single call lists only its own description.
292
+ const confirmItems = computed(() => {
293
+ const sessions = sessionsToResolve.value;
294
+ if (!sessions.length) {
295
+ return dialogItems.value;
296
+ }
297
+ if (sessions.length === 1) {
298
+ return sessions[0].actionDescription ? [sessions[0].actionDescription] : [];
299
+ }
300
+ return sessions.map((session) => session.actionDescription ?? t('Action without a description'));
301
+ });
302
+ const dialogTitle = computed(() => {
303
+ const count = confirmItems.value.length;
304
+ if (customDialogTitle.value) {
305
+ return customDialogTitle.value;
306
+ }
307
+ if (count > 1) {
308
+ return t('Confirm {count} actions', { count });
309
+ }
310
+ return count ? t('Confirm action') : t("Confirm it's you");
311
+ });
312
+ const approveLabel = computed(() => confirmItems.value.length > 1
313
+ ? t('Approve {count} actions', { count: confirmItems.value.length })
314
+ : t('Approve'));
261
315
  const codeError = ref("");
316
+ const otpInputClasses = computed(() => [
317
+ 'otp-input w-12 h-14 p-0 rounded-[10px] border-[1.5px] bg-gray-50 text-center text-2xl font-medium leading-none tabular-nums text-gray-900 outline-none focus:ring-4 dark:bg-gray-800 dark:text-white',
318
+ codeError.value
319
+ ? 'border-red-500 focus:border-red-500 focus:ring-red-500/25 dark:border-red-400 dark:focus:border-red-400'
320
+ : 'border-gray-300 focus:border-lightPrimary focus:ring-lightPrimary/25 dark:border-gray-600 dark:focus:border-darkPrimary dark:focus:ring-darkPrimary/25',
321
+ ].join(' '));
262
322
  const subtitles = computed(() => ({
263
- totp: t('Please enter your authenticator code'),
323
+ totp: t('Enter the 6-digit code from your authenticator app'),
264
324
  passkey: t('Confirm with your passkey'),
265
- grace: t('You verified recently, so no code is needed'),
325
+ grace: t('Review and approve'),
266
326
  }));
267
327
 
268
328
  function approveInGracePeriod() {
@@ -476,8 +536,13 @@
476
536
  </script>
477
537
 
478
538
  <style scoped>
479
- :deep(.otp-input-container) {
480
- display: flex;
481
- gap: 0.75rem;
539
+ /* a dash after the 3rd digit splits the code into two groups of three */
540
+ :deep(.otp-input-container > div:nth-child(3))::after {
541
+ content: '';
542
+ width: 0.75rem;
543
+ height: 2px;
544
+ margin-left: 0.5rem;
545
+ border-radius: 1px;
546
+ background: currentColor;
482
547
  }
483
548
  </style>
@@ -7,9 +7,11 @@ export declare class TotpService {
7
7
  private readonly userRepository;
8
8
  private readonly adminforth;
9
9
  private readonly codeAttemptLimiters;
10
+ private readonly lastUsedCodeSteps;
10
11
  constructor(options: PluginOptions, userRepository: UserRepository, adminforth: IAdminForth);
11
12
  generateSecret(issuerName: string, userName: string): string;
12
13
  verifySecret(secret: string, code: string): boolean;
14
+ private findCodeStep;
13
15
  verifyUserCode(userPk: string, code: string): Promise<VerificationResult>;
14
16
  verifyAdminUserCode(adminUser: AdminUser, code: string): Promise<{
15
17
  ok: true;
@@ -13,21 +13,28 @@ import { TwoFactorErrorCode } from "../utils/errors.js";
13
13
  const CODE_ATTEMPT_RATE_LIMITS = ['30/1m', '300/1h', '1000/1d'];
14
14
  const VERIFIED_CODE_JWT_TYPE = 'verifiedTotpCode';
15
15
  const VERIFIED_CODE_TOKEN_TTL = '1m';
16
+ const TOTP_STEP_SECONDS = 30;
16
17
  export class TotpService {
17
18
  constructor(options, userRepository, adminforth) {
18
19
  this.options = options;
19
20
  this.userRepository = userRepository;
20
21
  this.adminforth = adminforth;
21
22
  this.codeAttemptLimiters = CODE_ATTEMPT_RATE_LIMITS.map((rate) => new RateLimiter(rate));
23
+ this.lastUsedCodeSteps = new Map();
22
24
  }
23
25
  generateSecret(issuerName, userName) {
24
26
  return twofactor.generateSecret({ name: issuerName, account: userName }).secret;
25
27
  }
26
28
  verifySecret(secret, code) {
27
- return Boolean(twofactor.verifyToken(secret, code, this.options.timeStepWindow));
29
+ return this.findCodeStep(secret, code) !== null;
30
+ }
31
+ findCodeStep(secret, code) {
32
+ const match = twofactor.verifyToken(secret, code, this.options.timeStepWindow);
33
+ return match && Math.floor(Date.now() / 1000 / TOTP_STEP_SECONDS) + match.delta;
28
34
  }
29
35
  verifyUserCode(userPk, code) {
30
36
  return __awaiter(this, void 0, void 0, function* () {
37
+ var _a;
31
38
  const attemptsAllowed = yield Promise.all(this.codeAttemptLimiters.map((limiter) => limiter.consume(userPk)));
32
39
  if (!attemptsAllowed.every(Boolean)) {
33
40
  return { error: "Too many attempts to enter the code. Try again later.", code: TwoFactorErrorCode.RATE_LIMITED };
@@ -38,9 +45,14 @@ export class TotpService {
38
45
  const secret = this.userRepository.getSecret(user);
39
46
  if (!secret)
40
47
  return { error: "2FA is not set up for this user", code: TwoFactorErrorCode.NOT_SET_UP };
41
- return this.verifySecret(secret, code)
42
- ? { ok: true }
43
- : { error: "Wrong or expired OTP code", code: TwoFactorErrorCode.INVALID };
48
+ const step = this.findCodeStep(secret, code);
49
+ if (step === null)
50
+ return { error: "Wrong or expired OTP code", code: TwoFactorErrorCode.INVALID };
51
+ if (step <= ((_a = this.lastUsedCodeSteps.get(userPk)) !== null && _a !== void 0 ? _a : -Infinity)) {
52
+ return { error: "This code has already been used. Wait for the next one.", code: TwoFactorErrorCode.INVALID };
53
+ }
54
+ this.lastUsedCodeSteps.set(userPk, step);
55
+ return { ok: true };
44
56
  });
45
57
  }
46
58
  verifyAdminUserCode(adminUser, code) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/two-factors-auth",
3
- "version": "4.7.0",
3
+ "version": "4.8.0",
4
4
  "description": "AdminForth plugin for two factors authentication",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -8,9 +8,11 @@ import type { VerificationFailure, VerificationResult } from "../utils/types.js"
8
8
  const CODE_ATTEMPT_RATE_LIMITS = ['30/1m', '300/1h', '1000/1d'];
9
9
  const VERIFIED_CODE_JWT_TYPE = 'verifiedTotpCode';
10
10
  const VERIFIED_CODE_TOKEN_TTL = '1m';
11
+ const TOTP_STEP_SECONDS = 30;
11
12
 
12
13
  export class TotpService {
13
14
  private readonly codeAttemptLimiters = CODE_ATTEMPT_RATE_LIMITS.map((rate) => new RateLimiter(rate));
15
+ private readonly lastUsedCodeSteps = new Map<string, number>();
14
16
 
15
17
  constructor(
16
18
  private readonly options: PluginOptions,
@@ -23,7 +25,12 @@ export class TotpService {
23
25
  }
24
26
 
25
27
  public verifySecret(secret: string, code: string): boolean {
26
- return Boolean(twofactor.verifyToken(secret, code, this.options.timeStepWindow));
28
+ return this.findCodeStep(secret, code) !== null;
29
+ }
30
+
31
+ private findCodeStep(secret: string, code: string): number | null {
32
+ const match = twofactor.verifyToken(secret, code, this.options.timeStepWindow);
33
+ return match && Math.floor(Date.now() / 1000 / TOTP_STEP_SECONDS) + match.delta;
27
34
  }
28
35
 
29
36
  public async verifyUserCode(userPk: string, code: string): Promise<VerificationResult> {
@@ -38,9 +45,14 @@ export class TotpService {
38
45
  const secret = this.userRepository.getSecret(user);
39
46
  if (!secret) return { error: "2FA is not set up for this user", code: TwoFactorErrorCode.NOT_SET_UP };
40
47
 
41
- return this.verifySecret(secret, code)
42
- ? { ok: true }
43
- : { error: "Wrong or expired OTP code", code: TwoFactorErrorCode.INVALID };
48
+ const step = this.findCodeStep(secret, code);
49
+ if (step === null) return { error: "Wrong or expired OTP code", code: TwoFactorErrorCode.INVALID };
50
+
51
+ if (step <= (this.lastUsedCodeSteps.get(userPk) ?? -Infinity)) {
52
+ return { error: "This code has already been used. Wait for the next one.", code: TwoFactorErrorCode.INVALID };
53
+ }
54
+ this.lastUsedCodeSteps.set(userPk, step);
55
+ return { ok: true };
44
56
  }
45
57
 
46
58
  public async verifyAdminUserCode(adminUser: AdminUser, code: string): Promise<{ ok: true; verifiedCodeToken: string } | VerificationFailure> {
@@ -36,3 +36,30 @@ test('a correct code gives a token that confirms the code only for its user', as
36
36
  assert.equal(await service.isVerifiedCodeToken(verifiedCodeToken, 'user-2'), false);
37
37
  assert.equal(await service.isVerifiedCodeToken('forged', 'user-1'), false);
38
38
  });
39
+
40
+ const STEP_MS = 30_000;
41
+ const codeAt = (t, time) => {
42
+ t.mock.timers.setTime(time);
43
+ return twofactor.generateToken(SECRET).token;
44
+ };
45
+
46
+ test('a code confirms only one request, so a phished code can not be replayed', async (t) => {
47
+ t.mock.timers.enable({ apis: ['Date'], now: 1_000 * STEP_MS });
48
+ const service = createService();
49
+ const code = codeAt(t, 1_000 * STEP_MS);
50
+ const results = await Promise.all([service.verifyUserCode('user-1', code), service.verifyUserCode('user-1', code)]);
51
+ assert.equal(results.filter((result) => result.ok).length, 1);
52
+ assert.equal((await service.verifyUserCode('user-1', code)).code, TwoFactorErrorCode.INVALID);
53
+ assert.equal((await service.verifyAdminUserCode({ pk: 'user-1' }, code)).code, TwoFactorErrorCode.INVALID);
54
+ assert.deepEqual(await service.verifyUserCode('user-2', code), { ok: true });
55
+ });
56
+
57
+ test('after a code is used, older codes from the window are rejected and the next code is accepted', async (t) => {
58
+ t.mock.timers.enable({ apis: ['Date'], now: 1_000 * STEP_MS });
59
+ const service = createService();
60
+ const olderCode = codeAt(t, 1_000 * STEP_MS);
61
+ const code = codeAt(t, 1_001 * STEP_MS);
62
+ assert.deepEqual(await service.verifyUserCode('user-1', code), { ok: true });
63
+ assert.equal((await service.verifyUserCode('user-1', olderCode)).code, TwoFactorErrorCode.INVALID);
64
+ assert.deepEqual(await service.verifyUserCode('user-1', codeAt(t, 1_002 * STEP_MS)), { ok: true });
65
+ });