feat(core): Add MFA (#4767)

https://linear.app/n8n/issue/ADO-947/sync-branch-with-master-and-fix-fe-e2e-tets

---------

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
Ricardo Espinoza
2023-08-23 22:59:16 -04:00
committed by GitHub
parent a01c3fbc19
commit 2b7ba6fdf1
61 changed files with 2301 additions and 105 deletions

View File

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

View File

@@ -0,0 +1,21 @@
import config from '@/config';
import * as Db from '@/Db';
import { MFA_FEATURE_ENABLED } from './constants';
export const isMfaFeatureEnabled = () => config.get(MFA_FEATURE_ENABLED);
const isMfaFeatureDisabled = () => !isMfaFeatureEnabled();
const getUsersWithMfaEnabled = async () =>
Db.collections.User.count({ where: { mfaEnabled: true } });
export const handleMfaDisable = async () => {
if (isMfaFeatureDisabled()) {
// check for users with MFA enabled, and if there are
// users, then keep the feature enabled
const users = await getUsersWithMfaEnabled();
if (users) {
config.set(MFA_FEATURE_ENABLED, true);
}
}
};

View File

@@ -0,0 +1,79 @@
import { v4 as uuid } from 'uuid';
import { AES, enc } from 'crypto-js';
import { TOTPService } from './totp.service';
import { Service } from 'typedi';
import { UserRepository } from '@/databases/repositories';
@Service()
export class MfaService {
constructor(
private userRepository: UserRepository,
public totp: TOTPService,
private encryptionKey: string,
) {}
public generateRecoveryCodes(n = 10) {
return Array.from(Array(n)).map(() => uuid());
}
public generateEncryptedRecoveryCodes() {
return this.generateRecoveryCodes().map((code) =>
AES.encrypt(code, this.encryptionKey).toString(),
);
}
public async saveSecretAndRecoveryCodes(userId: string, secret: string, recoveryCodes: string[]) {
const { encryptedSecret, encryptedRecoveryCodes } = this.encryptSecretAndRecoveryCodes(
secret,
recoveryCodes,
);
return this.userRepository.update(userId, {
mfaSecret: encryptedSecret,
mfaRecoveryCodes: encryptedRecoveryCodes,
});
}
public encryptSecretAndRecoveryCodes(rawSecret: string, rawRecoveryCodes: string[]) {
const encryptedSecret = AES.encrypt(rawSecret, this.encryptionKey).toString(),
encryptedRecoveryCodes = rawRecoveryCodes.map((code) =>
AES.encrypt(code, this.encryptionKey).toString(),
);
return {
encryptedRecoveryCodes,
encryptedSecret,
};
}
private decryptSecretAndRecoveryCodes(mfaSecret: string, mfaRecoveryCodes: string[]) {
return {
decryptedSecret: AES.decrypt(mfaSecret, this.encryptionKey).toString(enc.Utf8),
decryptedRecoveryCodes: mfaRecoveryCodes.map((code) =>
AES.decrypt(code, this.encryptionKey).toString(enc.Utf8),
),
};
}
public async getSecretAndRecoveryCodes(userId: string) {
const { mfaSecret, mfaRecoveryCodes } = await this.userRepository.findOneOrFail({
where: { id: userId },
select: ['id', 'mfaSecret', 'mfaRecoveryCodes'],
});
return this.decryptSecretAndRecoveryCodes(mfaSecret ?? '', mfaRecoveryCodes ?? []);
}
public async enableMfa(userId: string) {
await this.userRepository.update(userId, { mfaEnabled: true });
}
public encryptRecoveryCodes(mfaRecoveryCodes: string[]) {
return mfaRecoveryCodes.map((code) => AES.encrypt(code, this.encryptionKey).toString());
}
public async disableMfa(userId: string) {
await this.userRepository.update(userId, {
mfaEnabled: false,
mfaSecret: null,
mfaRecoveryCodes: [],
});
}
}

View File

@@ -0,0 +1,36 @@
import OTPAuth from 'otpauth';
export class TOTPService {
generateSecret(): string {
return new OTPAuth.Secret()?.base32;
}
generateTOTPUri({
issuer = 'n8n',
secret,
label,
}: {
secret: string;
label: string;
issuer?: string;
}) {
return new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(secret),
issuer,
label,
}).toString();
}
verifySecret({ secret, token, window = 1 }: { secret: string; token: string; window?: number }) {
return new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(secret),
}).validate({ token, window }) === null
? false
: true;
}
generateTOTP(secret: string) {
return OTPAuth.TOTP.generate({
secret: OTPAuth.Secret.fromBase32(secret),
});
}
}