mirror of
https://github.com/standardnotes/web.git
synced 2024-10-27 16:31:40 +03:00
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:
parent
5f09fc74da
commit
be028ff87b
5
packages/api/src/Domain/Client/Auth/AuthApiOperations.ts
Normal file
5
packages/api/src/Domain/Client/Auth/AuthApiOperations.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export enum AuthApiOperations {
|
||||
GenerateRecoveryCodes,
|
||||
GetRecoveryKeyParams,
|
||||
SignInWithRecoveryCodes,
|
||||
}
|
89
packages/api/src/Domain/Client/Auth/AuthApiService.ts
Normal file
89
packages/api/src/Domain/Client/Auth/AuthApiService.ts
Normal file
@ -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<AuthApiOperations, boolean>
|
||||
|
||||
constructor(private authServer: AuthServerInterface) {
|
||||
this.operationsInProgress = new Map()
|
||||
}
|
||||
|
||||
async generateRecoveryCodes(): Promise<GenerateRecoveryCodesResponse> {
|
||||
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<RecoveryKeyParamsResponse> {
|
||||
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<SignInWithRecoveryCodesResponse> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
import {
|
||||
GenerateRecoveryCodesResponse,
|
||||
RecoveryKeyParamsResponse,
|
||||
SignInWithRecoveryCodesResponse,
|
||||
} from '../../Response'
|
||||
|
||||
export interface AuthApiServiceInterface {
|
||||
generateRecoveryCodes(): Promise<GenerateRecoveryCodesResponse>
|
||||
recoveryKeyParams(dto: {
|
||||
username: string
|
||||
codeChallenge: string
|
||||
recoveryCodes: string
|
||||
}): Promise<RecoveryKeyParamsResponse>
|
||||
signInWithRecoveryCodes(dto: {
|
||||
username: string
|
||||
password: string
|
||||
codeVerifier: string
|
||||
recoveryCodes: string
|
||||
}): Promise<SignInWithRecoveryCodesResponse>
|
||||
}
|
@ -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'
|
||||
|
@ -1 +1 @@
|
||||
export type HttpRequestParams = Record<string, unknown>
|
||||
export type HttpRequestParams = unknown
|
||||
|
@ -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<string, unknown>)
|
||||
.map((key) => {
|
||||
return key + '=' + encodeURIComponent(params[key] as string)
|
||||
return key + '=' + encodeURIComponent((params as Record<string, unknown>)[key] as string)
|
||||
})
|
||||
.join('&')
|
||||
|
||||
|
@ -0,0 +1,6 @@
|
||||
export interface RecoveryKeyParamsRequestParams {
|
||||
apiVersion: string
|
||||
username: string
|
||||
codeChallenge: string
|
||||
recoveryCodes: string
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
export interface SignInWithRecoveryCodesRequestParams {
|
||||
apiVersion: string
|
||||
username: string
|
||||
password: string
|
||||
codeVerifier: string
|
||||
recoveryCodes: string
|
||||
}
|
@ -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'
|
||||
|
@ -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<GenerateRecoveryCodesResponseBody, HttpErrorResponseBody>
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
export interface GenerateRecoveryCodesResponseBody {
|
||||
recoveryCodes: string
|
||||
}
|
@ -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<RecoveryKeyParamsResponseBody, HttpErrorResponseBody>
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { KeyParamsData } from '@standardnotes/responses'
|
||||
|
||||
export interface RecoveryKeyParamsResponseBody {
|
||||
keyParams: KeyParamsData
|
||||
}
|
@ -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<SignInWithRecoveryCodesResponseBody, HttpErrorResponseBody>
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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'
|
||||
|
33
packages/api/src/Domain/Server/Auth/AuthServer.ts
Normal file
33
packages/api/src/Domain/Server/Auth/AuthServer.ts
Normal file
@ -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<GenerateRecoveryCodesResponse> {
|
||||
const response = await this.httpService.post(Paths.v1.generateRecoveryCodes)
|
||||
|
||||
return response as GenerateRecoveryCodesResponse
|
||||
}
|
||||
|
||||
async recoveryKeyParams(params: RecoveryKeyParamsRequestParams): Promise<RecoveryKeyParamsResponse> {
|
||||
const response = await this.httpService.post(Paths.v1.recoveryKeyParams, params)
|
||||
|
||||
return response as RecoveryKeyParamsResponse
|
||||
}
|
||||
|
||||
async signInWithRecoveryCodes(
|
||||
params: SignInWithRecoveryCodesRequestParams,
|
||||
): Promise<SignInWithRecoveryCodesResponse> {
|
||||
const response = await this.httpService.post(Paths.v1.signInWithRecoveryCodes, params)
|
||||
|
||||
return response as SignInWithRecoveryCodesResponse
|
||||
}
|
||||
}
|
12
packages/api/src/Domain/Server/Auth/AuthServerInterface.ts
Normal file
12
packages/api/src/Domain/Server/Auth/AuthServerInterface.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { RecoveryKeyParamsRequestParams, SignInWithRecoveryCodesRequestParams } from '../../Request'
|
||||
import {
|
||||
GenerateRecoveryCodesResponse,
|
||||
RecoveryKeyParamsResponse,
|
||||
SignInWithRecoveryCodesResponse,
|
||||
} from '../../Response'
|
||||
|
||||
export interface AuthServerInterface {
|
||||
generateRecoveryCodes(): Promise<GenerateRecoveryCodesResponse>
|
||||
recoveryKeyParams(params: RecoveryKeyParamsRequestParams): Promise<RecoveryKeyParamsResponse>
|
||||
signInWithRecoveryCodes(params: SignInWithRecoveryCodesRequestParams): Promise<SignInWithRecoveryCodesResponse>
|
||||
}
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
export * from './Auth/AuthServer'
|
||||
export * from './Auth/AuthServerInterface'
|
||||
export * from './Authenticator/AuthenticatorServer'
|
||||
export * from './Authenticator/AuthenticatorServerInterface'
|
||||
export * from './Subscription/SubscriptionServer'
|
||||
|
@ -32,6 +32,9 @@ export interface EncryptionProviderInterface {
|
||||
getKeyEmbeddedKeyParams(key: EncryptedPayloadInterface): SNRootKeyParams | undefined
|
||||
computeRootKey(password: string, keyParams: SNRootKeyParams): Promise<RootKeyInterface>
|
||||
supportedVersions(): ProtocolVersion[]
|
||||
isVersionNewerThanLibraryVersion(version: ProtocolVersion): boolean
|
||||
platformSupportsKeyDerivation(keyParams: SNRootKeyParams): boolean
|
||||
computeWrappingKey(passcode: string): Promise<RootKeyInterface>
|
||||
getUserVersion(): ProtocolVersion | undefined
|
||||
decryptBackupFile(
|
||||
file: BackupFile,
|
||||
|
@ -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:^",
|
||||
|
@ -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'
|
||||
|
28
packages/services/src/Domain/Auth/AuthClientInterface.ts
Normal file
28
packages/services/src/Domain/Auth/AuthClientInterface.ts
Normal 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
|
||||
>
|
||||
}
|
82
packages/services/src/Domain/Auth/AuthManager.ts
Normal file
82
packages/services/src/Domain/Auth/AuthManager.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
}
|
||||
|
@ -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, {
|
||||
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) {
|
||||
|
@ -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'
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
@ -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()
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
export interface SignInWithRecoveryCodesDTO {
|
||||
recoveryCodes: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { UseCaseInterface } from '@standardnotes/domain-core'
|
||||
|
||||
export interface UseCaseContainerInterface {
|
||||
get signInWithRecoveryCodes(): UseCaseInterface<void>
|
||||
}
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
})
|
||||
|
@ -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:^"
|
||||
|
Loading…
Reference in New Issue
Block a user