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

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

View File

@@ -137,6 +137,7 @@ export interface FrontendSettings {
ldap: boolean;
saml: boolean;
oidc: boolean;
mfaEnforcement: boolean;
logStreaming: boolean;
advancedExecutionFilters: boolean;
variables: boolean;
@@ -167,6 +168,7 @@ export interface FrontendSettings {
};
mfa: {
enabled: boolean;
enforced: boolean;
};
folders: {
enabled: boolean;

View File

@@ -63,6 +63,10 @@ export class LicenseState {
return this.isLicensed('feat:oidc');
}
isMFAEnforcementLicensed() {
return this.isLicensed('feat:mfaEnforcement');
}
isApiKeyScopesLicensed() {
return this.isLicensed('feat:apiKeyScopes');
}

View File

@@ -9,6 +9,7 @@ export const LICENSE_FEATURES = {
LDAP: 'feat:ldap',
SAML: 'feat:saml',
OIDC: 'feat:oidc',
MFA_ENFORCEMENT: 'feat:mfaEnforcement',
LOG_STREAMING: 'feat:logStreaming',
ADVANCED_EXECUTION_FILTERS: 'feat:advancedExecutionFilters',
VARIABLES: 'feat:variables',

View File

@@ -114,6 +114,7 @@ export interface PublicUser {
isOwner?: boolean;
featureFlags?: FeatureFlags; // External type from n8n-workflow
lastActiveAt?: Date | null;
mfaAuthenticated?: boolean;
}
export type UserSettings = Pick<User, 'id' | 'settings'>;
@@ -367,6 +368,10 @@ export type APIRequest<
browserId?: string;
};
export type AuthenticationInformation = {
usedMfa: boolean;
};
export type AuthenticatedRequest<
RouteParams = {},
ResponseBody = {},
@@ -374,6 +379,7 @@ export type AuthenticatedRequest<
RequestQuery = {},
> = Omit<APIRequest<RouteParams, ResponseBody, RequestBody, RequestQuery>, 'user' | 'cookies'> & {
user: User;
authInfo?: AuthenticationInformation;
cookies: Record<string, string | undefined>;
headers: express.Request['headers'] & {
'push-ref': string;

View File

@@ -9,6 +9,8 @@ interface RouteOptions {
usesTemplates?: boolean;
/** When this flag is set to true, auth cookie isn't validated, and req.user will not be set */
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 */
rateLimit?: boolean | RateLimit;
}
@@ -26,6 +28,7 @@ const RouteFactory =
routeMetadata.middlewares = options.middlewares ?? [];
routeMetadata.usesTemplates = options.usesTemplates ?? false;
routeMetadata.skipAuth = options.skipAuth ?? false;
routeMetadata.allowSkipMFA = options.allowSkipMFA ?? false;
routeMetadata.rateLimit = options.rateLimit;
};

View File

@@ -33,6 +33,7 @@ export interface RouteMetadata {
middlewares: RequestHandler[];
usesTemplates: boolean;
skipAuth: boolean;
allowSkipMFA: boolean;
rateLimit?: boolean | RateLimit;
licenseFeature?: BooleanLicenseFeature;
accessScope?: AccessScope;

View File

@@ -19,7 +19,7 @@ export const RESOURCES = {
securityAudit: ['generate'] as const,
sourceControl: ['pull', 'push', 'manage'] 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,
workersView: ['manage'] 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,
securityAudit: ['generate'] 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,
credential: ['create', 'move', 'delete'] as const,
sourceControl: ['pull'] as const,

View File

@@ -6,6 +6,7 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [
'user:create',
'user:changeRole',
'user:delete',
'user:enforceMfa',
'sourceControl:pull',
'securityAudit:generate',
'project:create',

View File

@@ -56,6 +56,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'user:list',
'user:resetPassword',
'user:changeRole',
'user:enforceMfa',
'variable:create',
'variable:read',
'variable:update',

View File

@@ -27,10 +27,12 @@ describe('ControllerRegistry', () => {
const metadata = Container.get(ControllerRegistryMetadata);
const lastActiveAtService = mock<LastActiveAtService>();
let agent: SuperAgentTest;
const authMiddleware = jest.fn().mockImplementation(async (_req, _res, next) => next());
beforeEach(() => {
jest.resetAllMocks();
const app = express();
authService.createAuthMiddleware.mockImplementation(() => authMiddleware);
new ControllerRegistry(
license,
authService,
@@ -57,7 +59,7 @@ describe('ControllerRegistry', () => {
}
beforeEach(() => {
authService.authMiddleware.mockImplementation(async (_req, _res, next) => next());
authMiddleware.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 () => {
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 () => {
authService.authMiddleware.mockImplementation(async (_req, res) => {
authMiddleware.mockImplementation(async (_req, res) => {
res.status(401).send();
});
await agent.get('/rest/test/auth').expect(401);
expect(authService.authMiddleware).toHaveBeenCalled();
expect(authMiddleware).toHaveBeenCalled();
});
});
@@ -116,7 +118,7 @@ describe('ControllerRegistry', () => {
}
beforeEach(() => {
authService.authMiddleware.mockImplementation(async (_req, _res, next) => next());
authMiddleware.mockImplementation(async (_req, _res, next) => next());
lastActiveAtService.middleware.mockImplementation(async (_req, _res, next) => next());
});
@@ -145,7 +147,7 @@ describe('ControllerRegistry', () => {
}
beforeEach(() => {
authService.authMiddleware.mockImplementation(async (_req, _res, next) => next());
authMiddleware.mockImplementation(async (_req, _res, next) => next());
lastActiveAtService.middleware.mockImplementation(async (_req, _res, next) => next());
});

View File

@@ -11,6 +11,7 @@ import jwt from 'jsonwebtoken';
import { AuthService } from '@/auth/auth.service';
import config from '@/config';
import { AUTH_COOKIE_NAME } from '@/constants';
import type { MfaService } from '@/mfa/mfa.service';
import { JwtService } from '@/services/jwt.service';
import type { UrlService } from '@/services/url.service';
@@ -31,6 +32,7 @@ describe('AuthService', () => {
const urlService = mock<UrlService>();
const userRepository = mock<UserRepository>();
const invalidAuthTokenRepository = mock<InvalidAuthTokenRepository>();
const mfaService = mock<MfaService>();
const authService = new AuthService(
globalConfig,
mock(),
@@ -39,13 +41,17 @@ describe('AuthService', () => {
urlService,
userRepository,
invalidAuthTokenRepository,
mfaService,
);
const now = new Date('2024-02-01T01:23:45.678Z');
jest.useFakeTimers({ now });
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(() => {
jest.resetAllMocks();
@@ -107,7 +113,9 @@ describe('AuthService', () => {
it('should 401 if no cookie is set', async () => {
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(next).not.toHaveBeenCalled();
@@ -119,7 +127,9 @@ describe('AuthService', () => {
invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
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(userRepository.findOne).not.toHaveBeenCalled();
@@ -132,7 +142,9 @@ describe('AuthService', () => {
req.cookies[AUTH_COOKIE_NAME] = validToken;
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(userRepository.findOne).not.toHaveBeenCalled();
@@ -141,13 +153,34 @@ describe('AuthService', () => {
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 () => {
req.cookies[AUTH_COOKIE_NAME] = validToken;
jest.advanceTimersByTime(6 * Time.days.toMilliseconds);
invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
userRepository.findOne.mockResolvedValue(user);
await authService.authMiddleware(req, res, next);
const middleware = authService.createAuthMiddleware(true);
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.cookie).toHaveBeenCalledWith('n8n-auth', expect.any(String), {
@@ -162,7 +195,7 @@ describe('AuthService', () => {
describe('issueCookie', () => {
const res = mock<Response>();
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, {
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', () => {
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, {
httpOnly: true,
@@ -190,7 +234,7 @@ describe('AuthService', () => {
describe('when not setting userManagement.jwtSessionDuration', () => {
it('should default to expire in 7 days', () => {
const defaultInSeconds = 7 * Time.days.toSeconds;
const token = authService.issueJWT(user, browserId);
const token = authService.issueJWT(user, false, browserId);
expect(authService.jwtExpiration).toBe(defaultInSeconds);
const decodedToken = jwtService.verify(token);
@@ -208,7 +252,7 @@ describe('AuthService', () => {
it('should apply it to tokens', () => {
config.set('userManagement.jwtSessionDurationHours', testDurationHours);
const token = authService.issueJWT(user, browserId);
const token = authService.issueJWT(user, false, browserId);
const decodedToken = jwtService.verify(token);
if (decodedToken.exp === undefined || decodedToken.iat === undefined) {
@@ -280,11 +324,17 @@ describe('AuthService', () => {
it('should refresh the cookie before it expires', async () => {
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();
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), {
httpOnly: true,
maxAge: 604800000,
@@ -294,7 +344,7 @@ describe('AuthService', () => {
const newToken = res.cookie.mock.calls[0].at(1);
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(
(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 () => {
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();
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();
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();
});
@@ -318,11 +377,17 @@ describe('AuthService', () => {
config.set('userManagement.jwtRefreshTimeoutHours', -1);
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();
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();
});
});

View File

@@ -14,6 +14,7 @@ import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from '@/constants';
import { AuthError } from '@/errors/response-errors/auth.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { License } from '@/license';
import { MfaService } from '@/mfa/mfa.service';
import { JwtService } from '@/services/jwt.service';
import { UrlService } from '@/services/url.service';
@@ -24,6 +25,8 @@ interface AuthJwtPayload {
hash: string;
/** This is a client generated unique string to prevent session hijacking */
browserId?: string;
/** This indicates if mfa was used during the creation of this token */
usedMfa?: boolean;
}
interface IssuedJWT extends AuthJwtPayload {
@@ -48,10 +51,8 @@ export class AuthService {
private readonly urlService: UrlService,
private readonly userRepository: UserRepository,
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;
this.skipBrowserIdCheckEndpoints = [
// we need to exclude push endpoint because we can't send custom header on websocket requests
@@ -67,24 +68,44 @@ export class AuthService {
];
}
async authMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
const token = req.cookies[AUTH_COOKIE_NAME];
if (token) {
try {
const isInvalid = await this.invalidAuthTokenRepository.existsBy({ token });
if (isInvalid) throw new AuthError('Unauthorized');
req.user = await this.resolveJwt(token, req, res);
} catch (error) {
if (error instanceof JsonWebTokenError || error instanceof AuthError) {
this.clearCookie(res);
} else {
throw error;
createAuthMiddleware(allowSkipMFA: boolean) {
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
const token = req.cookies[AUTH_COOKIE_NAME];
if (token) {
try {
const isInvalid = await this.invalidAuthTokenRepository.existsBy({ token });
if (isInvalid) throw new AuthError('Unauthorized');
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) {
if (error instanceof JsonWebTokenError || error instanceof AuthError) {
this.clearCookie(res);
} else {
throw error;
}
}
}
}
if (req.user) next();
else res.status(401).json({ status: 'error', message: 'Unauthorized' });
if (req.user) next();
else res.status(401).json({ status: 'error', message: 'Unauthorized' });
};
}
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
// If the instance has exceeded its user quota, prevent non-owners from logging in
const isWithinUsersLimit = this.license.isWithinUsersLimit();
@@ -119,7 +140,7 @@ export class AuthService {
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;
res.cookie(AUTH_COOKIE_NAME, token, {
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 = {
id: user.id,
hash: this.createJWTHash(user),
browserId: browserId && this.hash(browserId),
usedMfa,
};
return this.jwtService.sign(payload, {
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, {
algorithms: ['HS256'],
});
@@ -175,10 +201,10 @@ export class AuthService {
if (jwtPayload.exp * 1000 - Date.now() < this.jwtRefreshTimeout) {
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') {

View File

@@ -8,5 +8,5 @@ import { AuthService } from './auth.service';
// DO NOT DELETE until the hooks have been updated
/** @deprecated Use `AuthService` instead */
export function issueCookie(res: Response, user: User) {
return Container.get(AuthService).issueCookie(res, user);
return Container.get(AuthService).issueCookie(res, user, user.mfaEnabled);
}

View File

@@ -87,7 +87,7 @@ export class ControllerRegistry {
...(route.skipAuth
? []
: ([
this.authService.authMiddleware.bind(this.authService),
this.authService.createAuthMiddleware(route.allowSkipMFA),
this.lastActiveAtService.middleware.bind(this.lastActiveAtService),
] as RequestHandler[])),
...(route.licenseFeature ? [this.createLicenseMiddleware(route.licenseFeature)] : []),

View File

@@ -87,13 +87,14 @@ describe('AuthController', () => {
body.password,
);
expect(authService.issueCookie).toHaveBeenCalledWith(res, member, browserId);
expect(authService.issueCookie).toHaveBeenCalledWith(res, member, false, browserId);
expect(eventsService.emit).toHaveBeenCalledWith('user-logged-in', {
user: member,
authenticationMethod: 'ldap',
});
expect(userService.toPublic).toHaveBeenCalledWith(member, {
mfaAuthenticated: false,
posthog: postHog,
withScopes: true,
});

View File

@@ -66,7 +66,7 @@ describe('OwnerController', () => {
authIdentities: [],
});
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 payload = mock<OwnerSetupRequestDto>({
email: 'valid@email.com',
@@ -85,7 +85,7 @@ describe('OwnerController', () => {
where: { role: 'global:owner' },
});
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(
{ key: 'userManagement.isInstanceOwnerSetUp' },
{ value: JSON.stringify(true) },

View File

@@ -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', {
user,
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', {
authenticationMethod: usedAuthenticationMethod,
@@ -115,11 +120,14 @@ export class AuthController {
}
/** Check if the user is already logged in */
@Get('/login')
@Get('/login', {
allowSkipMFA: true,
})
async currentUser(req: AuthenticatedRequest): Promise<PublicUser> {
return await this.userService.toPublic(req.user, {
posthog: this.postHog,
withScopes: true,
mfaAuthenticated: req.authInfo?.usedMfa,
});
}

View File

@@ -106,6 +106,7 @@ export class E2EController {
[LICENSE_FEATURES.INSIGHTS_VIEW_HOURLY_DATA]: false,
[LICENSE_FEATURES.API_KEY_SCOPES]: false,
[LICENSE_FEATURES.OIDC]: false,
[LICENSE_FEATURES.MFA_ENFORCEMENT]: false,
};
private static readonly numericFeaturesDefaults: Record<NumericLicenseFeature, number> = {

View File

@@ -128,7 +128,7 @@ export class InvitationController {
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', {
user: updatedUser,

View File

@@ -113,7 +113,7 @@ export class MeController {
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 fieldsChanged = changeableFields.filter(
@@ -183,7 +183,7 @@ export class MeController {
const updatedUser = await this.userRepository.save(user, { transaction: false });
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'] });

View File

@@ -1,5 +1,5 @@
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 { AuthService } from '@/auth/auth.service';
@@ -17,13 +17,32 @@ export class MFAController {
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) {
await this.externalHooks.run('mfa.beforeSetup', [req.user]);
return;
}
@Get('/qr')
@Get('/qr', {
allowSkipMFA: true,
})
async getQRCode(req: AuthenticatedRequest) {
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) {
const { mfaCode = null } = req.body;
const { id, mfaEnabled } = req.user;
@@ -88,7 +107,7 @@ export class MFAController {
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 })
@@ -115,10 +134,10 @@ export class MFAController {
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) {
const { id } = req.user;
const { mfaCode } = req.body;

View File

@@ -67,7 +67,7 @@ export class OwnerController {
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 });

View File

@@ -189,7 +189,7 @@ export class PasswordResetController {
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'] });

View File

@@ -0,0 +1,2 @@
export const MFA_FEATURE_ENABLED = 'mfa.enabled';
export const MFA_ENFORCE_SETTING = 'mfa.enforced';

View File

@@ -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 { Cipher } from 'n8n-core';
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 { InvalidMfaRecoveryCodeError } from '@/errors/response-errors/invalid-mfa-recovery-code-error';
import { MFA_ENFORCE_SETTING } from './constants';
import { TOTPService } from './totp.service';
@Service()
export class MfaService {
private enforceMFAValue: boolean = false;
constructor(
private userRepository: UserRepository,
private settingsRepository: SettingsRepository,
private license: LicenseState,
public totp: TOTPService,
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) {
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[]) {
const { encryptedSecret, encryptedRecoveryCodes } = this.encryptSecretAndRecoveryCodes(
secret,

View File

@@ -95,7 +95,7 @@ export class Push extends TypedEmitter<PushEvents> {
app.use(
`/${restEndpoint}/push`,
// eslint-disable-next-line @typescript-eslint/unbound-method
this.authService.authMiddleware,
this.authService.createAuthMiddleware(false),
(req: SSEPushRequest | WebSocketPushRequest, res: PushResponse) =>
this.handleRequest(req, res),
);

View File

@@ -149,6 +149,7 @@ export declare namespace UserRequest {
// ----------------------------------
export declare namespace MFA {
type Enforce = AuthenticatedRequest<{}, {}, { enforce: boolean }, {}>;
type Verify = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
type Activate = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
type Disable = AuthenticatedRequest<{}, {}, { mfaCode?: string; mfaRecoveryCode?: string }, {}>;

View File

@@ -62,6 +62,7 @@ import '@/evaluation.ee/test-runs.controller.ee';
import '@/workflows/workflow-history.ee/workflow-history.controller.ee';
import '@/workflows/workflows.controller';
import '@/webhooks/webhooks.controller';
import { MfaService } from './mfa/mfa.service';
@Service()
export class Server extends AbstractServer {
@@ -125,6 +126,7 @@ export class Server extends AbstractServer {
}
if (isMfaFeatureEnabled()) {
await Container.get(MfaService).init();
await import('@/controllers/mfa.controller');
}

View File

@@ -22,6 +22,11 @@ describe('HooksService', () => {
const settingsRepository = mock<SettingsRepository>();
const workflowRepository = mock<WorkflowRepository>();
const credentialsRepository = mock<CredentialsRepository>();
const authMiddleware = jest.fn();
authService.createAuthMiddleware.mockReturnValue(authMiddleware);
const hooksService = new HooksService(
userService,
authService,
@@ -49,12 +54,13 @@ describe('HooksService', () => {
it('hooksService.issueCookie should call authService.issueCookie', async () => {
// ARRANGE
const res = mock<Response>();
mockedUser.mfaEnabled = false; // Mock mfaEnabled property
// ACT
hooksService.issueCookie(res, mockedUser);
// ASSERT
expect(authService.issueCookie).toHaveBeenCalledWith(res, mockedUser);
expect(authService.issueCookie).toHaveBeenCalledWith(res, mockedUser, false);
});
it('hooksService.findOneUser should call userRepository.findOne', async () => {
@@ -134,7 +140,7 @@ describe('HooksService', () => {
await hooksService.authMiddleware(req, res, next);
// ASSERT
expect(authService.authMiddleware).toHaveBeenCalledWith(req, res, next);
expect(authMiddleware).toHaveBeenCalledWith(req, res, next);
});
it('hooksService.dbCollections should return valid repositories', async () => {

View File

@@ -17,6 +17,7 @@ import { CredentialsOverwrites } from '@/credentials-overwrites';
import { getLdapLoginLabel } from '@/ldap.ee/helpers.ee';
import { License } from '@/license';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { MfaService } from '@/mfa/mfa.service';
import { isApiEnabled } from '@/public-api';
import { PushConfig } from '@/push/push.config';
import type { CommunityPackagesService } from '@/services/community-packages.service';
@@ -51,6 +52,7 @@ export class FrontendService {
private readonly binaryDataConfig: BinaryDataConfig,
private readonly licenseState: LicenseState,
private readonly moduleRegistry: ModuleRegistry,
private readonly mfaService: MfaService,
) {
loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes());
void this.generateTypes();
@@ -195,6 +197,7 @@ export class FrontendService {
ldap: false,
saml: false,
oidc: false,
mfaEnforcement: false,
logStreaming: false,
advancedExecutionFilters: false,
variables: false,
@@ -216,6 +219,7 @@ export class FrontendService {
},
mfa: {
enabled: false,
enforced: false,
},
hideUsagePage: this.globalConfig.hideUsagePage,
license: {
@@ -321,6 +325,7 @@ export class FrontendService {
ldap: this.license.isLdapEnabled(),
saml: this.license.isSamlEnabled(),
oidc: this.licenseState.isOidcLicensed(),
mfaEnforcement: this.licenseState.isMFAEnforcementLicensed(),
advancedExecutionFilters: this.license.isAdvancedExecutionFiltersEnabled(),
variables: this.license.isVariablesEnabled(),
sourceControl: this.license.isSourceControlLicensed(),
@@ -385,6 +390,9 @@ export class FrontendService {
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.binaryDataMode = this.binaryDataConfig.mode;

View File

@@ -28,6 +28,12 @@ import { UserService } from '@/services/user.service';
*/
@Service()
export class HooksService {
private innerAuthMiddleware: (
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
) => Promise<void>;
constructor(
private readonly userService: UserService,
private readonly authService: AuthService,
@@ -35,7 +41,9 @@ export class HooksService {
private readonly settingsRepository: SettingsRepository,
private readonly workflowRepository: WorkflowRepository,
private readonly credentialsRepository: CredentialsRepository,
) {}
) {
this.innerAuthMiddleware = authService.createAuthMiddleware(false);
}
/**
* Invite users to instance during signup
@@ -49,7 +57,10 @@ export class HooksService {
* the user after instance is provisioned
*/
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
*/
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 {

View File

@@ -61,6 +61,7 @@ export class UserService {
inviterId?: string;
posthog?: PostHogClient;
withScopes?: boolean;
mfaAuthenticated?: boolean;
},
) {
const { password, updatedAt, authIdentities, mfaRecoveryCodes, mfaSecret, ...rest } = user;
@@ -90,6 +91,8 @@ export class UserService {
publicUser.globalScopes = getGlobalScopes(user);
}
publicUser.mfaAuthenticated = options?.mfaAuthenticated ?? false;
return publicUser;
}

View File

@@ -55,7 +55,7 @@ export class OidcController {
const user = await this.oidcService.loginUser(callbackUrl);
this.authService.issueCookie(res, user);
this.authService.issueCookie(res, user, false);
res.redirect('/');
}

View File

@@ -127,7 +127,7 @@ export class SamlController {
// Only sign in user if SAML is enabled, otherwise treat as test connection
if (isSamlLicensedAndEnabled()) {
this.authService.issueCookie(res, loginResult.authenticatedUser, req.browserId);
this.authService.issueCookie(res, loginResult.authenticatedUser, false, req.browserId);
if (loginResult.onboardingRequired) {
return res.redirect(this.urlService.getInstanceBaseUrl() + '/saml/onboarding');
} else {

View File

@@ -1,7 +1,8 @@
import { randomValidPassword, uniqueId } from '@n8n/backend-test-utils';
import { testDb } 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 { randomString } from 'n8n-workflow';
@@ -9,6 +10,7 @@ import { AuthService } from '@/auth/auth.service';
import config from '@/config';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ExternalHooks } from '@/external-hooks';
import { MFA_ENFORCE_SETTING } from '@/mfa/constants';
import { TOTPService } from '@/mfa/totp.service';
import { createOwner, createUser, createUserWithMfaEnabled } from '../shared/db/users';
@@ -22,6 +24,7 @@ const externalHooks = mockInstance(ExternalHooks);
const testServer = utils.setupTestServer({
endpointGroups: ['mfa', 'auth', 'me', 'passwordReset'],
enabledFeatures: [LICENSE_FEATURES.MFA_ENFORCEMENT],
});
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,
});
});
});

View File

@@ -58,7 +58,11 @@ function createAgent(
if (withRestSegment) void agent.use(prefix(REST_PATH_SEGMENT));
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}`);
}
return agent;

View File

@@ -16,6 +16,7 @@ interface UsersInfoProps {
disabled?: boolean;
settings?: object;
isSamlLoginEnabled?: boolean;
mfaEnabled?: boolean;
}
const props = withDefaults(defineProps<UsersInfoProps>(), {
@@ -55,6 +56,13 @@ const classes = computed(
</div>
<div>
<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>

View File

@@ -10,6 +10,7 @@ export type IUser = {
isPendingUser?: boolean;
inviteAcceptUrl?: string;
disabled?: boolean;
mfaEnabled?: boolean;
};
export interface UserAction<UserType extends IUser> {

View File

@@ -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.error.message": "Error disabling two-factor authentication",
"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.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",

View File

@@ -33,3 +33,9 @@ export type DisableMfaParams = {
export async function disableMfa(context: IRestApiContext, data: DisableMfaParams): Promise<void> {
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,
});
}

View File

@@ -19,6 +19,13 @@ const getBrowserId = () => {
export const NO_NETWORK_ERROR_CODE = 999;
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 {
// The HTTP status code of response
httpStatusCode?: number;
@@ -114,6 +121,9 @@ export async function request(config: {
}
const errorResponseData = error.response?.data;
if (errorResponseData?.mfaRequired === true) {
throw new MfaRequiredError();
}
if (errorResponseData?.message !== undefined) {
if (errorResponseData.name === 'NodeApiError') {
errorResponseData.httpStatusCode = error.response.status;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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