1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-20 02:03:39 +00:00

[PM-6688] Use AccountService as account source (#8893)

* Use account service to track accounts and active account

* Remove state service active account Observables.

* Add email verified to account service

* Do not store account info on logged out accounts

* Add account activity tracking to account service

* Use last account activity from account service

* migrate or replicate account service data

* Add `AccountActivityService` that handles storing account last active data

* Move active and next active user to account service

* Remove authenticated accounts from state object

* Fold account activity into account service

* Fix builds

* Fix desktop app switch

* Fix logging out non active user

* Expand helper to handle new authenticated accounts location

* Prefer view observable to tons of async pipes

* Fix `npm run test:types`

* Correct user activity sorting test

* Be more precise about log out messaging

* Fix dev compare errors

All stored values are serializable, the next step wasn't necessary and was erroring on some types that lack `toString`.

* If the account in unlocked on load of lock component, navigate away from lock screen

* Handle no users case for auth service statuses

* Specify account to switch to

* Filter active account out of inactive accounts

* Prefer constructor init

* Improve comparator

* Use helper methods internally

* Fixup component tests

* Clarify name

* Ensure accounts object has only valid userIds

* Capitalize const values

* Prefer descriptive, single-responsibility guards

* Update libs/common/src/state-migrations/migrate.ts

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>

* Fix merge

* Add user Id validation

activity for undefined was being set, which was resulting in requests for the auth status of `"undefined"` (string) userId, due to key enumeration. These changes stop that at both locations, as well as account add for good measure.

---------

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
This commit is contained in:
Matt Gibson
2024-04-30 09:13:02 -04:00
committed by GitHub
parent 61d079cc34
commit c70a5aa024
67 changed files with 1380 additions and 618 deletions

View File

@@ -31,10 +31,12 @@ describe("EnvironmentService", () => {
[testUser]: {
name: "name",
email: "email",
emailVerified: false,
},
[alternateTestUser]: {
name: "name",
email: "email",
emailVerified: false,
},
});
stateProvider = new FakeStateProvider(accountService);
@@ -47,6 +49,7 @@ describe("EnvironmentService", () => {
id: userId,
email: "test@example.com",
name: `Test Name ${userId}`,
emailVerified: false,
});
await awaitAsync();
};

View File

