@adminforth/two-factors-auth 4.6.0 → 4.7.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 CHANGED
@@ -17,5 +17,5 @@ custom/tsconfig.json
17
17
  custom/use2faApi.ts
18
18
  custom/utils.js
19
19
 
20
- sent 312,646 bytes received 248 bytes 625,788.00 bytes/sec
21
- total size is 311,686 speedup is 1.00
20
+ sent 311,411 bytes received 248 bytes 623,318.00 bytes/sec
21
+ total size is 310,438 speedup is 1.00
@@ -1,100 +1,58 @@
1
1
  <template>
2
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"
3
3
  v-show ="modelShow && (isLoading === false)">
4
- <div v-if="modalMode === 'totp'" class="af-two-factor-modal-totp flex flex-col gap-4 relative bg-white dark:bg-gray-700 rounded-lg shadow p-6 w-full max-w-md">
5
- <button
6
- type="button"
7
- class="af-2fa-close-btn text-lightDialogCloseButton bg-transparent hover:bg-lightDialogCloseButtonHoverBackground hover:text-lightDialogCloseButtonHover rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:text-darkDialogCloseButton dark:hover:bg-darkDialogCloseButtonHoverBackground dark:hover:text-darkDialogCloseButtonHover"
8
- @click="onCancel"
9
- >
10
- <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
11
- <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
12
- </svg>
13
- <span class="sr-only">{{$t('Close modal')}}</span>
14
- </button>
15
-
16
- <div class="af-2fa-totp-header flex flex-col items-center justify-center gap-3">
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">
17
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">
18
7
  <IconShieldOutline class="af-2fa-shield-icon w-7 h-7 text-white" />
19
8
  </div>
20
- <div id="mfaCode-label" class="af-2fa-totp-title-wrap">
21
- <p v-if="customDialogTitle" class="af-2fa-custom-title text-xl text-center font-medium text-gray-900 dark:text-white">{{ customDialogTitle }}</p>
22
- <p class="af-2fa-totp-subtitle text-xs text-center text-gray-500 dark:text-gray-400 mt-1">{{ $t('Please enter your authenticator code') }}</p>
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>
23
12
  </div>
24
13
  </div>
25
14
 
26
- <div class="af-2fa-otp-root flex justify-center" ref="otpRoot">
27
- <v-otp-input
28
- ref="confirmationResult"
29
- container-class="grid grid-cols-6 gap-3"
30
- 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"
31
- :num-inputs="6"
32
- inputType="number"
33
- inputmode="numeric"
34
- :should-auto-focus="true"
35
- :should-focus-order="true"
36
- v-model:value="bindValue"
37
- @on-complete="handleOnComplete"
38
- />
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
+ />
35
+ </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>
39
46
  </div>
40
- <p v-if="codeError" class="af-2fa-code-error text-center text-xs text-red-600 dark:text-red-400">{{ codeError }}</p>
41
- <p v-if="doesUserHavePasskeys" class="af-2fa-totp-footer text-center text-xs text-gray-500 dark:text-gray-400">
47
+
48
+ <p v-if="modalMode === 'totp' && doesUserHavePasskeys" class="af-2fa-totp-footer text-center text-xs text-gray-500 dark:text-gray-400">
42
49
  {{$t('Having trouble?')}}
43
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>
44
51
  </p>
45
- <p class="af-2fa-multiple-actions text-center text-red-500 text-xs" v-if="sessionsIdsToResolve.length > 1">
46
- {{ $t('You are confirming {count} action | You are confirming {count} actions', sessionsIdsToResolve.length) }}
47
- </p>
48
- </div>
49
-
50
- <div v-else-if="modalMode === 'passkey'" class="af-two-factor-modal-passkeys flex flex-col gap-4 relative bg-white dark:bg-gray-700 rounded-lg shadow p-6 w-full max-w-md">
51
- <button
52
- type="button"
53
- class="af-2fa-close-btn text-lightDialogCloseButton bg-transparent hover:bg-lightDialogCloseButtonHoverBackground hover:text-lightDialogCloseButtonHover rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:text-darkDialogCloseButton dark:hover:bg-darkDialogCloseButtonHoverBackground dark:hover:text-darkDialogCloseButtonHover"
54
- @click="onCancel"
55
- >
56
- <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
57
- <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
58
- </svg>
59
- <span class="sr-only">{{$t('Close modal')}}</span>
60
- </button>
61
-
62
- <div class="af-2fa-passkey-header flex flex-col items-center justify-center gap-3">
63
- <div class="af-2fa-icon-wrap w-14 h-14 shrink-0 flex items-center justify-center rounded-full bg-lightPrimary dark:bg-darkPrimary">
64
- <IconShieldOutline class="af-2fa-shield-icon w-7 h-7 text-white" />
65
- </div>
66
- <div class="af-2fa-passkey-title-wrap">
67
- <p class="af-2fa-passkey-title text-xl text-center font-medium text-gray-900 dark:text-white">{{$t('Verify to add passkey')}}</p>
68
- <p class="af-2fa-passkey-subtitle text-xs text-center text-gray-500 dark:text-gray-400 mt-1">{{$t("Confirm it's you before registering a new passkey on this device.")}}</p>
69
- </div>
70
- </div>
71
-
72
- <div class="af-2fa-passkey-steps flex flex-col gap-2 mt-2">
73
- <div
74
- v-for="(step, i) in [
75
- $t('Click the button below to begin'),
76
- $t('Authenticate with your device biometrics or PIN'),
77
- $t('Your passkey will be saved automatically'),
78
- ]"
79
- :key="i"
80
- class="af-2fa-passkey-step flex items-center gap-2.5 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-600/50 text-xs text-gray-600 dark:text-gray-300"
81
- >
82
- <span class="af-2fa-step-number shrink-0 w-5 h-5 flex items-center justify-center rounded-full bg-lightPrimary dark:bg-darkPrimary text-white font-semibold text-xs">{{ i + 1 }}</span>
83
- {{ step }}
84
- </div>
85
- </div>
86
-
87
- <Button @click="usePasskeyButtonClick" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="af-2fa-passkey-btn w-full flex items-center justify-center gap-2 mt-2">
88
- <IconShieldOutline class="w-4 h-4" />
89
- {{$t('Use passkey to verify')}}
90
- </Button>
91
- <p class="af-2fa-passkey-footer text-center text-xs text-gray-500 dark:text-gray-400">
52
+ <p v-else-if="modalMode === 'passkey'" class="af-2fa-passkey-footer text-center text-xs text-gray-500 dark:text-gray-400">
92
53
  {{$t('Having trouble?')}}
