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

[PM-7837] Move SyncService to Platform Ownership (#9055)

* Move

* Update  References In Unowned Files

* Update  References In Vault Files

* Update Web AppComponent

* Add Import
This commit is contained in:
Justin Baur
2024-06-05 10:48:12 -04:00
committed by GitHub
parent cb0927ac5d
commit 1aaa88a64d
29 changed files with 152 additions and 143 deletions

View File

@@ -0,0 +1,340 @@
import { firstValueFrom } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "../../../../auth/src/common/abstractions";
import { LogoutReason } from "../../../../auth/src/common/types";
import { ApiService } from "../../abstractions/api.service";
import { InternalOrganizationServiceAbstraction } from "../../admin-console/abstractions/organization/organization.service.abstraction";
import { InternalPolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { ProviderService } from "../../admin-console/abstractions/provider.service";
import { OrganizationUserType } from "../../admin-console/enums";
import { OrganizationData } from "../../admin-console/models/data/organization.data";
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 { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason";
import { DomainSettingsService } from "../../autofill/services/domain-settings.service";
import { BillingAccountProfileStateService } from "../../billing/abstractions";
import { DomainsResponse } from "../../models/response/domains.response";
import { ProfileResponse } from "../../models/response/profile.response";
import { SendData } from "../../tools/send/models/data/send.data";
import { SendResponse } from "../../tools/send/models/response/send.response";
import { SendApiService } from "../../tools/send/services/send-api.service.abstraction";
import { InternalSendService } from "../../tools/send/services/send.service.abstraction";
import { UserId } from "../../types/guid";
import { CipherService } from "../../vault/abstractions/cipher.service";
import { CollectionService } from "../../vault/abstractions/collection.service";
import { FolderApiServiceAbstraction } from "../../vault/abstractions/folder/folder-api.service.abstraction";
import { InternalFolderService } from "../../vault/abstractions/folder/folder.service.abstraction";
import { CipherData } from "../../vault/models/data/cipher.data";
import { CollectionData } from "../../vault/models/data/collection.data";
import { FolderData } from "../../vault/models/data/folder.data";
import { CipherResponse } from "../../vault/models/response/cipher.response";
import { CollectionDetailsResponse } from "../../vault/models/response/collection.response";
import { FolderResponse } from "../../vault/models/response/folder.response";
import { CryptoService } from "../abstractions/crypto.service";
import { LogService } from "../abstractions/log.service";
import { StateService } from "../abstractions/state.service";
import { MessageSender } from "../messaging";
import { sequentialize } from "../misc/sequentialize";
import { CoreSyncService } from "./core-sync.service";
export class DefaultSyncService extends CoreSyncService {
syncInProgress = false;
constructor(
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
accountService: AccountService,
apiService: ApiService,
private domainSettingsService: DomainSettingsService,
folderService: InternalFolderService,
cipherService: CipherService,
private cryptoService: CryptoService,
collectionService: CollectionService,
messageSender: MessageSender,
private policyService: InternalPolicyService,
sendService: InternalSendService,
logService: LogService,
private keyConnectorService: KeyConnectorService,
stateService: StateService,
private providerService: ProviderService,
folderApiService: FolderApiServiceAbstraction,
private organizationService: InternalOrganizationServiceAbstraction,
sendApiService: SendApiService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private avatarService: AvatarService,
private logoutCallback: (logoutReason: LogoutReason, userId?: UserId) => Promise<void>,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private tokenService: TokenService,
authService: AuthService,
) {
super(
stateService,
folderService,
folderApiService,
messageSender,
logService,
cipherService,
collectionService,
apiService,
accountService,
authService,
sendService,
sendApiService,
);
}
@sequentialize(() => "fullSync")
override async fullSync(forceSync: boolean, allowThrowOnError = false): Promise<boolean> {
this.syncStarted();
const isAuthenticated = await this.stateService.getIsAuthenticated();
if (!isAuthenticated) {
return this.syncCompleted(false);
}
const now = new Date();
let needsSync = false;
try {
needsSync = await this.needsSyncing(forceSync);
} catch (e) {
if (allowThrowOnError) {
this.syncCompleted(false);
throw e;
}
}
if (!needsSync) {
await this.setLastSync(now);
return this.syncCompleted(false);
}
try {
await this.apiService.refreshIdentityToken();
const response = await this.apiService.getSync();
await this.syncProfile(response.profile);
await this.syncFolders(response.folders);
await this.syncCollections(response.collections);
await this.syncCiphers(response.ciphers);
await this.syncSends(response.sends);
await this.syncSettings(response.domains);
await this.syncPolicies(response.policies);
await this.setLastSync(now);
return this.syncCompleted(true);
} catch (e) {
if (allowThrowOnError) {
this.syncCompleted(false);
throw e;
} else {
return this.syncCompleted(false);
}
}
}
private async needsSyncing(forceSync: boolean) {
if (forceSync) {
return true;
}
const lastSync = await this.getLastSync();
if (lastSync == null || lastSync.getTime() === 0) {
return true;
}
const response = await this.apiService.getAccountRevisionDate();
if (response < 0 && this.logoutCallback) {
// Account was deleted, log out now
await this.logoutCallback("accountDeleted");
}
if (new Date(response) <= lastSync) {
return false;
}
return true;
}
private async syncProfile(response: ProfileResponse) {
const stamp = await this.tokenService.getSecurityStamp(response.id);
if (stamp != null && stamp !== response.securityStamp) {
if (this.logoutCallback != null) {
await this.logoutCallback("invalidSecurityStamp");
}
throw new Error("Stamp has changed");
}
await this.cryptoService.setMasterKeyEncryptedUserKey(response.key);
await this.cryptoService.setPrivateKey(response.privateKey, response.id);
await this.cryptoService.setProviderKeys(response.providers, response.id);
await this.cryptoService.setOrgKeys(
response.organizations,
response.providerOrganizations,
response.id,
);
await this.avatarService.setSyncAvatarColor(response.id, response.avatarColor);
await this.tokenService.setSecurityStamp(response.securityStamp, response.id);
await this.accountService.setAccountEmailVerified(response.id, response.emailVerified);
await this.billingAccountProfileStateService.setHasPremium(
response.premiumPersonally,
response.premiumFromOrganization,
);
await this.keyConnectorService.setUsesKeyConnector(response.usesKeyConnector);
await this.setForceSetPasswordReasonIfNeeded(response);
const providers: { [id: string]: ProviderData } = {};
response.providers.forEach((p) => {
providers[p.id] = new ProviderData(p);
});
await this.providerService.save(providers);
await this.syncProfileOrganizations(response);
if (await this.keyConnectorService.userNeedsMigration()) {
await this.keyConnectorService.setConvertAccountRequired(true);
this.messageSender.send("convertAccountToKeyConnector");
} else {
// 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
this.keyConnectorService.removeConvertAccountRequired();
}
}
private async setForceSetPasswordReasonIfNeeded(profileResponse: ProfileResponse) {
// The `forcePasswordReset` flag indicates an admin has reset the user's password and must be updated
if (profileResponse.forcePasswordReset) {
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.AdminForcePasswordReset,
profileResponse.id,
);
}
const userDecryptionOptions = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptionsById$(profileResponse.id),
);
if (userDecryptionOptions === null || userDecryptionOptions === undefined) {
this.logService.error("Sync: Account decryption options are null or undefined.");
}
// Even though TDE users should only be in a single org (per single org policy), check
// through all orgs for the manageResetPassword permission. If they have it in any org,
// they should be forced to set a password.
let hasManageResetPasswordPermission = false;
for (const org of profileResponse.organizations) {
const isAdmin = org.type === OrganizationUserType.Admin;
const isOwner = org.type === OrganizationUserType.Owner;
// Note: apparently permissions only come down populated for custom roles.
if (isAdmin || isOwner || (org.permissions && org.permissions.manageResetPassword)) {
hasManageResetPasswordPermission = true;
break;
}
}
if (
userDecryptionOptions.trustedDeviceOption !== undefined &&
!userDecryptionOptions.hasMasterPassword &&
hasManageResetPasswordPermission
) {
// TDE user w/out MP went from having no password reset permission to having it.
// Must set the force password reset reason so the auth guard will redirect to the set password page.
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
userId,
);
}
}
private async syncProfileOrganizations(response: ProfileResponse) {
const organizations: { [id: string]: OrganizationData } = {};
response.organizations.forEach((o) => {
organizations[o.id] = new OrganizationData(o, {
isMember: true,
isProviderUser: false,
});
});
response.providerOrganizations.forEach((o) => {
if (organizations[o.id] == null) {
organizations[o.id] = new OrganizationData(o, {
isMember: false,
isProviderUser: true,
});
} else {
organizations[o.id].isProviderUser = true;
}
});
await this.organizationService.replace(organizations);
}
private async syncFolders(response: FolderResponse[]) {
const folders: { [id: string]: FolderData } = {};
response.forEach((f) => {
folders[f.id] = new FolderData(f);
});
return await this.folderService.replace(folders);
}
private async syncCollections(response: CollectionDetailsResponse[]) {
const collections: { [id: string]: CollectionData } = {};
response.forEach((c) => {
collections[c.id] = new CollectionData(c);
});
return await this.collectionService.replace(collections);
}
private async syncCiphers(response: CipherResponse[]) {
const ciphers: { [id: string]: CipherData } = {};
response.forEach((c) => {
ciphers[c.id] = new CipherData(c);
});
return await this.cipherService.replace(ciphers);
}
private async syncSends(response: SendResponse[]) {
const sends: { [id: string]: SendData } = {};
response.forEach((s) => {
sends[s.id] = new SendData(s);
});
return await this.sendService.replace(sends);
}
private async syncSettings(response: DomainsResponse) {
let eqDomains: string[][] = [];
if (response != null && response.equivalentDomains != null) {
eqDomains = eqDomains.concat(response.equivalentDomains);
}
if (response != null && response.globalEquivalentDomains != null) {
response.globalEquivalentDomains.forEach((global) => {
if (global.domains.length > 0) {
eqDomains.push(global.domains);
}
});
}
return this.domainSettingsService.setEquivalentDomains(eqDomains);
}
private async syncPolicies(response: PolicyResponse[]) {
const policies: { [id: string]: PolicyData } = {};
if (response != null) {
response.forEach((p) => {
policies[p.id] = new PolicyData(p);
});
}
return await this.policyService.replace(policies);
}
}

