From be028ff87bb5306fc266b70567cbea8a13547e7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Mon, 9 Jan 2023 06:52:56 +0100 Subject: [PATCH] feat(snjs): add sign in with recovery codes use case (#2130) * feat(snjs): add sign in with recovery codes use case * fix(snjs): code review adjustments * fix(snjs): remove unnecessary exposed getter * fix(services): waiting for event handling * fix: preferences test Co-authored-by: Mo --- .../Domain/Client/Auth/AuthApiOperations.ts | 5 + .../src/Domain/Client/Auth/AuthApiService.ts | 89 +++++++++ .../Client/Auth/AuthApiServiceInterface.ts | 20 ++ packages/api/src/Domain/Client/index.ts | 3 + .../api/src/Domain/Http/HttpRequestParams.ts | 2 +- packages/api/src/Domain/Http/HttpService.ts | 4 +- .../RecoveryKeyParamsRequestParams.ts | 6 + .../SignInWithRecoveryCodesRequestParams.ts | 7 + packages/api/src/Domain/Request/index.ts | 2 + .../Recovery/GenerateRecoveryCodesResponse.ts | 10 + .../GenerateRecoveryCodesResponseBody.ts | 3 + .../Recovery/RecoveryKeyParamsResponse.ts | 10 + .../Recovery/RecoveryKeyParamsResponseBody.ts | 5 + .../SignInWithRecoveryCodesResponse.ts | 10 + .../SignInWithRecoveryCodesResponseBody.ts | 11 ++ packages/api/src/Domain/Response/index.ts | 6 + .../api/src/Domain/Server/Auth/AuthServer.ts | 33 ++++ .../Domain/Server/Auth/AuthServerInterface.ts | 12 ++ packages/api/src/Domain/Server/Auth/Paths.ts | 7 + packages/api/src/Domain/Server/index.ts | 2 + .../Encryption/EncryptionProviderInterface.ts | 3 + packages/services/package.json | 1 + .../Application/ApplicationInterface.ts | 1 + .../src/Domain/Auth/AuthClientInterface.ts | 28 +++ .../services/src/Domain/Auth/AuthManager.ts | 82 ++++++++ .../Domain/Session/SessionsClientInterface.ts | 12 +- .../services/src/Domain/User/UserService.ts | 151 +++++++++------ packages/services/src/Domain/index.ts | 2 + packages/snjs/lib/Application/Application.ts | 44 ++++- .../SignInWithRecoveryCodes.spec.ts | 179 ++++++++++++++++++ .../SignInWithRecoveryCodes.ts | 107 +++++++++++ .../SignInWithRecoveryCodesDTO.ts | 5 + .../UseCase/UseCaseContainerInterface.ts | 5 + .../lib/Services/Session/SessionManager.ts | 33 +++- packages/snjs/mocha/lib/AppContext.js | 10 + packages/snjs/mocha/preferences.test.js | 8 +- yarn.lock | 1 + 37 files changed, 838 insertions(+), 81 deletions(-) create mode 100644 packages/api/src/Domain/Client/Auth/AuthApiOperations.ts create mode 100644 packages/api/src/Domain/Client/Auth/AuthApiService.ts create mode 100644 packages/api/src/Domain/Client/Auth/AuthApiServiceInterface.ts create mode 100644 packages/api/src/Domain/Request/Recovery/RecoveryKeyParamsRequestParams.ts create mode 100644 packages/api/src/Domain/Request/Recovery/SignInWithRecoveryCodesRequestParams.ts create mode 100644 packages/api/src/Domain/Response/Recovery/GenerateRecoveryCodesResponse.ts create mode 100644 packages/api/src/Domain/Response/Recovery/GenerateRecoveryCodesResponseBody.ts create mode 100644 packages/api/src/Domain/Response/Recovery/RecoveryKeyParamsResponse.ts create mode 100644 packages/api/src/Domain/Response/Recovery/RecoveryKeyParamsResponseBody.ts create mode 100644 packages/api/src/Domain/Response/Recovery/SignInWithRecoveryCodesResponse.ts create mode 100644 packages/api/src/Domain/Response/Recovery/SignInWithRecoveryCodesResponseBody.ts create mode 100644 packages/api/src/Domain/Server/Auth/AuthServer.ts create mode 100644 packages/api/src/Domain/Server/Auth/AuthServerInterface.ts create mode 100644 packages/services/src/Domain/Auth/AuthClientInterface.ts create mode 100644 packages/services/src/Domain/Auth/AuthManager.ts create mode 100644 packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.spec.ts create mode 100644 packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.ts create mode 100644 packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodesDTO.ts create mode 100644 packages/snjs/lib/Domain/UseCase/UseCaseContainerInterface.ts diff --git a/packages/api/src/Domain/Client/Auth/AuthApiOperations.ts b/packages/api/src/Domain/Client/Auth/AuthApiOperations.ts new file mode 100644 index 000000000..072c68507 --- /dev/null +++ b/packages/api/src/Domain/Client/Auth/AuthApiOperations.ts @@ -0,0 +1,5 @@ +export enum AuthApiOperations { + GenerateRecoveryCodes, + GetRecoveryKeyParams, + SignInWithRecoveryCodes, +} diff --git a/packages/api/src/Domain/Client/Auth/AuthApiService.ts b/packages/api/src/Domain/Client/Auth/AuthApiService.ts new file mode 100644 index 000000000..7b306c607 --- /dev/null +++ b/packages/api/src/Domain/Client/Auth/AuthApiService.ts @@ -0,0 +1,89 @@ +import { ApiVersion } from '../../Api' +import { ApiCallError } from '../../Error/ApiCallError' +import { ErrorMessage } from '../../Error/ErrorMessage' +import { + GenerateRecoveryCodesResponse, + RecoveryKeyParamsResponse, + SignInWithRecoveryCodesResponse, +} from '../../Response' +import { AuthServerInterface } from '../../Server' + +import { AuthApiOperations } from './AuthApiOperations' +import { AuthApiServiceInterface } from './AuthApiServiceInterface' + +export class AuthApiService implements AuthApiServiceInterface { + private operationsInProgress: Map + + constructor(private authServer: AuthServerInterface) { + this.operationsInProgress = new Map() + } + + async generateRecoveryCodes(): Promise { + if (this.operationsInProgress.get(AuthApiOperations.GenerateRecoveryCodes)) { + throw new ApiCallError(ErrorMessage.GenericInProgress) + } + + this.operationsInProgress.set(AuthApiOperations.GenerateRecoveryCodes, true) + + try { + const response = await this.authServer.generateRecoveryCodes() + + return response + } catch (error) { + throw new ApiCallError(ErrorMessage.GenericFail) + } finally { + this.operationsInProgress.set(AuthApiOperations.GenerateRecoveryCodes, false) + } + } + + async recoveryKeyParams(dto: { + username: string + codeChallenge: string + recoveryCodes: string + }): Promise { + if (this.operationsInProgress.get(AuthApiOperations.GetRecoveryKeyParams)) { + throw new ApiCallError(ErrorMessage.GenericInProgress) + } + + this.operationsInProgress.set(AuthApiOperations.GetRecoveryKeyParams, true) + + try { + const response = await this.authServer.recoveryKeyParams({ + apiVersion: ApiVersion.v0, + ...dto, + }) + + return response + } catch (error) { + throw new ApiCallError(ErrorMessage.GenericFail) + } finally { + this.operationsInProgress.set(AuthApiOperations.GetRecoveryKeyParams, false) + } + } + + async signInWithRecoveryCodes(dto: { + username: string + password: string + codeVerifier: string + recoveryCodes: string + }): Promise { + if (this.operationsInProgress.get(AuthApiOperations.SignInWithRecoveryCodes)) { + throw new ApiCallError(ErrorMessage.GenericInProgress) + } + + this.operationsInProgress.set(AuthApiOperations.SignInWithRecoveryCodes, true) + + try { + const response = await this.authServer.signInWithRecoveryCodes({ + apiVersion: ApiVersion.v0, + ...dto, + }) + + return response + } catch (error) { + throw new ApiCallError(ErrorMessage.GenericFail) + } finally { + this.operationsInProgress.set(AuthApiOperations.SignInWithRecoveryCodes, false) + } + } +} diff --git a/packages/api/src/Domain/Client/Auth/AuthApiServiceInterface.ts b/packages/api/src/Domain/Client/Auth/AuthApiServiceInterface.ts new file mode 100644 index 000000000..822ce1c96 --- /dev/null +++ b/packages/api/src/Domain/Client/Auth/AuthApiServiceInterface.ts @@ -0,0 +1,20 @@ +import { + GenerateRecoveryCodesResponse, + RecoveryKeyParamsResponse, + SignInWithRecoveryCodesResponse, +} from '../../Response' + +export interface AuthApiServiceInterface { + generateRecoveryCodes(): Promise + recoveryKeyParams(dto: { + username: string + codeChallenge: string + recoveryCodes: string + }): Promise + signInWithRecoveryCodes(dto: { + username: string + password: string + codeVerifier: string + recoveryCodes: string + }): Promise +} diff --git a/packages/api/src/Domain/Client/index.ts b/packages/api/src/Domain/Client/index.ts index 8c97ba6c5..a17152697 100644 --- a/packages/api/src/Domain/Client/index.ts +++ b/packages/api/src/Domain/Client/index.ts @@ -1,3 +1,6 @@ +export * from './Auth/AuthApiOperations' +export * from './Auth/AuthApiService' +export * from './Auth/AuthApiServiceInterface' export * from './Authenticator/AuthenticatorApiOperations' export * from './Authenticator/AuthenticatorApiService' export * from './Authenticator/AuthenticatorApiServiceInterface' diff --git a/packages/api/src/Domain/Http/HttpRequestParams.ts b/packages/api/src/Domain/Http/HttpRequestParams.ts index f0b6807cf..b678f74c9 100644 --- a/packages/api/src/Domain/Http/HttpRequestParams.ts +++ b/packages/api/src/Domain/Http/HttpRequestParams.ts @@ -1 +1 @@ -export type HttpRequestParams = Record +export type HttpRequestParams = unknown diff --git a/packages/api/src/Domain/Http/HttpService.ts b/packages/api/src/Domain/Http/HttpService.ts index be737188c..372bf54f7 100644 --- a/packages/api/src/Domain/Http/HttpService.ts +++ b/packages/api/src/Domain/Http/HttpService.ts @@ -276,9 +276,9 @@ export class HttpService implements HttpServiceInterface { } private urlForUrlAndParams(url: string, params: HttpRequestParams) { - const keyValueString = Object.keys(params) + const keyValueString = Object.keys(params as Record) .map((key) => { - return key + '=' + encodeURIComponent(params[key] as string) + return key + '=' + encodeURIComponent((params as Record)[key] as string) }) .join('&') diff --git a/packages/api/src/Domain/Request/Recovery/RecoveryKeyParamsRequestParams.ts b/packages/api/src/Domain/Request/Recovery/RecoveryKeyParamsRequestParams.ts new file mode 100644 index 000000000..f2a688198 --- /dev/null +++ b/packages/api/src/Domain/Request/Recovery/RecoveryKeyParamsRequestParams.ts @@ -0,0 +1,6 @@ +export interface RecoveryKeyParamsRequestParams { + apiVersion: string + username: string + codeChallenge: string + recoveryCodes: string +} diff --git a/packages/api/src/Domain/Request/Recovery/SignInWithRecoveryCodesRequestParams.ts b/packages/api/src/Domain/Request/Recovery/SignInWithRecoveryCodesRequestParams.ts new file mode 100644 index 000000000..a096c9e02 --- /dev/null +++ b/packages/api/src/Domain/Request/Recovery/SignInWithRecoveryCodesRequestParams.ts @@ -0,0 +1,7 @@ +export interface SignInWithRecoveryCodesRequestParams { + apiVersion: string + username: string + password: string + codeVerifier: string + recoveryCodes: string +} diff --git a/packages/api/src/Domain/Request/index.ts b/packages/api/src/Domain/Request/index.ts index 4875e1c19..ee6c7e1be 100644 --- a/packages/api/src/Domain/Request/index.ts +++ b/packages/api/src/Domain/Request/index.ts @@ -5,6 +5,8 @@ export * from './Authenticator/GenerateAuthenticatorRegistrationOptionsRequestPa export * from './Authenticator/ListAuthenticatorsRequestParams' export * from './Authenticator/VerifyAuthenticatorAuthenticationResponseRequestParams' export * from './Authenticator/VerifyAuthenticatorRegistrationResponseRequestParams' +export * from './Recovery/RecoveryKeyParamsRequestParams' +export * from './Recovery/SignInWithRecoveryCodesRequestParams' export * from './Subscription/AppleIAPConfirmRequestParams' export * from './Subscription/SubscriptionInviteAcceptRequestParams' export * from './Subscription/SubscriptionInviteCancelRequestParams' diff --git a/packages/api/src/Domain/Response/Recovery/GenerateRecoveryCodesResponse.ts b/packages/api/src/Domain/Response/Recovery/GenerateRecoveryCodesResponse.ts new file mode 100644 index 000000000..31faab7a4 --- /dev/null +++ b/packages/api/src/Domain/Response/Recovery/GenerateRecoveryCodesResponse.ts @@ -0,0 +1,10 @@ +import { Either } from '@standardnotes/common' + +import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody' +import { HttpResponse } from '../../Http/HttpResponse' + +import { GenerateRecoveryCodesResponseBody } from './GenerateRecoveryCodesResponseBody' + +export interface GenerateRecoveryCodesResponse extends HttpResponse { + data: Either +} diff --git a/packages/api/src/Domain/Response/Recovery/GenerateRecoveryCodesResponseBody.ts b/packages/api/src/Domain/Response/Recovery/GenerateRecoveryCodesResponseBody.ts new file mode 100644 index 000000000..8bd59ff1d --- /dev/null +++ b/packages/api/src/Domain/Response/Recovery/GenerateRecoveryCodesResponseBody.ts @@ -0,0 +1,3 @@ +export interface GenerateRecoveryCodesResponseBody { + recoveryCodes: string +} diff --git a/packages/api/src/Domain/Response/Recovery/RecoveryKeyParamsResponse.ts b/packages/api/src/Domain/Response/Recovery/RecoveryKeyParamsResponse.ts new file mode 100644 index 000000000..d0f0efe23 --- /dev/null +++ b/packages/api/src/Domain/Response/Recovery/RecoveryKeyParamsResponse.ts @@ -0,0 +1,10 @@ +import { Either } from '@standardnotes/common' + +import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody' +import { HttpResponse } from '../../Http/HttpResponse' + +import { RecoveryKeyParamsResponseBody } from './RecoveryKeyParamsResponseBody' + +export interface RecoveryKeyParamsResponse extends HttpResponse { + data: Either +} diff --git a/packages/api/src/Domain/Response/Recovery/RecoveryKeyParamsResponseBody.ts b/packages/api/src/Domain/Response/Recovery/RecoveryKeyParamsResponseBody.ts new file mode 100644 index 000000000..177f99957 --- /dev/null +++ b/packages/api/src/Domain/Response/Recovery/RecoveryKeyParamsResponseBody.ts @@ -0,0 +1,5 @@ +import { KeyParamsData } from '@standardnotes/responses' + +export interface RecoveryKeyParamsResponseBody { + keyParams: KeyParamsData +} diff --git a/packages/api/src/Domain/Response/Recovery/SignInWithRecoveryCodesResponse.ts b/packages/api/src/Domain/Response/Recovery/SignInWithRecoveryCodesResponse.ts new file mode 100644 index 000000000..cd6c53169 --- /dev/null +++ b/packages/api/src/Domain/Response/Recovery/SignInWithRecoveryCodesResponse.ts @@ -0,0 +1,10 @@ +import { Either } from '@standardnotes/common' + +import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody' +import { HttpResponse } from '../../Http/HttpResponse' + +import { SignInWithRecoveryCodesResponseBody } from './SignInWithRecoveryCodesResponseBody' + +export interface SignInWithRecoveryCodesResponse extends HttpResponse { + data: Either +} diff --git a/packages/api/src/Domain/Response/Recovery/SignInWithRecoveryCodesResponseBody.ts b/packages/api/src/Domain/Response/Recovery/SignInWithRecoveryCodesResponseBody.ts new file mode 100644 index 000000000..0a2bb7cde --- /dev/null +++ b/packages/api/src/Domain/Response/Recovery/SignInWithRecoveryCodesResponseBody.ts @@ -0,0 +1,11 @@ +import { KeyParamsData, SessionBody } from '@standardnotes/responses' + +export interface SignInWithRecoveryCodesResponseBody { + session: SessionBody + key_params: KeyParamsData + user: { + uuid: string + email: string + protocolVersion: string + } +} diff --git a/packages/api/src/Domain/Response/index.ts b/packages/api/src/Domain/Response/index.ts index 5d8effcb7..21b17b3c8 100644 --- a/packages/api/src/Domain/Response/index.ts +++ b/packages/api/src/Domain/Response/index.ts @@ -12,6 +12,12 @@ export * from './Authenticator/VerifyAuthenticatorAuthenticationResponseResponse export * from './Authenticator/VerifyAuthenticatorAuthenticationResponseResponseBody' export * from './Authenticator/VerifyAuthenticatorRegistrationResponseResponse' export * from './Authenticator/VerifyAuthenticatorRegistrationResponseResponseBody' +export * from './Recovery/GenerateRecoveryCodesResponse' +export * from './Recovery/GenerateRecoveryCodesResponseBody' +export * from './Recovery/RecoveryKeyParamsResponse' +export * from './Recovery/RecoveryKeyParamsResponseBody' +export * from './Recovery/SignInWithRecoveryCodesResponse' +export * from './Recovery/SignInWithRecoveryCodesResponseBody' export * from './Subscription/AppleIAPConfirmResponse' export * from './Subscription/AppleIAPConfirmResponseBody' export * from './Subscription/SubscriptionInviteAcceptResponse' diff --git a/packages/api/src/Domain/Server/Auth/AuthServer.ts b/packages/api/src/Domain/Server/Auth/AuthServer.ts new file mode 100644 index 000000000..9689a6d57 --- /dev/null +++ b/packages/api/src/Domain/Server/Auth/AuthServer.ts @@ -0,0 +1,33 @@ +import { HttpServiceInterface } from '../../Http/HttpServiceInterface' +import { RecoveryKeyParamsRequestParams, SignInWithRecoveryCodesRequestParams } from '../../Request' +import { + GenerateRecoveryCodesResponse, + RecoveryKeyParamsResponse, + SignInWithRecoveryCodesResponse, +} from '../../Response' +import { AuthServerInterface } from './AuthServerInterface' +import { Paths } from './Paths' + +export class AuthServer implements AuthServerInterface { + constructor(private httpService: HttpServiceInterface) {} + + async generateRecoveryCodes(): Promise { + const response = await this.httpService.post(Paths.v1.generateRecoveryCodes) + + return response as GenerateRecoveryCodesResponse + } + + async recoveryKeyParams(params: RecoveryKeyParamsRequestParams): Promise { + const response = await this.httpService.post(Paths.v1.recoveryKeyParams, params) + + return response as RecoveryKeyParamsResponse + } + + async signInWithRecoveryCodes( + params: SignInWithRecoveryCodesRequestParams, + ): Promise { + const response = await this.httpService.post(Paths.v1.signInWithRecoveryCodes, params) + + return response as SignInWithRecoveryCodesResponse + } +} diff --git a/packages/api/src/Domain/Server/Auth/AuthServerInterface.ts b/packages/api/src/Domain/Server/Auth/AuthServerInterface.ts new file mode 100644 index 000000000..0a72d626e --- /dev/null +++ b/packages/api/src/Domain/Server/Auth/AuthServerInterface.ts @@ -0,0 +1,12 @@ +import { RecoveryKeyParamsRequestParams, SignInWithRecoveryCodesRequestParams } from '../../Request' +import { + GenerateRecoveryCodesResponse, + RecoveryKeyParamsResponse, + SignInWithRecoveryCodesResponse, +} from '../../Response' + +export interface AuthServerInterface { + generateRecoveryCodes(): Promise + recoveryKeyParams(params: RecoveryKeyParamsRequestParams): Promise + signInWithRecoveryCodes(params: SignInWithRecoveryCodesRequestParams): Promise +} diff --git a/packages/api/src/Domain/Server/Auth/Paths.ts b/packages/api/src/Domain/Server/Auth/Paths.ts index 71ad817be..94940a8e4 100644 --- a/packages/api/src/Domain/Server/Auth/Paths.ts +++ b/packages/api/src/Domain/Server/Auth/Paths.ts @@ -2,8 +2,15 @@ const SessionPaths = { refreshSession: '/v1/sessions/refresh', } +const RecoveryPaths = { + generateRecoveryCodes: '/v1/auth/recovery/codes', + recoveryKeyParams: '/v1/auth/recovery/login-params', + signInWithRecoveryCodes: '/v1/auth/recovery/login', +} + export const Paths = { v1: { ...SessionPaths, + ...RecoveryPaths, }, } diff --git a/packages/api/src/Domain/Server/index.ts b/packages/api/src/Domain/Server/index.ts index 4572136b0..2b3ff0631 100644 --- a/packages/api/src/Domain/Server/index.ts +++ b/packages/api/src/Domain/Server/index.ts @@ -1,3 +1,5 @@ +export * from './Auth/AuthServer' +export * from './Auth/AuthServerInterface' export * from './Authenticator/AuthenticatorServer' export * from './Authenticator/AuthenticatorServerInterface' export * from './Subscription/SubscriptionServer' diff --git a/packages/encryption/src/Domain/Service/Encryption/EncryptionProviderInterface.ts b/packages/encryption/src/Domain/Service/Encryption/EncryptionProviderInterface.ts index 5db3962a3..cd49b9a46 100644 --- a/packages/encryption/src/Domain/Service/Encryption/EncryptionProviderInterface.ts +++ b/packages/encryption/src/Domain/Service/Encryption/EncryptionProviderInterface.ts @@ -32,6 +32,9 @@ export interface EncryptionProviderInterface { getKeyEmbeddedKeyParams(key: EncryptedPayloadInterface): SNRootKeyParams | undefined computeRootKey(password: string, keyParams: SNRootKeyParams): Promise supportedVersions(): ProtocolVersion[] + isVersionNewerThanLibraryVersion(version: ProtocolVersion): boolean + platformSupportsKeyDerivation(keyParams: SNRootKeyParams): boolean + computeWrappingKey(passcode: string): Promise getUserVersion(): ProtocolVersion | undefined decryptBackupFile( file: BackupFile, diff --git a/packages/services/package.json b/packages/services/package.json index 00aa7ed48..77a36ea66 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -19,6 +19,7 @@ "@standardnotes/api": "workspace:^", "@standardnotes/auth": "^3.19.4", "@standardnotes/common": "^1.45.0", + "@standardnotes/domain-core": "^1.11.0", "@standardnotes/encryption": "workspace:^", "@standardnotes/files": "workspace:^", "@standardnotes/models": "workspace:^", diff --git a/packages/services/src/Domain/Application/ApplicationInterface.ts b/packages/services/src/Domain/Application/ApplicationInterface.ts index 1538c8bc2..84afe8fdb 100644 --- a/packages/services/src/Domain/Application/ApplicationInterface.ts +++ b/packages/services/src/Domain/Application/ApplicationInterface.ts @@ -1,6 +1,7 @@ import { ApplicationIdentifier, ContentType } from '@standardnotes/common' import { BackupFile, DecryptedItemInterface, ItemStream, Platform, PrefKey, PrefValue } from '@standardnotes/models' import { FilesClientInterface } from '@standardnotes/files' + import { AlertService } from '../Alert/AlertService' import { ComponentManagerInterface } from '../Component/ComponentManagerInterface' import { ApplicationEvent } from '../Event/ApplicationEvent' diff --git a/packages/services/src/Domain/Auth/AuthClientInterface.ts b/packages/services/src/Domain/Auth/AuthClientInterface.ts new file mode 100644 index 000000000..6aa5d292b --- /dev/null +++ b/packages/services/src/Domain/Auth/AuthClientInterface.ts @@ -0,0 +1,28 @@ +import { AnyKeyParamsContent } from '@standardnotes/common' +import { SessionBody } from '@standardnotes/responses' + +export interface AuthClientInterface { + generateRecoveryCodes(): Promise + recoveryKeyParams(dto: { + username: string + codeChallenge: string + recoveryCodes: string + }): Promise + signInWithRecoveryCodes(dto: { + username: string + password: string + codeVerifier: string + recoveryCodes: string + }): Promise< + | { + keyParams: AnyKeyParamsContent + session: SessionBody + user: { + uuid: string + email: string + protocolVersion: string + } + } + | false + > +} diff --git a/packages/services/src/Domain/Auth/AuthManager.ts b/packages/services/src/Domain/Auth/AuthManager.ts new file mode 100644 index 000000000..465e56eeb --- /dev/null +++ b/packages/services/src/Domain/Auth/AuthManager.ts @@ -0,0 +1,82 @@ +import { AuthApiServiceInterface } from '@standardnotes/api' +import { AnyKeyParamsContent } from '@standardnotes/common' +import { SessionBody } from '@standardnotes/responses' + +import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface' +import { AbstractService } from '../Service/AbstractService' +import { AuthClientInterface } from './AuthClientInterface' + +export class AuthManager extends AbstractService implements AuthClientInterface { + constructor( + private authApiService: AuthApiServiceInterface, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + } + + async generateRecoveryCodes(): Promise { + try { + const result = await this.authApiService.generateRecoveryCodes() + + if (result.data.error) { + return false + } + + return result.data.recoveryCodes + } catch (error) { + return false + } + } + + async recoveryKeyParams(dto: { + username: string + codeChallenge: string + recoveryCodes: string + }): Promise { + try { + const result = await this.authApiService.recoveryKeyParams(dto) + + if (result.data.error) { + return false + } + + return result.data.keyParams as AnyKeyParamsContent + } catch (error) { + return false + } + } + + async signInWithRecoveryCodes(dto: { + username: string + password: string + codeVerifier: string + recoveryCodes: string + }): Promise< + | { + keyParams: AnyKeyParamsContent + session: SessionBody + user: { + uuid: string + email: string + protocolVersion: string + } + } + | false + > { + try { + const result = await this.authApiService.signInWithRecoveryCodes(dto) + + if (result.data.error) { + return false + } + + return { + keyParams: result.data.key_params as AnyKeyParamsContent, + session: result.data.session, + user: result.data.user, + } + } catch (error) { + return false + } + } +} diff --git a/packages/services/src/Domain/Session/SessionsClientInterface.ts b/packages/services/src/Domain/Session/SessionsClientInterface.ts index f5ed7e53b..08db150e1 100644 --- a/packages/services/src/Domain/Session/SessionsClientInterface.ts +++ b/packages/services/src/Domain/Session/SessionsClientInterface.ts @@ -1,7 +1,8 @@ import { UserRegistrationResponseBody } from '@standardnotes/api' import { ProtocolVersion } from '@standardnotes/common' +import { SNRootKey } from '@standardnotes/encryption' import { RootKeyInterface } from '@standardnotes/models' -import { ClientDisplayableError, HttpResponse, SignInResponse, User } from '@standardnotes/responses' +import { ClientDisplayableError, HttpResponse, SessionBody, SignInResponse, User } from '@standardnotes/responses' import { Base64String } from '@standardnotes/sncrypto-common' import { SessionManagerResponse } from './SessionManagerResponse' @@ -31,4 +32,13 @@ export interface SessionsClientInterface { wrappingKey?: RootKeyInterface newEmail?: string }): Promise + handleAuthentication(dto: { + session: SessionBody + user: { + uuid: string + email: string + } + rootKey: SNRootKey + wrappingKey?: SNRootKey + }): Promise } diff --git a/packages/services/src/Domain/User/UserService.ts b/packages/services/src/Domain/User/UserService.ts index 77cf19c39..61bbeaa9c 100644 --- a/packages/services/src/Domain/User/UserService.ts +++ b/packages/services/src/Domain/User/UserService.ts @@ -1,7 +1,7 @@ import { Base64String } from '@standardnotes/sncrypto-common' import { EncryptionProviderInterface, SNRootKey, SNRootKeyParams } from '@standardnotes/encryption' import { HttpResponse, SignInResponse, User } from '@standardnotes/responses' -import { KeyParamsOrigination, UserRequestType } from '@standardnotes/common' +import { Either, KeyParamsOrigination, UserRequestType } from '@standardnotes/common' import { UuidGenerator } from '@standardnotes/utils' import { UserApiServiceInterface, UserRegistrationResponseBody } from '@standardnotes/api' @@ -25,6 +25,8 @@ import { DeinitSource } from '../Application/DeinitSource' import { StoragePersistencePolicies } from '../Storage/StorageTypes' import { SessionsClientInterface } from '../Session/SessionsClientInterface' import { ProtectionsClientInterface } from '../Protection/ProtectionClientInterface' +import { InternalEventHandlerInterface } from '../Internal/InternalEventHandlerInterface' +import { InternalEventInterface } from '../Internal/InternalEventInterface' export type CredentialsChangeFunctionResponse = { error?: { message: string } } export type AccountServiceResponse = HttpResponse @@ -34,11 +36,25 @@ export enum AccountEvent { SignedOut = 'SignedOut', } -type AccountEventData = { +export interface SignedInOrRegisteredEventPayload { + ephemeral: boolean + mergeLocal: boolean + awaitSync: boolean + checkIntegrity: boolean +} + +export interface SignedOutEventPayload { source: DeinitSource } -export class UserService extends AbstractService implements UserClientInterface { +export interface AccountEventData { + payload: Either +} + +export class UserService + extends AbstractService + implements UserClientInterface, InternalEventHandlerInterface +{ private signingIn = false private registering = false @@ -60,6 +76,43 @@ export class UserService extends AbstractService super(internalEventBus) } + async handleEvent(event: InternalEventInterface): Promise { + if (event.type === AccountEvent.SignedInOrRegistered) { + const payload = (event.payload as AccountEventData).payload as SignedInOrRegisteredEventPayload + this.syncService.resetSyncState() + + await this.storageService.setPersistencePolicy( + payload.ephemeral ? StoragePersistencePolicies.Ephemeral : StoragePersistencePolicies.Default, + ) + + if (payload.mergeLocal) { + await this.syncService.markAllItemsAsNeedingSyncAndPersist() + } else { + void this.itemManager.removeAllItemsFromMemory() + await this.clearDatabase() + } + + this.unlockSyncing() + + const syncPromise = this.syncService + .downloadFirstSync(1_000, { + checkIntegrity: payload.checkIntegrity, + awaitAll: payload.awaitSync, + }) + .then(() => { + if (!payload.awaitSync) { + void this.protocolService.decryptErroredPayloads() + } + }) + + if (payload.awaitSync) { + await syncPromise + + await this.protocolService.decryptErroredPayloads() + } + } + } + public override deinit(): void { super.deinit() ;(this.sessionManager as unknown) = undefined @@ -97,27 +150,17 @@ export class UserService extends AbstractService this.lockSyncing() const response = await this.sessionManager.register(email, password, ephemeral) - this.syncService.resetSyncState() + await this.notifyEventSync(AccountEvent.SignedInOrRegistered, { + payload: { + ephemeral, + mergeLocal, + awaitSync: true, + checkIntegrity: false, + }, + }) - await this.storageService.setPersistencePolicy( - ephemeral ? StoragePersistencePolicies.Ephemeral : StoragePersistencePolicies.Default, - ) - - if (mergeLocal) { - await this.syncService.markAllItemsAsNeedingSyncAndPersist() - } else { - await this.itemManager.removeAllItemsFromMemory() - await this.clearDatabase() - } - - await this.notifyEvent(AccountEvent.SignedInOrRegistered) - - this.unlockSyncing() this.registering = false - await this.syncService.downloadFirstSync(300) - void this.protocolService.decryptErroredPayloads() - return response } catch (error) { this.unlockSyncing() @@ -156,39 +199,15 @@ export class UserService extends AbstractService const result = await this.sessionManager.signIn(email, password, strict, ephemeral) if (!result.response.error) { - this.syncService.resetSyncState() - - await this.storageService.setPersistencePolicy( - ephemeral ? StoragePersistencePolicies.Ephemeral : StoragePersistencePolicies.Default, - ) - - if (mergeLocal) { - await this.syncService.markAllItemsAsNeedingSyncAndPersist() - } else { - void this.itemManager.removeAllItemsFromMemory() - await this.clearDatabase() - } - - await this.notifyEvent(AccountEvent.SignedInOrRegistered) - - this.unlockSyncing() - - const syncPromise = this.syncService - .downloadFirstSync(1_000, { + const notifyingFunction = awaitSync ? this.notifyEventSync.bind(this) : this.notifyEvent.bind(this) + await notifyingFunction(AccountEvent.SignedInOrRegistered, { + payload: { + mergeLocal, + awaitSync, + ephemeral, checkIntegrity: true, - awaitAll: awaitSync, - }) - .then(() => { - if (!awaitSync) { - void this.protocolService.decryptErroredPayloads() - } - }) - - if (awaitSync) { - await syncPromise - - await this.protocolService.decryptErroredPayloads() - } + }, + }) } else { this.unlockSyncing() } @@ -267,15 +286,14 @@ export class UserService extends AbstractService ) if (!response.error) { - await this.notifyEvent(AccountEvent.SignedInOrRegistered) - - this.unlockSyncing() - - void this.syncService.downloadFirstSync(1_000, { - checkIntegrity: true, + await this.notifyEvent(AccountEvent.SignedInOrRegistered, { + payload: { + mergeLocal: true, + awaitSync: true, + ephemeral: false, + checkIntegrity: true, + }, }) - - void this.protocolService.decryptErroredPayloads() } this.unlockSyncing() @@ -310,7 +328,7 @@ export class UserService extends AbstractService await this.sessionManager.signOut() await this.protocolService.deleteWorkspaceSpecificKeyStateFromDevice() await this.storageService.clearAllData() - await this.notifyEvent(AccountEvent.SignedOut, { source }) + await this.notifyEvent(AccountEvent.SignedOut, { payload: { source } }) } if (force) { @@ -473,7 +491,14 @@ export class UserService extends AbstractService public async populateSessionFromDemoShareToken(token: Base64String): Promise { await this.sessionManager.populateSessionFromDemoShareToken(token) - await this.notifyEvent(AccountEvent.SignedInOrRegistered) + await this.notifyEvent(AccountEvent.SignedInOrRegistered, { + payload: { + ephemeral: false, + mergeLocal: false, + checkIntegrity: false, + awaitSync: true, + }, + }) } private async setPasscodeWithoutWarning(passcode: string, origination: KeyParamsOrigination) { diff --git a/packages/services/src/Domain/index.ts b/packages/services/src/Domain/index.ts index e770eb4c3..2324cc6f8 100644 --- a/packages/services/src/Domain/index.ts +++ b/packages/services/src/Domain/index.ts @@ -6,6 +6,8 @@ export * from './Application/ApplicationStage' export * from './Application/DeinitCallback' export * from './Application/DeinitSource' export * from './Application/DeinitMode' +export * from './Auth/AuthClientInterface' +export * from './Auth/AuthManager' export * from './Authenticator/AuthenticatorClientInterface' export * from './Authenticator/AuthenticatorManager' export * from './User/UserClientInterface' diff --git a/packages/snjs/lib/Application/Application.ts b/packages/snjs/lib/Application/Application.ts index 1136798ed..e0443833c 100644 --- a/packages/snjs/lib/Application/Application.ts +++ b/packages/snjs/lib/Application/Application.ts @@ -1,8 +1,10 @@ import { + AuthApiService, AuthenticatorApiService, AuthenticatorApiServiceInterface, AuthenticatorServer, AuthenticatorServerInterface, + AuthServer, HttpService, HttpServiceInterface, SubscriptionApiService, @@ -69,6 +71,8 @@ import { AccountEvent, AuthenticatorClientInterface, AuthenticatorManager, + AuthClientInterface, + AuthManager, } from '@standardnotes/services' import { BackupServiceInterface, FilesClientInterface } from '@standardnotes/files' import { ComputePrivateUsername } from '@standardnotes/encryption' @@ -88,9 +92,11 @@ import { SNLog } from '../Log' import { ChallengeResponse, ListedClientInterface } from '../Services' import { ApplicationConstructorOptions, FullyResolvedApplicationOptions } from './Options/ApplicationOptions' import { ApplicationOptionsDefaults } from './Options/Defaults' -import { LegacySession, MapperInterface, Session } from '@standardnotes/domain-core' +import { LegacySession, MapperInterface, Session, UseCaseInterface } from '@standardnotes/domain-core' import { SessionStorageMapper } from '@Lib/Services/Mapping/SessionStorageMapper' import { LegacySessionStorageMapper } from '@Lib/Services/Mapping/LegacySessionStorageMapper' +import { SignInWithRecoveryCodes } from '@Lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes' +import { UseCaseContainerInterface } from '@Lib/Domain/UseCase/UseCaseContainerInterface' /** How often to automatically sync, in milliseconds */ const DEFAULT_AUTO_SYNC_INTERVAL = 30_000 @@ -106,7 +112,7 @@ type ApplicationObserver = { type ObserverRemover = () => void -export class SNApplication implements ApplicationInterface, AppGroupManagedApplication { +export class SNApplication implements ApplicationInterface, AppGroupManagedApplication, UseCaseContainerInterface { onDeinit!: ExternalServices.DeinitCallback /** @@ -168,6 +174,9 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli private declare authenticatorApiService: AuthenticatorApiServiceInterface private declare authenticatorServer: AuthenticatorServerInterface private declare authenticatorManager: AuthenticatorClientInterface + private declare authManager: AuthClientInterface + + private declare _signInWithRecoveryCodes: SignInWithRecoveryCodes private internalEventBus!: ExternalServices.InternalEventBusInterface @@ -250,6 +259,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli return this.workspaceManager } + get signInWithRecoveryCodes(): UseCaseInterface { + return this._signInWithRecoveryCodes + } + public get files(): FilesClientInterface { return this.fileService } @@ -1150,6 +1163,9 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.createAuthenticatorServer() this.createAuthenticatorApiService() this.createAuthenticatorManager() + this.createAuthManager() + + this.createUseCases() } private clearServices() { @@ -1200,6 +1216,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli ;(this.authenticatorApiService as unknown) = undefined ;(this.authenticatorServer as unknown) = undefined ;(this.authenticatorManager as unknown) = undefined + ;(this.authManager as unknown) = undefined + ;(this._signInWithRecoveryCodes as unknown) = undefined this.services = [] } @@ -1212,6 +1230,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.internalEventBus.addEventHandler(this.featuresService, ExternalServices.ApiServiceEvent.MetaReceived) this.internalEventBus.addEventHandler(this.integrityService, ExternalServices.SyncEvent.SyncRequestsIntegrityCheck) this.internalEventBus.addEventHandler(this.syncService, ExternalServices.IntegrityEvent.IntegrityCheckCompleted) + this.internalEventBus.addEventHandler(this.userService, AccountEvent.SignedInOrRegistered) } private clearInternalEventBus(): void { @@ -1348,7 +1367,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli case AccountEvent.SignedOut: { await this.notifyEvent(ApplicationEvent.SignedOut) await this.prepareForDeinit() - this.deinit(this.getDeinitMode(), data?.source || DeinitSource.SignOut) + this.deinit(this.getDeinitMode(), data?.payload.source || DeinitSource.SignOut) break } default: { @@ -1739,4 +1758,23 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli private createAuthenticatorManager() { this.authenticatorManager = new AuthenticatorManager(this.authenticatorApiService, this.internalEventBus) } + + private createAuthManager() { + const authServer = new AuthServer(this.httpService) + + const authApiService = new AuthApiService(authServer) + + this.authManager = new AuthManager(authApiService, this.internalEventBus) + } + + private createUseCases() { + this._signInWithRecoveryCodes = new SignInWithRecoveryCodes( + this.authManager, + this.protocolService, + this.inMemoryStore, + this.options.crypto, + this.sessionManager, + this.internalEventBus, + ) + } } diff --git a/packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.spec.ts b/packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.spec.ts new file mode 100644 index 000000000..512b93285 --- /dev/null +++ b/packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.spec.ts @@ -0,0 +1,179 @@ +import { + AuthClientInterface, + InternalEventBusInterface, + KeyValueStoreInterface, + SessionsClientInterface, +} from '@standardnotes/services' +import { EncryptionProviderInterface } from '@standardnotes/encryption' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { AnyKeyParamsContent } from '@standardnotes/common' +import { DecryptedPayloadInterface, RootKeyContent, RootKeyInterface } from '@standardnotes/models' +import { SessionBody } from '@standardnotes/responses' + +import { SignInWithRecoveryCodes } from './SignInWithRecoveryCodes' + +describe('SignInWithRecoveryCodes', () => { + let authManager: AuthClientInterface + let protocolService: EncryptionProviderInterface + let inMemoryStore: KeyValueStoreInterface + let crypto: PureCryptoInterface + let sessionManager: SessionsClientInterface + let internalEventBus: InternalEventBusInterface + + const createUseCase = () => new SignInWithRecoveryCodes( + authManager, + protocolService, + inMemoryStore, + crypto, + sessionManager, + internalEventBus, + ) + + beforeEach(() => { + authManager = {} as jest.Mocked + authManager.recoveryKeyParams = jest.fn().mockReturnValue({ + identifier: 'test@test.te', + pw_nonce: 'pw_nonce', + created: new Date().toISOString(), + /** The event that lead to the creation of these params */ + origination: 'register', + version: '004', + }) + authManager.signInWithRecoveryCodes = jest.fn() + + const rootKey = { + serverPassword: 'foobar', + } as jest.Mocked + const payload = {} as jest.Mocked> + payload.ejected = jest.fn().mockReturnValue({ + uuid: 'uuid', + }) + rootKey.payload = payload + + protocolService = {} as jest.Mocked + protocolService.hasAccount = jest.fn() + protocolService.computeRootKey = jest.fn().mockReturnValue(rootKey) + protocolService.platformSupportsKeyDerivation = jest.fn().mockReturnValue(true) + protocolService.supportedVersions = jest.fn().mockReturnValue([ + '001', + '002', + '003', + '004', + ]) + protocolService.isVersionNewerThanLibraryVersion = jest.fn() + + inMemoryStore = {} as jest.Mocked> + inMemoryStore.setValue = jest.fn() + inMemoryStore.removeValue = jest.fn() + + crypto = {} as jest.Mocked + crypto.generateRandomKey = jest.fn() + crypto.base64URLEncode = jest.fn() + crypto.sha256 = jest.fn() + + sessionManager = {} as jest.Mocked + sessionManager.handleAuthentication = jest.fn() + + internalEventBus = {} as jest.Mocked + internalEventBus.publishSync = jest.fn() + }) + + it('should fail if an account already exists', async () => { + protocolService.hasAccount = jest.fn().mockReturnValue(true) + + const useCase = createUseCase() + const result = await useCase.execute({ recoveryCodes: 'recovery-codes', password: 'foobar', username: 'test@test.te' }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toEqual('Tried to sign in when an account already exists.') + }) + + it('should fail if recovery key params could not be retrieved', async () => { + authManager.recoveryKeyParams = jest.fn().mockReturnValue(false) + + const useCase = createUseCase() + const result = await useCase.execute({ recoveryCodes: 'recovery-codes', password: 'foobar', username: 'test@test.te' }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toEqual('Could not retrieve recovery key params') + }) + + it('should fail if key params has unsupported deriviation', async () => { + protocolService.platformSupportsKeyDerivation = jest.fn().mockReturnValue(false) + + const useCase = createUseCase() + const result = await useCase.execute({ recoveryCodes: 'recovery-codes', password: 'foobar', username: 'test@test.te' }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toEqual('Your account was created on a platform with higher security capabilities than this browser supports. If we attempted to generate your login keys here, it would take hours. Please use a browser with more up to date security capabilities, like Google Chrome or Firefox, to log in.') + }) + + it('should fail if key params has unsupported version', async () => { + protocolService.isVersionNewerThanLibraryVersion = jest.fn().mockReturnValue(true) + + authManager.recoveryKeyParams = jest.fn().mockReturnValue({ + identifier: 'test@test.te', + pw_nonce: 'pw_nonce', + created: new Date().toISOString(), + /** The event that lead to the creation of these params */ + origination: 'register', + version: '006', + }) + + protocolService.platformSupportsKeyDerivation = jest.fn().mockReturnValue(false) + + const useCase = createUseCase() + const result = await useCase.execute({ recoveryCodes: 'recovery-codes', password: 'foobar', username: 'test@test.te' }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toEqual('This version of the application does not support your newer account type. Please upgrade to the latest version of Standard Notes to sign in.') + }) + + it('should fail if key params has expired version', async () => { + protocolService.isVersionNewerThanLibraryVersion = jest.fn().mockReturnValue(false) + + authManager.recoveryKeyParams = jest.fn().mockReturnValue({ + identifier: 'test@test.te', + pw_nonce: 'pw_nonce', + created: new Date().toISOString(), + /** The event that lead to the creation of these params */ + origination: 'register', + version: '006', + }) + + protocolService.platformSupportsKeyDerivation = jest.fn().mockReturnValue(false) + + const useCase = createUseCase() + const result = await useCase.execute({ recoveryCodes: 'recovery-codes', password: 'foobar', username: 'test@test.te' }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toEqual('The protocol version associated with your account is outdated and no longer supported by this application. Please visit standardnotes.com/help/security for more information.') + }) + + it('should fail if the sign in with recovery codes fails', async () => { + authManager.signInWithRecoveryCodes = jest.fn().mockReturnValue(false) + + const useCase = createUseCase() + const result = await useCase.execute({ recoveryCodes: 'recovery-codes', password: 'foobar', username: 'test@test.te' }) + + expect(result.isFailed()).toBe(true) + expect(result.getError()).toEqual('Could not sign in with recovery codes') + }) + + it('should sign in with recovery codes', async () => { + authManager.signInWithRecoveryCodes = jest.fn().mockReturnValue({ + keyParams: {} as AnyKeyParamsContent, + session: {} as SessionBody, + user: { + uuid: '1-2-3', + email: 'test@test.te', + protocolVersion: '004', + } + }) + + const useCase = createUseCase() + const result = await useCase.execute({ recoveryCodes: 'recovery-codes', password: 'foobar', username: 'test@test.te' }) + + expect(result.isFailed()).toBe(false) + }) +}) diff --git a/packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.ts b/packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.ts new file mode 100644 index 000000000..73a18b7d6 --- /dev/null +++ b/packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodes.ts @@ -0,0 +1,107 @@ +import { Result, UseCaseInterface } from '@standardnotes/domain-core' +import { CopyPayloadWithContentOverride } from '@standardnotes/models' +import { + AccountEvent, + AuthClientInterface, + EXPIRED_PROTOCOL_VERSION, + InternalEventBusInterface, + InternalEventPublishStrategy, + KeyValueStoreInterface, + SessionsClientInterface, + StorageKey, + UNSUPPORTED_KEY_DERIVATION, + UNSUPPORTED_PROTOCOL_VERSION, +} from '@standardnotes/services' +import { CreateAnyKeyParams, EncryptionProviderInterface, SNRootKey } from '@standardnotes/encryption' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' + +import { SignInWithRecoveryCodesDTO } from './SignInWithRecoveryCodesDTO' + +export class SignInWithRecoveryCodes implements UseCaseInterface { + constructor( + private authManager: AuthClientInterface, + private protocolService: EncryptionProviderInterface, + private inMemoryStore: KeyValueStoreInterface, + private crypto: PureCryptoInterface, + private sessionManager: SessionsClientInterface, + private internalEventBus: InternalEventBusInterface, + ) {} + + async execute(dto: SignInWithRecoveryCodesDTO): Promise> { + if (this.protocolService.hasAccount()) { + return Result.fail('Tried to sign in when an account already exists.') + } + + const codeVerifier = this.crypto.generateRandomKey(256) + this.inMemoryStore.setValue(StorageKey.CodeVerifier, codeVerifier) + + const codeChallenge = this.crypto.base64URLEncode(await this.crypto.sha256(codeVerifier)) + + const recoveryKeyParams = await this.authManager.recoveryKeyParams({ + codeChallenge, + ...dto, + }) + + if (recoveryKeyParams === false) { + return Result.fail('Could not retrieve recovery key params') + } + + const rootKeyParams = CreateAnyKeyParams(recoveryKeyParams) + + if (!this.protocolService.supportedVersions().includes(rootKeyParams.version)) { + if (this.protocolService.isVersionNewerThanLibraryVersion(rootKeyParams.version)) { + return Result.fail(UNSUPPORTED_PROTOCOL_VERSION) + } + + return Result.fail(EXPIRED_PROTOCOL_VERSION) + } + + if (!this.protocolService.platformSupportsKeyDerivation(rootKeyParams)) { + return Result.fail(UNSUPPORTED_KEY_DERIVATION) + } + + const rootKey = await this.protocolService.computeRootKey(dto.password, rootKeyParams) + + const signInResult = await this.authManager.signInWithRecoveryCodes({ + codeVerifier, + recoveryCodes: dto.recoveryCodes, + username: dto.username, + password: rootKey.serverPassword as string, + }) + + if (signInResult === false) { + return Result.fail('Could not sign in with recovery codes') + } + + this.inMemoryStore.removeValue(StorageKey.CodeVerifier) + + const expandedRootKey = new SNRootKey( + CopyPayloadWithContentOverride(rootKey.payload, { + keyParams: signInResult.keyParams, + }), + ) + + await this.sessionManager.handleAuthentication({ + session: signInResult.session, + user: signInResult.user, + rootKey: expandedRootKey, + }) + + await this.internalEventBus.publishSync( + { + type: AccountEvent.SignedInOrRegistered, + payload: { + payload: { + ephemeral: false, + mergeLocal: false, + awaitSync: true, + checkIntegrity: false, + }, + }, + }, + InternalEventPublishStrategy.SEQUENCE, + ) + + return Result.ok() + } +} diff --git a/packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodesDTO.ts b/packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodesDTO.ts new file mode 100644 index 000000000..01f38b2dc --- /dev/null +++ b/packages/snjs/lib/Domain/UseCase/SignInWithRecoveryCodes/SignInWithRecoveryCodesDTO.ts @@ -0,0 +1,5 @@ +export interface SignInWithRecoveryCodesDTO { + recoveryCodes: string + username: string + password: string +} diff --git a/packages/snjs/lib/Domain/UseCase/UseCaseContainerInterface.ts b/packages/snjs/lib/Domain/UseCase/UseCaseContainerInterface.ts new file mode 100644 index 000000000..3a723df98 --- /dev/null +++ b/packages/snjs/lib/Domain/UseCase/UseCaseContainerInterface.ts @@ -0,0 +1,5 @@ +import { UseCaseInterface } from '@standardnotes/domain-core' + +export interface UseCaseContainerInterface { + get signInWithRecoveryCodes(): UseCaseInterface +} diff --git a/packages/snjs/lib/Services/Session/SessionManager.ts b/packages/snjs/lib/Services/Session/SessionManager.ts index 258fd156d..04869d3f8 100644 --- a/packages/snjs/lib/Services/Session/SessionManager.ts +++ b/packages/snjs/lib/Services/Session/SessionManager.ts @@ -24,7 +24,7 @@ import { Challenge, } from '@standardnotes/services' import { Base64String } from '@standardnotes/sncrypto-common' -import { ClientDisplayableError } from '@standardnotes/responses' +import { ClientDisplayableError, SessionBody } from '@standardnotes/responses' import { CopyPayloadWithContentOverride } from '@standardnotes/models' import { isNullOrUndefined } from '@standardnotes/utils' import { LegacySession, MapperInterface, Session, SessionToken } from '@standardnotes/domain-core' @@ -306,7 +306,12 @@ export class SNSessionManager extends AbstractService implements S throw new ApiCallError((registerResponse.data as HttpErrorResponseBody).error.message) } - await this.handleAuthResponse(registerResponse.data, rootKey, wrappingKey) + await this.handleAuthentication({ + rootKey, + wrappingKey, + session: registerResponse.data.session, + user: registerResponse.data.user, + }) return registerResponse.data } @@ -640,22 +645,30 @@ export class SNSessionManager extends AbstractService implements S this.setSession(session) } - private async handleAuthResponse(body: UserRegistrationResponseBody, rootKey: SNRootKey, wrappingKey?: SNRootKey) { + async handleAuthentication(dto: { + session: SessionBody + user: { + uuid: string + email: string + } + rootKey: SNRootKey + wrappingKey?: SNRootKey + }): Promise { const session = this.createSession( - body.session.access_token, - body.session.access_expiration, - body.session.refresh_token, - body.session.refresh_expiration, - body.session.readonly_access, + dto.session.access_token, + dto.session.access_expiration, + dto.session.refresh_token, + dto.session.refresh_expiration, + dto.session.readonly_access, ) if (session !== null) { - await this.populateSession(rootKey, body.user, session, this.apiService.getHost(), wrappingKey) + await this.populateSession(dto.rootKey, dto.user, session, this.apiService.getHost(), dto.wrappingKey) } } /** - * @deprecated use handleAuthResponse instead + * @deprecated use handleAuthentication instead */ private async handleSuccessAuthResponse( response: Responses.SignInResponse | Responses.ChangeCredentialsResponse, diff --git a/packages/snjs/mocha/lib/AppContext.js b/packages/snjs/mocha/lib/AppContext.js index 592b1f4dd..5c7d571be 100644 --- a/packages/snjs/mocha/lib/AppContext.js +++ b/packages/snjs/mocha/lib/AppContext.js @@ -220,6 +220,16 @@ export class AppContext { }) } + awaitUserPrefsSingletonResolution() { + return new Promise((resolve) => { + this.application.preferencesService.addEventObserver((eventName) => { + if (eventName === PreferencesServiceEvent.PreferencesChanged) { + resolve() + } + }) + }) + } + async launch({ awaitDatabaseLoad = true, receiveChallenge } = { awaitDatabaseLoad: true }) { await this.application.prepareForLaunch({ receiveChallenge: receiveChallenge || this.handleChallenge, diff --git a/packages/snjs/mocha/preferences.test.js b/packages/snjs/mocha/preferences.test.js index 346a1ab89..a952a0dba 100644 --- a/packages/snjs/mocha/preferences.test.js +++ b/packages/snjs/mocha/preferences.test.js @@ -74,10 +74,16 @@ describe('preferences', function () { await register.call(this) await this.application.setPreference('editorLeft', 300) await this.application.sync.sync() - this.application = await Factory.signOutApplicationAndReturnNew(this.application) + + this.application = await this.context.signout() + await this.application.setPreference('editorLeft', 200) await this.application.signIn(this.email, this.password) + + const promise = this.context.awaitUserPrefsSingletonResolution() await this.application.sync.sync({ awaitAll: true }) + await promise + const editorLeft = this.application.getPreference('editorLeft') expect(editorLeft).to.equal(300) }) diff --git a/yarn.lock b/yarn.lock index c6b6eec89..4d8727375 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6005,6 +6005,7 @@ __metadata: "@standardnotes/api": "workspace:^" "@standardnotes/auth": ^3.19.4 "@standardnotes/common": ^1.45.0 + "@standardnotes/domain-core": ^1.11.0 "@standardnotes/encryption": "workspace:^" "@standardnotes/files": "workspace:^" "@standardnotes/models": "workspace:^"