93
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>
94
55
  </p>
95
- <p class="af-2fa-multiple-actions text-center text-red-500 text-xs" v-if="sessionsIdsToResolve.length > 1">
96
- {{ $t('You are confirming {count} action | You are confirming {count} actions', sessionsIdsToResolve.length) }}
97
- </p>
98
56
  </div>
99
57
  </div>
100
58
  </template>
@@ -102,7 +60,7 @@
102
60
  <script setup lang="ts">
103
61
 
104
62
  import VOtpInput from 'vue3-otp-input';
105
- import { ref, nextTick, watch } from 'vue';
63
+ import { ref, computed, nextTick, watch } from 'vue';
106
64
  import { useUserStore } from '@/stores/user';
107
65
  import { useI18n } from 'vue-i18n';
108
66
  import { callAdminForthApi } from '@/utils';
@@ -113,7 +71,7 @@
113
71
  import websocket from '@/websocket';
114
72
  import { getAdminForthClientId } from '@/utils/clientId';
115
73
  import type { AdminUser } from '@/types/Common';
116
- import { useTwoFactorsAuthApi, type TwoFaConfirmationResult } from './use2faApi.js';
74
+ import { useTwoFactorsAuthApi, type TwoFaConfirmationResult, type TwoFaDialogContent } from './use2faApi.js';
117
75
  const props = defineProps<{
118
76
  autoFinishLogin?: boolean
119
77
  adminUser?: AdminUser
@@ -122,10 +80,15 @@
122
80
  const { alert } = useAdminforth();
123
81
  const twoFactorsAuthApi = useTwoFactorsAuthApi();
124
82
 
83
+ type AutoVerifySession = { sessionId: string; actionDescription?: string };
84
+
125
85
  const isAwaiting2FAResult = ref(false);
126
86
  let allowAddNewSessions = true;
127
87
  const ALLOW_NEW_SESSIONS_PERIOD = 1000;
128
- const sessionsIdsToResolve = ref<string[]>([]);
88
+ const MAX_ACTIONS_PER_CONFIRMATION = 20;
89
+ const sessionsToResolve = ref<AutoVerifySession[]>([]);
90
+ // Sessions over the limit of one confirmation wait for the next one, so the user never confirms an action they did not see.
91
+ let queuedSessions: AutoVerifySession[] = [];
129
92
 
130
93
  watch(isAwaiting2FAResult, (awaiting) => {
131
94
  if (awaiting) {
@@ -142,54 +105,63 @@
142
105
  if (props.adminUser) {
143
106
  websocket.unsubscribeByPrefix(`/user2fa/`);
144
107
  const user2FaTopic = `/user2fa/${props.adminUser.pk}/${getAdminForthClientId()}`;
145
- websocket.subscribe(user2FaTopic, async (data: {sessionId: string; actionDescription?: string}) => {
108
+ websocket.subscribe(user2FaTopic, async (session: AutoVerifySession) => {
146
109
  if (!allowAddNewSessions) {
147
110
  alert({message: t('Some process or user tries to add new actions to confirm. Action was blocked'), variant: 'warning'});
148
111
  return;
149
112
  }
150
- sessionsIdsToResolve.value.push(data.sessionId);
151
- let confirmationResult;
113
+ if (sessionsToResolve.value.length < MAX_ACTIONS_PER_CONFIRMATION) {
114
+ sessionsToResolve.value.push(session);
115
+ } else {
116
+ queuedSessions.push(session);
117
+ }
152
118
  if (isAwaiting2FAResult.value) {
153
119
  return;
154
120
  }
155
- try {
156
- isAwaiting2FAResult.value = true;
157
- confirmationResult = await twoFactorsAuthApi.get2FaConfirmationResult(data.actionDescription);
158
- } catch (error) {
159
- if (!isTwoFaCancel(error)) {
160
- console.error('[AdminForth 2FA] Error during 2FA confirmation', error);
161
- }
162
- }
163
- isAwaiting2FAResult.value = false;
164
- try {
165
- const response = await callAdminForthApi({
166
- method: "POST",
167
- path: "/plugin/passkeys/resolveVerifyAuto",
168
- body: { confirmationResult, sessionsIds: sessionsIdsToResolve.value }
169
- });
170
- if (!response.ok && response.code === 'VERIFICATION_CANCELLED'){
171
- alert({message: t('Verification session finished or cancelled.'), variant: 'warning'});
172
- } else if (!response.ok) {
173
- alert({message: response.error || t('Verification failed'), variant: 'danger'});
174
- } else if (response.ok) {
175
- // alert({message: 'Verification successful', variant: 'success'});
176
- }
177
- sessionsIdsToResolve.value = [];
178
- } catch (error) {
179
- console.error('Error resolving automatic 2FA verification:', error);
121
+ while (sessionsToResolve.value.length) {
122
+ await confirmSessions();
123
+ sessionsToResolve.value = queuedSessions.splice(0, MAX_ACTIONS_PER_CONFIRMATION);
180
124
  }
181
125
  allowAddNewSessions = true;
182
126
  });
183
127
  websocket.subscribe(`${user2FaTopic}/resolve`, async (data: {sessionId: string}) => {
184
- if (sessionsIdsToResolve.value.includes(data.sessionId) && rejectFn && modelShow.value) {
128
+ queuedSessions = queuedSessions.filter((session) => session.sessionId !== data.sessionId);
129
+ if (sessionsToResolve.value.some((session) => session.sessionId === data.sessionId) && rejectFn && modelShow.value) {
185
130
  onCancel();
186
- sessionsIdsToResolve.value = sessionsIdsToResolve.value.filter(id => id !== data.sessionId);
131
+ sessionsToResolve.value = sessionsToResolve.value.filter((session) => session.sessionId !== data.sessionId);
187
132
  }
188
133
  });
189
134
  }
190
135
  }, { immediate: true });
191
136
 
192
137
 
138
+ async function confirmSessions() {
139
+ let confirmationResult;
140
+ try {
141
+ isAwaiting2FAResult.value = true;
142
+ confirmationResult = await twoFactorsAuthApi.get2FaConfirmationResult(sessionsToResolve.value[0].actionDescription);
143
+ } catch (error) {
144
+ if (!isTwoFaCancel(error)) {
145
+ console.error('[AdminForth 2FA] Error during 2FA confirmation', error);
146
+ }
147
+ }
148
+ isAwaiting2FAResult.value = false;
149
+ try {
150
+ const response = await callAdminForthApi({
151
+ method: "POST",
152
+ path: "/plugin/passkeys/resolveVerifyAuto",
153
+ body: { confirmationResult, sessionsIds: sessionsToResolve.value.map((session) => session.sessionId) }
154
+ });
155
+ if (!response.ok && response.code === 'VERIFICATION_CANCELLED'){
156
+ alert({message: t('Verification session finished or cancelled.'), variant: 'warning'});
157
+ } else if (!response.ok) {
158
+ alert({message: response.error || t('Verification failed'), variant: 'danger'});
159
+ }
160
+ } catch (error) {
161
+ console.error('Error resolving automatic 2FA verification:', error);
162
+ }
163
+ }
164
+
193
165
  const emit = defineEmits<{
194
166
  (e: 'resolved', payload: any): void
195
167
  (e: 'rejected', err?: any): void
@@ -226,22 +198,27 @@
226
198
 
227
199
  function get2FaConfirmationResult(
228
200
  title?: string,
229
- verifyingCallback?: (confirmationResult: string) => Promise<boolean> | boolean
201
+ verifyingCallback?: (confirmationResult: string) => Promise<boolean> | boolean,
202
+ { details, items }: TwoFaDialogContent = {},
230
203
  ): Promise<TwoFaConfirmationResult> {
231
204
  return new Promise((resolve, reject) => {
232
205
  (async () => {
233
206
  if (modelShow.value) {
234
207
  throw new Error(t('Modal is already open'));
235
208
  }
236
- const skipAllowModal = await checkIfSkipAllowModal();
237
- if (skipAllowModal) {
209
+ const { skipAllowed, gracePeriod } = await checkIfSkipAllowModal();
210
+ if (skipAllowed) {
238
211
  resolve({});
239
212
  return;
240
213
  }
241
- await checkIfUserHasPasskeys();
242
- if (title) {
243
- customDialogTitle.value = title;
214
+ if (gracePeriod) {
215
+ modalMode.value = 'grace';
216
+ } else {
217
+ await checkIfUserHasPasskeys();
244
218
  }
219
+ customDialogTitle.value = title ?? '';
220
+ dialogDetails.value = details ?? '';
221
+ dialogItems.value = items ?? [];
245
222
  resolveFn = resolve;
246
223
  rejectFn = reject;
247
224
  verifyFn = verifyingCallback ?? null;
@@ -268,11 +245,30 @@
268
245
  const otpRoot = ref<HTMLElement | null>(null);
269
246
  const bindValue = ref('');
270
247
  const doesUserHavePasskeys = ref(false);
271
- const modalMode = ref<"totp" | "passkey">("totp");
248
+ const modalMode = ref<"totp" | "passkey" | "grace">("totp");
272
249
  const isLoading = ref(false);
273
250
  const isFetchingPasskey = ref(false);
274
251
  const customDialogTitle = ref("");
252
+ const dialogDetails = ref("");
253
+ 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"));
275
261
  const codeError = ref("");
262
+ const subtitles = computed(() => ({
263
+ totp: t('Please enter your authenticator code'),
264
+ passkey: t('Confirm with your passkey'),
265
+ grace: t('You verified recently, so no code is needed'),
266
+ }));
267
+
268
+ function approveInGracePeriod() {
269
+ modelShow.value = false;
270
+ resolveFn?.({});
271
+ }
276
272
 
277
273
  async function usePasskeyButtonClick() {
278
274
  isFetchingPasskey.value = true;
@@ -412,15 +408,8 @@
412
408
  path: '/plugin/passkeys/getPasskeys',
413
409
  });
414
410
 
415
- if (response.ok) {
416
- if (response.data.length >= 1) {
417
- doesUserHavePasskeys.value = true;
418
- modalMode.value = "passkey";
419
- } else {
420
- doesUserHavePasskeys.value = false;
421
- modalMode.value = "totp";
422
- }
423
- }
411
+ doesUserHavePasskeys.value = Boolean(response.ok && response.data.length);
412
+ modalMode.value = doesUserHavePasskeys.value ? "passkey" : "totp";
424
413
  } catch (error) {
425
414
  console.error('Error checking passkeys:', error);
426
415
  // Fallback to TOTP if there's an error
@@ -472,20 +461,15 @@
472
461
  }
473
462
 
474
463
 
475
- async function checkIfSkipAllowModal(){
464
+ async function checkIfSkipAllowModal(): Promise<{ skipAllowed: boolean; gracePeriod?: boolean }> {
476
465
  try {
477
- const response = await callAdminForthApi({
466
+ return await callAdminForthApi({
478
467
  method: "GET",
479
468
  path: "/plugin/twofa/skip-allow-modal",
480
469
  });
481
- if ( response.skipAllowed === true ) {
482
- return true;
483
- } else {
484
- return false;
485
- }
486
470
  } catch (error) {
487
471
  console.error('Error checking skip allow modal:', error);
488
- return false;
472
+ return { skipAllowed: false };
489
473
  }
490
474
  }
491
475
 
@@ -7,10 +7,13 @@ export type TwoFaConfirmationResult =
7
7
  | { mode: 'passkey'; result: Record<string, any> }
8
8
  | { mode?: undefined };
9
9
 
10
+ export type TwoFaDialogContent = { details?: string; items?: string[] };
11
+
10
12
  type VerifyFn = (confirmationResult: string) => Promise<boolean> | boolean;
11
13
  type Get2FaConfirmationResultHandler = (
12
14
  title?: string,
13
- verifyingCallback?: VerifyFn
15
+ verifyingCallback?: VerifyFn,
16
+ content?: TwoFaDialogContent,
14
17
  ) => Promise<TwoFaConfirmationResult>;
15
18
 
16
19
  declare global {
@@ -30,12 +33,13 @@ export const useTwoFactorsAuthApi = defineStore('twoFactorsAuthApi', () => {
30
33
 
31
34
  async function get2FaConfirmationResult(
32
35
  title?: string,
33
- verifyingCallback?: VerifyFn
36
+ verifyingCallback?: VerifyFn,
37
+ content?: TwoFaDialogContent,
34
38
  ): Promise<TwoFaConfirmationResult> {
35
39
  if (!get2FaConfirmationResultHandler.value) {
36
40
  throw new Error('Two factors auth modal API is not initialized');
37
41
  }
38
- return get2FaConfirmationResultHandler.value(title, verifyingCallback);
42
+ return get2FaConfirmationResultHandler.value(title, verifyingCallback, content);
39
43
  }
40
44
 
41
45
  function initGlobalApi() {
@@ -1,100 +1,58 @@
1
1
  <template>
2
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"
3
3
  v-show ="modelShow && (isLoading === false)">
4
- <div v-if="modalMode === 'totp'" class="af-two-factor-modal-totp flex flex-col gap-4 relative bg-white dark:bg-gray-700 rounded-lg shadow p-6 w-full max-w-md">
5
- <button
6
- type="button"
7
- class="af-2fa-close-btn text-lightDialogCloseButton bg-transparent hover:bg-lightDialogCloseButtonHoverBackground hover:text-lightDialogCloseButtonHover rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:text-darkDialogCloseButton dark:hover:bg-darkDialogCloseButtonHoverBackground dark:hover:text-darkDialogCloseButtonHover"
8
- @click="onCancel"
9
- >
10
- <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
11
- <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
12
- </svg>
13
- <span class="sr-only">{{$t('Close modal')}}</span>
14
- </button>
15
-
16
- <div class="af-2fa-totp-header flex flex-col items-center justify-center gap-3">
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">
17
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">
18
7
  <IconShieldOutline class="af-2fa-shield-icon w-7 h-7 text-white" />
19
8
  </div>
20
- <div id="mfaCode-label" class="af-2fa-totp-title-wrap">
21
- <p v-if="customDialogTitle" class="af-2fa-custom-title text-xl text-center font-medium text-gray-900 dark:text-white">{{ customDialogTitle }}</p>
22
- <p class="af-2fa-totp-subtitle text-xs text-center text-gray-500 dark:text-gray-400 mt-1">{{ $t('Please enter your authenticator code') }}</p>
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>
23
12
  </div>
24
13
  </div>
25
14
 
26
- <div class="af-2fa-otp-root flex justify-center" ref="otpRoot">
27
- <v-otp-input
28
- ref="confirmationResult"
29
- container-class="grid grid-cols-6 gap-3"
30
- 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"
31
- :num-inputs="6"
32
- inputType="number"
33
- inputmode="numeric"
34
- :should-auto-focus="true"
35
- :should-focus-order="true"
36
- v-model:value="bindValue"
37
- @on-complete="handleOnComplete"
38
- />
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
+ />
35
+ </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>
39
46
  </div>
40
- <p v-if="codeError" class="af-2fa-code-error text-center text-xs text-red-600 dark:text-red-400">{{ codeError }}</p>
41
- <p v-if="doesUserHavePasskeys" class="af-2fa-totp-footer text-center text-xs text-gray-500 dark:text-gray-400">
47
+
48
+ <p v-if="modalMode === 'totp' && doesUserHavePasskeys" class="af-2fa-totp-footer text-center text-xs text-gray-500 dark:text-gray-400">
42
49
  {{$t('Having trouble?')}}
43
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>
44
51
  </p>
45
- <p class="af-2fa-multiple-actions text-center text-red-500 text-xs" v-if="sessionsIdsToResolve.length > 1">
46
- {{ $t('You are confirming {count} action | You are confirming {count} actions', sessionsIdsToResolve.length) }}
47
- </p>
48
- </div>
49
-
50
- <div v-else-if="modalMode === 'passkey'" class="af-two-factor-modal-passkeys flex flex-col gap-4 relative bg-white dark:bg-gray-700 rounded-lg shadow p-6 w-full max-w-md">
51
- <button
52
- type="button"
53
- class="af-2fa-close-btn text-lightDialogCloseButton bg-transparent hover:bg-lightDialogCloseButtonHoverBackground hover:text-lightDialogCloseButtonHover rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:text-darkDialogCloseButton dark:hover:bg-darkDialogCloseButtonHoverBackground dark:hover:text-darkDialogCloseButtonHover"
54
- @click="onCancel"
55
- >
56
- <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
57
- <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
58
- </svg>
59
- <span class="sr-only">{{$t('Close modal')}}</span>
60
- </button>
61
-
62
- <div class="af-2fa-passkey-header flex flex-col items-center justify-center gap-3">
63
- <div class="af-2fa-icon-wrap w-14 h-14 shrink-0 flex items-center justify-center rounded-full bg-lightPrimary dark:bg-darkPrimary">
64
- <IconShieldOutline class="af-2fa-shield-icon w-7 h-7 text-white" />
65
- </div>
66
- <div class="af-2fa-passkey-title-wrap">
67
- <p class="af-2fa-passkey-title text-xl text-center font-medium text-gray-900 dark:text-white">{{$t('Verify to add passkey')}}</p>
68
- <p class="af-2fa-passkey-subtitle text-xs text-center text-gray-500 dark:text-gray-400 mt-1">{{$t("Confirm it's you before registering a new passkey on this device.")}}</p>
69
- </div>
70
- </div>
71
-
72
- <div class="af-2fa-passkey-steps flex flex-col gap-2 mt-2">
73
- <div
74
- v-for="(step, i) in [
75
- $t('Click the button below to begin'),
76
- $t('Authenticate with your device biometrics or PIN'),
77
- $t('Your passkey will be saved automatically'),
78
- ]"
79
- :key="i"
80
- class="af-2fa-passkey-step flex items-center gap-2.5 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-600/50 text-xs text-gray-600 dark:text-gray-300"
81
- >
82
- <span class="af-2fa-step-number shrink-0 w-5 h-5 flex items-center justify-center rounded-full bg-lightPrimary dark:bg-darkPrimary text-white font-semibold text-xs">{{ i + 1 }}</span>
83
- {{ step }}
84
- </div>
85
- </div>
86
-
87
- <Button @click="usePasskeyButtonClick" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="af-2fa-passkey-btn w-full flex items-center justify-center gap-2 mt-2">
88
- <IconShieldOutline class="w-4 h-4" />
89
- {{$t('Use passkey to verify')}}
90
- </Button>
91
- <p class="af-2fa-passkey-footer text-center text-xs text-gray-500 dark:text-gray-400">
52
+ <p v-else-if="modalMode === 'passkey'" class="af-2fa-passkey-footer text-center text-xs text-gray-500 dark:text-gray-400">
92
53
  {{$t('Having trouble?')}}
93
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>
94
55
  </p>
95
- <p class="af-2fa-multiple-actions text-center text-red-500 text-xs" v-if="sessionsIdsToResolve.length > 1">
96
- {{ $t('You are confirming {count} action | You are confirming {count} actions', sessionsIdsToResolve.length) }}
97
- </p>
98
56
  </div>
99
57
  </div>
100
58
  </template>
@@ -102,7 +60,7 @@
102
60
  <script setup lang="ts">
103
61
 
104
62
  import VOtpInput from 'vue3-otp-input';
105
- import { ref, nextTick, watch } from 'vue';
63
+ import { ref, computed, nextTick, watch } from 'vue';
106
64
  import { useUserStore } from '@/stores/user';
107
65
  import { useI18n } from 'vue-i18n';
108
66
  import { callAdminForthApi } from '@/utils';
@@ -113,7 +71,7 @@
113
71
  import websocket from '@/websocket';
114
72
  import { getAdminForthClientId } from '@/utils/clientId';
115
73
  import type { AdminUser } from '@/types/Common';
116
- import { useTwoFactorsAuthApi, type TwoFaConfirmationResult } from './use2faApi.js';
74
+ import { useTwoFactorsAuthApi, type TwoFaConfirmationResult, type TwoFaDialogContent } from './use2faApi.js';
117
75
  const props = defineProps<{
118
76
  autoFinishLogin?: boolean
119
77
  adminUser?: AdminUser
@@ -122,10 +80,15 @@
122
80
  const { alert } = useAdminforth();
123
81
  const twoFactorsAuthApi = useTwoFactorsAuthApi();
124
82
 
83
+ type AutoVerifySession = { sessionId: string; actionDescription?: string };
84
+
125
85
  const isAwaiting2FAResult = ref(false);
126
86
  let allowAddNewSessions = true;
127
87
  const ALLOW_NEW_SESSIONS_PERIOD = 1000;
128
- const sessionsIdsToResolve = ref<string[]>([]);
88
+ const MAX_ACTIONS_PER_CONFIRMATION = 20;
89
+ const sessionsToResolve = ref<AutoVerifySession[]>([]);
90
+ // Sessions over the limit of one confirmation wait for the next one, so the user never confirms an action they did not see.
91
+ let queuedSessions: AutoVerifySession[] = [];
129
92
 
130
93
  watch(isAwaiting2FAResult, (awaiting) => {
131
94
  if (awaiting) {
@@ -142,54 +105,63 @@
142
105
  if (props.adminUser) {
143
106
  websocket.unsubscribeByPrefix(`/user2fa/`);
144
107
  const user2FaTopic = `/user2fa/${props.adminUser.pk}/${getAdminForthClientId()}`;
145
- websocket.subscribe(user2FaTopic, async (data: {sessionId: string; actionDescription?: string}) => {
108
+ websocket.subscribe(user2FaTopic, async (session: AutoVerifySession) => {
146
109
  if (!allowAddNewSessions) {
147
110
  alert({message: t('Some process or user tries to add new actions to confirm. Action was blocked'), variant: 'warning'});
148
111
  return;
149
112
  }
150
- sessionsIdsToResolve.value.push(data.sessionId);
151
- let confirmationResult;
113
+ if (sessionsToResolve.value.length < MAX_ACTIONS_PER_CONFIRMATION) {
114
+ sessionsToResolve.value.push(session);
115
+ } else {
116
+ queuedSessions.push(session);
117
+ }
152
118
  if (isAwaiting2FAResult.value) {
153
119
  return;
154
120
  }
155
- try {
156
- isAwaiting2FAResult.value = true;
157
- confirmationResult = await twoFactorsAuthApi.get2FaConfirmationResult(data.actionDescription);
158
- } catch (error) {
159
- if (!isTwoFaCancel(error)) {
160
- console.error('[AdminForth 2FA] Error during 2FA confirmation', error);
161
- }
162
- }
163
- isAwaiting2FAResult.value = false;
164
- try {
165
- const response = await callAdminForthApi({
166
- method: "POST",
167
- path: "/plugin/passkeys/resolveVerifyAuto",
168
- body: { confirmationResult, sessionsIds: sessionsIdsToResolve.value }
169
- });
170
- if (!response.ok && response.code === 'VERIFICATION_CANCELLED'){
171
- alert({message: t('Verification session finished or cancelled.'), variant: 'warning'});
172
- } else if (!response.ok) {
173
- alert({message: response.error || t('Verification failed'), variant: 'danger'});
174
- } else if (response.ok) {
175
- // alert({message: 'Verification successful', variant: 'success'});
176
- }
177
- sessionsIdsToResolve.value = [];
178
- } catch (error) {
179
- console.error('Error resolving automatic 2FA verification:', error);
121
+ while (sessionsToResolve.value.length) {
122
+ await confirmSessions();
123
+ sessionsToResolve.value = queuedSessions.splice(0, MAX_ACTIONS_PER_CONFIRMATION);
180
124
  }
181
125
  allowAddNewSessions = true;
182
126
  });
183
127
  websocket.subscribe(`${user2FaTopic}/resolve`, async (data: {sessionId: string}) => {
184
- if (sessionsIdsToResolve.value.includes(data.sessionId) && rejectFn && modelShow.value) {
128
+ queuedSessions = queuedSessions.filter((session) => session.sessionId !== data.sessionId);
129
+ if (sessionsToResolve.value.some((session) => session.sessionId === data.sessionId) && rejectFn && modelShow.value) {
185
130
  onCancel();
186
- sessionsIdsToResolve.value = sessionsIdsToResolve.value.filter(id => id !== data.sessionId);
131
+ sessionsToResolve.value = sessionsToResolve.value.filter((session) => session.sessionId !== data.sessionId);
187
132
  }
188
133
  });
189
134
  }
190
135
  }, { immediate: true });
191
136
 
192
137
 
138
+ async function confirmSessions() {
139
+ let confirmationResult;
140
+ try {
141
+ isAwaiting2FAResult.value = true;
142
+ confirmationResult = await twoFactorsAuthApi.get2FaConfirmationResult(sessionsToResolve.value[0].actionDescription);
143
+ } catch (error) {
144
+ if (!isTwoFaCancel(error)) {
145
+ console.error('[AdminForth 2FA] Error during 2FA confirmation', error);
146
+ }
147
+ }
148
+ isAwaiting2FAResult.value = false;
149
+ try {
150
+ const response = await callAdminForthApi({
151
+ method: "POST",
152
+ path: "/plugin/passkeys/resolveVerifyAuto",
153
+ body: { confirmationResult, sessionsIds: sessionsToResolve.value.map((session) => session.sessionId) }
154
+ });
155
+ if (!response.ok && response.code === 'VERIFICATION_CANCELLED'){
156
+ alert({message: t('Verification session finished or cancelled.'), variant: 'warning'});
157
+ } else if (!response.ok) {
158
+ alert({message: response.error || t('Verification failed'), variant: 'danger'});
159
+ }
160
+ } catch (error) {
161
+ console.error('Error resolving automatic 2FA verification:', error);
162
+ }
163
+ }
164
+
193
165
  const emit = defineEmits<{
194
166
  (e: 'resolved', payload: any): void
195
167
  (e: 'rejected', err?: any): void
@@ -226,22 +198,27 @@
226
198
 
227
199
  function get2FaConfirmationResult(
228
200
  title?: string,
229
- verifyingCallback?: (confirmationResult: string) => Promise<boolean> | boolean
201
+ verifyingCallback?: (confirmationResult: string) => Promise<boolean> | boolean,
202
+ { details, items }: TwoFaDialogContent = {},
230
203
  ): Promise<TwoFaConfirmationResult> {
231
204
  return new Promise((resolve, reject) => {
232
205
  (async () => {
233
206
  if (modelShow.value) {
234
207
  throw new Error(t('Modal is already open'));
235
208
  }
236
- const skipAllowModal = await checkIfSkipAllowModal();
237
- if (skipAllowModal) {
209
+ const { skipAllowed, gracePeriod } = await checkIfSkipAllowModal();
210
+ if (skipAllowed) {
238
211
  resolve({});
239
212
  return;
240
213
  }
241
- await checkIfUserHasPasskeys();
242
- if (title) {
243
- customDialogTitle.value = title;
214
+ if (gracePeriod) {
215
+ modalMode.value = 'grace';
216
+ } else {
217
+ await checkIfUserHasPasskeys();
244
218
  }
219
+ customDialogTitle.value = title ?? '';
220
+ dialogDetails.value = details ?? '';
221
+ dialogItems.value = items ?? [];
245
222
  resolveFn = resolve;
246
223
  rejectFn = reject;
247
224
  verifyFn = verifyingCallback ?? null;
@@ -268,11 +245,30 @@
268
245
  const otpRoot = ref<HTMLElement | null>(null);
269
246
  const bindValue = ref('');
270
247
  const doesUserHavePasskeys = ref(false);
271
- const modalMode = ref<"totp" | "passkey">("totp");
248
+ const modalMode = ref<"totp" | "passkey" | "grace">("totp");
272
249
  const isLoading = ref(false);
273
250
  const isFetchingPasskey = ref(false);
274
251
  const customDialogTitle = ref("");
252
+ const dialogDetails = ref("");
253
+ 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"));
275
261
  const codeError = ref("");
262
+ const subtitles = computed(() => ({
263
+ totp: t('Please enter your authenticator code'),
264
+ passkey: t('Confirm with your passkey'),
265
+ grace: t('You verified recently, so no code is needed'),
266
+ }));
267
+
268
+ function approveInGracePeriod() {
269
+ modelShow.value = false;
270
+ resolveFn?.({});
271
+ }
276
272
 
277
273
  async function usePasskeyButtonClick() {
278
274
  isFetchingPasskey.value = true;
@@ -412,15 +408,8 @@
412
408
  path: '/plugin/passkeys/getPasskeys',
413
409
  });
414
410
 
415
- if (response.ok) {
416
- if (response.data.length >= 1) {
417
- doesUserHavePasskeys.value = true;
418
- modalMode.value = "passkey";
419
- } else {
420
- doesUserHavePasskeys.value = false;
421
- modalMode.value = "totp";
422
- }
423
- }
411
+ doesUserHavePasskeys.value = Boolean(response.ok && response.data.length);
412
+ modalMode.value = doesUserHavePasskeys.value ? "passkey" : "totp";
424
413
  } catch (error) {
425
414
  console.error('Error checking passkeys:', error);
426
415
  // Fallback to TOTP if there's an error
@@ -472,20 +461,15 @@
472
461
  }
473
462
 
474
463
 
475
- async function checkIfSkipAllowModal(){
464
+ async function checkIfSkipAllowModal(): Promise<{ skipAllowed: boolean; gracePeriod?: boolean }> {
476
465
  try {
477
- const response = await callAdminForthApi({
466
+ return await callAdminForthApi({
478
467
  method: "GET",
479
468
  path: "/plugin/twofa/skip-allow-modal",
480
469
  });
481
- if ( response.skipAllowed === true ) {
482
- return true;
483
- } else {
484
- return false;
485
- }
486
470
  } catch (error) {
487
471
  console.error('Error checking skip allow modal:', error);
488
- return false;
472
+ return { skipAllowed: false };
489
473
  }
490
474
  }
491
475
 
@@ -7,10 +7,13 @@ export type TwoFaConfirmationResult =
7
7
  | { mode: 'passkey'; result: Record<string, any> }
8
8
  | { mode?: undefined };
9
9
 
10
+ export type TwoFaDialogContent = { details?: string; items?: string[] };
11
+
10
12
  type VerifyFn = (confirmationResult: string) => Promise<boolean> | boolean;
11
13
  type Get2FaConfirmationResultHandler = (
12
14
  title?: string,
13
- verifyingCallback?: VerifyFn
15
+ verifyingCallback?: VerifyFn,
16
+ content?: TwoFaDialogContent,
14
17
  ) => Promise<TwoFaConfirmationResult>;
15
18
 
16
19
  declare global {
@@ -30,12 +33,13 @@ export const useTwoFactorsAuthApi = defineStore('twoFactorsAuthApi', () => {
30
33
 
31
34
  async function get2FaConfirmationResult(
32
35
  title?: string,
33
- verifyingCallback?: VerifyFn
36
+ verifyingCallback?: VerifyFn,
37
+ content?: TwoFaDialogContent,
34
38
  ): Promise<TwoFaConfirmationResult> {
35
39
  if (!get2FaConfirmationResultHandler.value) {
36
40
  throw new Error('Two factors auth modal API is not initialized');
37
41
  }
38
- return get2FaConfirmationResultHandler.value(title, verifyingCallback);
42
+ return get2FaConfirmationResultHandler.value(title, verifyingCallback, content);
39
43
  }
40
44
 
41
45
  function initGlobalApi() {
@@ -44,6 +44,10 @@ export declare function createTwoFaHandlers(ctx: any): {
44
44
  cookies: any;
45
45
  }) => Promise<{
46
46
  skipAllowed: boolean;
47
+ gracePeriod?: undefined;
48
+ } | {
49
+ skipAllowed: boolean;
50
+ gracePeriod: any;
47
51
  }>;
48
52
  verifyTotp: ({ adminUser, body, response }: {
49
53
  adminUser: any;
@@ -160,13 +160,9 @@ export function createTwoFaHandlers(ctx) {
160
160
  return { skipAllowed: true };
161
161
  }
162
162
  }
163
- if (ctx.options.stepUpMfaGracePeriodSeconds) {
164
- const verificationResult = yield ctx.cookieService.isMfaGraceValid(headers, cookies, true);
165
- if (verificationResult === true) {
166
- return { skipAllowed: true };
167
- }
168
- }
169
- return { skipAllowed: false };
163
+ const gracePeriod = Boolean(ctx.options.stepUpMfaGracePeriodSeconds)
164
+ && (yield ctx.cookieService.isMfaGraceValid(headers, cookies, true));
165
+ return { skipAllowed: false, gracePeriod };
170
166
  }),
171
167
  verifyTotp: (_a) => __awaiter(this, [_a], void 0, function* ({ adminUser, body, response }) {
172
168
  if (!(body === null || body === void 0 ? void 0 : body.code))
package/dist/index.js CHANGED
@@ -125,7 +125,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
125
125
  this.adminForthAuth = adminforth.auth;
126
126
  this.userRepository = new UserRepository(this.adminforth, this.options);
127
127
  this.cookieService = new CookieService(this.adminforth, this.options);
128
- this.totpService = new TotpService(this.options, this.userRepository, this.adminforth.auth);
128
+ this.totpService = new TotpService(this.options, this.userRepository, this.adminforth);
129
129
  this.passkeyService = new PasskeyService(this.options, this.adminforth, this.userRepository, undefined, this.cookieService);
130
130
  this.passkeyLoginRateLimiters = adminforth.config.auth.rateLimit.map((rate) => new RateLimiter(rate));
131
131
  this.ctx = {
@@ -1,15 +1,17 @@
1
- import { type AdminUser, type IAdminForthAuth } from "adminforth";
1
+ import { type AdminUser, type IAdminForth } from "adminforth";
2
2
  import type { PluginOptions } from "../types.js";
3
3
  import { UserRepository } from "../repositories/userRepository.js";
4
4
  import type { VerificationFailure, VerificationResult } from "../utils/types.js";
5
5
  export declare class TotpService {
6
6
  private readonly options;
7
7
  private readonly userRepository;
8
- private readonly auth;
8
+ private readonly adminforth;
9
9
  private readonly codeAttemptLimiters;
10
- constructor(options: PluginOptions, userRepository: UserRepository, auth: IAdminForthAuth);
10
+ private readonly lastUsedCodeSteps;
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
- constructor(options, userRepository, auth) {
18
+ constructor(options, userRepository, adminforth) {
18
19
  this.options = options;
19
20
  this.userRepository = userRepository;
20
- this.auth = auth;
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) {
@@ -48,12 +60,12 @@ export class TotpService {
48
60
  const result = yield this.verifyUserCode(this.userRepository.getUserPk(adminUser), code);
49
61
  if ('error' in result)
50
62
  return result;
51
- return { ok: true, verifiedCodeToken: this.auth.issueJWT({ pk: adminUser.pk }, VERIFIED_CODE_JWT_TYPE, VERIFIED_CODE_TOKEN_TTL) };
63
+ return { ok: true, verifiedCodeToken: this.adminforth.auth.issueJWT({ pk: adminUser.pk }, VERIFIED_CODE_JWT_TYPE, VERIFIED_CODE_TOKEN_TTL) };
52
64
  });
53
65
  }
54
66
  isVerifiedCodeToken(token, userPk) {
55
67
  return __awaiter(this, void 0, void 0, function* () {
56
- const payload = yield this.auth.verify(token, VERIFIED_CODE_JWT_TYPE, false);
68
+ const payload = yield this.adminforth.auth.verify(token, VERIFIED_CODE_JWT_TYPE, false);
57
69
  return (payload === null || payload === void 0 ? void 0 : payload.pk) === userPk;
58
70
  });
59
71
  }
@@ -167,14 +167,10 @@ export function createTwoFaHandlers(ctx: any) {
167
167
  }
168
168
  }
169
169
 
170
- if ( ctx.options.stepUpMfaGracePeriodSeconds ) {
171
- const verificationResult = await ctx.cookieService.isMfaGraceValid(headers, cookies, true);
172
- if ( verificationResult === true ) {
173
- return { skipAllowed: true };
174
- }
175
- }
170
+ const gracePeriod = Boolean(ctx.options.stepUpMfaGracePeriodSeconds)
171
+ && await ctx.cookieService.isMfaGraceValid(headers, cookies, true);
176
172
 
177
- return { skipAllowed: false };
173
+ return { skipAllowed: false, gracePeriod };
178
174
  },
179
175
 
180
176
  verifyTotp: async ({ adminUser, body, response }) => {
package/index.ts CHANGED
@@ -146,7 +146,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
146
146
  this.adminForthAuth = adminforth.auth;
147
147
  this.userRepository = new UserRepository(this.adminforth, this.options);
148
148
  this.cookieService = new CookieService(this.adminforth, this.options);
149
- this.totpService = new TotpService(this.options, this.userRepository, this.adminforth.auth);
149
+ this.totpService = new TotpService(this.options, this.userRepository, this.adminforth);
150
150
  this.passkeyService = new PasskeyService(this.options, this.adminforth, this.userRepository, undefined, this.cookieService);
151
151
  this.passkeyLoginRateLimiters = (adminforth.config.auth as any).rateLimit.map((rate) => new RateLimiter(rate));
152
152
  this.ctx = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/two-factors-auth",
3
- "version": "4.6.0",
3
+ "version": "4.7.1",
4
4
  "description": "AdminForth plugin for two factors authentication",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,4 +1,4 @@
1
- import { RateLimiter, type AdminUser, type IAdminForthAuth } from "adminforth";
1
+ import { RateLimiter, type AdminUser, type IAdminForth } from "adminforth";
2
2
  import twofactor from 'node-2fa';
3
3
  import type { PluginOptions } from "../types.js";
4
4
  import { UserRepository } from "../repositories/userRepository.js";
@@ -8,14 +8,16 @@ 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,
17
19
  private readonly userRepository: UserRepository,
18
- private readonly auth: IAdminForthAuth,
20
+ private readonly adminforth: IAdminForth,
19
21
  ) {}
20
22
 
21
23
  public generateSecret(issuerName: string, userName: string): string {
@@ -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,19 +45,24 @@ 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> {
47
59
  const result = await this.verifyUserCode(this.userRepository.getUserPk(adminUser), code);
48
60
  if ('error' in result) return result;
49
- return { ok: true, verifiedCodeToken: this.auth.issueJWT({ pk: adminUser.pk }, VERIFIED_CODE_JWT_TYPE, VERIFIED_CODE_TOKEN_TTL) };
61
+ return { ok: true, verifiedCodeToken: this.adminforth.auth.issueJWT({ pk: adminUser.pk }, VERIFIED_CODE_JWT_TYPE, VERIFIED_CODE_TOKEN_TTL) };
50
62
  }
51
63
 
52
64
  public async isVerifiedCodeToken(token: string, userPk: string): Promise<boolean> {
53
- const payload = await this.auth.verify(token, VERIFIED_CODE_JWT_TYPE, false);
65
+ const payload = await this.adminforth.auth.verify(token, VERIFIED_CODE_JWT_TYPE, false);
54
66
  return payload?.pk === userPk;
55
67
  }
56
68
 
@@ -17,7 +17,7 @@ function createService() {
17
17
  },
18
18
  verify: async (token, type) => (jwts.get(token)?.type === type ? jwts.get(token) : null),
19
19
  };
20
- return new TotpService({ timeStepWindow: 1 }, userRepository, auth);
20
+ return new TotpService({ timeStepWindow: 1 }, userRepository, { auth });
21
21
  }
22
22
 
23
23
  test('limits code attempts of each user to stop brute force of a stolen session', async () => {
@@ -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
+ });