diff --git a/angular/src/services/jslib-services.module.ts b/angular/src/services/jslib-services.module.ts index 8523818abb6..0133c80287a 100644 --- a/angular/src/services/jslib-services.module.ts +++ b/angular/src/services/jslib-services.module.ts @@ -76,6 +76,8 @@ import { PasswordRepromptService } from "./passwordReprompt.service"; import { UnauthGuardService } from "./unauth-guard.service"; import { ValidationService } from "./validation.service"; +import { Account, AccountFactory } from "jslib-common/models/domain/account"; + @NgModule({ declarations: [], providers: [ @@ -325,7 +327,19 @@ import { ValidationService } from "./validation.service"; }, { provide: StateServiceAbstraction, - useClass: StateService, + useFactory: ( + storageService: StorageServiceAbstraction, + secureStorageService: StorageServiceAbstraction, + logService: LogService, + stateMigrationService: StateMigrationServiceAbstraction + ) => + new StateService( + storageService, + secureStorageService, + logService, + stateMigrationService, + new AccountFactory(Account) + ), deps: [ StorageServiceAbstraction, "SECURE_STORAGE", diff --git a/common/src/models/domain/account.ts b/common/src/models/domain/account.ts index 565b86e6a3c..9af9f0534f0 100644 --- a/common/src/models/domain/account.ts +++ b/common/src/models/domain/account.ts @@ -183,3 +183,15 @@ export class Account { }); } } + +export class AccountFactory { + private accountConstructor: new (init: Partial) => T; + + constructor(accountConstructor: new (init: Partial) => T) { + this.accountConstructor = accountConstructor; + } + + create(args: Partial) { + return new this.accountConstructor(args); + } +} diff --git a/common/src/models/domain/globalState.ts b/common/src/models/domain/globalState.ts index 24dc54ae68f..f14d48d3e58 100644 --- a/common/src/models/domain/globalState.ts +++ b/common/src/models/domain/globalState.ts @@ -4,7 +4,6 @@ import { EnvironmentUrls } from "./environmentUrls"; export class GlobalState { enableAlwaysOnTop?: boolean; installedVersion?: string; - lastActive?: number; locale?: string = "en"; openAtLogin?: boolean; organizationInvitation?: any; diff --git a/common/src/models/domain/state.ts b/common/src/models/domain/state.ts index d9087264e36..16edca2a9aa 100644 --- a/common/src/models/domain/state.ts +++ b/common/src/models/domain/state.ts @@ -5,4 +5,5 @@ export class State { accounts: { [userId: string]: TAccount } = {}; globals: GlobalState = new GlobalState(); activeUserId: string; + authenticatedAccounts: string[] = []; } diff --git a/common/src/services/state.service.ts b/common/src/services/state.service.ts index 35dfca3f746..841b190251d 100644 --- a/common/src/services/state.service.ts +++ b/common/src/services/state.service.ts @@ -1,12 +1,6 @@ import { StateService as StateServiceAbstraction } from "../abstractions/state.service"; -import { - Account, - AccountData, - AccountKeys, - AccountProfile, - AccountTokens, -} from "../models/domain/account"; +import { Account, AccountData, AccountFactory } from "../models/domain/account"; import { LogService } from "../abstractions/log.service"; import { StorageService } from "../abstractions/storage.service"; @@ -43,6 +37,18 @@ import { BehaviorSubject } from "rxjs"; import { StateMigrationService } from "../abstractions/stateMigration.service"; import { EnvironmentUrls } from "../models/domain/environmentUrls"; +const keys = { + global: "global", + authenticatedAccounts: "authenticatedAccounts", + activeUserId: "activeUserId", +}; + +const partialKeys = { + autoKey: "_masterkey_auto", + biometricKey: "_masterkey_biometric", + masterKey: "_masterkey", +}; + export class StateService implements StateServiceAbstraction { @@ -55,32 +61,47 @@ export class StateService protected storageService: StorageService, protected secureStorageService: StorageService, protected logService: LogService, - protected stateMigrationService: StateMigrationService + protected stateMigrationService: StateMigrationService, + protected accountFactory: AccountFactory ) {} async init(): Promise { if (await this.stateMigrationService.needsMigration()) { await this.stateMigrationService.migrate(); } - if (this.state.activeUserId == null) { - await this.loadStateFromDisk(); - } + + await this.initAccountState(); } - async loadStateFromDisk() { - if ((await this.getActiveUserIdFromStorage()) != null) { - const diskState = await this.storageService.get>( - "state", - await this.defaultOnDiskOptions() - ); - this.state = diskState; - await this.pruneInMemoryAccounts(); - await this.pushAccounts(); + async initAccountState() { + this.state.authenticatedAccounts = + (await this.storageService.get(keys.authenticatedAccounts)) ?? []; + for (const i in this.state.authenticatedAccounts) { + if (i != null) { + await this.syncAccountFromDisk(this.state.authenticatedAccounts[i]); + } } + const storedActiveUser = await this.storageService.get(keys.activeUserId); + if (storedActiveUser != null) { + this.state.activeUserId = storedActiveUser; + } + await this.pushAccounts(); + this.activeAccount.next(this.state.activeUserId); + } + + async syncAccountFromDisk(userId: string) { + if (userId == null) { + return; + } + this.state.accounts[userId] = this.createAccount(); + const diskAccount = await this.getAccountFromDisk({ userId: userId }); + this.state.accounts[userId].profile = diskAccount.profile; } async addAccount(account: TAccount) { - await this.setAccountEnvironmentUrls(account); + account = await this.setAccountEnvironmentUrls(account); + this.state.authenticatedAccounts.push(account.profile.userId); + this.storageService.save(keys.authenticatedAccounts, this.state.authenticatedAccounts); this.state.accounts[account.profile.userId] = account; await this.scaffoldNewAccountStorage(account); await this.setActiveUser(account.profile.userId); @@ -88,36 +109,21 @@ export class StateService } async setActiveUser(userId: string): Promise { + this.clearDecryptedDataForActiveUser(); this.state.activeUserId = userId; - const storedState = await this.storageService.get>( - "state", - await this.defaultOnDiskOptions() - ); - storedState.activeUserId = userId; - await this.saveStateToStorage(storedState, await this.defaultOnDiskOptions()); - await this.pushAccounts(); + await this.storageService.save(keys.activeUserId, userId); this.activeAccount.next(this.state.activeUserId); + await this.pushAccounts(); } async clean(options?: StorageOptions): Promise { - // Find and set the next active user if any exists - await this.setAccessToken(null, { userId: options?.userId }); - if (options?.userId == null || options.userId === (await this.getUserId())) { - for (const userId in this.state.accounts) { - if (userId == null) { - continue; - } - if (await this.getIsAuthenticated({ userId: userId })) { - await this.setActiveUser(userId); - break; - } - await this.setActiveUser(null); - } + options = this.reconcileOptions(options, this.defaultInMemoryOptions); + await this.deAuthenticateAccount(options.userId); + if (options.userId === this.state.activeUserId) { + await this.dynamicallySetActiveUser(); } - await this.removeAccountFromSessionStorage(options?.userId); - await this.removeAccountFromLocalStorage(options?.userId); - await this.removeAccountFromSecureStorage(options?.userId); + await this.removeAccountFromDisk(options?.userId); this.removeAccountFromMemory(options?.userId); await this.pushAccounts(); } @@ -425,7 +431,7 @@ export class StateService if (options?.userId == null) { return null; } - return await this.secureStorageService.get(`${options.userId}_masterkey_auto`, options); + return await this.secureStorageService.get(`${options.userId}${partialKeys.autoKey}`, options); } async setCryptoMasterKeyAuto(value: string, options?: StorageOptions): Promise { @@ -436,7 +442,7 @@ export class StateService if (options?.userId == null) { return; } - await this.secureStorageService.save(`${options.userId}_masterkey_auto`, value, options); + await this.secureStorageService.save(`${options.userId}${partialKeys.autoKey}`, value, options); } async getCryptoMasterKeyB64(options?: StorageOptions): Promise { @@ -444,7 +450,10 @@ export class StateService if (options?.userId == null) { return null; } - return await this.secureStorageService.get(`${options?.userId}_masterkey`, options); + return await this.secureStorageService.get( + `${options?.userId}${partialKeys.masterKey}`, + options + ); } async setCryptoMasterKeyB64(value: string, options?: StorageOptions): Promise { @@ -452,7 +461,11 @@ export class StateService if (options?.userId == null) { return; } - await this.secureStorageService.save(`${options.userId}_masterkey`, value, options); + await this.secureStorageService.save( + `${options.userId}${partialKeys.masterKey}`, + value, + options + ); } async getCryptoMasterKeyBiometric(options?: StorageOptions): Promise { @@ -463,7 +476,10 @@ export class StateService if (options?.userId == null) { return null; } - return await this.secureStorageService.get(`${options.userId}_masterkey_biometric`, options); + return await this.secureStorageService.get( + `${options.userId}${partialKeys.biometricKey}`, + options + ); } async hasCryptoMasterKeyBiometric(options?: StorageOptions): Promise { @@ -474,7 +490,10 @@ export class StateService if (options?.userId == null) { return false; } - return await this.secureStorageService.has(`${options.userId}_masterkey_biometric`, options); + return await this.secureStorageService.has( + `${options.userId}${partialKeys.biometricKey}`, + options + ); } async setCryptoMasterKeyBiometric(value: string, options?: StorageOptions): Promise { @@ -485,7 +504,11 @@ export class StateService if (options?.userId == null) { return; } - await this.secureStorageService.save(`${options.userId}_masterkey_biometric`, value, options); + await this.secureStorageService.save( + `${options.userId}${partialKeys.biometricKey}`, + value, + options + ); } async getDecodedToken(options?: StorageOptions): Promise { @@ -866,20 +889,16 @@ export class StateService } async getEmail(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.profile?.email; + return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) + ?.profile?.email; } async setEmail(value: string, options?: StorageOptions): Promise { const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()) + this.reconcileOptions(options, this.defaultInMemoryOptions) ); account.profile.email = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()) - ); + await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); } async getEmailVerified(options?: StorageOptions): Promise { @@ -1526,14 +1545,9 @@ export class StateService } async getLastActive(options?: StorageOptions): Promise { - const lastActive = ( + return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) )?.profile?.lastActive; - return ( - lastActive ?? - (await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) - ?.lastActive - ); } async setLastActive(value: number, options?: StorageOptions): Promise { @@ -1547,15 +1561,6 @@ export class StateService this.reconcileOptions(options, await this.defaultOnDiskOptions()) ); } - - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskOptions()) - ); - globals.lastActive = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskOptions()) - ); } async getLastSync(options?: StorageOptions): Promise { @@ -2091,7 +2096,7 @@ export class StateService } protected async getGlobalsFromDisk(options: StorageOptions): Promise { - return (await this.storageService.get>("state", options))?.globals; + return await this.storageService.get(keys.global, options); } protected saveGlobalsToMemory(globals: GlobalState): void { @@ -2100,15 +2105,9 @@ export class StateService protected async saveGlobalsToDisk(globals: GlobalState, options: StorageOptions): Promise { if (options.useSecureStorage) { - const state = - (await this.secureStorageService.get>("state", options)) ?? new State(); - state.globals = globals; - await this.secureStorageService.save("state", state, options); + await this.secureStorageService.save(keys.global, globals, options); } else { - const state = - (await this.storageService.get>("state", options)) ?? new State(); - state.globals = globals; - await this.saveStateToStorage(state, options); + await this.storageService.save(keys.global, globals, options); } } @@ -2147,15 +2146,15 @@ export class StateService return null; } - const state = options?.useSecureStorage - ? (await this.secureStorageService.get>("state", options)) ?? - (await this.storageService.get>( - "state", + const account = options?.useSecureStorage + ? (await this.secureStorageService.get(options.userId, options)) ?? + (await this.storageService.get( + options.userId, this.reconcileOptions(options, { htmlStorageLocation: HtmlStorageLocation.Local }) )) - : await this.storageService.get>("state", options); + : await this.storageService.get(options.userId, options); - return state?.accounts[options?.userId ?? this.state.activeUserId]; + return account; } protected useMemory(storageLocation: StorageLocation) { @@ -2183,11 +2182,7 @@ export class StateService ? this.secureStorageService : this.storageService; - const state = - (await storageLocation.get>("state", options)) ?? new State(); - state.accounts[account.profile.userId] = account; - - await storageLocation.save("state", state, options); + await storageLocation.save(`${options.userId}`, account, options); } protected async saveAccountToMemory(account: TAccount): Promise { @@ -2203,47 +2198,57 @@ export class StateService await this.scaffoldNewAccountMemoryStorage(account); } + // TODO: There is a tech debt item for splitting up these methods - only Web uses multiple storage locations in its storageService. + // For now these methods exist with some redundancy to facilitate this special web requirement. protected async scaffoldNewAccountLocalStorage(account: TAccount): Promise { - const storedState = - (await this.storageService.get>( - "state", - await this.defaultOnDiskLocalOptions() - )) ?? new State(); - const storedAccount = storedState.accounts[account.profile.userId]; - if (storedAccount != null) { + const storedAccount = await this.storageService.get( + account.profile.userId, + await this.defaultOnDiskLocalOptions() + ); + if (storedAccount?.settings != null) { + // EnvironmentUrls are set before authenticating and should override whatever is stored from last session + storedAccount.settings.environmentUrls = account.settings.environmentUrls; account.settings = storedAccount.settings; } - storedState.accounts[account.profile.userId] = account; - await this.saveStateToStorage(storedState, await this.defaultOnDiskLocalOptions()); + await this.storageService.save( + account.profile.userId, + account, + await this.defaultOnDiskLocalOptions() + ); } protected async scaffoldNewAccountMemoryStorage(account: TAccount): Promise { - const storedState = - (await this.storageService.get>( - "state", - await this.defaultOnDiskMemoryOptions() - )) ?? new State(); - const storedAccount = storedState.accounts[account.profile.userId]; - if (storedAccount != null) { + const storedAccount = await this.storageService.get( + account.profile.userId, + await this.defaultOnDiskMemoryOptions() + ); + if (storedAccount?.settings != null) { + storedAccount.settings.environmentUrls = account.settings.environmentUrls; account.settings = storedAccount.settings; } - storedState.accounts[account.profile.userId] = account; - await this.saveStateToStorage(storedState, await this.defaultOnDiskMemoryOptions()); + await this.storageService.save( + account.profile.userId, + account, + await this.defaultOnDiskMemoryOptions() + ); } protected async scaffoldNewAccountSessionStorage(account: TAccount): Promise { - const storedState = - (await this.storageService.get>( - "state", - await this.defaultOnDiskOptions() - )) ?? new State(); - const storedAccount = storedState.accounts[account.profile.userId]; - if (storedAccount != null) { + const storedAccount = await this.storageService.get( + account.profile.userId, + await this.defaultOnDiskOptions() + ); + if (storedAccount?.settings != null) { + storedAccount.settings.environmentUrls = account.settings.environmentUrls; account.settings = storedAccount.settings; } - storedState.accounts[account.profile.userId] = account; - await this.saveStateToStorage(storedState, await this.defaultOnDiskOptions()); + await this.storageService.save( + account.profile.userId, + account, + await this.defaultOnDiskOptions() + ); } + // protected async pushAccounts(): Promise { await this.pruneInMemoryAccounts(); @@ -2313,34 +2318,33 @@ export class StateService } protected async getActiveUserIdFromStorage(): Promise { - const state = await this.storageService.get>("state"); - return state?.activeUserId; + return await this.storageService.get(keys.activeUserId); } protected async removeAccountFromLocalStorage( userId: string = this.state.activeUserId ): Promise { - const state = await this.storageService.get>("state", { + const storedAccount = await this.storageService.get(userId, { htmlStorageLocation: HtmlStorageLocation.Local, }); - if (state?.accounts[userId] == null) { - return; - } - state.accounts[userId] = this.resetAccount(state.accounts[userId]); - await this.saveStateToStorage(state, await this.defaultOnDiskLocalOptions()); + await this.storageService.save( + userId, + this.resetAccount(storedAccount), + await this.defaultOnDiskLocalOptions() + ); } protected async removeAccountFromSessionStorage( userId: string = this.state.activeUserId ): Promise { - const state = await this.storageService.get>("state", { + const storedAccount = await this.storageService.get(userId, { htmlStorageLocation: HtmlStorageLocation.Session, }); - if (state?.accounts[userId] == null) { - return; - } - state.accounts[userId] = this.resetAccount(state.accounts[userId]); - await this.saveStateToStorage(state, await this.defaultOnDiskOptions()); + await this.storageService.save( + userId, + this.resetAccount(storedAccount), + await this.defaultOnDiskOptions() + ); } protected async removeAccountFromSecureStorage( @@ -2355,13 +2359,6 @@ export class StateService delete this.state.accounts[userId]; } - protected async saveStateToStorage( - state: State, - options: StorageOptions - ): Promise { - await this.storageService.save("state", state, options); - } - protected async pruneInMemoryAccounts() { // We preserve settings for logged out accounts, but we don't want to consider them when thinking about active account state for (const userId in this.state.accounts) { @@ -2371,13 +2368,10 @@ export class StateService } } - // settings persist even on reset + // settings persist even on reset, and are not effected by this method protected resetAccount(account: TAccount) { - account.data = new AccountData(); - account.keys = new AccountKeys(); - account.profile = new AccountProfile(); - account.tokens = new AccountTokens(); - return account; + const persistentAccountInformation = { settings: account.settings }; + return Object.assign(this.createAccount(), persistentAccountInformation); } protected async setAccountEnvironmentUrls(account: TAccount): Promise { @@ -2389,4 +2383,44 @@ export class StateService options = this.reconcileOptions(options, await this.defaultOnDiskOptions()); return (await this.getGlobals(options)).environmentUrls ?? new EnvironmentUrls(); } + + protected clearDecryptedDataForActiveUser() { + const userId = this.state.activeUserId; + if (userId == null) { + return; + } + this.state.accounts[userId].data = new AccountData(); + } + + protected createAccount(init: Partial = null): TAccount { + return this.accountFactory.create(init); + } + + protected async deAuthenticateAccount(userId: string) { + await this.setAccessToken(null, { userId: userId }); + const index = this.state.authenticatedAccounts.indexOf(userId); + if (index > -1) { + this.state.authenticatedAccounts.splice(index, 1); + await this.storageService.save(keys.authenticatedAccounts, this.state.authenticatedAccounts); + } + } + + protected async removeAccountFromDisk(userId: string) { + await this.removeAccountFromSessionStorage(userId); + await this.removeAccountFromLocalStorage(userId); + await this.removeAccountFromSecureStorage(userId); + } + + protected async dynamicallySetActiveUser() { + for (const userId in this.state.accounts) { + if (userId == null) { + continue; + } + if (await this.getIsAuthenticated({ userId: userId })) { + await this.setActiveUser(userId); + break; + } + await this.setActiveUser(null); + } + } } diff --git a/common/src/services/stateMigration.service.ts b/common/src/services/stateMigration.service.ts index 9d422e7006b..59db60722e7 100644 --- a/common/src/services/stateMigration.service.ts +++ b/common/src/services/stateMigration.service.ts @@ -113,6 +113,18 @@ const v1KeyPrefixes = { settings: "settings_", }; +const keys = { + global: "global", + authenticatedAccounts: "authenticatedAccounts", + activeUserId: "activeUserId", +}; + +const partialKeys = { + autoKey: "_masterkey_auto", + biometricKey: "_masterkey_biometric", + masterKey: "_masterkey", +}; + export class StateMigrationService { constructor( protected storageService: StorageService, @@ -121,17 +133,16 @@ export class StateMigrationService { async needsMigration(): Promise { const currentStateVersion = ( - await this.storageService.get>("state", { + await this.storageService.get(keys.global, { htmlStorageLocation: HtmlStorageLocation.Local, }) - )?.globals?.stateVersion; + )?.stateVersion; return currentStateVersion == null || currentStateVersion < StateVersion.Latest; } async migrate(): Promise { let currentStateVersion = - (await this.storageService.get>("state"))?.globals?.stateVersion ?? - StateVersion.One; + (await this.storageService.get(keys.global))?.stateVersion ?? StateVersion.One; while (currentStateVersion < StateVersion.Latest) { switch (currentStateVersion) { case StateVersion.One: @@ -152,8 +163,10 @@ export class StateMigrationService { globals: new GlobalState(), accounts: {}, activeUserId: null, + authenticatedAccounts: [], } : { + authenticatedAccounts: [userId], activeUserId: userId, globals: { biometricAwaitingAcceptance: await this.storageService.get( @@ -182,7 +195,6 @@ export class StateMigrationService { v1Keys.installedVersion, options ), - lastActive: await this.storageService.get(v1Keys.lastActive, options), locale: await this.storageService.get(v1Keys.locale, options), loginRedirect: null, mainWindowSize: null, @@ -490,12 +502,19 @@ export class StateMigrationService { initialState.globals.environmentUrls = (await this.storageService.get(v1Keys.environmentUrls, options)) ?? new EnvironmentUrls(); - - await this.storageService.save("state", initialState, options); + await this.storageService.save(keys.global, initialState.globals, options); + await this.storageService.save(keys.activeUserId, initialState.activeUserId, options); + if (initialState.activeUserId != null) { + await this.storageService.save( + initialState.activeUserId, + initialState.accounts[initialState.activeUserId] + ); + } + await this.storageService.save(keys.authenticatedAccounts, initialState.authenticatedAccounts); if (await this.secureStorageService.has(v1Keys.key, { keySuffix: "biometric" })) { await this.secureStorageService.save( - `${userId}_masterkey_biometric`, + `${userId}${partialKeys.biometricKey}`, await this.secureStorageService.get(v1Keys.key, { keySuffix: "biometric" }), { keySuffix: "biometric" } ); @@ -504,7 +523,7 @@ export class StateMigrationService { if (await this.secureStorageService.has(v1Keys.key, { keySuffix: "auto" })) { await this.secureStorageService.save( - `${userId}_masterkey_auto`, + `${userId}${partialKeys.autoKey}`, await this.secureStorageService.get(v1Keys.key, { keySuffix: "auto" }), { keySuffix: "auto" } ); @@ -513,7 +532,7 @@ export class StateMigrationService { if (await this.secureStorageService.has(v1Keys.key)) { await this.secureStorageService.save( - `${userId}_masterkey`, + `${userId}${partialKeys.masterKey}`, await this.secureStorageService.get(v1Keys.key) ); await this.secureStorageService.remove(v1Keys.key);