@@ -1,4 +1,4 @@
import { BehaviorSubject } from "rxjs";
import { BehaviorSubject, firstValueFrom, map } from "rxjs";
import { Jsonify, JsonValue } from "type-fest";
import { AccountService } from "../../auth/abstractions/account.service";
@@ -33,10 +33,7 @@ const keys = {
state: "state",
stateVersion: "stateVersion",
global: "global",
authenticatedAccounts: "authenticatedAccounts",
activeUserId: "activeUserId",
tempAccountSettings: "tempAccountSettings", // used to hold account specific settings (i.e clear clipboard) between initial migration and first account authentication
accountActivity: "accountActivity",
};
const partialKeys = {
@@ -58,9 +55,6 @@ export class StateService<
protected accountsSubject = new BehaviorSubject<{ [userId: string]: TAccount }>({});
accounts$ = this.accountsSubject.asObservable();
protected activeAccountSubject = new BehaviorSubject<string | null>(null);
activeAccount$ = this.activeAccountSubject.asObservable();
private hasBeenInited = false;
protected isRecoveredSession = false;
@@ -112,36 +106,16 @@ export class StateService<
}
// Get all likely authenticated accounts
const authenticatedAccounts = (
(await this.storageService.get<string[]>(keys.authenticatedAccounts)) ?? []
).filter((account) => account != null);
const authenticatedAccounts = await firstValueFrom(
this.accountService.accounts$.pipe(map((accounts) => Object.keys(accounts))),
);
await this.updateState(async (state) => {
for (const i in authenticatedAccounts) {
state = await this.syncAccountFromDisk(authenticatedAccounts[i]);
}
// After all individual accounts have been added
state.authenticatedAccounts = authenticatedAccounts;
const storedActiveUser = await this.storageService.get<string>(keys.activeUserId);
if (storedActiveUser != null) {
state.activeUserId = storedActiveUser;
}
await this.pushAccounts();
this.activeAccountSubject.next(state.activeUserId);
// TODO: Temporary update to avoid routing all account status changes through account service for now.
// account service tracks logged out accounts, but State service does not, so we need to add the active account
// if it's not in the accounts list.
if (state.activeUserId != null && this.accountsSubject.value[state.activeUserId] == null) {
const activeDiskAccount = await this.getAccountFromDisk({ userId: state.activeUserId });
await this.accountService.addAccount(state.activeUserId as UserId, {
name: activeDiskAccount.profile.name,
email: activeDiskAccount.profile.email,
});
}
await this.accountService.switchAccount(state.activeUserId as UserId);
// End TODO
return state;
});
@@ -161,61 +135,25 @@ export class StateService<
return state;
});
// TODO: Temporary update to avoid routing all account status changes through account service for now.
// The determination of state should be handled by the various services that control those values.
await this.accountService.addAccount(userId as UserId, {
name: diskAccount.profile.name,
email: diskAccount.profile.email,
});
return state;
}
async addAccount(account: TAccount) {
await this.environmentService.seedUserEnvironment(account.profile.userId as UserId);
await this.updateState(async (state) => {
state.authenticatedAccounts.push(account.profile.userId);
await this.storageService.save(keys.authenticatedAccounts, state.authenticatedAccounts);
state.accounts[account.profile.userId] = account;
return state;
});
await this.scaffoldNewAccountStorage(account);
await this.setLastActive(new Date().getTime(), { userId: account.profile.userId });
// TODO: Temporary update to avoid routing all account status changes through account service for now.
await this.accountService.addAccount(account.profile.userId as UserId, {
name: account.profile.name,
email: account.profile.email,
});
await this.setActiveUser(account.profile.userId);
}
async setActiveUser(userId: string): Promise<void> {
await this.clearDecryptedDataForActiveUser();
await this.updateState(async (state) => {
state.activeUserId = userId;
await this.storageService.save(keys.activeUserId, userId);
this.activeAccountSubject.next(state.activeUserId);
// TODO: temporary update to avoid routing all account status changes through account service for now.
await this.accountService.switchAccount(userId as UserId);
return state;
});
await this.pushAccounts();
}
async clean(options?: StorageOptions): Promise<UserId> {
async clean(options?: StorageOptions): Promise<void> {
options = this.reconcileOptions(options, await this.defaultInMemoryOptions());
await this.deAuthenticateAccount(options.userId);
let currentUser = (await this.state())?.activeUserId;
if (options.userId === currentUser) {
currentUser = await this.dynamicallySetActiveUser();
}
await this.removeAccountFromDisk(options?.userId);
await this.removeAccountFromMemory(options?.userId);
await this.pushAccounts();
return currentUser as UserId;
}
/**
@@ -515,24 +453,6 @@ export class StateService<
);
}
async getEmailVerified(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.profile.emailVerified ?? false
);
}
async setEmailVerified(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.profile.emailVerified = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getEnableBrowserIntegration(options?: StorageOptions): Promise<boolean> {
return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
@@ -642,35 +562,6 @@ export class StateService<
);
}
async getLastActive(options?: StorageOptions): Promise<number> {
options = this.reconcileOptions(options, await this.defaultOnDiskOptions());
const accountActivity = await this.storageService.get<{ [userId: string]: number }>(
keys.accountActivity,
options,
);
if (accountActivity == null || Object.keys(accountActivity).length < 1) {
return null;
}
return accountActivity[options.userId];
}
async setLastActive(value: number, options?: StorageOptions): Promise<void> {
options = this.reconcileOptions(options, await this.defaultOnDiskOptions());
if (options.userId == null) {
return;
}
const accountActivity =
(await this.storageService.get<{ [userId: string]: number }>(
keys.accountActivity,
options,
)) ?? {};
accountActivity[options.userId] = value;
await this.storageService.save(keys.accountActivity, accountActivity, options);
}
async getLastSync(options?: StorageOptions): Promise<string> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()))
@@ -910,24 +801,28 @@ export class StateService<
}
protected async getAccountFromMemory(options: StorageOptions): Promise<TAccount> {
const userId =
options.userId ??
(await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
));
return await this.state().then(async (state) => {
if (state.accounts == null) {
return null;
}
return state.accounts[await this.getUserIdFromMemory(options)];
});
}
protected async getUserIdFromMemory(options: StorageOptions): Promise<string> {
return await this.state().then((state) => {
return options?.userId != null
? state.accounts[options.userId]?.profile?.userId
: state.activeUserId;
return state.accounts[userId];
});
}
protected async getAccountFromDisk(options: StorageOptions): Promise<TAccount> {
if (options?.userId == null && (await this.state())?.activeUserId == null) {
const userId =
options.userId ??
(await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
));
if (userId == null) {
return null;
}
@@ -1086,53 +981,76 @@ export class StateService<
}
protected async defaultInMemoryOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return {
storageLocation: StorageLocation.Memory,
userId: (await this.state()).activeUserId,
userId,
};
}
protected async defaultOnDiskOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return {
storageLocation: StorageLocation.Disk,
htmlStorageLocation: HtmlStorageLocation.Session,
userId: (await this.state())?.activeUserId ?? (await this.getActiveUserIdFromStorage()),
userId,
useSecureStorage: false,
};
}
protected async defaultOnDiskLocalOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return {
storageLocation: StorageLocation.Disk,
htmlStorageLocation: HtmlStorageLocation.Local,
userId: (await this.state())?.activeUserId ?? (await this.getActiveUserIdFromStorage()),
userId,
useSecureStorage: false,
};
}
protected async defaultOnDiskMemoryOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return {
storageLocation: StorageLocation.Disk,
htmlStorageLocation: HtmlStorageLocation.Memory,
userId: (await this.state())?.activeUserId ?? (await this.getUserId()),
userId,
useSecureStorage: false,
};
}
protected async defaultSecureStorageOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return {
storageLocation: StorageLocation.Disk,
useSecureStorage: true,
userId: (await this.state())?.activeUserId ?? (await this.getActiveUserIdFromStorage()),
userId,
};
}
protected async getActiveUserIdFromStorage(): Promise<string> {
return await this.storageService.get<string>(keys.activeUserId);
return await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
}
protected async removeAccountFromLocalStorage(userId: string = null): Promise<void> {
userId = userId ?? (await this.state())?.activeUserId;
userId ??= await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
const storedAccount = await this.getAccount(
this.reconcileOptions({ userId: userId }, await this.defaultOnDiskLocalOptions()),
);
@@ -1143,7 +1061,10 @@ export class StateService<
}
protected async removeAccountFromSessionStorage(userId: string = null): Promise<void> {
userId = userId ?? (await this.state())?.activeUserId;
userId ??= await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
const storedAccount = await this.getAccount(
this.reconcileOptions({ userId: userId }, await this.defaultOnDiskOptions()),
);
@@ -1154,7 +1075,10 @@ export class StateService<
}
protected async removeAccountFromSecureStorage(userId: string = null): Promise<void> {
userId = userId ?? (await this.state())?.activeUserId;
userId ??= await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
await this.setUserKeyAutoUnlock(null, { userId: userId });
await this.setUserKeyBiometric(null, { userId: userId });
await this.setCryptoMasterKeyAuto(null, { userId: userId });
@@ -1163,8 +1087,11 @@ export class StateService<
}
protected async removeAccountFromMemory(userId: string = null): Promise<void> {
userId ??= await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
await this.updateState(async (state) => {
userId = userId ?? state.activeUserId;
delete state.accounts[userId];
return state;
});
@@ -1178,15 +1105,16 @@ export class StateService<
return Object.assign(this.createAccount(), persistentAccountInformation);
}
protected async clearDecryptedDataForActiveUser(): Promise<void> {
async clearDecryptedData(userId: UserId): Promise<void> {
await this.updateState(async (state) => {
const userId = state?.activeUserId;
if (userId != null && state?.accounts[userId]?.data != null) {
state.accounts[userId].data = new AccountData();
}
return state;
});
await this.pushAccounts();
}
protected createAccount(init: Partial<TAccount> = null): TAccount {
@@ -1201,14 +1129,6 @@ export class StateService<
// We must have a manual call to clear tokens as we can't leverage state provider to clean
// up our data as we have secure storage in the mix.
await this.tokenService.clearTokens(userId as UserId);
await this.setLastActive(null, { userId: userId });
await this.updateState(async (state) => {
state.authenticatedAccounts = state.authenticatedAccounts.filter((id) => id !== userId);
await this.storageService.save(keys.authenticatedAccounts, state.authenticatedAccounts);
return state;
});
}
protected async removeAccountFromDisk(userId: string) {
@@ -1217,32 +1137,6 @@ export class StateService<
await this.removeAccountFromSecureStorage(userId);
}
async nextUpActiveUser() {
const accounts = (await this.state())?.accounts;
if (accounts == null || Object.keys(accounts).length < 1) {
return null;
}
let newActiveUser;
for (const userId in accounts) {
if (userId == null) {
continue;
}
if (await this.getIsAuthenticated({ userId: userId })) {
newActiveUser = userId;
break;
}
newActiveUser = null;
}
return newActiveUser as UserId;
}
protected async dynamicallySetActiveUser() {
const newActiveUser = await this.nextUpActiveUser();
await this.setActiveUser(newActiveUser);
return newActiveUser;
}
protected async saveSecureStorageKey<T extends JsonValue>(
key: string,
value: T,

View File

@@ -1,10 +1,12 @@
import { firstValueFrom, timeout } from "rxjs";
import { firstValueFrom, map, timeout } from "rxjs";
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
import { AccountService } from "../../auth/abstractions/account.service";
import { AuthService } from "../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { AutofillSettingsServiceAbstraction } from "../../autofill/services/autofill-settings.service";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { UserId } from "../../types/guid";
import { MessagingService } from "../abstractions/messaging.service";
import { PlatformUtilsService } from "../abstractions/platform-utils.service";
import { StateService } from "../abstractions/state.service";
@@ -25,15 +27,18 @@ export class SystemService implements SystemServiceAbstraction {
private autofillSettingsService: AutofillSettingsServiceAbstraction,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private biometricStateService: BiometricStateService,
private accountService: AccountService,
) {}
async startProcessReload(authService: AuthService): Promise<void> {
const accounts = await firstValueFrom(this.stateService.accounts$);
const accounts = await firstValueFrom(this.accountService.accounts$);
if (accounts != null) {
const keys = Object.keys(accounts);
if (keys.length > 0) {
for (const userId of keys) {
if ((await authService.getAuthStatus(userId)) === AuthenticationStatus.Unlocked) {
let status = await firstValueFrom(authService.authStatusFor$(userId as UserId));
status = await authService.getAuthStatus(userId);
if (status === AuthenticationStatus.Unlocked) {
return;
}
}
@@ -63,15 +68,24 @@ export class SystemService implements SystemServiceAbstraction {
clearInterval(this.reloadInterval);
this.reloadInterval = null;
const currentUser = await firstValueFrom(this.stateService.activeAccount$.pipe(timeout(500)));
const currentUser = await firstValueFrom(
this.accountService.activeAccount$.pipe(
map((a) => a?.id),
timeout(500),
),
);
// Replace current active user if they will be logged out on reload
if (currentUser != null) {
const timeoutAction = await firstValueFrom(
this.vaultTimeoutSettingsService.vaultTimeoutAction$().pipe(timeout(500)),
);
if (timeoutAction === VaultTimeoutAction.LogOut) {
const nextUser = await this.stateService.nextUpActiveUser();
await this.stateService.setActiveUser(nextUser);
const nextUser = await firstValueFrom(
this.accountService.nextUpAccount$.pipe(map((account) => account?.id ?? null)),
);
// Can be removed once we migrate password generation history to state providers
await this.stateService.clearDecryptedData(currentUser);
await this.accountService.switchAccount(nextUser);
}
}