refactor(core): Extract all Auth-related User columns into a separate entity (#9557)

Co-authored-by: Ricardo Espinoza <ricardo@n8n.io>
This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-05-31 09:40:19 +02:00 committed by GitHub
parent 08902bf941
commit 5887ed6498
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 182 additions and 282 deletions

View file

@ -624,7 +624,6 @@ export interface PublicUser {
passwordResetToken?: string; passwordResetToken?: string;
createdAt: Date; createdAt: Date;
isPending: boolean; isPending: boolean;
hasRecoveryCodesLeft: boolean;
role?: GlobalRole; role?: GlobalRole;
globalScopes?: Scope[]; globalScopes?: Scope[];
signInType: AuthProviderType; signInType: AuthProviderType;

View file

@ -1,43 +1,34 @@
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { Service } from 'typedi'; import { Service } from 'typedi';
import { Cipher } from 'n8n-core'; import { Cipher } from 'n8n-core';
import { UserRepository } from '@db/repositories/user.repository'; import { AuthUserRepository } from '@db/repositories/authUser.repository';
import { TOTPService } from './totp.service'; import { TOTPService } from './totp.service';
@Service() @Service()
export class MfaService { export class MfaService {
constructor( constructor(
private userRepository: UserRepository, private authUserRepository: AuthUserRepository,
public totp: TOTPService, public totp: TOTPService,
private cipher: Cipher, private cipher: Cipher,
) {} ) {}
public generateRecoveryCodes(n = 10) { generateRecoveryCodes(n = 10) {
return Array.from(Array(n)).map(() => uuid()); return Array.from(Array(n)).map(() => uuid());
} }
public generateEncryptedRecoveryCodes() { async saveSecretAndRecoveryCodes(userId: string, secret: string, recoveryCodes: string[]) {
return this.generateRecoveryCodes().map((code) => this.cipher.encrypt(code));
}
public async saveSecretAndRecoveryCodes(userId: string, secret: string, recoveryCodes: string[]) {
const { encryptedSecret, encryptedRecoveryCodes } = this.encryptSecretAndRecoveryCodes( const { encryptedSecret, encryptedRecoveryCodes } = this.encryptSecretAndRecoveryCodes(
secret, secret,
recoveryCodes, recoveryCodes,
); );
const user = await this.userRepository.findOneBy({ id: userId }); const user = await this.authUserRepository.findOneByOrFail({ id: userId });
if (user) { user.mfaSecret = encryptedSecret;
Object.assign(user, { user.mfaRecoveryCodes = encryptedRecoveryCodes;
mfaSecret: encryptedSecret, await this.authUserRepository.save(user);
mfaRecoveryCodes: encryptedRecoveryCodes,
});
await this.userRepository.save(user);
}
} }
public encryptSecretAndRecoveryCodes(rawSecret: string, rawRecoveryCodes: string[]) { encryptSecretAndRecoveryCodes(rawSecret: string, rawRecoveryCodes: string[]) {
const encryptedSecret = this.cipher.encrypt(rawSecret), const encryptedSecret = this.cipher.encrypt(rawSecret),
encryptedRecoveryCodes = rawRecoveryCodes.map((code) => this.cipher.encrypt(code)); encryptedRecoveryCodes = rawRecoveryCodes.map((code) => this.cipher.encrypt(code));
return { return {
@ -53,37 +44,46 @@ export class MfaService {
}; };
} }
public async getSecretAndRecoveryCodes(userId: string) { async getSecretAndRecoveryCodes(userId: string) {
const { mfaSecret, mfaRecoveryCodes } = await this.userRepository.findOneOrFail({ const { mfaSecret, mfaRecoveryCodes } = await this.authUserRepository.findOneByOrFail({
where: { id: userId }, id: userId,
select: ['id', 'mfaSecret', 'mfaRecoveryCodes'],
}); });
return this.decryptSecretAndRecoveryCodes(mfaSecret ?? '', mfaRecoveryCodes ?? []); return this.decryptSecretAndRecoveryCodes(mfaSecret ?? '', mfaRecoveryCodes ?? []);
} }
public async enableMfa(userId: string) { async validateMfa(
const user = await this.userRepository.findOneBy({ id: userId }); userId: string,
if (user) { mfaToken: string | undefined,
user.mfaEnabled = true; mfaRecoveryCode: string | undefined,
) {
await this.userRepository.save(user); const user = await this.authUserRepository.findOneByOrFail({ id: userId });
if (mfaToken) {
const decryptedSecret = this.cipher.decrypt(user.mfaSecret!);
return this.totp.verifySecret({ secret: decryptedSecret, token: mfaToken });
} else if (mfaRecoveryCode) {
const validCodes = user.mfaRecoveryCodes.map((code) => this.cipher.decrypt(code));
const index = validCodes.indexOf(mfaRecoveryCode);
if (index === -1) return false;
// remove used recovery code
validCodes.splice(index, 1);
user.mfaRecoveryCodes = validCodes.map((code) => this.cipher.encrypt(code));
await this.authUserRepository.save(user);
return true;
} }
return false;
} }
public encryptRecoveryCodes(mfaRecoveryCodes: string[]) { async enableMfa(userId: string) {
return mfaRecoveryCodes.map((code) => this.cipher.encrypt(code)); const user = await this.authUserRepository.findOneByOrFail({ id: userId });
user.mfaEnabled = true;
return await this.authUserRepository.save(user);
} }
public async disableMfa(userId: string) { async disableMfa(userId: string) {
const user = await this.userRepository.findOneBy({ id: userId }); const user = await this.authUserRepository.findOneByOrFail({ id: userId });
user.mfaEnabled = false;
if (user) { user.mfaSecret = null;
Object.assign(user, { user.mfaRecoveryCodes = [];
mfaEnabled: false, return await this.authUserRepository.save(user);
mfaSecret: null,
mfaRecoveryCodes: [],
});
await this.userRepository.save(user);
}
} }
} }

View file

@ -1,6 +1,6 @@
import Container from 'typedi'; import Container from 'typedi';
import { Flags } from '@oclif/core'; import { Flags } from '@oclif/core';
import { UserRepository } from '@db/repositories/user.repository'; import { AuthUserRepository } from '@db/repositories/authUser.repository';
import { BaseCommand } from '../BaseCommand'; import { BaseCommand } from '../BaseCommand';
export class DisableMFACommand extends BaseCommand { export class DisableMFACommand extends BaseCommand {
@ -27,7 +27,8 @@ export class DisableMFACommand extends BaseCommand {
return; return;
} }
const user = await Container.get(UserRepository).findOneBy({ email: flags.email }); const repository = Container.get(AuthUserRepository);
const user = await repository.findOneBy({ email: flags.email });
if (!user) { if (!user) {
this.reportUserDoesNotExistError(flags.email); this.reportUserDoesNotExistError(flags.email);
@ -46,7 +47,7 @@ export class DisableMFACommand extends BaseCommand {
Object.assign(user, { mfaSecret: null, mfaRecoveryCodes: [], mfaEnabled: false }); Object.assign(user, { mfaSecret: null, mfaRecoveryCodes: [], mfaEnabled: false });
await Container.get(UserRepository).save(user); await repository.save(user);
this.reportSuccess(flags.email); this.reportSuccess(flags.email);
} }

View file

@ -79,16 +79,11 @@ export class AuthController {
throw new AuthError('MFA Error', 998); throw new AuthError('MFA Error', 998);
} }
const { decryptedRecoveryCodes, decryptedSecret } = const isMFATokenValid = await this.mfaService.validateMfa(
await this.mfaService.getSecretAndRecoveryCodes(user.id); user.id,
mfaToken,
user.mfaSecret = decryptedSecret; mfaRecoveryCode,
user.mfaRecoveryCodes = decryptedRecoveryCodes; );
const isMFATokenValid =
(await this.validateMfaToken(user, mfaToken)) ||
(await this.validateMfaRecoveryCode(user, mfaRecoveryCode));
if (!isMFATokenValid) { if (!isMFATokenValid) {
throw new AuthError('Invalid mfa token or recovery code'); throw new AuthError('Invalid mfa token or recovery code');
} }
@ -193,27 +188,4 @@ export class AuthController {
this.authService.clearCookie(res); this.authService.clearCookie(res);
return { loggedOut: true }; return { loggedOut: true };
} }
private async validateMfaToken(user: User, token?: string) {
if (!!!token) return false;
return this.mfaService.totp.verifySecret({
secret: user.mfaSecret ?? '',
token,
});
}
private async validateMfaRecoveryCode(user: User, mfaRecoveryCode?: string) {
if (!!!mfaRecoveryCode) return false;
const index = user.mfaRecoveryCodes.indexOf(mfaRecoveryCode);
if (index === -1) return false;
// remove used recovery code
user.mfaRecoveryCodes.splice(index, 1);
await this.userService.update(user.id, {
mfaRecoveryCodes: this.mfaService.encryptRecoveryCodes(user.mfaRecoveryCodes),
});
return true;
}
} }

View file

@ -16,6 +16,7 @@ import { CacheService } from '@/services/cache/cache.service';
import { PasswordUtility } from '@/services/password.utility'; import { PasswordUtility } from '@/services/password.utility';
import Container from 'typedi'; import Container from 'typedi';
import { Logger } from '@/Logger'; import { Logger } from '@/Logger';
import { AuthUserRepository } from '@/databases/repositories/authUser.repository';
if (!inE2ETests) { if (!inE2ETests) {
Container.get(Logger).error('E2E endpoints only allowed during E2E tests'); Container.get(Logger).error('E2E endpoints only allowed during E2E tests');
@ -106,6 +107,7 @@ 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.isFeatureEnabled = (feature: BooleanLicenseFeature) => license.isFeatureEnabled = (feature: BooleanLicenseFeature) =>
this.enabledFeatures[feature] ?? false; this.enabledFeatures[feature] ?? false;
@ -185,13 +187,6 @@ export class E2EController {
members: UserSetupPayload[], members: UserSetupPayload[],
admin: UserSetupPayload, admin: UserSetupPayload,
) { ) {
if (owner?.mfaSecret && owner.mfaRecoveryCodes?.length) {
const { encryptedRecoveryCodes, encryptedSecret } =
this.mfaService.encryptSecretAndRecoveryCodes(owner.mfaSecret, owner.mfaRecoveryCodes);
owner.mfaSecret = encryptedSecret;
owner.mfaRecoveryCodes = encryptedRecoveryCodes;
}
const userCreatePromises = [ const userCreatePromises = [
this.userRepository.createUserWithProject({ this.userRepository.createUserWithProject({
id: uuid(), id: uuid(),
@ -221,7 +216,17 @@ export class E2EController {
); );
} }
await Promise.all(userCreatePromises); const [newOwner] = await Promise.all(userCreatePromises);
if (owner?.mfaSecret && owner.mfaRecoveryCodes?.length) {
const { encryptedRecoveryCodes, encryptedSecret } =
this.mfaService.encryptSecretAndRecoveryCodes(owner.mfaSecret, owner.mfaRecoveryCodes);
await this.authUserRepository.update(newOwner.user.id, {
mfaSecret: encryptedSecret,
mfaRecoveryCodes: encryptedRecoveryCodes,
});
}
await this.settingsRepo.update( await this.settingsRepo.update(
{ key: 'userManagement.isInstanceOwnerSetUp' }, { key: 'userManagement.isInstanceOwnerSetUp' },

View file

@ -77,7 +77,6 @@ export class UsersController {
delete user.isOwner; delete user.isOwner;
delete user.isPending; delete user.isPending;
delete user.signInType; delete user.signInType;
delete user.hasRecoveryCodesLeft;
} }
} }

View file

@ -0,0 +1,19 @@
import { Column, Entity, PrimaryColumn } from '@n8n/typeorm';
@Entity({ name: 'user' })
export class AuthUser {
@PrimaryColumn({ type: 'uuid', update: false })
id: string;
@Column({ type: String, update: false })
email: string;
@Column({ type: Boolean, default: false })
mfaEnabled: boolean;
@Column({ type: String, nullable: true })
mfaSecret?: string | null;
@Column({ type: 'simple-array', default: '' })
mfaRecoveryCodes: string[];
}

View file

@ -109,12 +109,6 @@ export class User extends WithTimestamps implements IUser {
@Column({ type: Boolean, default: false }) @Column({ type: Boolean, default: false })
mfaEnabled: boolean; mfaEnabled: boolean;
@Column({ type: String, nullable: true, select: false })
mfaSecret?: string | null;
@Column({ type: 'simple-array', default: '', select: false })
mfaRecoveryCodes: string[];
/** /**
* Whether the user is pending setup completion. * Whether the user is pending setup completion.
*/ */
@ -152,7 +146,7 @@ export class User extends WithTimestamps implements IUser {
} }
toJSON() { toJSON() {
const { password, apiKey, mfaSecret, mfaRecoveryCodes, ...rest } = this; const { password, apiKey, ...rest } = this;
return rest; return rest;
} }

View file

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
import { AuthIdentity } from './AuthIdentity'; import { AuthIdentity } from './AuthIdentity';
import { AuthProviderSyncHistory } from './AuthProviderSyncHistory'; import { AuthProviderSyncHistory } from './AuthProviderSyncHistory';
import { AuthUser } from './AuthUser';
import { CredentialsEntity } from './CredentialsEntity'; import { CredentialsEntity } from './CredentialsEntity';
import { EventDestinations } from './EventDestinations'; import { EventDestinations } from './EventDestinations';
import { ExecutionEntity } from './ExecutionEntity'; import { ExecutionEntity } from './ExecutionEntity';
@ -25,6 +26,7 @@ import { ProjectRelation } from './ProjectRelation';
export const entities = { export const entities = {
AuthIdentity, AuthIdentity,
AuthProviderSyncHistory, AuthProviderSyncHistory,
AuthUser,
CredentialsEntity, CredentialsEntity,
EventDestinations, EventDestinations,
ExecutionEntity, ExecutionEntity,

View file

@ -0,0 +1,10 @@
import { Service } from 'typedi';
import { DataSource, Repository } from '@n8n/typeorm';
import { AuthUser } from '../entities/AuthUser';
@Service()
export class AuthUserRepository extends Repository<AuthUser> {
constructor(dataSource: DataSource) {
super(AuthUser, dataSource.manager);
}
}

View file

@ -6,6 +6,7 @@ import type { ListQuery } from '@/requests';
import { type GlobalRole, User } from '../entities/User'; import { type GlobalRole, User } from '../entities/User';
import { Project } from '../entities/Project'; import { Project } from '../entities/Project';
import { ProjectRelation } from '../entities/ProjectRelation'; import { ProjectRelation } from '../entities/ProjectRelation';
@Service() @Service()
export class UserRepository extends Repository<User> { export class UserRepository extends Repository<User> {
constructor(dataSource: DataSource) { constructor(dataSource: DataSource) {

View file

@ -57,15 +57,13 @@ export class UserService {
withScopes?: boolean; withScopes?: boolean;
}, },
) { ) {
const { password, updatedAt, apiKey, authIdentities, mfaRecoveryCodes, mfaSecret, ...rest } = const { password, updatedAt, apiKey, authIdentities, ...rest } = user;
user;
const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap'); const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap');
let publicUser: PublicUser = { let publicUser: PublicUser = {
...rest, ...rest,
signInType: ldapIdentity ? 'ldap' : 'email', signInType: ldapIdentity ? 'ldap' : 'email',
hasRecoveryCodesLeft: !!user.mfaRecoveryCodes?.length,
}; };
if (options?.withInviteUrl && !options?.inviterId) { if (options?.withInviteUrl && !options?.inviterId) {

View file

@ -3,7 +3,7 @@ import Container from 'typedi';
import { AuthService } from '@/auth/auth.service'; import { AuthService } from '@/auth/auth.service';
import config from '@/config'; import config from '@/config';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import { UserRepository } from '@db/repositories/user.repository'; import { AuthUserRepository } from '@db/repositories/authUser.repository';
import { randomPassword } from '@/Ldap/helpers'; import { randomPassword } from '@/Ldap/helpers';
import { TOTPService } from '@/Mfa/totp.service'; import { TOTPService } from '@/Mfa/totp.service';
@ -35,24 +35,22 @@ afterAll(async () => {
describe('Enable MFA setup', () => { describe('Enable MFA setup', () => {
describe('Step one', () => { describe('Step one', () => {
test('GET /qr should fail due to unauthenticated user', async () => { test('GET /qr should fail due to unauthenticated user', async () => {
const response = await testServer.authlessAgent.get('/mfa/qr'); await testServer.authlessAgent.get('/mfa/qr').expect(401);
expect(response.statusCode).toBe(401);
}); });
test('GET /qr should reuse secret and recovery codes until setup is complete', async () => { test('GET /qr should reuse secret and recovery codes until setup is complete', async () => {
const firstCall = await testServer.authAgentFor(owner).get('/mfa/qr'); const firstCall = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
const secondCall = await testServer.authAgentFor(owner).get('/mfa/qr'); const secondCall = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
expect(firstCall.body.data.secret).toBe(secondCall.body.data.secret); expect(firstCall.body.data.secret).toBe(secondCall.body.data.secret);
expect(firstCall.body.data.recoveryCodes.join('')).toBe( expect(firstCall.body.data.recoveryCodes.join('')).toBe(
secondCall.body.data.recoveryCodes.join(''), secondCall.body.data.recoveryCodes.join(''),
); );
await testServer.authAgentFor(owner).delete('/mfa/disable'); await testServer.authAgentFor(owner).delete('/mfa/disable').expect(200);
const thirdCall = await testServer.authAgentFor(owner).get('/mfa/qr'); const thirdCall = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
expect(firstCall.body.data.secret).not.toBe(thirdCall.body.data.secret); expect(firstCall.body.data.secret).not.toBe(thirdCall.body.data.secret);
expect(firstCall.body.data.recoveryCodes.join('')).not.toBe( expect(firstCall.body.data.recoveryCodes.join('')).not.toBe(
@ -61,9 +59,7 @@ describe('Enable MFA setup', () => {
}); });
test('GET /qr should return qr, secret and recovery codes', async () => { test('GET /qr should return qr, secret and recovery codes', async () => {
const response = await testServer.authAgentFor(owner).get('/mfa/qr'); const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
expect(response.statusCode).toBe(200);
const { data } = response.body; const { data } = response.body;
@ -77,93 +73,57 @@ describe('Enable MFA setup', () => {
describe('Step two', () => { describe('Step two', () => {
test('POST /verify should fail due to unauthenticated user', async () => { test('POST /verify should fail due to unauthenticated user', async () => {
const response = await testServer.authlessAgent.post('/mfa/verify'); await testServer.authlessAgent.post('/mfa/verify').expect(401);
expect(response.statusCode).toBe(401);
}); });
test('POST /verify should fail due to invalid MFA token', async () => { test('POST /verify should fail due to invalid MFA token', async () => {
const response = await testServer await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '123' }).expect(400);
.authAgentFor(owner)
.post('/mfa/verify')
.send({ token: '123' });
expect(response.statusCode).toBe(400);
}); });
test('POST /verify should fail due to missing token parameter', async () => { test('POST /verify should fail due to missing token parameter', async () => {
await testServer.authAgentFor(owner).get('/mfa/qr'); await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '' }).expect(400);
const response = await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '' });
expect(response.statusCode).toBe(400);
}); });
test('POST /verify should validate MFA token', async () => { test('POST /verify should validate MFA token', async () => {
const response = await testServer.authAgentFor(owner).get('/mfa/qr'); const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
const { secret } = response.body.data; const { secret } = response.body.data;
const token = new TOTPService().generateTOTP(secret); const token = new TOTPService().generateTOTP(secret);
const { statusCode } = await testServer await testServer.authAgentFor(owner).post('/mfa/verify').send({ token }).expect(200);
.authAgentFor(owner)
.post('/mfa/verify')
.send({ token });
expect(statusCode).toBe(200);
}); });
}); });
describe('Step three', () => { describe('Step three', () => {
test('POST /enable should fail due to unauthenticated user', async () => { test('POST /enable should fail due to unauthenticated user', async () => {
const response = await testServer.authlessAgent.post('/mfa/enable'); await testServer.authlessAgent.post('/mfa/enable').expect(401);
expect(response.statusCode).toBe(401);
}); });
test('POST /verify should fail due to missing token parameter', async () => { test('POST /verify should fail due to missing token parameter', async () => {
const response = await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '' }); await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '' }).expect(400);
expect(response.statusCode).toBe(400);
}); });
test('POST /enable should fail due to invalid MFA token', async () => { test('POST /enable should fail due to invalid MFA token', async () => {
await testServer.authAgentFor(owner).get('/mfa/qr'); await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
await testServer.authAgentFor(owner).post('/mfa/enable').send({ token: '123' }).expect(400);
const response = await testServer
.authAgentFor(owner)
.post('/mfa/enable')
.send({ token: '123' });
expect(response.statusCode).toBe(400);
}); });
test('POST /enable should fail due to empty secret and recovery codes', async () => { test('POST /enable should fail due to empty secret and recovery codes', async () => {
const response = await testServer.authAgentFor(owner).post('/mfa/enable'); await testServer.authAgentFor(owner).post('/mfa/enable').expect(400);
expect(response.statusCode).toBe(400);
}); });
test('POST /enable should enable MFA in account', async () => { test('POST /enable should enable MFA in account', async () => {
const response = await testServer.authAgentFor(owner).get('/mfa/qr'); const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
const { secret } = response.body.data; const { secret } = response.body.data;
const token = new TOTPService().generateTOTP(secret); const token = new TOTPService().generateTOTP(secret);
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token }); await testServer.authAgentFor(owner).post('/mfa/verify').send({ token }).expect(200);
await testServer.authAgentFor(owner).post('/mfa/enable').send({ token }).expect(200);
const { statusCode } = await testServer const user = await Container.get(AuthUserRepository).findOneOrFail({
.authAgentFor(owner)
.post('/mfa/enable')
.send({ token });
expect(statusCode).toBe(200);
const user = await Container.get(UserRepository).findOneOrFail({
where: {}, where: {},
select: ['mfaEnabled', 'mfaRecoveryCodes', 'mfaSecret'],
}); });
expect(user.mfaEnabled).toBe(true); expect(user.mfaEnabled).toBe(true);
@ -177,13 +137,10 @@ describe('Disable MFA setup', () => {
test('POST /disable should disable login with MFA', async () => { test('POST /disable should disable login with MFA', async () => {
const { user } = await createUserWithMfaEnabled(); const { user } = await createUserWithMfaEnabled();
const response = await testServer.authAgentFor(user).delete('/mfa/disable'); await testServer.authAgentFor(user).delete('/mfa/disable').expect(200);
expect(response.statusCode).toBe(200); const dbUser = await Container.get(AuthUserRepository).findOneOrFail({
const dbUser = await Container.get(UserRepository).findOneOrFail({
where: { id: user.id }, where: { id: user.id },
select: ['mfaEnabled', 'mfaRecoveryCodes', 'mfaSecret'],
}); });
expect(dbUser.mfaEnabled).toBe(false); expect(dbUser.mfaEnabled).toBe(false);
@ -198,42 +155,39 @@ describe('Change password with MFA enabled', () => {
const newPassword = randomPassword(); const newPassword = randomPassword();
const response = await testServer await testServer
.authAgentFor(user) .authAgentFor(user)
.patch('/me/password') .patch('/me/password')
.send({ currentPassword: rawPassword, newPassword }); .send({ currentPassword: rawPassword, newPassword })
.expect(400);
expect(response.statusCode).toBe(400);
}); });
test('POST /change-password should fail due to missing MFA token', async () => { test('POST /change-password should fail due to missing MFA token', async () => {
await createUserWithMfaEnabled(); await createUserWithMfaEnabled();
const newPassword = randomValidPassword(); const newPassword = randomValidPassword();
const resetPasswordToken = uniqueId(); const resetPasswordToken = uniqueId();
const response = await testServer.authlessAgent await testServer.authlessAgent
.post('/change-password') .post('/change-password')
.send({ password: newPassword, token: resetPasswordToken }); .send({ password: newPassword, token: resetPasswordToken })
.expect(404);
expect(response.statusCode).toBe(404);
}); });
test('POST /change-password should fail due to invalid MFA token', async () => { test('POST /change-password should fail due to invalid MFA token', async () => {
await createUserWithMfaEnabled(); await createUserWithMfaEnabled();
const newPassword = randomValidPassword(); const newPassword = randomValidPassword();
const resetPasswordToken = uniqueId(); const resetPasswordToken = uniqueId();
const response = await testServer.authlessAgent.post('/change-password').send({ await testServer.authlessAgent
password: newPassword, .post('/change-password')
token: resetPasswordToken, .send({
mfaToken: randomDigit(), password: newPassword,
}); token: resetPasswordToken,
mfaToken: randomDigit(),
expect(response.statusCode).toBe(404); })
.expect(404);
}); });
test('POST /change-password should update password', async () => { test('POST /change-password should update password', async () => {
@ -247,13 +201,14 @@ describe('Change password with MFA enabled', () => {
const mfaToken = new TOTPService().generateTOTP(rawSecret); const mfaToken = new TOTPService().generateTOTP(rawSecret);
const response = await testServer.authlessAgent.post('/change-password').send({ await testServer.authlessAgent
password: newPassword, .post('/change-password')
token: resetPasswordToken, .send({
mfaToken, password: newPassword,
}); token: resetPasswordToken,
mfaToken,
expect(response.statusCode).toBe(200); })
.expect(200);
const loginResponse = await testServer const loginResponse = await testServer
.authAgentFor(user) .authAgentFor(user)
@ -262,9 +217,9 @@ describe('Change password with MFA enabled', () => {
email: user.email, email: user.email,
password: newPassword, password: newPassword,
mfaToken: new TOTPService().generateTOTP(rawSecret), mfaToken: new TOTPService().generateTOTP(rawSecret),
}); })
.expect(200);
expect(loginResponse.statusCode).toBe(200);
expect(loginResponse.body).toHaveProperty('data'); expect(loginResponse.body).toHaveProperty('data');
}); });
}); });
@ -275,30 +230,14 @@ describe('Login', () => {
const user = await createUser({ password }); const user = await createUser({ password });
const response = await testServer.authlessAgent await testServer.authlessAgent.post('/login').send({ email: user.email, password }).expect(200);
.post('/login')
.send({ email: user.email, password });
expect(response.statusCode).toBe(200);
});
test('GET /login should include hasRecoveryCodesLeft property in response', async () => {
const response = await testServer.authAgentFor(owner).get('/login');
const { data } = response.body;
expect(response.statusCode).toBe(200);
expect(data.hasRecoveryCodesLeft).toBeDefined();
}); });
test('GET /login should not include mfaSecret and mfaRecoveryCodes property in response', async () => { test('GET /login should not include mfaSecret and mfaRecoveryCodes property in response', async () => {
const response = await testServer.authAgentFor(owner).get('/login'); const response = await testServer.authAgentFor(owner).get('/login').expect(200);
const { data } = response.body; const { data } = response.body;
expect(response.statusCode).toBe(200);
expect(data.recoveryCodes).not.toBeDefined(); expect(data.recoveryCodes).not.toBeDefined();
expect(data.mfaSecret).not.toBeDefined(); expect(data.mfaSecret).not.toBeDefined();
}); });
@ -306,22 +245,20 @@ describe('Login', () => {
test('POST /login with email/password should fail when mfa is enabled', async () => { test('POST /login with email/password should fail when mfa is enabled', async () => {
const { user, rawPassword } = await createUserWithMfaEnabled(); const { user, rawPassword } = await createUserWithMfaEnabled();
const response = await testServer.authlessAgent await testServer.authlessAgent
.post('/login') .post('/login')
.send({ email: user.email, password: rawPassword }); .send({ email: user.email, password: rawPassword })
.expect(401);
expect(response.statusCode).toBe(401);
}); });
describe('Login with MFA token', () => { describe('Login with MFA token', () => {
test('POST /login should fail due to invalid MFA token', async () => { test('POST /login should fail due to invalid MFA token', async () => {
const { user, rawPassword } = await createUserWithMfaEnabled(); const { user, rawPassword } = await createUserWithMfaEnabled();
const response = await testServer.authlessAgent await testServer.authlessAgent
.post('/login') .post('/login')
.send({ email: user.email, password: rawPassword, mfaToken: 'wrongvalue' }); .send({ email: user.email, password: rawPassword, mfaToken: 'wrongvalue' })
.expect(401);
expect(response.statusCode).toBe(401);
}); });
test('POST /login should fail due two MFA step needed', async () => { test('POST /login should fail due two MFA step needed', async () => {
@ -329,9 +266,9 @@ describe('Login', () => {
const response = await testServer.authlessAgent const response = await testServer.authlessAgent
.post('/login') .post('/login')
.send({ email: user.email, password: rawPassword }); .send({ email: user.email, password: rawPassword })
.expect(401);
expect(response.statusCode).toBe(401);
expect(response.body.code).toBe(998); expect(response.body.code).toBe(998);
}); });
@ -342,11 +279,11 @@ describe('Login', () => {
const response = await testServer.authlessAgent const response = await testServer.authlessAgent
.post('/login') .post('/login')
.send({ email: user.email, password: rawPassword, mfaToken: token }); .send({ email: user.email, password: rawPassword, mfaToken: token })
.expect(200);
const data = response.body.data; const data = response.body.data;
expect(response.statusCode).toBe(200);
expect(data.mfaEnabled).toBe(true); expect(data.mfaEnabled).toBe(true);
}); });
}); });
@ -355,11 +292,10 @@ describe('Login', () => {
test('POST /login should fail due to invalid MFA recovery code', async () => { test('POST /login should fail due to invalid MFA recovery code', async () => {
const { user, rawPassword } = await createUserWithMfaEnabled(); const { user, rawPassword } = await createUserWithMfaEnabled();
const response = await testServer.authlessAgent await testServer.authlessAgent
.post('/login') .post('/login')
.send({ email: user.email, password: rawPassword, mfaRecoveryCode: 'wrongvalue' }); .send({ email: user.email, password: rawPassword, mfaRecoveryCode: 'wrongvalue' })
.expect(401);
expect(response.statusCode).toBe(401);
}); });
test('POST /login should succeed with MFA recovery code', async () => { test('POST /login should succeed with MFA recovery code', async () => {
@ -367,38 +303,19 @@ describe('Login', () => {
const response = await testServer.authlessAgent const response = await testServer.authlessAgent
.post('/login') .post('/login')
.send({ email: user.email, password: rawPassword, mfaRecoveryCode: rawRecoveryCodes[0] }); .send({ email: user.email, password: rawPassword, mfaRecoveryCode: rawRecoveryCodes[0] })
.expect(200);
const data = response.body.data; const data = response.body.data;
expect(response.statusCode).toBe(200);
expect(data.mfaEnabled).toBe(true); expect(data.mfaEnabled).toBe(true);
expect(data.hasRecoveryCodesLeft).toBe(true);
const dbUser = await Container.get(UserRepository).findOneOrFail({ const dbUser = await Container.get(AuthUserRepository).findOneOrFail({
where: { id: user.id }, where: { id: user.id },
select: ['mfaEnabled', 'mfaRecoveryCodes', 'mfaSecret'],
}); });
// Make sure the recovery code used was removed // Make sure the recovery code used was removed
expect(dbUser.mfaRecoveryCodes.length).toBe(rawRecoveryCodes.length - 1); expect(dbUser.mfaRecoveryCodes.length).toBe(rawRecoveryCodes.length - 1);
expect(dbUser.mfaRecoveryCodes.includes(rawRecoveryCodes[0])).toBe(false); expect(dbUser.mfaRecoveryCodes.includes(rawRecoveryCodes[0])).toBe(false);
}); });
test('POST /login with MFA recovery code should update hasRecoveryCodesLeft property', async () => {
const { user, rawPassword, rawRecoveryCodes } = await createUserWithMfaEnabled({
numberOfRecoveryCodes: 1,
});
const response = await testServer.authlessAgent
.post('/login')
.send({ email: user.email, password: rawPassword, mfaRecoveryCode: rawRecoveryCodes[0] });
const data = response.body.data;
expect(response.statusCode).toBe(200);
expect(data.mfaEnabled).toBe(true);
expect(data.hasRecoveryCodesLeft).toBe(false);
});
}); });
}); });

View file

@ -8,6 +8,7 @@ import { TOTPService } from '@/Mfa/totp.service';
import { MfaService } from '@/Mfa/mfa.service'; import { MfaService } from '@/Mfa/mfa.service';
import { randomApiKey, randomEmail, randomName, randomValidPassword } from '../random'; import { randomApiKey, randomEmail, randomName, randomValidPassword } from '../random';
import { AuthUserRepository } from '@/databases/repositories/authUser.repository';
// pre-computed bcrypt hash for the string 'password', using `await hash('password', 10)` // pre-computed bcrypt hash for the string 'password', using `await hash('password', 10)`
const passwordHash = '$2a$10$njedH7S6V5898mj6p0Jr..IGY9Ms.qNwR7RbSzzX9yubJocKfvGGK'; const passwordHash = '$2a$10$njedH7S6V5898mj6p0Jr..IGY9Ms.qNwR7RbSzzX9yubJocKfvGGK';
@ -58,14 +59,19 @@ export async function createUserWithMfaEnabled(
recoveryCodes, recoveryCodes,
); );
const user = await createUser({
mfaEnabled: true,
password,
email,
});
await Container.get(AuthUserRepository).update(user.id, {
mfaSecret: encryptedSecret,
mfaRecoveryCodes: encryptedRecoveryCodes,
});
return { return {
user: await createUser({ user,
mfaEnabled: true,
password,
email,
mfaSecret: encryptedSecret,
mfaRecoveryCodes: encryptedRecoveryCodes,
}),
rawPassword: password, rawPassword: password,
rawSecret: secret, rawSecret: secret,
rawRecoveryCodes: recoveryCodes, rawRecoveryCodes: recoveryCodes,

View file

@ -9,8 +9,6 @@ describe('User Entity', () => {
lastName: 'Joe', lastName: 'Joe',
password: '123456789', password: '123456789',
apiKey: '123', apiKey: '123',
mfaSecret: '123',
mfaRecoveryCodes: ['123'],
}); });
expect(JSON.stringify(user)).toEqual( expect(JSON.stringify(user)).toEqual(
'{"email":"test@example.com","firstName":"Don","lastName":"Joe"}', '{"email":"test@example.com","firstName":"Don","lastName":"Joe"}',

View file

@ -30,15 +30,13 @@ describe('UserService', () => {
}); });
type MaybeSensitiveProperties = Partial< type MaybeSensitiveProperties = Partial<
Pick<User, 'password' | 'mfaSecret' | 'mfaRecoveryCodes' | 'updatedAt' | 'authIdentities'> Pick<User, 'password' | 'updatedAt' | 'authIdentities'>
>; >;
// to prevent typechecking from blocking assertions // to prevent typechecking from blocking assertions
const publicUser: MaybeSensitiveProperties = await userService.toPublic(mockUser); const publicUser: MaybeSensitiveProperties = await userService.toPublic(mockUser);
expect(publicUser.password).toBeUndefined(); expect(publicUser.password).toBeUndefined();
expect(publicUser.mfaSecret).toBeUndefined();
expect(publicUser.mfaRecoveryCodes).toBeUndefined();
expect(publicUser.updatedAt).toBeUndefined(); expect(publicUser.updatedAt).toBeUndefined();
expect(publicUser.authIdentities).toBeUndefined(); expect(publicUser.authIdentities).toBeUndefined();
}); });

View file

@ -742,7 +742,6 @@ export interface CurrentUserResponse extends IUserResponse {
export interface IUser extends IUserResponse { export interface IUser extends IUserResponse {
isDefaultUser: boolean; isDefaultUser: boolean;
isPendingUser: boolean; isPendingUser: boolean;
hasRecoveryCodesLeft: boolean;
inviteAcceptUrl?: string; inviteAcceptUrl?: string;
fullName?: string; fullName?: string;
createdAt?: string; createdAt?: string;

View file

@ -10,7 +10,6 @@ export const createUser = (overrides?: Partial<IUser>): IUser => ({
isDefaultUser: false, isDefaultUser: false,
isPending: false, isPending: false,
isPendingUser: false, isPendingUser: false,
hasRecoveryCodesLeft: false,
mfaEnabled: false, mfaEnabled: false,
signInType: SignInType.EMAIL, signInType: SignInType.EMAIL,
...overrides, ...overrides,

View file

@ -28,7 +28,4 @@ export const userFactory = Factory.extend<IUser>({
mfaEnabled() { mfaEnabled() {
return false; return false;
}, },
hasRecoveryCodesLeft() {
return false;
},
}); });

View file

@ -30,7 +30,6 @@ const pinia = createTestingPinia({
lastName: 'Doe', lastName: 'Doe',
isDefaultUser: false, isDefaultUser: false,
isPendingUser: false, isPendingUser: false,
hasRecoveryCodesLeft: true,
role: ROLE.Owner, role: ROLE.Owner,
mfaEnabled: false, mfaEnabled: false,
}, },

View file

@ -148,7 +148,11 @@ export default defineComponent({
this.verifyingMfaToken = true; this.verifyingMfaToken = true;
this.hasAnyChanges = true; this.hasAnyChanges = true;
this.onSubmit({ token: value, recoveryCode: value }) const dataToSubmit = isSubmittingMfaToken
? { token: value, recoveryCode: '' }
: { token: '', recoveryCode: value };
this.onSubmit(dataToSubmit)
.catch(() => {}) .catch(() => {})
.finally(() => (this.verifyingMfaToken = false)); .finally(() => (this.verifyingMfaToken = false));
}, },

View file

@ -144,7 +144,6 @@ export default defineComponent({
} }
await this.settingsStore.getSettings(); await this.settingsStore.getSettings();
this.clearAllStickyNotifications(); this.clearAllStickyNotifications();
this.checkRecoveryCodesLeft();
this.$telemetry.track('User attempted to login', { this.$telemetry.track('User attempted to login', {
result: this.showMfaView ? 'mfa_success' : 'success', result: this.showMfaView ? 'mfa_success' : 'success',
@ -198,21 +197,6 @@ export default defineComponent({
this.email = form.email; this.email = form.email;
this.password = form.password; this.password = form.password;
}, },
checkRecoveryCodesLeft() {
if (this.usersStore.currentUser) {
const { hasRecoveryCodesLeft, mfaEnabled } = this.usersStore.currentUser;
if (mfaEnabled && !hasRecoveryCodesLeft) {
this.showToast({
title: this.$locale.baseText('settings.mfa.toast.noRecoveryCodeLeft.title'),
message: this.$locale.baseText('settings.mfa.toast.noRecoveryCodeLeft.message'),
type: 'info',
duration: 0,
dangerouslyUseHTMLString: true,
});
}
}
},
}, },
}); });
</script> </script>

View file

@ -24,7 +24,6 @@ const currentUser = {
isDefaultUser: false, isDefaultUser: false,
isPendingUser: false, isPendingUser: false,
isPending: false, isPending: false,
hasRecoveryCodesLeft: false,
mfaEnabled: false, mfaEnabled: false,
}; };