mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 21:07:28 -08:00
feat(core): Improve ldap/saml toggle and tests (#5771)
* improve ldap/saml toggle and tests * import cleanup * reject regular login users when saml is enabled * lint fix
This commit is contained in:
parent
30aeeb70b4
commit
47ee357059
|
@ -26,6 +26,11 @@ import type { ConnectionSecurity, LdapConfig } from './types';
|
||||||
import { jsonParse, LoggerProxy as Logger } from 'n8n-workflow';
|
import { jsonParse, LoggerProxy as Logger } from 'n8n-workflow';
|
||||||
import { License } from '@/License';
|
import { License } from '@/License';
|
||||||
import { InternalHooks } from '@/InternalHooks';
|
import { InternalHooks } from '@/InternalHooks';
|
||||||
|
import {
|
||||||
|
isEmailCurrentAuthenticationMethod,
|
||||||
|
isLdapCurrentAuthenticationMethod,
|
||||||
|
setCurrentAuthenticationMethod,
|
||||||
|
} from '@/sso/ssoHelpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether the LDAP feature is disabled in the instance
|
* Check whether the LDAP feature is disabled in the instance
|
||||||
|
@ -50,8 +55,24 @@ export const setLdapLoginLabel = (value: string): void => {
|
||||||
/**
|
/**
|
||||||
* Set the LDAP login enabled to the configuration object
|
* Set the LDAP login enabled to the configuration object
|
||||||
*/
|
*/
|
||||||
export const setLdapLoginEnabled = (value: boolean): void => {
|
export const setLdapLoginEnabled = async (value: boolean): Promise<void> => {
|
||||||
config.set(LDAP_LOGIN_ENABLED, value);
|
if (config.get(LDAP_LOGIN_ENABLED) === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// only one auth method can be active at a time, with email being the default
|
||||||
|
if (value && isEmailCurrentAuthenticationMethod()) {
|
||||||
|
// enable ldap login and disable email login, but only if email is the current auth method
|
||||||
|
config.set(LDAP_LOGIN_ENABLED, true);
|
||||||
|
await setCurrentAuthenticationMethod('ldap');
|
||||||
|
} else if (!value && isLdapCurrentAuthenticationMethod()) {
|
||||||
|
// disable ldap login, but only if ldap is the current auth method
|
||||||
|
config.set(LDAP_LOGIN_ENABLED, false);
|
||||||
|
await setCurrentAuthenticationMethod('email');
|
||||||
|
} else {
|
||||||
|
Logger.warn(
|
||||||
|
'Cannot switch LDAP login enabled state when an authentication method other than email is active',
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -126,8 +147,8 @@ export const getLdapConfig = async (): Promise<LdapConfig> => {
|
||||||
/**
|
/**
|
||||||
* Take the LDAP configuration and set login enabled and login label to the config object
|
* Take the LDAP configuration and set login enabled and login label to the config object
|
||||||
*/
|
*/
|
||||||
export const setGlobalLdapConfigVariables = (ldapConfig: LdapConfig): void => {
|
export const setGlobalLdapConfigVariables = async (ldapConfig: LdapConfig): Promise<void> => {
|
||||||
setLdapLoginEnabled(ldapConfig.loginEnabled);
|
await setLdapLoginEnabled(ldapConfig.loginEnabled);
|
||||||
setLdapLoginLabel(ldapConfig.loginLabel);
|
setLdapLoginLabel(ldapConfig.loginLabel);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -175,7 +196,7 @@ export const updateLdapConfig = async (ldapConfig: LdapConfig): Promise<void> =>
|
||||||
{ key: LDAP_FEATURE_NAME },
|
{ key: LDAP_FEATURE_NAME },
|
||||||
{ value: JSON.stringify(ldapConfig), loadOnStartup: true },
|
{ value: JSON.stringify(ldapConfig), loadOnStartup: true },
|
||||||
);
|
);
|
||||||
setGlobalLdapConfigVariables(ldapConfig);
|
await setGlobalLdapConfigVariables(ldapConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -197,7 +218,7 @@ export const handleLdapInit = async (): Promise<void> => {
|
||||||
|
|
||||||
const ldapConfig = await getLdapConfig();
|
const ldapConfig = await getLdapConfig();
|
||||||
|
|
||||||
setGlobalLdapConfigVariables(ldapConfig);
|
await setGlobalLdapConfigVariables(ldapConfig);
|
||||||
|
|
||||||
// init LDAP manager with the current
|
// init LDAP manager with the current
|
||||||
// configuration
|
// configuration
|
||||||
|
|
|
@ -19,8 +19,10 @@ import type {
|
||||||
} from '@/Interfaces';
|
} from '@/Interfaces';
|
||||||
import { handleEmailLogin, handleLdapLogin } from '@/auth';
|
import { handleEmailLogin, handleLdapLogin } from '@/auth';
|
||||||
import type { PostHogClient } from '@/posthog';
|
import type { PostHogClient } from '@/posthog';
|
||||||
import { isSamlCurrentAuthenticationMethod } from '../sso/ssoHelpers';
|
import {
|
||||||
import { SamlUrls } from '../sso/saml/constants';
|
isLdapCurrentAuthenticationMethod,
|
||||||
|
isSamlCurrentAuthenticationMethod,
|
||||||
|
} from '@/sso/ssoHelpers';
|
||||||
|
|
||||||
@RestController()
|
@RestController()
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
|
@ -73,19 +75,12 @@ export class AuthController {
|
||||||
if (preliminaryUser?.globalRole?.name === 'owner') {
|
if (preliminaryUser?.globalRole?.name === 'owner') {
|
||||||
user = preliminaryUser;
|
user = preliminaryUser;
|
||||||
} else {
|
} else {
|
||||||
// TODO:SAML - uncomment this block when we have a way to redirect users to the SSO flow
|
throw new AuthError('SAML is enabled, please log in with SAML');
|
||||||
// if (doRedirectUsersFromLoginToSsoFlow()) {
|
|
||||||
res.redirect(SamlUrls.restInitSSO);
|
|
||||||
return;
|
|
||||||
// return withFeatureFlags(this.postHog, sanitizeUser(preliminaryUser));
|
|
||||||
// } else {
|
|
||||||
// throw new AuthError(
|
|
||||||
// 'Login with username and password is disabled due to SAML being the default authentication method. Please use SAML to log in.',
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
} else if (isLdapCurrentAuthenticationMethod()) {
|
||||||
|
user = await handleLdapLogin(email, password);
|
||||||
} else {
|
} else {
|
||||||
user = (await handleLdapLogin(email, password)) ?? (await handleEmailLogin(email, password));
|
user = await handleEmailLogin(email, password);
|
||||||
}
|
}
|
||||||
if (user) {
|
if (user) {
|
||||||
await issueCookie(res, user);
|
await issueCookie(res, user);
|
||||||
|
|
|
@ -16,6 +16,7 @@ import {
|
||||||
isSamlCurrentAuthenticationMethod,
|
isSamlCurrentAuthenticationMethod,
|
||||||
setCurrentAuthenticationMethod,
|
setCurrentAuthenticationMethod,
|
||||||
} from '../ssoHelpers';
|
} from '../ssoHelpers';
|
||||||
|
import { LoggerProxy } from 'n8n-workflow';
|
||||||
/**
|
/**
|
||||||
* Check whether the SAML feature is licensed and enabled in the instance
|
* Check whether the SAML feature is licensed and enabled in the instance
|
||||||
*/
|
*/
|
||||||
|
@ -29,14 +30,19 @@ export function getSamlLoginLabel(): string {
|
||||||
|
|
||||||
// can only toggle between email and saml, not directly to e.g. ldap
|
// can only toggle between email and saml, not directly to e.g. ldap
|
||||||
export async function setSamlLoginEnabled(enabled: boolean): Promise<void> {
|
export async function setSamlLoginEnabled(enabled: boolean): Promise<void> {
|
||||||
if (enabled) {
|
if (config.get(SAML_LOGIN_ENABLED) === enabled) {
|
||||||
if (isEmailCurrentAuthenticationMethod()) {
|
return;
|
||||||
|
}
|
||||||
|
if (enabled && isEmailCurrentAuthenticationMethod()) {
|
||||||
config.set(SAML_LOGIN_ENABLED, true);
|
config.set(SAML_LOGIN_ENABLED, true);
|
||||||
await setCurrentAuthenticationMethod('saml');
|
await setCurrentAuthenticationMethod('saml');
|
||||||
}
|
} else if (!enabled && isSamlCurrentAuthenticationMethod()) {
|
||||||
} else {
|
|
||||||
config.set(SAML_LOGIN_ENABLED, false);
|
config.set(SAML_LOGIN_ENABLED, false);
|
||||||
await setCurrentAuthenticationMethod('email');
|
await setCurrentAuthenticationMethod('email');
|
||||||
|
} else {
|
||||||
|
LoggerProxy.warn(
|
||||||
|
'Cannot switch SAML login enabled state when an authentication method other than email is active',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,22 +2,12 @@ import config from '@/config';
|
||||||
import * as Db from '@/Db';
|
import * as Db from '@/Db';
|
||||||
import type { AuthProviderType } from '@/databases/entities/AuthIdentity';
|
import type { AuthProviderType } from '@/databases/entities/AuthIdentity';
|
||||||
|
|
||||||
export function isSamlCurrentAuthenticationMethod(): boolean {
|
/**
|
||||||
return config.getEnv('userManagement.authenticationMethod') === 'saml';
|
* Only one authentication method can be active at a time. This function sets the current authentication method
|
||||||
}
|
* and saves it to the database.
|
||||||
|
* SSO methods should only switch to email and then to another method. Email can switch to any method.
|
||||||
export function isEmailCurrentAuthenticationMethod(): boolean {
|
* @param authenticationMethod
|
||||||
return config.getEnv('userManagement.authenticationMethod') === 'email';
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
export function isSsoJustInTimeProvisioningEnabled(): boolean {
|
|
||||||
return config.getEnv('sso.justInTimeProvisioning');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function doRedirectUsersFromLoginToSsoFlow(): boolean {
|
|
||||||
return config.getEnv('sso.redirectLoginToSso');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setCurrentAuthenticationMethod(
|
export async function setCurrentAuthenticationMethod(
|
||||||
authenticationMethod: AuthProviderType,
|
authenticationMethod: AuthProviderType,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
@ -28,3 +18,27 @@ export async function setCurrentAuthenticationMethod(
|
||||||
loadOnStartup: true,
|
loadOnStartup: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCurrentAuthenticationMethod(): AuthProviderType {
|
||||||
|
return config.getEnv('userManagement.authenticationMethod');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSamlCurrentAuthenticationMethod(): boolean {
|
||||||
|
return getCurrentAuthenticationMethod() === 'saml';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLdapCurrentAuthenticationMethod(): boolean {
|
||||||
|
return getCurrentAuthenticationMethod() === 'ldap';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEmailCurrentAuthenticationMethod(): boolean {
|
||||||
|
return getCurrentAuthenticationMethod() === 'email';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSsoJustInTimeProvisioningEnabled(): boolean {
|
||||||
|
return config.getEnv('sso.justInTimeProvisioning');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function doRedirectUsersFromLoginToSsoFlow(): boolean {
|
||||||
|
return config.getEnv('sso.redirectLoginToSso');
|
||||||
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ import { randomEmail, randomName, uniqueId } from './../shared/random';
|
||||||
import * as testDb from './../shared/testDb';
|
import * as testDb from './../shared/testDb';
|
||||||
import type { AuthAgent } from '../shared/types';
|
import type { AuthAgent } from '../shared/types';
|
||||||
import * as utils from '../shared/utils';
|
import * as utils from '../shared/utils';
|
||||||
|
import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
|
||||||
|
|
||||||
jest.mock('@/telemetry');
|
jest.mock('@/telemetry');
|
||||||
jest.mock('@/UserManagement/email/NodeMailer');
|
jest.mock('@/UserManagement/email/NodeMailer');
|
||||||
|
@ -55,6 +56,8 @@ beforeAll(async () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
utils.initConfigFile();
|
utils.initConfigFile();
|
||||||
|
|
||||||
|
await setCurrentAuthenticationMethod('email');
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
@ -174,6 +177,7 @@ describe('PUT /ldap/config', () => {
|
||||||
const emailUser = await Db.collections.User.findOneByOrFail({ id: member.id });
|
const emailUser = await Db.collections.User.findOneByOrFail({ id: member.id });
|
||||||
const localLdapIdentities = await testDb.getLdapIdentities();
|
const localLdapIdentities = await testDb.getLdapIdentities();
|
||||||
|
|
||||||
|
expect(getCurrentAuthenticationMethod()).toBe('email');
|
||||||
expect(emailUser.email).toBe(member.email);
|
expect(emailUser.email).toBe(member.email);
|
||||||
expect(emailUser.lastName).toBe(member.lastName);
|
expect(emailUser.lastName).toBe(member.lastName);
|
||||||
expect(emailUser.firstName).toBe(member.firstName);
|
expect(emailUser.firstName).toBe(member.firstName);
|
||||||
|
@ -190,6 +194,7 @@ test('GET /ldap/config route should retrieve current configuration', async () =>
|
||||||
|
|
||||||
let response = await authAgent(owner).put('/ldap/config').send(validPayload);
|
let response = await authAgent(owner).put('/ldap/config').send(validPayload);
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(getCurrentAuthenticationMethod()).toBe('ldap');
|
||||||
|
|
||||||
response = await authAgent(owner).get('/ldap/config');
|
response = await authAgent(owner).get('/ldap/config');
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,11 @@ import type { SuperAgentTest } from 'supertest';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import { setSamlLoginEnabled } from '@/sso/saml/samlHelpers';
|
import { setSamlLoginEnabled } from '@/sso/saml/samlHelpers';
|
||||||
import { setCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
|
import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
|
||||||
import { randomEmail, randomName, randomValidPassword } from '../shared/random';
|
import { randomEmail, randomName, randomValidPassword } from '../shared/random';
|
||||||
import * as testDb from '../shared/testDb';
|
import * as testDb from '../shared/testDb';
|
||||||
import * as utils from '../shared/utils';
|
import * as utils from '../shared/utils';
|
||||||
|
import { sampleConfig } from './sampleMetadata';
|
||||||
|
|
||||||
let owner: User;
|
let owner: User;
|
||||||
let authOwnerAgent: SuperAgentTest;
|
let authOwnerAgent: SuperAgentTest;
|
||||||
|
@ -16,7 +17,7 @@ async function enableSaml(enable: boolean) {
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const app = await utils.initTestServer({ endpointGroups: ['me'] });
|
const app = await utils.initTestServer({ endpointGroups: ['me', 'saml'] });
|
||||||
owner = await testDb.createOwner();
|
owner = await testDb.createOwner();
|
||||||
authOwnerAgent = utils.createAuthAgent(app)(owner);
|
authOwnerAgent = utils.createAuthAgent(app)(owner);
|
||||||
});
|
});
|
||||||
|
@ -67,4 +68,66 @@ describe('Instance owner', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /sso/saml/config', () => {
|
||||||
|
test('should post saml config', async () => {
|
||||||
|
await authOwnerAgent
|
||||||
|
.post('/sso/saml/config')
|
||||||
|
.send({
|
||||||
|
...sampleConfig,
|
||||||
|
loginEnabled: true,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
expect(getCurrentAuthenticationMethod()).toBe('saml');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /sso/saml/config/toggle', () => {
|
||||||
|
test('should toggle saml as default authentication method', async () => {
|
||||||
|
await enableSaml(true);
|
||||||
|
expect(getCurrentAuthenticationMethod()).toBe('saml');
|
||||||
|
|
||||||
|
await authOwnerAgent
|
||||||
|
.post('/sso/saml/config/toggle')
|
||||||
|
.send({
|
||||||
|
loginEnabled: false,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
expect(getCurrentAuthenticationMethod()).toBe('email');
|
||||||
|
|
||||||
|
await authOwnerAgent
|
||||||
|
.post('/sso/saml/config/toggle')
|
||||||
|
.send({
|
||||||
|
loginEnabled: true,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
expect(getCurrentAuthenticationMethod()).toBe('saml');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /sso/saml/config/toggle', () => {
|
||||||
|
test('should fail enable saml if default authentication is not email', async () => {
|
||||||
|
await enableSaml(true);
|
||||||
|
|
||||||
|
await authOwnerAgent
|
||||||
|
.post('/sso/saml/config/toggle')
|
||||||
|
.send({
|
||||||
|
loginEnabled: false,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
expect(getCurrentAuthenticationMethod()).toBe('email');
|
||||||
|
|
||||||
|
await setCurrentAuthenticationMethod('ldap');
|
||||||
|
expect(getCurrentAuthenticationMethod()).toBe('ldap');
|
||||||
|
|
||||||
|
await authOwnerAgent
|
||||||
|
.post('/sso/saml/config/toggle')
|
||||||
|
.send({
|
||||||
|
loginEnabled: true,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(getCurrentAuthenticationMethod()).toBe('ldap');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
30
packages/cli/test/integration/saml/sampleMetadata.ts
Normal file
30
packages/cli/test/integration/saml/sampleMetadata.ts
Normal file
File diff suppressed because one or more lines are too long
|
@ -22,6 +22,7 @@ type EndpointGroup =
|
||||||
| 'publicApi'
|
| 'publicApi'
|
||||||
| 'nodes'
|
| 'nodes'
|
||||||
| 'ldap'
|
| 'ldap'
|
||||||
|
| 'saml'
|
||||||
| 'eventBus'
|
| 'eventBus'
|
||||||
| 'license';
|
| 'license';
|
||||||
|
|
||||||
|
|
|
@ -78,6 +78,9 @@ import { LdapManager } from '@/Ldap/LdapManager.ee';
|
||||||
import { LDAP_ENABLED } from '@/Ldap/constants';
|
import { LDAP_ENABLED } from '@/Ldap/constants';
|
||||||
import { handleLdapInit } from '@/Ldap/helpers';
|
import { handleLdapInit } from '@/Ldap/helpers';
|
||||||
import { Push } from '@/push';
|
import { Push } from '@/push';
|
||||||
|
import { setSamlLoginEnabled } from '@/sso/saml/samlHelpers';
|
||||||
|
import { SamlService } from '@/sso/saml/saml.service.ee';
|
||||||
|
import { SamlController } from '@/sso/saml/routes/saml.controller.ee';
|
||||||
|
|
||||||
export const mockInstance = <T>(
|
export const mockInstance = <T>(
|
||||||
ctor: new (...args: any[]) => T,
|
ctor: new (...args: any[]) => T,
|
||||||
|
@ -190,6 +193,11 @@ export async function initTestServer({
|
||||||
new LdapController(service, sync, internalHooks),
|
new LdapController(service, sync, internalHooks),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case 'saml':
|
||||||
|
await setSamlLoginEnabled(true);
|
||||||
|
const samlService = Container.get(SamlService);
|
||||||
|
registerController(testServer.app, config, new SamlController(samlService));
|
||||||
|
break;
|
||||||
case 'nodes':
|
case 'nodes':
|
||||||
registerController(
|
registerController(
|
||||||
testServer.app,
|
testServer.app,
|
||||||
|
|
Loading…
Reference in a new issue