Jelajahi Sumber

feat(auth): add recovery sign in with recovery codes

Karol Sójko 2 tahun lalu
induk
melakukan
cac899a7e5
22 mengubah file dengan 798 tambahan dan 6 penghapusan
  1. 15 0
      packages/api-gateway/src/Controller/v1/ActionsController.ts
  2. 1 1
      packages/auth/jest.config.js
  3. 26 0
      packages/auth/src/Bootstrap/Container.ts
  4. 2 0
      packages/auth/src/Bootstrap/Types.ts
  5. 16 2
      packages/auth/src/Controller/AuthController.spec.ts
  6. 115 3
      packages/auth/src/Controller/AuthController.ts
  7. 116 0
      packages/auth/src/Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParams.spec.ts
  8. 64 0
      packages/auth/src/Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecovery.ts
  9. 5 0
      packages/auth/src/Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecoveryDTO.ts
  10. 218 0
      packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.spec.ts
  11. 123 0
      packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.ts
  12. 7 0
      packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodesDTO.ts
  13. 3 0
      packages/auth/src/Infra/Http/Request/GenerateRecoveryCodesRequestParams.ts
  14. 6 0
      packages/auth/src/Infra/Http/Request/RecoveryKeyParamsRequestParams.ts
  15. 8 0
      packages/auth/src/Infra/Http/Request/SignInWithRecoveryCodesRequestParams.ts
  16. 8 0
      packages/auth/src/Infra/Http/Response/GenerateRecoveryCodesResponse.ts
  17. 3 0
      packages/auth/src/Infra/Http/Response/GenerateRecoveryCodesResponseBody.ts
  18. 8 0
      packages/auth/src/Infra/Http/Response/RecoveryKeyParamsResponse.ts
  19. 5 0
      packages/auth/src/Infra/Http/Response/RecoveryKeyParamsResponseBody.ts
  20. 8 0
      packages/auth/src/Infra/Http/Response/SignInWithRecoveryCodesResponse.ts
  21. 6 0
      packages/auth/src/Infra/Http/Response/SignInWithRecoveryCodesResponseBody.ts
  22. 35 0
      packages/auth/src/Infra/InversifyExpressUtils/InversifyExpressAuthController.ts

+ 15 - 0
packages/api-gateway/src/Controller/v1/ActionsController.ts

@@ -39,4 +39,19 @@ export class ActionsController extends BaseHttpController {
       request.body,
     )
   }
+
+  @httpPost('/recovery/codes', TYPES.AuthMiddleware)
+  async recoveryCodes(request: Request, response: Response): Promise<void> {
+    await this.httpService.callAuthServer(request, response, 'auth/recovery/codes', request.body)
+  }
+
+  @httpPost('/recovery/login')
+  async recoveryLogin(request: Request, response: Response): Promise<void> {
+    await this.httpService.callAuthServer(request, response, 'auth/recovery/login', request.body)
+  }
+
+  @httpPost('/recovery/login-params')
+  async recoveryParams(request: Request, response: Response): Promise<void> {
+    await this.httpService.callAuthServer(request, response, 'auth/recovery/params', request.body)
+  }
 }

+ 1 - 1
packages/auth/jest.config.js

@@ -7,6 +7,6 @@ module.exports = {
   transform: {
     ...tsjPreset.transform,
   },
-  coveragePathIgnorePatterns: ['/Bootstrap/', '/Infra/', '/Projection/', '/Domain/Email/', '/Mapping/'],
+  coveragePathIgnorePatterns: ['/Bootstrap/', '/Infra/', '/Controller/', '/Projection/', '/Domain/Email/', '/Mapping/'],
   setupFilesAfterEnv: ['./test-setup.ts'],
 }

+ 26 - 0
packages/auth/src/Bootstrap/Container.ts

@@ -223,6 +223,8 @@ import { AuthenticatorHttpProjection } from '../Infra/Http/Projection/Authentica
 import { AuthenticatorHttpMapper } from '../Mapping/AuthenticatorHttpMapper'
 import { DeleteAuthenticator } from '../Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator'
 import { GenerateRecoveryCodes } from '../Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodes'
+import { SignInWithRecoveryCodes } from '../Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes'
+import { GetUserKeyParamsRecovery } from '../Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecovery'
 
 // eslint-disable-next-line @typescript-eslint/no-var-requires
 const newrelicFormatter = require('@newrelic/winston-enricher')
