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;
createdAt: Date;
isPending: boolean;
hasRecoveryCodesLeft: boolean;
role?: GlobalRole;
globalScopes?: Scope[];
signInType: AuthProviderType;

View file

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

View file

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

View file

@ -79,16 +79,11 @@ export class AuthController {
throw new AuthError('MFA Error', 998);
}
const { decryptedRecoveryCodes, decryptedSecret } =
await this.mfaService.getSecretAndRecoveryCodes(user.id);
user.mfaSecret = decryptedSecret;
user.mfaRecoveryCodes = decryptedRecoveryCodes;
const isMFATokenValid =
(await this.validateMfaToken(user, mfaToken)) ||
(await this.validateMfaRecoveryCode(user, mfaRecoveryCode));
const isMFATokenValid = await this.mfaService.validateMfa(
user.id,
mfaToken,
mfaRecoveryCode,
);
if (!isMFATokenValid) {
throw new AuthError('Invalid mfa token or recovery code');
}
@ -193,27 +188,4 @@ export class AuthController {
this.authService.clearCookie(res);
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 Container from 'typedi';
import { Logger } from '@/Logger';
import { AuthUserRepository } from '@/databases/repositories/authUser.repository';
if (!inE2ETests) {
Container.get(Logger).error('E2E endpoints only allowed during E2E tests');
@ -106,6 +107,7 @@ export class E2EController {
private readonly passwordUtility: PasswordUtility,
private readonly eventBus: MessageEventBus,
private readonly userRepository: UserRepository,
private readonly authUserRepository: AuthUserRepository,
) {
license.isFeatureEnabled = (feature: BooleanLicenseFeature) =>
this.enabledFeatures[feature] ?? false;
@ -185,13 +187,6 @@ export class E2EController {
members: 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 = [
this.userRepository.createUserWithProject({
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(
{ key: 'userManagement.isInstanceOwnerSetUp' },

View file

@ -77,7 +77,6 @@ export class UsersController {
delete user.isOwner;
delete user.isPending;
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 })
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.
*/
@ -152,7 +146,7 @@ export class User extends WithTimestamps implements IUser {
}
toJSON() {
const { password, apiKey, mfaSecret, mfaRecoveryCodes, ...rest } = this;
const { password, apiKey, ...rest } = this;
return rest;
}

View file

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { AuthIdentity } from './AuthIdentity';
import { AuthProviderSyncHistory } from './AuthProviderSyncHistory';
import { AuthUser } from './AuthUser';
import { CredentialsEntity } from './CredentialsEntity';
import { EventDestinations } from './EventDestinations';
import { ExecutionEntity } from './ExecutionEntity';
@ -25,6 +26,7 @@ import { ProjectRelation } from './ProjectRelation';
export const entities = {
AuthIdentity,
AuthProviderSyncHistory,
AuthUser,
CredentialsEntity,
EventDestinations,
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 { Project } from '../entities/Project';
import { ProjectRelation } from '../entities/ProjectRelation';
@Service()
export class UserRepository extends Repository<User> {
constructor(dataSource: DataSource) {

View file

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

View file

@ -3,7 +3,7 @@ import Container from 'typedi';
import { AuthService } from '@/auth/auth.service';
import config from '@/config';
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 { TOTPService } from '@/Mfa/totp.service';
@ -35,24 +35,22 @@ afterAll(async () => {
describe('Enable MFA setup', () => {
describe('Step one', () => {
test('GET /qr should fail due to unauthenticated user', async () => {
const response = await testServer.authlessAgent.get('/mfa/qr');
expect(response.statusCode).toBe(401);
await testServer.authlessAgent.get('/mfa/qr').expect(401);
});
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.recoveryCodes.join('')).toBe(
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.recoveryCodes.join('')).not.toBe(
@ -61,9 +59,7 @@ describe('Enable MFA setup', () => {
});
test('GET /qr should return qr, secret and recovery codes', async () => {
const response = await testServer.authAgentFor(owner).get('/mfa/qr');
expect(response.statusCode).toBe(200);
const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
const { data } = response.body;
@ -77,93 +73,57 @@ describe('Enable MFA setup', () => {
describe('Step two', () => {
test('POST /verify should fail due to unauthenticated user', async () => {
const response = await testServer.authlessAgent.post('/mfa/verify');
expect(response.statusCode).toBe(401);
await testServer.authlessAgent.post('/mfa/verify').expect(401);
});
test('POST /verify should fail due to invalid MFA token', async () => {
const response = await testServer
.authAgentFor(owner)
.post('/mfa/verify')
.send({ token: '123' });
expect(response.statusCode).toBe(400);
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '123' }).expect(400);
});
test('POST /verify should fail due to missing token parameter', async () => {
await testServer.authAgentFor(owner).get('/mfa/qr');
const response = await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '' });
expect(response.statusCode).toBe(400);
await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '' }).expect(400);
});
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 token = new TOTPService().generateTOTP(secret);
const { statusCode } = await testServer
.authAgentFor(owner)
.post('/mfa/verify')
.send({ token });
expect(statusCode).toBe(200);
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token }).expect(200);
});
});
describe('Step three', () => {
test('POST /enable should fail due to unauthenticated user', async () => {
const response = await testServer.authlessAgent.post('/mfa/enable');
expect(response.statusCode).toBe(401);
await testServer.authlessAgent.post('/mfa/enable').expect(401);
});
test('POST /verify should fail due to missing token parameter', async () => {
const response = await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '' });
expect(response.statusCode).toBe(400);
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '' }).expect(400);
});
test('POST /enable should fail due to invalid MFA token', async () => {
await testServer.authAgentFor(owner).get('/mfa/qr');
const response = await testServer
.authAgentFor(owner)
.post('/mfa/enable')
.send({ token: '123' });
expect(response.statusCode).toBe(400);
await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
await testServer.authAgentFor(owner).post('/mfa/enable').send({ token: '123' }).expect(400);
});
test('POST /enable should fail due to empty secret and recovery codes', async () => {
const response = await testServer.authAgentFor(owner).post('/mfa/enable');
expect(response.statusCode).toBe(400);
await testServer.authAgentFor(owner).post('/mfa/enable').expect(400);
});
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 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
.authAgentFor(owner)
.post('/mfa/enable')
.send({ token });
expect(statusCode).toBe(200);
const user = await Container.get(UserRepository).findOneOrFail({
const user = await Container.get(AuthUserRepository).findOneOrFail({
where: {},
select: ['mfaEnabled', 'mfaRecoveryCodes', 'mfaSecret'],
});
expect(user.mfaEnabled).toBe(true);
@ -177,13 +137,10 @@ describe('Disable MFA setup', () => {
test('POST /disable should disable login with MFA', async () => {
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(UserRepository).findOneOrFail({
const dbUser = await Container.get(AuthUserRepository).findOneOrFail({
where: { id: user.id },
select: ['mfaEnabled', 'mfaRecoveryCodes', 'mfaSecret'],
});
expect(dbUser.mfaEnabled).toBe(false);
@ -198,42 +155,39 @@ describe('Change password with MFA enabled', () => {
const newPassword = randomPassword();
const response = await testServer
await testServer
.authAgentFor(user)
.patch('/me/password')
.send({ currentPassword: rawPassword, newPassword });
expect(response.statusCode).toBe(400);
.send({ currentPassword: rawPassword, newPassword })
.expect(400);
});
test('POST /change-password should fail due to missing MFA token', async () => {
await createUserWithMfaEnabled();
const newPassword = randomValidPassword();
const resetPasswordToken = uniqueId();
const response = await testServer.authlessAgent
await testServer.authlessAgent
.post('/change-password')
.send({ password: newPassword, token: resetPasswordToken });
expect(response.statusCode).toBe(404);
.send({ password: newPassword, token: resetPasswordToken })
.expect(404);
});
test('POST /change-password should fail due to invalid MFA token', async () => {
await createUserWithMfaEnabled();
const newPassword = randomValidPassword();
const resetPasswordToken = uniqueId();
const response = await testServer.authlessAgent.post('/change-password').send({
password: newPassword,
token: resetPasswordToken,
mfaToken: randomDigit(),
});
expect(response.statusCode).toBe(404);
await testServer.authlessAgent
.post('/change-password')
.send({
password: newPassword,
token: resetPasswordToken,
mfaToken: randomDigit(),
})
.expect(404);
});
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 response = await testServer.authlessAgent.post('/change-password').send({
password: newPassword,
token: resetPasswordToken,
mfaToken,
});
expect(response.statusCode).toBe(200);
await testServer.authlessAgent
.post('/change-password')
.send({
password: newPassword,
token: resetPasswordToken,
mfaToken,
})
.expect(200);
const loginResponse = await testServer
.authAgentFor(user)
@ -262,9 +217,9 @@ describe('Change password with MFA enabled', () => {
email: user.email,
password: newPassword,
mfaToken: new TOTPService().generateTOTP(rawSecret),
});
})
.expect(200);
expect(loginResponse.statusCode).toBe(200);
expect(loginResponse.body).toHaveProperty('data');
});
});
@ -275,30 +230,14 @@ describe('Login', () => {
const user = await createUser({ password });
const response = await testServer.authlessAgent
.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();
await testServer.authlessAgent.post('/login').send({ email: user.email, password }).expect(200);
});
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;
expect(response.statusCode).toBe(200);
expect(data.recoveryCodes).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 () => {
const { user, rawPassword } = await createUserWithMfaEnabled();
const response = await testServer.authlessAgent
await testServer.authlessAgent
.post('/login')
.send({ email: user.email, password: rawPassword });
expect(response.statusCode).toBe(401);
.send({ email: user.email, password: rawPassword })
.expect(401);
});
describe('Login with MFA token', () => {
test('POST /login should fail due to invalid MFA token', async () => {
const { user, rawPassword } = await createUserWithMfaEnabled();
const response = await testServer.authlessAgent
await testServer.authlessAgent
.post('/login')
.send({ email: user.email, password: rawPassword, mfaToken: 'wrongvalue' });
expect(response.statusCode).toBe(401);
.send({ email: user.email, password: rawPassword, mfaToken: 'wrongvalue' })
.expect(401);
});
test('POST /login should fail due two MFA step needed', async () => {
@ -329,9 +266,9 @@ describe('Login', () => {
const response = await testServer.authlessAgent
.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);
});
@ -342,11 +279,11 @@ describe('Login', () => {
const response = await testServer.authlessAgent
.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;
expect(response.statusCode).toBe(200);
expect(data.mfaEnabled).toBe(true);
});
});
@ -355,11 +292,10 @@ describe('Login', () => {
test('POST /login should fail due to invalid MFA recovery code', async () => {
const { user, rawPassword } = await createUserWithMfaEnabled();
const response = await testServer.authlessAgent
await testServer.authlessAgent
.post('/login')
.send({ email: user.email, password: rawPassword, mfaRecoveryCode: 'wrongvalue' });
expect(response.statusCode).toBe(401);
.send({ email: user.email, password: rawPassword, mfaRecoveryCode: 'wrongvalue' })
.expect(401);
});
test('POST /login should succeed with MFA recovery code', async () => {
@ -367,38 +303,19 @@ describe('Login', () => {
const response = await testServer.authlessAgent
.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;
expect(response.statusCode).toBe(200);
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 },
select: ['mfaEnabled', 'mfaRecoveryCodes', 'mfaSecret'],
});
// Make sure the recovery code used was removed
expect(dbUser.mfaRecoveryCodes.length).toBe(rawRecoveryCodes.length - 1);
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 { 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)`
const passwordHash = '$2a$10$njedH7S6V5898mj6p0Jr..IGY9Ms.qNwR7RbSzzX9yubJocKfvGGK';
@ -58,14 +59,19 @@ export async function createUserWithMfaEnabled(
recoveryCodes,
);
const user = await createUser({
mfaEnabled: true,
password,
email,
});
await Container.get(AuthUserRepository).update(user.id, {
mfaSecret: encryptedSecret,
mfaRecoveryCodes: encryptedRecoveryCodes,
});
return {
user: await createUser({
mfaEnabled: true,
password,
email,
mfaSecret: encryptedSecret,
mfaRecoveryCodes: encryptedRecoveryCodes,
}),
user,
rawPassword: password,
rawSecret: secret,
rawRecoveryCodes: recoveryCodes,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -144,7 +144,6 @@ export default defineComponent({
}
await this.settingsStore.getSettings();
this.clearAllStickyNotifications();
this.checkRecoveryCodesLeft();
this.$telemetry.track('User attempted to login', {
result: this.showMfaView ? 'mfa_success' : 'success',
@ -198,21 +197,6 @@ export default defineComponent({
this.email = form.email;
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>

View file

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