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 <mo@standardnotes.com>
This commit is contained in:
Karol Sójko
2023-01-09 06:52:56 +01:00
committed by GitHub
parent 5f09fc74da
commit be028ff87b
37 changed files with 838 additions and 81 deletions

View File

@@ -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'

View File

@@ -0,0 +1,28 @@
import { AnyKeyParamsContent } from '@standardnotes/common'
import { SessionBody } from '@standardnotes/responses'
export interface AuthClientInterface {
generateRecoveryCodes(): Promise<string | false>
recoveryKeyParams(dto: {
username: string
codeChallenge: string
recoveryCodes: string
}): Promise<AnyKeyParamsContent | false>
signInWithRecoveryCodes(dto: {
username: string
password: string
codeVerifier: string
recoveryCodes: string
}): Promise<
| {
keyParams: AnyKeyParamsContent
session: SessionBody
user: {
uuid: string
email: string
protocolVersion: string
}
}
| false
>
}

View File

@@ -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<string | false> {
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<AnyKeyParamsContent | false> {
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
}
}
}

View File

@@ -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<SessionManagerResponse>
handleAuthentication(dto: {
session: SessionBody
user: {
uuid: string
email: string
}
rootKey: SNRootKey
wrappingKey?: SNRootKey
}): Promise<void>
}

View File

@@ -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<AccountEvent, AccountEventData> implements UserClientInterface {
export interface AccountEventData {
payload: Either<SignedInOrRegisteredEventPayload, SignedOutEventPayload>
}
export class UserService
extends AbstractService<AccountEvent, AccountEventData>
implements UserClientInterface, InternalEventHandlerInterface
{
private signingIn = false
private registering = false
@@ -60,6 +76,43 @@ export class UserService extends AbstractService<AccountEvent, AccountEventData>
super(internalEventBus)
}
async handleEvent(event: InternalEventInterface): Promise<void> {
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<AccountEvent, AccountEventData>
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<AccountEvent, AccountEventData>
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<AccountEvent, AccountEventData>
)
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<AccountEvent, AccountEventData>
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<AccountEvent, AccountEventData>
public async populateSessionFromDemoShareToken(token: Base64String): Promise<void> {
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) {

View File

@@ -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'