1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +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

@@ -57,13 +57,14 @@ import { CipherServiceMigrator } from "./migrations/57-move-cipher-service-to-st
import { RemoveRefreshTokenMigratedFlagMigrator } from "./migrations/58-remove-refresh-token-migrated-state-provider-flag";
import { KdfConfigMigrator } from "./migrations/59-move-kdf-config-to-state-provider";
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
import { KnownAccountsMigrator } from "./migrations/60-known-accounts";
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global";
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 3;
export const CURRENT_VERSION = 59;
export const CURRENT_VERSION = 60;
export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() {
@@ -124,7 +125,8 @@ export function createMigrationBuilder() {
.with(AuthRequestMigrator, 55, 56)
.with(CipherServiceMigrator, 56, 57)
.with(RemoveRefreshTokenMigratedFlagMigrator, 57, 58)
.with(KdfConfigMigrator, 58, CURRENT_VERSION);
.with(KdfConfigMigrator, 58, 59)
.with(KnownAccountsMigrator, 59, CURRENT_VERSION);
}
export async function currentVersion(

View File

@@ -27,6 +27,14 @@ const exampleJSON = {
},
global_serviceName_key: "global_serviceName_key",
user_userId_serviceName_key: "user_userId_serviceName_key",
global_account_accounts: {
"c493ed01-4e08-4e88-abc7-332f380ca760": {
otherStuff: "otherStuff3",
},
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
otherStuff: "otherStuff4",
},
},
};
describe("RemoveLegacyEtmKeyMigrator", () => {
@@ -81,6 +89,41 @@ describe("RemoveLegacyEtmKeyMigrator", () => {
const accounts = await sut.getAccounts();
expect(accounts).toEqual([]);
});
it("handles global scoped known accounts for version 60 and after", async () => {
sut.currentVersion = 60;
const accounts = await sut.getAccounts();
expect(accounts).toEqual([
// Note, still gets values stored in state service objects, just grabs user ids from global
{
userId: "c493ed01-4e08-4e88-abc7-332f380ca760",
account: { otherStuff: "otherStuff1" },
},
{
userId: "23e61a5f-2ece-4f5e-b499-f0bc489482a9",
account: { otherStuff: "otherStuff2" },
},
]);
});
});
describe("getKnownUserIds", () => {
it("returns all user ids", async () => {
const userIds = await sut.getKnownUserIds();
expect(userIds).toEqual([
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
]);
});
it("returns all user ids when version is 60 or greater", async () => {
sut.currentVersion = 60;
const userIds = await sut.getKnownUserIds();
expect(userIds).toEqual([
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
]);
});
});
describe("getFromGlobal", () => {

View File

@@ -162,7 +162,7 @@ export class MigrationHelper {
async getAccounts<ExpectedAccountType>(): Promise<
{ userId: string; account: ExpectedAccountType }[]
> {
const userIds = (await this.get<string[]>("authenticatedAccounts")) ?? [];
const userIds = await this.getKnownUserIds();
return Promise.all(
userIds.map(async (userId) => ({
userId,
@@ -171,6 +171,17 @@ export class MigrationHelper {
);
}
/**
* Helper method to read known users ids.
*/
async getKnownUserIds(): Promise<string[]> {
if (this.currentVersion < 61) {
return knownAccountUserIdsBuilderPre61(this.storageService);
} else {
return knownAccountUserIdsBuilder(this.storageService);
}
}
/**
* Builds a user storage key appropriate for the current version.
*
@@ -233,3 +244,18 @@ function globalKeyBuilder(keyDefinition: KeyDefinitionLike): string {
function globalKeyBuilderPre9(): string {
throw Error("No key builder should be used for versions prior to 9.");
}
async function knownAccountUserIdsBuilderPre61(
storageService: AbstractStorageService,
): Promise<string[]> {
return (await storageService.get<string[]>("authenticatedAccounts")) ?? [];
}
async function knownAccountUserIdsBuilder(
storageService: AbstractStorageService,
): Promise<string[]> {
const accounts = await storageService.get<Record<string, unknown>>(
globalKeyBuilder({ stateDefinition: { name: "account" }, key: "accounts" }),
);
return Object.keys(accounts ?? {});
}

View File

@@ -0,0 +1,145 @@
import { MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
ACCOUNT_ACCOUNTS,
ACCOUNT_ACTIVE_ACCOUNT_ID,
ACCOUNT_ACTIVITY,
KnownAccountsMigrator,
} from "./60-known-accounts";
const migrateJson = () => {
return {
authenticatedAccounts: ["user1", "user2"],
activeUserId: "user1",
user1: {
profile: {
email: "user1",
name: "User 1",
emailVerified: true,
},
},
user2: {
profile: {
email: "",
emailVerified: false,
},
},
accountActivity: {
user1: 1609459200000, // 2021-01-01
user2: 1609545600000, // 2021-01-02
},
};
};
const rollbackJson = () => {
return {
user1: {
profile: {
email: "user1",
name: "User 1",
emailVerified: true,
},
},
user2: {
profile: {
email: "",
emailVerified: false,
},
},
global_account_accounts: {
user1: {
profile: {
email: "user1",
name: "User 1",
emailVerified: true,
},
},
user2: {
profile: {
email: "",
emailVerified: false,
},
},
},
global_account_activeAccountId: "user1",
global_account_activity: {
user1: "2021-01-01T00:00:00.000Z",
user2: "2021-01-02T00:00:00.000Z",
},
};
};
describe("ReplicateKnownAccounts", () => {
let helper: MockProxy<MigrationHelper>;
let sut: KnownAccountsMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(migrateJson(), 59);
sut = new KnownAccountsMigrator(59, 60);
});
it("migrates accounts", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledWith(ACCOUNT_ACCOUNTS, {
user1: {
email: "user1",
name: "User 1",
emailVerified: true,
},
user2: {
email: "",
emailVerified: false,
name: undefined,
},
});
expect(helper.remove).toHaveBeenCalledWith("authenticatedAccounts");
});
it("migrates active account it", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVE_ACCOUNT_ID, "user1");
expect(helper.remove).toHaveBeenCalledWith("activeUserId");
});
it("migrates account activity", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVITY, {
user1: '"2021-01-01T00:00:00.000Z"',
user2: '"2021-01-02T00:00:00.000Z"',
});
expect(helper.remove).toHaveBeenCalledWith("accountActivity");
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJson(), 60);
sut = new KnownAccountsMigrator(59, 60);
});
it("rolls back authenticated accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("authenticatedAccounts", ["user1", "user2"]);
expect(helper.removeFromGlobal).toHaveBeenCalledWith(ACCOUNT_ACCOUNTS);
});
it("rolls back active account id", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("activeUserId", "user1");
expect(helper.removeFromGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVE_ACCOUNT_ID);
});
it("rolls back account activity", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("accountActivity", {
user1: 1609459200000,
user2: 1609545600000,
});
expect(helper.removeFromGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVITY);
});
});
});

View File

@@ -0,0 +1,111 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
export const ACCOUNT_ACCOUNTS: KeyDefinitionLike = {
stateDefinition: {
name: "account",
},
key: "accounts",
};
export const ACCOUNT_ACTIVE_ACCOUNT_ID: KeyDefinitionLike = {
stateDefinition: {
name: "account",
},
key: "activeAccountId",
};
export const ACCOUNT_ACTIVITY: KeyDefinitionLike = {
stateDefinition: {
name: "account",
},
key: "activity",
};
type ExpectedAccountType = {
profile?: {
email?: string;
name?: string;
emailVerified?: boolean;
};
};
export class KnownAccountsMigrator extends Migrator<59, 60> {
async migrate(helper: MigrationHelper): Promise<void> {
await this.migrateAuthenticatedAccounts(helper);
await this.migrateActiveAccountId(helper);
await this.migrateAccountActivity(helper);
}
async rollback(helper: MigrationHelper): Promise<void> {
// authenticated account are removed, but the accounts record also contains logged out accounts. Best we can do is to add them all back
const accounts = (await helper.getFromGlobal<Record<string, unknown>>(ACCOUNT_ACCOUNTS)) ?? {};
await helper.set("authenticatedAccounts", Object.keys(accounts));
await helper.removeFromGlobal(ACCOUNT_ACCOUNTS);
// Active Account Id
const activeAccountId = await helper.getFromGlobal<string>(ACCOUNT_ACTIVE_ACCOUNT_ID);
if (activeAccountId) {
await helper.set("activeUserId", activeAccountId);
}
await helper.removeFromGlobal(ACCOUNT_ACTIVE_ACCOUNT_ID);
// Account Activity
const accountActivity = await helper.getFromGlobal<Record<string, string>>(ACCOUNT_ACTIVITY);
if (accountActivity) {
const toStore = Object.entries(accountActivity).reduce(
(agg, [userId, dateString]) => {
agg[userId] = new Date(dateString).getTime();
return agg;
},
{} as Record<string, number>,
);
await helper.set("accountActivity", toStore);
}
await helper.removeFromGlobal(ACCOUNT_ACTIVITY);
}
private async migrateAuthenticatedAccounts(helper: MigrationHelper) {
const authenticatedAccounts = (await helper.get<string[]>("authenticatedAccounts")) ?? [];
const accounts = await Promise.all(
authenticatedAccounts.map(async (userId) => {
const account = await helper.get<ExpectedAccountType>(userId);
return { userId, account };
}),
);
const accountsToStore = accounts.reduce(
(agg, { userId, account }) => {
if (account?.profile) {
agg[userId] = {
email: account.profile.email ?? "",
emailVerified: account.profile.emailVerified ?? false,
name: account.profile.name,
};
}
return agg;
},
{} as Record<string, { email: string; emailVerified: boolean; name: string | undefined }>,
);
await helper.setToGlobal(ACCOUNT_ACCOUNTS, accountsToStore);
await helper.remove("authenticatedAccounts");
}
private async migrateAccountActivity(helper: MigrationHelper) {
const stored = await helper.get<Record<string, Date>>("accountActivity");
const accountActivity = Object.entries(stored ?? {}).reduce(
(agg, [userId, dateMs]) => {
agg[userId] = JSON.stringify(new Date(dateMs));
return agg;
},
{} as Record<string, string>,
);
await helper.setToGlobal(ACCOUNT_ACTIVITY, accountActivity);
await helper.remove("accountActivity");
}
private async migrateActiveAccountId(helper: MigrationHelper) {
const activeAccountId = await helper.get<string>("activeUserId");
await helper.setToGlobal(ACCOUNT_ACTIVE_ACCOUNT_ID, activeAccountId);
await helper.remove("activeUserId");
}
}