diff --git a/custom/TwoFAModal.vue b/custom/TwoFAModal.vue index bb5e4e3..c3218fb 100644 --- a/custom/TwoFAModal.vue +++ b/custom/TwoFAModal.vue @@ -1,45 +1,55 @@ @@ -83,13 +112,15 @@ import websocket from '@/websocket'; import type { AdminUser } from '@/types/Common'; + type TwoFaConfirmationResult = { mode: 'totp'; result: string } | { mode: 'passkey'; result: Record }; + declare global { interface Window { adminforthTwoFaModal: { get2FaConfirmationResult: ( title?: string, verifyingCallback?: (confirmationResult: string) => Promise - ) => Promise; + ) => Promise; }; } } @@ -100,40 +131,65 @@ const { alert } = useAdminforth(); - let currentSessionId: string | null = null; + const isAwaiting2FAResult = ref(false); + let allowAddNewSessions = true; + const ALLOW_NEW_SESSIONS_PERIOD = 1000; + const sessionsIdsToResolve = ref([]); + + watch(isAwaiting2FAResult, (awaiting) => { + if (awaiting) { + allowAddNewSessions = true; + setTimeout(() => { + if (isAwaiting2FAResult.value) { + allowAddNewSessions = false; + } + }, ALLOW_NEW_SESSIONS_PERIOD); + } + }); + watch( props, () => { if (props.adminUser) { websocket.unsubscribeByPrefix(`/user2fa/`); websocket.subscribe(`/user2fa/${props.adminUser.pk}`, async (data: {sessionId: string}) => { - currentSessionId = data.sessionId; + if (!allowAddNewSessions) { + alert({message: t('Some process or user tries to add new actions to confirm. Action was blocked'), variant: 'warning'}); + return; + } + sessionsIdsToResolve.value.push(data.sessionId); let confirmationResult; + if (isAwaiting2FAResult.value) { + return; + } try { + isAwaiting2FAResult.value = true; confirmationResult = await window.adminforthTwoFaModal.get2FaConfirmationResult(); } catch (error) { - console.error('Error during 2FA confirmation:', error); + console.error(t('Error during 2FA confirmation:', error)); } + isAwaiting2FAResult.value = false; try { const response = await callAdminForthApi({ method: "POST", path: "/plugin/passkeys/resolveVerifyAuto", - body: { confirmationResult, sessionId: data.sessionId } + body: { confirmationResult, sessionsIds: sessionsIdsToResolve.value } }); if (!response.ok && response.error === 'No session ID or confirmation result'){ - alert({message: 'Verification session finished or cancelled.', variant: 'warning'}); + alert({message: t('Verification session finished or cancelled.'), variant: 'warning'}); } else if (!response.ok) { - alert({message: 'Verification failed', variant: 'danger'}); + alert({message: t('Verification failed'), variant: 'danger'}); } else if (response.ok) { - alert({message: 'Verification successful', variant: 'success'}); + alert({message: t('Verification successful'), variant: 'success'}); } + sessionsIdsToResolve.value = []; } catch (error) { - console.error('Error resolving automatic 2FA verification:', error); + console.error(t('Error resolving automatic 2FA verification:', error)); } - currentSessionId = null; + allowAddNewSessions = true; }); websocket.subscribe(`/user2fa/${props.adminUser.pk}-resolve`, async (data: {sessionId: string}) => { - if (currentSessionId === data.sessionId && rejectFn && modelShow.value) { + if (sessionsIdsToResolve.value.includes(data.sessionId) && rejectFn && modelShow.value) { onCancel(); - currentSessionId = null; + sessionsIdsToResolve.value = sessionsIdsToResolve.value.filter(id => id !== data.sessionId); } }); } @@ -174,7 +230,7 @@ window.adminforthTwoFaModal = { get2FaConfirmationResult: (title?: string, verifyingCallback?: (confirmationResult: string) => Promise) => new Promise(async (resolve, reject) => { - if (modelShow.value) throw new Error('Modal is already open'); + if (modelShow.value) throw new Error(t('Modal is already open')); const skipAllowModal = await checkIfSkipAllowModal(); if (skipAllowModal) { resolve({ code: "123456" }); // dummy code @@ -251,12 +307,12 @@ } async function sendConfirmationResult(value: string) { - if (!resolveFn) throw new Error('Modal is not initialized properly'); + if (!resolveFn) throw new Error(t('Modal is not initialized properly')); if (verifyFn) { try { const ok = await verifyFn(value); if (!ok) { - rejectFn?.(new Error('Invalid code')); + rejectFn?.(new Error(t('Invalid code'))); return; } } catch (err) { @@ -341,7 +397,7 @@ } } } catch (error) { - console.error('Error checking passkeys:', error); + console.error(t('Error checking passkeys:', error)); // Fallback to TOTP if there's an error doesUserHavePasskeys.value = false; modalMode.value = "totp"; @@ -403,7 +459,7 @@ return false; } } catch (error) { - console.error('Error checking skip allow modal:', error); + console.error(t('Error checking skip allow modal:', error)); return false; } } diff --git a/custom/TwoFactorsConfirmation.vue b/custom/TwoFactorsConfirmation.vue index 1331b86..3c12871 100644 --- a/custom/TwoFactorsConfirmation.vue +++ b/custom/TwoFactorsConfirmation.vue @@ -299,7 +299,7 @@ } }); } catch (error) { - console.error('Error checking if user has passkeys:', error); + console.error(t('Error checking if user has passkeys:', error)); } } @@ -318,7 +318,7 @@ try { options = PublicKeyCredential.parseRequestOptionsFromJSON(_options); } catch (e) { - console.error('Error parsing request options:', e); + console.error(t('Error parsing request options:', e)); adminforth.alert({message: t('Error initiating passkey authentication.'), variant: 'warning'}); return; } @@ -345,14 +345,14 @@ method: 'POST', }); } catch (error) { - console.error('Error creating sign-in request:', error); + console.error(t('Error creating sign-in request:', error)); return; } if (response.ok === true) { return { _options: response.data, challengeId: response.challengeId }; } else { adminforth.alert({message: t('Error creating sign-in request.'), variant: 'warning'}); - codeError.value = 'Error creating sign-in request.'; + codeError.value = t('Error creating sign-in request.'); } } @@ -378,7 +378,7 @@ }); return credential; } catch (error) { - console.error('Error during authentication:', error); + console.error(t('Error during authentication:', error)); // Handle specific concurrent/pending request error cases gracefully const name = (error && (error.name || error.constructor?.name)) || ''; const message = (error && error.message) || ''; diff --git a/custom/TwoFactorsPasskeysSettings.vue b/custom/TwoFactorsPasskeysSettings.vue index 162a5de..13ad133 100644 --- a/custom/TwoFactorsPasskeysSettings.vue +++ b/custom/TwoFactorsPasskeysSettings.vue @@ -1,421 +1,342 @@ - @@ -426,27 +347,10 @@ color: white; border: 1px solid rgb(153, 27, 27); } + .dialog-delete-button.dialog-delete-button:hover { background-color: rgb(153, 27, 27); } + .dialog-delete-button.dialog-delete-button:active { background-color: rgb(127, 20, 20); } - .dialog-delete-button.dialog-delete-button:hover { - background-color: rgb(153, 27, 27); - } - - .dialog-delete-button.dialog-delete-button:active { - background-color: rgb(127, 20, 20); - } - - /* Dark theme styles for delete button */ - .dark .dialog-delete-button.dialog-delete-button { - background-color: rgb(220, 38, 38); - color: white; - } - - .dark .dialog-delete-button.dialog-delete-button:hover { - background-color: rgb(185, 28, 28); - } - - .dark .dialog-delete-button.dialog-delete-button:active { - background-color: rgb(153, 27, 27); - } - + .dark .dialog-delete-button.dialog-delete-button { background-color: rgb(220, 38, 38); color: white; } + .dark .dialog-delete-button.dialog-delete-button:hover { background-color: rgb(185, 28, 28); } + .dark .dialog-delete-button.dialog-delete-button:active { background-color: rgb(153, 27, 27); } diff --git a/index.ts b/index.ts index 2dd0a5e..2fc4035 100644 --- a/index.ts +++ b/index.ts @@ -159,9 +159,10 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { public async verifyAuto(adminUser: AdminUser) { const sessionId = crypto.randomUUID(); - this.adminforth.websocket.publish(`/user2fa/${adminUser.pk}`, { sessionId }); - const result = await this.waitForResponse(sessionId); - this.adminforth.websocket.publish(`/user2fa/${adminUser.pk}-resolve`, { sessionId }); + const jwt = this.adminforth.auth.issueJWT({sessionId, adminUserPk: adminUser.pk}, 'auto2FA', '5m'); + this.adminforth.websocket.publish(`/user2fa/${adminUser.pk}`, { sessionId: jwt }); + const result = await this.waitForResponse(jwt); + this.adminforth.websocket.publish(`/user2fa/${adminUser.pk}-resolve`, { sessionId: jwt }); return result; } @@ -790,8 +791,8 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { headers, } as HttpExtra }); - if ( !verificationResult || !('ok' in verificationResult) ) { - return { ok: false, error: 'Verification failed' }; + if (!verificationResult || !('ok' in verificationResult)) { + return { ok: false, error: 'error' in verificationResult ? verificationResult.error : 'Verification failed' }; } const settingsOrigin = this.options.passkeys?.settings.expectedOrigin; @@ -1080,12 +1081,31 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { path: `/plugin/passkeys/resolveVerifyAuto`, noAuth: false, handler: async ({ body, adminUser, response, cookies, headers }) => { - const sessionId = body?.sessionId; + const sessionsIds = body?.sessionsIds; const confirmationResult = body?.confirmationResult; - if (!sessionId || !confirmationResult) { - this.resolveResponse(sessionId, { ok: false, error: 'No session ID or confirmation result' }); - return { ok: false, error: 'No session ID or confirmation result' }; + const idsToResolve = sessionsIds; + + const resolveAllIdsAsFailed = (message) => { + for (const id of idsToResolve) { + this.resolveResponse(id, { ok: false, error: message }); + } + return { ok: false, error: message }; } + + if (!(sessionsIds) || !confirmationResult) { + return(resolveAllIdsAsFailed('Confirmation window was closed or did not return required data')); + } + + for (const id of idsToResolve) { + const validationResult = await this.adminforth.auth.verify(id, 'auto2FA', false); + if (!validationResult) { + return(resolveAllIdsAsFailed('Invalid session ID or confirmation result')); + } + if (validationResult.adminUserPk !== adminUser.pk) { + return(resolveAllIdsAsFailed('Session does not belong to the authenticated user')); + } + } + const verificationResult = await this.verify(confirmationResult, { adminUser: adminUser, userPk: adminUser.pk, @@ -1096,11 +1116,14 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { } as HttpExtra }); if ( !verificationResult || !('ok' in verificationResult) ) { - this.resolveResponse(sessionId, { ok: false, error: 'Verification failed' }); - return { ok: false, error: 'Verification failed' }; + return(resolveAllIdsAsFailed('Verification failed')); + } + if ('ok' in verificationResult && verificationResult.ok){ + for (const id of idsToResolve) { + this.resolveResponse(id, { ok: true, passkeyConfirmed: verificationResult }); + } + return { ok: true }; } - this.resolveResponse(sessionId, { ok: true, passkeyConfirmed: verificationResult }); - return { ok: true }; } }); } diff --git a/package.json b/package.json index c70a700..ffc6609 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,11 @@ "node-2fa": "^2.0.3" }, "peerDependencies": { - "adminforth": "^2.42.0" + "adminforth": "^2.50.1" }, "devDependencies": { "@types/node": "^22.10.7", + "adminforth": "^2.50.1", "semantic-release": "^24.2.1", "semantic-release-slack-bot": "^4.0.2", "typescript": "^5.7.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49d7008..8a270da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@simplewebauthn/server': specifier: ^13.2.1 version: 13.2.3 - adminforth: - specifier: ^2.42.0 - version: 2.48.0(@types/node@22.19.13)(typescript@5.9.3) node-2fa: specifier: ^2.0.3 version: 2.0.3 @@ -21,6 +18,9 @@ importers: '@types/node': specifier: ^22.10.7 version: 22.19.13 + adminforth: + specifier: ^2.50.1 + version: 2.50.1(@types/node@22.19.13)(typescript@5.9.3) semantic-release: specifier: ^24.2.1 version: 24.2.9(typescript@5.9.3) @@ -455,8 +455,8 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} - adminforth@2.48.0: - resolution: {integrity: sha512-VesaleJWmenY/KN8gEMXxsmWdO6JyL4pcgm/KaDX1T9QoS7BdxG3OU4cI1Nud96eYVU23hjkZmojTXCzO7iHgA==} + adminforth@2.50.1: + resolution: {integrity: sha512-MHsLlmNLcnlxZhIZ57UIyUYjLPMczKVLa0/lnmASBiotQ0JFmJre9PFzKkHShsxHGXR8o8RjU71FdKwID6Vg7A==} hasBin: true agent-base@7.1.4: @@ -3069,7 +3069,7 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 - adminforth@2.48.0(@types/node@22.19.13)(typescript@5.9.3): + adminforth@2.50.1(@types/node@22.19.13)(typescript@5.9.3): dependencies: '@babel/parser': 7.29.0 '@clickhouse/client': 1.18.1