View File

@@ -0,0 +1,2 @@
export { SyncService } from "./sync.service";
export { SyncResponse } from "./sync.response";

View File

@@ -1 +1,2 @@
export { DefaultSyncService } from "./default-sync.service";
export { CoreSyncService } from "./core-sync.service";

View File

@@ -0,0 +1,38 @@
import { SyncResponse } from "./sync.response";
type SyncStatus = "Started" | "Completed";
type SyncEventArgsBase<T extends SyncStatus> = {
status: T;
};
type SyncCompletedEventArgsBase<T extends boolean> = SyncEventArgsBase<"Completed"> & {
successfully: T;
};
type SyncSuccessfullyCompletedEventArgs = SyncCompletedEventArgsBase<true> & {
data: SyncResponse;
};
export type SyncEventArgs =
| SyncSuccessfullyCompletedEventArgs
| SyncCompletedEventArgsBase<false>
| SyncEventArgsBase<"Started">;
/**
* Helper function to filter only on successfully completed syncs
* @returns a function that can be used in a `.pipe(filter(...))` from an observable
* @example
* ```
* of<SyncEventArgs>({ status: "Completed", successfully: true, data: new SyncResponse() })
* .pipe(filter(isSuccessfullyCompleted))
* .subscribe(event => {
* console.log(event.data);
* });
* ```
*/
export function isSuccessfullyCompleted(
syncEvent: SyncEventArgs,
): syncEvent is SyncSuccessfullyCompletedEventArgs {
return syncEvent.status === "Completed" && syncEvent.successfully;
}

