From 0cc659eb99384ae92c643b75602b6835e04dbcfb Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Wed, 13 May 2026 12:43:06 -0400 Subject: [PATCH] feat(core): Multi-credential UX + last-used tracking + method-aware reverify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Track lastUsedAt per webauthn credential (new column + migration) and surface it as "Last used X ago" in the credentials list. - Personal settings restructured to one card per method with a nested credentials list, device-aware label pre-fill on the setup wizard, and a dashed "Add another …" button. - Login MFA prompt picks the method the user signed in with last on this device (localStorage) and shows a switcher link when both TOTP and a webauthn credential are registered. - Credential removal re-verifies with the same kind of authenticator being removed (passkey → passkey, security key → security key) via a narrowed allowCredentials, falling back to the TOTP / recovery code prompt when the device is unavailable. - Refuse to delete the last MFA credential while 2FA is enforced. --- .../@n8n/api-types/src/dto/webauthn/index.ts | 1 + .../db/src/entities/webauthn-credential.ts | 3 + ...00000-AddLastUsedAtToWebauthnCredential.ts | 11 + .../db/src/migrations/postgresdb/index.ts | 2 + .../@n8n/db/src/migrations/sqlite/index.ts | 2 + .../cli/src/controllers/mfa.controller.ts | 74 ++- packages/cli/src/mfa/webauthn.service.ts | 17 +- .../frontend/@n8n/i18n/src/locales/en.json | 10 + .../@n8n/rest-api-client/src/api/webauthn.ts | 21 +- .../src/features/core/auth/auth.constants.ts | 6 + .../components/WebAuthnSetupWizardModal.vue | 3 +- .../core/auth/composables/useMfaReverify.ts | 59 ++- .../features/core/auth/utils/suggest-label.ts | 34 ++ .../src/features/core/auth/views/MfaView.vue | 124 ++++- .../core/auth/views/SettingsPersonalView.vue | 426 ++++++++++++------ .../features/core/auth/views/SigninView.vue | 17 +- .../features/settings/users/users.store.ts | 12 +- 17 files changed, 609 insertions(+), 213 deletions(-) create mode 100644 packages/@n8n/db/src/migrations/common/1783300000000-AddLastUsedAtToWebauthnCredential.ts create mode 100644 packages/frontend/editor-ui/src/features/core/auth/auth.constants.ts create mode 100644 packages/frontend/editor-ui/src/features/core/auth/utils/suggest-label.ts diff --git a/packages/@n8n/api-types/src/dto/webauthn/index.ts b/packages/@n8n/api-types/src/dto/webauthn/index.ts index aa1043ec65c..6334760b19b 100644 --- a/packages/@n8n/api-types/src/dto/webauthn/index.ts +++ b/packages/@n8n/api-types/src/dto/webauthn/index.ts @@ -7,4 +7,5 @@ export interface WebAuthnCredentialResponse { transports: string[] | null; aaguid: string | null; createdAt: string; + lastUsedAt: string | null; } diff --git a/packages/@n8n/db/src/entities/webauthn-credential.ts b/packages/@n8n/db/src/entities/webauthn-credential.ts index c7317c4ae23..326fb65808c 100644 --- a/packages/@n8n/db/src/entities/webauthn-credential.ts +++ b/packages/@n8n/db/src/entities/webauthn-credential.ts @@ -39,4 +39,7 @@ export class WebauthnCredential extends WithTimestampsAndStringId { @Column({ type: String, length: 255 }) label: string; + + @Column({ type: 'datetime', nullable: true }) + lastUsedAt: Date | null; } diff --git a/packages/@n8n/db/src/migrations/common/1783300000000-AddLastUsedAtToWebauthnCredential.ts b/packages/@n8n/db/src/migrations/common/1783300000000-AddLastUsedAtToWebauthnCredential.ts new file mode 100644 index 00000000000..065fed5975f --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1783300000000-AddLastUsedAtToWebauthnCredential.ts @@ -0,0 +1,11 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +export class AddLastUsedAtToWebauthnCredential1783300000000 implements ReversibleMigration { + async up({ schemaBuilder: { addColumns, column } }: MigrationContext) { + await addColumns('webauthn_credential', [column('lastUsedAt').timestamp()]); + } + + async down({ schemaBuilder: { dropColumns } }: MigrationContext) { + await dropColumns('webauthn_credential', ['lastUsedAt']); + } +} diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index dba3765552b..88792eb7c1f 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -175,6 +175,7 @@ import { AddEvaluationConfigColumnsToTestRun1778100002000 } from '../common/1778 import { CreateAgentTables1783000000000 } from '../common/1783000000000-CreateAgentTables'; import { CreateAgentExecutionTables1783000000001 } from '../common/1783000000001-CreateAgentExecutionTables'; import { AddTokensValidAfterToUser1783200000000 } from '../common/1783200000000-AddTokensValidAfterToUser'; +import { AddLastUsedAtToWebauthnCredential1783300000000 } from '../common/1783300000000-AddLastUsedAtToWebauthnCredential'; import type { Migration } from '../migration-types'; export const postgresMigrations: Migration[] = [ @@ -355,4 +356,5 @@ export const postgresMigrations: Migration[] = [ CreateAgentTables1783000000000, CreateAgentExecutionTables1783000000001, AddTokensValidAfterToUser1783200000000, + AddLastUsedAtToWebauthnCredential1783300000000, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 95d1773cc6a..3c86e3a2bf4 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -168,6 +168,7 @@ import { AddEvaluationConfigColumnsToTestRun1778100002000 } from '../common/1778 import { CreateAgentTables1783000000000 } from '../common/1783000000000-CreateAgentTables'; import { CreateAgentExecutionTables1783000000001 } from '../common/1783000000001-CreateAgentExecutionTables'; import { AddTokensValidAfterToUser1783200000000 } from '../common/1783200000000-AddTokensValidAfterToUser'; +import { AddLastUsedAtToWebauthnCredential1783300000000 } from '../common/1783300000000-AddLastUsedAtToWebauthnCredential'; import type { Migration } from '../migration-types'; const sqliteMigrations: Migration[] = [ @@ -341,6 +342,7 @@ const sqliteMigrations: Migration[] = [ CreateAgentTables1783000000000, CreateAgentExecutionTables1783000000001, AddTokensValidAfterToUser1783200000000, + AddLastUsedAtToWebauthnCredential1783300000000, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/controllers/mfa.controller.ts b/packages/cli/src/controllers/mfa.controller.ts index 422220c2946..6c687984a42 100644 --- a/packages/cli/src/controllers/mfa.controller.ts +++ b/packages/cli/src/controllers/mfa.controller.ts @@ -3,7 +3,6 @@ import { InstanceSettingsLoaderConfig } from '@n8n/config'; import { AuthenticatedRequest, UserRepository } from '@n8n/db'; import { createUserKeyedRateLimiter, - Delete, Get, GlobalScope, Patch, @@ -317,8 +316,10 @@ export class MFAController { @Post('/webauthn/authentication-options', { skipAuth: true, }) - async getWebAuthnAuthenticationOptions(req: AuthenticatedRequest<{}, {}, { email: string }>) { - const { email } = req.body; + async getWebAuthnAuthenticationOptions( + req: AuthenticatedRequest<{}, {}, { email: string; kind?: 'passkey' | 'security_key' }>, + ) { + const { email, kind } = req.body; if (!email) { throw new BadRequestError('Email is required'); } @@ -329,7 +330,8 @@ export class MFAController { throw new BadRequestError('MFA Error', 998); } - return await this.mfaService.webauthn.generateAuthenticationOptions(user.id); + const narrowedKind = kind === 'passkey' || kind === 'security_key' ? kind : undefined; + return await this.mfaService.webauthn.generateAuthenticationOptions(user.id, narrowedKind); } @Get('/webauthn/credentials') @@ -344,25 +346,73 @@ export class MFAController { transports: c.transports, aaguid: c.aaguid ?? null, createdAt: c.createdAt.toISOString(), + lastUsedAt: c.lastUsedAt ? c.lastUsedAt.toISOString() : null, })); } - @Delete('/webauthn/credentials/:id') - async deleteWebAuthnCredential(req: AuthenticatedRequest<{ id: string }>, res: Response) { + // Use POST (not DELETE) so the proof body reliably reaches the server — + // our rest-api-client routes DELETE data through query params, which + // can't carry a structured webauthn assertion. + @Post('/webauthn/credentials/:id/remove') + async deleteWebAuthnCredential( + req: AuthenticatedRequest< + { id: string }, + {}, + { mfaCode?: string; mfaRecoveryCode?: string; webauthnResponse?: unknown } + >, + res: Response, + ) { const { id } = req.params; + const { mfaCode, mfaRecoveryCode, webauthnResponse } = req.body; const userId = req.user.id; + // Removing a credential is security-sensitive — re-verify the user's + // active 2FA method (TOTP code, recovery code, or a fresh webauthn + // assertion) before we touch the credential row. + const provided = [mfaCode, mfaRecoveryCode, webauthnResponse].filter( + (v) => v !== undefined && v !== null && v !== '', + ); + if (provided.length !== 1) { + throw new BadRequestError( + 'Exactly one of mfaCode, mfaRecoveryCode, or webauthnResponse is required to remove this credential', + ); + } + + let proofValid: boolean; + if (webauthnResponse !== undefined && webauthnResponse !== null) { + proofValid = await this.mfaService.validateWebAuthn(userId, webauthnResponse); + } else { + proofValid = await this.mfaService.validateMfa(userId, mfaCode, mfaRecoveryCode); + } + if (!proofValid) { + throw new BadRequestError('Invalid two-factor proof'); + } + + const [credentialsBefore, isMFAEnforced, { decryptedSecret }] = await Promise.all([ + this.mfaService.webauthn.getUserCredentials(userId), + this.mfaService.isMFAEnforced(), + this.mfaService.getSecretAndRecoveryCodes(userId), + ]); + const remainingWebauthn = credentialsBefore.filter((c) => c.id !== id).length; + const hasTotpSecret = !!decryptedSecret; + + // Refuse if removing this credential would leave the user with no + // authentication factor on an instance that enforces MFA. + if (isMFAEnforced && remainingWebauthn === 0 && !hasTotpSecret) { + throw new BadRequestError( + 'You can’t remove your last MFA credential while two-factor authentication is required on this instance', + ); + } + const deleted = await this.mfaService.webauthn.deleteCredential(id, userId); if (!deleted) { throw new BadRequestError('Credential not found'); } - // Check if user has any remaining MFA methods - const hasWebauthnCredentials = await this.mfaService.webauthn.hasCredentials(userId); - const { decryptedSecret } = await this.mfaService.getSecretAndRecoveryCodes(userId); - const hasTotpSecret = !!decryptedSecret; - - if (!hasWebauthnCredentials && !hasTotpSecret) { + // If this was the last MFA factor, mirror disable-MFA semantics so the + // user's session reflects the new state. The pre-delete read above + // already told us what's left — no need to re-query. + if (remainingWebauthn === 0 && !hasTotpSecret) { await this.userRepository.update(userId, { mfaEnabled: false, mfaSecret: null, diff --git a/packages/cli/src/mfa/webauthn.service.ts b/packages/cli/src/mfa/webauthn.service.ts index 03d869f4d0d..b44a6bddf21 100644 --- a/packages/cli/src/mfa/webauthn.service.ts +++ b/packages/cli/src/mfa/webauthn.service.ts @@ -158,13 +158,24 @@ export class WebAuthnService { ); } - async generateAuthenticationOptions(userId: string) { + async generateAuthenticationOptions(userId: string, kind?: 'passkey' | 'security_key') { const { generateAuthenticationOptions } = await import('@simplewebauthn/server'); - const credentials = await this.webauthnCredentialRepository.find({ + const allCredentials = await this.webauthnCredentialRepository.find({ where: { userId }, }); + // When the caller asks for a specific kind (e.g. re-verify with a passkey + // when removing one) we narrow `allowCredentials` so the browser/OS can + // only satisfy the ceremony with a matching credential. + const filterByKind = (c: (typeof allCredentials)[number]) => { + if (!kind) return true; + const isPlatform = + (c.transports ?? []).includes('internal') || c.deviceType === 'multiDevice'; + return kind === 'passkey' ? isPlatform : !isPlatform; + }; + const credentials = allCredentials.filter(filterByKind); + const options = await generateAuthenticationOptions({ rpID: this.getRpId(), allowCredentials: credentials.map((cred) => ({ @@ -231,6 +242,7 @@ export class WebAuthnService { if (verification.verified && verification.authenticationInfo) { await this.webauthnCredentialRepository.update(credential.id, { counter: verification.authenticationInfo.newCounter, + lastUsedAt: new Date(), }); } @@ -307,6 +319,7 @@ export class WebAuthnService { await this.webauthnCredentialRepository.update(credential.id, { counter: verification.authenticationInfo.newCounter, + lastUsedAt: new Date(), }); await this.cacheService.delete(cacheKey); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 70bf74de44c..1c858af4d27 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -4867,7 +4867,14 @@ "settings.personal.method.button.remove": "Remove", "settings.personal.method.button.disable": "Disable", "settings.personal.method.button.addAnother": "Add another", + "settings.personal.method.button.addAnother.passkey": "Add another passkey", + "settings.personal.method.button.addAnother.security_key": "Add another security key", + "settings.personal.method.badge.registeredOne": "1 registered", + "settings.personal.method.badge.registeredMany": "{count} registered", "settings.personal.method.detail.setUpOn": "Set up {date}", + "settings.personal.method.detail.addedOn": "Added {date}", + "settings.personal.method.detail.lastUsed": "Last used {when}", + "settings.personal.method.detail.neverUsed": "Never used", "settings.personal.twoFactor.section.title": "Two-factor authentication (2FA)", "settings.personal.twoFactor.description": "Add a second step after your password. You can enable multiple methods at the same time.", "settings.personal.twoFactor.disabled.description": "Two-factor authentication is currently disabled.", @@ -4928,6 +4935,9 @@ "mfa.login.security_key.waiting.title": "Touch your security key", "mfa.login.security_key.waiting.description": "The key should be blinking. Touch the button on it now to verify.", "mfa.login.cancel": "Cancel", + "mfa.login.switcher.useTotp": "Use authenticator app instead", + "mfa.login.switcher.useSecurityKey": "Use security key instead", + "mfa.login.switcher.usePasskey": "Use passkey instead", "auth.changePassword.passkey.title": "Verify with your passkey", "auth.changePassword.passkey.description": "Confirm with Touch ID, Face ID, or your device PIN to set a new password.", "auth.changePassword.passkey.button": "Verify with passkey", diff --git a/packages/frontend/@n8n/rest-api-client/src/api/webauthn.ts b/packages/frontend/@n8n/rest-api-client/src/api/webauthn.ts index a5681b916ff..3901651295b 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/webauthn.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/webauthn.ts @@ -30,9 +30,14 @@ export async function verifyRegistration( return await makeRestApiRequest(context, 'POST', '/mfa/webauthn/registration-verify', data); } -export async function getAuthenticationOptions(context: IRestApiContext, email: string) { +export async function getAuthenticationOptions( + context: IRestApiContext, + email: string, + kind?: 'passkey' | 'security_key', +) { return await makeRestApiRequest(context, 'POST', '/mfa/webauthn/authentication-options', { email, + ...(kind ? { kind } : {}), }); } @@ -42,8 +47,18 @@ export async function getCredentials( return await makeRestApiRequest(context, 'GET', '/mfa/webauthn/credentials'); } -export async function deleteCredential(context: IRestApiContext, id: string): Promise { - return await makeRestApiRequest(context, 'DELETE', `/mfa/webauthn/credentials/${id}`); +export type DeleteCredentialProof = { + mfaCode?: string; + mfaRecoveryCode?: string; + webauthnResponse?: unknown; +}; + +export async function deleteCredential( + context: IRestApiContext, + id: string, + proof: DeleteCredentialProof, +): Promise { + return await makeRestApiRequest(context, 'POST', `/mfa/webauthn/credentials/${id}/remove`, proof); } export async function updateCredential( diff --git a/packages/frontend/editor-ui/src/features/core/auth/auth.constants.ts b/packages/frontend/editor-ui/src/features/core/auth/auth.constants.ts new file mode 100644 index 00000000000..9c2a312df8c --- /dev/null +++ b/packages/frontend/editor-ui/src/features/core/auth/auth.constants.ts @@ -0,0 +1,6 @@ +/** + * Per-device localStorage key remembering which 2FA method the user used last + * to sign in. Read on the MFA prompt to pick a sensible default and written + * after a successful sign-in. Stored values are `MfaMethod` strings. + */ +export const LAST_2FA_METHOD_KEY = 'n8n.last2faMethod'; diff --git a/packages/frontend/editor-ui/src/features/core/auth/components/WebAuthnSetupWizardModal.vue b/packages/frontend/editor-ui/src/features/core/auth/components/WebAuthnSetupWizardModal.vue index 62bdc2126c8..9ae508bc357 100644 --- a/packages/frontend/editor-ui/src/features/core/auth/components/WebAuthnSetupWizardModal.vue +++ b/packages/frontend/editor-ui/src/features/core/auth/components/WebAuthnSetupWizardModal.vue @@ -18,6 +18,7 @@ import { useUIStore } from '@/app/stores/ui.store'; import { useToast } from '@/app/composables/useToast'; import { useUsersStore } from '@/features/settings/users/users.store'; import { twoFactorWizardBus, type TwoFactorMethod } from '../auth.eventBus'; +import { suggestCredentialLabel } from '../utils/suggest-label'; import MfaWizardSteps from './MfaWizardSteps.vue'; const props = defineProps<{ @@ -36,7 +37,7 @@ const attachment = computed<'platform' | 'cross-platform'>(() => method.value === 'passkey' ? 'platform' : 'cross-platform', ); -const label = ref(''); +const label = ref(suggestCredentialLabel(navigator.userAgent, props.data?.method ?? 'passkey')); const submitting = ref(false); const recoveryCodes = ref([]); const showRecoveryCodes = computed(() => recoveryCodes.value.length > 0); diff --git a/packages/frontend/editor-ui/src/features/core/auth/composables/useMfaReverify.ts b/packages/frontend/editor-ui/src/features/core/auth/composables/useMfaReverify.ts index b69a7be3c30..f78ecfc27ed 100644 --- a/packages/frontend/editor-ui/src/features/core/auth/composables/useMfaReverify.ts +++ b/packages/frontend/editor-ui/src/features/core/auth/composables/useMfaReverify.ts @@ -14,53 +14,52 @@ export type MfaProof = * webauthn assertion ceremony. The caller forwards the returned proof to whichever * endpoint requires re-verification (e.g. disable MFA, change password, change email). * + * Pass `preferKind: 'passkey' | 'security_key'` to force a webauthn ceremony with + * a credential of that type (used when removing a credential — the user is asked + * to re-verify with the same kind of authenticator they're removing). When the + * user doesn't have any credentials of that kind, this falls back to the + * code/recovery-code prompt. + * * Resolves with `null` if the user cancels. */ export function useMfaReverify() { const usersStore = useUsersStore(); const uiStore = useUIStore(); - async function reverify(): Promise { + const promptCode = (): Promise => + new Promise((resolve) => { + const handler = (payload: { mfaCode?: string; mfaRecoveryCode?: string } | undefined) => { + promptMfaCodeBus.off('closed', handler); + if (!payload) return resolve(null); + if (payload.mfaCode) return resolve({ mfaCode: payload.mfaCode }); + if (payload.mfaRecoveryCode) return resolve({ mfaRecoveryCode: payload.mfaRecoveryCode }); + resolve(null); + }; + promptMfaCodeBus.on('closed', handler); + uiStore.openModal(PROMPT_MFA_CODE_MODAL_KEY); + }); + + async function reverify(preferKind?: 'passkey' | 'security_key'): Promise { const user = usersStore.currentUser; if (!user || !user.mfaEnabled) return null; const methods = user.availableMfaMethods ?? []; - const method = methods[0] ?? 'totp'; + const chosen: 'totp' | 'passkey' | 'security_key' = + preferKind && methods.includes(preferKind) ? preferKind : (methods[0] ?? 'totp'); - if (method === 'totp') { - return await new Promise((resolve) => { - const handler = (payload: { mfaCode?: string; mfaRecoveryCode?: string } | undefined) => { - promptMfaCodeBus.off('closed', handler); - if (!payload) return resolve(null); - if (payload.mfaCode) return resolve({ mfaCode: payload.mfaCode }); - if (payload.mfaRecoveryCode) return resolve({ mfaRecoveryCode: payload.mfaRecoveryCode }); - resolve(null); - }; - promptMfaCodeBus.on('closed', handler); - uiStore.openModal(PROMPT_MFA_CODE_MODAL_KEY); - }); + if (chosen === 'totp') { + return await promptCode(); } - // webauthn — passkey or security_key if (!user.email) return null; try { - const webauthnResponse = await usersStore.verifyWebAuthnAuthentication(user.email); + const webauthnResponse = await usersStore.verifyWebAuthnAuthentication(user.email, chosen); return { webauthnResponse }; } catch { - // Authenticator unavailable (lost device, cancelled ceremony) — - // fall back to the code/recovery-code prompt so users can still - // manage their account with a recovery code. - return await new Promise((resolve) => { - const handler = (payload: { mfaCode?: string; mfaRecoveryCode?: string } | undefined) => { - promptMfaCodeBus.off('closed', handler); - if (!payload) return resolve(null); - if (payload.mfaCode) return resolve({ mfaCode: payload.mfaCode }); - if (payload.mfaRecoveryCode) return resolve({ mfaRecoveryCode: payload.mfaRecoveryCode }); - resolve(null); - }; - promptMfaCodeBus.on('closed', handler); - uiStore.openModal(PROMPT_MFA_CODE_MODAL_KEY); - }); + // Authenticator unavailable (lost device, cancelled ceremony) — fall + // back to the code/recovery-code prompt so users can still manage + // their account with a recovery code. + return await promptCode(); } } diff --git a/packages/frontend/editor-ui/src/features/core/auth/utils/suggest-label.ts b/packages/frontend/editor-ui/src/features/core/auth/utils/suggest-label.ts new file mode 100644 index 00000000000..adb318f6b62 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/core/auth/utils/suggest-label.ts @@ -0,0 +1,34 @@ +import Bowser from 'bowser'; + +/** + * Suggest a sensible default label for a freshly-registered passkey based on + * the current device. Returns `''` for security keys (the user is more likely + * to want a specific, inventory-style name like "YubiKey 5C (primary)") and + * for unknown platforms. The result is purely a UX hint — users can edit it + * freely before registration. + */ +export function suggestCredentialLabel( + userAgent: string, + method: 'passkey' | 'security_key', +): string { + if (method !== 'passkey') return ''; + + const { os } = Bowser.parse(userAgent); + switch (os.name) { + case 'iOS': + return 'iPhone'; + case 'iPadOS': + return 'iPad'; + case 'macOS': + return 'Mac'; + case 'Android': + return 'Android device'; + case 'Windows': + return 'Windows PC'; + case 'Linux': + case 'Chrome OS': + return `${os.name} PC`; + default: + return ''; + } +} diff --git a/packages/frontend/editor-ui/src/features/core/auth/views/MfaView.vue b/packages/frontend/editor-ui/src/features/core/auth/views/MfaView.vue index 5888912c5d5..4c2dc209ad7 100644 --- a/packages/frontend/editor-ui/src/features/core/auth/views/MfaView.vue +++ b/packages/frontend/editor-ui/src/features/core/auth/views/MfaView.vue @@ -8,7 +8,8 @@ import { MFA_FORM, } from '@/app/constants'; import { mfaEventBus } from '../auth.eventBus'; -import { onMounted, ref } from 'vue'; +import { LAST_2FA_METHOD_KEY } from '../auth.constants'; +import { computed, onMounted, ref, watch } from 'vue'; import { useI18n } from '@n8n/i18n'; import { toRefs } from '@vueuse/core'; import { useSettingsStore } from '@/app/stores/settings.store'; @@ -27,9 +28,55 @@ import { const props = defineProps<{ reportError: boolean; email: string; - mfaMethod: MfaMethod; + availableMethods: MfaMethod[]; }>(); +const readLastMethod = (): MfaMethod | null => { + try { + const v = localStorage.getItem(LAST_2FA_METHOD_KEY); + if (v === 'totp' || v === 'security_key' || v === 'passkey') return v; + } catch { + // localStorage unavailable + } + return null; +}; + +// Pick the method the user used last on this device, falling back to a +// security key when registered (passkey/security_key share the same WebAuthn +// ceremony) and finally to TOTP. +const computeInitialMethod = (): MfaMethod => { + const last = readLastMethod(); + if (last && props.availableMethods.includes(last)) return last; + if (props.availableMethods.includes('security_key')) return 'security_key'; + if (props.availableMethods.includes('passkey')) return 'passkey'; + return 'totp'; +}; + +const webauthnMethod = computed<'passkey' | 'security_key'>(() => + props.availableMethods.includes('security_key') ? 'security_key' : 'passkey', +); +const currentMethod = ref(computeInitialMethod()); +const showSwitcher = computed(() => { + const hasTotp = props.availableMethods.includes('totp'); + const hasWebauthn = + props.availableMethods.includes('security_key') || props.availableMethods.includes('passkey'); + return hasTotp && hasWebauthn; +}); +const switcherTarget = computed(() => + currentMethod.value === 'totp' ? webauthnMethod.value : 'totp', +); +const switcherLabelKey = computed(() => + switcherTarget.value === 'totp' + ? 'mfa.login.switcher.useTotp' + : switcherTarget.value === 'passkey' + ? 'mfa.login.switcher.usePasskey' + : 'mfa.login.switcher.useSecurityKey', +); +const switcherIcon = computed(() => (switcherTarget.value === 'totp' ? 'shield' : 'key-round')); +const onSwitcherClick = () => { + currentMethod.value = switcherTarget.value; +}; + const hasAnyChanges = ref(false); const formBus = ref(mfaEventBus); const formInputs = ref(null); @@ -194,11 +241,22 @@ const { } = useSettingsStore(); onMounted(() => { - if (props.mfaMethod === 'totp') { + if (currentMethod.value === 'totp') { formInputs.value = [mfaCodeFieldWithDefaults()]; focusMfaCodeAfterPasswordManager(); } }); + +// When the user toggles between methods via the switcher, swap form inputs so +// the new method's primary input is auto-focused. +watch(currentMethod, (next) => { + if (next === 'totp') { + formInputs.value = [mfaCodeFieldWithDefaults()]; + focusMfaCodeAfterPasswordManager(); + } else { + formInputs.value = null; + } +});