1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-28 12:45:45 +01:00

[PM-7838] [PM-7864] Ensure AuthStatus Changes Before Exiting (#9018)

* Ensure AuthStatus Changes Before Exiting

* Do Not Display Account Without Name Or Email

* Fix Environment Selectors

* Add AccountService.clean to Web
This commit is contained in:
Justin Baur 2024-05-03 16:43:42 -04:00 committed by GitHub
parent b46766affd
commit e4ef7d362e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 182 additions and 85 deletions

View File

@ -1,4 +1,4 @@
import { Subject, firstValueFrom, map, merge, timeout } from "rxjs"; import { Subject, filter, firstValueFrom, map, merge, timeout } from "rxjs";
import { import {
PinCryptoServiceAbstraction, PinCryptoServiceAbstraction,
@ -1200,31 +1200,46 @@ export default class MainBackground {
} }
async logout(expired: boolean, userId?: UserId) { async logout(expired: boolean, userId?: UserId) {
userId ??= ( const activeUserId = await firstValueFrom(
await firstValueFrom( this.accountService.activeAccount$.pipe(
this.accountService.activeAccount$.pipe( map((a) => a?.id),
timeout({ timeout({
first: 2000, first: 2000,
with: () => { with: () => {
throw new Error("No active account found to logout"); throw new Error("No active account found to logout");
}, },
}), }),
), ),
) );
)?.id;
await this.eventUploadService.uploadEvents(userId as UserId); const userBeingLoggedOut = userId ?? activeUserId;
await this.eventUploadService.uploadEvents(userBeingLoggedOut);
// HACK: We shouldn't wait for the authentication status to change but instead subscribe to the
// authentication status to do various actions.
const logoutPromise = firstValueFrom(
this.authService.authStatusFor$(userBeingLoggedOut).pipe(
filter((authenticationStatus) => authenticationStatus === AuthenticationStatus.LoggedOut),
timeout({
first: 5_000,
with: () => {
throw new Error("The logout process did not complete in a reasonable amount of time.");
},
}),
),
);
await Promise.all([ await Promise.all([
this.syncService.setLastSync(new Date(0), userId), this.syncService.setLastSync(new Date(0), userBeingLoggedOut),
this.cryptoService.clearKeys(userId), this.cryptoService.clearKeys(userBeingLoggedOut),
this.cipherService.clear(userId), this.cipherService.clear(userBeingLoggedOut),
this.folderService.clear(userId), this.folderService.clear(userBeingLoggedOut),
this.collectionService.clear(userId), this.collectionService.clear(userBeingLoggedOut),
this.passwordGenerationService.clear(userId), this.passwordGenerationService.clear(userBeingLoggedOut),
this.vaultTimeoutSettingsService.clear(userId), this.vaultTimeoutSettingsService.clear(userBeingLoggedOut),
this.vaultFilterService.clear(), this.vaultFilterService.clear(),
this.biometricStateService.logout(userId), this.biometricStateService.logout(userBeingLoggedOut),
/* We intentionally do not clear: /* We intentionally do not clear:
* - autofillSettingsService * - autofillSettingsService
* - badgeSettingsService * - badgeSettingsService
@ -1235,20 +1250,28 @@ export default class MainBackground {
//Needs to be checked before state is cleaned //Needs to be checked before state is cleaned
const needStorageReseed = await this.needsStorageReseed(); const needStorageReseed = await this.needsStorageReseed();
const newActiveUser = await firstValueFrom( const newActiveUser =
this.accountService.nextUpAccount$.pipe(map((a) => a?.id)), userBeingLoggedOut === activeUserId
); ? await firstValueFrom(this.accountService.nextUpAccount$.pipe(map((a) => a?.id)))
await this.stateService.clean({ userId: userId }); : null;
await this.accountService.clean(userId);
await this.stateEventRunnerService.handleEvent("logout", userId); await this.stateService.clean({ userId: userBeingLoggedOut });
await this.accountService.clean(userBeingLoggedOut);
await this.stateEventRunnerService.handleEvent("logout", userBeingLoggedOut);
// HACK: Wait for the user logging outs authentication status to transition to LoggedOut
await logoutPromise;
await this.switchAccount(newActiveUser);
if (newActiveUser != null) { if (newActiveUser != null) {
// we have a new active user, do not continue tearing down application // we have a new active user, do not continue tearing down application
await this.switchAccount(newActiveUser as UserId);
this.messagingService.send("switchAccountFinish"); this.messagingService.send("switchAccountFinish");
} else { } else {
this.messagingService.send("doneLoggingOut", { expired: expired, userId: userId }); this.messagingService.send("doneLoggingOut", {
expired: expired,
userId: userBeingLoggedOut,
});
} }
if (needStorageReseed) { if (needStorageReseed) {

View File

@ -8,7 +8,7 @@ import {
ViewContainerRef, ViewContainerRef,
} from "@angular/core"; } from "@angular/core";
import { Router } from "@angular/router"; import { Router } from "@angular/router";
import { firstValueFrom, map, Subject, takeUntil } from "rxjs"; import { filter, firstValueFrom, map, Subject, takeUntil, timeout } from "rxjs";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalService } from "@bitwarden/angular/services/modal.service";
@ -566,19 +566,42 @@ export class AppComponent implements OnInit, OnDestroy {
this.messagingService.send("updateAppMenu", { updateRequest: updateRequest }); this.messagingService.send("updateAppMenu", { updateRequest: updateRequest });
} }
private async logOut(expired: boolean, userId?: string) { // Even though the userId parameter is no longer optional doesn't mean a message couldn't be
const userBeingLoggedOut = // passing null-ish values to us.
(userId as UserId) ?? private async logOut(expired: boolean, userId: UserId) {
(await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)))); const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const userBeingLoggedOut = userId ?? activeUserId;
// Mark account as being cleaned up so that the updateAppMenu logic (executed on syncCompleted) // Mark account as being cleaned up so that the updateAppMenu logic (executed on syncCompleted)
// doesn't attempt to update a user that is being logged out as we will manually // doesn't attempt to update a user that is being logged out as we will manually
// call updateAppMenu when the logout is complete. // call updateAppMenu when the logout is complete.
this.startAccountCleanUp(userBeingLoggedOut); this.startAccountCleanUp(userBeingLoggedOut);
let preLogoutActiveUserId; const nextUpAccount =
const nextUpAccount = await firstValueFrom(this.accountService.nextUpAccount$); activeUserId === userBeingLoggedOut
? await firstValueFrom(this.accountService.nextUpAccount$) // We'll need to switch accounts
: null;
try { try {
// HACK: We shouldn't wait for authentication status to change here but instead subscribe to the
// authentication status to do various actions.
const logoutPromise = firstValueFrom(
this.authService.authStatusFor$(userBeingLoggedOut).pipe(
filter((authenticationStatus) => authenticationStatus === AuthenticationStatus.LoggedOut),
timeout({
first: 5_000,
with: () => {
throw new Error(
"The logout process did not complete in a reasonable amount of time.",
);
},
}),
),
);
// Provide the userId of the user to upload events for // Provide the userId of the user to upload events for
await this.eventUploadService.uploadEvents(userBeingLoggedOut); await this.eventUploadService.uploadEvents(userBeingLoggedOut);
await this.syncService.setLastSync(new Date(0), userBeingLoggedOut); await this.syncService.setLastSync(new Date(0), userBeingLoggedOut);
@ -592,26 +615,33 @@ export class AppComponent implements OnInit, OnDestroy {
await this.stateEventRunnerService.handleEvent("logout", userBeingLoggedOut); await this.stateEventRunnerService.handleEvent("logout", userBeingLoggedOut);
preLogoutActiveUserId = this.activeUserId;
await this.stateService.clean({ userId: userBeingLoggedOut }); await this.stateService.clean({ userId: userBeingLoggedOut });
await this.accountService.clean(userBeingLoggedOut); await this.accountService.clean(userBeingLoggedOut);
// HACK: Wait for the user logging outs authentication status to transition to LoggedOut
await logoutPromise;
} finally { } finally {
this.finishAccountCleanUp(userBeingLoggedOut); this.finishAccountCleanUp(userBeingLoggedOut);
} }
if (nextUpAccount == null) { // We only need to change the display at all if the account being looked at is the one
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // being logged out. If it was a background account, no need to do anything.
// eslint-disable-next-line @typescript-eslint/no-floating-promises if (userBeingLoggedOut === activeUserId) {
this.router.navigate(["login"]); if (nextUpAccount != null) {
} else if (preLogoutActiveUserId !== nextUpAccount.id) { this.messagingService.send("switchAccount", { userId: nextUpAccount.id });
this.messagingService.send("switchAccount", { userId: nextUpAccount.id }); } else {
// We don't have another user to switch to, bring them to the login page so they
// can sign into a user.
await this.accountService.switchAccount(null);
void this.router.navigate(["login"]);
}
} }
await this.updateAppMenu(); await this.updateAppMenu();
// This must come last otherwise the logout will prematurely trigger // This must come last otherwise the logout will prematurely trigger
// a process reload before all the state service user data can be cleaned up // a process reload before all the state service user data can be cleaned up
if (userBeingLoggedOut === preLogoutActiveUserId) { if (userBeingLoggedOut === activeUserId) {
this.authService.logOut(async () => { this.authService.logOut(async () => {
if (expired) { if (expired) {
this.platformUtilsService.showToast( this.platformUtilsService.showToast(
@ -702,7 +732,7 @@ export class AppComponent implements OnInit, OnDestroy {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
options[1] === "logOut" options[1] === "logOut"
? this.logOut(false, userId) ? this.logOut(false, userId as UserId)
: await this.vaultTimeoutService.lock(userId); : await this.vaultTimeoutService.lock(userId);
} }
} }

View File

@ -92,6 +92,11 @@ export class AccountSwitcherComponent {
return null; return null;
} }
if (!active.name && !active.email) {
// We need to have this information at minimum to display them.
return null;
}
return { return {
id: active.id, id: active.id,
name: active.name, name: active.name,

View File

@ -2,7 +2,7 @@ import { DOCUMENT } from "@angular/common";
import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core"; import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core";
import { NavigationEnd, Router } from "@angular/router"; import { NavigationEnd, Router } from "@angular/router";
import * as jq from "jquery"; import * as jq from "jquery";
import { Subject, firstValueFrom, map, switchMap, takeUntil, timer } from "rxjs"; import { Subject, filter, firstValueFrom, map, switchMap, takeUntil, timeout, timer } from "rxjs";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
@ -13,6 +13,7 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { PaymentMethodWarningsServiceAbstraction as PaymentMethodWarningService } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction"; import { PaymentMethodWarningsServiceAbstraction as PaymentMethodWarningService } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@ -136,9 +137,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.router.navigate(["/"]); this.router.navigate(["/"]);
break; break;
case "logout": case "logout":
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. await this.logOut(!!message.expired, message.redirect);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.logOut(!!message.expired, message.redirect);
break; break;
case "lockVault": case "lockVault":
await this.vaultTimeoutService.lock(); await this.vaultTimeoutService.lock();
@ -266,7 +265,20 @@ export class AppComponent implements OnDestroy, OnInit {
private async logOut(expired: boolean, redirect = true) { private async logOut(expired: boolean, redirect = true) {
await this.eventUploadService.uploadEvents(); await this.eventUploadService.uploadEvents();
const userId = await this.stateService.getUserId(); const userId = (await this.stateService.getUserId()) as UserId;
const logoutPromise = firstValueFrom(
this.authService.authStatusFor$(userId).pipe(
filter((authenticationStatus) => authenticationStatus === AuthenticationStatus.LoggedOut),
timeout({
first: 5_000,
with: () => {
throw new Error("The logout process did not complete in a reasonable amount of time.");
},
}),
),
);
await Promise.all([ await Promise.all([
this.syncService.setLastSync(new Date(0)), this.syncService.setLastSync(new Date(0)),
this.cryptoService.clearKeys(), this.cryptoService.clearKeys(),
@ -274,11 +286,11 @@ export class AppComponent implements OnDestroy, OnInit {
this.folderService.clear(userId), this.folderService.clear(userId),
this.collectionService.clear(userId), this.collectionService.clear(userId),
this.passwordGenerationService.clear(), this.passwordGenerationService.clear(),
this.biometricStateService.logout(userId as UserId), this.biometricStateService.logout(userId),
this.paymentMethodWarningService.clear(), this.paymentMethodWarningService.clear(),
]); ]);
await this.stateEventRunnerService.handleEvent("logout", userId as UserId); await this.stateEventRunnerService.handleEvent("logout", userId);
await this.searchService.clearIndex(); await this.searchService.clearIndex();
this.authService.logOut(async () => { this.authService.logOut(async () => {
@ -291,6 +303,10 @@ export class AppComponent implements OnDestroy, OnInit {
} }
await this.stateService.clean({ userId: userId }); await this.stateService.clean({ userId: userId });
await this.accountService.clean(userId);
await logoutPromise;
if (redirect) { if (redirect) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises

View File

@ -1,5 +1,5 @@
import { MockProxy, any, mock } from "jest-mock-extended"; import { MockProxy, any, mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs"; import { BehaviorSubject, from, of } from "rxjs";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { SearchService } from "../../abstractions/search.service"; import { SearchService } from "../../abstractions/search.service";
@ -106,6 +106,13 @@ describe("VaultTimeoutService", () => {
// Both are available by default and the specific test can change this per test // Both are available by default and the specific test can change this per test
availableVaultTimeoutActionsSubject.next([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]); availableVaultTimeoutActionsSubject.next([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]);
authService.authStatusFor$.mockImplementation((userId) => {
return from([
accounts[userId]?.authStatus ?? AuthenticationStatus.LoggedOut,
AuthenticationStatus.Locked,
]);
});
authService.getAuthStatus.mockImplementation((userId) => { authService.getAuthStatus.mockImplementation((userId) => {
return Promise.resolve(accounts[userId]?.authStatus); return Promise.resolve(accounts[userId]?.authStatus);
}); });
@ -387,18 +394,6 @@ describe("VaultTimeoutService", () => {
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", "user1"); expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", "user1");
}); });
it("should call messaging service locked message if no user passed into lock", async () => {
setupLock();
await vaultTimeoutService.lock();
// Currently these pass `undefined` (or what they were given) as the userId back
// but we could change this to give the user that was locked (active) to these methods
// so they don't have to get it their own way, but that is a behavioral change that needs
// to be tested.
expect(messagingService.send).toHaveBeenCalledWith("locked", { userId: undefined });
});
it("should call locked callback if no user passed into lock", async () => { it("should call locked callback if no user passed into lock", async () => {
setupLock(); setupLock();
@ -414,25 +409,31 @@ describe("VaultTimeoutService", () => {
it("should call state event runner with user passed into lock", async () => { it("should call state event runner with user passed into lock", async () => {
setupLock(); setupLock();
await vaultTimeoutService.lock("user2"); const user2 = "user2" as UserId;
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", "user2"); await vaultTimeoutService.lock(user2);
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", user2);
}); });
it("should call messaging service locked message with user passed into lock", async () => { it("should call messaging service locked message with user passed into lock", async () => {
setupLock(); setupLock();
await vaultTimeoutService.lock("user2"); const user2 = "user2" as UserId;
expect(messagingService.send).toHaveBeenCalledWith("locked", { userId: "user2" }); await vaultTimeoutService.lock(user2);
expect(messagingService.send).toHaveBeenCalledWith("locked", { userId: user2 });
}); });
it("should call locked callback with user passed into lock", async () => { it("should call locked callback with user passed into lock", async () => {
setupLock(); setupLock();
await vaultTimeoutService.lock("user2"); const user2 = "user2" as UserId;
expect(lockedCallback).toHaveBeenCalledWith("user2"); await vaultTimeoutService.lock(user2);
expect(lockedCallback).toHaveBeenCalledWith(user2);
}); });
}); });
}); });

View File

@ -1,4 +1,4 @@
import { combineLatest, firstValueFrom, switchMap } from "rxjs"; import { combineLatest, filter, firstValueFrom, map, switchMap, timeout } from "rxjs";
import { SearchService } from "../../abstractions/search.service"; import { SearchService } from "../../abstractions/search.service";
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
@ -80,7 +80,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
); );
} }
async lock(userId?: string): Promise<void> { async lock(userId?: UserId): Promise<void> {
const authed = await this.stateService.getIsAuthenticated({ userId: userId }); const authed = await this.stateService.getIsAuthenticated({ userId: userId });
if (!authed) { if (!authed) {
return; return;
@ -94,7 +94,27 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
await this.logOut(userId); await this.logOut(userId);
} }
const currentUserId = (await firstValueFrom(this.accountService.activeAccount$)).id; const currentUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const lockingUserId = userId ?? currentUserId;
// HACK: Start listening for the transition of the locking user from something to the locked state.
// This is very much a hack to ensure that the authentication status to retrievable right after
// it does its work. Particularly the `lockedCallback` and `"locked"` message. Instead
// lockedCallback should be deprecated and people should subscribe and react to `authStatusFor$` themselves.
const lockPromise = firstValueFrom(
this.authService.authStatusFor$(lockingUserId).pipe(
filter((authStatus) => authStatus === AuthenticationStatus.Locked),
timeout({
first: 5_000,
with: () => {
throw new Error("The lock process did not complete in a reasonable amount of time.");
},
}),
),
);
if (userId == null || userId === currentUserId) { if (userId == null || userId === currentUserId) {
await this.searchService.clearIndex(); await this.searchService.clearIndex();
@ -102,19 +122,21 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
await this.collectionService.clearActiveUserCache(); await this.collectionService.clearActiveUserCache();
} }
await this.masterPasswordService.clearMasterKey((userId ?? currentUserId) as UserId); await this.masterPasswordService.clearMasterKey(lockingUserId);
await this.stateService.setUserKeyAutoUnlock(null, { userId: userId }); await this.stateService.setUserKeyAutoUnlock(null, { userId: lockingUserId });
await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId }); await this.stateService.setCryptoMasterKeyAuto(null, { userId: lockingUserId });
await this.cipherService.clearCache(userId); await this.cipherService.clearCache(lockingUserId);
await this.stateEventRunnerService.handleEvent("lock", (userId ?? currentUserId) as UserId); await this.stateEventRunnerService.handleEvent("lock", lockingUserId);
// FIXME: We should send the userId of the user that was locked, in the case of this method being passed // HACK: Sit here and wait for the the auth status to transition to `Locked`
// undefined then it should give back the currentUserId. Better yet, this method shouldn't take // to ensure the message and lockedCallback will get the correct status
// an undefined userId at all. All receivers need to be checked for how they handle getting undefined. // if/when they call it.
this.messagingService.send("locked", { userId: userId }); await lockPromise;
this.messagingService.send("locked", { userId: lockingUserId });
if (this.lockedCallback != null) { if (this.lockedCallback != null) {
await this.lockedCallback(userId); await this.lockedCallback(userId);
@ -162,7 +184,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
return diffSeconds >= vaultTimeoutSeconds; return diffSeconds >= vaultTimeoutSeconds;
} }
private async executeTimeoutAction(userId: string): Promise<void> { private async executeTimeoutAction(userId: UserId): Promise<void> {
const timeoutAction = await firstValueFrom( const timeoutAction = await firstValueFrom(
this.vaultTimeoutSettingsService.vaultTimeoutAction$(userId), this.vaultTimeoutSettingsService.vaultTimeoutAction$(userId),
); );