mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
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:
parent
08902bf941
commit
5887ed6498
|
@ -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;
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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' },
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
19
packages/cli/src/databases/entities/AuthUser.ts
Normal file
19
packages/cli/src/databases/entities/AuthUser.ts
Normal 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[];
|
||||||
|
}
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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"}',
|
||||||
|
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -28,7 +28,4 @@ export const userFactory = Factory.extend<IUser>({
|
||||||
mfaEnabled() {
|
mfaEnabled() {
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
hasRecoveryCodesLeft() {
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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));
|
||||||
},
|
},
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -24,7 +24,6 @@ const currentUser = {
|
||||||
isDefaultUser: false,
|
isDefaultUser: false,
|
||||||
isPendingUser: false,
|
isPendingUser: false,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
hasRecoveryCodesLeft: false,
|
|
||||||
mfaEnabled: false,
|
mfaEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue