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:
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user