diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 0c8751209a..d84952f427 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -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; diff --git a/packages/@n8n/backend-common/src/license-state.ts b/packages/@n8n/backend-common/src/license-state.ts index 939739893f..85e021f06d 100644 --- a/packages/@n8n/backend-common/src/license-state.ts +++ b/packages/@n8n/backend-common/src/license-state.ts @@ -63,6 +63,10 @@ export class LicenseState { return this.isLicensed('feat:oidc'); } + isMFAEnforcementLicensed() { + return this.isLicensed('feat:mfaEnforcement'); + } + isApiKeyScopesLicensed() { return this.isLicensed('feat:apiKeyScopes'); } diff --git a/packages/@n8n/constants/src/index.ts b/packages/@n8n/constants/src/index.ts index 30b5e9d7b2..feb818e6e0 100644 --- a/packages/@n8n/constants/src/index.ts +++ b/packages/@n8n/constants/src/index.ts @@ -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', diff --git a/packages/@n8n/db/src/entities/types-db.ts b/packages/@n8n/db/src/entities/types-db.ts index f249e82313..94c3cdc18a 100644 --- a/packages/@n8n/db/src/entities/types-db.ts +++ b/packages/@n8n/db/src/entities/types-db.ts @@ -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; @@ -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, 'user' | 'cookies'> & { user: User; + authInfo?: AuthenticationInformation; cookies: Record; headers: express.Request['headers'] & { 'push-ref': string; diff --git a/packages/@n8n/decorators/src/controller/route.ts b/packages/@n8n/decorators/src/controller/route.ts index 0d3852f806..7086b607d0 100644 --- a/packages/@n8n/decorators/src/controller/route.ts +++ b/packages/@n8n/decorators/src/controller/route.ts @@ -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; }; diff --git a/packages/@n8n/decorators/src/controller/types.ts b/packages/@n8n/decorators/src/controller/types.ts index fa752cd061..f57f6d3ae3 100644 --- a/packages/@n8n/decorators/src/controller/types.ts +++ b/packages/@n8n/decorators/src/controller/types.ts @@ -33,6 +33,7 @@ export interface RouteMetadata { middlewares: RequestHandler[]; usesTemplates: boolean; skipAuth: boolean; + allowSkipMFA: boolean; rateLimit?: boolean | RateLimit; licenseFeature?: BooleanLicenseFeature; accessScope?: AccessScope; diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index b4ea62fe46..b24b4328da 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -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, diff --git a/packages/@n8n/permissions/src/public-api-permissions.ee.ts b/packages/@n8n/permissions/src/public-api-permissions.ee.ts index c05e999809..fd6c62c537 100644 --- a/packages/@n8n/permissions/src/public-api-permissions.ee.ts +++ b/packages/@n8n/permissions/src/public-api-permissions.ee.ts @@ -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', diff --git a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index c3e25aae26..6574339187 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -56,6 +56,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'user:list', 'user:resetPassword', 'user:changeRole', + 'user:enforceMfa', 'variable:create', 'variable:read', 'variable:update', diff --git a/packages/cli/src/__tests__/controller.registry.test.ts b/packages/cli/src/__tests__/controller.registry.test.ts index 99f6bde914..92a985b1a3 100644 --- a/packages/cli/src/__tests__/controller.registry.test.ts +++ b/packages/cli/src/__tests__/controller.registry.test.ts @@ -27,10 +27,12 @@ describe('ControllerRegistry', () => { const metadata = Container.get(ControllerRegistryMetadata); const lastActiveAtService = mock(); 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()); }); diff --git a/packages/cli/src/auth/__tests__/auth.service.test.ts b/packages/cli/src/auth/__tests__/auth.service.test.ts index 80a48431c6..5f98fbb6dc 100644 --- a/packages/cli/src/auth/__tests__/auth.service.test.ts +++ b/packages/cli/src/auth/__tests__/auth.service.test.ts @@ -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(); const userRepository = mock(); const invalidAuthTokenRepository = mock(); + const mfaService = mock(); 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(); 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(); }); }); diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index ab2f7e0e4c..50000c9be0 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -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 { + 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') { diff --git a/packages/cli/src/auth/jwt.ts b/packages/cli/src/auth/jwt.ts index 67baab4de3..fc04b03cd6 100644 --- a/packages/cli/src/auth/jwt.ts +++ b/packages/cli/src/auth/jwt.ts @@ -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); } diff --git a/packages/cli/src/controller.registry.ts b/packages/cli/src/controller.registry.ts index e82506b0a9..9a53c6b732 100644 --- a/packages/cli/src/controller.registry.ts +++ b/packages/cli/src/controller.registry.ts @@ -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)] : []), diff --git a/packages/cli/src/controllers/__tests__/auth.controller.test.ts b/packages/cli/src/controllers/__tests__/auth.controller.test.ts index 3bde4a5f56..27380d6361 100644 --- a/packages/cli/src/controllers/__tests__/auth.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/auth.controller.test.ts @@ -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, }); diff --git a/packages/cli/src/controllers/__tests__/owner.controller.test.ts b/packages/cli/src/controllers/__tests__/owner.controller.test.ts index d45f3694cd..256b9f1e23 100644 --- a/packages/cli/src/controllers/__tests__/owner.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/owner.controller.test.ts @@ -66,7 +66,7 @@ describe('OwnerController', () => { authIdentities: [], }); const browserId = 'test-browser-id'; - const req = mock({ user, browserId }); + const req = mock({ user, browserId, authInfo: { usedMfa: false } }); const res = mock(); const payload = mock({ 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) }, diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 5e0550b974..55fc67d3ad 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -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 { return await this.userService.toPublic(req.user, { posthog: this.postHog, withScopes: true, + mfaAuthenticated: req.authInfo?.usedMfa, }); } diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index f86354138a..1d6b041c1c 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -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 = { diff --git a/packages/cli/src/controllers/invitation.controller.ts b/packages/cli/src/controllers/invitation.controller.ts index 0b92bdb1cc..ffa84a2c99 100644 --- a/packages/cli/src/controllers/invitation.controller.ts +++ b/packages/cli/src/controllers/invitation.controller.ts @@ -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, diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts index 8db2c61562..2a86f4b02c 100644 --- a/packages/cli/src/controllers/me.controller.ts +++ b/packages/cli/src/controllers/me.controller.ts @@ -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'] }); diff --git a/packages/cli/src/controllers/mfa.controller.ts b/packages/cli/src/controllers/mfa.controller.ts index a0f2e6f434..8833a25ecc 100644 --- a/packages/cli/src/controllers/mfa.controller.ts +++ b/packages/cli/src/controllers/mfa.controller.ts @@ -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; diff --git a/packages/cli/src/controllers/owner.controller.ts b/packages/cli/src/controllers/owner.controller.ts index 3021e173a4..9b4feb5945 100644 --- a/packages/cli/src/controllers/owner.controller.ts +++ b/packages/cli/src/controllers/owner.controller.ts @@ -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 }); diff --git a/packages/cli/src/controllers/password-reset.controller.ts b/packages/cli/src/controllers/password-reset.controller.ts index c68c997158..706b5ebfa1 100644 --- a/packages/cli/src/controllers/password-reset.controller.ts +++ b/packages/cli/src/controllers/password-reset.controller.ts @@ -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'] }); diff --git a/packages/cli/src/mfa/constants.ts b/packages/cli/src/mfa/constants.ts new file mode 100644 index 0000000000..9d3ddebef8 --- /dev/null +++ b/packages/cli/src/mfa/constants.ts @@ -0,0 +1,2 @@ +export const MFA_FEATURE_ENABLED = 'mfa.enabled'; +export const MFA_ENFORCE_SETTING = 'mfa.enforced'; diff --git a/packages/cli/src/mfa/mfa.service.ts b/packages/cli/src/mfa/mfa.service.ts index 9b77ab01af..db81b2b54a 100644 --- a/packages/cli/src/mfa/mfa.service.ts +++ b/packages/cli/src/mfa/mfa.service.ts @@ -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, diff --git a/packages/cli/src/push/index.ts b/packages/cli/src/push/index.ts index 7ffb989536..b3eb10914d 100644 --- a/packages/cli/src/push/index.ts +++ b/packages/cli/src/push/index.ts @@ -95,7 +95,7 @@ export class Push extends TypedEmitter { 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), ); diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index ab6b3e3b5f..bfde747102 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -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 }, {}>; diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 1bd00c08fc..2c9e74f97b 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -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'); } diff --git a/packages/cli/src/services/__tests__/hooks.service.test.ts b/packages/cli/src/services/__tests__/hooks.service.test.ts index effcb1f878..65d0c359ba 100644 --- a/packages/cli/src/services/__tests__/hooks.service.test.ts +++ b/packages/cli/src/services/__tests__/hooks.service.test.ts @@ -22,6 +22,11 @@ describe('HooksService', () => { const settingsRepository = mock(); const workflowRepository = mock(); const credentialsRepository = mock(); + + 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(); + 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 () => { diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index a92a39d2ce..d3d2557724 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -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; diff --git a/packages/cli/src/services/hooks.service.ts b/packages/cli/src/services/hooks.service.ts index 5255b8a222..b6dc94a263 100644 --- a/packages/cli/src/services/hooks.service.ts +++ b/packages/cli/src/services/hooks.service.ts @@ -28,6 +28,12 @@ import { UserService } from '@/services/user.service'; */ @Service() export class HooksService { + private innerAuthMiddleware: ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, + ) => Promise; + 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 { diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index be9b6e1705..8d2e9b5894 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -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; } diff --git a/packages/cli/src/sso.ee/oidc/routes/oidc.controller.ee.ts b/packages/cli/src/sso.ee/oidc/routes/oidc.controller.ee.ts index bc92de5d29..eeb0a2b8d8 100644 --- a/packages/cli/src/sso.ee/oidc/routes/oidc.controller.ee.ts +++ b/packages/cli/src/sso.ee/oidc/routes/oidc.controller.ee.ts @@ -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('/'); } diff --git a/packages/cli/src/sso.ee/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso.ee/saml/routes/saml.controller.ee.ts index c6d3329cdb..2cc1ad3f27 100644 --- a/packages/cli/src/sso.ee/saml/routes/saml.controller.ee.ts +++ b/packages/cli/src/sso.ee/saml/routes/saml.controller.ee.ts @@ -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 { diff --git a/packages/cli/test/integration/mfa/mfa.api.test.ts b/packages/cli/test/integration/mfa/mfa.api.test.ts index 6f2f00fbff..5f14952542 100644 --- a/packages/cli/test/integration/mfa/mfa.api.test.ts +++ b/packages/cli/test/integration/mfa/mfa.api.test.ts @@ -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, + }); + }); +}); diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index 603044a84d..676189ac3a 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -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; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nUserInfo/UserInfo.vue b/packages/frontend/@n8n/design-system/src/components/N8nUserInfo/UserInfo.vue index 708c6562d0..580f7a4555 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nUserInfo/UserInfo.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nUserInfo/UserInfo.vue @@ -16,6 +16,7 @@ interface UsersInfoProps { disabled?: boolean; settings?: object; isSamlLoginEnabled?: boolean; + mfaEnabled?: boolean; } const props = withDefaults(defineProps(), { @@ -55,6 +56,13 @@ const classes = computed(
{{ email }} + | + {{ mfaEnabled ? '2FA Enabled' : '2FA Disabled' }}
diff --git a/packages/frontend/@n8n/design-system/src/types/user.ts b/packages/frontend/@n8n/design-system/src/types/user.ts index 20330eec17..f373afa3fc 100644 --- a/packages/frontend/@n8n/design-system/src/types/user.ts +++ b/packages/frontend/@n8n/design-system/src/types/user.ts @@ -10,6 +10,7 @@ export type IUser = { isPendingUser?: boolean; inviteAcceptUrl?: string; disabled?: boolean; + mfaEnabled?: boolean; }; export interface UserAction { diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 6d8bfd7dea..bbeca32bb0 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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 require you to set up 2FA. 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. Open settings", "sso.login.divider": "or", diff --git a/packages/frontend/@n8n/rest-api-client/src/api/mfa.ts b/packages/frontend/@n8n/rest-api-client/src/api/mfa.ts index 4b5d9d7c8e..eecaa4b4a7 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/mfa.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/mfa.ts @@ -33,3 +33,9 @@ export type DisableMfaParams = { export async function disableMfa(context: IRestApiContext, data: DisableMfaParams): Promise { return await makeRestApiRequest(context, 'POST', '/mfa/disable', data); } + +export async function updateEnforceMfa(context: IRestApiContext, enforce: boolean): Promise { + return await makeRestApiRequest(context, 'POST', '/mfa/enforce-mfa', { + enforce, + }); +} diff --git a/packages/frontend/@n8n/rest-api-client/src/utils.ts b/packages/frontend/@n8n/rest-api-client/src/utils.ts index 39437dde91..f0a9836d69 100644 --- a/packages/frontend/@n8n/rest-api-client/src/utils.ts +++ b/packages/frontend/@n8n/rest-api-client/src/utils.ts @@ -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; diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 09a66dc160..4e61cfa88f 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -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; diff --git a/packages/frontend/editor-ui/src/__tests__/defaults.ts b/packages/frontend/editor-ui/src/__tests__/defaults.ts index 841dc3686a..6681c02f16 100644 --- a/packages/frontend/editor-ui/src/__tests__/defaults.ts +++ b/packages/frontend/editor-ui/src/__tests__/defaults.ts @@ -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, diff --git a/packages/frontend/editor-ui/src/__tests__/mocks.ts b/packages/frontend/editor-ui/src/__tests__/mocks.ts index 15566f5ba4..a3e1d5fbd0 100644 --- a/packages/frontend/editor-ui/src/__tests__/mocks.ts +++ b/packages/frontend/editor-ui/src/__tests__/mocks.ts @@ -221,6 +221,7 @@ export function createMockEnterpriseSettings( ldap: false, saml: false, oidc: false, + mfaEnforcement: false, logStreaming: false, advancedExecutionFilters: false, variables: false, diff --git a/packages/frontend/editor-ui/src/components/MfaSetupModal.vue b/packages/frontend/editor-ui/src/components/MfaSetupModal.vue index b4b5cb9572..791c91bfac 100644 --- a/packages/frontend/editor-ui/src/components/MfaSetupModal.vue +++ b/packages/frontend/editor-ui/src/components/MfaSetupModal.vue @@ -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 () => { - + @@ -236,7 +244,7 @@ onMounted(async () => { {{ i18n.baseText('mfa.setup.step2.infobox.description.part2') }} - +
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 { diff --git a/packages/frontend/editor-ui/src/stores/settings.store.ts b/packages/frontend/editor-ui/src/stores/settings.store.ts index bdc4518b7a..e1277f49a1 100644 --- a/packages/frontend/editor-ui/src/stores/settings.store.ts +++ b/packages/frontend/editor-ui/src/stores/settings.store.ts @@ -49,6 +49,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { const saveDataSuccessExecution = ref('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, }; }); diff --git a/packages/frontend/editor-ui/src/stores/users.store.ts b/packages/frontend/editor-ui/src/stores/users.store.ts index 8a14ebc638..4dc8db1b59 100644 --- a/packages/frontend/editor-ui/src/stores/users.store.ts +++ b/packages/frontend/editor-ui/src/stores/users.store.ts @@ -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, diff --git a/packages/frontend/editor-ui/src/utils/rbac/checks/isAuthenticated.ts b/packages/frontend/editor-ui/src/utils/rbac/checks/isAuthenticated.ts index 4cbdd4cf18..33c80e585e 100644 --- a/packages/frontend/editor-ui/src/utils/rbac/checks/isAuthenticated.ts +++ b/packages/frontend/editor-ui/src/utils/rbac/checks/isAuthenticated.ts @@ -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 = (options) => { @@ -9,3 +10,15 @@ export const isAuthenticated: RBACPermissionCheck = () => { + // 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; +}; diff --git a/packages/frontend/editor-ui/src/utils/rbac/middleware/authenticated.test.ts b/packages/frontend/editor-ui/src/utils/rbac/middleware/authenticated.test.ts index 191a2cbe3c..b95ab7c70b 100644 --- a/packages/frontend/editor-ui/src/utils/rbac/middleware/authenticated.test.ts +++ b/packages/frontend/editor-ui/src/utils/rbac/middleware/authenticated.test.ts @@ -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 diff --git a/packages/frontend/editor-ui/src/utils/rbac/middleware/authenticated.ts b/packages/frontend/editor-ui/src/utils/rbac/middleware/authenticated.ts index bdb6afce77..6187f3a7f6 100644 --- a/packages/frontend/editor-ui/src/utils/rbac/middleware/authenticated.ts +++ b/packages/frontend/editor-ui/src/utils/rbac/middleware/authenticated.ts @@ -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 = async ( to, @@ -9,11 +9,23 @@ export const authenticatedMiddleware: RouterMiddleware { + // 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; + } }; diff --git a/packages/frontend/editor-ui/src/views/SettingsPersonalView.vue b/packages/frontend/editor-ui/src/views/SettingsPersonalView.vue index 4e443d6675..012a859a33 100644 --- a/packages/frontend/editor-ui/src/views/SettingsPersonalView.vue +++ b/packages/frontend/editor-ui/src/views/SettingsPersonalView.vue @@ -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(() => {
+ + { 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); diff --git a/packages/frontend/editor-ui/src/views/SettingsUsersView.vue b/packages/frontend/editor-ui/src/views/SettingsUsersView.vue index 103c9995f3..4330ed18a8 100644 --- a/packages/frontend/editor-ui/src/views/SettingsUsersView.vue +++ b/packages/frontend/editor-ui/src/views/SettingsUsersView.vue @@ -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')); + } +} +
+
+ {{ i18n.baseText('settings.personal.mfa.enforce.title') }} + {{ + i18n.baseText('settings.personal.mfa.enforce.message') + }} +
+
+ + + + +
+
+
diff --git a/packages/frontend/editor-ui/src/views/SigninView.vue b/packages/frontend/editor-ui/src/views/SigninView.vue index baf6587ab7..7cfaa68679 100644 --- a/packages/frontend/editor-ui/src/views/SigninView.vue +++ b/packages/frontend/editor-ui/src/views/SigninView.vue @@ -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', });