View File

@@ -0,0 +1,57 @@
import { PolicyResponse } from "../../admin-console/models/response/policy.response";
import { BaseResponse } from "../../models/response/base.response";
import { DomainsResponse } from "../../models/response/domains.response";
import { ProfileResponse } from "../../models/response/profile.response";
import { SendResponse } from "../../tools/send/models/response/send.response";
import { CipherResponse } from "../../vault/models/response/cipher.response";
import { CollectionDetailsResponse } from "../../vault/models/response/collection.response";
import { FolderResponse } from "../../vault/models/response/folder.response";
export class SyncResponse extends BaseResponse {
profile?: ProfileResponse;
folders: FolderResponse[] = [];
collections: CollectionDetailsResponse[] = [];
ciphers: CipherResponse[] = [];
domains?: DomainsResponse;
policies?: PolicyResponse[] = [];
sends: SendResponse[] = [];
constructor(response: any) {
super(response);
const profile = this.getResponseProperty("Profile");
if (profile != null) {
this.profile = new ProfileResponse(profile);
}
const folders = this.getResponseProperty("Folders");
if (folders != null) {
this.folders = folders.map((f: any) => new FolderResponse(f));
}
const collections = this.getResponseProperty("Collections");
if (collections != null) {
this.collections = collections.map((c: any) => new CollectionDetailsResponse(c));
}
const ciphers = this.getResponseProperty("Ciphers");
if (ciphers != null) {
this.ciphers = ciphers.map((c: any) => new CipherResponse(c));
}
const domains = this.getResponseProperty("Domains");
if (domains != null) {
this.domains = new DomainsResponse(domains);
}
const policies = this.getResponseProperty("Policies");
if (policies != null) {
this.policies = policies.map((p: any) => new PolicyResponse(p));
}
const sends = this.getResponseProperty("Sends");
if (sends != null) {
this.sends = sends.map((s: any) => new SendResponse(s));
}
}
}

