mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-19 11:01:15 +00:00
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:
@@ -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'>;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -221,6 +221,7 @@ export function createMockEnterpriseSettings(
|
||||
ldap: false,
|
||||
saml: false,
|
||||
oidc: false,
|
||||
mfaEnforcement: false,
|
||||
logStreaming: false,
|
||||
advancedExecutionFilters: false,
|
||||
variables: false,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -651,6 +651,7 @@ export const EnterpriseEditionFeature: Record<
|
||||
Variables: 'variables',
|
||||
Saml: 'saml',
|
||||
Oidc: 'oidc',
|
||||
EnforceMFA: 'mfaEnforcement',
|
||||
SourceControl: 'sourceControl',
|
||||
ExternalSecrets: 'externalSecrets',
|
||||
AuditLogs: 'auditLogs',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user