@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 +2 -2
- package/custom/TwoFAModal.vue +125 -60
- package/dist/custom/TwoFAModal.vue +125 -60
- package/dist/services/totpService.d.ts +2 -0
- package/dist/services/totpService.js +16 -4
- package/package.json +1 -1
- package/services/totpService.ts +16 -4
- package/tests/totpService.test.mjs +27 -0
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
|
|
21
|
-
total size is
|
|
20
|
+
sent 315,618 bytes received 248 bytes 631,732.00 bytes/sec
|
|
21
|
+
total size is 314,645 speedup is 1.00
|
package/custom/TwoFAModal.vue
CHANGED
|
@@ -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
|
|
5
|
-
<div class="af-2fa-header flex
|
|
6
|
-
<div class="af-2fa-icon-wrap w-
|
|
7
|
-
<
|
|
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
|
|
10
|
-
<p class="af-2fa-title text-
|
|
11
|
-
<p class="af-2fa-subtitle
|
|
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
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 {
|
|
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(
|
|
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(() =>
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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('
|
|
323
|
+
totp: t('Enter the 6-digit code from your authenticator app'),
|
|
264
324
|
passkey: t('Confirm with your passkey'),
|
|
265
|
-
grace: t('
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
|
5
|
-
<div class="af-2fa-header flex
|
|
6
|
-
<div class="af-2fa-icon-wrap w-
|
|
7
|
-
<
|
|
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
|
|
10
|
-
<p class="af-2fa-title text-
|
|
11
|
-
<p class="af-2fa-subtitle
|
|
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
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 {
|
|
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(
|
|
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(() =>
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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('
|
|
323
|
+
totp: t('Enter the 6-digit code from your authenticator app'),
|
|
264
324
|
passkey: t('Confirm with your passkey'),
|
|
265
|
-
grace: t('
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
package/services/totpService.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
});
|