View File

@@ -0,0 +1,58 @@
import {
SyncCipherNotification,
SyncFolderNotification,
SyncSendNotification,
} from "../../models/response/notification.response";
/**
* A class encapsulating sync operations and data.
*/
export abstract class SyncService {
/**
* A boolean indicating if a sync is currently in progress via this instance and this instance only.
*
* @deprecated Trusting this property is not safe as it only tells if the current instance is currently
* doing a sync operation but does not tell if another instance of SyncService is doing a sync operation.
*/
abstract syncInProgress: boolean;
/**
* Gets the date of the last sync for the currently active user.
*
* @returns The date of the last sync or null if there is no active user or the active user has not synced before.
*/
abstract getLastSync(): Promise<Date>;
/**
* Updates a users last sync date.
* @param date The date to be set as the users last sync date.
* @param userId The userId of the user to update the last sync date for.
*/
abstract setLastSync(date: Date, userId?: string): Promise<void>;
/**
* Optionally does a full sync operation including going to the server to gather the source
* of truth and set that data to state.
* @param forceSync A boolean dictating if a sync should be forced. If `true` a sync will happen
* as long as the current user is authenticated. If `false` it will only sync if either a sync
* has not happened before or the last sync date for the active user is before their account
* revision date. Try to always use `false` if possible.
*
* @param allowThrowOnError A boolean dictating whether or not caught errors should be rethrown.
* `true` if they can be rethrown, `false` if they should not be rethrown.
*/
abstract fullSync(forceSync: boolean, allowThrowOnError?: boolean): Promise<boolean>;
abstract syncUpsertFolder(
notification: SyncFolderNotification,
isEdit: boolean,
): Promise<boolean>;
abstract syncDeleteFolder(notification: SyncFolderNotification): Promise<boolean>;
abstract syncUpsertCipher(
notification: SyncCipherNotification,
isEdit: boolean,
): Promise<boolean>;
abstract syncDeleteCipher(notification: SyncFolderNotification): Promise<boolean>;
abstract syncUpsertSend(notification: SyncSendNotification, isEdit: boolean): Promise<boolean>;
abstract syncDeleteSend(notification: SyncSendNotification): Promise<boolean>;
}