@adminforth/two-factors-auth 2.1.1 → 2.1.2
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
|
@@ -13,5 +13,5 @@ custom/package.json
|
|
|
13
13
|
custom/tsconfig.json
|
|
14
14
|
custom/utils.js
|
|
15
15
|
|
|
16
|
-
sent
|
|
17
|
-
total size is
|
|
16
|
+
sent 74,931 bytes received 172 bytes 150,206.00 bytes/sec
|
|
17
|
+
total size is 74,291 speedup is 0.99
|
|
@@ -98,6 +98,9 @@
|
|
|
98
98
|
import { IconShieldOutline } from '@iconify-prerendered/vue-flowbite';
|
|
99
99
|
import ErrorMessage from '@/components/ErrorMessage.vue';
|
|
100
100
|
import { handlePasskeyAlert } from './utils.js';
|
|
101
|
+
|
|
102
|
+
// Global provided by AdminForth runtime
|
|
103
|
+
declare const adminforth: any;
|
|
101
104
|
|
|
102
105
|
const { t } = useI18n();
|
|
103
106
|
const code = ref(null);
|
|
@@ -108,6 +111,29 @@
|
|
|
108
111
|
const codeError = ref(null);
|
|
109
112
|
const isFetchingPasskey = ref(false);
|
|
110
113
|
|
|
114
|
+
// Shared WebAuthn state to prevent concurrent navigator.credentials.get calls
|
|
115
|
+
const webAuthn = {
|
|
116
|
+
controller: null as AbortController | null,
|
|
117
|
+
inFlight: false,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
function cancelPendingWebAuthn(reason?: string) {
|
|
121
|
+
try {
|
|
122
|
+
if (webAuthn.controller) {
|
|
123
|
+
// Abort any pending WebAuthn request
|
|
124
|
+
webAuthn.controller.abort();
|
|
125
|
+
}
|
|
126
|
+
} catch (e) {
|
|
127
|
+
// no-op
|
|
128
|
+
} finally {
|
|
129
|
+
webAuthn.controller = null;
|
|
130
|
+
webAuthn.inFlight = false;
|
|
131
|
+
if (reason) {
|
|
132
|
+
console.debug('[WebAuthn] Aborted pending request:', reason);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
111
137
|
onBeforeMount(() => {
|
|
112
138
|
if (localStorage.getItem('isAuthorized') === 'true') {
|
|
113
139
|
coreStore.fetchMenuAndResource();
|
|
@@ -147,11 +173,16 @@
|
|
|
147
173
|
|
|
148
174
|
onMounted(async () => {
|
|
149
175
|
if (localStorage.getItem('isAuthorized') !== 'true') {
|
|
176
|
+
// Safety: ensure no pending WebAuthn request survives SPA navigation
|
|
177
|
+
cancelPendingWebAuthn('mount-init');
|
|
150
178
|
await nextTick();
|
|
151
179
|
await isCMAAvailable();
|
|
152
180
|
tagOtpInputs();
|
|
181
|
+
console.log("Checking if device supports passkeys:", isPasskeysSupported.value);
|
|
153
182
|
if (isPasskeysSupported.value === true) {
|
|
183
|
+
console.log("Device supports passkeys, checking if user has passkeys...");
|
|
154
184
|
await checkIfUserHasPasskeys();
|
|
185
|
+
console.log("Does user have passkeys:", doesUserHavePasskeys.value);
|
|
155
186
|
}
|
|
156
187
|
document.addEventListener('focusin', handleGlobalFocusIn, true);
|
|
157
188
|
focusFirstAvailableOtpInput();
|
|
@@ -164,8 +195,10 @@
|
|
|
164
195
|
watch(route, (newRoute) => {
|
|
165
196
|
codeError.value = null;
|
|
166
197
|
if ( newRoute.hash === '#passkey' ) {
|
|
198
|
+
cancelPendingWebAuthn('switch-to-passkey');
|
|
167
199
|
confirmationMode.value = 'passkey';
|
|
168
200
|
} else if ( newRoute.hash === '#code' ) {
|
|
201
|
+
cancelPendingWebAuthn('switch-to-code');
|
|
169
202
|
confirmationMode.value = 'code';
|
|
170
203
|
}
|
|
171
204
|
});
|
|
@@ -185,11 +218,15 @@
|
|
|
185
218
|
document.removeEventListener('focusin', handleGlobalFocusIn, true);
|
|
186
219
|
const rootEl = otpRoot.value;
|
|
187
220
|
rootEl && rootEl.removeEventListener('focusout', handleFocusOut, true);
|
|
221
|
+
// Abort any in-flight WebAuthn request when leaving the component
|
|
222
|
+
cancelPendingWebAuthn('component-unmount');
|
|
188
223
|
});
|
|
189
224
|
|
|
190
225
|
async function sendCode (value: any, factorMode: 'TOTP' | 'passkey', passkeyOptions: any) {
|
|
191
226
|
inProgress.value = true;
|
|
192
227
|
const usePasskey = factorMode === 'passkey';
|
|
228
|
+
console.log("Sending code with factorMode:", factorMode);
|
|
229
|
+
console.log("Passkey options:", passkeyOptions);
|
|
193
230
|
const resp = await callAdminForthApi({
|
|
194
231
|
method: 'POST',
|
|
195
232
|
path: '/plugin/twofa/confirmLogin',
|
|
@@ -200,12 +237,15 @@
|
|
|
200
237
|
secret: null,
|
|
201
238
|
}
|
|
202
239
|
})
|
|
240
|
+
console.log("Response from confirmLogin:", resp);
|
|
203
241
|
if ( resp.allowedLogin ) {
|
|
204
242
|
if ( route.meta.isPasskeysEnabled && !doesUserHavePasskeys.value ) {
|
|
205
243
|
handlePasskeyAlert(route.meta.suggestionPeriod, router);
|
|
206
244
|
}
|
|
245
|
+
console.log("Login confirmed, finishing login...");
|
|
207
246
|
await user.finishLogin();
|
|
208
247
|
} else {
|
|
248
|
+
console.log("Login not allowed, showing error:", resp.error);
|
|
209
249
|
if (usePasskey) {
|
|
210
250
|
showErrorTost(t(resp.error));
|
|
211
251
|
codeError.value = resp.error || t('Passkey authentication failed');
|
|
@@ -249,8 +289,16 @@
|
|
|
249
289
|
}
|
|
250
290
|
|
|
251
291
|
async function usePasskeyButton() {
|
|
292
|
+
// Cancel any stray pending requests from earlier flows
|
|
293
|
+
cancelPendingWebAuthn('button-pressed');
|
|
294
|
+
codeError.value = null;
|
|
252
295
|
isFetchingPasskey.value = true;
|
|
253
|
-
const
|
|
296
|
+
const signIn = await createSignInRequest();
|
|
297
|
+
if (!signIn) {
|
|
298
|
+
isFetchingPasskey.value = false;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const { _options, challengeId } = signIn;
|
|
254
302
|
const options = PublicKeyCredential.parseRequestOptionsFromJSON(_options);
|
|
255
303
|
const credential = await authenticate(options);
|
|
256
304
|
if (!credential) {
|
|
@@ -269,15 +317,18 @@
|
|
|
269
317
|
|
|
270
318
|
async function createSignInRequest() {
|
|
271
319
|
let response;
|
|
320
|
+
console.log("Creating sign-in request for passkey...");
|
|
272
321
|
try {
|
|
273
322
|
response = await callAdminForthApi({
|
|
274
323
|
path: `/plugin/passkeys/signInRequest`,
|
|
275
324
|
method: 'POST',
|
|
276
325
|
});
|
|
277
326
|
} catch (error) {
|
|
327
|
+
console.log("Error creating sign-in request:", error);
|
|
278
328
|
console.error('Error creating sign-in request:', error);
|
|
279
329
|
return;
|
|
280
330
|
}
|
|
331
|
+
console.log("Sign-in request response:", response);
|
|
281
332
|
if (response.ok === true) {
|
|
282
333
|
return { _options: response.data, challengeId: response.challengeId };
|
|
283
334
|
} else {
|
|
@@ -287,25 +338,54 @@
|
|
|
287
338
|
}
|
|
288
339
|
|
|
289
340
|
async function authenticate(options) {
|
|
341
|
+
console.log("Authenticating with options:", options);
|
|
290
342
|
try {
|
|
343
|
+
// Guard: prevent concurrent navigator.credentials.get calls
|
|
344
|
+
if (webAuthn.inFlight) {
|
|
345
|
+
console.warn('[WebAuthn] A request is already in flight. Aborting previous and retrying.');
|
|
346
|
+
cancelPendingWebAuthn('pre-auth-guard');
|
|
347
|
+
}
|
|
348
|
+
|
|
291
349
|
const abortController = new AbortController();
|
|
350
|
+
webAuthn.controller = abortController;
|
|
351
|
+
webAuthn.inFlight = true;
|
|
352
|
+
|
|
292
353
|
const credential = await navigator.credentials.get({
|
|
293
354
|
publicKey: options,
|
|
294
355
|
signal: abortController.signal,
|
|
356
|
+
// mediation can be set if using conditional UI, omitted here intentionally
|
|
295
357
|
});
|
|
358
|
+
console.log("Credential obtained:", credential);
|
|
296
359
|
return credential;
|
|
297
360
|
} catch (error) {
|
|
298
361
|
console.error('Error during authentication:', error);
|
|
362
|
+
// Handle specific concurrent/pending request error cases gracefully
|
|
363
|
+
const name = (error && (error.name || error.constructor?.name)) || '';
|
|
364
|
+
const message = (error && error.message) || '';
|
|
365
|
+
if (name === 'AbortError') {
|
|
366
|
+
// Aborted intentionally; no user-facing error needed
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
if (name === 'InvalidStateError' || name === 'OperationError' || /pending/i.test(message)) {
|
|
370
|
+
adminforth.alert({ message: t('Another security prompt is already open. Please try again.'), variant: 'warning' });
|
|
371
|
+
codeError.value = t('A previous passkey attempt was still pending. Please try again.');
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
299
374
|
adminforth.alert({message: `Error during authentication: ${error}`, variant: 'warning'});
|
|
300
375
|
codeError.value = 'Error during authentication.';
|
|
301
376
|
}
|
|
377
|
+
finally {
|
|
378
|
+
// Clear in-flight state regardless of outcome
|
|
379
|
+
webAuthn.inFlight = false;
|
|
380
|
+
webAuthn.controller = null;
|
|
381
|
+
}
|
|
302
382
|
}
|
|
303
383
|
|
|
304
384
|
|
|
305
|
-
function getOtpInputs() {
|
|
385
|
+
function getOtpInputs(): HTMLInputElement[] {
|
|
306
386
|
const root = otpRoot.value;
|
|
307
387
|
if (!root) return [];
|
|
308
|
-
return Array.from(root.querySelectorAll('input.otp-input'));
|
|
388
|
+
return Array.from(root.querySelectorAll('input.otp-input')) as HTMLInputElement[];
|
|
309
389
|
}
|
|
310
390
|
|
|
311
391
|
function focusFirstAvailableOtpInput() {
|
|
@@ -318,7 +398,7 @@
|
|
|
318
398
|
function handleGlobalFocusIn(event) {
|
|
319
399
|
const inputs = getOtpInputs();
|
|
320
400
|
if (!inputs.length) return;
|
|
321
|
-
const target = event.target;
|
|
401
|
+
const target = event.target as HTMLInputElement | null;
|
|
322
402
|
if (!target) return;
|
|
323
403
|
if (!inputs.includes(target)) {
|
|
324
404
|
requestAnimationFrame(() => {
|
|
@@ -331,8 +411,9 @@
|
|
|
331
411
|
requestAnimationFrame(() => {
|
|
332
412
|
const inputs = getOtpInputs();
|
|
333
413
|
if (!inputs.length) return;
|
|
334
|
-
const active = document.activeElement;
|
|
335
|
-
|
|
414
|
+
const active = document.activeElement as Element | null;
|
|
415
|
+
const activeInput = active instanceof HTMLInputElement ? active : null;
|
|
416
|
+
if (!activeInput || !inputs.includes(activeInput)) {
|
|
336
417
|
focusFirstAvailableOtpInput();
|
|
337
418
|
}
|
|
338
419
|
});
|
|
@@ -98,6 +98,9 @@
|
|
|
98
98
|
import { IconShieldOutline } from '@iconify-prerendered/vue-flowbite';
|
|
99
99
|
import ErrorMessage from '@/components/ErrorMessage.vue';
|
|
100
100
|
import { handlePasskeyAlert } from './utils.js';
|
|
101
|
+
|
|
102
|
+
// Global provided by AdminForth runtime
|
|
103
|
+
declare const adminforth: any;
|
|
101
104
|
|
|
102
105
|
const { t } = useI18n();
|
|
103
106
|
const code = ref(null);
|
|
@@ -108,6 +111,29 @@
|
|
|
108
111
|
const codeError = ref(null);
|
|
109
112
|
const isFetchingPasskey = ref(false);
|
|
110
113
|
|
|
114
|
+
// Shared WebAuthn state to prevent concurrent navigator.credentials.get calls
|
|
115
|
+
const webAuthn = {
|
|
116
|
+
controller: null as AbortController | null,
|
|
117
|
+
inFlight: false,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
function cancelPendingWebAuthn(reason?: string) {
|
|
121
|
+
try {
|
|
122
|
+
if (webAuthn.controller) {
|
|
123
|
+
// Abort any pending WebAuthn request
|
|
124
|
+
webAuthn.controller.abort();
|
|
125
|
+
}
|
|
126
|
+
} catch (e) {
|
|
127
|
+
// no-op
|
|
128
|
+
} finally {
|
|
129
|
+
webAuthn.controller = null;
|
|
130
|
+
webAuthn.inFlight = false;
|
|
131
|
+
if (reason) {
|
|
132
|
+
console.debug('[WebAuthn] Aborted pending request:', reason);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
111
137
|
onBeforeMount(() => {
|
|
112
138
|
if (localStorage.getItem('isAuthorized') === 'true') {
|
|
113
139
|
coreStore.fetchMenuAndResource();
|
|
@@ -147,11 +173,16 @@
|
|
|
147
173
|
|
|
148
174
|
onMounted(async () => {
|
|
149
175
|
if (localStorage.getItem('isAuthorized') !== 'true') {
|
|
176
|
+
// Safety: ensure no pending WebAuthn request survives SPA navigation
|
|
177
|
+
cancelPendingWebAuthn('mount-init');
|
|
150
178
|
await nextTick();
|
|
151
179
|
await isCMAAvailable();
|
|
152
180
|
tagOtpInputs();
|
|
181
|
+
console.log("Checking if device supports passkeys:", isPasskeysSupported.value);
|
|
153
182
|
if (isPasskeysSupported.value === true) {
|
|
183
|
+
console.log("Device supports passkeys, checking if user has passkeys...");
|
|
154
184
|
await checkIfUserHasPasskeys();
|
|
185
|
+
console.log("Does user have passkeys:", doesUserHavePasskeys.value);
|
|
155
186
|
}
|
|
156
187
|
document.addEventListener('focusin', handleGlobalFocusIn, true);
|
|
157
188
|
focusFirstAvailableOtpInput();
|
|
@@ -164,8 +195,10 @@
|
|
|
164
195
|
watch(route, (newRoute) => {
|
|
165
196
|
codeError.value = null;
|
|
166
197
|
if ( newRoute.hash === '#passkey' ) {
|
|
198
|
+
cancelPendingWebAuthn('switch-to-passkey');
|
|
167
199
|
confirmationMode.value = 'passkey';
|
|
168
200
|
} else if ( newRoute.hash === '#code' ) {
|
|
201
|
+
cancelPendingWebAuthn('switch-to-code');
|
|
169
202
|
confirmationMode.value = 'code';
|
|
170
203
|
}
|
|
171
204
|
});
|
|
@@ -185,11 +218,15 @@
|
|
|
185
218
|
document.removeEventListener('focusin', handleGlobalFocusIn, true);
|
|
186
219
|
const rootEl = otpRoot.value;
|
|
187
220
|
rootEl && rootEl.removeEventListener('focusout', handleFocusOut, true);
|
|
221
|
+
// Abort any in-flight WebAuthn request when leaving the component
|
|
222
|
+
cancelPendingWebAuthn('component-unmount');
|
|
188
223
|
});
|
|
189
224
|
|
|
190
225
|
async function sendCode (value: any, factorMode: 'TOTP' | 'passkey', passkeyOptions: any) {
|
|
191
226
|
inProgress.value = true;
|
|
192
227
|
const usePasskey = factorMode === 'passkey';
|
|
228
|
+
console.log("Sending code with factorMode:", factorMode);
|
|
229
|
+
console.log("Passkey options:", passkeyOptions);
|
|
193
230
|
const resp = await callAdminForthApi({
|
|
194
231
|
method: 'POST',
|
|
195
232
|
path: '/plugin/twofa/confirmLogin',
|
|
@@ -200,12 +237,15 @@
|
|
|
200
237
|
secret: null,
|
|
201
238
|
}
|
|
202
239
|
})
|
|
240
|
+
console.log("Response from confirmLogin:", resp);
|
|
203
241
|
if ( resp.allowedLogin ) {
|
|
204
242
|
if ( route.meta.isPasskeysEnabled && !doesUserHavePasskeys.value ) {
|
|
205
243
|
handlePasskeyAlert(route.meta.suggestionPeriod, router);
|
|
206
244
|
}
|
|
245
|
+
console.log("Login confirmed, finishing login...");
|
|
207
246
|
await user.finishLogin();
|
|
208
247
|
} else {
|
|
248
|
+
console.log("Login not allowed, showing error:", resp.error);
|
|
209
249
|
if (usePasskey) {
|
|
210
250
|
showErrorTost(t(resp.error));
|
|
211
251
|
codeError.value = resp.error || t('Passkey authentication failed');
|
|
@@ -249,8 +289,16 @@
|
|
|
249
289
|
}
|
|
250
290
|
|
|
251
291
|
async function usePasskeyButton() {
|
|
292
|
+
// Cancel any stray pending requests from earlier flows
|
|
293
|
+
cancelPendingWebAuthn('button-pressed');
|
|
294
|
+
codeError.value = null;
|
|
252
295
|
isFetchingPasskey.value = true;
|
|
253
|
-
const
|
|
296
|
+
const signIn = await createSignInRequest();
|
|
297
|
+
if (!signIn) {
|
|
298
|
+
isFetchingPasskey.value = false;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const { _options, challengeId } = signIn;
|
|
254
302
|
const options = PublicKeyCredential.parseRequestOptionsFromJSON(_options);
|
|
255
303
|
const credential = await authenticate(options);
|
|
256
304
|
if (!credential) {
|
|
@@ -269,15 +317,18 @@
|
|
|
269
317
|
|
|
270
318
|
async function createSignInRequest() {
|
|
271
319
|
let response;
|
|
320
|
+
console.log("Creating sign-in request for passkey...");
|
|
272
321
|
try {
|
|
273
322
|
response = await callAdminForthApi({
|
|
274
323
|
path: `/plugin/passkeys/signInRequest`,
|
|
275
324
|
method: 'POST',
|
|
276
325
|
});
|
|
277
326
|
} catch (error) {
|
|
327
|
+
console.log("Error creating sign-in request:", error);
|
|
278
328
|
console.error('Error creating sign-in request:', error);
|
|
279
329
|
return;
|
|
280
330
|
}
|
|
331
|
+
console.log("Sign-in request response:", response);
|
|
281
332
|
if (response.ok === true) {
|
|
282
333
|
return { _options: response.data, challengeId: response.challengeId };
|
|
283
334
|
} else {
|
|
@@ -287,25 +338,54 @@
|
|
|
287
338
|
}
|
|
288
339
|
|
|
289
340
|
async function authenticate(options) {
|
|
341
|
+
console.log("Authenticating with options:", options);
|
|
290
342
|
try {
|
|
343
|
+
// Guard: prevent concurrent navigator.credentials.get calls
|
|
344
|
+
if (webAuthn.inFlight) {
|
|
345
|
+
console.warn('[WebAuthn] A request is already in flight. Aborting previous and retrying.');
|
|
346
|
+
cancelPendingWebAuthn('pre-auth-guard');
|
|
347
|
+
}
|
|
348
|
+
|
|
291
349
|
const abortController = new AbortController();
|
|
350
|
+
webAuthn.controller = abortController;
|
|
351
|
+
webAuthn.inFlight = true;
|
|
352
|
+
|
|
292
353
|
const credential = await navigator.credentials.get({
|
|
293
354
|
publicKey: options,
|
|
294
355
|
signal: abortController.signal,
|
|
356
|
+
// mediation can be set if using conditional UI, omitted here intentionally
|
|
295
357
|
});
|
|
358
|
+
console.log("Credential obtained:", credential);
|
|
296
359
|
return credential;
|
|
297
360
|
} catch (error) {
|
|
298
361
|
console.error('Error during authentication:', error);
|
|
362
|
+
// Handle specific concurrent/pending request error cases gracefully
|
|
363
|
+
const name = (error && (error.name || error.constructor?.name)) || '';
|
|
364
|
+
const message = (error && error.message) || '';
|
|
365
|
+
if (name === 'AbortError') {
|
|
366
|
+
// Aborted intentionally; no user-facing error needed
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
if (name === 'InvalidStateError' || name === 'OperationError' || /pending/i.test(message)) {
|
|
370
|
+
adminforth.alert({ message: t('Another security prompt is already open. Please try again.'), variant: 'warning' });
|
|
371
|
+
codeError.value = t('A previous passkey attempt was still pending. Please try again.');
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
299
374
|
adminforth.alert({message: `Error during authentication: ${error}`, variant: 'warning'});
|
|
300
375
|
codeError.value = 'Error during authentication.';
|
|
301
376
|
}
|
|
377
|
+
finally {
|
|
378
|
+
// Clear in-flight state regardless of outcome
|
|
379
|
+
webAuthn.inFlight = false;
|
|
380
|
+
webAuthn.controller = null;
|
|
381
|
+
}
|
|
302
382
|
}
|
|
303
383
|
|
|
304
384
|
|
|
305
|
-
function getOtpInputs() {
|
|
385
|
+
function getOtpInputs(): HTMLInputElement[] {
|
|
306
386
|
const root = otpRoot.value;
|
|
307
387
|
if (!root) return [];
|
|
308
|
-
return Array.from(root.querySelectorAll('input.otp-input'));
|
|
388
|
+
return Array.from(root.querySelectorAll('input.otp-input')) as HTMLInputElement[];
|
|
309
389
|
}
|
|
310
390
|
|
|
311
391
|
function focusFirstAvailableOtpInput() {
|
|
@@ -318,7 +398,7 @@
|
|
|
318
398
|
function handleGlobalFocusIn(event) {
|
|
319
399
|
const inputs = getOtpInputs();
|
|
320
400
|
if (!inputs.length) return;
|
|
321
|
-
const target = event.target;
|
|
401
|
+
const target = event.target as HTMLInputElement | null;
|
|
322
402
|
if (!target) return;
|
|
323
403
|
if (!inputs.includes(target)) {
|
|
324
404
|
requestAnimationFrame(() => {
|
|
@@ -331,8 +411,9 @@
|
|
|
331
411
|
requestAnimationFrame(() => {
|
|
332
412
|
const inputs = getOtpInputs();
|
|
333
413
|
if (!inputs.length) return;
|
|
334
|
-
const active = document.activeElement;
|
|
335
|
-
|
|
414
|
+
const active = document.activeElement as Element | null;
|
|
415
|
+
const activeInput = active instanceof HTMLInputElement ? active : null;
|
|
416
|
+
if (!activeInput || !inputs.includes(activeInput)) {
|
|
336
417
|
focusFirstAvailableOtpInput();
|
|
337
418
|
}
|
|
338
419
|
});
|