feat(core): Allow enforcement of MFA usage on instance (#16556)

Co-authored-by: Marc Littlemore <marc@n8n.io>
Co-authored-by: Csaba Tuncsik <csaba.tuncsik@gmail.com>
This commit is contained in:
Andreas Fitzek
2025-07-02 11:03:10 +02:00
committed by GitHub
parent 060acd2db8
commit 657e5a3b3a
56 changed files with 619 additions and 88 deletions

View File

@@ -585,6 +585,7 @@ export interface IUser extends IUserResponse {
fullName?: string;
createdAt?: string;
mfaEnabled: boolean;
mfaAuthenticated?: boolean;
}
export interface IUserListAction {
@@ -1320,7 +1321,8 @@ export type EnterpriseEditionFeatureKey =
| 'WorkflowHistory'
| 'WorkerView'
| 'AdvancedPermissions'
| 'ApiKeyScopes';
| 'ApiKeyScopes'
| 'EnforceMFA';
export type EnterpriseEditionFeatureValue = keyof Omit<FrontendSettings['enterprise'], 'projects'>;

View File

@@ -26,6 +26,7 @@ export const defaultSettings: FrontendSettings = {
ldap: false,
oidc: false,
saml: false,
mfaEnforcement: false,
logStreaming: false,
debugInEditor: false,
advancedExecutionFilters: false,
@@ -122,6 +123,7 @@ export const defaultSettings: FrontendSettings = {
previewMode: false,
mfa: {
enabled: false,
enforced: false,
},
askAi: {
enabled: false,

View File

@@ -221,6 +221,7 @@ export function createMockEnterpriseSettings(
ldap: false,
saml: false,
oidc: false,
mfaEnforcement: false,
logStreaming: false,
advancedExecutionFilters: false,
variables: false,

View File

@@ -4,6 +4,7 @@ import {
MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH,
MFA_AUTHENTICATION_CODE_WINDOW_EXPIRED,
MFA_SETUP_MODAL_KEY,
VIEWS,
} from '../constants';
import { ref, onMounted } from 'vue';
import { useUsersStore } from '@/stores/users.store';
@@ -13,6 +14,8 @@ import { useToast } from '@/composables/useToast';
import QrcodeVue from 'qrcode.vue';
import { useClipboard } from '@/composables/useClipboard';
import { useI18n } from '@n8n/i18n';
import { useSettingsStore } from '@/stores/settings.store';
import router from '@/router';
// ---------------------------------------------------------------------------
// #region Reactive properties
@@ -39,6 +42,7 @@ const loadingQrCode = ref(true);
const clipboard = useClipboard();
const userStore = useUsersStore();
const settingsStore = useSettingsStore();
const i18n = useI18n();
const toast = useToast();
@@ -104,6 +108,10 @@ const onSetupClick = async () => {
type: 'success',
title: i18n.baseText('mfa.setup.step2.toast.setupFinished.message'),
});
if (settingsStore.isMFAEnforced) {
await userStore.logout();
await router.push({ name: VIEWS.SIGNIN });
}
} catch (e) {
if (e.errorCode === MFA_AUTHENTICATION_CODE_WINDOW_EXPIRED) {
toast.showMessage({
@@ -227,7 +235,7 @@ onMounted(async () => {
</div>
</div>
<n8n-info-tip :bold="false" :class="$style['edit-mode-footer-infotip']">
<i18nn-t keypath="mfa.setup.step2.infobox.description" tag="span">
<i18n-t keypath="mfa.setup.step2.infobox.description" tag="span">
<template #part1>
{{ i18n.baseText('mfa.setup.step2.infobox.description.part1') }}
</template>
@@ -236,7 +244,7 @@ onMounted(async () => {
{{ i18n.baseText('mfa.setup.step2.infobox.description.part2') }}
</n8n-text>
</template>
</i18nn-t>
</i18n-t>
</n8n-info-tip>
<div>
<n8n-button

View File

@@ -651,6 +651,7 @@ export const EnterpriseEditionFeature: Record<
Variables: 'variables',
Saml: 'saml',
Oidc: 'oidc',
EnforceMFA: 'mfaEnforcement',
SourceControl: 'sourceControl',
ExternalSecrets: 'externalSecrets',
AuditLogs: 'auditLogs',

View File

@@ -21,6 +21,7 @@ import { tryToParseNumber } from '@/utils/typesUtils';
import { projectsRoutes } from '@/routes/projects.routes';
import { insightsRoutes } from '@/features/insights/insights.router';
import TestRunDetailView from '@/views/Evaluations.ee/TestRunDetailView.vue';
import { MfaRequiredError } from '@n8n/rest-api-client';
const ChangePasswordView = async () => await import('./views/ChangePasswordView.vue');
const ErrorView = async () => await import('./views/ErrorView.vue');
@@ -832,6 +833,14 @@ router.beforeEach(async (to: RouteLocationNormalized, from, next) => {
return next();
} catch (failure) {
const settingsStore = useSettingsStore();
if (failure instanceof MfaRequiredError && settingsStore.isMFAEnforced) {
if (to.name !== VIEWS.PERSONAL_SETTINGS) {
return next({ name: VIEWS.PERSONAL_SETTINGS });
} else {
return next();
}
}
if (isNavigationFailure(failure)) {
console.log(failure);
} else {

View File

@@ -49,6 +49,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const saveDataSuccessExecution = ref<WorkflowSettings.SaveDataExecution>('all');
const saveManualExecutions = ref(false);
const saveDataProgressExecution = ref(false);
const isMFAEnforced = ref(false);
const isDocker = computed(() => settings.value?.isDocker ?? false);
@@ -130,6 +131,10 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
() => settings.value.telemetry && settings.value.telemetry.enabled,
);
const isMFAEnforcementLicensed = computed(() => {
return settings.value.enterprise?.mfaEnforcement ?? false;
});
const isMfaFeatureEnabled = computed(() => mfa.value.enabled);
const isFoldersFeatureEnabled = computed(() => folders.value.enabled);
@@ -235,6 +240,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
setSaveDataProgressExecution(fetchedSettings.saveExecutionProgress);
setSaveManualExecutions(fetchedSettings.saveManualExecutions);
isMFAEnforced.value = settings.value.mfa?.enforced ?? false;
rootStore.setUrlBaseWebhook(fetchedSettings.urlBaseWebhook);
rootStore.setUrlBaseEditor(fetchedSettings.urlBaseEditor);
rootStore.setEndpointForm(fetchedSettings.endpointForm);
@@ -391,5 +398,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
activeModules,
getModuleSettings,
moduleSettings,
isMFAEnforcementLicensed,
isMFAEnforced,
};
});

View File

@@ -388,6 +388,11 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
}
};
const updateEnforceMfa = async (enforce: boolean) => {
await mfaApi.updateEnforceMfa(rootStore.restApiContext, enforce);
settingsStore.isMFAEnforced = enforce;
};
const sendConfirmationEmail = async () => {
await cloudApi.sendConfirmationEmail(rootStore.restApiContext);
};
@@ -466,6 +471,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
verifyMfaCode,
enableMfa,
disableMfa,
updateEnforceMfa,
canEnableMFA,
sendConfirmationEmail,
updateGlobalRole,

View File

@@ -1,4 +1,5 @@
import { useUsersStore } from '@/stores/users.store';
import { useSettingsStore } from '@/stores/settings.store';
import type { RBACPermissionCheck, AuthenticatedPermissionOptions } from '@/types/rbac';
export const isAuthenticated: RBACPermissionCheck<AuthenticatedPermissionOptions> = (options) => {
@@ -9,3 +10,15 @@ export const isAuthenticated: RBACPermissionCheck<AuthenticatedPermissionOptions
const usersStore = useUsersStore();
return !!usersStore.currentUser;
};
export const shouldEnableMfa: RBACPermissionCheck<AuthenticatedPermissionOptions> = () => {
// Had user got MFA enabled?
const usersStore = useUsersStore();
const hasUserEnabledMfa = usersStore.currentUser?.mfaAuthenticated ?? false;
// Are we enforcing MFA?
const settingsStore = useSettingsStore();
const isMfaEnforced = settingsStore.isMFAEnforced;
return !hasUserEnabledMfa && isMfaEnforced;
};

View File

@@ -2,6 +2,7 @@ import { authenticatedMiddleware } from '@/utils/rbac/middleware/authenticated';
import { useUsersStore } from '@/stores/users.store';
import { VIEWS } from '@/constants';
import type { RouteLocationNormalized } from 'vue-router';
import { createPinia, setActivePinia } from 'pinia';
vi.mock('@/stores/users.store', () => ({
useUsersStore: vi.fn(),
@@ -9,6 +10,10 @@ vi.mock('@/stores/users.store', () => ({
describe('Middleware', () => {
describe('authenticated', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('should redirect to signin if no current user is present', async () => {
vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType<
typeof useUsersStore

View File

@@ -1,7 +1,7 @@
import type { RouterMiddleware } from '@/types/router';
import { VIEWS } from '@/constants';
import type { AuthenticatedPermissionOptions } from '@/types/rbac';
import { isAuthenticated } from '@/utils/rbac/checks';
import { isAuthenticated, shouldEnableMfa } from '@/utils/rbac/checks';
export const authenticatedMiddleware: RouterMiddleware<AuthenticatedPermissionOptions> = async (
to,
@@ -9,11 +9,23 @@ export const authenticatedMiddleware: RouterMiddleware<AuthenticatedPermissionOp
next,
options,
) => {
// ensure that we are removing the already existing redirect query parameter
// to avoid infinite redirect loops
const url = new URL(window.location.href);
url.searchParams.delete('redirect');
const redirect = to.query.redirect ?? encodeURIComponent(`${url.pathname}${url.search}`);
const valid = isAuthenticated(options);
if (!valid) {
const redirect =
to.query.redirect ??
encodeURIComponent(`${window.location.pathname}${window.location.search}`);
return next({ name: VIEWS.SIGNIN, query: { redirect } });
}
// If MFA is not enabled, and the instance enforces MFA, redirect to personal settings
const mfaNeeded = shouldEnableMfa();
if (mfaNeeded) {
if (to.name !== VIEWS.PERSONAL_SETTINGS) {
return next({ name: VIEWS.PERSONAL_SETTINGS, query: { redirect } });
}
return;
}
};

View File

@@ -86,7 +86,9 @@ const isPersonalSecurityEnabled = computed((): boolean => {
const mfaDisabled = computed((): boolean => {
return !usersStore.mfaEnabled;
});
const mfaEnforced = computed((): boolean => {
return settingsStore.isMFAEnforced;
});
const isMfaFeatureEnabled = computed((): boolean => {
return settingsStore.isMfaFeatureEnabled;
});
@@ -362,6 +364,11 @@ onBeforeUnmount(() => {
</n8n-link>
</n8n-text>
</div>
<n8n-notice
v-if="mfaDisabled && mfaEnforced"
:content="i18n.baseText('settings.personal.mfa.enforced')"
/>
<n8n-button
v-if="mfaDisabled"
:class="$style.button"

View File

@@ -89,6 +89,56 @@ describe('SettingsUsersView', () => {
showError.mockReset();
});
it('turn enforcing mfa on', async () => {
const pinia = createTestingPinia({
initialState: getInitialState({
settings: {
settings: {
enterprise: {
mfaEnforcement: true,
},
},
},
}),
});
const userStore = mockedStore(useUsersStore);
const { getByTestId } = renderView({ pinia });
const actionSwitch = getByTestId('enable-force-mfa');
expect(actionSwitch).toBeInTheDocument();
await userEvent.click(actionSwitch);
expect(userStore.updateEnforceMfa).toHaveBeenCalledWith(true);
});
it('turn enforcing mfa off', async () => {
const pinia = createTestingPinia({
initialState: getInitialState({
settings: {
settings: {
enterprise: {
mfaEnforcement: true,
},
},
},
}),
});
const userStore = mockedStore(useUsersStore);
const settingsStore = mockedStore(useSettingsStore);
settingsStore.isMFAEnforced = true;
const { getByTestId } = renderView({ pinia });
const actionSwitch = getByTestId('enable-force-mfa');
expect(actionSwitch).toBeInTheDocument();
await userEvent.click(actionSwitch);
expect(userStore.updateEnforceMfa).toHaveBeenCalledWith(false);
});
it('hides invite button visibility based on user permissions', async () => {
const pinia = createTestingPinia({ initialState: getInitialState() });
const userStore = mockedStore(useUsersStore);

View File

@@ -26,6 +26,8 @@ const ssoStore = useSSOStore();
const documentTitle = useDocumentTitle();
const pageRedirectionHelper = usePageRedirectionHelper();
const tooltipKey = 'settings.personal.mfa.enforce.unlicensed_tooltip';
const i18n = useI18n();
const showUMSetupWarning = computed(() => {
@@ -238,6 +240,23 @@ async function onRoleChange(user: IUser, newRoleName: UpdateGlobalRolePayload['n
showError(e, i18n.baseText('settings.users.userReinviteError'));
}
}
async function onUpdateMfaEnforced(value: boolean) {
try {
await usersStore.updateEnforceMfa(value);
showToast({
type: 'success',
title: value
? i18n.baseText('settings.personal.mfa.enforce.enabled.title')
: i18n.baseText('settings.personal.mfa.enforce.disabled.title'),
message: value
? i18n.baseText('settings.personal.mfa.enforce.enabled.message')
: i18n.baseText('settings.personal.mfa.enforce.disabled.message'),
});
} catch (error) {
showError(error, i18n.baseText('settings.personal.mfa.enforce.error'));
}
}
</script>
<template>
@@ -284,6 +303,44 @@ async function onRoleChange(user: IUser, newRoleName: UpdateGlobalRolePayload['n
</template>
</i18n-t>
</n8n-notice>
<div :class="$style.settingsContainer">
<div :class="$style.settingsContainerInfo">
<n8n-text :bold="true">{{ i18n.baseText('settings.personal.mfa.enforce.title') }}</n8n-text>
<n8n-text size="small" color="text-light">{{
i18n.baseText('settings.personal.mfa.enforce.message')
}}</n8n-text>
</div>
<div :class="$style.settingsContainerAction">
<EnterpriseEdition :features="[EnterpriseEditionFeature.EnforceMFA]">
<el-switch
:model-value="settingsStore.isMFAEnforced"
size="large"
data-test-id="enable-force-mfa"
@update:model-value="onUpdateMfaEnforced"
/>
<template #fallback>
<N8nTooltip>
<el-switch
:model-value="settingsStore.isMFAEnforced"
size="large"
:disabled="true"
@update:model-value="onUpdateMfaEnforced"
/>
<template #content>
<i18n-t :keypath="tooltipKey" tag="span">
<template #action>
<a @click="goToUpgrade">
{{ i18n.baseText('settings.personal.mfa.enforce.unlicensed_tooltip.link') }}
</a>
</template>
</i18n-t>
</template>
</N8nTooltip>
</template>
</EnterpriseEdition>
</div>
</div>
<!-- If there's more than 1 user it means the account quota was more than 1 in the past. So we need to allow instance owner to be able to delete users and transfer workflows.
-->
<div
@@ -348,4 +405,32 @@ async function onRoleChange(user: IUser, newRoleName: UpdateGlobalRolePayload['n
.alert {
left: calc(50% + 100px);
}
.settingsContainer {
display: flex;
align-items: center;
padding-left: 16px;
justify-content: space-between;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid var(--Colors-Foreground---color-foreground-base, #d9dee8);
}
.settingsContainerInfo {
display: flex;
padding: 8px 0px;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: 1px;
}
.settingsContainerAction {
display: flex;
padding: 20px 16px 20px 248px;
justify-content: flex-end;
align-items: center;
flex-shrink: 0;
}
</style>

View File

@@ -142,6 +142,11 @@ const login = async (form: LoginRequestDto) => {
toast.clearAllStickyNotifications();
if (settingsStore.isMFAEnforced && !usersStore.currentUser?.mfaAuthenticated) {
await router.push({ name: VIEWS.PERSONAL_SETTINGS });
return;
}
telemetry.track('User attempted to login', {
result: showMfaView.value ? 'mfa_success' : 'success',
});