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,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<void> {
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,
)
}
}

View File

@@ -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<string>
let crypto: PureCryptoInterface
let sessionManager: SessionsClientInterface
let internalEventBus: InternalEventBusInterface
const createUseCase = () => new SignInWithRecoveryCodes(
authManager,
protocolService,
inMemoryStore,
crypto,
sessionManager,
internalEventBus,
)
beforeEach(() => {
authManager = {} as jest.Mocked<AuthClientInterface>
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<RootKeyInterface>
const payload = {} as jest.Mocked<DecryptedPayloadInterface<RootKeyContent>>
payload.ejected = jest.fn().mockReturnValue({
uuid: 'uuid',
})
rootKey.payload = payload
protocolService = {} as jest.Mocked<EncryptionProviderInterface>
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<KeyValueStoreInterface<string>>
inMemoryStore.setValue = jest.fn()
inMemoryStore.removeValue = jest.fn()
crypto = {} as jest.Mocked<PureCryptoInterface>
crypto.generateRandomKey = jest.fn()
crypto.base64URLEncode = jest.fn()
crypto.sha256 = jest.fn()
sessionManager = {} as jest.Mocked<SessionsClientInterface>
sessionManager.handleAuthentication = jest.fn()
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
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)
})
})

View File

@@ -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<void> {
constructor(
private authManager: AuthClientInterface,
private protocolService: EncryptionProviderInterface,
private inMemoryStore: KeyValueStoreInterface<string>,
private crypto: PureCryptoInterface,
private sessionManager: SessionsClientInterface,
private internalEventBus: InternalEventBusInterface,
) {}
async execute(dto: SignInWithRecoveryCodesDTO): Promise<Result<void>> {
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()
}
}

View File

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

View File

@@ -0,0 +1,5 @@
import { UseCaseInterface } from '@standardnotes/domain-core'
export interface UseCaseContainerInterface {
get signInWithRecoveryCodes(): UseCaseInterface<void>
}

View File

@@ -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<SessionEvent> 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<SessionEvent> 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<void> {
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,

View File

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

View File

@@ -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)
})