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:
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 ?? {});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
111
libs/common/src/state-migrations/migrations/60-known-accounts.ts
Normal file
111
libs/common/src/state-migrations/migrations/60-known-accounts.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user