diff --git a/packages/auth/src/Domain/UseCase/VerifyMFA.spec.ts b/packages/auth/src/Domain/UseCase/VerifyMFA.spec.ts index a2a8f19be..2c58b9480 100644 --- a/packages/auth/src/Domain/UseCase/VerifyMFA.spec.ts +++ b/packages/auth/src/Domain/UseCase/VerifyMFA.spec.ts @@ -1,14 +1,19 @@ import 'reflect-metadata' import { authenticator } from 'otplib' +import { SettingName } from '@standardnotes/settings' +import { SelectorInterface } from '@standardnotes/security' +import { Result, UseCaseInterface } from '@standardnotes/domain-core' import { User } from '../User/User' import { UserRepositoryInterface } from '../User/UserRepositoryInterface' -import { VerifyMFA } from './VerifyMFA' import { Setting } from '../Setting/Setting' import { SettingServiceInterface } from '../Setting/SettingServiceInterface' -import { SettingName } from '@standardnotes/settings' -import { SelectorInterface } from '@standardnotes/security' import { LockRepositoryInterface } from '../User/LockRepositoryInterface' +import { AuthenticatorRepositoryInterface } from '../Authenticator/AuthenticatorRepositoryInterface' + +import { VerifyMFA } from './VerifyMFA' +import { Logger } from 'winston' +import { Authenticator } from '../Authenticator/Authenticator' describe('VerifyMFA', () => { let user: User @@ -17,13 +22,27 @@ describe('VerifyMFA', () => { let settingService: SettingServiceInterface let booleanSelector: SelectorInterface let lockRepository: LockRepositoryInterface + let authenticatorRepository: AuthenticatorRepositoryInterface + let verifyAuthenticatorAuthenticationResponse: UseCaseInterface + let logger: Logger const pseudoKeyParamsKey = 'foobar' const createVerifyMFA = () => - new VerifyMFA(userRepository, settingService, booleanSelector, lockRepository, pseudoKeyParamsKey) + new VerifyMFA( + userRepository, + settingService, + booleanSelector, + lockRepository, + pseudoKeyParamsKey, + authenticatorRepository, + verifyAuthenticatorAuthenticationResponse, + logger, + ) beforeEach(() => { - user = {} as jest.Mocked + user = { + uuid: '00000000-0000-0000-0000-000000000000', + } as jest.Mocked userRepository = {} as jest.Mocked userRepository.findOneByEmail = jest.fn().mockReturnValue(user) @@ -42,164 +61,270 @@ describe('VerifyMFA', () => { settingService = {} as jest.Mocked settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting) + + authenticatorRepository = {} as jest.Mocked + authenticatorRepository.findByUserUuid = jest.fn().mockReturnValue([]) + + verifyAuthenticatorAuthenticationResponse = {} as jest.Mocked> + verifyAuthenticatorAuthenticationResponse.execute = jest.fn().mockReturnValue(Result.ok()) + + logger = {} as jest.Mocked + logger.debug = jest.fn() }) - it('should pass MFA verification if user has no MFA enabled', async () => { - settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(null) + describe('2FA', () => { + it('should pass MFA verification if user has no MFA enabled', async () => { + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(null) - expect( - await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }), - ).toEqual({ - success: true, - }) - - expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled() - }) - - it('should pass MFA verification if user has MFA deleted', async () => { - setting = { - name: SettingName.MfaSecret, - value: null, - } as jest.Mocked - - settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting) - - expect( - await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }), - ).toEqual({ - success: true, - }) - - expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled() - }) - - it('should pass MFA verification if user is not found and pseudo mfa is not required', async () => { - userRepository.findOneByEmail = jest.fn().mockReturnValue(null) - expect( - await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }), - ).toEqual({ - success: true, - }) - - expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled() - }) - - it('should not pass MFA verification if user is not found and pseudo mfa is required', async () => { - booleanSelector.select = jest.fn().mockReturnValue(true) - userRepository.findOneByEmail = jest.fn().mockReturnValue(null) - - expect( - await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }), - ).toEqual({ - success: false, - errorTag: 'mfa-required', - errorMessage: 'Please enter your two-factor authentication code.', - errorPayload: { mfa_key: expect.stringMatching(/^mfa_/) }, - }) - }) - - it('should pass MFA verification if mfa key is correctly encrypted', async () => { - expect( - await createVerifyMFA().execute({ - email: 'test@test.te', - requestParams: { 'mfa_1-2-3': authenticator.generate('shhhh') }, - preventOTPFromFurtherUsage: true, - }), - ).toEqual({ - success: true, - }) - - expect(lockRepository.lockSuccessfullOTP).toHaveBeenCalledWith('test@test.te', expect.any(String)) - }) - - it('should pass MFA verification without locking otp', async () => { - expect( - await createVerifyMFA().execute({ - email: 'test@test.te', - requestParams: { 'mfa_1-2-3': authenticator.generate('shhhh') }, - preventOTPFromFurtherUsage: false, - }), - ).toEqual({ - success: true, - }) - - expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled() - }) - - it('should not pass MFA verification if otp is already used within lock out period', async () => { - lockRepository.isOTPLocked = jest.fn().mockReturnValue(true) - - expect( - await createVerifyMFA().execute({ - email: 'test@test.te', - requestParams: { 'mfa_1-2-3': authenticator.generate('shhhh') }, - preventOTPFromFurtherUsage: true, - }), - ).toEqual({ - success: false, - errorTag: 'mfa-invalid', - errorMessage: - 'The two-factor authentication code you entered has been already utilized. Please try again in a while.', - errorPayload: { mfa_key: 'mfa_1-2-3' }, - }) - - expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled() - }) - - it('should not pass MFA verification if mfa is not correct', async () => { - setting = { - name: SettingName.MfaSecret, - value: 'shhhh2', - } as jest.Mocked - - settingService = {} as jest.Mocked - settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting) - - expect( - await createVerifyMFA().execute({ - email: 'test@test.te', - requestParams: { 'mfa_1-2-3': 'test' }, - preventOTPFromFurtherUsage: true, - }), - ).toEqual({ - success: false, - errorTag: 'mfa-invalid', - errorMessage: 'The two-factor authentication code you entered is incorrect. Please try again.', - errorPayload: { mfa_key: 'mfa_1-2-3' }, - }) - }) - - it('should not pass MFA verification if no mfa param is found in the request', async () => { - expect( - await createVerifyMFA().execute({ - email: 'test@test.te', - requestParams: { foo: 'bar' }, - preventOTPFromFurtherUsage: true, - }), - ).toEqual({ - success: false, - errorTag: 'mfa-required', - errorMessage: 'Please enter your two-factor authentication code.', - errorPayload: { mfa_key: expect.stringMatching(/^mfa_/) }, - }) - }) - - it('should throw an error if the error is not handled mfa validation error', async () => { - settingService.findSettingWithDecryptedValue = jest.fn().mockImplementation(() => { - throw new Error('oops!') - }) - - let error = null - try { - await createVerifyMFA().execute({ - email: 'test@test.te', - requestParams: { 'mfa_1-2-3': 'test' }, - preventOTPFromFurtherUsage: true, + expect( + await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }), + ).toEqual({ + success: true, }) - } catch (caughtError) { - error = caughtError - } - expect(error).not.toBeNull() + expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled() + }) + + it('should pass MFA verification if user has MFA deleted', async () => { + setting = { + name: SettingName.MfaSecret, + value: null, + } as jest.Mocked + + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting) + + expect( + await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }), + ).toEqual({ + success: true, + }) + + expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled() + }) + + it('should pass MFA verification if user is not found and pseudo mfa is not required', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + expect( + await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }), + ).toEqual({ + success: true, + }) + + expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled() + }) + + it('should not pass MFA verification if user is not found and pseudo mfa is required', async () => { + booleanSelector.select = jest.fn().mockReturnValue(true) + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + expect( + await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }), + ).toEqual({ + success: false, + errorTag: 'mfa-required', + errorMessage: 'Please enter your two-factor authentication code.', + errorPayload: { mfa_key: expect.stringMatching(/^mfa_/) }, + }) + }) + + it('should pass MFA verification if mfa key is correctly encrypted', async () => { + expect( + await createVerifyMFA().execute({ + email: 'test@test.te', + requestParams: { 'mfa_1-2-3': authenticator.generate('shhhh') }, + preventOTPFromFurtherUsage: true, + }), + ).toEqual({ + success: true, + }) + + expect(lockRepository.lockSuccessfullOTP).toHaveBeenCalledWith('test@test.te', expect.any(String)) + }) + + it('should pass MFA verification without locking otp', async () => { + expect( + await createVerifyMFA().execute({ + email: 'test@test.te', + requestParams: { 'mfa_1-2-3': authenticator.generate('shhhh') }, + preventOTPFromFurtherUsage: false, + }), + ).toEqual({ + success: true, + }) + + expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled() + }) + + it('should not pass MFA verification if otp is already used within lock out period', async () => { + lockRepository.isOTPLocked = jest.fn().mockReturnValue(true) + + expect( + await createVerifyMFA().execute({ + email: 'test@test.te', + requestParams: { 'mfa_1-2-3': authenticator.generate('shhhh') }, + preventOTPFromFurtherUsage: true, + }), + ).toEqual({ + success: false, + errorTag: 'mfa-invalid', + errorMessage: + 'The two-factor authentication code you entered has been already utilized. Please try again in a while.', + errorPayload: { mfa_key: 'mfa_1-2-3' }, + }) + + expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled() + }) + + it('should not pass MFA verification if mfa is not correct', async () => { + setting = { + name: SettingName.MfaSecret, + value: 'shhhh2', + } as jest.Mocked + + settingService = {} as jest.Mocked + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting) + + expect( + await createVerifyMFA().execute({ + email: 'test@test.te', + requestParams: { 'mfa_1-2-3': 'test' }, + preventOTPFromFurtherUsage: true, + }), + ).toEqual({ + success: false, + errorTag: 'mfa-invalid', + errorMessage: 'The two-factor authentication code you entered is incorrect. Please try again.', + errorPayload: { mfa_key: 'mfa_1-2-3' }, + }) + }) + + it('should not pass MFA verification if no mfa param is found in the request', async () => { + expect( + await createVerifyMFA().execute({ + email: 'test@test.te', + requestParams: { foo: 'bar' }, + preventOTPFromFurtherUsage: true, + }), + ).toEqual({ + success: false, + errorTag: 'mfa-required', + errorMessage: 'Please enter your two-factor authentication code.', + errorPayload: { mfa_key: expect.stringMatching(/^mfa_/) }, + }) + }) + + it('should throw an error if the error is not handled mfa validation error', async () => { + settingService.findSettingWithDecryptedValue = jest.fn().mockImplementation(() => { + throw new Error('oops!') + }) + + let error = null + try { + await createVerifyMFA().execute({ + email: 'test@test.te', + requestParams: { 'mfa_1-2-3': 'test' }, + preventOTPFromFurtherUsage: true, + }) + } catch (caughtError) { + error = caughtError + } + + expect(error).not.toBeNull() + }) + }) + + describe('U2F', () => { + beforeEach(() => { + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(null) + + authenticatorRepository.findByUserUuid = jest.fn().mockReturnValue([{} as jest.Mocked]) + }) + + it('should not pass if the user has an invalid uuid', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue({ uuid: 'invalid' } as jest.Mocked) + + expect( + await createVerifyMFA().execute({ + email: 'test@test.te', + requestParams: {}, + preventOTPFromFurtherUsage: true, + }), + ).toEqual({ + success: false, + errorMessage: 'User UUID is invalid.', + }) + }) + + it('should not pass if the request is missing authenticator response', async () => { + expect( + await createVerifyMFA().execute({ + email: 'test@test.te', + requestParams: {}, + preventOTPFromFurtherUsage: true, + }), + ).toEqual({ + success: false, + errorTag: 'mfa-required', + errorMessage: 'Please authenticate with your U2F device.', + }) + }) + + it('should not pass if the authenticator response verification fails', async () => { + verifyAuthenticatorAuthenticationResponse.execute = jest.fn().mockReturnValue(Result.fail('oops!')) + + expect( + await createVerifyMFA().execute({ + email: 'test@test.te', + requestParams: { + authenticator_response: { + id: Buffer.from([1]), + }, + }, + preventOTPFromFurtherUsage: true, + }), + ).toEqual({ + success: false, + errorTag: 'mfa-invalid', + errorMessage: 'Could not verify U2F device.', + }) + }) + + it('should not pass if the authenticator is not verified', async () => { + verifyAuthenticatorAuthenticationResponse.execute = jest.fn().mockReturnValue(Result.ok(false)) + + expect( + await createVerifyMFA().execute({ + email: 'test@test.te', + requestParams: { + authenticator_response: { + id: Buffer.from([1]), + }, + }, + preventOTPFromFurtherUsage: true, + }), + ).toEqual({ + success: false, + errorTag: 'mfa-invalid', + errorMessage: 'Could not verify U2F device.', + }) + }) + + it('should pass if the authenticator is verified', async () => { + verifyAuthenticatorAuthenticationResponse.execute = jest.fn().mockReturnValue(Result.ok(true)) + + expect( + await createVerifyMFA().execute({ + email: 'test@test.te', + requestParams: { + authenticator_response: { + id: Buffer.from([1]), + }, + }, + preventOTPFromFurtherUsage: true, + }), + ).toEqual({ + success: true, + }) + }) }) }) diff --git a/packages/auth/src/Domain/UseCase/VerifyMFA.ts b/packages/auth/src/Domain/UseCase/VerifyMFA.ts index 0a2a4f7c6..f9f062eb6 100644 --- a/packages/auth/src/Domain/UseCase/VerifyMFA.ts +++ b/packages/auth/src/Domain/UseCase/VerifyMFA.ts @@ -4,16 +4,21 @@ import { SettingName } from '@standardnotes/settings' import { v4 as uuidv4 } from 'uuid' import { inject, injectable } from 'inversify' import { authenticator } from 'otplib' +import { SelectorInterface } from '@standardnotes/security' +import { UseCaseInterface as DomainUseCaseInterface, Uuid } from '@standardnotes/domain-core' import TYPES from '../../Bootstrap/Types' import { MFAValidationError } from '../Error/MFAValidationError' import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { SettingServiceInterface } from '../Setting/SettingServiceInterface' +import { LockRepositoryInterface } from '../User/LockRepositoryInterface' +import { AuthenticatorRepositoryInterface } from '../Authenticator/AuthenticatorRepositoryInterface' + import { UseCaseInterface } from './UseCaseInterface' import { VerifyMFADTO } from './VerifyMFADTO' import { VerifyMFAResponse } from './VerifyMFAResponse' -import { SettingServiceInterface } from '../Setting/SettingServiceInterface' -import { SelectorInterface } from '@standardnotes/security' -import { LockRepositoryInterface } from '../User/LockRepositoryInterface' +import { Logger } from 'winston' +import { Setting } from '../Setting/Setting' @injectable() export class VerifyMFA implements UseCaseInterface { @@ -23,6 +28,10 @@ export class VerifyMFA implements UseCaseInterface { @inject(TYPES.BooleanSelector) private booleanSelector: SelectorInterface, @inject(TYPES.LockRepository) private lockRepository: LockRepositoryInterface, @inject(TYPES.PSEUDO_KEY_PARAMS_KEY) private pseudoKeyParamsKey: string, + @inject(TYPES.AuthenticatorRepository) private authenticatorRepository: AuthenticatorRepositoryInterface, + @inject(TYPES.VerifyAuthenticatorAuthenticationResponse) + private verifyAuthenticatorAuthenticationResponse: DomainUseCaseInterface, + @inject(TYPES.Logger) private logger: Logger, ) {} async execute(dto: VerifyMFADTO): Promise { @@ -48,24 +57,78 @@ export class VerifyMFA implements UseCaseInterface { } } + const userUuidOrError = Uuid.create(user.uuid) + if (userUuidOrError.isFailed()) { + return { + success: false, + errorMessage: 'User UUID is invalid.', + } + } + const userUuid = userUuidOrError.getValue() + + let u2fEnabled = false + const u2fAuthenticators = await this.authenticatorRepository.findByUserUuid(userUuid) + if (u2fAuthenticators.length > 0) { + u2fEnabled = true + } + const mfaSecret = await this.settingService.findSettingWithDecryptedValue({ userUuid: user.uuid, settingName: SettingName.MfaSecret, }) - if (mfaSecret === null || mfaSecret.value === null) { + const twoFactorEnabled = mfaSecret !== null && mfaSecret.value !== null + + if (u2fEnabled === false && twoFactorEnabled === false) { return { success: true, } } - const verificationResult = await this.verifyMFASecret( - dto.email, - mfaSecret.value, - dto.requestParams, - dto.preventOTPFromFurtherUsage, - ) + if (u2fEnabled) { + if (!dto.requestParams.authenticator_response) { + return { + success: false, + errorTag: ErrorTag.MfaRequired, + errorMessage: 'Please authenticate with your U2F device.', + } + } - return verificationResult + const verificationResultOrError = await this.verifyAuthenticatorAuthenticationResponse.execute({ + userUuid: userUuid.value, + authenticatorResponse: dto.requestParams.authenticator_response, + }) + if (verificationResultOrError.isFailed()) { + this.logger.debug(`Could not verify U2F authentication: ${verificationResultOrError.getError()}`) + + return { + success: false, + errorTag: ErrorTag.MfaInvalid, + errorMessage: 'Could not verify U2F device.', + } + } + + const verificationResult = verificationResultOrError.getValue() + if (verificationResult === false) { + return { + success: false, + errorTag: ErrorTag.MfaInvalid, + errorMessage: 'Could not verify U2F device.', + } + } + + return { + success: true, + } + } else { + const verificationResult = await this.verifyMFASecret( + dto.email, + (mfaSecret as Setting).value as string, + dto.requestParams, + dto.preventOTPFromFurtherUsage, + ) + + return verificationResult + } } catch (error) { if (error instanceof MFAValidationError) { return {