1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +00:00

[PM-7926] Handle complex user logout events (#9115)

* Update activity when switching users

* Clear data of designated user

* Do not switchMap to null, always to Promise or Observable

* handle uninitialized popup services

* Switch to new account immediately and log out as inactive.

Split up done logging out and navigation so we can always display expire warning.

* Do not navigate in account switcher, main.background takes care of it

* Ignore storage updates from reseed events

* Remove loading on cancelled logout

* Catch missed account switch errors

* Avoid usage of active user state in sync service

Send service does not currently support specified user data
manipulation, so we ensure that the notification was sent to the
active user prior to processing the notification.

* Clear sequentialize caches on account switch

These caches are used to ensure that rapid calls to an async method are not repeated. However, the cached promises are valid only within a given userId context and must be cleared when that context changes.

* Revert `void` promise for notification reconnect

* Update libs/angular/src/services/jslib-services.module.ts

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

* Handle switch account routing through messaging background -> app

* Use account switch status to handle unlocked navigation case.

* Revert "Handle switch account routing through messaging background -> app"

This reverts commit 8f35078ecb.

---------

Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com>
Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
This commit is contained in:
Matt Gibson
2024-05-14 16:27:43 -04:00
committed by GitHub
parent b4f4818635
commit 94b57687f5
15 changed files with 214 additions and 94 deletions

View File

@@ -119,6 +119,7 @@ export class AccountServiceImplementation implements InternalAccountService {
}
async switchAccount(userId: UserId): Promise<void> {
let updateActivity = false;
await this.activeAccountIdState.update(
(_, accounts) => {
if (userId == null) {
@@ -129,6 +130,7 @@ export class AccountServiceImplementation implements InternalAccountService {
if (accounts?.[userId] == null) {
throw new Error("Account does not exist");
}
updateActivity = true;
return userId;
},
{
@@ -139,6 +141,10 @@ export class AccountServiceImplementation implements InternalAccountService {
},
},
);
if (updateActivity) {
await this.setAccountActivity(userId, new Date());
}
}
async setAccountActivity(userId: UserId, lastActivity: Date): Promise<void> {

View File

@@ -1,4 +1,4 @@
import { sequentialize } from "./sequentialize";
import { clearCaches, sequentialize } from "./sequentialize";
describe("sequentialize decorator", () => {
it("should call the function once", async () => {
@@ -100,6 +100,18 @@ describe("sequentialize decorator", () => {
allRes.sort();
expect(allRes).toEqual([3, 3, 6, 6, 9, 9]);
});
describe("clearCaches", () => {
it("should clear all caches", async () => {
const foo = new Foo();
const promise = Promise.all([foo.bar(1), foo.bar(1)]);
clearCaches();
await foo.bar(1);
await promise;
// one call for the first two, one for the third after the cache was cleared
expect(foo.calls).toBe(2);
});
});
});
class Foo {

View File

@@ -1,3 +1,19 @@
const caches = new Map<any, Map<string, Promise<any>>>();
const getCache = (obj: any) => {
let cache = caches.get(obj);
if (cache != null) {
return cache;
}
cache = new Map<string, Promise<any>>();
caches.set(obj, cache);
return cache;
};
export function clearCaches() {
caches.clear();
}
/**
* Use as a Decorator on async functions, it will prevent multiple 'active' calls as the same time
*
@@ -11,17 +27,6 @@
export function sequentialize(cacheKey: (args: any[]) => string) {
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
const originalMethod: () => Promise<any> = descriptor.value;
const caches = new Map<any, Map<string, Promise<any>>>();
const getCache = (obj: any) => {
let cache = caches.get(obj);
if (cache != null) {
return cache;
}
cache = new Map<string, Promise<any>>();
caches.set(obj, cache);
return cache;
};
return {
value: function (...args: any[]) {

View File

@@ -28,7 +28,7 @@ import {
DerivedState,
StateProvider,
} from "../../platform/state";
import { CipherId, CollectionId, OrganizationId } from "../../types/guid";
import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid";
import { UserKey, OrgKey } from "../../types/key";
import { CipherService as CipherServiceAbstraction } from "../abstractions/cipher.service";
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
@@ -136,11 +136,12 @@ export class CipherService implements CipherServiceAbstraction {
}
async setDecryptedCipherCache(value: CipherView[]) {
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
// Sometimes we might prematurely decrypt the vault and that will result in no ciphers
// if we cache it then we may accidentially return it when it's not right, we'd rather try decryption again.
// if we cache it then we may accidentally return it when it's not right, we'd rather try decryption again.
// We still want to set null though, that is the indicator that the cache isn't valid and we should do decryption.
if (value == null || value.length !== 0) {
await this.setDecryptedCiphers(value);
await this.setDecryptedCiphers(value, userId);
}
if (this.searchService != null) {
if (value == null) {
@@ -151,15 +152,16 @@ export class CipherService implements CipherServiceAbstraction {
}
}
private async setDecryptedCiphers(value: CipherView[]) {
private async setDecryptedCiphers(value: CipherView[], userId: UserId) {
const cipherViews: { [id: string]: CipherView } = {};
value?.forEach((c) => {
cipherViews[c.id] = c;
});
await this.decryptedCiphersState.update(() => cipherViews);
await this.stateProvider.setUserState(DECRYPTED_CIPHERS, cipherViews, userId);
}
async clearCache(userId?: string): Promise<void> {
async clearCache(userId?: UserId): Promise<void> {
userId ??= await firstValueFrom(this.stateProvider.activeUserId$);
await this.clearDecryptedCiphersState(userId);
}
@@ -524,6 +526,7 @@ export class CipherService implements CipherServiceAbstraction {
}
async updateLastUsedDate(id: string): Promise<void> {
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
let ciphersLocalData = await firstValueFrom(this.localData$);
if (!ciphersLocalData) {
@@ -553,10 +556,11 @@ export class CipherService implements CipherServiceAbstraction {
break;
}
}
await this.setDecryptedCiphers(decryptedCipherCache);
await this.setDecryptedCiphers(decryptedCipherCache, userId);
}
async updateLastLaunchedDate(id: string): Promise<void> {
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
let ciphersLocalData = await firstValueFrom(this.localData$);
if (!ciphersLocalData) {
@@ -586,7 +590,7 @@ export class CipherService implements CipherServiceAbstraction {
break;
}
}
await this.setDecryptedCiphers(decryptedCipherCache);
await this.setDecryptedCiphers(decryptedCipherCache, userId);
}
async saveNeverDomain(domain: string): Promise<void> {
@@ -833,12 +837,18 @@ export class CipherService implements CipherServiceAbstraction {
await this.updateEncryptedCipherState(() => ciphers);
}
/**
* Updates ciphers for the currently active user. Inactive users can only clear all ciphers, for now.
* @param update update callback for encrypted cipher data
* @returns
*/
private async updateEncryptedCipherState(
update: (current: Record<CipherId, CipherData>) => Record<CipherId, CipherData>,
): Promise<Record<CipherId, CipherData>> {
const userId = await firstValueFrom(this.stateProvider.activeUserId$);
// Store that we should wait for an update to return any ciphers
await this.ciphersExpectingUpdate.forceValue(true);
await this.clearDecryptedCiphersState();
await this.clearDecryptedCiphersState(userId);
const [, updatedCiphers] = await this.encryptedCiphersState.update((current) => {
const result = update(current ?? {});
return result;
@@ -846,7 +856,8 @@ export class CipherService implements CipherServiceAbstraction {
return updatedCiphers;
}
async clear(userId?: string): Promise<any> {
async clear(userId?: UserId): Promise<any> {
userId ??= await firstValueFrom(this.stateProvider.activeUserId$);
await this.clearEncryptedCiphersState(userId);
await this.clearCache(userId);
}
@@ -1464,12 +1475,12 @@ export class CipherService implements CipherServiceAbstraction {
}
}
private async clearEncryptedCiphersState(userId?: string) {
await this.encryptedCiphersState.update(() => ({}));
private async clearEncryptedCiphersState(userId: UserId) {
await this.stateProvider.setUserState(ENCRYPTED_CIPHERS, {}, userId);
}
private async clearDecryptedCiphersState(userId?: string) {
await this.setDecryptedCiphers(null);
private async clearDecryptedCiphersState(userId: UserId) {
await this.setDecryptedCiphers(null, userId);
this.clearSortedCiphers();
}

View File

@@ -1,4 +1,4 @@
import { firstValueFrom } from "rxjs";
import { firstValueFrom, map, of, switchMap } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
@@ -12,10 +12,12 @@ import { PolicyData } from "../../../admin-console/models/data/policy.data";
import { ProviderData } from "../../../admin-console/models/data/provider.data";
import { PolicyResponse } from "../../../admin-console/models/response/policy.response";
import { AccountService } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AvatarService } from "../../../auth/abstractions/avatar.service";
import { KeyConnectorService } from "../../../auth/abstractions/key-connector.service";
import { InternalMasterPasswordServiceAbstraction } from "../../../auth/abstractions/master-password.service.abstraction";
import { TokenService } from "../../../auth/abstractions/token.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
import { DomainSettingsService } from "../../../autofill/services/domain-settings.service";
import { BillingAccountProfileStateService } from "../../../billing/abstractions/account/billing-account-profile-state.service";
@@ -74,6 +76,7 @@ export class SyncService implements SyncServiceAbstraction {
private logoutCallback: (expired: boolean) => Promise<void>,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private tokenService: TokenService,
private authService: AuthService,
) {}
async getLastSync(): Promise<Date> {
@@ -247,7 +250,19 @@ export class SyncService implements SyncServiceAbstraction {
async syncUpsertSend(notification: SyncSendNotification, isEdit: boolean): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
const [activeUserId, status] = await firstValueFrom(
this.accountService.activeAccount$.pipe(
switchMap((a) => {
if (a == null) {
of([null, AuthenticationStatus.LoggedOut]);
}
return this.authService.authStatusFor$(a.id).pipe(map((s) => [a.id, s]));
}),
),
);
// Process only notifications for currently active user when user is not logged out
// TODO: once send service allows data manipulation of non-active users, this should process any received notification
if (activeUserId === notification.userId && status !== AuthenticationStatus.LoggedOut) {
try {
const localSend = await firstValueFrom(this.sendService.get$(notification.id));
if (
@@ -361,15 +376,14 @@ export class SyncService implements SyncServiceAbstraction {
private async setForceSetPasswordReasonIfNeeded(profileResponse: ProfileResponse) {
// The `forcePasswordReset` flag indicates an admin has reset the user's password and must be updated
if (profileResponse.forcePasswordReset) {
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.AdminForcePasswordReset,
userId,
profileResponse.id,
);
}
const userDecryptionOptions = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
this.userDecryptionOptionsService.userDecryptionOptionsById$(profileResponse.id),
);
if (userDecryptionOptions === null || userDecryptionOptions === undefined) {