mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
feat(core): Invalidate all sessions when MFA is enabled/disabled (#15524)
This commit is contained in:
@@ -1,12 +0,0 @@
|
|||||||
import { Column, Entity } from '@n8n/typeorm';
|
|
||||||
|
|
||||||
import { User } from './user';
|
|
||||||
|
|
||||||
@Entity({ name: 'user' })
|
|
||||||
export class AuthUser extends User {
|
|
||||||
@Column({ type: String, nullable: true })
|
|
||||||
mfaSecret?: string | null;
|
|
||||||
|
|
||||||
@Column({ type: 'simple-array', default: '' })
|
|
||||||
mfaRecoveryCodes: string[];
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import { AnnotationTagMapping } from './annotation-tag-mapping.ee';
|
|||||||
import { ApiKey } from './api-key';
|
import { ApiKey } from './api-key';
|
||||||
import { AuthIdentity } from './auth-identity';
|
import { AuthIdentity } from './auth-identity';
|
||||||
import { AuthProviderSyncHistory } from './auth-provider-sync-history';
|
import { AuthProviderSyncHistory } from './auth-provider-sync-history';
|
||||||
import { AuthUser } from './auth-user';
|
|
||||||
import { CredentialsEntity } from './credentials-entity';
|
import { CredentialsEntity } from './credentials-entity';
|
||||||
import { EventDestinations } from './event-destinations';
|
import { EventDestinations } from './event-destinations';
|
||||||
import { ExecutionAnnotation } from './execution-annotation.ee';
|
import { ExecutionAnnotation } from './execution-annotation.ee';
|
||||||
@@ -58,7 +57,6 @@ export {
|
|||||||
WorkflowTagMapping,
|
WorkflowTagMapping,
|
||||||
FolderTagMapping,
|
FolderTagMapping,
|
||||||
AuthProviderSyncHistory,
|
AuthProviderSyncHistory,
|
||||||
AuthUser,
|
|
||||||
WorkflowHistory,
|
WorkflowHistory,
|
||||||
ExecutionData,
|
ExecutionData,
|
||||||
ExecutionMetadata,
|
ExecutionMetadata,
|
||||||
@@ -96,7 +94,6 @@ export const entities = {
|
|||||||
WorkflowTagMapping,
|
WorkflowTagMapping,
|
||||||
FolderTagMapping,
|
FolderTagMapping,
|
||||||
AuthProviderSyncHistory,
|
AuthProviderSyncHistory,
|
||||||
AuthUser,
|
|
||||||
WorkflowHistory,
|
WorkflowHistory,
|
||||||
ExecutionData,
|
ExecutionData,
|
||||||
ExecutionMetadata,
|
ExecutionMetadata,
|
||||||
|
|||||||
@@ -96,6 +96,12 @@ export class User extends WithTimestamps implements IUser, AuthPrincipal {
|
|||||||
@Column({ type: Boolean, default: false })
|
@Column({ type: Boolean, default: false })
|
||||||
mfaEnabled: boolean;
|
mfaEnabled: boolean;
|
||||||
|
|
||||||
|
@Column({ type: String, nullable: true })
|
||||||
|
mfaSecret?: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'simple-array', default: '' })
|
||||||
|
mfaRecoveryCodes: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the user is pending setup completion.
|
* Whether the user is pending setup completion.
|
||||||
*/
|
*/
|
||||||
@@ -108,7 +114,7 @@ export class User extends WithTimestamps implements IUser, AuthPrincipal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toJSON() {
|
toJSON() {
|
||||||
const { password, ...rest } = this;
|
const { password, mfaSecret, mfaRecoveryCodes, ...rest } = this;
|
||||||
return rest;
|
return rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { Service } from '@n8n/di';
|
|
||||||
import { DataSource, Repository } from '@n8n/typeorm';
|
|
||||||
|
|
||||||
import { AuthUser } from '../entities';
|
|
||||||
|
|
||||||
@Service()
|
|
||||||
export class AuthUserRepository extends Repository<AuthUser> {
|
|
||||||
constructor(dataSource: DataSource) {
|
|
||||||
super(AuthUser, dataSource.manager);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ export { AnnotationTagRepository } from './annotation-tag.repository.ee';
|
|||||||
export { ApiKeyRepository } from './api-key.repository';
|
export { ApiKeyRepository } from './api-key.repository';
|
||||||
export { AuthIdentityRepository } from './auth-identity.repository';
|
export { AuthIdentityRepository } from './auth-identity.repository';
|
||||||
export { AuthProviderSyncHistoryRepository } from './auth-provider-sync-history.repository';
|
export { AuthProviderSyncHistoryRepository } from './auth-provider-sync-history.repository';
|
||||||
export { AuthUserRepository } from './auth-user.repository';
|
|
||||||
export { CredentialsRepository } from './credentials.repository';
|
export { CredentialsRepository } from './credentials.repository';
|
||||||
export { ExecutionAnnotationRepository } from './execution-annotation.repository';
|
export { ExecutionAnnotationRepository } from './execution-annotation.repository';
|
||||||
export { ExecutionDataRepository } from './execution-data.repository';
|
export { ExecutionDataRepository } from './execution-data.repository';
|
||||||
|
|||||||
@@ -58,13 +58,35 @@ describe('AuthService', () => {
|
|||||||
it('should generate unique hashes', () => {
|
it('should generate unique hashes', () => {
|
||||||
expect(authService.createJWTHash(user)).toEqual('mJAYx4Wb7k');
|
expect(authService.createJWTHash(user)).toEqual('mJAYx4Wb7k');
|
||||||
expect(
|
expect(
|
||||||
authService.createJWTHash(mock<User>({ email: user.email, password: 'newPasswordHash' })),
|
authService.createJWTHash(
|
||||||
|
mock<User>({ email: user.email, password: 'newPasswordHash', mfaEnabled: false }),
|
||||||
|
),
|
||||||
).toEqual('FVALtU7AE0');
|
).toEqual('FVALtU7AE0');
|
||||||
expect(
|
expect(
|
||||||
authService.createJWTHash(
|
authService.createJWTHash(
|
||||||
mock<User>({ email: 'test1@example.com', password: user.password }),
|
mock<User>({ email: 'test1@example.com', password: user.password, mfaEnabled: false }),
|
||||||
),
|
),
|
||||||
).toEqual('y8ha6X01jd');
|
).toEqual('y8ha6X01jd');
|
||||||
|
expect(
|
||||||
|
authService.createJWTHash(
|
||||||
|
mock<User>({
|
||||||
|
email: user.email,
|
||||||
|
password: user.password,
|
||||||
|
mfaEnabled: true,
|
||||||
|
mfaSecret: 'secret',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual('WUXEVFet9W');
|
||||||
|
expect(
|
||||||
|
authService.createJWTHash(
|
||||||
|
mock<User>({
|
||||||
|
email: user.email,
|
||||||
|
password: 'newPasswordHash',
|
||||||
|
mfaEnabled: true,
|
||||||
|
mfaSecret: 'secret',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual('toYQYKufH6');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -245,6 +267,10 @@ describe('AuthService', () => {
|
|||||||
'user email does not match the one on the token',
|
'user email does not match the one on the token',
|
||||||
{ ...userData, email: 'someone@example.com' },
|
{ ...userData, email: 'someone@example.com' },
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'user mfa secret does not match the one on the token',
|
||||||
|
{ ...userData, mfaEnabled: true, mfaSecret: '123' },
|
||||||
|
],
|
||||||
])('should throw if %s', async (_, data) => {
|
])('should throw if %s', async (_, data) => {
|
||||||
userRepository.findOne.mockResolvedValueOnce(data && mock<User>(data));
|
userRepository.findOne.mockResolvedValueOnce(data && mock<User>(data));
|
||||||
await expect(authService.resolveJwt(validToken, req, res)).rejects.toThrow('Unauthorized');
|
await expect(authService.resolveJwt(validToken, req, res)).rejects.toThrow('Unauthorized');
|
||||||
|
|||||||
@@ -229,8 +229,12 @@ export class AuthService {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
createJWTHash({ email, password }: User) {
|
createJWTHash({ email, password, mfaEnabled, mfaSecret }: User) {
|
||||||
return this.hash(email + ':' + password).substring(0, 10);
|
const payload = [email, password];
|
||||||
|
if (mfaEnabled && mfaSecret) {
|
||||||
|
payload.push(mfaSecret.substring(0, 3));
|
||||||
|
}
|
||||||
|
return this.hash(payload.join(':')).substring(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
private hash(input: string) {
|
private hash(input: string) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AuthUserRepository } from '@n8n/db';
|
import { UserRepository } from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import { Flags } from '@oclif/core';
|
import { Flags } from '@oclif/core';
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export class DisableMFACommand extends BaseCommand {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const repository = Container.get(AuthUserRepository);
|
const repository = Container.get(UserRepository);
|
||||||
const user = await repository.findOneBy({ email: flags.email });
|
const user = await repository.findOneBy({ email: flags.email });
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { UserUpdateRequestDto } from '@n8n/api-types';
|
import { UserUpdateRequestDto } from '@n8n/api-types';
|
||||||
import type { User } from '@n8n/db';
|
import type { User } from '@n8n/db';
|
||||||
import type { PublicUser } from '@n8n/db';
|
import type { PublicUser } from '@n8n/db';
|
||||||
import { AuthUserRepository } from '@n8n/db';
|
|
||||||
import { InvalidAuthTokenRepository } from '@n8n/db';
|
import { InvalidAuthTokenRepository } from '@n8n/db';
|
||||||
import { UserRepository } from '@n8n/db';
|
import { UserRepository } from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
@@ -30,7 +29,6 @@ describe('MeController', () => {
|
|||||||
const userService = mockInstance(UserService);
|
const userService = mockInstance(UserService);
|
||||||
const userRepository = mockInstance(UserRepository);
|
const userRepository = mockInstance(UserRepository);
|
||||||
const mockMfaService = mockInstance(MfaService);
|
const mockMfaService = mockInstance(MfaService);
|
||||||
mockInstance(AuthUserRepository);
|
|
||||||
mockInstance(InvalidAuthTokenRepository);
|
mockInstance(InvalidAuthTokenRepository);
|
||||||
mockInstance(License).isWithinUsersLimit.mockReturnValue(true);
|
mockInstance(License).isWithinUsersLimit.mockReturnValue(true);
|
||||||
const controller = Container.get(MeController);
|
const controller = Container.get(MeController);
|
||||||
@@ -171,6 +169,7 @@ describe('MeController', () => {
|
|||||||
authIdentities: [],
|
authIdentities: [],
|
||||||
role: 'global:owner',
|
role: 'global:owner',
|
||||||
mfaEnabled: true,
|
mfaEnabled: true,
|
||||||
|
mfaSecret: 'secret',
|
||||||
});
|
});
|
||||||
const req = mock<AuthenticatedRequest>({ user, browserId });
|
const req = mock<AuthenticatedRequest>({ user, browserId });
|
||||||
const res = mock<Response>();
|
const res = mock<Response>();
|
||||||
@@ -316,7 +315,7 @@ describe('MeController', () => {
|
|||||||
|
|
||||||
it('should succeed when mfa code is correct', async () => {
|
it('should succeed when mfa code is correct', async () => {
|
||||||
const req = mock<AuthenticatedRequest>({
|
const req = mock<AuthenticatedRequest>({
|
||||||
user: mock({ password: passwordHash, mfaEnabled: true }),
|
user: mock({ password: passwordHash, mfaEnabled: true, mfaSecret: 'secret' }),
|
||||||
browserId,
|
browserId,
|
||||||
});
|
});
|
||||||
const res = mock<Response>();
|
const res = mock<Response>();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PushMessage } from '@n8n/api-types';
|
import type { PushMessage } from '@n8n/api-types';
|
||||||
import type { BooleanLicenseFeature, NumericLicenseFeature } from '@n8n/constants';
|
import type { BooleanLicenseFeature, NumericLicenseFeature } from '@n8n/constants';
|
||||||
import { LICENSE_FEATURES, LICENSE_QUOTAS, UNLIMITED_LICENSE_QUOTA } from '@n8n/constants';
|
import { LICENSE_FEATURES, LICENSE_QUOTAS, UNLIMITED_LICENSE_QUOTA } from '@n8n/constants';
|
||||||
import { AuthUserRepository, SettingsRepository, UserRepository } from '@n8n/db';
|
import { SettingsRepository, UserRepository } from '@n8n/db';
|
||||||
import { Patch, Post, RestController } from '@n8n/decorators';
|
import { Patch, Post, RestController } from '@n8n/decorators';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
@@ -149,7 +149,6 @@ export class E2EController {
|
|||||||
private readonly passwordUtility: PasswordUtility,
|
private readonly passwordUtility: PasswordUtility,
|
||||||
private readonly eventBus: MessageEventBus,
|
private readonly eventBus: MessageEventBus,
|
||||||
private readonly userRepository: UserRepository,
|
private readonly userRepository: UserRepository,
|
||||||
private readonly authUserRepository: AuthUserRepository,
|
|
||||||
) {
|
) {
|
||||||
license.isLicensed = (feature: BooleanLicenseFeature) => this.enabledFeatures[feature] ?? false;
|
license.isLicensed = (feature: BooleanLicenseFeature) => this.enabledFeatures[feature] ?? false;
|
||||||
|
|
||||||
@@ -280,7 +279,7 @@ export class E2EController {
|
|||||||
const { encryptedRecoveryCodes, encryptedSecret } =
|
const { encryptedRecoveryCodes, encryptedSecret } =
|
||||||
this.mfaService.encryptSecretAndRecoveryCodes(owner.mfaSecret, owner.mfaRecoveryCodes);
|
this.mfaService.encryptSecretAndRecoveryCodes(owner.mfaSecret, owner.mfaRecoveryCodes);
|
||||||
|
|
||||||
await this.authUserRepository.update(newOwner.user.id, {
|
await this.userRepository.update(newOwner.user.id, {
|
||||||
mfaSecret: encryptedSecret,
|
mfaSecret: encryptedSecret,
|
||||||
mfaRecoveryCodes: encryptedRecoveryCodes,
|
mfaRecoveryCodes: encryptedRecoveryCodes,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { UserRepository } from '@n8n/db';
|
||||||
import { Get, Post, RestController } from '@n8n/decorators';
|
import { Get, Post, RestController } from '@n8n/decorators';
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
import { AuthService } from '@/auth/auth.service';
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
import { ExternalHooks } from '@/external-hooks';
|
import { ExternalHooks } from '@/external-hooks';
|
||||||
import { MfaService } from '@/mfa/mfa.service';
|
import { MfaService } from '@/mfa/mfa.service';
|
||||||
@@ -10,6 +13,8 @@ export class MFAController {
|
|||||||
constructor(
|
constructor(
|
||||||
private mfaService: MfaService,
|
private mfaService: MfaService,
|
||||||
private externalHooks: ExternalHooks,
|
private externalHooks: ExternalHooks,
|
||||||
|
private authService: AuthService,
|
||||||
|
private userRepository: UserRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('/can-enable')
|
@Post('/can-enable')
|
||||||
@@ -59,7 +64,7 @@ export class MFAController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('/enable', { rateLimit: true })
|
@Post('/enable', { rateLimit: true })
|
||||||
async activateMFA(req: MFA.Activate) {
|
async activateMFA(req: MFA.Activate, res: Response) {
|
||||||
const { mfaCode = null } = req.body;
|
const { mfaCode = null } = req.body;
|
||||||
const { id, mfaEnabled } = req.user;
|
const { id, mfaEnabled } = req.user;
|
||||||
|
|
||||||
@@ -81,11 +86,13 @@ export class MFAController {
|
|||||||
if (!verified)
|
if (!verified)
|
||||||
throw new BadRequestError('MFA code expired. Close the modal and enable MFA again', 997);
|
throw new BadRequestError('MFA code expired. Close the modal and enable MFA again', 997);
|
||||||
|
|
||||||
await this.mfaService.enableMfa(id);
|
const updatedUser = await this.mfaService.enableMfa(id);
|
||||||
|
|
||||||
|
this.authService.issueCookie(res, updatedUser, req.browserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('/disable', { rateLimit: true })
|
@Post('/disable', { rateLimit: true })
|
||||||
async disableMFA(req: MFA.Disable) {
|
async disableMFA(req: MFA.Disable, res: Response) {
|
||||||
const { id: userId } = req.user;
|
const { id: userId } = req.user;
|
||||||
|
|
||||||
const { mfaCode, mfaRecoveryCode } = req.body;
|
const { mfaCode, mfaRecoveryCode } = req.body;
|
||||||
@@ -105,6 +112,10 @@ export class MFAController {
|
|||||||
} else if (mfaRecoveryCodeDefined) {
|
} else if (mfaRecoveryCodeDefined) {
|
||||||
await this.mfaService.disableMfaWithRecoveryCode(userId, mfaRecoveryCode);
|
await this.mfaService.disableMfaWithRecoveryCode(userId, mfaRecoveryCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updatedUser = await this.userRepository.findOneByOrFail({ id: userId });
|
||||||
|
|
||||||
|
this.authService.issueCookie(res, updatedUser, req.browserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('/verify', { rateLimit: true })
|
@Post('/verify', { rateLimit: true })
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AuthUserRepository } from '@n8n/db';
|
import { UserRepository } from '@n8n/db';
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
import { Cipher } from 'n8n-core';
|
import { Cipher } from 'n8n-core';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
@@ -11,7 +11,7 @@ import { TOTPService } from './totp.service';
|
|||||||
@Service()
|
@Service()
|
||||||
export class MfaService {
|
export class MfaService {
|
||||||
constructor(
|
constructor(
|
||||||
private authUserRepository: AuthUserRepository,
|
private userRepository: UserRepository,
|
||||||
public totp: TOTPService,
|
public totp: TOTPService,
|
||||||
private cipher: Cipher,
|
private cipher: Cipher,
|
||||||
) {}
|
) {}
|
||||||
@@ -26,10 +26,10 @@ export class MfaService {
|
|||||||
recoveryCodes,
|
recoveryCodes,
|
||||||
);
|
);
|
||||||
|
|
||||||
const user = await this.authUserRepository.findOneByOrFail({ id: userId });
|
const user = await this.userRepository.findOneByOrFail({ id: userId });
|
||||||
user.mfaSecret = encryptedSecret;
|
user.mfaSecret = encryptedSecret;
|
||||||
user.mfaRecoveryCodes = encryptedRecoveryCodes;
|
user.mfaRecoveryCodes = encryptedRecoveryCodes;
|
||||||
await this.authUserRepository.save(user);
|
await this.userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
encryptSecretAndRecoveryCodes(rawSecret: string, rawRecoveryCodes: string[]) {
|
encryptSecretAndRecoveryCodes(rawSecret: string, rawRecoveryCodes: string[]) {
|
||||||
@@ -49,7 +49,7 @@ export class MfaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getSecretAndRecoveryCodes(userId: string) {
|
async getSecretAndRecoveryCodes(userId: string) {
|
||||||
const { mfaSecret, mfaRecoveryCodes } = await this.authUserRepository.findOneByOrFail({
|
const { mfaSecret, mfaRecoveryCodes } = await this.userRepository.findOneByOrFail({
|
||||||
id: userId,
|
id: userId,
|
||||||
});
|
});
|
||||||
return this.decryptSecretAndRecoveryCodes(mfaSecret ?? '', mfaRecoveryCodes ?? []);
|
return this.decryptSecretAndRecoveryCodes(mfaSecret ?? '', mfaRecoveryCodes ?? []);
|
||||||
@@ -60,7 +60,7 @@ export class MfaService {
|
|||||||
mfaCode: string | undefined,
|
mfaCode: string | undefined,
|
||||||
mfaRecoveryCode: string | undefined,
|
mfaRecoveryCode: string | undefined,
|
||||||
) {
|
) {
|
||||||
const user = await this.authUserRepository.findOneByOrFail({ id: userId });
|
const user = await this.userRepository.findOneByOrFail({ id: userId });
|
||||||
if (mfaCode) {
|
if (mfaCode) {
|
||||||
const decryptedSecret = this.cipher.decrypt(user.mfaSecret!);
|
const decryptedSecret = this.cipher.decrypt(user.mfaSecret!);
|
||||||
return this.totp.verifySecret({ secret: decryptedSecret, mfaCode });
|
return this.totp.verifySecret({ secret: decryptedSecret, mfaCode });
|
||||||
@@ -73,7 +73,7 @@ export class MfaService {
|
|||||||
// remove used recovery code
|
// remove used recovery code
|
||||||
validCodes.splice(index, 1);
|
validCodes.splice(index, 1);
|
||||||
user.mfaRecoveryCodes = validCodes.map((code) => this.cipher.encrypt(code));
|
user.mfaRecoveryCodes = validCodes.map((code) => this.cipher.encrypt(code));
|
||||||
await this.authUserRepository.save(user);
|
await this.userRepository.save(user);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,9 +81,9 @@ export class MfaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async enableMfa(userId: string) {
|
async enableMfa(userId: string) {
|
||||||
const user = await this.authUserRepository.findOneByOrFail({ id: userId });
|
const user = await this.userRepository.findOneByOrFail({ id: userId });
|
||||||
user.mfaEnabled = true;
|
user.mfaEnabled = true;
|
||||||
return await this.authUserRepository.save(user);
|
return await this.userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async disableMfaWithMfaCode(userId: string, mfaCode: string) {
|
async disableMfaWithMfaCode(userId: string, mfaCode: string) {
|
||||||
@@ -107,7 +107,7 @@ export class MfaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async disableMfaForUser(userId: string) {
|
private async disableMfaForUser(userId: string) {
|
||||||
await this.authUserRepository.update(userId, {
|
await this.userRepository.update(userId, {
|
||||||
mfaEnabled: false,
|
mfaEnabled: false,
|
||||||
mfaSecret: null,
|
mfaSecret: null,
|
||||||
mfaRecoveryCodes: [],
|
mfaRecoveryCodes: [],
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { AuthUser, SettingsRepository } from '@n8n/db';
|
import type { SettingsRepository, User } from '@n8n/db';
|
||||||
import type { AuthUserRepository } from '@n8n/db';
|
|
||||||
import type { CredentialsRepository } from '@n8n/db';
|
import type { CredentialsRepository } from '@n8n/db';
|
||||||
import type { WorkflowRepository } from '@n8n/db';
|
import type { WorkflowRepository } from '@n8n/db';
|
||||||
import type { UserRepository } from '@n8n/db';
|
import type { UserRepository } from '@n8n/db';
|
||||||
@@ -16,14 +15,13 @@ import type { UserService } from '@/services/user.service';
|
|||||||
jest.mock('@rudderstack/rudder-sdk-node');
|
jest.mock('@rudderstack/rudder-sdk-node');
|
||||||
|
|
||||||
describe('HooksService', () => {
|
describe('HooksService', () => {
|
||||||
const mockedUser = mock<AuthUser>();
|
const mockedUser = mock<User>();
|
||||||
const userService = mock<UserService>();
|
const userService = mock<UserService>();
|
||||||
const authService = mock<AuthService>();
|
const authService = mock<AuthService>();
|
||||||
const userRepository = mock<UserRepository>();
|
const userRepository = mock<UserRepository>();
|
||||||
const settingsRepository = mock<SettingsRepository>();
|
const settingsRepository = mock<SettingsRepository>();
|
||||||
const workflowRepository = mock<WorkflowRepository>();
|
const workflowRepository = mock<WorkflowRepository>();
|
||||||
const credentialsRepository = mock<CredentialsRepository>();
|
const credentialsRepository = mock<CredentialsRepository>();
|
||||||
const authUserRepository = mock<AuthUserRepository>();
|
|
||||||
const hooksService = new HooksService(
|
const hooksService = new HooksService(
|
||||||
userService,
|
userService,
|
||||||
authService,
|
authService,
|
||||||
@@ -31,7 +29,6 @@ describe('HooksService', () => {
|
|||||||
settingsRepository,
|
settingsRepository,
|
||||||
workflowRepository,
|
workflowRepository,
|
||||||
credentialsRepository,
|
credentialsRepository,
|
||||||
authUserRepository,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -60,7 +57,7 @@ describe('HooksService', () => {
|
|||||||
expect(authService.issueCookie).toHaveBeenCalledWith(res, mockedUser);
|
expect(authService.issueCookie).toHaveBeenCalledWith(res, mockedUser);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hooksService.findOneUser should call authUserRepository.findOne', async () => {
|
it('hooksService.findOneUser should call userRepository.findOne', async () => {
|
||||||
// ARRANGE
|
// ARRANGE
|
||||||
const filter = { where: { id: '1' } };
|
const filter = { where: { id: '1' } };
|
||||||
|
|
||||||
@@ -68,7 +65,7 @@ describe('HooksService', () => {
|
|||||||
await hooksService.findOneUser(filter);
|
await hooksService.findOneUser(filter);
|
||||||
|
|
||||||
// ASSERT
|
// ASSERT
|
||||||
expect(authUserRepository.findOne).toHaveBeenCalledWith(filter);
|
expect(userRepository.findOne).toHaveBeenCalledWith(filter);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hooksService.saveUser should call userRepository.save', async () => {
|
it('hooksService.saveUser should call userRepository.save', async () => {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe('UserService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
type MaybeSensitiveProperties = Partial<
|
type MaybeSensitiveProperties = Partial<
|
||||||
Pick<User, 'password' | 'updatedAt' | 'authIdentities'>
|
Pick<User, 'password' | 'updatedAt' | 'authIdentities' | 'mfaSecret' | 'mfaRecoveryCodes'>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
// to prevent typechecking from blocking assertions
|
// to prevent typechecking from blocking assertions
|
||||||
@@ -47,6 +47,8 @@ describe('UserService', () => {
|
|||||||
expect(publicUser.password).toBeUndefined();
|
expect(publicUser.password).toBeUndefined();
|
||||||
expect(publicUser.updatedAt).toBeUndefined();
|
expect(publicUser.updatedAt).toBeUndefined();
|
||||||
expect(publicUser.authIdentities).toBeUndefined();
|
expect(publicUser.authIdentities).toBeUndefined();
|
||||||
|
expect(publicUser.mfaSecret).toBeUndefined();
|
||||||
|
expect(publicUser.mfaRecoveryCodes).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add scopes if requested', async () => {
|
it('should add scopes if requested', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Settings, CredentialsEntity, User, WorkflowEntity, AuthUser } from '@n8n/db';
|
import type { Settings, CredentialsEntity, User, WorkflowEntity } from '@n8n/db';
|
||||||
import {
|
import {
|
||||||
AuthUserRepository,
|
|
||||||
CredentialsRepository,
|
CredentialsRepository,
|
||||||
WorkflowRepository,
|
WorkflowRepository,
|
||||||
SettingsRepository,
|
SettingsRepository,
|
||||||
@@ -31,7 +30,6 @@ export class HooksService {
|
|||||||
private readonly settingsRepository: SettingsRepository,
|
private readonly settingsRepository: SettingsRepository,
|
||||||
private readonly workflowRepository: WorkflowRepository,
|
private readonly workflowRepository: WorkflowRepository,
|
||||||
private readonly credentialsRepository: CredentialsRepository,
|
private readonly credentialsRepository: CredentialsRepository,
|
||||||
private readonly authUserRepository: AuthUserRepository,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,7 +43,7 @@ export class HooksService {
|
|||||||
* Set the n8n-auth cookie in the response to auto-login
|
* Set the n8n-auth cookie in the response to auto-login
|
||||||
* the user after instance is provisioned
|
* the user after instance is provisioned
|
||||||
*/
|
*/
|
||||||
issueCookie(res: Response, user: AuthUser) {
|
issueCookie(res: Response, user: User) {
|
||||||
return this.authService.issueCookie(res, user);
|
return this.authService.issueCookie(res, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,8 +52,8 @@ export class HooksService {
|
|||||||
* 1. To know whether the instance owner is already setup
|
* 1. To know whether the instance owner is already setup
|
||||||
* 2. To know when to update the user's profile also in cloud
|
* 2. To know when to update the user's profile also in cloud
|
||||||
*/
|
*/
|
||||||
async findOneUser(filter: FindOneOptions<AuthUser>) {
|
async findOneUser(filter: FindOneOptions<User>) {
|
||||||
return await this.authUserRepository.findOne(filter);
|
return await this.userRepository.findOne(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export class UserService {
|
|||||||
withScopes?: boolean;
|
withScopes?: boolean;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { password, updatedAt, authIdentities, ...rest } = user;
|
const { password, updatedAt, authIdentities, mfaRecoveryCodes, mfaSecret, ...rest } = user;
|
||||||
|
|
||||||
const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap');
|
const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap');
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { User } from '@n8n/db';
|
import { UserRepository, type User } from '@n8n/db';
|
||||||
import { AuthUserRepository } from '@n8n/db';
|
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import { randomString } from 'n8n-workflow';
|
import { randomString } from 'n8n-workflow';
|
||||||
|
|
||||||
@@ -130,7 +129,7 @@ describe('Enable MFA setup', () => {
|
|||||||
await testServer.authAgentFor(owner).post('/mfa/verify').send({ mfaCode }).expect(200);
|
await testServer.authAgentFor(owner).post('/mfa/verify').send({ mfaCode }).expect(200);
|
||||||
await testServer.authAgentFor(owner).post('/mfa/enable').send({ mfaCode }).expect(200);
|
await testServer.authAgentFor(owner).post('/mfa/enable').send({ mfaCode }).expect(200);
|
||||||
|
|
||||||
const user = await Container.get(AuthUserRepository).findOneOrFail({
|
const user = await Container.get(UserRepository).findOneOrFail({
|
||||||
where: {},
|
where: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,7 +152,7 @@ describe('Enable MFA setup', () => {
|
|||||||
|
|
||||||
await testServer.authAgentFor(owner).post('/mfa/enable').send({ mfaCode }).expect(400);
|
await testServer.authAgentFor(owner).post('/mfa/enable').send({ mfaCode }).expect(400);
|
||||||
|
|
||||||
const user = await Container.get(AuthUserRepository).findOneOrFail({
|
const user = await Container.get(UserRepository).findOneOrFail({
|
||||||
where: {},
|
where: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -175,7 +174,7 @@ describe('Disable MFA setup', () => {
|
|||||||
})
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
const dbUser = await Container.get(AuthUserRepository).findOneOrFail({
|
const dbUser = await Container.get(UserRepository).findOneOrFail({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -396,7 +395,7 @@ describe('Login', () => {
|
|||||||
const data = response.body.data;
|
const data = response.body.data;
|
||||||
expect(data.mfaEnabled).toBe(true);
|
expect(data.mfaEnabled).toBe(true);
|
||||||
|
|
||||||
const dbUser = await Container.get(AuthUserRepository).findOneOrFail({
|
const dbUser = await Container.get(UserRepository).findOneOrFail({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { AuthIdentity } from '@n8n/db';
|
import { AuthIdentity } from '@n8n/db';
|
||||||
import { type User } from '@n8n/db';
|
import { type User } from '@n8n/db';
|
||||||
import { AuthIdentityRepository } from '@n8n/db';
|
import { AuthIdentityRepository } from '@n8n/db';
|
||||||
import { AuthUserRepository } from '@n8n/db';
|
|
||||||
import { UserRepository } from '@n8n/db';
|
import { UserRepository } from '@n8n/db';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import type { ApiKeyScope, GlobalRole } from '@n8n/permissions';
|
import type { ApiKeyScope, GlobalRole } from '@n8n/permissions';
|
||||||
@@ -73,11 +72,14 @@ export async function createUserWithMfaEnabled(
|
|||||||
email,
|
email,
|
||||||
});
|
});
|
||||||
|
|
||||||
await Container.get(AuthUserRepository).update(user.id, {
|
await Container.get(UserRepository).update(user.id, {
|
||||||
mfaSecret: encryptedSecret,
|
mfaSecret: encryptedSecret,
|
||||||
mfaRecoveryCodes: encryptedRecoveryCodes,
|
mfaRecoveryCodes: encryptedRecoveryCodes,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
user.mfaSecret = encryptedSecret;
|
||||||
|
user.mfaRecoveryCodes = encryptedRecoveryCodes;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
rawPassword: password,
|
rawPassword: password,
|
||||||
|
|||||||
Reference in New Issue
Block a user