@@ -617,6 +619,30 @@ export class ContainerConfigLoader {
     container.bind<VerifyMFA>(TYPES.VerifyMFA).to(VerifyMFA)
     container.bind<ClearLoginAttempts>(TYPES.ClearLoginAttempts).to(ClearLoginAttempts)
     container.bind<IncreaseLoginAttempts>(TYPES.IncreaseLoginAttempts).to(IncreaseLoginAttempts)
+    container
+      .bind<SignInWithRecoveryCodes>(TYPES.SignInWithRecoveryCodes)
+      .toConstantValue(
+        new SignInWithRecoveryCodes(
+          container.get(TYPES.UserRepository),
+          container.get(TYPES.AuthResponseFactory20200115),
+          container.get(TYPES.PKCERepository),
+          container.get(TYPES.Crypter),
+          container.get(TYPES.SettingService),
+          container.get(TYPES.GenerateRecoveryCodes),
+          container.get(TYPES.IncreaseLoginAttempts),
+          container.get(TYPES.ClearLoginAttempts),
+        ),
+      )
+    container
+      .bind<GetUserKeyParamsRecovery>(TYPES.GetUserKeyParamsRecovery)
+      .toConstantValue(
+        new GetUserKeyParamsRecovery(
+          container.get(TYPES.KeyParamsFactory),
+          container.get(TYPES.UserRepository),
+          container.get(TYPES.PKCERepository),
+          container.get(TYPES.SettingService),
+        ),
+      )
     container.bind<GetUserKeyParams>(TYPES.GetUserKeyParams).to(GetUserKeyParams)
     container.bind<UpdateUser>(TYPES.UpdateUser).to(UpdateUser)
     container.bind<Register>(TYPES.Register).to(Register)

+ 2 - 0
packages/auth/src/Bootstrap/Types.ts

@@ -142,6 +142,8 @@ const TYPES = {
   ListAuthenticators: Symbol.for('ListAuthenticators'),
   DeleteAuthenticator: Symbol.for('DeleteAuthenticator'),
   GenerateRecoveryCodes: Symbol.for('GenerateRecoveryCodes'),
+  SignInWithRecoveryCodes: Symbol.for('SignInWithRecoveryCodes'),
+  GetUserKeyParamsRecovery: Symbol.for('GetUserKeyParamsRecovery'),
   // Handlers
   UserRegisteredEventHandler: Symbol.for('UserRegisteredEventHandler'),
   AccountDeletionRequestedEventHandler: Symbol.for('AccountDeletionRequestedEventHandler'),

+ 16 - 2
packages/auth/src/Controller/AuthController.spec.ts

@@ -9,6 +9,9 @@ import { Register } from '../Domain/UseCase/Register'
 import { DomainEventFactoryInterface } from '../Domain/Event/DomainEventFactoryInterface'
 import { KeyParamsOrigination, ProtocolVersion } from '@standardnotes/common'
 import { ApiVersion } from '@standardnotes/api'
+import { SignInWithRecoveryCodes } from '../Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes'
+import { GetUserKeyParamsRecovery } from '../Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecovery'
+import { GenerateRecoveryCodes } from '../Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodes'
 
 describe('AuthController', () => {
   let clearLoginAttempts: ClearLoginAttempts
@@ -17,9 +20,20 @@ describe('AuthController', () => {
   let domainEventFactory: DomainEventFactoryInterface
   let event: DomainEventInterface
   let user: User
+  let doSignInWithRecoveryCodes: SignInWithRecoveryCodes
+  let getUserKeyParamsRecovery: GetUserKeyParamsRecovery
+  let doGenerateRecoveryCodes: GenerateRecoveryCodes
 
   const createController = () =>
-    new AuthController(clearLoginAttempts, register, domainEventPublisher, domainEventFactory)
+    new AuthController(
+      clearLoginAttempts,
+      register,
+      domainEventPublisher,
+      domainEventFactory,
+      doSignInWithRecoveryCodes,
+      getUserKeyParamsRecovery,
+      doGenerateRecoveryCodes,
+    )
 
   beforeEach(() => {
     register = {} as jest.Mocked<Register>
@@ -113,7 +127,7 @@ describe('AuthController', () => {
   it('should throw error on the delete user method as it is still a part of the payments server', async () => {
     let caughtError = null
     try {
-      await createController().deleteAccount({ userUuid: '1-2-3' })
+      await createController().deleteAccount({} as never)
     } catch (error) {
       caughtError = error
     }

+ 115 - 3
packages/auth/src/Controller/AuthController.ts

@@ -1,19 +1,28 @@
 import { inject, injectable } from 'inversify'
 import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
 import {
+  ApiVersion,
   HttpStatusCode,
   UserDeletionResponse,
   UserRegistrationRequestParams,
   UserRegistrationResponse,
   UserServerInterface,
 } from '@standardnotes/api'
+import { ProtocolVersion } from '@standardnotes/common'
 
 import TYPES from '../Bootstrap/Types'
 import { ClearLoginAttempts } from '../Domain/UseCase/ClearLoginAttempts'
 import { Register } from '../Domain/UseCase/Register'
 import { DomainEventFactoryInterface } from '../Domain/Event/DomainEventFactoryInterface'
-import { ProtocolVersion } from '@standardnotes/common'
-import { UserDeletionRequestParams } from '@standardnotes/api/dist/Domain/Request/User/UserDeletionRequestParams'
+import { SignInWithRecoveryCodes } from '../Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes'
+import { SignInWithRecoveryCodesRequestParams } from '../Infra/Http/Request/SignInWithRecoveryCodesRequestParams'
+import { SignInWithRecoveryCodesResponse } from '../Infra/Http/Response/SignInWithRecoveryCodesResponse'
+import { GetUserKeyParamsRecovery } from '../Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecovery'
+import { RecoveryKeyParamsRequestParams } from '../Infra/Http/Request/RecoveryKeyParamsRequestParams'
+import { RecoveryKeyParamsResponse } from '../Infra/Http/Response/RecoveryKeyParamsResponse'
+import { GenerateRecoveryCodes } from '../Domain/UseCase/GenerateRecoveryCodes/GenerateRecoveryCodes'
+import { GenerateRecoveryCodesRequestParams } from '../Infra/Http/Request/GenerateRecoveryCodesRequestParams'
+import { GenerateRecoveryCodesResponse } from '../Infra/Http/Response/GenerateRecoveryCodesResponse'
 
 @injectable()
 export class AuthController implements UserServerInterface {
@@ -22,9 +31,12 @@ export class AuthController implements UserServerInterface {
     @inject(TYPES.Register) private registerUser: Register,
     @inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface,
     @inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface,
+    @inject(TYPES.SignInWithRecoveryCodes) private doSignInWithRecoveryCodes: SignInWithRecoveryCodes,
+    @inject(TYPES.GetUserKeyParamsRecovery) private getUserKeyParamsRecovery: GetUserKeyParamsRecovery,
+    @inject(TYPES.GenerateRecoveryCodes) private doGenerateRecoveryCodes: GenerateRecoveryCodes,
   ) {}
 
-  async deleteAccount(_params: UserDeletionRequestParams): Promise<UserDeletionResponse> {
+  async deleteAccount(_params: never): Promise<UserDeletionResponse> {
     throw new Error('This method is implemented on the payments server.')
   }
 
@@ -78,4 +90,104 @@ export class AuthController implements UserServerInterface {
       data: registerResult.authResponse,
     }
   }
+
+  async generateRecoveryCodes(params: GenerateRecoveryCodesRequestParams): Promise<GenerateRecoveryCodesResponse> {
+    const result = await this.doGenerateRecoveryCodes.execute({
+      userUuid: params.userUuid,
+    })
+
+    if (result.isFailed()) {
+      return {
+        status: HttpStatusCode.BadRequest,
+        data: {
+          error: {
+            message: 'Could not generate recovery codes.',
+          },
+        },
+      }
+    }
+
+    return {
+      status: HttpStatusCode.Success,
+      data: {
+        recoveryCodes: result.getValue(),
+      },
+    }
+  }
+
+  async signInWithRecoveryCodes(
+    params: SignInWithRecoveryCodesRequestParams,
+  ): Promise<SignInWithRecoveryCodesResponse> {
+    if (params.apiVersion !== ApiVersion.v0) {
+      return {
+        status: HttpStatusCode.BadRequest,
+        data: {
+          error: {
+            message: 'Invalid API version.',
+          },
+        },
+      }
+    }
+
+    const result = await this.doSignInWithRecoveryCodes.execute({
+      userAgent: params.userAgent,
+      username: params.username,
+      password: params.password,
+      codeVerifier: params.codeVerifier,
+      recoveryCodes: params.recoveryCodes,
+    })
+
+    if (result.isFailed()) {
+      return {
+        status: HttpStatusCode.Unauthorized,
+        data: {
+          error: {
+            message: 'Invalid login credentials.',
+          },
+        },
+      }
+    }
+
+    return {
+      status: HttpStatusCode.Success,
+      data: result.getValue(),
+    }
+  }
+
+  async recoveryKeyParams(params: RecoveryKeyParamsRequestParams): Promise<RecoveryKeyParamsResponse> {
+    if (params.apiVersion !== ApiVersion.v0) {
+      return {
+        status: HttpStatusCode.BadRequest,
+        data: {
+          error: {
+            message: 'Invalid API version.',
+          },
+        },
+      }
+    }
+
+    const result = await this.getUserKeyParamsRecovery.execute({
+      username: params.username,
+      codeChallenge: params.codeChallenge,
+      recoveryCodes: params.recoveryCodes,
+    })
+
+    if (result.isFailed()) {
+      return {
+        status: HttpStatusCode.Unauthorized,
+        data: {
+          error: {
+            message: 'Invalid login credentials.',
+          },
+        },
+      }
+    }
+
+    return {
+      status: HttpStatusCode.Success,
+      data: {
+        keyParams: result.getValue(),
+      },
+    }
+  }
 }

+ 116 - 0
packages/auth/src/Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParams.spec.ts

@@ -0,0 +1,116 @@
+import { Setting } from '../../Setting/Setting'
+import { SettingServiceInterface } from '../../Setting/SettingServiceInterface'
+import { KeyParamsFactoryInterface } from '../../User/KeyParamsFactoryInterface'
+import { PKCERepositoryInterface } from '../../User/PKCERepositoryInterface'
+import { User } from '../../User/User'
+import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
+import { GetUserKeyParamsRecovery } from './GetUserKeyParamsRecovery'
+
+describe('GetUserKeyParamsRecovery', () => {
+  let keyParamsFactory: KeyParamsFactoryInterface
+  let userRepository: UserRepositoryInterface
+  let settingService: SettingServiceInterface
+  let user: User
+  let pkceRepository: PKCERepositoryInterface
+
+  const createUseCase = () =>
+    new GetUserKeyParamsRecovery(keyParamsFactory, userRepository, pkceRepository, settingService)
+
+  beforeEach(() => {
+    keyParamsFactory = {} as jest.Mocked<KeyParamsFactoryInterface>
+    keyParamsFactory.create = jest.fn().mockReturnValue({ foo: 'bar' })
+    keyParamsFactory.createPseudoParams = jest.fn().mockReturnValue({ bar: 'baz' })
+
+    user = {} as jest.Mocked<User>
+
+    userRepository = {} as jest.Mocked<UserRepositoryInterface>
+    userRepository.findOneByEmail = jest.fn().mockReturnValue(user)
+
+    settingService = {} as jest.Mocked<SettingServiceInterface>
+    settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue({ value: 'foo' } as Setting)
+
+    pkceRepository = {} as jest.Mocked<PKCERepositoryInterface>
+    pkceRepository.storeCodeChallenge = jest.fn()
+  })
+
+  it('should return error if code challenge is not provided', async () => {
+    const result = await createUseCase().execute({
+      username: 'username',
+      codeChallenge: '',
+      recoveryCodes: '1234 5678',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Invalid code challenge')
+  })
+
+  it('should return error if username is not provided', async () => {
+    const result = await createUseCase().execute({
+      username: '',
+      codeChallenge: 'code-challenge',
+      recoveryCodes: '1234 5678',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Could not sign in with recovery codes: Username cannot be empty')
+  })
+
+  it('should return error if recovery codes are not provided', async () => {
+    const result = await createUseCase().execute({
+      username: 'username',
+      codeChallenge: 'codeChallenge',
+      recoveryCodes: '',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Invalid recovery codes')
+  })
+
+  it('should return pseudo params if user does not exist', async () => {
+    userRepository.findOneByEmail = jest.fn().mockReturnValue(null)
+
+    const result = await createUseCase().execute({
+      username: 'username',
+      codeChallenge: 'codeChallenge',
+      recoveryCodes: '1234 5678',
+    })
+
+    expect(keyParamsFactory.createPseudoParams).toHaveBeenCalled()
+    expect(result.isFailed()).toBe(false)
+  })
+
+  it('should return error if user has no recovery codes generated', async () => {
+    settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(null)
+
+    const result = await createUseCase().execute({
+      username: 'username',
+      codeChallenge: 'codeChallenge',
+      recoveryCodes: '1234 5678',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('User does not have recovery codes generated')
+  })
+
+  it('should return error if recovery codes do not match', async () => {
+    const result = await createUseCase().execute({
+      username: 'username',
+      codeChallenge: 'codeChallenge',
+      recoveryCodes: '1234 5678',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Invalid recovery codes')
+  })
+
+  it('should return key params if recovery codes match', async () => {
+    const result = await createUseCase().execute({
+      username: 'username',
+      codeChallenge: 'codeChallenge',
+      recoveryCodes: 'foo',
+    })
+
+    expect(keyParamsFactory.create).toHaveBeenCalled()
+    expect(result.isFailed()).toBe(false)
+  })
+})

+ 64 - 0
packages/auth/src/Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecovery.ts

@@ -0,0 +1,64 @@
+import { KeyParamsData } from '@standardnotes/responses'
+import { Result, UseCaseInterface, Username, Validator } from '@standardnotes/domain-core'
+import { SettingName } from '@standardnotes/settings'
+
+import { KeyParamsFactoryInterface } from '../../User/KeyParamsFactoryInterface'
+import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
+import { GetUserKeyParamsRecoveryDTO } from './GetUserKeyParamsRecoveryDTO'
+import { User } from '../../User/User'
+import { PKCERepositoryInterface } from '../../User/PKCERepositoryInterface'
+import { SettingServiceInterface } from '../../Setting/SettingServiceInterface'
+
+export class GetUserKeyParamsRecovery implements UseCaseInterface<KeyParamsData> {
+  constructor(
+    private keyParamsFactory: KeyParamsFactoryInterface,
+    private userRepository: UserRepositoryInterface,
+    private pkceRepository: PKCERepositoryInterface,
+    private settingService: SettingServiceInterface,
+  ) {}
+
+  async execute(dto: GetUserKeyParamsRecoveryDTO): Promise<Result<KeyParamsData>> {
+    const usernameOrError = Username.create(dto.username)
+    if (usernameOrError.isFailed()) {
+      return Result.fail(`Could not sign in with recovery codes: ${usernameOrError.getError()}`)
+    }
+    const username = usernameOrError.getValue()
+
+    const recoveryCodesValidationResult = Validator.isNotEmpty(dto.recoveryCodes)
+    if (recoveryCodesValidationResult.isFailed()) {
+      return Result.fail('Invalid recovery codes')
+    }
+
+    const codeChallengeValidationResult = Validator.isNotEmpty(dto.codeChallenge)
+    if (codeChallengeValidationResult.isFailed()) {
+      return Result.fail('Invalid code challenge')
+    }
+
+    const user = await this.userRepository.findOneByEmail(username.value)
+    if (!user) {
+      return Result.ok(this.keyParamsFactory.createPseudoParams(username.value))
+    }
+
+    const recoveryCodesSetting = await this.settingService.findSettingWithDecryptedValue({
+      settingName: SettingName.RecoveryCodes,
+      userUuid: user.uuid,
+    })
+    if (!recoveryCodesSetting) {
+      return Result.fail('User does not have recovery codes generated')
+    }
+
+    if (recoveryCodesSetting.value !== dto.recoveryCodes) {
+      return Result.fail('Invalid recovery codes')
+    }
+
+    const keyParams = await this.createKeyParams(dto.codeChallenge, user)
+
+    return Result.ok(keyParams)
+  }
+
+  private async createKeyParams(codeChallenge: string, user: User): Promise<KeyParamsData> {
+    await this.pkceRepository.storeCodeChallenge(codeChallenge)
+
+    return this.keyParamsFactory.create(user, false)
+  }
+}

+ 5 - 0
packages/auth/src/Domain/UseCase/GetUserKeyParamsRecovery/GetUserKeyParamsRecoveryDTO.ts

@@ -0,0 +1,5 @@
+export interface GetUserKeyParamsRecoveryDTO {
+  codeChallenge: string
+  username: string
+  recoveryCodes: string
+}

+ 218 - 0
packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.spec.ts

@@ -0,0 +1,218 @@
+import { Result } from '@standardnotes/domain-core'
+
+import { AuthResponse20200115 } from '../../Auth/AuthResponse20200115'
+import { AuthResponseFactory20200115 } from '../../Auth/AuthResponseFactory20200115'
+import { CrypterInterface } from '../../Encryption/CrypterInterface'
+import { Setting } from '../../Setting/Setting'
+import { SettingServiceInterface } from '../../Setting/SettingServiceInterface'
+import { PKCERepositoryInterface } from '../../User/PKCERepositoryInterface'
+import { User } from '../../User/User'
+import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
+import { ClearLoginAttempts } from '../ClearLoginAttempts'
+import { GenerateRecoveryCodes } from '../GenerateRecoveryCodes/GenerateRecoveryCodes'
+import { IncreaseLoginAttempts } from '../IncreaseLoginAttempts'
+import { SignInWithRecoveryCodes } from './SignInWithRecoveryCodes'
+
+describe('SignInWithRecoveryCodes', () => {
+  let userRepository: UserRepositoryInterface
+  let authResponseFactory: AuthResponseFactory20200115
+  let pkceRepository: PKCERepositoryInterface
+  let crypter: CrypterInterface
+  let settingService: SettingServiceInterface
+  let generateRecoveryCodes: GenerateRecoveryCodes
+  let increaseLoginAttempts: IncreaseLoginAttempts
+  let clearLoginAttempts: ClearLoginAttempts
+
+  const createUseCase = () =>
+    new SignInWithRecoveryCodes(
+      userRepository,
+      authResponseFactory,
+      pkceRepository,
+      crypter,
+      settingService,
+      generateRecoveryCodes,
+      increaseLoginAttempts,
+      clearLoginAttempts,
+    )
+
+  beforeEach(() => {
+    userRepository = {} as jest.Mocked<UserRepositoryInterface>
+    userRepository.findOneByEmail = jest.fn().mockReturnValue({
+      uuid: '1-2-3',
+      encryptedPassword: '$2a$11$K3g6XoTau8VmLJcai1bB0eD9/YvBSBRtBhMprJOaVZ0U3SgasZH3a',
+    } as jest.Mocked<User>)
+
+    authResponseFactory = {} as jest.Mocked<AuthResponseFactory20200115>
+    authResponseFactory.createResponse = jest.fn().mockReturnValue({} as jest.Mocked<AuthResponse20200115>)
+
+    pkceRepository = {} as jest.Mocked<PKCERepositoryInterface>
+    pkceRepository.removeCodeChallenge = jest.fn().mockReturnValue(true)
+
+    crypter = {} as jest.Mocked<CrypterInterface>
+    crypter.base64URLEncode = jest.fn().mockReturnValue('base64-url-encoded')
+    crypter.sha256Hash = jest.fn().mockReturnValue('sha256-hashed')
+
+    settingService = {} as jest.Mocked<SettingServiceInterface>
+    settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue({ value: 'foo' } as Setting)
+
+    generateRecoveryCodes = {} as jest.Mocked<GenerateRecoveryCodes>
+    generateRecoveryCodes.execute = jest.fn().mockReturnValue(Result.ok('1234 5678'))
+
+    increaseLoginAttempts = {} as jest.Mocked<IncreaseLoginAttempts>
+    increaseLoginAttempts.execute = jest.fn()
+
+    clearLoginAttempts = {} as jest.Mocked<ClearLoginAttempts>
+    clearLoginAttempts.execute = jest.fn()
+  })
+
+  it('should return error if password is not provided', async () => {
+    const result = await createUseCase().execute({
+      userAgent: 'user-agent',
+      username: 'test@test.te',
+      password: '',
+      codeVerifier: 'code-verifier',
+      recoveryCodes: '1234 5678',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Invalid email or password')
+  })
+
+  it('should return error if username is not provided', async () => {
+    const result = await createUseCase().execute({
+      userAgent: 'user-agent',
+      username: '',
+      password: 'qweqwe123123',
+      codeVerifier: 'code-verifier',
+      recoveryCodes: '1234 5678',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Could not sign in with recovery codes: Username cannot be empty')
+  })
+
+  it('should return error if code verifier is not provided', async () => {
+    const result = await createUseCase().execute({
+      userAgent: 'user-agent',
+      username: 'username',
+      password: 'qweqwe123123',
+      codeVerifier: '',
+      recoveryCodes: '1234 5678',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Invalid email or password')
+  })
+
+  it('should return error if recovery codes are not provided', async () => {
+    const result = await createUseCase().execute({
+      userAgent: 'user-agent',
+      username: 'username',
+      password: 'qweqwe123123',
+      codeVerifier: 'code-verifier',
+      recoveryCodes: '',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Invalid recovery codes')
+  })
+
+  it('should return error if code verifier is invalid', async () => {
+    pkceRepository.removeCodeChallenge = jest.fn().mockReturnValue(false)
+
+    const result = await createUseCase().execute({
+      userAgent: 'user-agent',
+      username: 'test@test.te',
+      password: 'qweqwe123123',
+      codeVerifier: 'code-verifier',
+      recoveryCodes: '1234 5678',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Invalid email or password')
+  })
+
+  it('should return error if user is not found', async () => {
+    userRepository.findOneByEmail = jest.fn().mockReturnValue(undefined)
+
+    const result = await createUseCase().execute({
+      userAgent: 'user-agent',
+      username: 'test@test.te',
+      password: 'qweqwe123123',
+      codeVerifier: 'code-verifier',
+      recoveryCodes: '1234 5678',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Invalid email or password')
+  })
+
+  it('should return error if recovery codes are invalid', async () => {
+    const result = await createUseCase().execute({
+      userAgent: 'user-agent',
+      username: 'test@test.te',
+      password: 'qweqwe123123',
+      codeVerifier: 'code-verifier',
+      recoveryCodes: '1234 5678',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Invalid recovery codes')
+  })
+
+  it('should return error if password does not match', async () => {
+    const result = await createUseCase().execute({
+      userAgent: 'user-agent',
+      username: 'test@test.te',
+      password: 'asdasd123123',
+      codeVerifier: 'code-verifier',
+      recoveryCodes: '1234 5678',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Invalid email or password')
+  })
+
+  it('should return error if recovery codes are not generated for user', async () => {
+    settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(null)
+
+    const result = await createUseCase().execute({
+      userAgent: 'user-agent',
+      username: 'test@test.te',
+      password: 'qweqwe123123',
+      codeVerifier: 'code-verifier',
+      recoveryCodes: '1234 5678',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('User does not have recovery codes generated')
+  })
+
+  it('should return error if generating new recovery codes fails', async () => {
+    generateRecoveryCodes.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
+
+    const result = await createUseCase().execute({
+      userAgent: 'user-agent',
+      username: 'test@test.te',
+      password: 'qweqwe123123',
+      codeVerifier: 'code-verifier',
+      recoveryCodes: 'foo',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Could not sign in with recovery codes: Oops')
+  })
+
+  it('should return auth response', async () => {
+    const result = await createUseCase().execute({
+      userAgent: 'user-agent',
+      username: 'test@test.te',
+      password: 'qweqwe123123',
+      codeVerifier: 'code-verifier',
+      recoveryCodes: 'foo',
+    })
+
+    expect(clearLoginAttempts.execute).toHaveBeenCalled()
+    expect(result.isFailed()).toBe(false)
+  })
+})

+ 123 - 0
packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.ts

@@ -0,0 +1,123 @@
+import * as bcrypt from 'bcryptjs'
+import { Result, UseCaseInterface, Username, Validator } from '@standardnotes/domain-core'
+import { SettingName } from '@standardnotes/settings'
+import { ApiVersion } from '@standardnotes/api'
+
+import { AuthResponse20200115 } from '../../Auth/AuthResponse20200115'
+import { SettingServiceInterface } from '../../Setting/SettingServiceInterface'
+import { CrypterInterface } from '../../Encryption/CrypterInterface'
+import { PKCERepositoryInterface } from '../../User/PKCERepositoryInterface'
+import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
+import { GenerateRecoveryCodes } from '../GenerateRecoveryCodes/GenerateRecoveryCodes'
+
+import { SignInWithRecoveryCodesDTO } from './SignInWithRecoveryCodesDTO'
+import { AuthResponseFactory20200115 } from '../../Auth/AuthResponseFactory20200115'
+import { IncreaseLoginAttempts } from '../IncreaseLoginAttempts'
+import { ClearLoginAttempts } from '../ClearLoginAttempts'
+
+export class SignInWithRecoveryCodes implements UseCaseInterface<AuthResponse20200115> {
+  constructor(
+    private userRepository: UserRepositoryInterface,
+    private authResponseFactory: AuthResponseFactory20200115,
+    private pkceRepository: PKCERepositoryInterface,
+    private crypter: CrypterInterface,
+    private settingService: SettingServiceInterface,
+    private generateRecoveryCodes: GenerateRecoveryCodes,
+    private increaseLoginAttempts: IncreaseLoginAttempts,
+    private clearLoginAttempts: ClearLoginAttempts,
+  ) {}
+
+  async execute(dto: SignInWithRecoveryCodesDTO): Promise<Result<AuthResponse20200115>> {
+    const usernameOrError = Username.create(dto.username)
+    if (usernameOrError.isFailed()) {
+      return Result.fail(`Could not sign in with recovery codes: ${usernameOrError.getError()}`)
+    }
+    const username = usernameOrError.getValue()
+
+    const validCodeVerifier = await this.validateCodeVerifier(dto.codeVerifier)
+    if (!validCodeVerifier) {
+      await this.increaseLoginAttempts.execute({ email: username.value })
+
+      return Result.fail('Invalid email or password')
+    }
+
+    const passwordValidationResult = Validator.isNotEmpty(dto.password)
+    if (passwordValidationResult.isFailed()) {
+      await this.increaseLoginAttempts.execute({ email: username.value })
+
+      return Result.fail('Invalid email or password')
+    }
+
+    const recoveryCodesValidationResult = Validator.isNotEmpty(dto.recoveryCodes)
+    if (recoveryCodesValidationResult.isFailed()) {
+      await this.increaseLoginAttempts.execute({ email: username.value })
+
+      return Result.fail('Invalid recovery codes')
+    }
+
+    const user = await this.userRepository.findOneByEmail(username.value)
+
+    if (!user) {
+      await this.increaseLoginAttempts.execute({ email: username.value })
+
+      return Result.fail('Invalid email or password')
+    }
+
+    const passwordMatches = await bcrypt.compare(dto.password, user.encryptedPassword)
+    if (!passwordMatches) {
+      await this.increaseLoginAttempts.execute({ email: username.value })
+
+      return Result.fail('Invalid email or password')
+    }
+
+    const recoveryCodesSetting = await this.settingService.findSettingWithDecryptedValue({
+      settingName: SettingName.RecoveryCodes,
+      userUuid: user.uuid,
+    })
+    if (!recoveryCodesSetting) {
+      await this.increaseLoginAttempts.execute({ email: username.value })
+
+      return Result.fail('User does not have recovery codes generated')
+    }
+
+    if (recoveryCodesSetting.value !== dto.recoveryCodes) {
+      await this.increaseLoginAttempts.execute({ email: username.value })
+
+      return Result.fail('Invalid recovery codes')
+    }
+
+    const authResponse = await this.authResponseFactory.createResponse({
+      user,
+      apiVersion: ApiVersion.v0,
+      userAgent: dto.userAgent,
+      ephemeralSession: false,
+      readonlyAccess: false,
+    })
+
+    const generateNewRecoveryCodesResult = await this.generateRecoveryCodes.execute({
+      userUuid: user.uuid,
+    })
+    if (generateNewRecoveryCodesResult.isFailed()) {
+      await this.increaseLoginAttempts.execute({ email: username.value })
+
+      return Result.fail(`Could not sign in with recovery codes: ${generateNewRecoveryCodesResult.getError()}`)
+    }
+
+    await this.clearLoginAttempts.execute({ email: username.value })
+
+    return Result.ok(authResponse as AuthResponse20200115)
+  }
+
+  private async validateCodeVerifier(codeVerifier: string): Promise<boolean> {
+    const codeEmptinessVerificationResult = Validator.isNotEmpty(codeVerifier)
+    if (codeEmptinessVerificationResult.isFailed()) {
+      return false
+    }
+
+    const codeChallenge = this.crypter.base64URLEncode(this.crypter.sha256Hash(codeVerifier))
+
+    const matchingCodeChallengeWasPresentAndRemoved = await this.pkceRepository.removeCodeChallenge(codeChallenge)
+
+    return matchingCodeChallengeWasPresentAndRemoved
+  }
+}

+ 7 - 0
packages/auth/src/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodesDTO.ts

@@ -0,0 +1,7 @@
+export interface SignInWithRecoveryCodesDTO {
+  userAgent: string
+  username: string
+  password: string
+  codeVerifier: string
+  recoveryCodes: string
+}

+ 3 - 0
packages/auth/src/Infra/Http/Request/GenerateRecoveryCodesRequestParams.ts

@@ -0,0 +1,3 @@
+export interface GenerateRecoveryCodesRequestParams {
+  userUuid: string
+}

+ 6 - 0
packages/auth/src/Infra/Http/Request/RecoveryKeyParamsRequestParams.ts

@@ -0,0 +1,6 @@
+export interface RecoveryKeyParamsRequestParams {
+  apiVersion: string
+  username: string
+  codeChallenge: string
+  recoveryCodes: string
+}

+ 8 - 0
packages/auth/src/Infra/Http/Request/SignInWithRecoveryCodesRequestParams.ts

@@ -0,0 +1,8 @@
+export interface SignInWithRecoveryCodesRequestParams {
+  apiVersion: string
+  userAgent: string
+  username: string
+  password: string
+  codeVerifier: string
+  recoveryCodes: string
+}

+ 8 - 0
packages/auth/src/Infra/Http/Response/GenerateRecoveryCodesResponse.ts

@@ -0,0 +1,8 @@
+import { HttpErrorResponseBody, HttpResponse } from '@standardnotes/api'
+import { Either } from '@standardnotes/common'
+
+import { GenerateRecoveryCodesResponseBody } from './GenerateRecoveryCodesResponseBody'
+
+export interface GenerateRecoveryCodesResponse extends HttpResponse {
+  data: Either<GenerateRecoveryCodesResponseBody, HttpErrorResponseBody>
+}

+ 3 - 0
packages/auth/src/Infra/Http/Response/GenerateRecoveryCodesResponseBody.ts

@@ -0,0 +1,3 @@
+export interface GenerateRecoveryCodesResponseBody {
+  recoveryCodes: string
+}

+ 8 - 0
packages/auth/src/Infra/Http/Response/RecoveryKeyParamsResponse.ts

@@ -0,0 +1,8 @@
+import { HttpErrorResponseBody, HttpResponse } from '@standardnotes/api'
+import { Either } from '@standardnotes/common'
+
+import { RecoveryKeyParamsResponseBody } from './RecoveryKeyParamsResponseBody'
+
+export interface RecoveryKeyParamsResponse extends HttpResponse {
+  data: Either<RecoveryKeyParamsResponseBody, HttpErrorResponseBody>
+}

+ 5 - 0
packages/auth/src/Infra/Http/Response/RecoveryKeyParamsResponseBody.ts

@@ -0,0 +1,5 @@
+import { KeyParamsData } from '@standardnotes/responses'
+
+export interface RecoveryKeyParamsResponseBody {
+  keyParams: KeyParamsData
+}

+ 8 - 0
packages/auth/src/Infra/Http/Response/SignInWithRecoveryCodesResponse.ts

@@ -0,0 +1,8 @@
+import { HttpErrorResponseBody, HttpResponse } from '@standardnotes/api'
+import { Either } from '@standardnotes/common'
+
+import { SignInWithRecoveryCodesResponseBody } from './SignInWithRecoveryCodesResponseBody'
+
+export interface SignInWithRecoveryCodesResponse extends HttpResponse {
+  data: Either<SignInWithRecoveryCodesResponseBody, HttpErrorResponseBody>
+}

+ 6 - 0
packages/auth/src/Infra/Http/Response/SignInWithRecoveryCodesResponseBody.ts

@@ -0,0 +1,6 @@
+import { KeyParamsData, SessionBody } from '@standardnotes/responses'
+
+export interface SignInWithRecoveryCodesResponseBody {
+  session: SessionBody
+  key_params: KeyParamsData
+}

+ 35 - 0
packages/auth/src/Infra/InversifyExpressUtils/InversifyExpressAuthController.ts

@@ -252,6 +252,41 @@ export class InversifyExpressAuthController extends BaseHttpController {
     return this.json(signInResult.authResponse)
   }
 
+  @httpPost('/recovery/codes', TYPES.ApiGatewayAuthMiddleware)
+  async generateRecoveryCodes(_request: Request, response: Response): Promise<results.JsonResult> {
+    const result = await this.authController.generateRecoveryCodes({
+      userUuid: response.locals.user.uuid,
+    })
+
+    return this.json(result.data, result.status)
+  }
+
+  @httpPost('/recovery/login', TYPES.LockMiddleware)
+  async recoveryLogin(request: Request): Promise<results.JsonResult> {
+    const result = await this.authController.signInWithRecoveryCodes({
+      apiVersion: request.body.api,
+      userAgent: <string>request.headers['user-agent'],
+      codeVerifier: request.body.code_verifier,
+      username: request.body.email,
+      recoveryCodes: request.body.recovery_codes,
+      password: request.body.password,
+    })
+
+    return this.json(result.data, result.status)
+  }
+
+  @httpPost('/recovery/params')
+  async recoveryParams(request: Request): Promise<results.JsonResult> {
+    const result = await this.authController.recoveryKeyParams({
+      apiVersion: request.body.api,
+      username: request.body.email,
+      codeChallenge: request.body.code_challenge,
+      recoveryCodes: request.body.recovery_codes,
+    })
+
+    return this.json(result.data, result.status)
+  }
+
   @httpPost('/sign_out', TYPES.AuthMiddlewareWithoutResponse)
   async signOut(request: Request, response: Response): Promise<results.JsonResult | void> {
     if (response.locals.readOnlyAccess) {