diff --git a/packages/auth/src/Bootstrap/Container.ts b/packages/auth/src/Bootstrap/Container.ts index ecbad87b8..b0f82b645 100644 --- a/packages/auth/src/Bootstrap/Container.ts +++ b/packages/auth/src/Bootstrap/Container.ts @@ -222,6 +222,7 @@ import { ListAuthenticators } from '../Domain/UseCase/ListAuthenticators/ListAut import { AuthenticatorHttpProjection } from '../Infra/Http/Projection/AuthenticatorHttpProjection' import { AuthenticatorHttpMapper } from '../Mapping/AuthenticatorHttpMapper' import { DeleteAuthenticator } from '../Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator' +import { GenerateRecoveryCodes } from '../Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodes' // eslint-disable-next-line @typescript-eslint/no-var-requires const newrelicFormatter = require('@newrelic/winston-enricher') @@ -596,6 +597,15 @@ export class ContainerConfigLoader { container .bind(TYPES.DeleteAuthenticator) .toConstantValue(new DeleteAuthenticator(container.get(TYPES.AuthenticatorRepository))) + container + .bind(TYPES.GenerateRecoveryCodes) + .toConstantValue( + new GenerateRecoveryCodes( + container.get(TYPES.UserRepository), + container.get(TYPES.SettingService), + container.get(TYPES.CryptoNode), + ), + ) container .bind(TYPES.CleanupSessionTraces) diff --git a/packages/auth/src/Bootstrap/Types.ts b/packages/auth/src/Bootstrap/Types.ts index eed911728..5dd8e7c43 100644 --- a/packages/auth/src/Bootstrap/Types.ts +++ b/packages/auth/src/Bootstrap/Types.ts @@ -141,6 +141,7 @@ const TYPES = { VerifyAuthenticatorAuthenticationResponse: Symbol.for('VerifyAuthenticatorAuthenticationResponse'), ListAuthenticators: Symbol.for('ListAuthenticators'), DeleteAuthenticator: Symbol.for('DeleteAuthenticator'), + GenerateRecoveryCodes: Symbol.for('GenerateRecoveryCodes'), // Handlers UserRegisteredEventHandler: Symbol.for('UserRegisteredEventHandler'), AccountDeletionRequestedEventHandler: Symbol.for('AccountDeletionRequestedEventHandler'), diff --git a/packages/auth/src/Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodes.spec.ts b/packages/auth/src/Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodes.spec.ts new file mode 100644 index 000000000..ed0eafd97 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodes.spec.ts @@ -0,0 +1,65 @@ +import { CryptoNode } from '@standardnotes/sncrypto-node' +import { SettingServiceInterface } from '../../Setting/SettingServiceInterface' +import { User } from '../../User/User' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' +import { GenerateRecoveryCodes } from './GenerateRecoveryCodes' + +describe('GenerateRecoveryCodes', () => { + let userRepository: UserRepositoryInterface + let settingService: SettingServiceInterface + let cryptoNode: CryptoNode + + const createUseCase = () => new GenerateRecoveryCodes(userRepository, settingService, cryptoNode) + + beforeEach(() => { + userRepository = {} as jest.Mocked + userRepository.findOneByUuid = jest.fn().mockReturnValue({} as jest.Mocked) + + settingService = {} as jest.Mocked + settingService.createOrReplace = jest.fn() + + cryptoNode = {} as jest.Mocked + cryptoNode.generateRandomKey = jest.fn().mockReturnValue('randomKey123') + }) + + it('should generate recovery codes', async () => { + const useCase = createUseCase() + + const result = await useCase.execute({ userUuid: '2221101c-1da9-4d2b-9b32-b8be2a8d1c82' }) + + expect(settingService.createOrReplace).toHaveBeenCalled() + expect(result.isFailed()).toBeFalsy() + expect(result.getValue()).toEqual('RAND OMKE Y123') + }) + + it('should return error if empty random string', async () => { + cryptoNode.generateRandomKey = jest.fn().mockReturnValue('') + + const useCase = createUseCase() + + const result = await useCase.execute({ userUuid: '2221101c-1da9-4d2b-9b32-b8be2a8d1c82' }) + + expect(result.isFailed()).toBeTruthy() + expect(result.getError()).toEqual('Could not generate recovery codes: random key is invalid') + }) + + it('should return error if user not found', async () => { + userRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + const useCase = createUseCase() + + const result = await useCase.execute({ userUuid: '2221101c-1da9-4d2b-9b32-b8be2a8d1c82' }) + + expect(result.isFailed()).toBeTruthy() + expect(result.getError()).toEqual('Could not generate recovery codes: user not found') + }) + + it('should return error if user uuid is invalid', async () => { + const useCase = createUseCase() + + const result = await useCase.execute({ userUuid: 'invalid' }) + + expect(result.isFailed()).toBeTruthy() + expect(result.getError()).toEqual('Could not generate recovery codes: Given value is not a valid uuid: invalid') + }) +}) diff --git a/packages/auth/src/Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodes.ts b/packages/auth/src/Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodes.ts new file mode 100644 index 000000000..a3f2dd50b --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodes.ts @@ -0,0 +1,47 @@ +import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core' +import { SettingName } from '@standardnotes/settings' +import { CryptoNode } from '@standardnotes/sncrypto-node' +import { EncryptionVersion } from '../../Encryption/EncryptionVersion' +import { SettingServiceInterface } from '../../Setting/SettingServiceInterface' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' +import { GenerateRecoveryCodesDTO } from './GenerateRecoveryCodesDTO' + +export class GenerateRecoveryCodes implements UseCaseInterface { + constructor( + private userRepository: UserRepositoryInterface, + private settingService: SettingServiceInterface, + private cryptoNode: CryptoNode, + ) {} + async execute(dto: GenerateRecoveryCodesDTO): Promise> { + const userUuidOrError = Uuid.create(dto.userUuid) + if (userUuidOrError.isFailed()) { + return Result.fail(`Could not generate recovery codes: ${userUuidOrError.getError()}`) + } + const userUuid = userUuidOrError.getValue() + + const user = await this.userRepository.findOneByUuid(userUuid.value) + if (user === null) { + return Result.fail('Could not generate recovery codes: user not found') + } + + const randomKey = await this.cryptoNode.generateRandomKey(160) + const recoveryCodesSplit = randomKey.toUpperCase().match(/.{1,4}/g) + if (!recoveryCodesSplit) { + return Result.fail('Could not generate recovery codes: random key is invalid') + } + + const recoveryCodes = recoveryCodesSplit.join(' ') + + await this.settingService.createOrReplace({ + user, + props: { + name: SettingName.RecoveryCodes, + unencryptedValue: recoveryCodes, + serverEncryptionVersion: EncryptionVersion.Default, + sensitive: true, + }, + }) + + return Result.ok(recoveryCodes) + } +} diff --git a/packages/auth/src/Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodesDTO.ts b/packages/auth/src/Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodesDTO.ts new file mode 100644 index 000000000..1fb343aee --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodesDTO.ts @@ -0,0 +1,3 @@ +export interface GenerateRecoveryCodesDTO { + userUuid: string +} diff --git a/packages/settings/src/Domain/Setting/SettingName.ts b/packages/settings/src/Domain/Setting/SettingName.ts index f9451eb94..d53616df5 100644 --- a/packages/settings/src/Domain/Setting/SettingName.ts +++ b/packages/settings/src/Domain/Setting/SettingName.ts @@ -14,4 +14,5 @@ export enum SettingName { MuteMarketingEmails = 'MUTE_MARKETING_EMAILS', ListedAuthorSecrets = 'LISTED_AUTHOR_SECRETS', LogSessionUserAgent = 'LOG_SESSION_USER_AGENT', + RecoveryCodes = 'RECOVERY_CODES', }