@adminforth/two-factors-auth 4.5.0 → 4.7.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 +149 -154
- package/custom/TwoFactorsPasskeysSettings.vue +2 -4
- package/custom/use2faApi.ts +8 -3
- package/dist/custom/TwoFAModal.vue +149 -154
- package/dist/custom/TwoFactorsPasskeysSettings.vue +2 -4
- package/dist/custom/use2faApi.ts +8 -3
- package/dist/handlers/createPasskeyHandlers.d.ts +3 -6
- package/dist/handlers/createPasskeyHandlers.js +11 -15
- package/dist/handlers/createTwoFaHandlers.d.ts +4 -0
- package/dist/handlers/createTwoFaHandlers.js +3 -7
- package/dist/index.d.ts +11 -7
- package/dist/index.js +23 -5
- package/dist/services/mfaRequestContext.d.ts +13 -0
- package/dist/services/mfaRequestContext.js +50 -0
- package/dist/services/totpService.d.ts +9 -10
- package/dist/services/totpService.js +27 -5
- package/dist/services/verifyMfaConfirmation.d.ts +7 -7
- package/dist/services/verifyMfaConfirmation.js +23 -20
- package/dist/utils/errors.d.ts +13 -0
- package/dist/utils/errors.js +14 -0
- package/dist/utils/types.d.ts +8 -0
- package/handlers/createPasskeyHandlers.ts +30 -16
- package/handlers/createTwoFaHandlers.ts +3 -7
- package/index.ts +28 -7
- package/package.json +1 -1
- package/services/mfaRequestContext.ts +51 -0
- package/services/totpService.ts +30 -7
- package/services/verifyMfaConfirmation.ts +30 -24
- package/tests/mfaRequestContext.test.mjs +79 -0
- package/tests/totpService.test.mjs +38 -0
- package/utils/errors.ts +14 -0
- package/utils/types.ts +4 -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 311,
|
|
21
|
-
total size is 310,
|
|
20
|
+
sent 311,411 bytes received 248 bytes 623,318.00 bytes/sec
|
|
21
|
+
total size is 310,438 speedup is 1.00
|
package/custom/TwoFAModal.vue
CHANGED
|
@@ -1,99 +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
|
|
5
|
-
<
|
|
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-
|
|
21
|
-
<p
|
|
22
|
-
<p class="af-2fa-
|
|
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
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
47
|
+
|
|
48
|
+
<p v-if="modalMode === 'totp' && doesUserHavePasskeys" class="af-2fa-totp-footer text-center text-xs text-gray-500 dark:text-gray-400">
|
|
41
49
|
{{$t('Having trouble?')}}
|
|
42
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>
|
|
43
51
|
</p>
|
|
44
|
-
<p class="af-2fa-
|
|
45
|
-
{{ $t('You are confirming {count} action | You are confirming {count} actions', sessionsIdsToResolve.length) }}
|
|
46
|
-
</p>
|
|
47
|
-
</div>
|
|
48
|
-
|
|
49
|
-
<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">
|
|
50
|
-
<button
|
|
51
|
-
type="button"
|
|
52
|
-
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"
|
|
53
|
-
@click="onCancel"
|
|
54
|
-
>
|
|
55
|
-
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
|
56
|
-
<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"/>
|
|
57
|
-
</svg>
|
|
58
|
-
<span class="sr-only">{{$t('Close modal')}}</span>
|
|
59
|
-
</button>
|
|
60
|
-
|
|
61
|
-
<div class="af-2fa-passkey-header flex flex-col items-center justify-center gap-3">
|
|
62
|
-
<div class="af-2fa-icon-wrap w-14 h-14 shrink-0 flex items-center justify-center rounded-full bg-lightPrimary dark:bg-darkPrimary">
|
|
63
|
-
<IconShieldOutline class="af-2fa-shield-icon w-7 h-7 text-white" />
|
|
64
|
-
</div>
|
|
65
|
-
<div class="af-2fa-passkey-title-wrap">
|
|
66
|
-
<p class="af-2fa-passkey-title text-xl text-center font-medium text-gray-900 dark:text-white">{{$t('Verify to add passkey')}}</p>
|
|
67
|
-
<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>
|
|
68
|
-
</div>
|
|
69
|
-
</div>
|
|
70
|
-
|
|
71
|
-
<div class="af-2fa-passkey-steps flex flex-col gap-2 mt-2">
|
|
72
|
-
<div
|
|
73
|
-
v-for="(step, i) in [
|
|
74
|
-
$t('Click the button below to begin'),
|
|
75
|
-
$t('Authenticate with your device biometrics or PIN'),
|
|
76
|
-
$t('Your passkey will be saved automatically'),
|
|
77
|
-
]"
|
|
78
|
-
:key="i"
|
|
79
|
-
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"
|
|
80
|
-
>
|
|
81
|
-
<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>
|
|
82
|
-
{{ step }}
|
|
83
|
-
</div>
|
|
84
|
-
</div>
|
|
85
|
-
|
|
86
|
-
<Button @click="usePasskeyButtonClick" :disabled="isFetchingPasskey" :loader="isFetchingPasskey" class="af-2fa-passkey-btn w-full flex items-center justify-center gap-2 mt-2">
|
|
87
|
-
<IconShieldOutline class="w-4 h-4" />
|
|
88
|
-
{{$t('Use passkey to verify')}}
|
|
89
|
-
</Button>
|
|
90
|
-
<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">
|
|
91
53
|
{{$t('Having trouble?')}}
|
|
92
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>
|
|
93
55
|
</p>
|
|
94
|
-
<p class="af-2fa-multiple-actions text-center text-red-500 text-xs" v-if="sessionsIdsToResolve.length > 1">
|
|
95
|
-
{{ $t('You are confirming {count} action | You are confirming {count} actions', sessionsIdsToResolve.length) }}
|
|
96
|
-
</p>
|
|
97
56
|
</div>
|
|
98
57
|
</div>
|
|
99
58
|
</template>
|
|
@@ -101,7 +60,7 @@
|
|
|
101
60
|
<script setup lang="ts">
|
|
102
61
|
|
|
103
62
|
import VOtpInput from 'vue3-otp-input';
|
|
104
|
-
import { ref, nextTick, watch } from 'vue';
|
|
63
|
+
import { ref, computed, nextTick, watch } from 'vue';
|
|
105
64
|
import { useUserStore } from '@/stores/user';
|
|
106
65
|
import { useI18n } from 'vue-i18n';
|
|
107
66
|
import { callAdminForthApi } from '@/utils';
|
|
@@ -112,7 +71,7 @@
|
|
|
112
71
|
import websocket from '@/websocket';
|
|
113
72
|
import { getAdminForthClientId } from '@/utils/clientId';
|
|
114
73
|
import type { AdminUser } from '@/types/Common';
|
|
115
|
-
import { useTwoFactorsAuthApi, type TwoFaConfirmationResult } from './use2faApi.js';
|
|
74
|
+
import { useTwoFactorsAuthApi, type TwoFaConfirmationResult, type TwoFaDialogContent } from './use2faApi.js';
|
|
116
75
|
const props = defineProps<{
|
|
117
76
|
autoFinishLogin?: boolean
|
|
118
77
|
adminUser?: AdminUser
|
|
@@ -121,10 +80,15 @@
|
|
|
121
80
|
const { alert } = useAdminforth();
|
|
122
81
|
const twoFactorsAuthApi = useTwoFactorsAuthApi();
|
|
123
82
|
|
|
83
|
+
type AutoVerifySession = { sessionId: string; actionDescription?: string };
|
|
84
|
+
|
|
124
85
|
const isAwaiting2FAResult = ref(false);
|
|
125
86
|
let allowAddNewSessions = true;
|
|
126
87
|
const ALLOW_NEW_SESSIONS_PERIOD = 1000;
|
|
127
|
-
const
|
|
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[] = [];
|
|
128
92
|
|
|
129
93
|
watch(isAwaiting2FAResult, (awaiting) => {
|
|
130
94
|
if (awaiting) {
|
|
@@ -141,54 +105,63 @@
|
|
|
141
105
|
if (props.adminUser) {
|
|
142
106
|
websocket.unsubscribeByPrefix(`/user2fa/`);
|
|
143
107
|
const user2FaTopic = `/user2fa/${props.adminUser.pk}/${getAdminForthClientId()}`;
|
|
144
|
-
websocket.subscribe(user2FaTopic, async (
|
|
108
|
+
websocket.subscribe(user2FaTopic, async (session: AutoVerifySession) => {
|
|
145
109
|
if (!allowAddNewSessions) {
|
|
146
110
|
alert({message: t('Some process or user tries to add new actions to confirm. Action was blocked'), variant: 'warning'});
|
|
147
111
|
return;
|
|
148
112
|
}
|
|
149
|
-
|
|
150
|
-
|
|
113
|
+
if (sessionsToResolve.value.length < MAX_ACTIONS_PER_CONFIRMATION) {
|
|
114
|
+
sessionsToResolve.value.push(session);
|
|
115
|
+
} else {
|
|
116
|
+
queuedSessions.push(session);
|
|
117
|
+
}
|
|
151
118
|
if (isAwaiting2FAResult.value) {
|
|
152
119
|
return;
|
|
153
120
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
} catch (error) {
|
|
158
|
-
if (!isTwoFaCancel(error)) {
|
|
159
|
-
console.error('[AdminForth 2FA] Error during 2FA confirmation', error);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
isAwaiting2FAResult.value = false;
|
|
163
|
-
try {
|
|
164
|
-
const response = await callAdminForthApi({
|
|
165
|
-
method: "POST",
|
|
166
|
-
path: "/plugin/passkeys/resolveVerifyAuto",
|
|
167
|
-
body: { confirmationResult, sessionsIds: sessionsIdsToResolve.value }
|
|
168
|
-
});
|
|
169
|
-
if (!response.ok && response.code === 'VERIFICATION_CANCELLED'){
|
|
170
|
-
alert({message: t('Verification session finished or cancelled.'), variant: 'warning'});
|
|
171
|
-
} else if (!response.ok) {
|
|
172
|
-
alert({message: response.error || t('Verification failed'), variant: 'danger'});
|
|
173
|
-
} else if (response.ok) {
|
|
174
|
-
// alert({message: 'Verification successful', variant: 'success'});
|
|
175
|
-
}
|
|
176
|
-
sessionsIdsToResolve.value = [];
|
|
177
|
-
} catch (error) {
|
|
178
|
-
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);
|
|
179
124
|
}
|
|
180
125
|
allowAddNewSessions = true;
|
|
181
126
|
});
|
|
182
127
|
websocket.subscribe(`${user2FaTopic}/resolve`, async (data: {sessionId: string}) => {
|
|
183
|
-
|
|
128
|
+
queuedSessions = queuedSessions.filter((session) => session.sessionId !== data.sessionId);
|
|
129
|
+
if (sessionsToResolve.value.some((session) => session.sessionId === data.sessionId) && rejectFn && modelShow.value) {
|
|
184
130
|
onCancel();
|
|
185
|
-
|
|
131
|
+
sessionsToResolve.value = sessionsToResolve.value.filter((session) => session.sessionId !== data.sessionId);
|
|
186
132
|
}
|
|
187
133
|
});
|
|
188
134
|
}
|
|
189
135
|
}, { immediate: true });
|
|
190
136
|
|
|
191
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
|
+
|
|
192
165
|
const emit = defineEmits<{
|
|
193
166
|
(e: 'resolved', payload: any): void
|
|
194
167
|
(e: 'rejected', err?: any): void
|
|
@@ -225,22 +198,27 @@
|
|
|
225
198
|
|
|
226
199
|
function get2FaConfirmationResult(
|
|
227
200
|
title?: string,
|
|
228
|
-
verifyingCallback?: (confirmationResult: string) => Promise<boolean> | boolean
|
|
201
|
+
verifyingCallback?: (confirmationResult: string) => Promise<boolean> | boolean,
|
|
202
|
+
{ details, items }: TwoFaDialogContent = {},
|
|
229
203
|
): Promise<TwoFaConfirmationResult> {
|
|
230
204
|
return new Promise((resolve, reject) => {
|
|
231
205
|
(async () => {
|
|
232
206
|
if (modelShow.value) {
|
|
233
207
|
throw new Error(t('Modal is already open'));
|
|
234
208
|
}
|
|
235
|
-
const
|
|
236
|
-
if (
|
|
209
|
+
const { skipAllowed, gracePeriod } = await checkIfSkipAllowModal();
|
|
210
|
+
if (skipAllowed) {
|
|
237
211
|
resolve({});
|
|
238
212
|
return;
|
|
239
213
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
214
|
+
if (gracePeriod) {
|
|
215
|
+
modalMode.value = 'grace';
|
|
216
|
+
} else {
|
|
217
|
+
await checkIfUserHasPasskeys();
|
|
243
218
|
}
|
|
219
|
+
customDialogTitle.value = title ?? '';
|
|
220
|
+
dialogDetails.value = details ?? '';
|
|
221
|
+
dialogItems.value = items ?? [];
|
|
244
222
|
resolveFn = resolve;
|
|
245
223
|
rejectFn = reject;
|
|
246
224
|
verifyFn = verifyingCallback ?? null;
|
|
@@ -267,11 +245,31 @@
|
|
|
267
245
|
const otpRoot = ref<HTMLElement | null>(null);
|
|
268
246
|
const bindValue = ref('');
|
|
269
247
|
const doesUserHavePasskeys = ref(false);
|
|
270
|
-
const modalMode = ref<"totp" | "passkey">("totp");
|
|
248
|
+
const modalMode = ref<"totp" | "passkey" | "grace">("totp");
|
|
271
249
|
const isLoading = ref(false);
|
|
272
250
|
const isFetchingPasskey = ref(false);
|
|
273
251
|
const customDialogTitle = ref("");
|
|
274
|
-
|
|
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"));
|
|
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
|
+
}
|
|
272
|
+
|
|
275
273
|
async function usePasskeyButtonClick() {
|
|
276
274
|
isFetchingPasskey.value = true;
|
|
277
275
|
let passkeyData;
|
|
@@ -322,32 +320,41 @@
|
|
|
322
320
|
|
|
323
321
|
async function sendConfirmationResult(value: string) {
|
|
324
322
|
if (!resolveFn) throw new Error(t('Modal is not initialized properly'));
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
323
|
+
let checked: { error: string } | { result: TwoFaConfirmationResult };
|
|
324
|
+
try {
|
|
325
|
+
checked = await checkCode(value);
|
|
326
|
+
} catch (err) {
|
|
327
|
+
rejectFn?.(err);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if ('error' in checked) {
|
|
331
|
+
codeError.value = checked.error;
|
|
332
|
+
bindValue.value = '';
|
|
333
|
+
confirmationResult.value?.clearInput();
|
|
334
|
+
return;
|
|
336
335
|
}
|
|
337
336
|
|
|
338
337
|
modelShow.value = false;
|
|
339
|
-
|
|
340
|
-
mode: "totp",
|
|
341
|
-
result: value
|
|
342
|
-
}
|
|
338
|
+
codeError.value = "";
|
|
343
339
|
customDialogTitle.value = "";
|
|
344
340
|
removeEventListenerForOTPInput();
|
|
345
|
-
resolveFn(
|
|
341
|
+
resolveFn(checked.result);
|
|
346
342
|
}
|
|
347
343
|
|
|
348
344
|
|
|
345
|
+
async function checkCode(code: string): Promise<{ error: string } | { result: TwoFaConfirmationResult }> {
|
|
346
|
+
if (verifyFn) {
|
|
347
|
+
return (await verifyFn(code)) ? { result: { mode: 'totp', result: code } } : { error: t('Wrong or expired OTP code') };
|
|
348
|
+
}
|
|
349
|
+
const response = await callAdminForthApi({ method: 'POST', path: '/plugin/twofa/verify', body: { code } });
|
|
350
|
+
return response?.ok
|
|
351
|
+
? { result: { mode: 'verifiedTotp', result: response.verifiedCodeToken } }
|
|
352
|
+
: { error: response?.error ?? t('Wrong or expired OTP code') };
|
|
353
|
+
}
|
|
354
|
+
|
|
349
355
|
function onCancel() {
|
|
350
356
|
modelShow.value = false;
|
|
357
|
+
codeError.value = "";
|
|
351
358
|
bindValue.value = '';
|
|
352
359
|
confirmationResult.value?.clearInput();
|
|
353
360
|
removeEventListenerForOTPInput();
|
|
@@ -401,15 +408,8 @@
|
|
|
401
408
|
path: '/plugin/passkeys/getPasskeys',
|
|
402
409
|
});
|
|
403
410
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
doesUserHavePasskeys.value = true;
|
|
407
|
-
modalMode.value = "passkey";
|
|
408
|
-
} else {
|
|
409
|
-
doesUserHavePasskeys.value = false;
|
|
410
|
-
modalMode.value = "totp";
|
|
411
|
-
}
|
|
412
|
-
}
|
|
411
|
+
doesUserHavePasskeys.value = Boolean(response.ok && response.data.length);
|
|
412
|
+
modalMode.value = doesUserHavePasskeys.value ? "passkey" : "totp";
|
|
413
413
|
} catch (error) {
|
|
414
414
|
console.error('Error checking passkeys:', error);
|
|
415
415
|
// Fallback to TOTP if there's an error
|
|
@@ -461,20 +461,15 @@
|
|
|
461
461
|
}
|
|
462
462
|
|
|
463
463
|
|
|
464
|
-
async function checkIfSkipAllowModal(){
|
|
464
|
+
async function checkIfSkipAllowModal(): Promise<{ skipAllowed: boolean; gracePeriod?: boolean }> {
|
|
465
465
|
try {
|
|
466
|
-
|
|
466
|
+
return await callAdminForthApi({
|
|
467
467
|
method: "GET",
|
|
468
468
|
path: "/plugin/twofa/skip-allow-modal",
|
|
469
469
|
});
|
|
470
|
-
if ( response.skipAllowed === true ) {
|
|
471
|
-
return true;
|
|
472
|
-
} else {
|
|
473
|
-
return false;
|
|
474
|
-
}
|
|
475
470
|
} catch (error) {
|
|
476
471
|
console.error('Error checking skip allow modal:', error);
|
|
477
|
-
return false;
|
|
472
|
+
return { skipAllowed: false };
|
|
478
473
|
}
|
|
479
474
|
}
|
|
480
475
|
|
|
@@ -130,7 +130,7 @@
|
|
|
130
130
|
import { Dialog, Tooltip, Spinner, Input } from '@/afcl';
|
|
131
131
|
import { IconTrashBinSolid, IconPenSolid, IconPlusOutline, IconInfoCircleOutline } from '@iconify-prerendered/vue-flowbite';
|
|
132
132
|
import { useCoreStore } from '@/stores/core';
|
|
133
|
-
import { useTwoFactorsAuth } from './use2faApi';
|
|
133
|
+
import { useTwoFactorsAuth, type TwoFaConfirmationResult } from './use2faApi';
|
|
134
134
|
|
|
135
135
|
const { t } = useI18n();
|
|
136
136
|
const coreStore = useCoreStore();
|
|
@@ -215,9 +215,7 @@
|
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
async function requestPasskeyChallenge(confirmationResult: ConfirmationResult) {
|
|
218
|
+
async function requestPasskeyChallenge(confirmationResult: TwoFaConfirmationResult) {
|
|
221
219
|
try {
|
|
222
220
|
const response = await callAdminForthApi({
|
|
223
221
|
path: '/plugin/passkeys/registrationOptions',
|
package/custom/use2faApi.ts
CHANGED
|
@@ -3,13 +3,17 @@ import { defineStore } from 'pinia';
|
|
|
3
3
|
|
|
4
4
|
export type TwoFaConfirmationResult =
|
|
5
5
|
| { mode: 'totp'; result: string }
|
|
6
|
+
| { mode: 'verifiedTotp'; result: string }
|
|
6
7
|
| { mode: 'passkey'; result: Record<string, any> }
|
|
7
8
|
| { mode?: undefined };
|
|
8
9
|
|
|
10
|
+
export type TwoFaDialogContent = { details?: string; items?: string[] };
|
|
11
|
+
|
|
9
12
|
type VerifyFn = (confirmationResult: string) => Promise<boolean> | boolean;
|
|
10
13
|
type Get2FaConfirmationResultHandler = (
|
|
11
14
|
title?: string,
|
|
12
|
-
verifyingCallback?: VerifyFn
|
|
15
|
+
verifyingCallback?: VerifyFn,
|
|
16
|
+
content?: TwoFaDialogContent,
|
|
13
17
|
) => Promise<TwoFaConfirmationResult>;
|
|
14
18
|
|
|
15
19
|
declare global {
|
|
@@ -29,12 +33,13 @@ export const useTwoFactorsAuthApi = defineStore('twoFactorsAuthApi', () => {
|
|
|
29
33
|
|
|
30
34
|
async function get2FaConfirmationResult(
|
|
31
35
|
title?: string,
|
|
32
|
-
verifyingCallback?: VerifyFn
|
|
36
|
+
verifyingCallback?: VerifyFn,
|
|
37
|
+
content?: TwoFaDialogContent,
|
|
33
38
|
): Promise<TwoFaConfirmationResult> {
|
|
34
39
|
if (!get2FaConfirmationResultHandler.value) {
|
|
35
40
|
throw new Error('Two factors auth modal API is not initialized');
|
|
36
41
|
}
|
|
37
|
-
return get2FaConfirmationResultHandler.value(title, verifyingCallback);
|
|
42
|
+
return get2FaConfirmationResultHandler.value(title, verifyingCallback, content);
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
function initGlobalApi() {
|