mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +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:
@@ -137,6 +137,7 @@ export interface FrontendSettings {
|
|||||||
ldap: boolean;
|
ldap: boolean;
|
||||||
saml: boolean;
|
saml: boolean;
|
||||||
oidc: boolean;
|
oidc: boolean;
|
||||||
|
mfaEnforcement: boolean;
|
||||||
logStreaming: boolean;
|
logStreaming: boolean;
|
||||||
advancedExecutionFilters: boolean;
|
advancedExecutionFilters: boolean;
|
||||||
variables: boolean;
|
variables: boolean;
|
||||||
@@ -167,6 +168,7 @@ export interface FrontendSettings {
|
|||||||
};
|
};
|
||||||
mfa: {
|
mfa: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
enforced: boolean;
|
||||||
};
|
};
|
||||||
folders: {
|
folders: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ export class LicenseState {
|
|||||||
return this.isLicensed('feat:oidc');
|
return this.isLicensed('feat:oidc');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isMFAEnforcementLicensed() {
|
||||||
|
return this.isLicensed('feat:mfaEnforcement');
|
||||||
|
}
|
||||||
|
|
||||||
isApiKeyScopesLicensed() {
|
isApiKeyScopesLicensed() {
|
||||||
return this.isLicensed('feat:apiKeyScopes');
|
return this.isLicensed('feat:apiKeyScopes');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const LICENSE_FEATURES = {
|
|||||||
LDAP: 'feat:ldap',
|
LDAP: 'feat:ldap',
|
||||||
SAML: 'feat:saml',
|
SAML: 'feat:saml',
|
||||||
OIDC: 'feat:oidc',
|
OIDC: 'feat:oidc',
|
||||||
|
MFA_ENFORCEMENT: 'feat:mfaEnforcement',
|
||||||
LOG_STREAMING: 'feat:logStreaming',
|
LOG_STREAMING: 'feat:logStreaming',
|
||||||
ADVANCED_EXECUTION_FILTERS: 'feat:advancedExecutionFilters',
|
ADVANCED_EXECUTION_FILTERS: 'feat:advancedExecutionFilters',
|
||||||
VARIABLES: 'feat:variables',
|
VARIABLES: 'feat:variables',
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export interface PublicUser {
|
|||||||
isOwner?: boolean;
|
isOwner?: boolean;
|
||||||
featureFlags?: FeatureFlags; // External type from n8n-workflow
|
featureFlags?: FeatureFlags; // External type from n8n-workflow
|
||||||
lastActiveAt?: Date | null;
|
lastActiveAt?: Date | null;
|
||||||
|
mfaAuthenticated?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserSettings = Pick<User, 'id' | 'settings'>;
|
export type UserSettings = Pick<User, 'id' | 'settings'>;
|
||||||
@@ -367,6 +368,10 @@ export type APIRequest<
|
|||||||
browserId?: string;
|
browserId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AuthenticationInformation = {
|
||||||
|
usedMfa: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type AuthenticatedRequest<
|
export type AuthenticatedRequest<
|
||||||
RouteParams = {},
|
RouteParams = {},
|
||||||
ResponseBody = {},
|
ResponseBody = {},
|
||||||
@@ -374,6 +379,7 @@ export type AuthenticatedRequest<
|
|||||||
RequestQuery = {},
|
RequestQuery = {},
|
||||||
> = Omit<APIRequest<RouteParams, ResponseBody, RequestBody, RequestQuery>, 'user' | 'cookies'> & {
|
> = Omit<APIRequest<RouteParams, ResponseBody, RequestBody, RequestQuery>, 'user' | 'cookies'> & {
|
||||||
user: User;
|
user: User;
|
||||||
|
authInfo?: AuthenticationInformation;
|
||||||
cookies: Record<string, string | undefined>;
|
cookies: Record<string, string | undefined>;
|
||||||
headers: express.Request['headers'] & {
|
headers: express.Request['headers'] & {
|
||||||
'push-ref': string;
|
'push-ref': string;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface RouteOptions {
|
|||||||
usesTemplates?: boolean;
|
usesTemplates?: boolean;
|
||||||
/** When this flag is set to true, auth cookie isn't validated, and req.user will not be set */
|
/** When this flag is set to true, auth cookie isn't validated, and req.user will not be set */
|
||||||
skipAuth?: boolean;
|
skipAuth?: boolean;
|
||||||
|
/** When this flag is set to true, the auth cookie does not enforce MFA to be used in the token */
|
||||||
|
allowSkipMFA?: boolean;
|
||||||
/** When these options are set, calls to this endpoint are rate limited using the options */
|
/** When these options are set, calls to this endpoint are rate limited using the options */
|
||||||
rateLimit?: boolean | RateLimit;
|
rateLimit?: boolean | RateLimit;
|
||||||
}
|
}
|
||||||
@@ -26,6 +28,7 @@ const RouteFactory =
|
|||||||
routeMetadata.middlewares = options.middlewares ?? [];
|
routeMetadata.middlewares = options.middlewares ?? [];
|
||||||
routeMetadata.usesTemplates = options.usesTemplates ?? false;
|
routeMetadata.usesTemplates = options.usesTemplates ?? false;
|
||||||
routeMetadata.skipAuth = options.skipAuth ?? false;
|
routeMetadata.skipAuth = options.skipAuth ?? false;
|
||||||
|
routeMetadata.allowSkipMFA = options.allowSkipMFA ?? false;
|
||||||
routeMetadata.rateLimit = options.rateLimit;
|
routeMetadata.rateLimit = options.rateLimit;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface RouteMetadata {
|
|||||||
middlewares: RequestHandler[];
|
middlewares: RequestHandler[];
|
||||||
usesTemplates: boolean;
|
usesTemplates: boolean;
|
||||||
skipAuth: boolean;
|
skipAuth: boolean;
|
||||||
|
allowSkipMFA: boolean;
|
||||||
rateLimit?: boolean | RateLimit;
|
rateLimit?: boolean | RateLimit;
|
||||||
licenseFeature?: BooleanLicenseFeature;
|
licenseFeature?: BooleanLicenseFeature;
|
||||||
accessScope?: AccessScope;
|
accessScope?: AccessScope;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const RESOURCES = {
|
|||||||
securityAudit: ['generate'] as const,
|
securityAudit: ['generate'] as const,
|
||||||
sourceControl: ['pull', 'push', 'manage'] as const,
|
sourceControl: ['pull', 'push', 'manage'] as const,
|
||||||
tag: [...DEFAULT_OPERATIONS] as const,
|
tag: [...DEFAULT_OPERATIONS] as const,
|
||||||
user: ['resetPassword', 'changeRole', ...DEFAULT_OPERATIONS] as const,
|
user: ['resetPassword', 'changeRole', 'enforceMfa', ...DEFAULT_OPERATIONS] as const,
|
||||||
variable: [...DEFAULT_OPERATIONS] as const,
|
variable: [...DEFAULT_OPERATIONS] as const,
|
||||||
workersView: ['manage'] as const,
|
workersView: ['manage'] as const,
|
||||||
workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const,
|
workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const,
|
||||||
@@ -34,7 +34,7 @@ export const API_KEY_RESOURCES = {
|
|||||||
variable: ['create', 'update', 'delete', 'list'] as const,
|
variable: ['create', 'update', 'delete', 'list'] as const,
|
||||||
securityAudit: ['generate'] as const,
|
securityAudit: ['generate'] as const,
|
||||||
project: ['create', 'update', 'delete', 'list'] as const,
|
project: ['create', 'update', 'delete', 'list'] as const,
|
||||||
user: ['read', 'list', 'create', 'changeRole', 'delete'] as const,
|
user: ['read', 'list', 'create', 'changeRole', 'delete', 'enforceMfa'] as const,
|
||||||
execution: ['delete', 'read', 'list', 'get'] as const,
|
execution: ['delete', 'read', 'list', 'get'] as const,
|
||||||
credential: ['create', 'move', 'delete'] as const,
|
credential: ['create', 'move', 'delete'] as const,
|
||||||
sourceControl: ['pull'] as const,
|
sourceControl: ['pull'] as const,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [
|
|||||||
'user:create',
|
'user:create',
|
||||||
'user:changeRole',
|
'user:changeRole',
|
||||||
'user:delete',
|
'user:delete',
|
||||||
|
'user:enforceMfa',
|
||||||
'sourceControl:pull',
|
'sourceControl:pull',
|
||||||
'securityAudit:generate',
|
'securityAudit:generate',
|
||||||
'project:create',
|
'project:create',
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
|||||||
'user:list',
|
'user:list',
|
||||||
'user:resetPassword',
|
'user:resetPassword',
|
||||||
'user:changeRole',
|
'user:changeRole',
|
||||||
|
'user:enforceMfa',
|
||||||
'variable:create',
|
'variable:create',
|
||||||
'variable:read',
|
'variable:read',
|
||||||
'variable:update',
|
'variable:update',
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ describe('ControllerRegistry', () => {
|
|||||||
const metadata = Container.get(ControllerRegistryMetadata);
|
const metadata = Container.get(ControllerRegistryMetadata);
|
||||||
const lastActiveAtService = mock<LastActiveAtService>();
|
const lastActiveAtService = mock<LastActiveAtService>();
|
||||||
let agent: SuperAgentTest;
|
let agent: SuperAgentTest;
|
||||||
|
const authMiddleware = jest.fn().mockImplementation(async (_req, _res, next) => next());
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
const app = express();
|
const app = express();
|
||||||
|
authService.createAuthMiddleware.mockImplementation(() => authMiddleware);
|
||||||
new ControllerRegistry(
|
new ControllerRegistry(
|
||||||
license,
|
license,
|
||||||
authService,
|
authService,
|
||||||
@@ -57,7 +59,7 @@ describe('ControllerRegistry', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
authService.authMiddleware.mockImplementation(async (_req, _res, next) => next());
|
authMiddleware.mockImplementation(async (_req, _res, next) => next());
|
||||||
lastActiveAtService.middleware.mockImplementation(async (_req, _res, next) => next());
|
lastActiveAtService.middleware.mockImplementation(async (_req, _res, next) => next());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,15 +94,15 @@ describe('ControllerRegistry', () => {
|
|||||||
|
|
||||||
it('should not require auth if configured to skip', async () => {
|
it('should not require auth if configured to skip', async () => {
|
||||||
await agent.get('/rest/test/no-auth').expect(200);
|
await agent.get('/rest/test/no-auth').expect(200);
|
||||||
expect(authService.authMiddleware).not.toHaveBeenCalled();
|
expect(authMiddleware).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should require auth by default', async () => {
|
it('should require auth by default', async () => {
|
||||||
authService.authMiddleware.mockImplementation(async (_req, res) => {
|
authMiddleware.mockImplementation(async (_req, res) => {
|
||||||
res.status(401).send();
|
res.status(401).send();
|
||||||
});
|
});
|
||||||
await agent.get('/rest/test/auth').expect(401);
|
await agent.get('/rest/test/auth').expect(401);
|
||||||
expect(authService.authMiddleware).toHaveBeenCalled();
|
expect(authMiddleware).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,7 +118,7 @@ describe('ControllerRegistry', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
authService.authMiddleware.mockImplementation(async (_req, _res, next) => next());
|
authMiddleware.mockImplementation(async (_req, _res, next) => next());
|
||||||
lastActiveAtService.middleware.mockImplementation(async (_req, _res, next) => next());
|
lastActiveAtService.middleware.mockImplementation(async (_req, _res, next) => next());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,7 +147,7 @@ describe('ControllerRegistry', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
authService.authMiddleware.mockImplementation(async (_req, _res, next) => next());
|
authMiddleware.mockImplementation(async (_req, _res, next) => next());
|
||||||
lastActiveAtService.middleware.mockImplementation(async (_req, _res, next) => next());
|
lastActiveAtService.middleware.mockImplementation(async (_req, _res, next) => next());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import jwt from 'jsonwebtoken';
|
|||||||
import { AuthService } from '@/auth/auth.service';
|
import { AuthService } from '@/auth/auth.service';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { AUTH_COOKIE_NAME } from '@/constants';
|
import { AUTH_COOKIE_NAME } from '@/constants';
|
||||||
|
import type { MfaService } from '@/mfa/mfa.service';
|
||||||
import { JwtService } from '@/services/jwt.service';
|
import { JwtService } from '@/services/jwt.service';
|
||||||
import type { UrlService } from '@/services/url.service';
|
import type { UrlService } from '@/services/url.service';
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ describe('AuthService', () => {
|
|||||||
const urlService = mock<UrlService>();
|
const urlService = mock<UrlService>();
|
||||||
const userRepository = mock<UserRepository>();
|
const userRepository = mock<UserRepository>();
|
||||||
const invalidAuthTokenRepository = mock<InvalidAuthTokenRepository>();
|
const invalidAuthTokenRepository = mock<InvalidAuthTokenRepository>();
|
||||||
|
const mfaService = mock<MfaService>();
|
||||||
const authService = new AuthService(
|
const authService = new AuthService(
|
||||||
globalConfig,
|
globalConfig,
|
||||||
mock(),
|
mock(),
|
||||||
@@ -39,13 +41,17 @@ describe('AuthService', () => {
|
|||||||
urlService,
|
urlService,
|
||||||
userRepository,
|
userRepository,
|
||||||
invalidAuthTokenRepository,
|
invalidAuthTokenRepository,
|
||||||
|
mfaService,
|
||||||
);
|
);
|
||||||
|
|
||||||
const now = new Date('2024-02-01T01:23:45.678Z');
|
const now = new Date('2024-02-01T01:23:45.678Z');
|
||||||
jest.useFakeTimers({ now });
|
jest.useFakeTimers({ now });
|
||||||
|
|
||||||
const validToken =
|
const validToken =
|
||||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImhhc2giOiJtSkFZeDRXYjdrIiwiYnJvd3NlcklkIjoiOFpDVXE1YU1uSFhnMFZvcURLcm9hMHNaZ0NwdWlPQ1AzLzB2UmZKUXU0MD0iLCJpYXQiOjE3MDY3NTA2MjUsImV4cCI6MTcwNzM1NTQyNX0.YE-ZGGIQRNQ4DzUe9rjXvOOFFN9ufU34WibsCxAsc4o'; // Generated using `authService.issueJWT(user, browserId)`
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImhhc2giOiJtSkFZeDRXYjdrIiwiYnJvd3NlcklkIjoiOFpDVXE1YU1uSFhnMFZvcURLcm9hMHNaZ0NwdWlPQ1AzLzB2UmZKUXU0MD0iLCJ1c2VkTWZhIjpmYWxzZSwiaWF0IjoxNzA2NzUwNjI1LCJleHAiOjE3MDczNTU0MjV9.N7JgwETmO41o4FUDVb4pA1HM3Clj4jyjDK-lE8Fa1Zw'; // Generated using `authService.issueJWT(user, false, browserId)`
|
||||||
|
|
||||||
|
const validTokenWithMfa =
|
||||||
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImhhc2giOiJtSkFZeDRXYjdrIiwiYnJvd3NlcklkIjoiOFpDVXE1YU1uSFhnMFZvcURLcm9hMHNaZ0NwdWlPQ1AzLzB2UmZKUXU0MD0iLCJ1c2VkTWZhIjp0cnVlLCJpYXQiOjE3MDY3NTA2MjUsImV4cCI6MTcwNzM1NTQyNX0.9kTTue-ZdBQ0CblH0IrqW9K-k0WWfxfsWTglyPB10ko'; // Generated using `authService.issueJWT(user, true, browserId)`
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
@@ -107,7 +113,9 @@ describe('AuthService', () => {
|
|||||||
it('should 401 if no cookie is set', async () => {
|
it('should 401 if no cookie is set', async () => {
|
||||||
req.cookies[AUTH_COOKIE_NAME] = undefined;
|
req.cookies[AUTH_COOKIE_NAME] = undefined;
|
||||||
|
|
||||||
await authService.authMiddleware(req, res, next);
|
const middleware = authService.createAuthMiddleware(true);
|
||||||
|
|
||||||
|
await middleware(req, res, next);
|
||||||
|
|
||||||
expect(invalidAuthTokenRepository.existsBy).not.toHaveBeenCalled();
|
expect(invalidAuthTokenRepository.existsBy).not.toHaveBeenCalled();
|
||||||
expect(next).not.toHaveBeenCalled();
|
expect(next).not.toHaveBeenCalled();
|
||||||
@@ -119,7 +127,9 @@ describe('AuthService', () => {
|
|||||||
invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
|
invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
|
||||||
jest.advanceTimersByTime(365 * Time.days.toMilliseconds);
|
jest.advanceTimersByTime(365 * Time.days.toMilliseconds);
|
||||||
|
|
||||||
await authService.authMiddleware(req, res, next);
|
const middleware = authService.createAuthMiddleware(true);
|
||||||
|
|
||||||
|
await middleware(req, res, next);
|
||||||
|
|
||||||
expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
|
expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
|
||||||
expect(userRepository.findOne).not.toHaveBeenCalled();
|
expect(userRepository.findOne).not.toHaveBeenCalled();
|
||||||
@@ -132,7 +142,9 @@ describe('AuthService', () => {
|
|||||||
req.cookies[AUTH_COOKIE_NAME] = validToken;
|
req.cookies[AUTH_COOKIE_NAME] = validToken;
|
||||||
invalidAuthTokenRepository.existsBy.mockResolvedValue(true);
|
invalidAuthTokenRepository.existsBy.mockResolvedValue(true);
|
||||||
|
|
||||||
await authService.authMiddleware(req, res, next);
|
const middleware = authService.createAuthMiddleware(true);
|
||||||
|
|
||||||
|
await middleware(req, res, next);
|
||||||
|
|
||||||
expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
|
expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
|
||||||
expect(userRepository.findOne).not.toHaveBeenCalled();
|
expect(userRepository.findOne).not.toHaveBeenCalled();
|
||||||
@@ -141,13 +153,34 @@ describe('AuthService', () => {
|
|||||||
expect(res.clearCookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME);
|
expect(res.clearCookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should 401 but not clear the cookie if 2FA is enforced and not configured for the user', async () => {
|
||||||
|
req.cookies[AUTH_COOKIE_NAME] = validToken;
|
||||||
|
userRepository.findOne.mockResolvedValue(user);
|
||||||
|
invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
|
||||||
|
mfaService.isMFAEnforced.mockImplementation(() => {
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const middleware = authService.createAuthMiddleware(false);
|
||||||
|
|
||||||
|
await middleware(req, res, next);
|
||||||
|
|
||||||
|
expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
|
||||||
|
expect(userRepository.findOne).toHaveBeenCalled();
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.clearCookie).not.toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
it('should refresh the cookie before it expires', async () => {
|
it('should refresh the cookie before it expires', async () => {
|
||||||
req.cookies[AUTH_COOKIE_NAME] = validToken;
|
req.cookies[AUTH_COOKIE_NAME] = validToken;
|
||||||
jest.advanceTimersByTime(6 * Time.days.toMilliseconds);
|
jest.advanceTimersByTime(6 * Time.days.toMilliseconds);
|
||||||
invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
|
invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
|
||||||
userRepository.findOne.mockResolvedValue(user);
|
userRepository.findOne.mockResolvedValue(user);
|
||||||
|
|
||||||
await authService.authMiddleware(req, res, next);
|
const middleware = authService.createAuthMiddleware(true);
|
||||||
|
|
||||||
|
await middleware(req, res, next);
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
expect(next).toHaveBeenCalled();
|
||||||
expect(res.cookie).toHaveBeenCalledWith('n8n-auth', expect.any(String), {
|
expect(res.cookie).toHaveBeenCalledWith('n8n-auth', expect.any(String), {
|
||||||
@@ -162,7 +195,7 @@ describe('AuthService', () => {
|
|||||||
describe('issueCookie', () => {
|
describe('issueCookie', () => {
|
||||||
const res = mock<Response>();
|
const res = mock<Response>();
|
||||||
it('should issue a cookie with the correct options', () => {
|
it('should issue a cookie with the correct options', () => {
|
||||||
authService.issueCookie(res, user, browserId);
|
authService.issueCookie(res, user, false, browserId);
|
||||||
|
|
||||||
expect(res.cookie).toHaveBeenCalledWith('n8n-auth', validToken, {
|
expect(res.cookie).toHaveBeenCalledWith('n8n-auth', validToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -172,10 +205,21 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should issue a cookie with the correct options, when 2FA was used', () => {
|
||||||
|
authService.issueCookie(res, user, true, browserId);
|
||||||
|
|
||||||
|
expect(res.cookie).toHaveBeenCalledWith('n8n-auth', validTokenWithMfa, {
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 604800000,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should allow changing cookie options', () => {
|
it('should allow changing cookie options', () => {
|
||||||
globalConfig.auth.cookie = { secure: false, samesite: 'none' };
|
globalConfig.auth.cookie = { secure: false, samesite: 'none' };
|
||||||
|
|
||||||
authService.issueCookie(res, user, browserId);
|
authService.issueCookie(res, user, false, browserId);
|
||||||
|
|
||||||
expect(res.cookie).toHaveBeenCalledWith('n8n-auth', validToken, {
|
expect(res.cookie).toHaveBeenCalledWith('n8n-auth', validToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -190,7 +234,7 @@ describe('AuthService', () => {
|
|||||||
describe('when not setting userManagement.jwtSessionDuration', () => {
|
describe('when not setting userManagement.jwtSessionDuration', () => {
|
||||||
it('should default to expire in 7 days', () => {
|
it('should default to expire in 7 days', () => {
|
||||||
const defaultInSeconds = 7 * Time.days.toSeconds;
|
const defaultInSeconds = 7 * Time.days.toSeconds;
|
||||||
const token = authService.issueJWT(user, browserId);
|
const token = authService.issueJWT(user, false, browserId);
|
||||||
|
|
||||||
expect(authService.jwtExpiration).toBe(defaultInSeconds);
|
expect(authService.jwtExpiration).toBe(defaultInSeconds);
|
||||||
const decodedToken = jwtService.verify(token);
|
const decodedToken = jwtService.verify(token);
|
||||||
@@ -208,7 +252,7 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
it('should apply it to tokens', () => {
|
it('should apply it to tokens', () => {
|
||||||
config.set('userManagement.jwtSessionDurationHours', testDurationHours);
|
config.set('userManagement.jwtSessionDurationHours', testDurationHours);
|
||||||
const token = authService.issueJWT(user, browserId);
|
const token = authService.issueJWT(user, false, browserId);
|
||||||
|
|
||||||
const decodedToken = jwtService.verify(token);
|
const decodedToken = jwtService.verify(token);
|
||||||
if (decodedToken.exp === undefined || decodedToken.iat === undefined) {
|
if (decodedToken.exp === undefined || decodedToken.iat === undefined) {
|
||||||
@@ -280,11 +324,17 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
it('should refresh the cookie before it expires', async () => {
|
it('should refresh the cookie before it expires', async () => {
|
||||||
userRepository.findOne.mockResolvedValue(user);
|
userRepository.findOne.mockResolvedValue(user);
|
||||||
expect(await authService.resolveJwt(validToken, req, res)).toEqual(user);
|
expect(await authService.resolveJwt(validToken, req, res)).toEqual([
|
||||||
|
user,
|
||||||
|
{ usedMfa: false },
|
||||||
|
]);
|
||||||
expect(res.cookie).not.toHaveBeenCalled();
|
expect(res.cookie).not.toHaveBeenCalled();
|
||||||
|
|
||||||
jest.advanceTimersByTime(6 * Time.days.toMilliseconds); // 6 Days
|
jest.advanceTimersByTime(6 * Time.days.toMilliseconds); // 6 Days
|
||||||
expect(await authService.resolveJwt(validToken, req, res)).toEqual(user);
|
expect(await authService.resolveJwt(validToken, req, res)).toEqual([
|
||||||
|
user,
|
||||||
|
{ usedMfa: false },
|
||||||
|
]);
|
||||||
expect(res.cookie).toHaveBeenCalledWith('n8n-auth', expect.any(String), {
|
expect(res.cookie).toHaveBeenCalledWith('n8n-auth', expect.any(String), {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
maxAge: 604800000,
|
maxAge: 604800000,
|
||||||
@@ -294,7 +344,7 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
const newToken = res.cookie.mock.calls[0].at(1);
|
const newToken = res.cookie.mock.calls[0].at(1);
|
||||||
expect(newToken).not.toBe(validToken);
|
expect(newToken).not.toBe(validToken);
|
||||||
expect(await authService.resolveJwt(newToken, req, res)).toEqual(user);
|
expect(await authService.resolveJwt(newToken, req, res)).toEqual([user, { usedMfa: false }]);
|
||||||
expect((jwt.decode(newToken) as jwt.JwtPayload).browserId).toEqual(
|
expect((jwt.decode(newToken) as jwt.JwtPayload).browserId).toEqual(
|
||||||
(jwt.decode(validToken) as jwt.JwtPayload).browserId,
|
(jwt.decode(validToken) as jwt.JwtPayload).browserId,
|
||||||
);
|
);
|
||||||
@@ -302,15 +352,24 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
it('should refresh the cookie only if less than 1/4th of time is left', async () => {
|
it('should refresh the cookie only if less than 1/4th of time is left', async () => {
|
||||||
userRepository.findOne.mockResolvedValue(user);
|
userRepository.findOne.mockResolvedValue(user);
|
||||||
expect(await authService.resolveJwt(validToken, req, res)).toEqual(user);
|
expect(await authService.resolveJwt(validToken, req, res)).toEqual([
|
||||||
|
user,
|
||||||
|
{ usedMfa: false },
|
||||||
|
]);
|
||||||
expect(res.cookie).not.toHaveBeenCalled();
|
expect(res.cookie).not.toHaveBeenCalled();
|
||||||
|
|
||||||
jest.advanceTimersByTime(5 * Time.days.toMilliseconds);
|
jest.advanceTimersByTime(5 * Time.days.toMilliseconds);
|
||||||
expect(await authService.resolveJwt(validToken, req, res)).toEqual(user);
|
expect(await authService.resolveJwt(validToken, req, res)).toEqual([
|
||||||
|
user,
|
||||||
|
{ usedMfa: false },
|
||||||
|
]);
|
||||||
expect(res.cookie).not.toHaveBeenCalled();
|
expect(res.cookie).not.toHaveBeenCalled();
|
||||||
|
|
||||||
jest.advanceTimersByTime(1 * Time.days.toMilliseconds);
|
jest.advanceTimersByTime(1 * Time.days.toMilliseconds);
|
||||||
expect(await authService.resolveJwt(validToken, req, res)).toEqual(user);
|
expect(await authService.resolveJwt(validToken, req, res)).toEqual([
|
||||||
|
user,
|
||||||
|
{ usedMfa: false },
|
||||||
|
]);
|
||||||
expect(res.cookie).toHaveBeenCalled();
|
expect(res.cookie).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -318,11 +377,17 @@ describe('AuthService', () => {
|
|||||||
config.set('userManagement.jwtRefreshTimeoutHours', -1);
|
config.set('userManagement.jwtRefreshTimeoutHours', -1);
|
||||||
|
|
||||||
userRepository.findOne.mockResolvedValue(user);
|
userRepository.findOne.mockResolvedValue(user);
|
||||||
expect(await authService.resolveJwt(validToken, req, res)).toEqual(user);
|
expect(await authService.resolveJwt(validToken, req, res)).toEqual([
|
||||||
|
user,
|
||||||
|
{ usedMfa: false },
|
||||||
|
]);
|
||||||
expect(res.cookie).not.toHaveBeenCalled();
|
expect(res.cookie).not.toHaveBeenCalled();
|
||||||
|
|
||||||
jest.advanceTimersByTime(6 * Time.days.toMilliseconds); // 6 Days
|
jest.advanceTimersByTime(6 * Time.days.toMilliseconds); // 6 Days
|
||||||
expect(await authService.resolveJwt(validToken, req, res)).toEqual(user);
|
expect(await authService.resolveJwt(validToken, req, res)).toEqual([
|
||||||
|
user,
|
||||||
|
{ usedMfa: false },
|
||||||
|
]);
|
||||||
expect(res.cookie).not.toHaveBeenCalled();
|
expect(res.cookie).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from '@/constants';
|
|||||||
import { AuthError } from '@/errors/response-errors/auth.error';
|
import { AuthError } from '@/errors/response-errors/auth.error';
|
||||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||||
import { License } from '@/license';
|
import { License } from '@/license';
|
||||||
|
import { MfaService } from '@/mfa/mfa.service';
|
||||||
import { JwtService } from '@/services/jwt.service';
|
import { JwtService } from '@/services/jwt.service';
|
||||||
import { UrlService } from '@/services/url.service';
|
import { UrlService } from '@/services/url.service';
|
||||||
|
|
||||||
@@ -24,6 +25,8 @@ interface AuthJwtPayload {
|
|||||||
hash: string;
|
hash: string;
|
||||||
/** This is a client generated unique string to prevent session hijacking */
|
/** This is a client generated unique string to prevent session hijacking */
|
||||||
browserId?: string;
|
browserId?: string;
|
||||||
|
/** This indicates if mfa was used during the creation of this token */
|
||||||
|
usedMfa?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IssuedJWT extends AuthJwtPayload {
|
interface IssuedJWT extends AuthJwtPayload {
|
||||||
@@ -48,10 +51,8 @@ export class AuthService {
|
|||||||
private readonly urlService: UrlService,
|
private readonly urlService: UrlService,
|
||||||
private readonly userRepository: UserRepository,
|
private readonly userRepository: UserRepository,
|
||||||
private readonly invalidAuthTokenRepository: InvalidAuthTokenRepository,
|
private readonly invalidAuthTokenRepository: InvalidAuthTokenRepository,
|
||||||
|
private readonly mfaService: MfaService,
|
||||||
) {
|
) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
||||||
this.authMiddleware = this.authMiddleware.bind(this);
|
|
||||||
|
|
||||||
const restEndpoint = globalConfig.endpoints.rest;
|
const restEndpoint = globalConfig.endpoints.rest;
|
||||||
this.skipBrowserIdCheckEndpoints = [
|
this.skipBrowserIdCheckEndpoints = [
|
||||||
// we need to exclude push endpoint because we can't send custom header on websocket requests
|
// we need to exclude push endpoint because we can't send custom header on websocket requests
|
||||||
@@ -67,13 +68,32 @@ export class AuthService {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async authMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
createAuthMiddleware(allowSkipMFA: boolean) {
|
||||||
|
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
const token = req.cookies[AUTH_COOKIE_NAME];
|
const token = req.cookies[AUTH_COOKIE_NAME];
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
const isInvalid = await this.invalidAuthTokenRepository.existsBy({ token });
|
const isInvalid = await this.invalidAuthTokenRepository.existsBy({ token });
|
||||||
if (isInvalid) throw new AuthError('Unauthorized');
|
if (isInvalid) throw new AuthError('Unauthorized');
|
||||||
req.user = await this.resolveJwt(token, req, res);
|
const [user, { usedMfa }] = await this.resolveJwt(token, req, res);
|
||||||
|
const mfaEnforced = this.mfaService.isMFAEnforced();
|
||||||
|
|
||||||
|
if (mfaEnforced && !usedMfa && !allowSkipMFA) {
|
||||||
|
// If MFA is enforced, we need to check if the user has MFA enabled and used it during authentication
|
||||||
|
if (user.mfaEnabled) {
|
||||||
|
// If the user has MFA enforced, but did not use it during authentication, we need to throw an error
|
||||||
|
throw new AuthError('MFA not used during authentication');
|
||||||
|
} else {
|
||||||
|
// In this case we don't want to clear the cookie, to allow for MFA setup
|
||||||
|
res.status(401).json({ status: 'error', message: 'Unauthorized', mfaRequired: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = user;
|
||||||
|
req.authInfo = {
|
||||||
|
usedMfa,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof JsonWebTokenError || error instanceof AuthError) {
|
if (error instanceof JsonWebTokenError || error instanceof AuthError) {
|
||||||
this.clearCookie(res);
|
this.clearCookie(res);
|
||||||
@@ -85,6 +105,7 @@ export class AuthService {
|
|||||||
|
|
||||||
if (req.user) next();
|
if (req.user) next();
|
||||||
else res.status(401).json({ status: 'error', message: 'Unauthorized' });
|
else res.status(401).json({ status: 'error', message: 'Unauthorized' });
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
clearCookie(res: Response) {
|
clearCookie(res: Response) {
|
||||||
@@ -107,7 +128,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
issueCookie(res: Response, user: User, browserId?: string) {
|
issueCookie(res: Response, user: User, usedMfa: boolean, browserId?: string) {
|
||||||
// TODO: move this check to the login endpoint in AuthController
|
// TODO: move this check to the login endpoint in AuthController
|
||||||
// If the instance has exceeded its user quota, prevent non-owners from logging in
|
// If the instance has exceeded its user quota, prevent non-owners from logging in
|
||||||
const isWithinUsersLimit = this.license.isWithinUsersLimit();
|
const isWithinUsersLimit = this.license.isWithinUsersLimit();
|
||||||
@@ -119,7 +140,7 @@ export class AuthService {
|
|||||||
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
|
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = this.issueJWT(user, browserId);
|
const token = this.issueJWT(user, usedMfa, browserId);
|
||||||
const { samesite, secure } = this.globalConfig.auth.cookie;
|
const { samesite, secure } = this.globalConfig.auth.cookie;
|
||||||
res.cookie(AUTH_COOKIE_NAME, token, {
|
res.cookie(AUTH_COOKIE_NAME, token, {
|
||||||
maxAge: this.jwtExpiration * Time.seconds.toMilliseconds,
|
maxAge: this.jwtExpiration * Time.seconds.toMilliseconds,
|
||||||
@@ -129,18 +150,23 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
issueJWT(user: User, browserId?: string) {
|
issueJWT(user: User, usedMfa: boolean = false, browserId?: string) {
|
||||||
const payload: AuthJwtPayload = {
|
const payload: AuthJwtPayload = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
hash: this.createJWTHash(user),
|
hash: this.createJWTHash(user),
|
||||||
browserId: browserId && this.hash(browserId),
|
browserId: browserId && this.hash(browserId),
|
||||||
|
usedMfa,
|
||||||
};
|
};
|
||||||
return this.jwtService.sign(payload, {
|
return this.jwtService.sign(payload, {
|
||||||
expiresIn: this.jwtExpiration,
|
expiresIn: this.jwtExpiration,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolveJwt(token: string, req: AuthenticatedRequest, res: Response): Promise<User> {
|
async resolveJwt(
|
||||||
|
token: string,
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
): Promise<[User, { usedMfa: boolean }]> {
|
||||||
const jwtPayload: IssuedJWT = this.jwtService.verify(token, {
|
const jwtPayload: IssuedJWT = this.jwtService.verify(token, {
|
||||||
algorithms: ['HS256'],
|
algorithms: ['HS256'],
|
||||||
});
|
});
|
||||||
@@ -175,10 +201,10 @@ export class AuthService {
|
|||||||
|
|
||||||
if (jwtPayload.exp * 1000 - Date.now() < this.jwtRefreshTimeout) {
|
if (jwtPayload.exp * 1000 - Date.now() < this.jwtRefreshTimeout) {
|
||||||
this.logger.debug('JWT about to expire. Will be refreshed');
|
this.logger.debug('JWT about to expire. Will be refreshed');
|
||||||
this.issueCookie(res, user, req.browserId);
|
this.issueCookie(res, user, jwtPayload.usedMfa ?? false, req.browserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
return [user, { usedMfa: jwtPayload.usedMfa ?? false }];
|
||||||
}
|
}
|
||||||
|
|
||||||
generatePasswordResetToken(user: User, expiresIn: TimeUnitValue = '20m') {
|
generatePasswordResetToken(user: User, expiresIn: TimeUnitValue = '20m') {
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ import { AuthService } from './auth.service';
|
|||||||
// DO NOT DELETE until the hooks have been updated
|
// DO NOT DELETE until the hooks have been updated
|
||||||
/** @deprecated Use `AuthService` instead */
|
/** @deprecated Use `AuthService` instead */
|
||||||
export function issueCookie(res: Response, user: User) {
|
export function issueCookie(res: Response, user: User) {
|
||||||
return Container.get(AuthService).issueCookie(res, user);
|
return Container.get(AuthService).issueCookie(res, user, user.mfaEnabled);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export class ControllerRegistry {
|
|||||||
...(route.skipAuth
|
...(route.skipAuth
|
||||||
? []
|
? []
|
||||||
: ([
|
: ([
|
||||||
this.authService.authMiddleware.bind(this.authService),
|
this.authService.createAuthMiddleware(route.allowSkipMFA),
|
||||||
this.lastActiveAtService.middleware.bind(this.lastActiveAtService),
|
this.lastActiveAtService.middleware.bind(this.lastActiveAtService),
|
||||||
] as RequestHandler[])),
|
] as RequestHandler[])),
|
||||||
...(route.licenseFeature ? [this.createLicenseMiddleware(route.licenseFeature)] : []),
|
...(route.licenseFeature ? [this.createLicenseMiddleware(route.licenseFeature)] : []),
|
||||||
|
|||||||
@@ -87,13 +87,14 @@ describe('AuthController', () => {
|
|||||||
body.password,
|
body.password,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(authService.issueCookie).toHaveBeenCalledWith(res, member, browserId);
|
expect(authService.issueCookie).toHaveBeenCalledWith(res, member, false, browserId);
|
||||||
expect(eventsService.emit).toHaveBeenCalledWith('user-logged-in', {
|
expect(eventsService.emit).toHaveBeenCalledWith('user-logged-in', {
|
||||||
user: member,
|
user: member,
|
||||||
authenticationMethod: 'ldap',
|
authenticationMethod: 'ldap',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(userService.toPublic).toHaveBeenCalledWith(member, {
|
expect(userService.toPublic).toHaveBeenCalledWith(member, {
|
||||||
|
mfaAuthenticated: false,
|
||||||
posthog: postHog,
|
posthog: postHog,
|
||||||
withScopes: true,
|
withScopes: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ describe('OwnerController', () => {
|
|||||||
authIdentities: [],
|
authIdentities: [],
|
||||||
});
|
});
|
||||||
const browserId = 'test-browser-id';
|
const browserId = 'test-browser-id';
|
||||||
const req = mock<AuthenticatedRequest>({ user, browserId });
|
const req = mock<AuthenticatedRequest>({ user, browserId, authInfo: { usedMfa: false } });
|
||||||
const res = mock<Response>();
|
const res = mock<Response>();
|
||||||
const payload = mock<OwnerSetupRequestDto>({
|
const payload = mock<OwnerSetupRequestDto>({
|
||||||
email: 'valid@email.com',
|
email: 'valid@email.com',
|
||||||
@@ -85,7 +85,7 @@ describe('OwnerController', () => {
|
|||||||
where: { role: 'global:owner' },
|
where: { role: 'global:owner' },
|
||||||
});
|
});
|
||||||
expect(userRepository.save).toHaveBeenCalledWith(user, { transaction: false });
|
expect(userRepository.save).toHaveBeenCalledWith(user, { transaction: false });
|
||||||
expect(authService.issueCookie).toHaveBeenCalledWith(res, user, browserId);
|
expect(authService.issueCookie).toHaveBeenCalledWith(res, user, false, browserId);
|
||||||
expect(settingsRepository.update).toHaveBeenCalledWith(
|
expect(settingsRepository.update).toHaveBeenCalledWith(
|
||||||
{ key: 'userManagement.isInstanceOwnerSetUp' },
|
{ key: 'userManagement.isInstanceOwnerSetUp' },
|
||||||
{ value: JSON.stringify(true) },
|
{ value: JSON.stringify(true) },
|
||||||
|
|||||||
@@ -97,14 +97,19 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.authService.issueCookie(res, user, req.browserId);
|
// If user.mfaEnabled is enabled we checked for the MFA code, therefore it was used during this login execution
|
||||||
|
this.authService.issueCookie(res, user, user.mfaEnabled, req.browserId);
|
||||||
|
|
||||||
this.eventService.emit('user-logged-in', {
|
this.eventService.emit('user-logged-in', {
|
||||||
user,
|
user,
|
||||||
authenticationMethod: usedAuthenticationMethod,
|
authenticationMethod: usedAuthenticationMethod,
|
||||||
});
|
});
|
||||||
|
|
||||||
return await this.userService.toPublic(user, { posthog: this.postHog, withScopes: true });
|
return await this.userService.toPublic(user, {
|
||||||
|
posthog: this.postHog,
|
||||||
|
withScopes: true,
|
||||||
|
mfaAuthenticated: user.mfaEnabled,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
this.eventService.emit('user-login-failed', {
|
this.eventService.emit('user-login-failed', {
|
||||||
authenticationMethod: usedAuthenticationMethod,
|
authenticationMethod: usedAuthenticationMethod,
|
||||||
@@ -115,11 +120,14 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Check if the user is already logged in */
|
/** Check if the user is already logged in */
|
||||||
@Get('/login')
|
@Get('/login', {
|
||||||
|
allowSkipMFA: true,
|
||||||
|
})
|
||||||
async currentUser(req: AuthenticatedRequest): Promise<PublicUser> {
|
async currentUser(req: AuthenticatedRequest): Promise<PublicUser> {
|
||||||
return await this.userService.toPublic(req.user, {
|
return await this.userService.toPublic(req.user, {
|
||||||
posthog: this.postHog,
|
posthog: this.postHog,
|
||||||
withScopes: true,
|
withScopes: true,
|
||||||
|
mfaAuthenticated: req.authInfo?.usedMfa,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export class E2EController {
|
|||||||
[LICENSE_FEATURES.INSIGHTS_VIEW_HOURLY_DATA]: false,
|
[LICENSE_FEATURES.INSIGHTS_VIEW_HOURLY_DATA]: false,
|
||||||
[LICENSE_FEATURES.API_KEY_SCOPES]: false,
|
[LICENSE_FEATURES.API_KEY_SCOPES]: false,
|
||||||
[LICENSE_FEATURES.OIDC]: false,
|
[LICENSE_FEATURES.OIDC]: false,
|
||||||
|
[LICENSE_FEATURES.MFA_ENFORCEMENT]: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly numericFeaturesDefaults: Record<NumericLicenseFeature, number> = {
|
private static readonly numericFeaturesDefaults: Record<NumericLicenseFeature, number> = {
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export class InvitationController {
|
|||||||
|
|
||||||
const updatedUser = await this.userRepository.save(invitee, { transaction: false });
|
const updatedUser = await this.userRepository.save(invitee, { transaction: false });
|
||||||
|
|
||||||
this.authService.issueCookie(res, updatedUser, req.browserId);
|
this.authService.issueCookie(res, updatedUser, false, req.browserId);
|
||||||
|
|
||||||
this.eventService.emit('user-signed-up', {
|
this.eventService.emit('user-signed-up', {
|
||||||
user: updatedUser,
|
user: updatedUser,
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export class MeController {
|
|||||||
|
|
||||||
this.logger.info('User updated successfully', { userId });
|
this.logger.info('User updated successfully', { userId });
|
||||||
|
|
||||||
this.authService.issueCookie(res, user, req.browserId);
|
this.authService.issueCookie(res, user, req.authInfo?.usedMfa ?? false, req.browserId);
|
||||||
|
|
||||||
const changeableFields = ['email', 'firstName', 'lastName'] as const;
|
const changeableFields = ['email', 'firstName', 'lastName'] as const;
|
||||||
const fieldsChanged = changeableFields.filter(
|
const fieldsChanged = changeableFields.filter(
|
||||||
@@ -183,7 +183,7 @@ export class MeController {
|
|||||||
const updatedUser = await this.userRepository.save(user, { transaction: false });
|
const updatedUser = await this.userRepository.save(user, { transaction: false });
|
||||||
this.logger.info('Password updated successfully', { userId: user.id });
|
this.logger.info('Password updated successfully', { userId: user.id });
|
||||||
|
|
||||||
this.authService.issueCookie(res, updatedUser, req.browserId);
|
this.authService.issueCookie(res, updatedUser, req.authInfo?.usedMfa ?? false, req.browserId);
|
||||||
|
|
||||||
this.eventService.emit('user-updated', { user: updatedUser, fieldsChanged: ['password'] });
|
this.eventService.emit('user-updated', { user: updatedUser, fieldsChanged: ['password'] });
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AuthenticatedRequest, UserRepository } from '@n8n/db';
|
import { AuthenticatedRequest, UserRepository } from '@n8n/db';
|
||||||
import { Get, Post, RestController } from '@n8n/decorators';
|
import { Get, GlobalScope, Post, RestController } from '@n8n/decorators';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
|
||||||
import { AuthService } from '@/auth/auth.service';
|
import { AuthService } from '@/auth/auth.service';
|
||||||
@@ -17,13 +17,32 @@ export class MFAController {
|
|||||||
private userRepository: UserRepository,
|
private userRepository: UserRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('/can-enable')
|
@Post('/enforce-mfa')
|
||||||
|
@GlobalScope('user:enforceMfa')
|
||||||
|
async enforceMFA(req: MFA.Enforce) {
|
||||||
|
if (req.body.enforce && !(req.authInfo?.usedMfa ?? false)) {
|
||||||
|
// The current user tries to enforce MFA, but does not have
|
||||||
|
// MFA set up for them self. We are forbidding this, to
|
||||||
|
// help the user not lock them selfs out.
|
||||||
|
throw new BadRequestError(
|
||||||
|
'You must enable two-factor authentication on your own account before enforcing it for all users',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.mfaService.enforceMFA(req.body.enforce);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/can-enable', {
|
||||||
|
allowSkipMFA: true,
|
||||||
|
})
|
||||||
async canEnableMFA(req: AuthenticatedRequest) {
|
async canEnableMFA(req: AuthenticatedRequest) {
|
||||||
await this.externalHooks.run('mfa.beforeSetup', [req.user]);
|
await this.externalHooks.run('mfa.beforeSetup', [req.user]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('/qr')
|
@Get('/qr', {
|
||||||
|
allowSkipMFA: true,
|
||||||
|
})
|
||||||
async getQRCode(req: AuthenticatedRequest) {
|
async getQRCode(req: AuthenticatedRequest) {
|
||||||
const { email, id, mfaEnabled } = req.user;
|
const { email, id, mfaEnabled } = req.user;
|
||||||
|
|
||||||
@@ -63,7 +82,7 @@ export class MFAController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('/enable', { rateLimit: true })
|
@Post('/enable', { rateLimit: true, allowSkipMFA: true })
|
||||||
async activateMFA(req: MFA.Activate, res: Response) {
|
async activateMFA(req: MFA.Activate, res: Response) {
|
||||||
const { mfaCode = null } = req.body;
|
const { mfaCode = null } = req.body;
|
||||||
const { id, mfaEnabled } = req.user;
|
const { id, mfaEnabled } = req.user;
|
||||||
@@ -88,7 +107,7 @@ export class MFAController {
|
|||||||
|
|
||||||
const updatedUser = await this.mfaService.enableMfa(id);
|
const updatedUser = await this.mfaService.enableMfa(id);
|
||||||
|
|
||||||
this.authService.issueCookie(res, updatedUser, req.browserId);
|
this.authService.issueCookie(res, updatedUser, verified, req.browserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('/disable', { rateLimit: true })
|
@Post('/disable', { rateLimit: true })
|
||||||
@@ -115,10 +134,10 @@ export class MFAController {
|
|||||||
|
|
||||||
const updatedUser = await this.userRepository.findOneByOrFail({ id: userId });
|
const updatedUser = await this.userRepository.findOneByOrFail({ id: userId });
|
||||||
|
|
||||||
this.authService.issueCookie(res, updatedUser, req.browserId);
|
this.authService.issueCookie(res, updatedUser, false, req.browserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('/verify', { rateLimit: true })
|
@Post('/verify', { rateLimit: true, allowSkipMFA: true })
|
||||||
async verifyMFA(req: MFA.Verify) {
|
async verifyMFA(req: MFA.Verify) {
|
||||||
const { id } = req.user;
|
const { id } = req.user;
|
||||||
const { mfaCode } = req.body;
|
const { mfaCode } = req.body;
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export class OwnerController {
|
|||||||
|
|
||||||
this.logger.debug('Setting isInstanceOwnerSetUp updated successfully');
|
this.logger.debug('Setting isInstanceOwnerSetUp updated successfully');
|
||||||
|
|
||||||
this.authService.issueCookie(res, owner, req.browserId);
|
this.authService.issueCookie(res, owner, req.authInfo?.usedMfa ?? false, req.browserId);
|
||||||
|
|
||||||
this.eventService.emit('instance-owner-setup', { userId: owner.id });
|
this.eventService.emit('instance-owner-setup', { userId: owner.id });
|
||||||
|
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export class PasswordResetController {
|
|||||||
|
|
||||||
this.logger.info('User password updated successfully', { userId: user.id });
|
this.logger.info('User password updated successfully', { userId: user.id });
|
||||||
|
|
||||||
this.authService.issueCookie(res, user, req.browserId);
|
this.authService.issueCookie(res, user, user.mfaEnabled, req.browserId);
|
||||||
|
|
||||||
this.eventService.emit('user-updated', { user, fieldsChanged: ['password'] });
|
this.eventService.emit('user-updated', { user, fieldsChanged: ['password'] });
|
||||||
|
|
||||||
|
|||||||
2
packages/cli/src/mfa/constants.ts
Normal file
2
packages/cli/src/mfa/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const MFA_FEATURE_ENABLED = 'mfa.enabled';
|
||||||
|
export const MFA_ENFORCE_SETTING = 'mfa.enforced';
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { UserRepository } from '@n8n/db';
|
import { LicenseState, Logger } from '@n8n/backend-common';
|
||||||
|
import { SettingsRepository, UserRepository } from '@n8n/db';
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
import { Cipher } from 'n8n-core';
|
import { Cipher } from 'n8n-core';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
@@ -6,20 +7,61 @@ import { v4 as uuid } from 'uuid';
|
|||||||
import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error';
|
import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error';
|
||||||
import { InvalidMfaRecoveryCodeError } from '@/errors/response-errors/invalid-mfa-recovery-code-error';
|
import { InvalidMfaRecoveryCodeError } from '@/errors/response-errors/invalid-mfa-recovery-code-error';
|
||||||
|
|
||||||
|
import { MFA_ENFORCE_SETTING } from './constants';
|
||||||
import { TOTPService } from './totp.service';
|
import { TOTPService } from './totp.service';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class MfaService {
|
export class MfaService {
|
||||||
|
private enforceMFAValue: boolean = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private userRepository: UserRepository,
|
private userRepository: UserRepository,
|
||||||
|
private settingsRepository: SettingsRepository,
|
||||||
|
private license: LicenseState,
|
||||||
public totp: TOTPService,
|
public totp: TOTPService,
|
||||||
private cipher: Cipher,
|
private cipher: Cipher,
|
||||||
|
private logger: Logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
await this.loadMFASettings();
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
this.logger.warn('Failed to load MFA settings', { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
generateRecoveryCodes(n = 10) {
|
generateRecoveryCodes(n = 10) {
|
||||||
return Array.from(Array(n)).map(() => uuid());
|
return Array.from(Array(n)).map(() => uuid());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadMFASettings() {
|
||||||
|
const value = await this.settingsRepository.findByKey(MFA_ENFORCE_SETTING);
|
||||||
|
if (value) {
|
||||||
|
this.enforceMFAValue = value.value === 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async enforceMFA(value: boolean) {
|
||||||
|
if (!this.license.isMFAEnforcementLicensed()) {
|
||||||
|
value = false; // If the license does not allow MFA enforcement, set it to false
|
||||||
|
}
|
||||||
|
await this.settingsRepository.upsert(
|
||||||
|
{
|
||||||
|
key: MFA_ENFORCE_SETTING,
|
||||||
|
value: `${value}`,
|
||||||
|
loadOnStartup: true,
|
||||||
|
},
|
||||||
|
['key'],
|
||||||
|
);
|
||||||
|
this.enforceMFAValue = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
isMFAEnforced() {
|
||||||
|
return this.license.isMFAEnforcementLicensed() && this.enforceMFAValue;
|
||||||
|
}
|
||||||
|
|
||||||
async saveSecretAndRecoveryCodes(userId: string, secret: string, recoveryCodes: string[]) {
|
async saveSecretAndRecoveryCodes(userId: string, secret: string, recoveryCodes: string[]) {
|
||||||
const { encryptedSecret, encryptedRecoveryCodes } = this.encryptSecretAndRecoveryCodes(
|
const { encryptedSecret, encryptedRecoveryCodes } = this.encryptSecretAndRecoveryCodes(
|
||||||
secret,
|
secret,
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export class Push extends TypedEmitter<PushEvents> {
|
|||||||
app.use(
|
app.use(
|
||||||
`/${restEndpoint}/push`,
|
`/${restEndpoint}/push`,
|
||||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||||
this.authService.authMiddleware,
|
this.authService.createAuthMiddleware(false),
|
||||||
(req: SSEPushRequest | WebSocketPushRequest, res: PushResponse) =>
|
(req: SSEPushRequest | WebSocketPushRequest, res: PushResponse) =>
|
||||||
this.handleRequest(req, res),
|
this.handleRequest(req, res),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ export declare namespace UserRequest {
|
|||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
|
||||||
export declare namespace MFA {
|
export declare namespace MFA {
|
||||||
|
type Enforce = AuthenticatedRequest<{}, {}, { enforce: boolean }, {}>;
|
||||||
type Verify = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
|
type Verify = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
|
||||||
type Activate = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
|
type Activate = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
|
||||||
type Disable = AuthenticatedRequest<{}, {}, { mfaCode?: string; mfaRecoveryCode?: string }, {}>;
|
type Disable = AuthenticatedRequest<{}, {}, { mfaCode?: string; mfaRecoveryCode?: string }, {}>;
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import '@/evaluation.ee/test-runs.controller.ee';
|
|||||||
import '@/workflows/workflow-history.ee/workflow-history.controller.ee';
|
import '@/workflows/workflow-history.ee/workflow-history.controller.ee';
|
||||||
import '@/workflows/workflows.controller';
|
import '@/workflows/workflows.controller';
|
||||||
import '@/webhooks/webhooks.controller';
|
import '@/webhooks/webhooks.controller';
|
||||||
|
import { MfaService } from './mfa/mfa.service';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class Server extends AbstractServer {
|
export class Server extends AbstractServer {
|
||||||
@@ -125,6 +126,7 @@ export class Server extends AbstractServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isMfaFeatureEnabled()) {
|
if (isMfaFeatureEnabled()) {
|
||||||
|
await Container.get(MfaService).init();
|
||||||
await import('@/controllers/mfa.controller');
|
await import('@/controllers/mfa.controller');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ describe('HooksService', () => {
|
|||||||
const settingsRepository = mock<SettingsRepository>();
|
const settingsRepository = mock<SettingsRepository>();
|
||||||
const workflowRepository = mock<WorkflowRepository>();
|
const workflowRepository = mock<WorkflowRepository>();
|
||||||
const credentialsRepository = mock<CredentialsRepository>();
|
const credentialsRepository = mock<CredentialsRepository>();
|
||||||
|
|
||||||
|
const authMiddleware = jest.fn();
|
||||||
|
|
||||||
|
authService.createAuthMiddleware.mockReturnValue(authMiddleware);
|
||||||
|
|
||||||
const hooksService = new HooksService(
|
const hooksService = new HooksService(
|
||||||
userService,
|
userService,
|
||||||
authService,
|
authService,
|
||||||
@@ -49,12 +54,13 @@ describe('HooksService', () => {
|
|||||||
it('hooksService.issueCookie should call authService.issueCookie', async () => {
|
it('hooksService.issueCookie should call authService.issueCookie', async () => {
|
||||||
// ARRANGE
|
// ARRANGE
|
||||||
const res = mock<Response>();
|
const res = mock<Response>();
|
||||||
|
mockedUser.mfaEnabled = false; // Mock mfaEnabled property
|
||||||
|
|
||||||
// ACT
|
// ACT
|
||||||
hooksService.issueCookie(res, mockedUser);
|
hooksService.issueCookie(res, mockedUser);
|
||||||
|
|
||||||
// ASSERT
|
// ASSERT
|
||||||
expect(authService.issueCookie).toHaveBeenCalledWith(res, mockedUser);
|
expect(authService.issueCookie).toHaveBeenCalledWith(res, mockedUser, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hooksService.findOneUser should call userRepository.findOne', async () => {
|
it('hooksService.findOneUser should call userRepository.findOne', async () => {
|
||||||
@@ -134,7 +140,7 @@ describe('HooksService', () => {
|
|||||||
await hooksService.authMiddleware(req, res, next);
|
await hooksService.authMiddleware(req, res, next);
|
||||||
|
|
||||||
// ASSERT
|
// ASSERT
|
||||||
expect(authService.authMiddleware).toHaveBeenCalledWith(req, res, next);
|
expect(authMiddleware).toHaveBeenCalledWith(req, res, next);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hooksService.dbCollections should return valid repositories', async () => {
|
it('hooksService.dbCollections should return valid repositories', async () => {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { CredentialsOverwrites } from '@/credentials-overwrites';
|
|||||||
import { getLdapLoginLabel } from '@/ldap.ee/helpers.ee';
|
import { getLdapLoginLabel } from '@/ldap.ee/helpers.ee';
|
||||||
import { License } from '@/license';
|
import { License } from '@/license';
|
||||||
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||||
|
import { MfaService } from '@/mfa/mfa.service';
|
||||||
import { isApiEnabled } from '@/public-api';
|
import { isApiEnabled } from '@/public-api';
|
||||||
import { PushConfig } from '@/push/push.config';
|
import { PushConfig } from '@/push/push.config';
|
||||||
import type { CommunityPackagesService } from '@/services/community-packages.service';
|
import type { CommunityPackagesService } from '@/services/community-packages.service';
|
||||||
@@ -51,6 +52,7 @@ export class FrontendService {
|
|||||||
private readonly binaryDataConfig: BinaryDataConfig,
|
private readonly binaryDataConfig: BinaryDataConfig,
|
||||||
private readonly licenseState: LicenseState,
|
private readonly licenseState: LicenseState,
|
||||||
private readonly moduleRegistry: ModuleRegistry,
|
private readonly moduleRegistry: ModuleRegistry,
|
||||||
|
private readonly mfaService: MfaService,
|
||||||
) {
|
) {
|
||||||
loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes());
|
loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes());
|
||||||
void this.generateTypes();
|
void this.generateTypes();
|
||||||
@@ -195,6 +197,7 @@ export class FrontendService {
|
|||||||
ldap: false,
|
ldap: false,
|
||||||
saml: false,
|
saml: false,
|
||||||
oidc: false,
|
oidc: false,
|
||||||
|
mfaEnforcement: false,
|
||||||
logStreaming: false,
|
logStreaming: false,
|
||||||
advancedExecutionFilters: false,
|
advancedExecutionFilters: false,
|
||||||
variables: false,
|
variables: false,
|
||||||
@@ -216,6 +219,7 @@ export class FrontendService {
|
|||||||
},
|
},
|
||||||
mfa: {
|
mfa: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
enforced: false,
|
||||||
},
|
},
|
||||||
hideUsagePage: this.globalConfig.hideUsagePage,
|
hideUsagePage: this.globalConfig.hideUsagePage,
|
||||||
license: {
|
license: {
|
||||||
@@ -321,6 +325,7 @@ export class FrontendService {
|
|||||||
ldap: this.license.isLdapEnabled(),
|
ldap: this.license.isLdapEnabled(),
|
||||||
saml: this.license.isSamlEnabled(),
|
saml: this.license.isSamlEnabled(),
|
||||||
oidc: this.licenseState.isOidcLicensed(),
|
oidc: this.licenseState.isOidcLicensed(),
|
||||||
|
mfaEnforcement: this.licenseState.isMFAEnforcementLicensed(),
|
||||||
advancedExecutionFilters: this.license.isAdvancedExecutionFiltersEnabled(),
|
advancedExecutionFilters: this.license.isAdvancedExecutionFiltersEnabled(),
|
||||||
variables: this.license.isVariablesEnabled(),
|
variables: this.license.isVariablesEnabled(),
|
||||||
sourceControl: this.license.isSourceControlLicensed(),
|
sourceControl: this.license.isSourceControlLicensed(),
|
||||||
@@ -385,6 +390,9 @@ export class FrontendService {
|
|||||||
|
|
||||||
this.settings.mfa.enabled = this.globalConfig.mfa.enabled;
|
this.settings.mfa.enabled = this.globalConfig.mfa.enabled;
|
||||||
|
|
||||||
|
// TODO: read from settings
|
||||||
|
this.settings.mfa.enforced = this.mfaService.isMFAEnforced();
|
||||||
|
|
||||||
this.settings.executionMode = config.getEnv('executions.mode');
|
this.settings.executionMode = config.getEnv('executions.mode');
|
||||||
|
|
||||||
this.settings.binaryDataMode = this.binaryDataConfig.mode;
|
this.settings.binaryDataMode = this.binaryDataConfig.mode;
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ import { UserService } from '@/services/user.service';
|
|||||||
*/
|
*/
|
||||||
@Service()
|
@Service()
|
||||||
export class HooksService {
|
export class HooksService {
|
||||||
|
private innerAuthMiddleware: (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly userService: UserService,
|
private readonly userService: UserService,
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
@@ -35,7 +41,9 @@ export class HooksService {
|
|||||||
private readonly settingsRepository: SettingsRepository,
|
private readonly settingsRepository: SettingsRepository,
|
||||||
private readonly workflowRepository: WorkflowRepository,
|
private readonly workflowRepository: WorkflowRepository,
|
||||||
private readonly credentialsRepository: CredentialsRepository,
|
private readonly credentialsRepository: CredentialsRepository,
|
||||||
) {}
|
) {
|
||||||
|
this.innerAuthMiddleware = authService.createAuthMiddleware(false);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invite users to instance during signup
|
* Invite users to instance during signup
|
||||||
@@ -49,7 +57,10 @@ export class HooksService {
|
|||||||
* the user after instance is provisioned
|
* the user after instance is provisioned
|
||||||
*/
|
*/
|
||||||
issueCookie(res: Response, user: User) {
|
issueCookie(res: Response, user: User) {
|
||||||
return this.authService.issueCookie(res, user);
|
// TODO: The information on user has mfa enabled here, is missing!!
|
||||||
|
// This could be a security problem!!
|
||||||
|
// This is in just for the hackmation!!
|
||||||
|
return this.authService.issueCookie(res, user, user.mfaEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,7 +116,7 @@ export class HooksService {
|
|||||||
* 1. To authenticate the /proxy routes in the hooks
|
* 1. To authenticate the /proxy routes in the hooks
|
||||||
*/
|
*/
|
||||||
async authMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
async authMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
return await this.authService.authMiddleware(req, res, next);
|
return await this.innerAuthMiddleware(req, res, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
getRudderStackClient(key: string, options: constructorOptions): RudderStack {
|
getRudderStackClient(key: string, options: constructorOptions): RudderStack {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export class UserService {
|
|||||||
inviterId?: string;
|
inviterId?: string;
|
||||||
posthog?: PostHogClient;
|
posthog?: PostHogClient;
|
||||||
withScopes?: boolean;
|
withScopes?: boolean;
|
||||||
|
mfaAuthenticated?: boolean;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { password, updatedAt, authIdentities, mfaRecoveryCodes, mfaSecret, ...rest } = user;
|
const { password, updatedAt, authIdentities, mfaRecoveryCodes, mfaSecret, ...rest } = user;
|
||||||
@@ -90,6 +91,8 @@ export class UserService {
|
|||||||
publicUser.globalScopes = getGlobalScopes(user);
|
publicUser.globalScopes = getGlobalScopes(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
publicUser.mfaAuthenticated = options?.mfaAuthenticated ?? false;
|
||||||
|
|
||||||
return publicUser;
|
return publicUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export class OidcController {
|
|||||||
|
|
||||||
const user = await this.oidcService.loginUser(callbackUrl);
|
const user = await this.oidcService.loginUser(callbackUrl);
|
||||||
|
|
||||||
this.authService.issueCookie(res, user);
|
this.authService.issueCookie(res, user, false);
|
||||||
|
|
||||||
res.redirect('/');
|
res.redirect('/');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export class SamlController {
|
|||||||
|
|
||||||
// Only sign in user if SAML is enabled, otherwise treat as test connection
|
// Only sign in user if SAML is enabled, otherwise treat as test connection
|
||||||
if (isSamlLicensedAndEnabled()) {
|
if (isSamlLicensedAndEnabled()) {
|
||||||
this.authService.issueCookie(res, loginResult.authenticatedUser, req.browserId);
|
this.authService.issueCookie(res, loginResult.authenticatedUser, false, req.browserId);
|
||||||
if (loginResult.onboardingRequired) {
|
if (loginResult.onboardingRequired) {
|
||||||
return res.redirect(this.urlService.getInstanceBaseUrl() + '/saml/onboarding');
|
return res.redirect(this.urlService.getInstanceBaseUrl() + '/saml/onboarding');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { randomValidPassword, uniqueId } from '@n8n/backend-test-utils';
|
import { randomValidPassword, uniqueId } from '@n8n/backend-test-utils';
|
||||||
import { testDb } from '@n8n/backend-test-utils';
|
import { testDb } from '@n8n/backend-test-utils';
|
||||||
import { mockInstance } from '@n8n/backend-test-utils';
|
import { mockInstance } from '@n8n/backend-test-utils';
|
||||||
import { UserRepository, type User } from '@n8n/db';
|
import { LICENSE_FEATURES } from '@n8n/constants';
|
||||||
|
import { SettingsRepository, UserRepository, type User } from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import { randomString } from 'n8n-workflow';
|
import { randomString } from 'n8n-workflow';
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ import { AuthService } from '@/auth/auth.service';
|
|||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
import { ExternalHooks } from '@/external-hooks';
|
import { ExternalHooks } from '@/external-hooks';
|
||||||
|
import { MFA_ENFORCE_SETTING } from '@/mfa/constants';
|
||||||
import { TOTPService } from '@/mfa/totp.service';
|
import { TOTPService } from '@/mfa/totp.service';
|
||||||
|
|
||||||
import { createOwner, createUser, createUserWithMfaEnabled } from '../shared/db/users';
|
import { createOwner, createUser, createUserWithMfaEnabled } from '../shared/db/users';
|
||||||
@@ -22,6 +24,7 @@ const externalHooks = mockInstance(ExternalHooks);
|
|||||||
|
|
||||||
const testServer = utils.setupTestServer({
|
const testServer = utils.setupTestServer({
|
||||||
endpointGroups: ['mfa', 'auth', 'me', 'passwordReset'],
|
endpointGroups: ['mfa', 'auth', 'me', 'passwordReset'],
|
||||||
|
enabledFeatures: [LICENSE_FEATURES.MFA_ENFORCEMENT],
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -405,3 +408,61 @@ describe('Login', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Enforce MFA', () => {
|
||||||
|
test('Enforce MFA for the instance', async () => {
|
||||||
|
const settingsRepository = Container.get(SettingsRepository);
|
||||||
|
|
||||||
|
await settingsRepository.delete({
|
||||||
|
key: MFA_ENFORCE_SETTING,
|
||||||
|
});
|
||||||
|
|
||||||
|
let enforced = await settingsRepository.findByKey(MFA_ENFORCE_SETTING);
|
||||||
|
|
||||||
|
expect(enforced).toBe(null);
|
||||||
|
|
||||||
|
owner.mfaEnabled = true;
|
||||||
|
await testServer
|
||||||
|
.authAgentFor(owner)
|
||||||
|
.post('/mfa/enforce-mfa')
|
||||||
|
.send({ enforce: true })
|
||||||
|
.expect(200);
|
||||||
|
owner.mfaEnabled = false;
|
||||||
|
|
||||||
|
enforced = await settingsRepository.findByKey(MFA_ENFORCE_SETTING);
|
||||||
|
|
||||||
|
expect(enforced?.value).toBe('true');
|
||||||
|
|
||||||
|
await settingsRepository.delete({
|
||||||
|
key: MFA_ENFORCE_SETTING,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Disable MFA for the instance', async () => {
|
||||||
|
const settingsRepository = Container.get(SettingsRepository);
|
||||||
|
|
||||||
|
await settingsRepository.delete({
|
||||||
|
key: MFA_ENFORCE_SETTING,
|
||||||
|
});
|
||||||
|
|
||||||
|
let enforced = await settingsRepository.findByKey(MFA_ENFORCE_SETTING);
|
||||||
|
|
||||||
|
expect(enforced).toBe(null);
|
||||||
|
|
||||||
|
owner.mfaEnabled = true;
|
||||||
|
await testServer
|
||||||
|
.authAgentFor(owner)
|
||||||
|
.post('/mfa/enforce-mfa')
|
||||||
|
.send({ enforce: false })
|
||||||
|
.expect(200);
|
||||||
|
owner.mfaEnabled = false;
|
||||||
|
|
||||||
|
enforced = await settingsRepository.findByKey(MFA_ENFORCE_SETTING);
|
||||||
|
|
||||||
|
expect(enforced?.value).toBe('false');
|
||||||
|
|
||||||
|
await settingsRepository.delete({
|
||||||
|
key: MFA_ENFORCE_SETTING,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -58,7 +58,11 @@ function createAgent(
|
|||||||
if (withRestSegment) void agent.use(prefix(REST_PATH_SEGMENT));
|
if (withRestSegment) void agent.use(prefix(REST_PATH_SEGMENT));
|
||||||
|
|
||||||
if (options?.auth && options?.user) {
|
if (options?.auth && options?.user) {
|
||||||
const token = Container.get(AuthService).issueJWT(options.user, browserId);
|
const token = Container.get(AuthService).issueJWT(
|
||||||
|
options.user,
|
||||||
|
options.user.mfaEnabled,
|
||||||
|
browserId,
|
||||||
|
);
|
||||||
agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`);
|
agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`);
|
||||||
}
|
}
|
||||||
return agent;
|
return agent;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface UsersInfoProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
settings?: object;
|
settings?: object;
|
||||||
isSamlLoginEnabled?: boolean;
|
isSamlLoginEnabled?: boolean;
|
||||||
|
mfaEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<UsersInfoProps>(), {
|
const props = withDefaults(defineProps<UsersInfoProps>(), {
|
||||||
@@ -55,6 +56,13 @@ const classes = computed(
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<N8nText data-test-id="user-email" size="small" color="text-light">{{ email }}</N8nText>
|
<N8nText data-test-id="user-email" size="small" color="text-light">{{ email }}</N8nText>
|
||||||
|
<N8nText color="text-light"> | </N8nText>
|
||||||
|
<N8nText
|
||||||
|
data-test-id="user-mfa-state"
|
||||||
|
size="small"
|
||||||
|
:color="mfaEnabled ? 'text-light' : 'warning'"
|
||||||
|
>{{ mfaEnabled ? '2FA Enabled' : '2FA Disabled' }}</N8nText
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type IUser = {
|
|||||||
isPendingUser?: boolean;
|
isPendingUser?: boolean;
|
||||||
inviteAcceptUrl?: string;
|
inviteAcceptUrl?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
mfaEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface UserAction<UserType extends IUser> {
|
export interface UserAction<UserType extends IUser> {
|
||||||
|
|||||||
@@ -2970,6 +2970,16 @@
|
|||||||
"settings.personal.mfa.toast.disabledMfa.message": "You will no longer need your authenticator app when signing in",
|
"settings.personal.mfa.toast.disabledMfa.message": "You will no longer need your authenticator app when signing in",
|
||||||
"settings.personal.mfa.toast.disabledMfa.error.message": "Error disabling two-factor authentication",
|
"settings.personal.mfa.toast.disabledMfa.error.message": "Error disabling two-factor authentication",
|
||||||
"settings.personal.mfa.toast.canEnableMfa.title": "MFA pre-requisite failed",
|
"settings.personal.mfa.toast.canEnableMfa.title": "MFA pre-requisite failed",
|
||||||
|
"settings.personal.mfa.enforced": "The settings on this instance <strong>require you to set up 2FA</strong>. Please enable it to continue working in this instance.",
|
||||||
|
"settings.personal.mfa.enforce.message": "Enforces 2FA for all users on this instance.",
|
||||||
|
"settings.personal.mfa.enforce.unlicensed_tooltip": "You can enforce 2FA for all users on this instance when you upgrade your plan. {action}",
|
||||||
|
"settings.personal.mfa.enforce.unlicensed_tooltip.link": "View plans",
|
||||||
|
"settings.personal.mfa.enforce.title": "Enforce two-factor authentication",
|
||||||
|
"settings.personal.mfa.enforce.error": "Cannot enforce 2FA for all users",
|
||||||
|
"settings.personal.mfa.enforce.enabled.title": "2FA Enforced",
|
||||||
|
"settings.personal.mfa.enforce.enabled.message": "Two-factor authentication is now required for all users on this instance.",
|
||||||
|
"settings.personal.mfa.enforce.disabled.title": "2FA No Longer Enforced",
|
||||||
|
"settings.personal.mfa.enforce.disabled.message": "Two-factor authentication is no longer mandatory for users on this instance.",
|
||||||
"settings.mfa.toast.noRecoveryCodeLeft.title": "No 2FA recovery codes remaining",
|
"settings.mfa.toast.noRecoveryCodeLeft.title": "No 2FA recovery codes remaining",
|
||||||
"settings.mfa.toast.noRecoveryCodeLeft.message": "You have used all of your recovery codes. Disable then re-enable two-factor authentication to generate new codes. <a href='/settings/personal' target='_blank' >Open settings</a>",
|
"settings.mfa.toast.noRecoveryCodeLeft.message": "You have used all of your recovery codes. Disable then re-enable two-factor authentication to generate new codes. <a href='/settings/personal' target='_blank' >Open settings</a>",
|
||||||
"sso.login.divider": "or",
|
"sso.login.divider": "or",
|
||||||
|
|||||||
@@ -33,3 +33,9 @@ export type DisableMfaParams = {
|
|||||||
export async function disableMfa(context: IRestApiContext, data: DisableMfaParams): Promise<void> {
|
export async function disableMfa(context: IRestApiContext, data: DisableMfaParams): Promise<void> {
|
||||||
return await makeRestApiRequest(context, 'POST', '/mfa/disable', data);
|
return await makeRestApiRequest(context, 'POST', '/mfa/disable', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateEnforceMfa(context: IRestApiContext, enforce: boolean): Promise<void> {
|
||||||
|
return await makeRestApiRequest(context, 'POST', '/mfa/enforce-mfa', {
|
||||||
|
enforce,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ const getBrowserId = () => {
|
|||||||
export const NO_NETWORK_ERROR_CODE = 999;
|
export const NO_NETWORK_ERROR_CODE = 999;
|
||||||
export const STREAM_SEPERATOR = '⧉⇋⇋➽⌑⧉§§\n';
|
export const STREAM_SEPERATOR = '⧉⇋⇋➽⌑⧉§§\n';
|
||||||
|
|
||||||
|
export class MfaRequiredError extends ApplicationError {
|
||||||
|
constructor() {
|
||||||
|
super('MFA is required to access this resource. Please set up MFA in your user settings.');
|
||||||
|
this.name = 'MfaRequiredError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class ResponseError extends ApplicationError {
|
export class ResponseError extends ApplicationError {
|
||||||
// The HTTP status code of response
|
// The HTTP status code of response
|
||||||
httpStatusCode?: number;
|
httpStatusCode?: number;
|
||||||
@@ -114,6 +121,9 @@ export async function request(config: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errorResponseData = error.response?.data;
|
const errorResponseData = error.response?.data;
|
||||||
|
if (errorResponseData?.mfaRequired === true) {
|
||||||
|
throw new MfaRequiredError();
|
||||||
|
}
|
||||||
if (errorResponseData?.message !== undefined) {
|
if (errorResponseData?.message !== undefined) {
|
||||||
if (errorResponseData.name === 'NodeApiError') {
|
if (errorResponseData.name === 'NodeApiError') {
|
||||||
errorResponseData.httpStatusCode = error.response.status;
|
errorResponseData.httpStatusCode = error.response.status;
|
||||||
|
|||||||
@@ -585,6 +585,7 @@ export interface IUser extends IUserResponse {
|
|||||||
fullName?: string;
|
fullName?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
mfaEnabled: boolean;
|
mfaEnabled: boolean;
|
||||||
|
mfaAuthenticated?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IUserListAction {
|
export interface IUserListAction {
|
||||||
@@ -1320,7 +1321,8 @@ export type EnterpriseEditionFeatureKey =
|
|||||||
| 'WorkflowHistory'
|
| 'WorkflowHistory'
|
||||||
| 'WorkerView'
|
| 'WorkerView'
|
||||||
| 'AdvancedPermissions'
|
| 'AdvancedPermissions'
|
||||||
| 'ApiKeyScopes';
|
| 'ApiKeyScopes'
|
||||||
|
| 'EnforceMFA';
|
||||||
|
|
||||||
export type EnterpriseEditionFeatureValue = keyof Omit<FrontendSettings['enterprise'], 'projects'>;
|
export type EnterpriseEditionFeatureValue = keyof Omit<FrontendSettings['enterprise'], 'projects'>;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const defaultSettings: FrontendSettings = {
|
|||||||
ldap: false,
|
ldap: false,
|
||||||
oidc: false,
|
oidc: false,
|
||||||
saml: false,
|
saml: false,
|
||||||
|
mfaEnforcement: false,
|
||||||
logStreaming: false,
|
logStreaming: false,
|
||||||
debugInEditor: false,
|
debugInEditor: false,
|
||||||
advancedExecutionFilters: false,
|
advancedExecutionFilters: false,
|
||||||
@@ -122,6 +123,7 @@ export const defaultSettings: FrontendSettings = {
|
|||||||
previewMode: false,
|
previewMode: false,
|
||||||
mfa: {
|
mfa: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
enforced: false,
|
||||||
},
|
},
|
||||||
askAi: {
|
askAi: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ export function createMockEnterpriseSettings(
|
|||||||
ldap: false,
|
ldap: false,
|
||||||
saml: false,
|
saml: false,
|
||||||
oidc: false,
|
oidc: false,
|
||||||
|
mfaEnforcement: false,
|
||||||
logStreaming: false,
|
logStreaming: false,
|
||||||
advancedExecutionFilters: false,
|
advancedExecutionFilters: false,
|
||||||
variables: false,
|
variables: false,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH,
|
MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH,
|
||||||
MFA_AUTHENTICATION_CODE_WINDOW_EXPIRED,
|
MFA_AUTHENTICATION_CODE_WINDOW_EXPIRED,
|
||||||
MFA_SETUP_MODAL_KEY,
|
MFA_SETUP_MODAL_KEY,
|
||||||
|
VIEWS,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
@@ -13,6 +14,8 @@ import { useToast } from '@/composables/useToast';
|
|||||||
import QrcodeVue from 'qrcode.vue';
|
import QrcodeVue from 'qrcode.vue';
|
||||||
import { useClipboard } from '@/composables/useClipboard';
|
import { useClipboard } from '@/composables/useClipboard';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import router from '@/router';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// #region Reactive properties
|
// #region Reactive properties
|
||||||
@@ -39,6 +42,7 @@ const loadingQrCode = ref(true);
|
|||||||
|
|
||||||
const clipboard = useClipboard();
|
const clipboard = useClipboard();
|
||||||
const userStore = useUsersStore();
|
const userStore = useUsersStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
@@ -104,6 +108,10 @@ const onSetupClick = async () => {
|
|||||||
type: 'success',
|
type: 'success',
|
||||||
title: i18n.baseText('mfa.setup.step2.toast.setupFinished.message'),
|
title: i18n.baseText('mfa.setup.step2.toast.setupFinished.message'),
|
||||||
});
|
});
|
||||||
|
if (settingsStore.isMFAEnforced) {
|
||||||
|
await userStore.logout();
|
||||||
|
await router.push({ name: VIEWS.SIGNIN });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.errorCode === MFA_AUTHENTICATION_CODE_WINDOW_EXPIRED) {
|
if (e.errorCode === MFA_AUTHENTICATION_CODE_WINDOW_EXPIRED) {
|
||||||
toast.showMessage({
|
toast.showMessage({
|
||||||
@@ -227,7 +235,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<n8n-info-tip :bold="false" :class="$style['edit-mode-footer-infotip']">
|
<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>
|
<template #part1>
|
||||||
{{ i18n.baseText('mfa.setup.step2.infobox.description.part1') }}
|
{{ i18n.baseText('mfa.setup.step2.infobox.description.part1') }}
|
||||||
</template>
|
</template>
|
||||||
@@ -236,7 +244,7 @@ onMounted(async () => {
|
|||||||
{{ i18n.baseText('mfa.setup.step2.infobox.description.part2') }}
|
{{ i18n.baseText('mfa.setup.step2.infobox.description.part2') }}
|
||||||
</n8n-text>
|
</n8n-text>
|
||||||
</template>
|
</template>
|
||||||
</i18nn-t>
|
</i18n-t>
|
||||||
</n8n-info-tip>
|
</n8n-info-tip>
|
||||||
<div>
|
<div>
|
||||||
<n8n-button
|
<n8n-button
|
||||||
|
|||||||
@@ -651,6 +651,7 @@ export const EnterpriseEditionFeature: Record<
|
|||||||
Variables: 'variables',
|
Variables: 'variables',
|
||||||
Saml: 'saml',
|
Saml: 'saml',
|
||||||
Oidc: 'oidc',
|
Oidc: 'oidc',
|
||||||
|
EnforceMFA: 'mfaEnforcement',
|
||||||
SourceControl: 'sourceControl',
|
SourceControl: 'sourceControl',
|
||||||
ExternalSecrets: 'externalSecrets',
|
ExternalSecrets: 'externalSecrets',
|
||||||
AuditLogs: 'auditLogs',
|
AuditLogs: 'auditLogs',
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { tryToParseNumber } from '@/utils/typesUtils';
|
|||||||
import { projectsRoutes } from '@/routes/projects.routes';
|
import { projectsRoutes } from '@/routes/projects.routes';
|
||||||
import { insightsRoutes } from '@/features/insights/insights.router';
|
import { insightsRoutes } from '@/features/insights/insights.router';
|
||||||
import TestRunDetailView from '@/views/Evaluations.ee/TestRunDetailView.vue';
|
import TestRunDetailView from '@/views/Evaluations.ee/TestRunDetailView.vue';
|
||||||
|
import { MfaRequiredError } from '@n8n/rest-api-client';
|
||||||
|
|
||||||
const ChangePasswordView = async () => await import('./views/ChangePasswordView.vue');
|
const ChangePasswordView = async () => await import('./views/ChangePasswordView.vue');
|
||||||
const ErrorView = async () => await import('./views/ErrorView.vue');
|
const ErrorView = async () => await import('./views/ErrorView.vue');
|
||||||
@@ -832,6 +833,14 @@ router.beforeEach(async (to: RouteLocationNormalized, from, next) => {
|
|||||||
|
|
||||||
return next();
|
return next();
|
||||||
} catch (failure) {
|
} 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)) {
|
if (isNavigationFailure(failure)) {
|
||||||
console.log(failure);
|
console.log(failure);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
|||||||
const saveDataSuccessExecution = ref<WorkflowSettings.SaveDataExecution>('all');
|
const saveDataSuccessExecution = ref<WorkflowSettings.SaveDataExecution>('all');
|
||||||
const saveManualExecutions = ref(false);
|
const saveManualExecutions = ref(false);
|
||||||
const saveDataProgressExecution = ref(false);
|
const saveDataProgressExecution = ref(false);
|
||||||
|
const isMFAEnforced = ref(false);
|
||||||
|
|
||||||
const isDocker = computed(() => settings.value?.isDocker ?? 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,
|
() => settings.value.telemetry && settings.value.telemetry.enabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isMFAEnforcementLicensed = computed(() => {
|
||||||
|
return settings.value.enterprise?.mfaEnforcement ?? false;
|
||||||
|
});
|
||||||
|
|
||||||
const isMfaFeatureEnabled = computed(() => mfa.value.enabled);
|
const isMfaFeatureEnabled = computed(() => mfa.value.enabled);
|
||||||
|
|
||||||
const isFoldersFeatureEnabled = computed(() => folders.value.enabled);
|
const isFoldersFeatureEnabled = computed(() => folders.value.enabled);
|
||||||
@@ -235,6 +240,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
|||||||
setSaveDataProgressExecution(fetchedSettings.saveExecutionProgress);
|
setSaveDataProgressExecution(fetchedSettings.saveExecutionProgress);
|
||||||
setSaveManualExecutions(fetchedSettings.saveManualExecutions);
|
setSaveManualExecutions(fetchedSettings.saveManualExecutions);
|
||||||
|
|
||||||
|
isMFAEnforced.value = settings.value.mfa?.enforced ?? false;
|
||||||
|
|
||||||
rootStore.setUrlBaseWebhook(fetchedSettings.urlBaseWebhook);
|
rootStore.setUrlBaseWebhook(fetchedSettings.urlBaseWebhook);
|
||||||
rootStore.setUrlBaseEditor(fetchedSettings.urlBaseEditor);
|
rootStore.setUrlBaseEditor(fetchedSettings.urlBaseEditor);
|
||||||
rootStore.setEndpointForm(fetchedSettings.endpointForm);
|
rootStore.setEndpointForm(fetchedSettings.endpointForm);
|
||||||
@@ -391,5 +398,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
|||||||
activeModules,
|
activeModules,
|
||||||
getModuleSettings,
|
getModuleSettings,
|
||||||
moduleSettings,
|
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 () => {
|
const sendConfirmationEmail = async () => {
|
||||||
await cloudApi.sendConfirmationEmail(rootStore.restApiContext);
|
await cloudApi.sendConfirmationEmail(rootStore.restApiContext);
|
||||||
};
|
};
|
||||||
@@ -466,6 +471,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
|||||||
verifyMfaCode,
|
verifyMfaCode,
|
||||||
enableMfa,
|
enableMfa,
|
||||||
disableMfa,
|
disableMfa,
|
||||||
|
updateEnforceMfa,
|
||||||
canEnableMFA,
|
canEnableMFA,
|
||||||
sendConfirmationEmail,
|
sendConfirmationEmail,
|
||||||
updateGlobalRole,
|
updateGlobalRole,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import type { RBACPermissionCheck, AuthenticatedPermissionOptions } from '@/types/rbac';
|
import type { RBACPermissionCheck, AuthenticatedPermissionOptions } from '@/types/rbac';
|
||||||
|
|
||||||
export const isAuthenticated: RBACPermissionCheck<AuthenticatedPermissionOptions> = (options) => {
|
export const isAuthenticated: RBACPermissionCheck<AuthenticatedPermissionOptions> = (options) => {
|
||||||
@@ -9,3 +10,15 @@ export const isAuthenticated: RBACPermissionCheck<AuthenticatedPermissionOptions
|
|||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
return !!usersStore.currentUser;
|
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 { useUsersStore } from '@/stores/users.store';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import type { RouteLocationNormalized } from 'vue-router';
|
import type { RouteLocationNormalized } from 'vue-router';
|
||||||
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
|
||||||
vi.mock('@/stores/users.store', () => ({
|
vi.mock('@/stores/users.store', () => ({
|
||||||
useUsersStore: vi.fn(),
|
useUsersStore: vi.fn(),
|
||||||
@@ -9,6 +10,10 @@ vi.mock('@/stores/users.store', () => ({
|
|||||||
|
|
||||||
describe('Middleware', () => {
|
describe('Middleware', () => {
|
||||||
describe('authenticated', () => {
|
describe('authenticated', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
});
|
||||||
|
|
||||||
it('should redirect to signin if no current user is present', async () => {
|
it('should redirect to signin if no current user is present', async () => {
|
||||||
vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType<
|
vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType<
|
||||||
typeof useUsersStore
|
typeof useUsersStore
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { RouterMiddleware } from '@/types/router';
|
import type { RouterMiddleware } from '@/types/router';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import type { AuthenticatedPermissionOptions } from '@/types/rbac';
|
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 (
|
export const authenticatedMiddleware: RouterMiddleware<AuthenticatedPermissionOptions> = async (
|
||||||
to,
|
to,
|
||||||
@@ -9,11 +9,23 @@ export const authenticatedMiddleware: RouterMiddleware<AuthenticatedPermissionOp
|
|||||||
next,
|
next,
|
||||||
options,
|
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);
|
const valid = isAuthenticated(options);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
const redirect =
|
|
||||||
to.query.redirect ??
|
|
||||||
encodeURIComponent(`${window.location.pathname}${window.location.search}`);
|
|
||||||
return next({ name: VIEWS.SIGNIN, query: { redirect } });
|
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 => {
|
const mfaDisabled = computed((): boolean => {
|
||||||
return !usersStore.mfaEnabled;
|
return !usersStore.mfaEnabled;
|
||||||
});
|
});
|
||||||
|
const mfaEnforced = computed((): boolean => {
|
||||||
|
return settingsStore.isMFAEnforced;
|
||||||
|
});
|
||||||
const isMfaFeatureEnabled = computed((): boolean => {
|
const isMfaFeatureEnabled = computed((): boolean => {
|
||||||
return settingsStore.isMfaFeatureEnabled;
|
return settingsStore.isMfaFeatureEnabled;
|
||||||
});
|
});
|
||||||
@@ -362,6 +364,11 @@ onBeforeUnmount(() => {
|
|||||||
</n8n-link>
|
</n8n-link>
|
||||||
</n8n-text>
|
</n8n-text>
|
||||||
</div>
|
</div>
|
||||||
|
<n8n-notice
|
||||||
|
v-if="mfaDisabled && mfaEnforced"
|
||||||
|
:content="i18n.baseText('settings.personal.mfa.enforced')"
|
||||||
|
/>
|
||||||
|
|
||||||
<n8n-button
|
<n8n-button
|
||||||
v-if="mfaDisabled"
|
v-if="mfaDisabled"
|
||||||
:class="$style.button"
|
:class="$style.button"
|
||||||
|
|||||||
@@ -89,6 +89,56 @@ describe('SettingsUsersView', () => {
|
|||||||
showError.mockReset();
|
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 () => {
|
it('hides invite button visibility based on user permissions', async () => {
|
||||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
const pinia = createTestingPinia({ initialState: getInitialState() });
|
||||||
const userStore = mockedStore(useUsersStore);
|
const userStore = mockedStore(useUsersStore);
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ const ssoStore = useSSOStore();
|
|||||||
const documentTitle = useDocumentTitle();
|
const documentTitle = useDocumentTitle();
|
||||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||||
|
|
||||||
|
const tooltipKey = 'settings.personal.mfa.enforce.unlicensed_tooltip';
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
const showUMSetupWarning = computed(() => {
|
const showUMSetupWarning = computed(() => {
|
||||||
@@ -238,6 +240,23 @@ async function onRoleChange(user: IUser, newRoleName: UpdateGlobalRolePayload['n
|
|||||||
showError(e, i18n.baseText('settings.users.userReinviteError'));
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -284,6 +303,44 @@ async function onRoleChange(user: IUser, newRoleName: UpdateGlobalRolePayload['n
|
|||||||
</template>
|
</template>
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
</n8n-notice>
|
</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.
|
<!-- 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
|
<div
|
||||||
@@ -348,4 +405,32 @@ async function onRoleChange(user: IUser, newRoleName: UpdateGlobalRolePayload['n
|
|||||||
.alert {
|
.alert {
|
||||||
left: calc(50% + 100px);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -142,6 +142,11 @@ const login = async (form: LoginRequestDto) => {
|
|||||||
|
|
||||||
toast.clearAllStickyNotifications();
|
toast.clearAllStickyNotifications();
|
||||||
|
|
||||||
|
if (settingsStore.isMFAEnforced && !usersStore.currentUser?.mfaAuthenticated) {
|
||||||
|
await router.push({ name: VIEWS.PERSONAL_SETTINGS });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
telemetry.track('User attempted to login', {
|
telemetry.track('User attempted to login', {
|
||||||
result: showMfaView.value ? 'mfa_success' : 'success',
|
result: showMfaView.value ? 'mfa_success' : 'success',
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user