1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-23 16:13:21 +00:00

fix conflicts

This commit is contained in:
Bryan Cunningham
2025-07-25 10:47:54 -04:00
1410 changed files with 66442 additions and 28641 deletions

View File

@@ -1,18 +1,23 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CollectionDetailsResponse } from "@bitwarden/admin-console/common";
import { UserId } from "@bitwarden/common/types/guid";
import { CollectionAccessSelectionView, CollectionAdminView } from "../models";
export abstract class CollectionAdminService {
getAll: (organizationId: string) => Promise<CollectionAdminView[]>;
get: (organizationId: string, collectionId: string) => Promise<CollectionAdminView | undefined>;
save: (collection: CollectionAdminView) => Promise<CollectionDetailsResponse>;
delete: (organizationId: string, collectionId: string) => Promise<void>;
bulkAssignAccess: (
abstract getAll(organizationId: string): Promise<CollectionAdminView[]>;
abstract get(
organizationId: string,
collectionId: string,
): Promise<CollectionAdminView | undefined>;
abstract save(
collection: CollectionAdminView,
userId: UserId,
): Promise<CollectionDetailsResponse>;
abstract delete(organizationId: string, collectionId: string): Promise<void>;
abstract bulkAssignAccess(
organizationId: string,
collectionIds: string[],
users: CollectionAccessSelectionView[],
groups: CollectionAccessSelectionView[],
) => Promise<void>;
): Promise<void>;
}

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable } from "rxjs";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
@@ -9,27 +7,25 @@ import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CollectionData, Collection, CollectionView } from "../models";
export abstract class CollectionService {
encryptedCollections$: Observable<Collection[]>;
decryptedCollections$: Observable<CollectionView[]>;
clearActiveUserCache: () => Promise<void>;
encrypt: (model: CollectionView) => Promise<Collection>;
decryptedCollectionViews$: (ids: CollectionId[]) => Observable<CollectionView[]>;
abstract encryptedCollections$(userId: UserId): Observable<Collection[] | null>;
abstract decryptedCollections$(userId: UserId): Observable<CollectionView[]>;
abstract upsert(collection: CollectionData, userId: UserId): Promise<any>;
abstract replace(collections: { [id: string]: CollectionData }, userId: UserId): Promise<any>;
/**
* @deprecated This method will soon be made private
* See PM-12375
* @deprecated This method will soon be made private, use `decryptedCollections$` instead.
*/
decryptMany: (
abstract decryptMany$(
collections: Collection[],
orgKeys?: Record<OrganizationId, OrgKey>,
) => Promise<CollectionView[]>;
get: (id: string) => Promise<Collection>;
getAll: () => Promise<Collection[]>;
getAllDecrypted: () => Promise<CollectionView[]>;
getAllNested: (collections?: CollectionView[]) => Promise<TreeNode<CollectionView>[]>;
getNested: (id: string) => Promise<TreeNode<CollectionView>>;
upsert: (collection: CollectionData | CollectionData[]) => Promise<any>;
replace: (collections: { [id: string]: CollectionData }, userId: UserId) => Promise<any>;
clear: (userId?: string) => Promise<void>;
delete: (id: string | string[]) => Promise<any>;
orgKeys: Record<OrganizationId, OrgKey>,
): Observable<CollectionView[]>;
abstract delete(ids: CollectionId[], userId: UserId): Promise<any>;
abstract encrypt(model: CollectionView, userId: UserId): Promise<Collection>;
/**
* Transforms the input CollectionViews into TreeNodes
*/
abstract getAllNested(collections: CollectionView[]): TreeNode<CollectionView>[];
/*
* Transforms the input CollectionViews into TreeNodes and then returns the Treenode with the specified id
*/
abstract getNested(collections: CollectionView[], id: string): TreeNode<CollectionView>;
}

View File

@@ -1,43 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable } from "rxjs";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CollectionData, Collection, CollectionView } from "../models";
export abstract class vNextCollectionService {
encryptedCollections$: (userId: UserId) => Observable<Collection[]>;
decryptedCollections$: (userId: UserId) => Observable<CollectionView[]>;
upsert: (collection: CollectionData | CollectionData[], userId: UserId) => Promise<any>;
replace: (collections: { [id: string]: CollectionData }, userId: UserId) => Promise<any>;
/**
* Clear decrypted state without affecting encrypted state.
* Used for locking the vault.
*/
clearDecryptedState: (userId: UserId) => Promise<void>;
/**
* Clear decrypted and encrypted state.
* Used for logging out.
*/
clear: (userId: UserId) => Promise<void>;
delete: (id: string | string[], userId: UserId) => Promise<any>;
encrypt: (model: CollectionView) => Promise<Collection>;
/**
* @deprecated This method will soon be made private, use `decryptedCollections$` instead.
*/
decryptMany: (
collections: Collection[],
orgKeys?: Record<OrganizationId, OrgKey> | null,
) => Promise<CollectionView[]>;
/**
* Transforms the input CollectionViews into TreeNodes
*/
getAllNested: (collections: CollectionView[]) => TreeNode<CollectionView>[];
/**
* Transforms the input CollectionViews into TreeNodes and then returns the Treenode with the specified id
*/
getNested: (collections: CollectionView[], id: string) => TreeNode<CollectionView>;
}

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionAccessSelectionView } from "./collection-access-selection.view";
@@ -16,12 +14,12 @@ export class CollectionAdminView extends CollectionView {
* Flag indicating the collection has no active user or group assigned to it with CanManage permissions
* In this case, the collection can be managed by admins/owners or custom users with appropriate permissions
*/
unmanaged: boolean;
unmanaged: boolean = false;
/**
* Flag indicating the user has been explicitly assigned to this Collection
*/
assigned: boolean;
assigned: boolean = false;
constructor(response?: CollectionAccessDetailsResponse) {
super(response);
@@ -45,6 +43,10 @@ export class CollectionAdminView extends CollectionView {
* Returns true if the user can edit a collection (including user and group access) from the Admin Console.
*/
override canEdit(org: Organization): boolean {
if (this.isDefaultCollection) {
return false;
}
return (
org?.canEditAnyCollection ||
(this.unmanaged && org?.canEditUnmanagedCollections) ||
@@ -56,6 +58,10 @@ export class CollectionAdminView extends CollectionView {
* Returns true if the user can delete a collection from the Admin Console.
*/
override canDelete(org: Organization): boolean {
if (this.isDefaultCollection) {
return false;
}
return org?.canDeleteAnyCollection || super.canDelete(org);
}
@@ -63,6 +69,10 @@ export class CollectionAdminView extends CollectionView {
* Whether the user can modify user access to this collection
*/
canEditUserAccess(org: Organization): boolean {
if (this.isDefaultCollection) {
return false;
}
return (
(org.permissions.manageUsers && org.allowAdminAccessToAllCollectionItems) || this.canEdit(org)
);
@@ -72,6 +82,10 @@ export class CollectionAdminView extends CollectionView {
* Whether the user can modify group access to this collection
*/
canEditGroupAccess(org: Organization): boolean {
if (this.isDefaultCollection) {
return false;
}
return (
(org.permissions.manageGroups && org.allowAdminAccessToAllCollectionItems) ||
this.canEdit(org)
@@ -82,11 +96,13 @@ export class CollectionAdminView extends CollectionView {
* Returns true if the user can view collection info and access in a read-only state from the Admin Console
*/
override canViewCollectionInfo(org: Organization | undefined): boolean {
if (this.isUnassignedCollection) {
if (this.isUnassignedCollection || this.isDefaultCollection) {
return false;
}
const isAdmin = org?.isAdmin ?? false;
const permissions = org?.permissions.editAnyCollection ?? false;
return this.manage || org?.isAdmin || org?.permissions.editAnyCollection;
return this.manage || isAdmin || permissions;
}
/**

View File

@@ -26,7 +26,10 @@ export class CollectionData {
this.type = response.type;
}
static fromJSON(obj: Jsonify<CollectionData>) {
static fromJSON(obj: Jsonify<CollectionData | null>): CollectionData | null {
if (obj == null) {
return null;
}
return Object.assign(new CollectionData(new CollectionDetailsResponse({})), obj);
}
}

View File

@@ -1,7 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import Domain from "@bitwarden/common/platform/models/domain/domain-base";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import Domain, { EncryptableKeys } from "@bitwarden/common/platform/models/domain/domain-base";
import { OrgKey } from "@bitwarden/common/types/key";
import { CollectionData } from "./collection.data";
@@ -15,16 +13,16 @@ export const CollectionTypes = {
export type CollectionType = (typeof CollectionTypes)[keyof typeof CollectionTypes];
export class Collection extends Domain {
id: string;
organizationId: string;
name: EncString;
externalId: string;
readOnly: boolean;
hidePasswords: boolean;
manage: boolean;
type: CollectionType;
id: string | undefined;
organizationId: string | undefined;
name: EncString | undefined;
externalId: string | undefined;
readOnly: boolean = false;
hidePasswords: boolean = false;
manage: boolean = false;
type: CollectionType = CollectionTypes.SharedCollection;
constructor(obj?: CollectionData) {
constructor(obj?: CollectionData | null) {
super();
if (obj == null) {
return;
@@ -51,8 +49,8 @@ export class Collection extends Domain {
return this.decryptObj<Collection, CollectionView>(
this,
new CollectionView(this),
["name"],
this.organizationId,
["name"] as EncryptableKeys<Collection, CollectionView>[],
this.organizationId ?? null,
orgKey,
);
}

View File

@@ -1,27 +1,25 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { View } from "@bitwarden/common/models/view/view";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
import { Collection, CollectionType } from "./collection";
import { Collection, CollectionType, CollectionTypes } from "./collection";
import { CollectionAccessDetailsResponse } from "./collection.response";
export const NestingDelimiter = "/";
export class CollectionView implements View, ITreeNodeObject {
id: string = null;
organizationId: string = null;
name: string = null;
externalId: string = null;
id: string | undefined;
organizationId: string | undefined;
name: string = "";
externalId: string | undefined;
// readOnly applies to the items within a collection
readOnly: boolean = null;
hidePasswords: boolean = null;
manage: boolean = null;
assigned: boolean = null;
type: CollectionType = null;
readOnly: boolean = false;
hidePasswords: boolean = false;
manage: boolean = false;
assigned: boolean = false;
type: CollectionType = CollectionTypes.SharedCollection;
constructor(c?: Collection | CollectionAccessDetailsResponse) {
if (!c) {
@@ -57,7 +55,11 @@ export class CollectionView implements View, ITreeNodeObject {
* Returns true if the user can edit a collection (including user and group access) from the individual vault.
* Does not include admin permissions - see {@link CollectionAdminView.canEdit}.
*/
canEdit(org: Organization): boolean {
canEdit(org: Organization | undefined): boolean {
if (this.isDefaultCollection) {
return false;
}
if (org != null && org.id !== this.organizationId) {
throw new Error(
"Id of the organization provided does not match the org id of the collection.",
@@ -71,7 +73,7 @@ export class CollectionView implements View, ITreeNodeObject {
* Returns true if the user can delete a collection from the individual vault.
* Does not include admin permissions - see {@link CollectionAdminView.canDelete}.
*/
canDelete(org: Organization): boolean {
canDelete(org: Organization | undefined): boolean {
if (org != null && org.id !== this.organizationId) {
throw new Error(
"Id of the organization provided does not match the org id of the collection.",
@@ -81,7 +83,7 @@ export class CollectionView implements View, ITreeNodeObject {
const canDeleteManagedCollections = !org?.limitCollectionDeletion || org.isAdmin;
// Only use individual permissions, not admin permissions
return canDeleteManagedCollections && this.manage;
return canDeleteManagedCollections && this.manage && !this.isDefaultCollection;
}
/**
@@ -94,4 +96,8 @@ export class CollectionView implements View, ITreeNodeObject {
static fromJSON(obj: Jsonify<CollectionView>) {
return Object.assign(new CollectionView(new Collection()), obj);
}
get isDefaultCollection() {
return this.type == CollectionTypes.DefaultUserCollection;
}
}

View File

@@ -0,0 +1,28 @@
import { Jsonify } from "type-fest";
import {
COLLECTION_DISK,
COLLECTION_MEMORY,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { CollectionId } from "@bitwarden/common/types/guid";
import { CollectionData, CollectionView } from "../models";
export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<
CollectionData | null,
CollectionId
>(COLLECTION_DISK, "collections", {
deserializer: (jsonData: Jsonify<CollectionData | null>) => CollectionData.fromJSON(jsonData),
clearOn: ["logout"],
});
export const DECRYPTED_COLLECTION_DATA_KEY = new UserKeyDefinition<CollectionView[] | null>(
COLLECTION_MEMORY,
"decryptedCollections",
{
deserializer: (obj: Jsonify<CollectionView[] | null>) =>
obj?.map((f) => CollectionView.fromJSON(f)) ?? null,
clearOn: ["logout", "lock"],
},
);

View File

@@ -1,9 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { CollectionId, UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
import { CollectionAdminService, CollectionService } from "../abstractions";
@@ -55,7 +57,7 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
return view;
}
async save(collection: CollectionAdminView): Promise<CollectionDetailsResponse> {
async save(collection: CollectionAdminView, userId: UserId): Promise<CollectionDetailsResponse> {
const request = await this.encrypt(collection);
let response: CollectionDetailsResponse;
@@ -71,9 +73,9 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
}
if (response.assigned) {
await this.collectionService.upsert(new CollectionData(response));
await this.collectionService.upsert(new CollectionData(response), userId);
} else {
await this.collectionService.delete(collection.id);
await this.collectionService.delete([collection.id as CollectionId], userId);
}
return response;

View File

@@ -1,10 +1,11 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { mock, MockProxy } from "jest-mock-extended";
import { combineLatest, first, firstValueFrom, of, ReplaySubject, takeWhile } from "rxjs";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import {
FakeStateProvider,
@@ -16,124 +17,382 @@ import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/gu
import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import { CollectionData } from "../models";
import { CollectionData, CollectionView } from "../models";
import {
DefaultCollectionService,
ENCRYPTED_COLLECTION_DATA_KEY,
} from "./default-collection.service";
import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state";
import { DefaultCollectionService } from "./default-collection.service";
describe("DefaultCollectionService", () => {
let keyService: MockProxy<KeyService>;
let encryptService: MockProxy<EncryptService>;
let i18nService: MockProxy<I18nService>;
let stateProvider: FakeStateProvider;
let userId: UserId;
let cryptoKeys: ReplaySubject<Record<OrganizationId, OrgKey> | null>;
let collectionService: DefaultCollectionService;
beforeEach(() => {
userId = Utils.newGuid() as UserId;
keyService = mock();
encryptService = mock();
i18nService = mock();
stateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
cryptoKeys = new ReplaySubject(1);
keyService.orgKeys$.mockReturnValue(cryptoKeys);
// Set up mock decryption
encryptService.decryptString
.calledWith(expect.any(EncString), expect.any(SymmetricCryptoKey))
.mockImplementation((encString, key) =>
Promise.resolve(encString.data.replace("ENC_", "DEC_")),
);
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
// Arrange i18nService so that sorting algorithm doesn't throw
i18nService.collator = null;
collectionService = new DefaultCollectionService(
keyService,
encryptService,
i18nService,
stateProvider,
);
});
afterEach(() => {
delete (window as any).bitwardenContainerService;
});
describe("decryptedCollections$", () => {
it("emits decrypted collections from state", async () => {
// Arrange test collections
// Arrange test data
const org1 = Utils.newGuid() as OrganizationId;
const org2 = Utils.newGuid() as OrganizationId;
const orgKey1 = makeSymmetricCryptoKey<OrgKey>(64, 1);
const collection1 = collectionDataFactory(org1);
const org2 = Utils.newGuid() as OrganizationId;
const orgKey2 = makeSymmetricCryptoKey<OrgKey>(64, 2);
const collection2 = collectionDataFactory(org2);
// Arrange state provider
const fakeStateProvider = mockStateProvider();
await fakeStateProvider.setUserState(ENCRYPTED_COLLECTION_DATA_KEY, {
[collection1.id]: collection1,
[collection2.id]: collection2,
// Arrange dependencies
await setEncryptedState([collection1, collection2]);
cryptoKeys.next({
[org1]: orgKey1,
[org2]: orgKey2,
});
// Arrange cryptoService - orgKeys and mock decryption
const cryptoService = mockCryptoService();
cryptoService.orgKeys$.mockReturnValue(
of({
[org1]: makeSymmetricCryptoKey<OrgKey>(),
[org2]: makeSymmetricCryptoKey<OrgKey>(),
}),
);
const result = await firstValueFrom(collectionService.decryptedCollections$(userId));
const collectionService = new DefaultCollectionService(
cryptoService,
mock<EncryptService>(),
mockI18nService(),
fakeStateProvider,
);
const result = await firstValueFrom(collectionService.decryptedCollections$);
// Assert emitted values
expect(result.length).toBe(2);
expect(result[0]).toMatchObject({
id: collection1.id,
name: "DECRYPTED_STRING",
});
expect(result[1]).toMatchObject({
id: collection2.id,
name: "DECRYPTED_STRING",
});
expect(result).toContainPartialObjects([
{
id: collection1.id,
name: "DEC_NAME_" + collection1.id,
},
{
id: collection2.id,
name: "DEC_NAME_" + collection2.id,
},
]);
// Assert that the correct org keys were used for each encrypted string
// This should be replaced with decryptString when the platform PR (https://github.com/bitwarden/clients/pull/14544) is merged
expect(encryptService.decryptString).toHaveBeenCalledWith(
expect.objectContaining(new EncString(collection1.name)),
orgKey1,
);
expect(encryptService.decryptString).toHaveBeenCalledWith(
expect.objectContaining(new EncString(collection2.name)),
orgKey2,
);
});
it("emits decrypted collections from in-memory state when available", async () => {
// Arrange test data
const org1 = Utils.newGuid() as OrganizationId;
const collection1 = collectionViewDataFactory(org1);
const org2 = Utils.newGuid() as OrganizationId;
const collection2 = collectionViewDataFactory(org2);
await setDecryptedState([collection1, collection2]);
const result = await firstValueFrom(collectionService.decryptedCollections$(userId));
// Assert emitted values
expect(result.length).toBe(2);
expect(result).toContainPartialObjects([
{
id: collection1.id,
name: "DEC_NAME_" + collection1.id,
},
{
id: collection2.id,
name: "DEC_NAME_" + collection2.id,
},
]);
// Ensure that the returned data came from the in-memory state, rather than from decryption.
expect(encryptService.decryptString).not.toHaveBeenCalled();
});
it("handles null collection state", async () => {
// Arrange test collections
// Arrange dependencies
await setEncryptedState(null);
cryptoKeys.next({});
const encryptedCollections = await firstValueFrom(
collectionService.encryptedCollections$(userId),
);
expect(encryptedCollections).toBe(null);
});
it("handles undefined orgKeys", (done) => {
// Arrange test data
const org1 = Utils.newGuid() as OrganizationId;
const collection1 = collectionDataFactory(org1);
const org2 = Utils.newGuid() as OrganizationId;
const collection2 = collectionDataFactory(org2);
// Arrange state provider
const fakeStateProvider = mockStateProvider();
await fakeStateProvider.setUserState(ENCRYPTED_COLLECTION_DATA_KEY, null);
// Emit a non-null value after the first undefined value has propagated
// This will cause the collections to emit, calling done()
cryptoKeys.pipe(first()).subscribe((val) => {
cryptoKeys.next({});
});
// Arrange cryptoService - orgKeys and mock decryption
const cryptoService = mockCryptoService();
cryptoService.orgKeys$.mockReturnValue(
of({
[org1]: makeSymmetricCryptoKey<OrgKey>(),
[org2]: makeSymmetricCryptoKey<OrgKey>(),
}),
);
collectionService
.decryptedCollections$(userId)
.pipe(takeWhile((val) => val.length != 2))
.subscribe({ complete: () => done() });
const collectionService = new DefaultCollectionService(
cryptoService,
mock<EncryptService>(),
mockI18nService(),
fakeStateProvider,
);
// Arrange dependencies
void setEncryptedState([collection1, collection2]).then(() => {
// Act: emit undefined
cryptoKeys.next(undefined);
keyService.activeUserOrgKeys$ = of(undefined);
});
});
const decryptedCollections = await firstValueFrom(collectionService.decryptedCollections$);
expect(decryptedCollections.length).toBe(0);
it("Decrypts one time for multiple simultaneous callers", async () => {
const decryptedMock: CollectionView[] = [{ id: "col1" }] as CollectionView[];
const decryptManySpy = jest
.spyOn(collectionService, "decryptMany$")
.mockReturnValue(of(decryptedMock));
const encryptedCollections = await firstValueFrom(collectionService.encryptedCollections$);
expect(encryptedCollections.length).toBe(0);
jest
.spyOn(collectionService as any, "encryptedCollections$")
.mockReturnValue(of([{ id: "enc1" }]));
jest.spyOn(keyService, "orgKeys$").mockReturnValue(of({ key: "fake-key" }));
// Simulate multiple subscribers
const obs1 = collectionService.decryptedCollections$(userId);
const obs2 = collectionService.decryptedCollections$(userId);
const obs3 = collectionService.decryptedCollections$(userId);
await firstValueFrom(combineLatest([obs1, obs2, obs3]));
// Expect decryptMany$ to be called only once
expect(decryptManySpy).toHaveBeenCalledTimes(1);
});
});
describe("encryptedCollections$", () => {
it("emits encrypted collections from state", async () => {
// Arrange test data
const collection1 = collectionDataFactory();
const collection2 = collectionDataFactory();
// Arrange dependencies
await setEncryptedState([collection1, collection2]);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result!.length).toBe(2);
expect(result).toContainPartialObjects([
{
id: collection1.id,
name: makeEncString("ENC_NAME_" + collection1.id),
},
{
id: collection2.id,
name: makeEncString("ENC_NAME_" + collection2.id),
},
]);
});
it("handles null collection state", async () => {
await setEncryptedState(null);
const decryptedCollections = await firstValueFrom(
collectionService.encryptedCollections$(userId),
);
expect(decryptedCollections).toBe(null);
});
});
describe("upsert", () => {
it("upserts to existing collections", async () => {
const org1 = Utils.newGuid() as OrganizationId;
const orgKey1 = makeSymmetricCryptoKey<OrgKey>(64, 1);
const collection1 = collectionDataFactory(org1);
await setEncryptedState([collection1]);
cryptoKeys.next({
[collection1.organizationId]: orgKey1,
});
const updatedCollection1 = Object.assign(new CollectionData({} as any), collection1, {
name: makeEncString("UPDATED_ENC_NAME_" + collection1.id).encryptedString,
});
await collectionService.upsert(updatedCollection1, userId);
const encryptedResult = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(encryptedResult!.length).toBe(1);
expect(encryptedResult).toContainPartialObjects([
{
id: collection1.id,
name: makeEncString("UPDATED_ENC_NAME_" + collection1.id),
},
]);
const decryptedResult = await firstValueFrom(collectionService.decryptedCollections$(userId));
expect(decryptedResult.length).toBe(1);
expect(decryptedResult).toContainPartialObjects([
{
id: collection1.id,
name: "UPDATED_DEC_NAME_" + collection1.id,
},
]);
});
it("upserts to a null state", async () => {
const org1 = Utils.newGuid() as OrganizationId;
const orgKey1 = makeSymmetricCryptoKey<OrgKey>(64, 1);
const collection1 = collectionDataFactory(org1);
cryptoKeys.next({
[collection1.organizationId]: orgKey1,
});
await setEncryptedState(null);
await collectionService.upsert(collection1, userId);
const encryptedResult = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(encryptedResult!.length).toBe(1);
expect(encryptedResult).toContainPartialObjects([
{
id: collection1.id,
name: makeEncString("ENC_NAME_" + collection1.id),
},
]);
const decryptedResult = await firstValueFrom(collectionService.decryptedCollections$(userId));
expect(decryptedResult.length).toBe(1);
expect(decryptedResult).toContainPartialObjects([
{
id: collection1.id,
name: "DEC_NAME_" + collection1.id,
},
]);
});
});
describe("replace", () => {
it("replaces all collections", async () => {
await setEncryptedState([collectionDataFactory(), collectionDataFactory()]);
const newCollection3 = collectionDataFactory();
await collectionService.replace(
{
[newCollection3.id]: newCollection3,
},
userId,
);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result!.length).toBe(1);
expect(result).toContainPartialObjects([
{
id: newCollection3.id,
name: makeEncString("ENC_NAME_" + newCollection3.id),
},
]);
});
});
describe("delete", () => {
it("deletes a collection", async () => {
const collection1 = collectionDataFactory();
const collection2 = collectionDataFactory();
await setEncryptedState([collection1, collection2]);
await collectionService.delete([collection1.id], userId);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result!.length).toEqual(1);
expect(result![0]).toMatchObject({ id: collection2.id });
});
it("deletes several collections", async () => {
const collection1 = collectionDataFactory();
const collection2 = collectionDataFactory();
const collection3 = collectionDataFactory();
await setEncryptedState([collection1, collection2, collection3]);
await collectionService.delete([collection1.id, collection3.id], userId);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result!.length).toEqual(1);
expect(result![0]).toMatchObject({ id: collection2.id });
});
it("handles null collections", async () => {
const collection1 = collectionDataFactory();
await setEncryptedState(null);
await collectionService.delete([collection1.id], userId);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result!.length).toEqual(0);
});
});
const setEncryptedState = (collectionData: CollectionData[] | null) =>
stateProvider.setUserState(
ENCRYPTED_COLLECTION_DATA_KEY,
collectionData == null ? null : Object.fromEntries(collectionData.map((c) => [c.id, c])),
userId,
);
const setDecryptedState = (collectionViews: CollectionView[] | null) =>
stateProvider.setUserState(DECRYPTED_COLLECTION_DATA_KEY, collectionViews, userId);
});
const mockI18nService = () => {
const i18nService = mock<I18nService>();
i18nService.collator = null; // this is a mock only, avoid use of this object
return i18nService;
};
const mockStateProvider = () => {
const userId = Utils.newGuid() as UserId;
return new FakeStateProvider(mockAccountServiceWith(userId));
};
const mockCryptoService = () => {
const keyService = mock<KeyService>();
const encryptService = mock<EncryptService>();
encryptService.decryptString
.calledWith(expect.any(EncString), expect.anything())
.mockResolvedValue("DECRYPTED_STRING");
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
return keyService;
};
const collectionDataFactory = (orgId: OrganizationId) => {
const collectionDataFactory = (orgId?: OrganizationId) => {
const collection = new CollectionData({} as any);
collection.id = Utils.newGuid() as CollectionId;
collection.organizationId = orgId;
collection.name = makeEncString("ENC_STRING").encryptedString;
collection.organizationId = orgId ?? (Utils.newGuid() as OrganizationId);
collection.name = makeEncString("ENC_NAME_" + collection.id).encryptedString ?? "";
return collection;
};
function collectionViewDataFactory(orgId?: OrganizationId): CollectionView {
const collectionView = new CollectionView();
collectionView.id = Utils.newGuid() as CollectionId;
collectionView.organizationId = orgId ?? (Utils.newGuid() as OrganizationId);
collectionView.name = "DEC_NAME_" + collectionView.id;
return collectionView;
}

View File

@@ -1,113 +1,193 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { combineLatest, firstValueFrom, map, Observable, of, shareReplay, switchMap } from "rxjs";
import { Jsonify } from "type-fest";
import {
combineLatest,
delayWhen,
filter,
firstValueFrom,
from,
map,
NEVER,
Observable,
of,
shareReplay,
switchMap,
} from "rxjs";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
ActiveUserState,
COLLECTION_DATA,
DeriveDefinition,
DerivedState,
StateProvider,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { KeyService } from "@bitwarden/key-management";
import { CollectionService } from "../abstractions";
import { CollectionService } from "../abstractions/collection.service";
import { Collection, CollectionData, CollectionView } from "../models";
export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<CollectionData, CollectionId>(
COLLECTION_DATA,
"collections",
{
deserializer: (jsonData: Jsonify<CollectionData>) => CollectionData.fromJSON(jsonData),
clearOn: ["logout"],
},
);
const DECRYPTED_COLLECTION_DATA_KEY = new DeriveDefinition<
[Record<CollectionId, CollectionData>, Record<OrganizationId, OrgKey>],
CollectionView[],
{ collectionService: DefaultCollectionService }
>(COLLECTION_DATA, "decryptedCollections", {
deserializer: (obj) => obj.map((collection) => CollectionView.fromJSON(collection)),
derive: async ([collections, orgKeys], { collectionService }) => {
if (collections == null) {
return [];
}
const data = Object.values(collections).map((c) => new Collection(c));
return await collectionService.decryptMany(data, orgKeys);
},
});
import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state";
const NestingDelimiter = "/";
export class DefaultCollectionService implements CollectionService {
private encryptedCollectionDataState: ActiveUserState<Record<CollectionId, CollectionData>>;
encryptedCollections$: Observable<Collection[]>;
private decryptedCollectionDataState: DerivedState<CollectionView[]>;
decryptedCollections$: Observable<CollectionView[]>;
decryptedCollectionViews$(ids: CollectionId[]): Observable<CollectionView[]> {
return this.decryptedCollections$.pipe(
map((collections) => collections.filter((c) => ids.includes(c.id as CollectionId))),
);
}
constructor(
private keyService: KeyService,
private encryptService: EncryptService,
private i18nService: I18nService,
protected stateProvider: StateProvider,
) {
this.encryptedCollectionDataState = this.stateProvider.getActive(ENCRYPTED_COLLECTION_DATA_KEY);
) {}
this.encryptedCollections$ = this.encryptedCollectionDataState.state$.pipe(
private collectionViewCache = new Map<UserId, Observable<CollectionView[]>>();
/**
* @returns a SingleUserState for encrypted collection data.
*/
private encryptedState(
userId: UserId,
): SingleUserState<Record<CollectionId, CollectionData | null>> {
return this.stateProvider.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY);
}
/**
* @returns a SingleUserState for decrypted collection data.
*/
private decryptedState(userId: UserId): SingleUserState<CollectionView[] | null> {
return this.stateProvider.getUser(userId, DECRYPTED_COLLECTION_DATA_KEY);
}
encryptedCollections$(userId: UserId): Observable<Collection[] | null> {
return this.encryptedState(userId).state$.pipe(
map((collections) => {
if (collections == null) {
return [];
return null;
}
return Object.values(collections).map((c) => new Collection(c));
}),
);
}
const encryptedCollectionsWithKeys = this.encryptedCollectionDataState.combinedState$.pipe(
switchMap(([userId, collectionData]) =>
combineLatest([of(collectionData), this.keyService.orgKeys$(userId)]),
decryptedCollections$(userId: UserId): Observable<CollectionView[]> {
const cachedResult = this.collectionViewCache.get(userId);
if (cachedResult) {
return cachedResult;
}
const result$ = this.decryptedState(userId).state$.pipe(
switchMap((decryptedState) => {
// If decrypted state is already populated, return that
if (decryptedState !== null) {
return of(decryptedState ?? []);
}
return this.initializeDecryptedState(userId).pipe(switchMap(() => NEVER));
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.collectionViewCache.set(userId, result$);
return result$;
}
private initializeDecryptedState(userId: UserId): Observable<CollectionView[]> {
return combineLatest([
this.encryptedCollections$(userId),
this.keyService.orgKeys$(userId).pipe(filter((orgKeys) => !!orgKeys)),
]).pipe(
switchMap(([collections, orgKeys]) =>
this.decryptMany$(collections, orgKeys).pipe(
delayWhen((collections) => this.setDecryptedCollections(collections, userId)),
),
),
shareReplay({ refCount: false, bufferSize: 1 }),
);
this.decryptedCollectionDataState = this.stateProvider.getDerived(
encryptedCollectionsWithKeys,
DECRYPTED_COLLECTION_DATA_KEY,
{ collectionService: this },
);
this.decryptedCollections$ = this.decryptedCollectionDataState.state$;
}
async clearActiveUserCache(): Promise<void> {
await this.decryptedCollectionDataState.forceValue(null);
async upsert(toUpdate: CollectionData, userId: UserId): Promise<void> {
if (toUpdate == null) {
return;
}
await this.encryptedState(userId).update((collections) => {
if (collections == null) {
collections = {};
}
collections[toUpdate.id] = toUpdate;
return collections;
});
const decryptedCollections = await firstValueFrom(
this.keyService.orgKeys$(userId).pipe(
switchMap((orgKeys) => {
if (!orgKeys) {
throw new Error("No key for this collection's organization.");
}
return this.decryptMany$([new Collection(toUpdate)], orgKeys);
}),
),
);
await this.decryptedState(userId).update((collections) => {
if (collections == null) {
collections = [];
}
if (!decryptedCollections?.length) {
return collections;
}
const decryptedCollection = decryptedCollections[0];
const existingIndex = collections.findIndex((collection) => collection.id == toUpdate.id);
if (existingIndex >= 0) {
collections[existingIndex] = decryptedCollection;
} else {
collections.push(decryptedCollection);
}
return collections;
});
}
async encrypt(model: CollectionView): Promise<Collection> {
async replace(collections: Record<CollectionId, CollectionData>, userId: UserId): Promise<void> {
await this.encryptedState(userId).update(() => collections);
await this.decryptedState(userId).update(() => null);
}
async delete(ids: CollectionId[], userId: UserId): Promise<any> {
await this.encryptedState(userId).update((collections) => {
if (collections == null) {
collections = {};
}
ids.forEach((i) => {
delete collections[i];
});
return collections;
});
await this.decryptedState(userId).update((collections) => {
if (collections == null) {
collections = [];
}
ids.forEach((i) => {
if (collections?.length) {
collections = collections.filter((c) => c.id != i) ?? [];
}
});
return collections;
});
}
async encrypt(model: CollectionView, userId: UserId): Promise<Collection> {
if (model.organizationId == null) {
throw new Error("Collection has no organization id.");
}
const key = await this.keyService.getOrgKey(model.organizationId);
if (key == null) {
throw new Error("No key for this collection's organization.");
}
const key = await firstValueFrom(
this.keyService.orgKeys$(userId).pipe(
filter((orgKeys) => !!orgKeys),
map((k) => k[model.organizationId as OrganizationId]),
),
);
const collection = new Collection();
collection.id = model.id;
collection.organizationId = model.organizationId;
@@ -117,58 +197,37 @@ export class DefaultCollectionService implements CollectionService {
return collection;
}
// TODO: this should be private and orgKeys should be required.
// TODO: this should be private.
// See https://bitwarden.atlassian.net/browse/PM-12375
async decryptMany(
collections: Collection[],
orgKeys?: Record<OrganizationId, OrgKey>,
): Promise<CollectionView[]> {
if (collections == null || collections.length === 0) {
return [];
decryptMany$(
collections: Collection[] | null,
orgKeys: Record<OrganizationId, OrgKey>,
): Observable<CollectionView[]> {
if (collections === null || collections.length == 0 || orgKeys === null) {
return of([]);
}
const decCollections: CollectionView[] = [];
orgKeys ??= await firstValueFrom(this.keyService.activeUserOrgKeys$);
const decCollections: Observable<CollectionView>[] = [];
const promises: Promise<any>[] = [];
collections.forEach((collection) => {
promises.push(
collection
.decrypt(orgKeys[collection.organizationId as OrganizationId])
.then((c) => decCollections.push(c)),
decCollections.push(
from(collection.decrypt(orgKeys[collection.organizationId as OrganizationId])),
);
});
await Promise.all(promises);
return decCollections.sort(Utils.getSortFunction(this.i18nService, "name"));
}
async get(id: string): Promise<Collection> {
return (
(await firstValueFrom(
this.encryptedCollections$.pipe(map((cs) => cs.find((c) => c.id === id))),
)) ?? null
return combineLatest(decCollections).pipe(
map((collections) => collections.sort(Utils.getSortFunction(this.i18nService, "name"))),
);
}
async getAll(): Promise<Collection[]> {
return await firstValueFrom(this.encryptedCollections$);
}
async getAllDecrypted(): Promise<CollectionView[]> {
return await firstValueFrom(this.decryptedCollections$);
}
async getAllNested(collections: CollectionView[] = null): Promise<TreeNode<CollectionView>[]> {
if (collections == null) {
collections = await this.getAllDecrypted();
}
getAllNested(collections: CollectionView[]): TreeNode<CollectionView>[] {
const nodes: TreeNode<CollectionView>[] = [];
collections.forEach((c) => {
const collectionCopy = new CollectionView();
collectionCopy.id = c.id;
collectionCopy.organizationId = c.organizationId;
const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter);
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, undefined, NestingDelimiter);
});
return nodes;
}
@@ -177,58 +236,23 @@ export class DefaultCollectionService implements CollectionService {
* @deprecated August 30 2022: Moved to new Vault Filter Service
* Remove when Desktop and Browser are updated
*/
async getNested(id: string): Promise<TreeNode<CollectionView>> {
const collections = await this.getAllNested();
return ServiceUtils.getTreeNodeObjectFromList(collections, id) as TreeNode<CollectionView>;
getNested(collections: CollectionView[], id: string): TreeNode<CollectionView> {
const nestedCollections = this.getAllNested(collections);
return ServiceUtils.getTreeNodeObjectFromList(
nestedCollections,
id,
) as TreeNode<CollectionView>;
}
async upsert(toUpdate: CollectionData | CollectionData[]): Promise<void> {
if (toUpdate == null) {
return;
}
await this.encryptedCollectionDataState.update((collections) => {
if (collections == null) {
collections = {};
}
if (Array.isArray(toUpdate)) {
toUpdate.forEach((c) => {
collections[c.id] = c;
});
} else {
collections[toUpdate.id] = toUpdate;
}
return collections;
});
}
async replace(collections: Record<CollectionId, CollectionData>, userId: UserId): Promise<void> {
await this.stateProvider
.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY)
.update(() => collections);
}
async clear(userId?: UserId): Promise<void> {
if (userId == null) {
await this.encryptedCollectionDataState.update(() => null);
await this.decryptedCollectionDataState.forceValue(null);
} else {
await this.stateProvider.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY).update(() => null);
}
}
async delete(id: CollectionId | CollectionId[]): Promise<any> {
await this.encryptedCollectionDataState.update((collections) => {
if (collections == null) {
collections = {};
}
if (typeof id === "string") {
delete collections[id];
} else {
(id as CollectionId[]).forEach((i) => {
delete collections[i];
});
}
return collections;
});
/**
* Sets the decrypted collections state for a user.
* @param collections the decrypted collections
* @param userId the user id
*/
private async setDecryptedCollections(
collections: CollectionView[],
userId: UserId,
): Promise<void> {
await this.stateProvider.setUserState(DECRYPTED_COLLECTION_DATA_KEY, collections, userId);
}
}

View File

@@ -1,345 +0,0 @@
import { mock, MockProxy } from "jest-mock-extended";
import { first, firstValueFrom, of, ReplaySubject, takeWhile } from "rxjs";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import {
FakeStateProvider,
makeEncString,
makeSymmetricCryptoKey,
mockAccountServiceWith,
} from "@bitwarden/common/spec";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import { CollectionData } from "../models";
import { DefaultvNextCollectionService } from "./default-vnext-collection.service";
import { ENCRYPTED_COLLECTION_DATA_KEY } from "./vnext-collection.state";
describe("DefaultvNextCollectionService", () => {
let keyService: MockProxy<KeyService>;
let encryptService: MockProxy<EncryptService>;
let i18nService: MockProxy<I18nService>;
let stateProvider: FakeStateProvider;
let userId: UserId;
let cryptoKeys: ReplaySubject<Record<OrganizationId, OrgKey> | null>;
let collectionService: DefaultvNextCollectionService;
beforeEach(() => {
userId = Utils.newGuid() as UserId;
keyService = mock();
encryptService = mock();
i18nService = mock();
stateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
cryptoKeys = new ReplaySubject(1);
keyService.orgKeys$.mockReturnValue(cryptoKeys);
// Set up mock decryption
encryptService.decryptString
.calledWith(expect.any(EncString), expect.any(SymmetricCryptoKey))
.mockImplementation((encString, key) =>
Promise.resolve(encString.data.replace("ENC_", "DEC_")),
);
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
// Arrange i18nService so that sorting algorithm doesn't throw
i18nService.collator = null;
collectionService = new DefaultvNextCollectionService(
keyService,
encryptService,
i18nService,
stateProvider,
);
});
afterEach(() => {
delete (window as any).bitwardenContainerService;
});
describe("decryptedCollections$", () => {
it("emits decrypted collections from state", async () => {
// Arrange test data
const org1 = Utils.newGuid() as OrganizationId;
const orgKey1 = makeSymmetricCryptoKey<OrgKey>(64, 1);
const collection1 = collectionDataFactory(org1);
const org2 = Utils.newGuid() as OrganizationId;
const orgKey2 = makeSymmetricCryptoKey<OrgKey>(64, 2);
const collection2 = collectionDataFactory(org2);
// Arrange dependencies
await setEncryptedState([collection1, collection2]);
cryptoKeys.next({
[org1]: orgKey1,
[org2]: orgKey2,
});
const result = await firstValueFrom(collectionService.decryptedCollections$(userId));
// Assert emitted values
expect(result.length).toBe(2);
expect(result).toContainPartialObjects([
{
id: collection1.id,
name: "DEC_NAME_" + collection1.id,
},
{
id: collection2.id,
name: "DEC_NAME_" + collection2.id,
},
]);
// Assert that the correct org keys were used for each encrypted string
// This should be replaced with decryptString when the platform PR (https://github.com/bitwarden/clients/pull/14544) is merged
expect(encryptService.decryptString).toHaveBeenCalledWith(
expect.objectContaining(new EncString(collection1.name)),
orgKey1,
);
expect(encryptService.decryptString).toHaveBeenCalledWith(
expect.objectContaining(new EncString(collection2.name)),
orgKey2,
);
});
it("handles null collection state", async () => {
// Arrange dependencies
await setEncryptedState(null);
cryptoKeys.next({});
const encryptedCollections = await firstValueFrom(
collectionService.encryptedCollections$(userId),
);
expect(encryptedCollections.length).toBe(0);
});
it("handles undefined orgKeys", (done) => {
// Arrange test data
const org1 = Utils.newGuid() as OrganizationId;
const collection1 = collectionDataFactory(org1);
const org2 = Utils.newGuid() as OrganizationId;
const collection2 = collectionDataFactory(org2);
// Emit a non-null value after the first undefined value has propagated
// This will cause the collections to emit, calling done()
cryptoKeys.pipe(first()).subscribe((val) => {
cryptoKeys.next({});
});
collectionService
.decryptedCollections$(userId)
.pipe(takeWhile((val) => val.length != 2))
.subscribe({ complete: () => done() });
// Arrange dependencies
void setEncryptedState([collection1, collection2]).then(() => {
// Act: emit undefined
cryptoKeys.next(undefined);
keyService.activeUserOrgKeys$ = of(undefined);
});
});
});
describe("encryptedCollections$", () => {
it("emits encrypted collections from state", async () => {
// Arrange test data
const collection1 = collectionDataFactory();
const collection2 = collectionDataFactory();
// Arrange dependencies
await setEncryptedState([collection1, collection2]);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result.length).toBe(2);
expect(result).toContainPartialObjects([
{
id: collection1.id,
name: makeEncString("ENC_NAME_" + collection1.id),
},
{
id: collection2.id,
name: makeEncString("ENC_NAME_" + collection2.id),
},
]);
});
it("handles null collection state", async () => {
await setEncryptedState(null);
const decryptedCollections = await firstValueFrom(
collectionService.encryptedCollections$(userId),
);
expect(decryptedCollections.length).toBe(0);
});
});
describe("upsert", () => {
it("upserts to existing collections", async () => {
const collection1 = collectionDataFactory();
const collection2 = collectionDataFactory();
await setEncryptedState([collection1, collection2]);
const updatedCollection1 = Object.assign(new CollectionData({} as any), collection1, {
name: makeEncString("UPDATED_ENC_NAME_" + collection1.id).encryptedString,
});
const newCollection3 = collectionDataFactory();
await collectionService.upsert([updatedCollection1, newCollection3], userId);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result.length).toBe(3);
expect(result).toContainPartialObjects([
{
id: collection1.id,
name: makeEncString("UPDATED_ENC_NAME_" + collection1.id),
},
{
id: collection2.id,
name: makeEncString("ENC_NAME_" + collection2.id),
},
{
id: newCollection3.id,
name: makeEncString("ENC_NAME_" + newCollection3.id),
},
]);
});
it("upserts to a null state", async () => {
const collection1 = collectionDataFactory();
await setEncryptedState(null);
await collectionService.upsert(collection1, userId);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result.length).toBe(1);
expect(result).toContainPartialObjects([
{
id: collection1.id,
name: makeEncString("ENC_NAME_" + collection1.id),
},
]);
});
});
describe("replace", () => {
it("replaces all collections", async () => {
await setEncryptedState([collectionDataFactory(), collectionDataFactory()]);
const newCollection3 = collectionDataFactory();
await collectionService.replace(
{
[newCollection3.id]: newCollection3,
},
userId,
);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result.length).toBe(1);
expect(result).toContainPartialObjects([
{
id: newCollection3.id,
name: makeEncString("ENC_NAME_" + newCollection3.id),
},
]);
});
});
it("clearDecryptedState", async () => {
await setEncryptedState([collectionDataFactory(), collectionDataFactory()]);
await collectionService.clearDecryptedState(userId);
// Encrypted state remains
const encryptedState = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(encryptedState.length).toEqual(2);
// Decrypted state is cleared
const decryptedState = await firstValueFrom(collectionService.decryptedCollections$(userId));
expect(decryptedState.length).toEqual(0);
});
it("clear", async () => {
await setEncryptedState([collectionDataFactory(), collectionDataFactory()]);
cryptoKeys.next({});
await collectionService.clear(userId);
// Encrypted state is cleared
const encryptedState = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(encryptedState.length).toEqual(0);
// Decrypted state is cleared
const decryptedState = await firstValueFrom(collectionService.decryptedCollections$(userId));
expect(decryptedState.length).toEqual(0);
});
describe("delete", () => {
it("deletes a collection", async () => {
const collection1 = collectionDataFactory();
const collection2 = collectionDataFactory();
await setEncryptedState([collection1, collection2]);
await collectionService.delete(collection1.id, userId);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result.length).toEqual(1);
expect(result[0]).toMatchObject({ id: collection2.id });
});
it("deletes several collections", async () => {
const collection1 = collectionDataFactory();
const collection2 = collectionDataFactory();
const collection3 = collectionDataFactory();
await setEncryptedState([collection1, collection2, collection3]);
await collectionService.delete([collection1.id, collection3.id], userId);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result.length).toEqual(1);
expect(result[0]).toMatchObject({ id: collection2.id });
});
it("handles null collections", async () => {
const collection1 = collectionDataFactory();
await setEncryptedState(null);
await collectionService.delete(collection1.id, userId);
const result = await firstValueFrom(collectionService.encryptedCollections$(userId));
expect(result.length).toEqual(0);
});
});
const setEncryptedState = (collectionData: CollectionData[] | null) =>
stateProvider.setUserState(
ENCRYPTED_COLLECTION_DATA_KEY,
collectionData == null ? null : Object.fromEntries(collectionData.map((c) => [c.id, c])),
userId,
);
});
const collectionDataFactory = (orgId?: OrganizationId) => {
const collection = new CollectionData({} as any);
collection.id = Utils.newGuid() as CollectionId;
collection.organizationId = orgId ?? (Utils.newGuid() as OrganizationId);
collection.name = makeEncString("ENC_NAME_" + collection.id).encryptedString;
return collection;
};

View File

@@ -1,194 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { combineLatest, filter, firstValueFrom, map } from "rxjs";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { StateProvider, DerivedState } from "@bitwarden/common/platform/state";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { KeyService } from "@bitwarden/key-management";
import { vNextCollectionService } from "../abstractions/vnext-collection.service";
import { Collection, CollectionData, CollectionView } from "../models";
import {
DECRYPTED_COLLECTION_DATA_KEY,
ENCRYPTED_COLLECTION_DATA_KEY,
} from "./vnext-collection.state";
const NestingDelimiter = "/";
export class DefaultvNextCollectionService implements vNextCollectionService {
constructor(
private keyService: KeyService,
private encryptService: EncryptService,
private i18nService: I18nService,
protected stateProvider: StateProvider,
) {}
encryptedCollections$(userId: UserId) {
return this.encryptedState(userId).state$.pipe(
map((collections) => {
if (collections == null) {
return [];
}
return Object.values(collections).map((c) => new Collection(c));
}),
);
}
decryptedCollections$(userId: UserId) {
return this.decryptedState(userId).state$.pipe(map((collections) => collections ?? []));
}
async upsert(toUpdate: CollectionData | CollectionData[], userId: UserId): Promise<void> {
if (toUpdate == null) {
return;
}
await this.encryptedState(userId).update((collections) => {
if (collections == null) {
collections = {};
}
if (Array.isArray(toUpdate)) {
toUpdate.forEach((c) => {
collections[c.id] = c;
});
} else {
collections[toUpdate.id] = toUpdate;
}
return collections;
});
}
async replace(collections: Record<CollectionId, CollectionData>, userId: UserId): Promise<void> {
await this.encryptedState(userId).update(() => collections);
}
async clearDecryptedState(userId: UserId): Promise<void> {
if (userId == null) {
throw new Error("User ID is required.");
}
await this.decryptedState(userId).forceValue([]);
}
async clear(userId: UserId): Promise<void> {
await this.encryptedState(userId).update(() => null);
// This will propagate from the encrypted state update, but by doing it explicitly
// the promise doesn't resolve until the update is complete.
await this.decryptedState(userId).forceValue([]);
}
async delete(id: CollectionId | CollectionId[], userId: UserId): Promise<any> {
await this.encryptedState(userId).update((collections) => {
if (collections == null) {
collections = {};
}
if (typeof id === "string") {
delete collections[id];
} else {
(id as CollectionId[]).forEach((i) => {
delete collections[i];
});
}
return collections;
});
}
async encrypt(model: CollectionView): Promise<Collection> {
if (model.organizationId == null) {
throw new Error("Collection has no organization id.");
}
const key = await this.keyService.getOrgKey(model.organizationId);
if (key == null) {
throw new Error("No key for this collection's organization.");
}
const collection = new Collection();
collection.id = model.id;
collection.organizationId = model.organizationId;
collection.readOnly = model.readOnly;
collection.externalId = model.externalId;
collection.name = await this.encryptService.encryptString(model.name, key);
return collection;
}
// TODO: this should be private and orgKeys should be required.
// See https://bitwarden.atlassian.net/browse/PM-12375
async decryptMany(
collections: Collection[],
orgKeys?: Record<OrganizationId, OrgKey> | null,
): Promise<CollectionView[]> {
if (collections == null || collections.length === 0) {
return [];
}
const decCollections: CollectionView[] = [];
orgKeys ??= await firstValueFrom(this.keyService.activeUserOrgKeys$);
const promises: Promise<any>[] = [];
collections.forEach((collection) => {
promises.push(
collection
.decrypt(orgKeys[collection.organizationId as OrganizationId])
.then((c) => decCollections.push(c)),
);
});
await Promise.all(promises);
return decCollections.sort(Utils.getSortFunction(this.i18nService, "name"));
}
getAllNested(collections: CollectionView[]): TreeNode<CollectionView>[] {
const nodes: TreeNode<CollectionView>[] = [];
collections.forEach((c) => {
const collectionCopy = new CollectionView();
collectionCopy.id = c.id;
collectionCopy.organizationId = c.organizationId;
const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, undefined, NestingDelimiter);
});
return nodes;
}
/**
* @deprecated August 30 2022: Moved to new Vault Filter Service
* Remove when Desktop and Browser are updated
*/
getNested(collections: CollectionView[], id: string): TreeNode<CollectionView> {
const nestedCollections = this.getAllNested(collections);
return ServiceUtils.getTreeNodeObjectFromList(
nestedCollections,
id,
) as TreeNode<CollectionView>;
}
/**
* @returns a SingleUserState for encrypted collection data.
*/
private encryptedState(userId: UserId) {
return this.stateProvider.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY);
}
/**
* @returns a SingleUserState for decrypted collection data.
*/
private decryptedState(userId: UserId): DerivedState<CollectionView[]> {
const encryptedCollectionsWithKeys$ = combineLatest([
this.encryptedCollections$(userId),
// orgKeys$ can emit null during brief moments on unlock and lock/logout, we want to ignore those intermediate states
this.keyService.orgKeys$(userId).pipe(filter((orgKeys) => orgKeys != null)),
]);
return this.stateProvider.getDerived(
encryptedCollectionsWithKeys$,
DECRYPTED_COLLECTION_DATA_KEY,
{
collectionService: this,
},
);
}
}

View File

@@ -1,36 +0,0 @@
import { Jsonify } from "type-fest";
import {
COLLECTION_DATA,
DeriveDefinition,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { vNextCollectionService } from "../abstractions/vnext-collection.service";
import { Collection, CollectionData, CollectionView } from "../models";
export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<CollectionData, CollectionId>(
COLLECTION_DATA,
"collections",
{
deserializer: (jsonData: Jsonify<CollectionData>) => CollectionData.fromJSON(jsonData),
clearOn: ["logout"],
},
);
export const DECRYPTED_COLLECTION_DATA_KEY = new DeriveDefinition<
[Collection[], Record<OrganizationId, OrgKey> | null],
CollectionView[],
{ collectionService: vNextCollectionService }
>(COLLECTION_DATA, "decryptedCollections", {
deserializer: (obj) => obj.map((collection) => CollectionView.fromJSON(collection)),
derive: async ([collections, orgKeys], { collectionService }) => {
if (collections == null) {
return [];
}
return await collectionService.decryptMany(collections, orgKeys);
},
});

View File

@@ -37,13 +37,6 @@ export abstract class OrganizationUserApiService {
},
): Promise<OrganizationUserDetailsResponse>;
/**
* Retrieve a list of groups Ids the specified organization user belongs to
* @param organizationId - Identifier for the user's organization
* @param id - Organization user identifier
*/
abstract getOrganizationUserGroups(organizationId: string, id: string): Promise<string[]>;
/**
* Retrieve full details of all users that belong to the specified organization.
* This is only accessible to privileged users, if you need a simple listing of basic details, use

View File

@@ -1,4 +1,4 @@
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
import { EncryptedString } from "@bitwarden/common/key-management/crypto/models/enc-string";
export class OrganizationUserConfirmRequest {
key: EncryptedString | undefined;

View File

@@ -48,17 +48,6 @@ export class DefaultOrganizationUserApiService implements OrganizationUserApiSer
return new OrganizationUserDetailsResponse(r);
}
async getOrganizationUserGroups(organizationId: string, id: string): Promise<string[]> {
const r = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/users/" + id + "/groups",
null,
true,
true,
);
return r;
}
async getAllUsers(
organizationId: string,
options?: {

View File

@@ -1,136 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { ToastService } from "@bitwarden/components";
@Directive()
export class CollectionsComponent implements OnInit {
@Input() cipherId: string;
@Input() allowSelectNone = false;
@Output() onSavedCollections = new EventEmitter();
formPromise: Promise<any>;
cipher: CipherView;
collectionIds: string[];
collections: CollectionView[] = [];
organization: Organization;
protected cipherDomain: Cipher;
constructor(
protected collectionService: CollectionService,
protected platformUtilsService: PlatformUtilsService,
protected i18nService: I18nService,
protected cipherService: CipherService,
protected organizationService: OrganizationService,
private logService: LogService,
private accountService: AccountService,
private toastService: ToastService,
) {}
async ngOnInit() {
await this.load();
}
async load() {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.cipherDomain = await this.loadCipher(activeUserId);
this.collectionIds = this.loadCipherCollections();
this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId);
this.collections = await this.loadCollections();
this.collections.forEach((c) => ((c as any).checked = false));
if (this.collectionIds != null) {
this.collections.forEach((c) => {
(c as any).checked = this.collectionIds != null && this.collectionIds.indexOf(c.id) > -1;
});
}
if (this.organization == null) {
this.organization = await firstValueFrom(
this.organizationService
.organizations$(activeUserId)
.pipe(
map((organizations) =>
organizations.find((org) => org.id === this.cipher.organizationId),
),
),
);
}
}
async submit(): Promise<boolean> {
const selectedCollectionIds = this.collections
.filter((c) => {
if (this.organization.canEditAllCiphers) {
return !!(c as any).checked;
} else {
return !!(c as any).checked && !c.readOnly;
}
})
.map((c) => c.id);
if (!this.allowSelectNone && selectedCollectionIds.length === 0) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("selectOneCollection"),
});
return false;
}
this.cipherDomain.collectionIds = selectedCollectionIds;
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.formPromise = this.saveCollections(activeUserId);
await this.formPromise;
this.onSavedCollections.emit();
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("editedItem"),
});
return true;
} catch (e) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: e.message,
});
return false;
}
}
protected loadCipher(userId: UserId) {
return this.cipherService.get(this.cipherId, userId);
}
protected loadCipherCollections() {
return this.cipherDomain.collectionIds;
}
protected async loadCollections() {
const allCollections = await this.collectionService.getAllDecrypted();
return allCollections.filter(
(c) => !c.readOnly && c.organizationId === this.cipher.organizationId,
);
}
protected saveCollections(userId: UserId) {
return this.cipherService.saveCollectionsWithServer(this.cipherDomain, userId);
}
}

View File

@@ -1,232 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, OnDestroy, OnInit } from "@angular/core";
import { Subject, firstValueFrom, map, switchMap, takeUntil } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
import { DialogService, ToastService } from "@bitwarden/components";
import { KdfConfig, KdfConfigService, KeyService } from "@bitwarden/key-management";
import { PasswordColorText } from "../../tools/password-strength/password-strength.component";
@Directive()
export class ChangePasswordComponent implements OnInit, OnDestroy {
masterPassword: string;
masterPasswordRetype: string;
formPromise: Promise<any>;
enforcedPolicyOptions: MasterPasswordPolicyOptions;
passwordStrengthResult: any;
color: string;
text: string;
leakedPassword: boolean;
minimumLength = Utils.minimumPasswordLength;
protected email: string;
protected kdfConfig: KdfConfig;
protected destroy$ = new Subject<void>();
constructor(
protected i18nService: I18nService,
protected keyService: KeyService,
protected messagingService: MessagingService,
protected platformUtilsService: PlatformUtilsService,
protected policyService: PolicyService,
protected dialogService: DialogService,
protected kdfConfigService: KdfConfigService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected accountService: AccountService,
protected toastService: ToastService,
) {}
async ngOnInit() {
this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)),
takeUntil(this.destroy$),
)
.subscribe(
(enforcedPasswordPolicyOptions) =>
(this.enforcedPolicyOptions ??= enforcedPasswordPolicyOptions),
);
if (this.enforcedPolicyOptions?.minLength) {
this.minimumLength = this.enforcedPolicyOptions.minLength;
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
async submit() {
if (!(await this.strongPassword())) {
return;
}
if (!(await this.setupSubmitActions())) {
return;
}
const [userId, email] = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])),
);
if (this.kdfConfig == null) {
this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
}
// Create new master key
const newMasterKey = await this.keyService.makeMasterKey(
this.masterPassword,
email.trim().toLowerCase(),
this.kdfConfig,
);
const newMasterKeyHash = await this.keyService.hashMasterKey(this.masterPassword, newMasterKey);
let newProtectedUserKey: [UserKey, EncString] = null;
const userKey = await this.keyService.getUserKey();
if (userKey == null) {
newProtectedUserKey = await this.keyService.makeUserKey(newMasterKey);
} else {
newProtectedUserKey = await this.keyService.encryptUserKeyWithMasterKey(newMasterKey);
}
await this.performSubmitActions(newMasterKeyHash, newMasterKey, newProtectedUserKey);
}
async setupSubmitActions(): Promise<boolean> {
// Override in sub-class
// Can be used for additional validation and/or other processes the should occur before changing passwords
return true;
}
async performSubmitActions(
newMasterKeyHash: string,
newMasterKey: MasterKey,
newUserKey: [UserKey, EncString],
) {
// Override in sub-class
}
async strongPassword(): Promise<boolean> {
if (this.masterPassword == null || this.masterPassword === "") {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordRequired"),
});
return false;
}
if (this.masterPassword.length < this.minimumLength) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordMinimumlength", this.minimumLength),
});
return false;
}
if (this.masterPassword !== this.masterPasswordRetype) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPassDoesntMatch"),
});
return false;
}
const strengthResult = this.passwordStrengthResult;
if (
this.enforcedPolicyOptions != null &&
!this.policyService.evaluateMasterPassword(
strengthResult.score,
this.masterPassword,
this.enforcedPolicyOptions,
)
) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordPolicyRequirementsNotMet"),
});
return false;
}
const weakPassword = strengthResult != null && strengthResult.score < 3;
if (weakPassword && this.leakedPassword) {
const result = await this.dialogService.openSimpleDialog({
title: { key: "weakAndExposedMasterPassword" },
content: { key: "weakAndBreachedMasterPasswordDesc" },
type: "warning",
});
if (!result) {
return false;
}
} else {
if (weakPassword) {
const result = await this.dialogService.openSimpleDialog({
title: { key: "weakMasterPassword" },
content: { key: "weakMasterPasswordDesc" },
type: "warning",
});
if (!result) {
return false;
}
}
if (this.leakedPassword) {
const result = await this.dialogService.openSimpleDialog({
title: { key: "exposedMasterPassword" },
content: { key: "exposedMasterPasswordDesc" },
type: "warning",
});
if (!result) {
return false;
}
}
}
return true;
}
async logOut() {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "logOut" },
content: { key: "logOutConfirmation" },
acceptButtonText: { key: "logOut" },
type: "warning",
});
if (confirmed) {
this.messagingService.send("logout");
}
}
getStrengthResult(result: any) {
this.passwordStrengthResult = result;
}
getPasswordScoreText(event: PasswordColorText) {
this.color = event.color;
this.text = event.text;
}
}

View File

@@ -1,302 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, of } from "rxjs";
import { filter, first, switchMap, tap } from "rxjs/operators";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
OrganizationUserApiService,
OrganizationUserResetPasswordEnrollmentRequest,
} from "@bitwarden/admin-console/common";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { OrganizationAutoEnrollStatusResponse } from "@bitwarden/common/admin-console/models/response/organization-auto-enroll-status.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
import { ChangePasswordComponent as BaseChangePasswordComponent } from "./change-password.component";
@Directive()
export class SetPasswordComponent extends BaseChangePasswordComponent implements OnInit {
syncLoading = true;
showPassword = false;
hint = "";
orgSsoIdentifier: string = null;
orgId: string;
resetPasswordAutoEnroll = false;
onSuccessfulChangePassword: () => Promise<void>;
successRoute = "vault";
activeUserId: UserId;
forceSetPasswordReason: ForceSetPasswordReason = ForceSetPasswordReason.None;
ForceSetPasswordReason = ForceSetPasswordReason;
constructor(
accountService: AccountService,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
i18nService: I18nService,
keyService: KeyService,
messagingService: MessagingService,
platformUtilsService: PlatformUtilsService,
private policyApiService: PolicyApiServiceAbstraction,
policyService: PolicyService,
protected router: Router,
private masterPasswordApiService: MasterPasswordApiService,
private apiService: ApiService,
private syncService: SyncService,
private route: ActivatedRoute,
private organizationApiService: OrganizationApiServiceAbstraction,
private organizationUserApiService: OrganizationUserApiService,
private userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
private ssoLoginService: SsoLoginServiceAbstraction,
dialogService: DialogService,
kdfConfigService: KdfConfigService,
private encryptService: EncryptService,
protected toastService: ToastService,
) {
super(
i18nService,
keyService,
messagingService,
platformUtilsService,
policyService,
dialogService,
kdfConfigService,
masterPasswordService,
accountService,
toastService,
);
}
async ngOnInit() {
// 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
super.ngOnInit();
await this.syncService.fullSync(true);
this.syncLoading = false;
this.activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
this.forceSetPasswordReason = await firstValueFrom(
this.masterPasswordService.forceSetPasswordReason$(this.activeUserId),
);
this.route.queryParams
.pipe(
first(),
switchMap((qParams) => {
if (qParams.identifier != null) {
return of(qParams.identifier);
} else {
// Try to get orgSsoId from state as fallback
// Note: this is primarily for the TDE user w/out MP obtains admin MP reset permission scenario.
return this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(this.activeUserId);
}
}),
filter((orgSsoId) => orgSsoId != null),
tap((orgSsoId: string) => {
this.orgSsoIdentifier = orgSsoId;
}),
switchMap((orgSsoId: string) => this.organizationApiService.getAutoEnrollStatus(orgSsoId)),
tap((orgAutoEnrollStatusResponse: OrganizationAutoEnrollStatusResponse) => {
this.orgId = orgAutoEnrollStatusResponse.id;
this.resetPasswordAutoEnroll = orgAutoEnrollStatusResponse.resetPasswordEnabled;
}),
switchMap((orgAutoEnrollStatusResponse: OrganizationAutoEnrollStatusResponse) =>
// Must get org id from response to get master password policy options
this.policyApiService.getMasterPasswordPolicyOptsForOrgUser(
orgAutoEnrollStatusResponse.id,
),
),
tap((masterPasswordPolicyOptions: MasterPasswordPolicyOptions) => {
this.enforcedPolicyOptions = masterPasswordPolicyOptions;
}),
)
.subscribe({
error: () => {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
},
});
}
async setupSubmitActions() {
this.kdfConfig = DEFAULT_KDF_CONFIG;
return true;
}
async performSubmitActions(
masterPasswordHash: string,
masterKey: MasterKey,
userKey: [UserKey, EncString],
) {
let keysRequest: KeysRequest | null = null;
let newKeyPair: [string, EncString] | null = null;
if (
this.forceSetPasswordReason !=
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission
) {
// Existing JIT provisioned user in a MP encryption org setting first password
// Users in this state will not already have a user asymmetric key pair so must create it for them
// We don't want to re-create the user key pair if the user already has one (TDE user case)
// in case we have a local private key, and are not sure whether it has been posted to the server, we post the local private key instead of generating a new one
const existingUserPrivateKey = (await firstValueFrom(
this.keyService.userPrivateKey$(this.activeUserId),
)) as Uint8Array;
const existingUserPublicKey = await firstValueFrom(
this.keyService.userPublicKey$(this.activeUserId),
);
if (existingUserPrivateKey != null && existingUserPublicKey != null) {
const existingUserPublicKeyB64 = Utils.fromBufferToB64(existingUserPublicKey);
newKeyPair = [
existingUserPublicKeyB64,
await this.encryptService.wrapDecapsulationKey(existingUserPrivateKey, userKey[0]),
];
} else {
newKeyPair = await this.keyService.makeKeyPair(userKey[0]);
}
keysRequest = new KeysRequest(newKeyPair[0], newKeyPair[1].encryptedString);
}
const request = new SetPasswordRequest(
masterPasswordHash,
userKey[1].encryptedString,
this.hint,
this.orgSsoIdentifier,
keysRequest,
this.kdfConfig.kdfType, //always PBKDF2 --> see this.setupSubmitActions
this.kdfConfig.iterations,
);
try {
if (this.resetPasswordAutoEnroll) {
this.formPromise = this.masterPasswordApiService
.setPassword(request)
.then(async () => {
await this.onSetPasswordSuccess(masterKey, userKey, newKeyPair);
return this.organizationApiService.getKeys(this.orgId);
})
.then(async (response) => {
if (response == null) {
throw new Error(this.i18nService.t("resetPasswordOrgKeysError"));
}
const publicKey = Utils.fromB64ToArray(response.publicKey);
// RSA Encrypt user key with organization public key
const userKey = await this.keyService.getUserKey();
const encryptedUserKey = await this.encryptService.encapsulateKeyUnsigned(
userKey,
publicKey,
);
const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest();
resetRequest.masterPasswordHash = masterPasswordHash;
resetRequest.resetPasswordKey = encryptedUserKey.encryptedString;
return this.organizationUserApiService.putOrganizationUserResetPasswordEnrollment(
this.orgId,
this.activeUserId,
resetRequest,
);
});
} else {
this.formPromise = this.masterPasswordApiService.setPassword(request).then(async () => {
await this.onSetPasswordSuccess(masterKey, userKey, newKeyPair);
});
}
await this.formPromise;
if (this.onSuccessfulChangePassword != null) {
// 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.onSuccessfulChangePassword();
} 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.router.navigate([this.successRoute]);
}
} catch {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
}
}
togglePassword(confirmField: boolean) {
this.showPassword = !this.showPassword;
document.getElementById(confirmField ? "masterPasswordRetype" : "masterPassword").focus();
}
protected async onSetPasswordSuccess(
masterKey: MasterKey,
userKey: [UserKey, EncString],
keyPair: [string, EncString] | null,
) {
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.None,
this.activeUserId,
);
// User now has a password so update account decryption options in state
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
);
userDecryptionOpts.hasMasterPassword = true;
await this.userDecryptionOptionsService.setUserDecryptionOptions(userDecryptionOpts);
await this.kdfConfigService.setKdfConfig(this.activeUserId, this.kdfConfig);
await this.masterPasswordService.setMasterKey(masterKey, this.activeUserId);
await this.keyService.setUserKey(userKey[0], this.activeUserId);
// Set private key only for new JIT provisioned users in MP encryption orgs
// Existing TDE users will have private key set on sync or on login
if (
keyPair !== null &&
this.forceSetPasswordReason !=
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission
) {
await this.keyService.setPrivateKey(keyPair[1].encryptedString, this.activeUserId);
}
const localMasterKeyHash = await this.keyService.hashMasterKey(
this.masterPassword,
masterKey,
HashPurpose.LocalAuthorization,
);
await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, this.activeUserId);
}
}

View File

@@ -1,141 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { Verification } from "@bitwarden/common/auth/types/verification";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { DialogService, ToastService } from "@bitwarden/components";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { ChangePasswordComponent as BaseChangePasswordComponent } from "./change-password.component";
@Directive()
export class UpdatePasswordComponent extends BaseChangePasswordComponent {
hint: string;
key: string;
enforcedPolicyOptions: MasterPasswordPolicyOptions;
showPassword = false;
currentMasterPassword: string;
onSuccessfulChangePassword: () => Promise<void>;
constructor(
protected router: Router,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
policyService: PolicyService,
keyService: KeyService,
messagingService: MessagingService,
private masterPasswordApiService: MasterPasswordApiService,
private userVerificationService: UserVerificationService,
private logService: LogService,
dialogService: DialogService,
kdfConfigService: KdfConfigService,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
accountService: AccountService,
toastService: ToastService,
) {
super(
i18nService,
keyService,
messagingService,
platformUtilsService,
policyService,
dialogService,
kdfConfigService,
masterPasswordService,
accountService,
toastService,
);
}
togglePassword(confirmField: boolean) {
this.showPassword = !this.showPassword;
document.getElementById(confirmField ? "masterPasswordRetype" : "masterPassword").focus();
}
async cancel() {
await this.router.navigate(["/vault"]);
}
async setupSubmitActions(): Promise<boolean> {
if (this.currentMasterPassword == null || this.currentMasterPassword === "") {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordRequired"),
});
return false;
}
const secret: Verification = {
type: VerificationType.MasterPassword,
secret: this.currentMasterPassword,
};
try {
await this.userVerificationService.verifyUser(secret);
} catch (e) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: e.message,
});
return false;
}
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
return true;
}
async performSubmitActions(
newMasterKeyHash: string,
newMasterKey: MasterKey,
newUserKey: [UserKey, EncString],
) {
try {
// Create Request
const request = new PasswordRequest();
request.masterPasswordHash = await this.keyService.hashMasterKey(
this.currentMasterPassword,
await this.keyService.getOrDeriveMasterKey(this.currentMasterPassword),
);
request.newMasterPasswordHash = newMasterKeyHash;
request.key = newUserKey[1].encryptedString;
// Update user's password
await this.masterPasswordApiService.postPassword(request);
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("masterPasswordChanged"),
message: this.i18nService.t("logBackIn"),
});
if (this.onSuccessfulChangePassword != null) {
// 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.onSuccessfulChangePassword();
} else {
this.messagingService.send("logout");
}
} catch (e) {
this.logService.error(e);
}
}
}

View File

@@ -1,232 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom, map } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
import { UpdateTdeOffboardingPasswordRequest } from "@bitwarden/common/auth/models/request/update-tde-offboarding-password.request";
import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request";
import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { ChangePasswordComponent as BaseChangePasswordComponent } from "./change-password.component";
@Directive()
export class UpdateTempPasswordComponent extends BaseChangePasswordComponent implements OnInit {
hint: string;
key: string;
enforcedPolicyOptions: MasterPasswordPolicyOptions;
showPassword = false;
reason: ForceSetPasswordReason = ForceSetPasswordReason.None;
verification: MasterPasswordVerification = {
type: VerificationType.MasterPassword,
secret: "",
};
onSuccessfulChangePassword: () => Promise<any>;
get requireCurrentPassword(): boolean {
return this.reason === ForceSetPasswordReason.WeakMasterPassword;
}
constructor(
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
policyService: PolicyService,
keyService: KeyService,
messagingService: MessagingService,
private masterPasswordApiService: MasterPasswordApiService,
private syncService: SyncService,
private logService: LogService,
private userVerificationService: UserVerificationService,
protected router: Router,
dialogService: DialogService,
kdfConfigService: KdfConfigService,
accountService: AccountService,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
toastService: ToastService,
) {
super(
i18nService,
keyService,
messagingService,
platformUtilsService,
policyService,
dialogService,
kdfConfigService,
masterPasswordService,
accountService,
toastService,
);
}
async ngOnInit() {
await this.syncService.fullSync(true);
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
this.reason = await firstValueFrom(this.masterPasswordService.forceSetPasswordReason$(userId));
// If we somehow end up here without a reason, go back to the home page
if (this.reason == ForceSetPasswordReason.None) {
// 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.router.navigate(["/"]);
return;
}
await super.ngOnInit();
}
get masterPasswordWarningText(): string {
if (this.reason == ForceSetPasswordReason.WeakMasterPassword) {
return this.i18nService.t("updateWeakMasterPasswordWarning");
} else if (this.reason == ForceSetPasswordReason.TdeOffboarding) {
return this.i18nService.t("tdeDisabledMasterPasswordRequired");
} else {
return this.i18nService.t("updateMasterPasswordWarning");
}
}
togglePassword(confirmField: boolean) {
this.showPassword = !this.showPassword;
document.getElementById(confirmField ? "masterPasswordRetype" : "masterPassword").focus();
}
async setupSubmitActions(): Promise<boolean> {
const [userId, email] = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])),
);
this.email = email;
this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
return true;
}
async submit() {
// Validation
if (!(await this.strongPassword())) {
return;
}
if (!(await this.setupSubmitActions())) {
return;
}
try {
// Create new key and hash new password
const newMasterKey = await this.keyService.makeMasterKey(
this.masterPassword,
this.email.trim().toLowerCase(),
this.kdfConfig,
);
const newPasswordHash = await this.keyService.hashMasterKey(
this.masterPassword,
newMasterKey,
);
// Grab user key
const userKey = await this.keyService.getUserKey();
// Encrypt user key with new master key
const newProtectedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
newMasterKey,
userKey,
);
await this.performSubmitActions(newPasswordHash, newMasterKey, newProtectedUserKey);
} catch (e) {
this.logService.error(e);
}
}
async performSubmitActions(
masterPasswordHash: string,
masterKey: MasterKey,
userKey: [UserKey, EncString],
) {
try {
switch (this.reason) {
case ForceSetPasswordReason.AdminForcePasswordReset:
this.formPromise = this.updateTempPassword(masterPasswordHash, userKey);
break;
case ForceSetPasswordReason.WeakMasterPassword:
this.formPromise = this.updatePassword(masterPasswordHash, userKey);
break;
case ForceSetPasswordReason.TdeOffboarding:
this.formPromise = this.updateTdeOffboardingPassword(masterPasswordHash, userKey);
break;
}
await this.formPromise;
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("updatedMasterPassword"),
});
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.None,
userId,
);
if (this.onSuccessfulChangePassword != null) {
// 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.onSuccessfulChangePassword();
} else {
this.messagingService.send("logout");
}
} catch (e) {
this.logService.error(e);
}
}
private async updateTempPassword(masterPasswordHash: string, userKey: [UserKey, EncString]) {
const request = new UpdateTempPasswordRequest();
request.key = userKey[1].encryptedString;
request.newMasterPasswordHash = masterPasswordHash;
request.masterPasswordHint = this.hint;
return this.masterPasswordApiService.putUpdateTempPassword(request);
}
private async updatePassword(newMasterPasswordHash: string, userKey: [UserKey, EncString]) {
const request = await this.userVerificationService.buildRequest(
this.verification,
PasswordRequest,
);
request.masterPasswordHint = this.hint;
request.newMasterPasswordHash = newMasterPasswordHash;
request.key = userKey[1].encryptedString;
return this.masterPasswordApiService.postPassword(request);
}
private async updateTdeOffboardingPassword(
masterPasswordHash: string,
userKey: [UserKey, EncString],
) {
const request = new UpdateTdeOffboardingPasswordRequest();
request.key = userKey[1].encryptedString;
request.newMasterPasswordHash = masterPasswordHash;
request.masterPasswordHint = this.hint;
return this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request);
}
}

View File

@@ -0,0 +1,15 @@
import { DeviceManagementComponentServiceAbstraction } from "./device-management-component.service.abstraction";
/**
* Default implementation of the device management component service
*/
export class DefaultDeviceManagementComponentService
implements DeviceManagementComponentServiceAbstraction
{
/**
* Show header information in web client
*/
showHeaderInformation(): boolean {
return true;
}
}

View File

@@ -0,0 +1,11 @@
/**
* Service abstraction for device management component
* Used to determine client-specific behavior
*/
export abstract class DeviceManagementComponentServiceAbstraction {
/**
* Whether to show header information (title, description, etc.) in the device management component
* @returns true if header information should be shown, false otherwise
*/
abstract showHeaderInformation(): boolean;
}

View File

@@ -0,0 +1,63 @@
<bit-item-group>
<bit-item *ngFor="let device of devices">
@if (device.pendingAuthRequest) {
<button
class="tw-relative"
bit-item-content
type="button"
[attr.tabindex]="device.pendingAuthRequest != null ? 0 : null"
(click)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
(keydown.enter)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
>
<!-- Default Content -->
<span class="tw-text-base">{{ device.displayName }}</span>
<!-- Default Trailing Content -->
<span class="tw-absolute tw-top-[6px] tw-right-3" slot="default-trailing">
<span bitBadge variant="warning">
{{ "requestPending" | i18n }}
</span>
</span>
<!-- Secondary Content -->
<span slot="secondary" class="tw-text-sm">
<span>{{ "needsApproval" | i18n }}</span>
<div>
<span class="tw-font-semibold"> {{ "firstLogin" | i18n }}: </span>
<span>{{ device.firstLogin | date: "medium" }}</span>
</div>
</span>
</button>
} @else {
<bit-item-content ngClass="tw-relative">
<!-- Default Content -->
<span class="tw-text-base">{{ device.displayName }}</span>
<!-- Default Trailing Content -->
<div
*ngIf="device.isCurrentDevice"
class="tw-absolute tw-top-[6px] tw-right-3"
slot="default-trailing"
>
<span bitBadge variant="primary">
{{ "currentSession" | i18n }}
</span>
</div>
<!-- Secondary Content -->
<div slot="secondary" class="tw-text-sm">
@if (device.isTrusted) {
<span>{{ "trusted" | i18n }}</span>
} @else {
<br />
}
<div>
<span class="tw-font-semibold">{{ "firstLogin" | i18n }}: </span>
<span>{{ device.firstLogin | date: "medium" }}</span>
</div>
</div>
</bit-item-content>
}
</bit-item>
</bit-item-group>

View File

@@ -0,0 +1,44 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { firstValueFrom } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { LoginApprovalComponent } from "@bitwarden/auth/angular";
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
import { BadgeModule, DialogService, ItemModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { DeviceDisplayData } from "./device-management.component";
import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
/** Displays user devices in an item list view */
@Component({
standalone: true,
selector: "auth-device-management-item-group",
templateUrl: "./device-management-item-group.component.html",
imports: [BadgeModule, CommonModule, ItemModule, I18nPipe],
})
export class DeviceManagementItemGroupComponent {
@Input() devices: DeviceDisplayData[] = [];
constructor(private dialogService: DialogService) {}
protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
if (pendingAuthRequest == null) {
return;
}
const loginApprovalDialog = LoginApprovalComponent.open(this.dialogService, {
notificationId: pendingAuthRequest.id,
});
const result = await firstValueFrom(loginApprovalDialog.closed);
if (result !== undefined && typeof result === "boolean") {
// Auth request was approved or denied, so clear the
// pending auth request and re-sort the device array
this.devices = clearAuthRequestAndResortDevices(this.devices, pendingAuthRequest);
}
}
}

View File

@@ -0,0 +1,62 @@
<bit-table-scroll [dataSource]="tableDataSource" [rowSize]="50">
<!-- Table Header -->
<ng-container header>
<th
*ngFor="let column of columnConfig"
[class]="column.headerClass"
bitCell
[bitSortable]="column.sortable ? column.name : ''"
[default]="column.name === 'loginStatus' ? 'desc' : false"
scope="col"
role="columnheader"
>
{{ column.title }}
</th>
</ng-container>
<!-- Table Rows -->
<ng-template bitRowDef let-device>
<!-- Column: Device Name -->
<td bitCell class="tw-flex tw-gap-2">
<div class="tw-flex tw-items-center tw-justify-center tw-w-10">
<i [class]="device.icon" class="bwi-lg" aria-hidden="true"></i>
</div>
<div>
@if (device.pendingAuthRequest) {
<a
bitLink
href="#"
appStopClick
(click)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
>
{{ device.displayName }}
</a>
<div class="tw-text-sm tw-text-muted">
{{ "needsApproval" | i18n }}
</div>
} @else {
<span>{{ device.displayName }}</span>
<div *ngIf="device.isTrusted" class="tw-text-sm tw-text-muted">
{{ "trusted" | i18n }}
</div>
}
</div>
</td>
<!-- Column: Login Status -->
<td bitCell>
<div class="tw-flex tw-gap-1">
<span *ngIf="device.isCurrentDevice" bitBadge variant="primary">
{{ "currentSession" | i18n }}
</span>
<span *ngIf="device.pendingAuthRequest" bitBadge variant="warning">
{{ "requestPending" | i18n }}
</span>
</div>
</td>
<!-- Column: First Login -->
<td bitCell>{{ device.firstLogin | date: "medium" }}</td>
</ng-template>
</bit-table-scroll>

View File

@@ -0,0 +1,86 @@
import { CommonModule } from "@angular/common";
import { Component, Input, OnChanges, SimpleChanges } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { LoginApprovalComponent } from "@bitwarden/auth/angular";
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
BadgeModule,
ButtonModule,
DialogService,
LinkModule,
TableDataSource,
TableModule,
} from "@bitwarden/components";
import { DeviceDisplayData } from "./device-management.component";
import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
/** Displays user devices in a sortable table view */
@Component({
standalone: true,
selector: "auth-device-management-table",
templateUrl: "./device-management-table.component.html",
imports: [BadgeModule, ButtonModule, CommonModule, JslibModule, LinkModule, TableModule],
})
export class DeviceManagementTableComponent implements OnChanges {
@Input() devices: DeviceDisplayData[] = [];
protected tableDataSource = new TableDataSource<DeviceDisplayData>();
protected readonly columnConfig = [
{
name: "displayName",
title: this.i18nService.t("device"),
headerClass: "tw-w-1/3",
sortable: true,
},
{
name: "loginStatus",
title: this.i18nService.t("loginStatus"),
headerClass: "tw-w-1/3",
sortable: true,
},
{
name: "firstLogin",
title: this.i18nService.t("firstLogin"),
headerClass: "tw-w-1/3",
sortable: true,
},
];
constructor(
private i18nService: I18nService,
private dialogService: DialogService,
) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes.devices) {
this.tableDataSource.data = this.devices;
}
}
protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
if (pendingAuthRequest == null) {
return;
}
const loginApprovalDialog = LoginApprovalComponent.open(this.dialogService, {
notificationId: pendingAuthRequest.id,
});
const result = await firstValueFrom(loginApprovalDialog.closed);
if (result !== undefined && typeof result === "boolean") {
// Auth request was approved or denied, so clear the
// pending auth request and re-sort the device array
this.tableDataSource.data = clearAuthRequestAndResortDevices(
this.devices,
pendingAuthRequest,
);
}
}
}

View File

@@ -0,0 +1,40 @@
<div *ngIf="showHeaderInfo" class="tw-mt-6 tw-mb-2 tw-pb-2.5">
<div class="tw-flex tw-items-center tw-gap-2 tw-mb-5">
<h1 class="tw-m-0">{{ "devices" | i18n }}</h1>
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-flex tw-items-center tw-size-4"
[bitPopoverTriggerFor]="infoPopover"
position="right-start"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</button>
<bit-popover [title]="'whatIsADevice' | i18n" #infoPopover>
<p>{{ "aDeviceIs" | i18n }}</p>
</bit-popover>
</div>
<p>
{{ "deviceListDescriptionTemp" | i18n }}
</p>
</div>
@if (initializing) {
<div class="tw-flex tw-justify-center tw-items-center tw-p-4">
<i class="bwi bwi-spinner bwi-spin tw-text-2xl" aria-hidden="true"></i>
</div>
} @else {
<!-- Table View: displays on medium to large screens -->
<auth-device-management-table
ngClass="tw-hidden md:tw-block"
[devices]="devices"
></auth-device-management-table>
<!-- List View: displays on small screens -->
<auth-device-management-item-group
ngClass="md:tw-hidden"
[devices]="devices"
></auth-device-management-item-group>
}

View File

@@ -0,0 +1,230 @@
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { firstValueFrom } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { AuthRequestApiServiceAbstraction } from "@bitwarden/auth/common";
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
import {
DevicePendingAuthRequest,
DeviceResponse,
} from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
import { DeviceView } from "@bitwarden/common/auth/abstractions/devices/views/device.view";
import { DeviceType, DeviceTypeMetadata } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { MessageListener } from "@bitwarden/common/platform/messaging";
import { ButtonModule, PopoverModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { DeviceManagementComponentServiceAbstraction } from "./device-management-component.service.abstraction";
import { DeviceManagementItemGroupComponent } from "./device-management-item-group.component";
import { DeviceManagementTableComponent } from "./device-management-table.component";
export interface DeviceDisplayData {
displayName: string;
firstLogin: Date;
icon: string;
id: string;
identifier: string;
isCurrentDevice: boolean;
isTrusted: boolean;
loginStatus: string;
pendingAuthRequest: DevicePendingAuthRequest | null;
}
/**
* The `DeviceManagementComponent` fetches user devices and passes them down
* to a child component for display.
*
* The specific child component that gets displayed depends on the viewport width:
* - Medium to Large screens = `bit-table` view
* - Small screens = `bit-item-group` view
*/
@Component({
standalone: true,
selector: "auth-device-management",
templateUrl: "./device-management.component.html",
imports: [
ButtonModule,
CommonModule,
DeviceManagementItemGroupComponent,
DeviceManagementTableComponent,
I18nPipe,
PopoverModule,
],
})
export class DeviceManagementComponent implements OnInit {
protected devices: DeviceDisplayData[] = [];
protected initializing = true;
protected showHeaderInfo = false;
constructor(
private authRequestApiService: AuthRequestApiServiceAbstraction,
private destroyRef: DestroyRef,
private deviceManagementComponentService: DeviceManagementComponentServiceAbstraction,
private devicesService: DevicesServiceAbstraction,
private i18nService: I18nService,
private messageListener: MessageListener,
private validationService: ValidationService,
) {
this.showHeaderInfo = this.deviceManagementComponentService.showHeaderInformation();
}
async ngOnInit() {
await this.loadDevices();
this.messageListener.allMessages$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((message) => {
if (
message.command === "openLoginApproval" &&
message.notificationId &&
typeof message.notificationId === "string"
) {
void this.upsertDeviceWithPendingAuthRequest(message.notificationId);
}
});
}
async loadDevices() {
try {
const devices = await firstValueFrom(this.devicesService.getDevices$());
const currentDevice = await firstValueFrom(this.devicesService.getCurrentDevice$());
if (!devices || !currentDevice) {
return;
}
this.devices = this.mapDevicesToDisplayData(devices, currentDevice);
} catch (e) {
this.validationService.showError(e);
} finally {
this.initializing = false;
}
}
private mapDevicesToDisplayData(
devices: DeviceView[],
currentDevice: DeviceResponse,
): DeviceDisplayData[] {
return devices
.map((device): DeviceDisplayData | null => {
if (!device.id) {
this.validationService.showError(new Error(this.i18nService.t("deviceIdMissing")));
return null;
}
if (device.type == undefined) {
this.validationService.showError(new Error(this.i18nService.t("deviceTypeMissing")));
return null;
}
if (!device.creationDate) {
this.validationService.showError(
new Error(this.i18nService.t("deviceCreationDateMissing")),
);
return null;
}
return {
displayName: this.devicesService.getReadableDeviceTypeName(device.type),
firstLogin: device.creationDate ? new Date(device.creationDate) : new Date(),
icon: this.getDeviceIcon(device.type),
id: device.id || "",
identifier: device.identifier ?? "",
isCurrentDevice: this.isCurrentDevice(device, currentDevice),
isTrusted: device.response?.isTrusted ?? false,
loginStatus: this.getLoginStatus(device, currentDevice),
pendingAuthRequest: device.response?.devicePendingAuthRequest ?? null,
};
})
.filter((device) => device !== null);
}
private async upsertDeviceWithPendingAuthRequest(authRequestId: string) {
const authRequestResponse = await this.authRequestApiService.getAuthRequest(authRequestId);
if (!authRequestResponse) {
return;
}
const upsertDevice: DeviceDisplayData = {
displayName: this.devicesService.getReadableDeviceTypeName(
authRequestResponse.requestDeviceTypeValue,
),
firstLogin: new Date(authRequestResponse.creationDate),
icon: this.getDeviceIcon(authRequestResponse.requestDeviceTypeValue),
id: "",
identifier: authRequestResponse.requestDeviceIdentifier,
isCurrentDevice: false,
isTrusted: false,
loginStatus: this.i18nService.t("requestPending"),
pendingAuthRequest: {
id: authRequestResponse.id,
creationDate: authRequestResponse.creationDate,
},
};
// If the device already exists in the DB, update the device id and first login date
if (authRequestResponse.requestDeviceIdentifier) {
const existingDevice = await firstValueFrom(
this.devicesService.getDeviceByIdentifier$(authRequestResponse.requestDeviceIdentifier),
);
if (existingDevice?.id && existingDevice.creationDate) {
upsertDevice.id = existingDevice.id;
upsertDevice.firstLogin = new Date(existingDevice.creationDate);
}
}
const existingDeviceIndex = this.devices.findIndex(
(device) => device.identifier === upsertDevice.identifier,
);
if (existingDeviceIndex >= 0) {
// Update existing device in device list
this.devices[existingDeviceIndex] = upsertDevice;
this.devices = [...this.devices];
} else {
// Add new device to device list
this.devices = [upsertDevice, ...this.devices];
}
}
private getLoginStatus(device: DeviceView, currentDevice: DeviceResponse): string {
if (this.isCurrentDevice(device, currentDevice)) {
return this.i18nService.t("currentSession");
}
if (this.hasPendingAuthRequest(device)) {
return this.i18nService.t("requestPending");
}
return "";
}
private isCurrentDevice(device: DeviceView, currentDevice: DeviceResponse): boolean {
return device.id === currentDevice.id;
}
private hasPendingAuthRequest(device: DeviceView): boolean {
return device.response?.devicePendingAuthRequest != null;
}
private getDeviceIcon(type: DeviceType): string {
const defaultIcon = "bwi bwi-desktop";
const categoryIconMap: Record<string, string> = {
webApp: "bwi bwi-browser",
desktop: "bwi bwi-desktop",
mobile: "bwi bwi-mobile",
cli: "bwi bwi-cli",
extension: "bwi bwi-puzzle",
sdk: "bwi bwi-desktop",
};
const metadata = DeviceTypeMetadata[type];
return metadata ? (categoryIconMap[metadata.category] ?? defaultIcon) : defaultIcon;
}
}

View File

@@ -0,0 +1,53 @@
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
import { DeviceDisplayData } from "./device-management.component";
export function clearAuthRequestAndResortDevices(
devices: DeviceDisplayData[],
pendingAuthRequest: DevicePendingAuthRequest,
): DeviceDisplayData[] {
return devices
.map((device) => {
if (device.pendingAuthRequest?.id === pendingAuthRequest.id) {
device.pendingAuthRequest = null;
device.loginStatus = "";
}
return device;
})
.sort(resortDevices);
}
/**
* After a device is approved/denied, it will still be at the beginning of the array,
* so we must resort the array to ensure it is in the correct order.
*
* This is a helper function that gets passed to the `Array.sort()` method
*/
function resortDevices(deviceA: DeviceDisplayData, deviceB: DeviceDisplayData) {
// Devices with a pending auth request should be first
if (deviceA.pendingAuthRequest) {
return -1;
}
if (deviceB.pendingAuthRequest) {
return 1;
}
// Next is the current device
if (deviceA.isCurrentDevice) {
return -1;
}
if (deviceB.isCurrentDevice) {
return 1;
}
// Then sort the rest by display name (alphabetically)
if (deviceA.displayName < deviceB.displayName) {
return -1;
}
if (deviceA.displayName > deviceB.displayName) {
return 1;
}
// Default
return 0;
}

View File

@@ -68,12 +68,9 @@ describe("AuthGuard", () => {
{ path: "", component: EmptyComponent },
{ path: "guarded-route", component: EmptyComponent, canActivate: [authGuard] },
{ path: "lock", component: EmptyComponent },
{ path: "set-password", component: EmptyComponent },
{ path: "set-password-jit", component: EmptyComponent },
{ path: "set-initial-password", component: EmptyComponent },
{ path: "update-temp-password", component: EmptyComponent },
{ path: "set-initial-password", component: EmptyComponent, canActivate: [authGuard] },
{ path: "change-password", component: EmptyComponent },
{ path: "remove-password", component: EmptyComponent },
{ path: "remove-password", component: EmptyComponent, canActivate: [authGuard] },
]),
],
providers: [
@@ -124,87 +121,59 @@ describe("AuthGuard", () => {
expect(router.url).toBe("/remove-password");
});
describe("given user is Unlocked", () => {
describe("given the PM16117_SetInitialPasswordRefactor feature flag is ON", () => {
const tests = [
ForceSetPasswordReason.SsoNewJitProvisionedUser,
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
ForceSetPasswordReason.TdeOffboarding,
];
describe("given user is Locked", () => {
it("should redirect to /set-initial-password when the user has ForceSetPasswordReaason.TdeOffboardingUntrustedDevice", async () => {
const { router } = setup(
AuthenticationStatus.Locked,
ForceSetPasswordReason.TdeOffboardingUntrustedDevice,
false,
);
describe("given user attempts to navigate to an auth guarded route", () => {
tests.forEach((reason) => {
it(`should redirect to /set-initial-password when the user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
reason,
false,
FeatureFlag.PM16117_SetInitialPasswordRefactor,
);
await router.navigate(["guarded-route"]);
expect(router.url).toBe("/set-initial-password");
});
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/set-initial-password");
});
});
});
it("should allow navigation to continue to /set-initial-password when the user has ForceSetPasswordReason.TdeOffboardingUntrustedDevice", async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.TdeOffboardingUntrustedDevice,
false,
);
describe("given user attempts to navigate to /set-initial-password", () => {
tests.forEach((reason) => {
it(`should allow navigation to continue to /set-initial-password when the user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
reason,
false,
FeatureFlag.PM16117_SetInitialPasswordRefactor,
);
await router.navigate(["/set-initial-password"]);
expect(router.url).toContain("/set-initial-password");
});
});
await router.navigate(["/set-initial-password"]);
expect(router.url).toContain("/set-initial-password");
});
describe("given user is Unlocked and ForceSetPasswordReason requires setting an initial password", () => {
const tests = [
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
ForceSetPasswordReason.TdeOffboarding,
];
describe("given user attempts to navigate to an auth guarded route", () => {
tests.forEach((reason) => {
it(`should redirect to /set-initial-password when the user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(AuthenticationStatus.Unlocked, reason, false);
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/set-initial-password");
});
});
});
describe("given the PM16117_SetInitialPasswordRefactor feature flag is OFF", () => {
const tests = [
{
reason: ForceSetPasswordReason.SsoNewJitProvisionedUser,
url: "/set-password-jit",
},
{
reason: ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
url: "/set-password",
},
{
reason: ForceSetPasswordReason.TdeOffboarding,
url: "/update-temp-password",
},
];
describe("given user attempts to navigate to /set-initial-password", () => {
tests.forEach((reason) => {
it(`should allow navigation to continue to /set-initial-password when the user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(AuthenticationStatus.Unlocked, reason, false);
describe("given user attempts to navigate to an auth guarded route", () => {
tests.forEach(({ reason, url }) => {
it(`should redirect to ${url} when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(AuthenticationStatus.Unlocked, reason);
await router.navigate(["/guarded-route"]);
expect(router.url).toContain(url);
});
});
});
describe("given user attempts to navigate to the set- or update- password route itself", () => {
tests.forEach(({ reason, url }) => {
it(`should allow navigation to continue to ${url} when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(AuthenticationStatus.Unlocked, reason);
await router.navigate([url]);
expect(router.url).toContain(url);
});
await router.navigate(["/set-initial-password"]);
expect(router.url).toContain("/set-initial-password");
});
});
});
describe("given the PM16117_ChangeExistingPasswordRefactor feature flag is ON", () => {
describe("given user is Unlocked and ForceSetPasswordReason requires changing an existing password", () => {
const tests = [
ForceSetPasswordReason.AdminForcePasswordReset,
ForceSetPasswordReason.WeakMasterPassword,
@@ -213,12 +182,7 @@ describe("AuthGuard", () => {
describe("given user attempts to navigate to an auth guarded route", () => {
tests.forEach((reason) => {
it(`should redirect to /change-password when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(
AuthenticationStatus.Unlocked,
reason,
false,
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
);
const { router } = setup(AuthenticationStatus.Unlocked, reason, false);
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/change-password");
@@ -233,7 +197,6 @@ describe("AuthGuard", () => {
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.AdminForcePasswordReset,
false,
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
);
await router.navigate(["/change-password"]);
@@ -242,34 +205,5 @@ describe("AuthGuard", () => {
});
});
});
describe("given the PM16117_ChangeExistingPasswordRefactor feature flag is OFF", () => {
const tests = [
ForceSetPasswordReason.AdminForcePasswordReset,
ForceSetPasswordReason.WeakMasterPassword,
];
describe("given user attempts to navigate to an auth guarded route", () => {
tests.forEach((reason) => {
it(`should redirect to /update-temp-password when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(AuthenticationStatus.Unlocked, reason);
await router.navigate(["guarded-route"]);
expect(router.url).toContain("/update-temp-password");
});
});
});
describe("given user attempts to navigate to /update-temp-password", () => {
tests.forEach((reason) => {
it(`should allow navigation to continue to /update-temp-password when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => {
const { router } = setup(AuthenticationStatus.Unlocked, reason);
await router.navigate(["/update-temp-password"]);
expect(router.url).toContain("/update-temp-password");
});
});
});
});
});
});

View File

@@ -14,10 +14,8 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
export const authGuard: CanActivateFn = async (
@@ -30,7 +28,6 @@ export const authGuard: CanActivateFn = async (
const keyConnectorService = inject(KeyConnectorService);
const accountService = inject(AccountService);
const masterPasswordService = inject(MasterPasswordServiceAbstraction);
const configService = inject(ConfigService);
const authStatus = await authService.getAuthStatus();
@@ -39,7 +36,36 @@ export const authGuard: CanActivateFn = async (
return false;
}
if (authStatus === AuthenticationStatus.Locked) {
const userId = (await firstValueFrom(accountService.activeAccount$)).id;
const forceSetPasswordReason = await firstValueFrom(
masterPasswordService.forceSetPasswordReason$(userId),
);
// User JIT provisioned into a master-password-encryption org
if (
authStatus === AuthenticationStatus.Locked &&
forceSetPasswordReason === ForceSetPasswordReason.SsoNewJitProvisionedUser &&
!routerState.url.includes("set-initial-password")
) {
return router.createUrlTree(["/set-initial-password"]);
}
// TDE Offboarding on untrusted device
if (
authStatus === AuthenticationStatus.Locked &&
forceSetPasswordReason === ForceSetPasswordReason.TdeOffboardingUntrustedDevice &&
!routerState.url.includes("set-initial-password")
) {
return router.createUrlTree(["/set-initial-password"]);
}
// We must add exemptions for the SsoNewJitProvisionedUser and TdeOffboardingUntrustedDevice scenarios as
// the "set-initial-password" route is guarded by the authGuard.
if (
authStatus === AuthenticationStatus.Locked &&
forceSetPasswordReason !== ForceSetPasswordReason.SsoNewJitProvisionedUser &&
forceSetPasswordReason !== ForceSetPasswordReason.TdeOffboardingUntrustedDevice
) {
if (routerState != null) {
messagingService.send("lockedUrl", { url: routerState.url });
}
@@ -55,57 +81,28 @@ export const authGuard: CanActivateFn = async (
return router.createUrlTree(["/remove-password"]);
}
const userId = (await firstValueFrom(accountService.activeAccount$)).id;
const forceSetPasswordReason = await firstValueFrom(
masterPasswordService.forceSetPasswordReason$(userId),
);
const isSetInitialPasswordFlagOn = await configService.getFeatureFlag(
FeatureFlag.PM16117_SetInitialPasswordRefactor,
);
const isChangePasswordFlagOn = await configService.getFeatureFlag(
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
);
// User JIT provisioned into a master-password-encryption org
// Handle cases where a user needs to set a password when they don't already have one:
// - TDE org user has been given "manage account recovery" permission
// - TDE offboarding on a trusted device, where we have access to their encryption key wrap with their new password
if (
forceSetPasswordReason === ForceSetPasswordReason.SsoNewJitProvisionedUser &&
!routerState.url.includes("set-password-jit") &&
(forceSetPasswordReason ===
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission ||
forceSetPasswordReason === ForceSetPasswordReason.TdeOffboarding) &&
!routerState.url.includes("set-initial-password")
) {
const route = isSetInitialPasswordFlagOn ? "/set-initial-password" : "/set-password-jit";
const route = "/set-initial-password";
return router.createUrlTree([route]);
}
// TDE org user has "manage account recovery" permission
// Handle cases where a user has a password but needs to set a new one:
// - Account recovery
// - Weak Password on login
if (
forceSetPasswordReason ===
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission &&
!routerState.url.includes("set-password") &&
!routerState.url.includes("set-initial-password")
(forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset ||
forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword) &&
!routerState.url.includes("change-password")
) {
const route = isSetInitialPasswordFlagOn ? "/set-initial-password" : "/set-password";
return router.createUrlTree([route]);
}
// TDE Offboarding
if (
forceSetPasswordReason === ForceSetPasswordReason.TdeOffboarding &&
!routerState.url.includes("update-temp-password") &&
!routerState.url.includes("set-initial-password")
) {
const route = isSetInitialPasswordFlagOn ? "/set-initial-password" : "/update-temp-password";
return router.createUrlTree([route]);
}
// Post- Account Recovery or Weak Password on login
if (
forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset ||
(forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword &&
!routerState.url.includes("update-temp-password") &&
!routerState.url.includes("change-password"))
) {
const route = isChangePasswordFlagOn ? "/change-password" : "/update-temp-password";
const route = "/change-password";
return router.createUrlTree([route]);
}

View File

@@ -4,3 +4,4 @@ export * from "./lock.guard";
export * from "./redirect/redirect.guard";
export * from "./tde-decryption-required.guard";
export * from "./unauth.guard";
export * from "./redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard";

View File

@@ -26,7 +26,6 @@ import { lockGuard } from "./lock.guard";
interface SetupParams {
authStatus: AuthenticationStatus;
canLock?: boolean;
isLegacyUser?: boolean;
clientType?: ClientType;
everHadUserKey?: boolean;
supportsDeviceTrust?: boolean;
@@ -43,7 +42,6 @@ describe("lockGuard", () => {
vaultTimeoutSettingsService.canLock.mockResolvedValue(setupParams.canLock);
const keyService: MockProxy<KeyService> = mock<KeyService>();
keyService.isLegacyUser.mockResolvedValue(setupParams.isLegacyUser);
keyService.everHadUserKey$.mockReturnValue(of(setupParams.everHadUserKey));
const platformUtilService: MockProxy<PlatformUtilsService> = mock<PlatformUtilsService>();
@@ -155,37 +153,10 @@ describe("lockGuard", () => {
expect(router.url).toBe("/");
});
it("should log user out if they are a legacy user on a desktop client", async () => {
const { router, messagingService } = setup({
authStatus: AuthenticationStatus.Locked,
canLock: true,
isLegacyUser: true,
clientType: ClientType.Desktop,
});
await router.navigate(["lock"]);
expect(router.url).toBe("/");
expect(messagingService.send).toHaveBeenCalledWith("logout");
});
it("should log user out if they are a legacy user on a browser extension client", async () => {
const { router, messagingService } = setup({
authStatus: AuthenticationStatus.Locked,
canLock: true,
isLegacyUser: true,
clientType: ClientType.Browser,
});
await router.navigate(["lock"]);
expect(router.url).toBe("/");
expect(messagingService.send).toHaveBeenCalledWith("logout");
});
it("should allow navigation to the lock route when device trust is supported, the user has a MP, and the user is coming from the login-initiated page", async () => {
const { router } = setup({
authStatus: AuthenticationStatus.Locked,
canLock: true,
isLegacyUser: false,
clientType: ClientType.Web,
everHadUserKey: false,
supportsDeviceTrust: true,
@@ -213,7 +184,6 @@ describe("lockGuard", () => {
const { router } = setup({
authStatus: AuthenticationStatus.Locked,
canLock: true,
isLegacyUser: false,
clientType: ClientType.Web,
everHadUserKey: false,
supportsDeviceTrust: true,

View File

@@ -13,7 +13,6 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { KeyService } from "@bitwarden/key-management";
/**
@@ -31,7 +30,6 @@ export function lockGuard(): CanActivateFn {
const authService = inject(AuthService);
const keyService = inject(KeyService);
const deviceTrustService = inject(DeviceTrustServiceAbstraction);
const messagingService = inject(MessagingService);
const router = inject(Router);
const userVerificationService = inject(UserVerificationService);
const vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
@@ -56,11 +54,6 @@ export function lockGuard(): CanActivateFn {
return false;
}
if (await keyService.isLegacyUser()) {
messagingService.send("logout");
return false;
}
// User is authN and in locked state.
const tdeEnabled = await firstValueFrom(deviceTrustService.supportsDeviceTrust$);

View File

@@ -0,0 +1,19 @@
# RedirectToVaultIfUnlocked Guard
The `redirectToVaultIfUnlocked` redirects the user to `/vault` if they are `Unlocked`. Otherwise, it allows access to the route.
This is particularly useful for routes that can handle BOTH unauthenticated AND authenticated-but-locked users (which makes the `authGuard` unusable on those routes).
<br>
### Special Use Case - Authenticating in the Extension Popout
Imagine a user is going through the Login with Device flow in the Extension pop*out*:
- They open the pop*out* while on `/login-with-device`
- The approve the login from another device
- They are authenticated and routed to `/vault` while in the pop*out*
If the `redirectToVaultIfUnlocked` were NOT applied, if this user now opens the pop*up* they would be shown the `/login-with-device`, not their `/vault`.
But by adding the `redirectToVaultIfUnlocked` to `/login-with-device` we make sure to check if the user has already `Unlocked`, and if so, route them to `/vault` upon opening the pop*up*.

View File

@@ -0,0 +1,98 @@
import { TestBed } from "@angular/core/testing";
import { Router, provideRouter } from "@angular/router";
import { mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { UserId } from "@bitwarden/common/types/guid";
import { redirectToVaultIfUnlockedGuard } from "./redirect-to-vault-if-unlocked.guard";
describe("redirectToVaultIfUnlockedGuard", () => {
const activeUser: Account = {
id: "userId" as UserId,
email: "test@email.com",
emailVerified: true,
name: "Test User",
};
const setup = (activeUser: Account | null, authStatus: AuthenticationStatus | null) => {
const accountService = mock<AccountService>();
const authService = mock<AuthService>();
accountService.activeAccount$ = new BehaviorSubject<Account | null>(activeUser);
authService.authStatusFor$.mockReturnValue(of(authStatus));
const testBed = TestBed.configureTestingModule({
providers: [
{ provide: AccountService, useValue: accountService },
{ provide: AuthService, useValue: authService },
provideRouter([
{ path: "", component: EmptyComponent },
{ path: "vault", component: EmptyComponent },
{
path: "guarded-route",
component: EmptyComponent,
canActivate: [redirectToVaultIfUnlockedGuard()],
},
]),
],
});
return {
router: testBed.inject(Router),
};
};
it("should be created", () => {
const { router } = setup(null, null);
expect(router).toBeTruthy();
});
it("should redirect to /vault if the user is AuthenticationStatus.Unlocked", async () => {
// Arrange
const { router } = setup(activeUser, AuthenticationStatus.Unlocked);
// Act
await router.navigate(["guarded-route"]);
// Assert
expect(router.url).toBe("/vault");
});
it("should allow navigation to continue to the route if there is no active user", async () => {
// Arrange
const { router } = setup(null, null);
// Act
await router.navigate(["guarded-route"]);
// Assert
expect(router.url).toBe("/guarded-route");
});
it("should allow navigation to continue to the route if the user is AuthenticationStatus.LoggedOut", async () => {
// Arrange
const { router } = setup(null, AuthenticationStatus.LoggedOut);
// Act
await router.navigate(["guarded-route"]);
// Assert
expect(router.url).toBe("/guarded-route");
});
it("should allow navigation to continue to the route if the user is AuthenticationStatus.Locked", async () => {
// Arrange
const { router } = setup(null, AuthenticationStatus.Locked);
// Act
await router.navigate(["guarded-route"]);
// Assert
expect(router.url).toBe("/guarded-route");
});
});

View File

@@ -0,0 +1,36 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
/**
* Redirects the user to `/vault` if they are `Unlocked`. Otherwise, it allows access to the route.
* See ./redirect-to-vault-if-unlocked/README.md for more details.
*/
export function redirectToVaultIfUnlockedGuard(): CanActivateFn {
return async () => {
const accountService = inject(AccountService);
const authService = inject(AuthService);
const router = inject(Router);
const activeUser = await firstValueFrom(accountService.activeAccount$);
// If there is no active user, allow access to the route
if (!activeUser) {
return true;
}
const authStatus = await firstValueFrom(authService.authStatusFor$(activeUser.id));
// If user is Unlocked, redirect to vault
if (authStatus === AuthenticationStatus.Unlocked) {
return router.createUrlTree(["/vault"]);
}
// If user is LoggedOut or Locked, allow access to the route
return true;
};
}

View File

@@ -6,6 +6,12 @@
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
} @else {
<bit-callout
*ngIf="this.forceSetPasswordReason !== ForceSetPasswordReason.AdminForcePasswordReset"
type="warning"
>{{ "changePasswordWarning" | i18n }}</bit-callout
>
<auth-input-password
[flow]="inputPasswordFlow"
[email]="email"
@@ -15,6 +21,8 @@
[inlineButtons]="true"
[primaryButtonText]="{ key: 'changeMasterPassword' }"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
[secondaryButtonText]="secondaryButtonText()"
(onSecondaryButtonClick)="logOut()"
>
</auth-input-password>
}

View File

@@ -0,0 +1,206 @@
import { CommonModule } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core";
import { firstValueFrom } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
InputPasswordComponent,
InputPasswordFlow,
PasswordInputResult,
} from "@bitwarden/auth/angular";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import {
AnonLayoutWrapperDataService,
DialogService,
ToastService,
Icons,
CalloutComponent,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { ChangePasswordService } from "./change-password.service.abstraction";
/**
* Change Password Component
*
* NOTE: The change password component uses the input-password component which will show the
* current password input form in some flows, although it could be left off. This is intentional
* and by design to maintain a strong security posture as some flows could have the user
* end up at a change password without having one before.
*/
@Component({
selector: "auth-change-password",
templateUrl: "change-password.component.html",
imports: [InputPasswordComponent, I18nPipe, CalloutComponent, CommonModule],
})
export class ChangePasswordComponent implements OnInit {
@Input() inputPasswordFlow: InputPasswordFlow = InputPasswordFlow.ChangePassword;
activeAccount: Account | null = null;
email?: string;
userId?: UserId;
masterPasswordPolicyOptions?: MasterPasswordPolicyOptions;
initializing = true;
submitting = false;
formPromise?: Promise<any>;
forceSetPasswordReason: ForceSetPasswordReason = ForceSetPasswordReason.None;
protected readonly ForceSetPasswordReason = ForceSetPasswordReason;
constructor(
private accountService: AccountService,
private changePasswordService: ChangePasswordService,
private i18nService: I18nService,
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private organizationInviteService: OrganizationInviteService,
private messagingService: MessagingService,
private policyService: PolicyService,
private toastService: ToastService,
private syncService: SyncService,
private dialogService: DialogService,
private logService: LogService,
) {}
async ngOnInit() {
this.activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (!this.activeAccount) {
throw new Error("No active active account found while trying to change passwords.");
}
this.userId = this.activeAccount.id;
this.email = this.activeAccount.email;
if (!this.userId) {
throw new Error("userId not found");
}
this.masterPasswordPolicyOptions = await firstValueFrom(
this.policyService.masterPasswordPolicyOptions$(this.userId),
);
this.forceSetPasswordReason = await firstValueFrom(
this.masterPasswordService.forceSetPasswordReason$(this.userId),
);
if (this.forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset) {
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
pageIcon: Icons.LockIcon,
pageTitle: { key: "updateMasterPassword" },
pageSubtitle: { key: "accountRecoveryUpdateMasterPasswordSubtitle" },
});
} else if (this.forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword) {
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
pageIcon: Icons.LockIcon,
pageTitle: { key: "updateMasterPassword" },
pageSubtitle: { key: "updateMasterPasswordSubtitle" },
maxWidth: "lg",
});
}
this.initializing = false;
}
async logOut() {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "logOut" },
content: { key: "logOutConfirmation" },
acceptButtonText: { key: "logOut" },
type: "warning",
});
if (confirmed) {
await this.organizationInviteService.clearOrganizationInvitation();
if (this.changePasswordService.clearDeeplinkState) {
await this.changePasswordService.clearDeeplinkState();
}
// TODO: PM-23515 eventually use the logout service instead of messaging service once it is available without circular dependencies
this.messagingService.send("logout");
}
}
async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.submitting = true;
try {
if (passwordInputResult.rotateUserKey) {
if (this.activeAccount == null) {
throw new Error("activeAccount not found");
}
if (
passwordInputResult.currentPassword == null ||
passwordInputResult.newPasswordHint == null
) {
throw new Error("currentPassword or newPasswordHint not found");
}
await this.syncService.fullSync(true);
await this.changePasswordService.rotateUserKeyMasterPasswordAndEncryptedData(
passwordInputResult.currentPassword,
passwordInputResult.newPassword,
this.activeAccount,
passwordInputResult.newPasswordHint,
);
} else {
if (!this.userId) {
throw new Error("userId not found");
}
if (this.forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset) {
await this.changePasswordService.changePasswordForAccountRecovery(
passwordInputResult,
this.userId,
);
} else {
await this.changePasswordService.changePassword(passwordInputResult, this.userId);
}
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("masterPasswordChanged"),
});
// TODO: PM-23515 eventually use the logout service instead of messaging service once it is available without circular dependencies
this.messagingService.send("logout");
// Close the popout if we are in a browser extension popout.
this.changePasswordService.closeBrowserExtensionPopout?.();
}
} catch (error) {
this.logService.error(error);
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("errorOccurred"),
});
} finally {
this.submitting = false;
}
}
/**
* Shows the logout button in the case of admin force reset password or weak password upon login.
*/
protected secondaryButtonText(): { key: string } | undefined {
return this.forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset ||
this.forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword
? { key: "logOut" }
: undefined;
}
}

View File

@@ -1,3 +1,5 @@
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { PasswordInputResult } from "@bitwarden/auth/angular";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
@@ -32,5 +34,35 @@ export abstract class ChangePasswordService {
* @param userId the `userId`
* @throws if the `userId`, `currentMasterKey`, or `currentServerMasterKeyHash` is not found
*/
abstract changePassword(passwordInputResult: PasswordInputResult, userId: UserId): Promise<void>;
abstract changePassword(
passwordInputResult: PasswordInputResult,
userId: UserId | null,
): Promise<void>;
/**
* Changes the user's password and re-encrypts the user key with the `newMasterKey`.
* - Specifically, this method uses credentials from the `passwordInputResult` to:
* 1. Decrypt the user key with the `currentMasterKey`
* 2. Re-encrypt that user key with the `newMasterKey`, resulting in a `newMasterKeyEncryptedUserKey`
* 3. Build a `PasswordRequest` object that gets PUTed to `"/accounts/update-temp-password"` so that the
* ForcePasswordReset gets set to false.
* @param passwordInputResult
* @param userId
*/
abstract changePasswordForAccountRecovery(
passwordInputResult: PasswordInputResult,
userId: UserId,
): Promise<void>;
/**
* Optional method that will clear up any deep link state.
* - Currently only used on the web change password service.
*/
clearDeeplinkState?: () => Promise<void>;
/**
* Optional method that closes the browser extension popout if in a popout
* If not in a popout, does nothing.
*/
abstract closeBrowserExtensionPopout?(): void;
}

View File

@@ -1,16 +1,17 @@
import { mock, MockProxy } from "jest-mock-extended";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { PasswordInputResult } from "@bitwarden/auth/angular";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { PasswordInputResult } from "../input-password/password-input-result";
import { ChangePasswordService } from "./change-password.service.abstraction";
import { DefaultChangePasswordService } from "./default-change-password.service";
@@ -109,7 +110,7 @@ describe("DefaultChangePasswordService", () => {
it("should throw if a currentMasterKey was not found", async () => {
// Arrange
const incorrectPasswordInputResult = { ...passwordInputResult };
incorrectPasswordInputResult.currentMasterKey = null;
incorrectPasswordInputResult.currentMasterKey = undefined;
// Act
const testFn = sut.changePassword(incorrectPasswordInputResult, userId);
@@ -123,7 +124,7 @@ describe("DefaultChangePasswordService", () => {
it("should throw if a currentServerMasterKeyHash was not found", async () => {
// Arrange
const incorrectPasswordInputResult = { ...passwordInputResult };
incorrectPasswordInputResult.currentServerMasterKeyHash = null;
incorrectPasswordInputResult.currentServerMasterKeyHash = undefined;
// Act
const testFn = sut.changePassword(incorrectPasswordInputResult, userId);
@@ -174,4 +175,43 @@ describe("DefaultChangePasswordService", () => {
);
});
});
describe("changePasswordForAccountRecovery()", () => {
it("should call the putUpdateTempPassword() API method with the correct UpdateTempPasswordRequest credentials", async () => {
// Act
await sut.changePasswordForAccountRecovery(passwordInputResult, userId);
// Assert
expect(masterPasswordApiService.putUpdateTempPassword).toHaveBeenCalledWith(
expect.objectContaining({
newMasterPasswordHash: passwordInputResult.newServerMasterKeyHash,
masterPasswordHint: passwordInputResult.newPasswordHint,
key: newMasterKeyEncryptedUserKey[1].encryptedString,
}),
);
});
it("should throw an error if user key decryption fails", async () => {
// Arrange
masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(null);
// Act
const testFn = sut.changePasswordForAccountRecovery(passwordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow("Could not decrypt user key");
});
it("should throw an error if putUpdateTempPassword() fails", async () => {
// Arrange
masterPasswordApiService.putUpdateTempPassword.mockRejectedValueOnce(new Error("error"));
// Act
const testFn = sut.changePasswordForAccountRecovery(passwordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow("Could not change password");
expect(masterPasswordApiService.putUpdateTempPassword).toHaveBeenCalled();
});
});
});

View File

@@ -1,11 +1,18 @@
import { PasswordInputResult, ChangePasswordService } from "@bitwarden/auth/angular";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { PasswordInputResult } from "@bitwarden/auth/angular";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import { ChangePasswordService } from "./change-password.service.abstraction";
export class DefaultChangePasswordService implements ChangePasswordService {
constructor(
protected keyService: KeyService,
@@ -22,7 +29,11 @@ export class DefaultChangePasswordService implements ChangePasswordService {
throw new Error("rotateUserKeyMasterPasswordAndEncryptedData() is only implemented in Web");
}
async changePassword(passwordInputResult: PasswordInputResult, userId: UserId) {
private async preparePasswordChange(
passwordInputResult: PasswordInputResult,
userId: UserId | null,
request: PasswordRequest | UpdateTempPasswordRequest,
): Promise<[UserKey, EncString]> {
if (!userId) {
throw new Error("userId not found");
}
@@ -45,15 +56,32 @@ export class DefaultChangePasswordService implements ChangePasswordService {
throw new Error("Could not decrypt user key");
}
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
const newKeyValue = await this.keyService.encryptUserKeyWithMasterKey(
passwordInputResult.newMasterKey,
decryptedUserKey,
);
if (request instanceof PasswordRequest) {
request.masterPasswordHash = passwordInputResult.currentServerMasterKeyHash;
request.newMasterPasswordHash = passwordInputResult.newServerMasterKeyHash;
request.masterPasswordHint = passwordInputResult.newPasswordHint;
} else if (request instanceof UpdateTempPasswordRequest) {
request.newMasterPasswordHash = passwordInputResult.newServerMasterKeyHash;
request.masterPasswordHint = passwordInputResult.newPasswordHint;
}
return newKeyValue;
}
async changePassword(passwordInputResult: PasswordInputResult, userId: UserId | null) {
const request = new PasswordRequest();
request.masterPasswordHash = passwordInputResult.currentServerMasterKeyHash;
request.newMasterPasswordHash = passwordInputResult.newServerMasterKeyHash;
request.masterPasswordHint = passwordInputResult.newPasswordHint;
const newMasterKeyEncryptedUserKey = await this.preparePasswordChange(
passwordInputResult,
userId,
request,
);
request.key = newMasterKeyEncryptedUserKey[1].encryptedString as string;
try {
@@ -62,4 +90,23 @@ export class DefaultChangePasswordService implements ChangePasswordService {
throw new Error("Could not change password");
}
}
async changePasswordForAccountRecovery(passwordInputResult: PasswordInputResult, userId: UserId) {
const request = new UpdateTempPasswordRequest();
const newMasterKeyEncryptedUserKey = await this.preparePasswordChange(
passwordInputResult,
userId,
request,
);
request.key = newMasterKeyEncryptedUserKey[1].encryptedString as string;
try {
// TODO: PM-23047 will look to consolidate this into the change password endpoint.
await this.masterPasswordApiService.putUpdateTempPassword(request);
} catch {
throw new Error("Could not change password");
}
}
}

View File

@@ -0,0 +1,3 @@
export * from "./change-password.component";
export * from "./change-password.service.abstraction";
export * from "./default-change-password.service";

View File

@@ -0,0 +1,290 @@
import { firstValueFrom } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
OrganizationUserApiService,
OrganizationUserResetPasswordEnrollmentRequest,
} from "@bitwarden/admin-console/common";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { UpdateTdeOffboardingPasswordRequest } from "@bitwarden/common/auth/models/request/update-tde-offboarding-password.request";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { KdfConfigService, KeyService, KdfConfig } from "@bitwarden/key-management";
import {
SetInitialPasswordService,
SetInitialPasswordCredentials,
SetInitialPasswordUserType,
SetInitialPasswordTdeOffboardingCredentials,
} from "./set-initial-password.service.abstraction";
export class DefaultSetInitialPasswordService implements SetInitialPasswordService {
constructor(
protected apiService: ApiService,
protected encryptService: EncryptService,
protected i18nService: I18nService,
protected kdfConfigService: KdfConfigService,
protected keyService: KeyService,
protected masterPasswordApiService: MasterPasswordApiService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected organizationApiService: OrganizationApiServiceAbstraction,
protected organizationUserApiService: OrganizationUserApiService,
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
) {}
async setInitialPassword(
credentials: SetInitialPasswordCredentials,
userType: SetInitialPasswordUserType,
userId: UserId,
): Promise<void> {
const {
newMasterKey,
newServerMasterKeyHash,
newLocalMasterKeyHash,
newPasswordHint,
kdfConfig,
orgSsoIdentifier,
orgId,
resetPasswordAutoEnroll,
} = credentials;
for (const [key, value] of Object.entries(credentials)) {
if (value == null) {
throw new Error(`${key} not found. Could not set password.`);
}
}
if (userId == null) {
throw new Error("userId not found. Could not set password.");
}
if (userType == null) {
throw new Error("userType not found. Could not set password.");
}
const masterKeyEncryptedUserKey = await this.makeMasterKeyEncryptedUserKey(
newMasterKey,
userId,
);
if (masterKeyEncryptedUserKey == null || !masterKeyEncryptedUserKey[1].encryptedString) {
throw new Error("masterKeyEncryptedUserKey not found. Could not set password.");
}
let keyPair: [string, EncString] | null = null;
let keysRequest: KeysRequest | null = null;
if (userType === SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER) {
/**
* A user being JIT provisioned into a MP encryption org does not yet have a user
* asymmetric key pair, so we create it for them here.
*
* Sidenote:
* In the case of a TDE user whose permissions require that they have a MP - that user
* will already have a user asymmetric key pair by this point, so we skip this if-block
* so that we don't create a new key pair for them.
*/
// Extra safety check (see description on https://github.com/bitwarden/clients/pull/10180):
// In case we have have a local private key and are not sure whether it has been posted to the server,
// we post the local private key instead of generating a new one
const existingUserPrivateKey = (await firstValueFrom(
this.keyService.userPrivateKey$(userId),
)) as Uint8Array;
const existingUserPublicKey = await firstValueFrom(this.keyService.userPublicKey$(userId));
if (existingUserPrivateKey != null && existingUserPublicKey != null) {
const existingUserPublicKeyB64 = Utils.fromBufferToB64(existingUserPublicKey);
// Existing key pair
keyPair = [
existingUserPublicKeyB64,
await this.encryptService.wrapDecapsulationKey(
existingUserPrivateKey,
masterKeyEncryptedUserKey[0],
),
];
} else {
// New key pair
keyPair = await this.keyService.makeKeyPair(masterKeyEncryptedUserKey[0]);
}
if (keyPair == null) {
throw new Error("keyPair not found. Could not set password.");
}
if (!keyPair[1].encryptedString) {
throw new Error("encrypted private key not found. Could not set password.");
}
keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString);
}
const request = new SetPasswordRequest(
newServerMasterKeyHash,
masterKeyEncryptedUserKey[1].encryptedString,
newPasswordHint,
orgSsoIdentifier,
keysRequest,
kdfConfig.kdfType,
kdfConfig.iterations,
);
await this.masterPasswordApiService.setPassword(request);
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
// User now has a password so update account decryption options in state
await this.updateAccountDecryptionProperties(
newMasterKey,
kdfConfig,
masterKeyEncryptedUserKey,
userId,
);
/**
* Set the private key only for new JIT provisioned users in MP encryption orgs.
* (Existing TDE users will have their private key set on sync or on login.)
*/
if (keyPair != null && userType === SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER) {
if (!keyPair[1].encryptedString) {
throw new Error("encrypted private key not found. Could not set private key in state.");
}
await this.keyService.setPrivateKey(keyPair[1].encryptedString, userId);
}
await this.masterPasswordService.setMasterKeyHash(newLocalMasterKeyHash, userId);
if (resetPasswordAutoEnroll) {
await this.handleResetPasswordAutoEnroll(newServerMasterKeyHash, orgId, userId);
}
}
private async makeMasterKeyEncryptedUserKey(
masterKey: MasterKey,
userId: UserId,
): Promise<[UserKey, EncString]> {
let masterKeyEncryptedUserKey: [UserKey, EncString] | null = null;
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (userKey == null) {
masterKeyEncryptedUserKey = await this.keyService.makeUserKey(masterKey);
} else {
masterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(masterKey);
}
return masterKeyEncryptedUserKey;
}
private async updateAccountDecryptionProperties(
masterKey: MasterKey,
kdfConfig: KdfConfig,
masterKeyEncryptedUserKey: [UserKey, EncString],
userId: UserId,
) {
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
);
userDecryptionOpts.hasMasterPassword = true;
await this.userDecryptionOptionsService.setUserDecryptionOptions(userDecryptionOpts);
await this.kdfConfigService.setKdfConfig(userId, kdfConfig);
await this.masterPasswordService.setMasterKey(masterKey, userId);
await this.keyService.setUserKey(masterKeyEncryptedUserKey[0], userId);
}
private async handleResetPasswordAutoEnroll(
masterKeyHash: string,
orgId: string,
userId: UserId,
) {
const organizationKeys = await this.organizationApiService.getKeys(orgId);
if (organizationKeys == null) {
throw new Error(
"Organization keys response is null. Could not handle reset password auto enroll.",
);
}
const orgPublicKey = Utils.fromB64ToArray(organizationKeys.publicKey);
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (userKey == null) {
throw new Error("userKey not found. Could not handle reset password auto enroll.");
}
// RSA encrypt user key with organization public key
const orgPublicKeyEncryptedUserKey = await this.encryptService.encapsulateKeyUnsigned(
userKey,
orgPublicKey,
);
if (orgPublicKeyEncryptedUserKey == null || !orgPublicKeyEncryptedUserKey.encryptedString) {
throw new Error(
"orgPublicKeyEncryptedUserKey not found. Could not handle reset password auto enroll.",
);
}
const enrollmentRequest = new OrganizationUserResetPasswordEnrollmentRequest();
enrollmentRequest.masterPasswordHash = masterKeyHash;
enrollmentRequest.resetPasswordKey = orgPublicKeyEncryptedUserKey.encryptedString;
await this.organizationUserApiService.putOrganizationUserResetPasswordEnrollment(
orgId,
userId,
enrollmentRequest,
);
}
async setInitialPasswordTdeOffboarding(
credentials: SetInitialPasswordTdeOffboardingCredentials,
userId: UserId,
) {
const { newMasterKey, newServerMasterKeyHash, newPasswordHint } = credentials;
for (const [key, value] of Object.entries(credentials)) {
if (value == null) {
throw new Error(`${key} not found. Could not set password.`);
}
}
if (userId == null) {
throw new Error("userId not found. Could not set password.");
}
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (userKey == null) {
throw new Error("userKey not found. Could not set password.");
}
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
newMasterKey,
userKey,
);
if (!newMasterKeyEncryptedUserKey[1].encryptedString) {
throw new Error("newMasterKeyEncryptedUserKey not found. Could not set password.");
}
const request = new UpdateTdeOffboardingPasswordRequest();
request.key = newMasterKeyEncryptedUserKey[1].encryptedString;
request.newMasterPasswordHash = newServerMasterKeyHash;
request.masterPasswordHint = newPasswordHint;
await this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request);
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
}
}

View File

@@ -0,0 +1,748 @@
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
OrganizationUserApiService,
OrganizationUserResetPasswordEnrollmentRequest,
} from "@bitwarden/admin-console/common";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
FakeUserDecryptionOptions as UserDecryptionOptions,
InternalUserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models/response/organization-keys.response";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { UpdateTdeOffboardingPasswordRequest } from "@bitwarden/common/auth/models/request/update-tde-offboarding-password.request";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import {
EncryptedString,
EncString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey, UserPrivateKey, UserPublicKey } from "@bitwarden/common/types/key";
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
import { DefaultSetInitialPasswordService } from "./default-set-initial-password.service.implementation";
import {
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
SetInitialPasswordUserType,
} from "./set-initial-password.service.abstraction";
describe("DefaultSetInitialPasswordService", () => {
let sut: SetInitialPasswordService;
let apiService: MockProxy<ApiService>;
let encryptService: MockProxy<EncryptService>;
let i18nService: MockProxy<I18nService>;
let kdfConfigService: MockProxy<KdfConfigService>;
let keyService: MockProxy<KeyService>;
let masterPasswordApiService: MockProxy<MasterPasswordApiService>;
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
let userId: UserId;
let userKey: UserKey;
let userKeyEncString: EncString;
let masterKeyEncryptedUserKey: [UserKey, EncString];
beforeEach(() => {
apiService = mock<ApiService>();
encryptService = mock<EncryptService>();
i18nService = mock<I18nService>();
kdfConfigService = mock<KdfConfigService>();
keyService = mock<KeyService>();
masterPasswordApiService = mock<MasterPasswordApiService>();
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
organizationApiService = mock<OrganizationApiServiceAbstraction>();
organizationUserApiService = mock<OrganizationUserApiService>();
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
userId = "userId" as UserId;
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
userKeyEncString = new EncString("masterKeyEncryptedUserKey");
masterKeyEncryptedUserKey = [userKey, userKeyEncString];
sut = new DefaultSetInitialPasswordService(
apiService,
encryptService,
i18nService,
kdfConfigService,
keyService,
masterPasswordApiService,
masterPasswordService,
organizationApiService,
organizationUserApiService,
userDecryptionOptionsService,
);
});
it("should instantiate", () => {
expect(sut).not.toBeFalsy();
});
describe("setInitialPassword(...)", () => {
// Mock function parameters
let credentials: SetInitialPasswordCredentials;
let userType: SetInitialPasswordUserType;
// Mock other function data
let existingUserPublicKey: UserPublicKey;
let existingUserPrivateKey: UserPrivateKey;
let userKeyEncryptedPrivateKey: EncString;
let keyPair: [string, EncString];
let keysRequest: KeysRequest;
let organizationKeys: OrganizationKeysResponse;
let orgPublicKeyEncryptedUserKey: EncString;
let userDecryptionOptions: UserDecryptionOptions;
let userDecryptionOptionsSubject: BehaviorSubject<UserDecryptionOptions>;
let setPasswordRequest: SetPasswordRequest;
let enrollmentRequest: OrganizationUserResetPasswordEnrollmentRequest;
beforeEach(() => {
// Mock function parameters
credentials = {
newMasterKey: new SymmetricCryptoKey(new Uint8Array(32).buffer as CsprngArray) as MasterKey,
newServerMasterKeyHash: "newServerMasterKeyHash",
newLocalMasterKeyHash: "newLocalMasterKeyHash",
newPasswordHint: "newPasswordHint",
kdfConfig: DEFAULT_KDF_CONFIG,
orgSsoIdentifier: "orgSsoIdentifier",
orgId: "orgId",
resetPasswordAutoEnroll: false,
};
userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;
// Mock other function data
existingUserPublicKey = Utils.fromB64ToArray("existingUserPublicKey") as UserPublicKey;
existingUserPrivateKey = Utils.fromB64ToArray("existingUserPrivateKey") as UserPrivateKey;
userKeyEncryptedPrivateKey = new EncString("userKeyEncryptedPrivateKey");
keyPair = ["publicKey", new EncString("privateKey")];
keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString);
organizationKeys = {
privateKey: "orgPrivateKey",
publicKey: "orgPublicKey",
} as OrganizationKeysResponse;
orgPublicKeyEncryptedUserKey = new EncString("orgPublicKeyEncryptedUserKey");
userDecryptionOptions = new UserDecryptionOptions({ hasMasterPassword: true });
userDecryptionOptionsSubject = new BehaviorSubject(userDecryptionOptions);
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
setPasswordRequest = new SetPasswordRequest(
credentials.newServerMasterKeyHash,
masterKeyEncryptedUserKey[1].encryptedString,
credentials.newPasswordHint,
credentials.orgSsoIdentifier,
keysRequest,
credentials.kdfConfig.kdfType,
credentials.kdfConfig.iterations,
);
enrollmentRequest = new OrganizationUserResetPasswordEnrollmentRequest();
enrollmentRequest.masterPasswordHash = credentials.newServerMasterKeyHash;
enrollmentRequest.resetPasswordKey = orgPublicKeyEncryptedUserKey.encryptedString;
});
interface MockConfig {
userType: SetInitialPasswordUserType;
userHasUserKey: boolean;
userHasLocalKeyPair: boolean;
resetPasswordAutoEnroll: boolean;
}
const defaultMockConfig: MockConfig = {
userType: SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER,
userHasUserKey: true,
userHasLocalKeyPair: false,
resetPasswordAutoEnroll: false,
};
function setupMocks(config: MockConfig = defaultMockConfig) {
// Mock makeMasterKeyEncryptedUserKey() values
if (config.userHasUserKey) {
keyService.userKey$.mockReturnValue(of(userKey));
keyService.encryptUserKeyWithMasterKey.mockResolvedValue(masterKeyEncryptedUserKey);
} else {
keyService.userKey$.mockReturnValue(of(null));
keyService.makeUserKey.mockResolvedValue(masterKeyEncryptedUserKey);
}
// Mock keyPair values
if (config.userType === SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER) {
if (config.userHasLocalKeyPair) {
keyService.userPrivateKey$.mockReturnValue(of(existingUserPrivateKey));
keyService.userPublicKey$.mockReturnValue(of(existingUserPublicKey));
encryptService.wrapDecapsulationKey.mockResolvedValue(userKeyEncryptedPrivateKey);
} else {
keyService.userPrivateKey$.mockReturnValue(of(null));
keyService.userPublicKey$.mockReturnValue(of(null));
keyService.makeKeyPair.mockResolvedValue(keyPair);
}
}
// Mock handleResetPasswordAutoEnroll() values
if (config.resetPasswordAutoEnroll) {
organizationApiService.getKeys.mockResolvedValue(organizationKeys);
encryptService.encapsulateKeyUnsigned.mockResolvedValue(orgPublicKeyEncryptedUserKey);
keyService.userKey$.mockReturnValue(of(userKey));
}
}
describe("general error handling", () => {
[
"newMasterKey",
"newServerMasterKeyHash",
"newLocalMasterKeyHash",
"newPasswordHint",
"kdfConfig",
"orgSsoIdentifier",
"orgId",
"resetPasswordAutoEnroll",
].forEach((key) => {
it(`should throw if ${key} is not provided on the SetInitialPasswordCredentials object`, async () => {
// Arrange
const invalidCredentials: SetInitialPasswordCredentials = {
...credentials,
[key]: null,
};
// Act
const promise = sut.setInitialPassword(invalidCredentials, userType, userId);
// Assert
await expect(promise).rejects.toThrow(`${key} not found. Could not set password.`);
});
});
["userId", "userType"].forEach((param) => {
it(`should throw if ${param} was not passed in`, async () => {
// Arrange & Act
const promise = sut.setInitialPassword(
credentials,
param === "userType" ? null : userType,
param === "userId" ? null : userId,
);
// Assert
await expect(promise).rejects.toThrow(`${param} not found. Could not set password.`);
});
});
});
describe("given SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER", () => {
beforeEach(() => {
userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;
});
describe("given the user has an existing local key pair", () => {
it("should NOT create a brand new key pair for the user", async () => {
// Arrange
setPasswordRequest.keys = {
encryptedPrivateKey: userKeyEncryptedPrivateKey.encryptedString,
publicKey: Utils.fromBufferToB64(existingUserPublicKey),
};
setupMocks({ ...defaultMockConfig, userHasLocalKeyPair: true });
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(userId);
expect(keyService.userPublicKey$).toHaveBeenCalledWith(userId);
expect(encryptService.wrapDecapsulationKey).toHaveBeenCalledWith(
existingUserPrivateKey,
masterKeyEncryptedUserKey[0],
);
expect(keyService.makeKeyPair).not.toHaveBeenCalled();
});
});
describe("given the user has a userKey", () => {
it("should successfully set an initial password", async () => {
// Arrange
setupMocks();
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
});
});
describe("given the user does NOT have a userKey", () => {
it("should successfully set an initial password", async () => {
// Arrange
setupMocks({ ...defaultMockConfig, userHasUserKey: false });
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
});
});
it("should throw if a key pair is not found", async () => {
// Arrange
keyPair = null;
setupMocks();
// Act
const promise = sut.setInitialPassword(credentials, userType, userId);
// Assert
await expect(promise).rejects.toThrow("keyPair not found. Could not set password.");
expect(masterPasswordApiService.setPassword).not.toHaveBeenCalled();
});
it("should throw if an encrypted private key is not found", async () => {
// Arrange
keyPair[1].encryptedString = "" as EncryptedString;
setupMocks();
// Act
const promise = sut.setInitialPassword(credentials, userType, userId);
// Assert
await expect(promise).rejects.toThrow(
"encrypted private key not found. Could not set password.",
);
expect(masterPasswordApiService.setPassword).not.toHaveBeenCalled();
});
describe("given the initial password has been successfully set", () => {
it("should clear the ForceSetPasswordReason by setting it to None", async () => {
// Arrange
setupMocks();
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
ForceSetPasswordReason.None,
userId,
);
});
it("should update account decryption properties", async () => {
// Arrange
setupMocks();
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith(
userDecryptionOptions,
);
expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith(userId, credentials.kdfConfig);
expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith(
credentials.newMasterKey,
userId,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(masterKeyEncryptedUserKey[0], userId);
});
it("should set the private key to state", async () => {
// Arrange
setupMocks();
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(keyService.setPrivateKey).toHaveBeenCalledWith(keyPair[1].encryptedString, userId);
});
it("should set the local master key hash to state", async () => {
// Arrange
setupMocks();
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(
credentials.newLocalMasterKeyHash,
userId,
);
});
describe("given resetPasswordAutoEnroll is true", () => {
it(`should handle reset password (account recovery) auto enroll`, async () => {
// Arrange
credentials.resetPasswordAutoEnroll = true;
setupMocks({ ...defaultMockConfig, resetPasswordAutoEnroll: true });
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
).toHaveBeenCalledWith(credentials.orgId, userId, enrollmentRequest);
});
it("should throw if organization keys are not found", async () => {
// Arrange
credentials.resetPasswordAutoEnroll = true;
organizationKeys = null;
setupMocks({ ...defaultMockConfig, resetPasswordAutoEnroll: true });
// Act
const promise = sut.setInitialPassword(credentials, userType, userId);
// Assert
await expect(promise).rejects.toThrow(
"Organization keys response is null. Could not handle reset password auto enroll.",
);
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
).not.toHaveBeenCalled();
});
["orgPublicKeyEncryptedUserKey", "orgPublicKeyEncryptedUserKey.encryptedString"].forEach(
(property) => {
it("should throw if orgPublicKeyEncryptedUserKey is not found", async () => {
// Arrange
credentials.resetPasswordAutoEnroll = true;
if (property === "orgPublicKeyEncryptedUserKey") {
orgPublicKeyEncryptedUserKey = null;
} else {
orgPublicKeyEncryptedUserKey.encryptedString = "" as EncryptedString;
}
setupMocks({ ...defaultMockConfig, resetPasswordAutoEnroll: true });
// Act
const promise = sut.setInitialPassword(credentials, userType, userId);
// Assert
await expect(promise).rejects.toThrow(
"orgPublicKeyEncryptedUserKey not found. Could not handle reset password auto enroll.",
);
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(
setPasswordRequest,
);
expect(
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
).not.toHaveBeenCalled();
});
},
);
});
describe("given resetPasswordAutoEnroll is false", () => {
it(`should NOT handle reset password (account recovery) auto enroll`, async () => {
// Arrange
credentials.resetPasswordAutoEnroll = false;
setupMocks();
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
).not.toHaveBeenCalled();
});
});
});
});
describe("given SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP", () => {
beforeEach(() => {
userType = SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP;
setPasswordRequest.keys = null;
});
it("should NOT generate a keyPair", async () => {
// Arrange
setupMocks({ ...defaultMockConfig, userType });
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(keyService.userPrivateKey$).not.toHaveBeenCalled();
expect(keyService.userPublicKey$).not.toHaveBeenCalled();
expect(encryptService.wrapDecapsulationKey).not.toHaveBeenCalled();
expect(keyService.makeKeyPair).not.toHaveBeenCalled();
});
describe("given the user has a userKey", () => {
it("should successfully set an initial password", async () => {
// Arrange
setupMocks({ ...defaultMockConfig, userType });
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
});
});
describe("given the user does NOT have a userKey", () => {
it("should successfully set an initial password", async () => {
// Arrange
setupMocks({ ...defaultMockConfig, userType });
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
});
});
describe("given the initial password has been successfully set", () => {
it("should clear the ForceSetPasswordReason by setting it to None", async () => {
// Arrange
setupMocks({ ...defaultMockConfig, userType });
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
ForceSetPasswordReason.None,
userId,
);
});
it("should update account decryption properties", async () => {
// Arrange
setupMocks({ ...defaultMockConfig, userType });
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith(
userDecryptionOptions,
);
expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith(userId, credentials.kdfConfig);
expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith(
credentials.newMasterKey,
userId,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(masterKeyEncryptedUserKey[0], userId);
});
it("should NOT set the private key to state", async () => {
// Arrange
setupMocks({ ...defaultMockConfig, userType });
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
});
it("should set the local master key hash to state", async () => {
// Arrange
setupMocks({ ...defaultMockConfig, userType });
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(
credentials.newLocalMasterKeyHash,
userId,
);
});
describe("given resetPasswordAutoEnroll is true", () => {
it(`should handle reset password (account recovery) auto enroll`, async () => {
// Arrange
credentials.resetPasswordAutoEnroll = true;
setupMocks({ ...defaultMockConfig, userType, resetPasswordAutoEnroll: true });
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
).toHaveBeenCalledWith(credentials.orgId, userId, enrollmentRequest);
});
});
describe("given resetPasswordAutoEnroll is false", () => {
it(`should NOT handle reset password (account recovery) auto enroll`, async () => {
// Arrange
setupMocks({ ...defaultMockConfig, userType });
// Act
await sut.setInitialPassword(credentials, userType, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
).not.toHaveBeenCalled();
});
});
});
});
});
describe("setInitialPasswordTdeOffboarding(...)", () => {
// Mock function parameters
let credentials: SetInitialPasswordTdeOffboardingCredentials;
beforeEach(() => {
// Mock function parameters
credentials = {
newMasterKey: new SymmetricCryptoKey(new Uint8Array(32).buffer as CsprngArray) as MasterKey,
newServerMasterKeyHash: "newServerMasterKeyHash",
newPasswordHint: "newPasswordHint",
};
});
function setupTdeOffboardingMocks() {
keyService.userKey$.mockReturnValue(of(userKey));
keyService.encryptUserKeyWithMasterKey.mockResolvedValue(masterKeyEncryptedUserKey);
}
it("should successfully set an initial password for the TDE offboarding user", async () => {
// Arrange
setupTdeOffboardingMocks();
const request = new UpdateTdeOffboardingPasswordRequest();
request.key = masterKeyEncryptedUserKey[1].encryptedString;
request.newMasterPasswordHash = credentials.newServerMasterKeyHash;
request.masterPasswordHint = credentials.newPasswordHint;
// Act
await sut.setInitialPasswordTdeOffboarding(credentials, userId);
// Assert
expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledTimes(1);
expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledWith(
request,
);
});
describe("given the initial password has been successfully set", () => {
it("should clear the ForceSetPasswordReason by setting it to None", async () => {
// Arrange
setupTdeOffboardingMocks();
// Act
await sut.setInitialPasswordTdeOffboarding(credentials, userId);
// Assert
expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledTimes(1);
expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
ForceSetPasswordReason.None,
userId,
);
});
});
describe("general error handling", () => {
["newMasterKey", "newServerMasterKeyHash", "newPasswordHint"].forEach((key) => {
it(`should throw if ${key} is not provided on the SetInitialPasswordTdeOffboardingCredentials object`, async () => {
// Arrange
const invalidCredentials: SetInitialPasswordTdeOffboardingCredentials = {
...credentials,
[key]: null,
};
// Act
const promise = sut.setInitialPasswordTdeOffboarding(invalidCredentials, userId);
// Assert
await expect(promise).rejects.toThrow(`${key} not found. Could not set password.`);
});
});
it(`should throw if the userId was not passed in`, async () => {
// Arrange
userId = null;
// Act
const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId);
// Assert
await expect(promise).rejects.toThrow("userId not found. Could not set password.");
});
it(`should throw if the userKey was not found`, async () => {
// Arrange
keyService.userKey$.mockReturnValue(of(null));
// Act
const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId);
// Assert
await expect(promise).rejects.toThrow("userKey not found. Could not set password.");
});
it(`should throw if a newMasterKeyEncryptedUserKey was not returned`, async () => {
// Arrange
masterKeyEncryptedUserKey[1].encryptedString = "" as EncryptedString;
setupTdeOffboardingMocks();
// Act
const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId);
// Assert
await expect(promise).rejects.toThrow(
"newMasterKeyEncryptedUserKey not found. Could not set password.",
);
});
});
});
});

View File

@@ -0,0 +1,44 @@
@if (initializing) {
<div class="tw-flex tw-items-center tw-justify-center">
<i
class="bwi bwi-spinner bwi-spin bwi-3x"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
</div>
} @else {
@if (userType === SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER_UNTRUSTED_DEVICE) {
<div class="tw-mt-4"></div>
<bit-callout type="warning">
{{ "loginOnTrustedDeviceOrAskAdminToAssignPassword" | i18n }}
</bit-callout>
<button type="button" bitButton block buttonType="secondary" (click)="logout()">
{{ "logOut" | i18n }}
</button>
} @else {
<bit-callout
*ngIf="resetPasswordAutoEnroll"
type="warning"
title="{{ 'resetPasswordPolicyAutoEnroll' | i18n }}"
>
{{ "resetPasswordAutoEnrollInviteWarning" | i18n }}
</bit-callout>
<auth-input-password
[flow]="inputPasswordFlow"
[email]="email"
[userId]="userId"
[loading]="submitting"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
[primaryButtonText]="{
key:
userType === SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER
? 'setPassword'
: 'createAccount',
}"
[secondaryButtonText]="{ key: 'logOut' }"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
(onSecondaryButtonClick)="logout()"
></auth-input-password>
}
}

View File

@@ -0,0 +1,318 @@
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
// import { NoAccess } from "libs/components/src/icon/icons";
import { firstValueFrom } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
InputPasswordComponent,
InputPasswordFlow,
PasswordInputResult,
} from "@bitwarden/auth/angular";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { LogoutService } from "@bitwarden/auth/common";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { assertTruthy, assertNonNullish } from "@bitwarden/common/auth/utils";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import {
AnonLayoutWrapperDataService,
ButtonModule,
CalloutComponent,
DialogService,
ToastService,
Icons,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import {
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
SetInitialPasswordUserType,
} from "./set-initial-password.service.abstraction";
@Component({
standalone: true,
templateUrl: "set-initial-password.component.html",
imports: [ButtonModule, CalloutComponent, CommonModule, InputPasswordComponent, I18nPipe],
})
export class SetInitialPasswordComponent implements OnInit {
protected inputPasswordFlow = InputPasswordFlow.SetInitialPasswordAuthedUser;
protected email?: string;
protected forceSetPasswordReason?: ForceSetPasswordReason;
protected initializing = true;
protected masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null;
protected orgId?: string;
protected orgSsoIdentifier?: string;
protected resetPasswordAutoEnroll?: boolean;
protected submitting = false;
protected userId?: UserId;
protected userType?: SetInitialPasswordUserType;
protected SetInitialPasswordUserType = SetInitialPasswordUserType;
constructor(
private accountService: AccountService,
private activatedRoute: ActivatedRoute,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private dialogService: DialogService,
private i18nService: I18nService,
private logoutService: LogoutService,
private logService: LogService,
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
private messagingService: MessagingService,
private organizationApiService: OrganizationApiServiceAbstraction,
private policyApiService: PolicyApiServiceAbstraction,
private policyService: PolicyService,
private router: Router,
private setInitialPasswordService: SetInitialPasswordService,
private ssoLoginService: SsoLoginServiceAbstraction,
private syncService: SyncService,
private toastService: ToastService,
private validationService: ValidationService,
) {}
async ngOnInit() {
await this.syncService.fullSync(true);
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
this.userId = activeAccount?.id;
this.email = activeAccount?.email;
await this.establishUserType();
await this.getOrgInfo();
this.initializing = false;
}
private async establishUserType() {
if (!this.userId) {
throw new Error("userId not found. Could not determine user type.");
}
this.forceSetPasswordReason = await firstValueFrom(
this.masterPasswordService.forceSetPasswordReason$(this.userId),
);
if (this.forceSetPasswordReason === ForceSetPasswordReason.TdeOffboardingUntrustedDevice) {
this.userType = SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER_UNTRUSTED_DEVICE;
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
pageTitle: { key: "unableToCompleteLogin" },
pageIcon: Icons.NoAccess,
});
}
if (this.forceSetPasswordReason === ForceSetPasswordReason.SsoNewJitProvisionedUser) {
this.userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER;
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
pageTitle: { key: "joinOrganization" },
pageSubtitle: { key: "finishJoiningThisOrganizationBySettingAMasterPassword" },
});
}
if (
this.forceSetPasswordReason ===
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission
) {
this.userType = SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP;
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
pageTitle: { key: "setMasterPassword" },
pageSubtitle: { key: "orgPermissionsUpdatedMustSetPassword" },
});
}
if (this.forceSetPasswordReason === ForceSetPasswordReason.TdeOffboarding) {
this.userType = SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER;
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
pageTitle: { key: "setMasterPassword" },
pageSubtitle: { key: "tdeDisabledMasterPasswordRequired" },
});
}
// If we somehow end up here without a reason, navigate to root
if (this.forceSetPasswordReason === ForceSetPasswordReason.None) {
await this.router.navigate(["/"]);
}
}
private async getOrgInfo() {
if (!this.userId) {
throw new Error("userId not found. Could not handle query params.");
}
if (this.userType === SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER) {
this.masterPasswordPolicyOptions =
(await firstValueFrom(this.policyService.masterPasswordPolicyOptions$(this.userId))) ??
null;
return;
}
const qParams = await firstValueFrom(this.activatedRoute.queryParams);
this.orgSsoIdentifier =
qParams.identifier ??
(await this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(this.userId));
if (this.orgSsoIdentifier != null) {
try {
const autoEnrollStatus = await this.organizationApiService.getAutoEnrollStatus(
this.orgSsoIdentifier,
);
this.orgId = autoEnrollStatus.id;
this.resetPasswordAutoEnroll = autoEnrollStatus.resetPasswordEnabled;
this.masterPasswordPolicyOptions =
await this.policyApiService.getMasterPasswordPolicyOptsForOrgUser(this.orgId);
} catch {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("errorOccurred"),
});
}
}
}
protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.submitting = true;
switch (this.userType) {
case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER:
case SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP:
await this.setInitialPassword(passwordInputResult);
break;
case SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER:
await this.setInitialPasswordTdeOffboarding(passwordInputResult);
break;
default:
this.logService.error(
`Unexpected user type: ${this.userType}. Could not set initial password.`,
);
this.validationService.showError("Unexpected user type. Could not set initial password.");
}
}
private async setInitialPassword(passwordInputResult: PasswordInputResult) {
const ctx = "Could not set initial password.";
assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx);
assertTruthy(passwordInputResult.newServerMasterKeyHash, "newServerMasterKeyHash", ctx);
assertTruthy(passwordInputResult.newLocalMasterKeyHash, "newLocalMasterKeyHash", ctx);
assertTruthy(passwordInputResult.kdfConfig, "kdfConfig", ctx);
assertTruthy(this.orgSsoIdentifier, "orgSsoIdentifier", ctx);
assertTruthy(this.orgId, "orgId", ctx);
assertTruthy(this.userType, "userType", ctx);
assertTruthy(this.userId, "userId", ctx);
assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish
assertNonNullish(this.resetPasswordAutoEnroll, "resetPasswordAutoEnroll", ctx); // can have `false` as a valid value, so check non-nullish
try {
const credentials: SetInitialPasswordCredentials = {
newMasterKey: passwordInputResult.newMasterKey,
newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash,
newLocalMasterKeyHash: passwordInputResult.newLocalMasterKeyHash,
newPasswordHint: passwordInputResult.newPasswordHint,
kdfConfig: passwordInputResult.kdfConfig,
orgSsoIdentifier: this.orgSsoIdentifier,
orgId: this.orgId,
resetPasswordAutoEnroll: this.resetPasswordAutoEnroll,
};
await this.setInitialPasswordService.setInitialPassword(
credentials,
this.userType,
this.userId,
);
this.showSuccessToastByUserType();
this.submitting = false;
await this.router.navigate(["vault"]);
} catch (e) {
this.logService.error("Error setting initial password", e);
this.validationService.showError(e);
this.submitting = false;
}
}
private async setInitialPasswordTdeOffboarding(passwordInputResult: PasswordInputResult) {
const ctx = "Could not set initial password.";
assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx);
assertTruthy(passwordInputResult.newServerMasterKeyHash, "newServerMasterKeyHash", ctx);
assertTruthy(this.userId, "userId", ctx);
assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish
try {
const credentials: SetInitialPasswordTdeOffboardingCredentials = {
newMasterKey: passwordInputResult.newMasterKey,
newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash,
newPasswordHint: passwordInputResult.newPasswordHint,
};
await this.setInitialPasswordService.setInitialPasswordTdeOffboarding(
credentials,
this.userId,
);
this.showSuccessToastByUserType();
await this.logoutService.logout(this.userId);
// navigate to root so redirect guard can properly route next active user or null user to correct page
await this.router.navigate(["/"]);
} catch (e) {
this.logService.error("Error setting initial password during TDE offboarding", e);
this.validationService.showError(e);
} finally {
this.submitting = false;
}
}
private showSuccessToastByUserType() {
if (this.userType === SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER) {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("accountSuccessfullyCreated"),
});
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("inviteAccepted"),
});
} else {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("masterPasswordSuccessfullySet"),
});
}
}
protected async logout() {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "logOut" },
content: { key: "logOutConfirmation" },
acceptButtonText: { key: "logOut" },
type: "warning",
});
if (confirmed) {
this.messagingService.send("logout");
}
}
}

View File

@@ -0,0 +1,95 @@
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey } from "@bitwarden/common/types/key";
import { KdfConfig } from "@bitwarden/key-management";
export const _SetInitialPasswordUserType = {
/**
* A user being "just-in-time" (JIT) provisioned into a master-password-encryption org
*/
JIT_PROVISIONED_MP_ORG_USER: "jit_provisioned_mp_org_user",
/**
* Could be one of two scenarios:
* 1. A user being "just-in-time" (JIT) provisioned into a trusted-device-encryption org
* with the reset password permission granted ("manage account recovery"), which requires
* that the user sets a master password
* 2. An user in a trusted-device-encryption org whose permissions were upgraded to include
* the reset password permission ("manage account recovery"), which requires that the user
* sets a master password
*/
TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP:
"tde_org_user_reset_password_permission_requires_mp",
/**
* A user in an org that offboarded from trusted device encryption and is now a
* master-password-encryption org. User is on a trusted device.
*/
OFFBOARDED_TDE_ORG_USER: "offboarded_tde_org_user",
/**
* A user in an org that offboarded from trusted device encryption and is now a
* master-password-encryption org. User is on an untrusted device.
*/
OFFBOARDED_TDE_ORG_USER_UNTRUSTED_DEVICE: "offboarded_tde_org_user_untrusted_device",
} as const;
type _SetInitialPasswordUserType = typeof _SetInitialPasswordUserType;
export type SetInitialPasswordUserType =
_SetInitialPasswordUserType[keyof _SetInitialPasswordUserType];
export const SetInitialPasswordUserType: Readonly<{
[K in keyof typeof _SetInitialPasswordUserType]: SetInitialPasswordUserType;
}> = Object.freeze(_SetInitialPasswordUserType);
export interface SetInitialPasswordCredentials {
newMasterKey: MasterKey;
newServerMasterKeyHash: string;
newLocalMasterKeyHash: string;
newPasswordHint: string;
kdfConfig: KdfConfig;
orgSsoIdentifier: string;
orgId: string;
resetPasswordAutoEnroll: boolean;
}
export interface SetInitialPasswordTdeOffboardingCredentials {
newMasterKey: MasterKey;
newServerMasterKeyHash: string;
newPasswordHint: string;
}
/**
* Handles setting an initial password for an existing authed user.
*
* To see the different scenarios where an existing authed user needs to set an
* initial password, see {@link SetInitialPasswordUserType}
*/
export abstract class SetInitialPasswordService {
/**
* Sets an initial password for an existing authed user who is either:
* - {@link SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER}
* - {@link SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP}
*
* @param credentials An object of the credentials needed to set the initial password
* @throws If any property on the `credentials` object is null or undefined, or if a
* masterKeyEncryptedUserKey or newKeyPair could not be created.
*/
abstract setInitialPassword: (
credentials: SetInitialPasswordCredentials,
userType: SetInitialPasswordUserType,
userId: UserId,
) => Promise<void>;
/**
* Sets an initial password for a user who logs in after their org offboarded from
* trusted device encryption and is now a master-password-encryption org:
* - {@link SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER}
*
* @param passwordInputResult credentials object received from the `InputPasswordComponent`
* @param userId the account `userId`
*/
abstract setInitialPasswordTdeOffboarding: (
credentials: SetInitialPasswordTdeOffboardingCredentials,
userId: UserId,
) => Promise<void>;
}

View File

@@ -3,7 +3,7 @@
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "country" | i18n }}</bit-label>
<bit-select formControlName="country">
<bit-select formControlName="country" data-testid="country">
<bit-option
*ngFor="let country of countries"
[value]="country.value"
@@ -16,38 +16,68 @@
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "zipPostalCode" | i18n }}</bit-label>
<input bitInput type="text" formControlName="postalCode" autocomplete="postal-code" />
<input
bitInput
type="text"
formControlName="postalCode"
autocomplete="postal-code"
data-testid="postal-code"
/>
</bit-form-field>
</div>
<ng-container *ngIf="isTaxSupported">
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "address1" | i18n }}</bit-label>
<input bitInput type="text" formControlName="line1" autocomplete="address-line1" />
<input
bitInput
type="text"
formControlName="line1"
autocomplete="address-line1"
data-testid="address-line1"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "address2" | i18n }}</bit-label>
<input bitInput type="text" formControlName="line2" autocomplete="address-line2" />
<input
bitInput
type="text"
formControlName="line2"
autocomplete="address-line2"
data-testid="address-line2"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "cityTown" | i18n }}</bit-label>
<input bitInput type="text" formControlName="city" autocomplete="address-level2" />
<input
bitInput
type="text"
formControlName="city"
autocomplete="address-level2"
data-testid="city"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field disableMargin>
<bit-label>{{ "stateProvince" | i18n }}</bit-label>
<input bitInput type="text" formControlName="state" autocomplete="address-level1" />
<input
bitInput
type="text"
formControlName="state"
autocomplete="address-level1"
data-testid="state"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6" *ngIf="showTaxIdField">
<bit-form-field disableMargin>
<bit-label>{{ "taxIdNumber" | i18n }}</bit-label>
<input bitInput type="text" formControlName="taxId" />
<input bitInput type="text" formControlName="taxId" data-testid="tax-id" />
</bit-form-field>
</div>
</ng-container>

View File

@@ -1,142 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { firstValueFrom, map, Observable, Subject, takeUntil } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { Checkable, isChecked } from "@bitwarden/common/types/checkable";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@Directive()
export class ShareComponent implements OnInit, OnDestroy {
@Input() cipherId: string;
@Input() organizationId: string;
@Output() onSharedCipher = new EventEmitter();
formPromise: Promise<void>;
cipher: CipherView;
collections: Checkable<CollectionView>[] = [];
organizations$: Observable<Organization[]>;
protected writeableCollections: Checkable<CollectionView>[] = [];
private _destroy = new Subject<void>();
constructor(
protected collectionService: CollectionService,
protected platformUtilsService: PlatformUtilsService,
protected i18nService: I18nService,
protected cipherService: CipherService,
private logService: LogService,
protected organizationService: OrganizationService,
protected accountService: AccountService,
) {}
async ngOnInit() {
await this.load();
}
ngOnDestroy(): void {
this._destroy.next();
this._destroy.complete();
}
async load() {
const allCollections = await this.collectionService.getAllDecrypted();
this.writeableCollections = allCollections.map((c) => c).filter((c) => !c.readOnly);
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
this.organizations$ = this.organizationService.memberOrganizations$(userId).pipe(
map((orgs) => {
return orgs
.filter((o) => o.enabled && o.status === OrganizationUserStatusType.Confirmed)
.sort(Utils.getSortFunction(this.i18nService, "name"));
}),
);
this.organizations$.pipe(takeUntil(this._destroy)).subscribe((orgs) => {
if (this.organizationId == null && orgs.length > 0) {
this.organizationId = orgs[0].id;
this.filterCollections();
}
});
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipherDomain = await this.cipherService.get(this.cipherId, activeUserId);
this.cipher = await this.cipherService.decrypt(cipherDomain, activeUserId);
}
filterCollections() {
this.writeableCollections.forEach((c) => (c.checked = false));
if (this.organizationId == null || this.writeableCollections.length === 0) {
this.collections = [];
} else {
this.collections = this.writeableCollections.filter(
(c) => c.organizationId === this.organizationId,
);
}
}
async submit(): Promise<boolean> {
const selectedCollectionIds = this.collections.filter(isChecked).map((c) => c.id);
if (selectedCollectionIds.length === 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("selectOneCollection"),
);
return;
}
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipherDomain = await this.cipherService.get(this.cipherId, activeUserId);
const cipherView = await this.cipherService.decrypt(cipherDomain, activeUserId);
const orgs = await firstValueFrom(this.organizations$);
const orgName =
orgs.find((o) => o.id === this.organizationId)?.name ?? this.i18nService.t("organization");
try {
this.formPromise = this.cipherService
.shareWithServer(cipherView, this.organizationId, selectedCollectionIds, activeUserId)
.then(async () => {
this.onSharedCipher.emit();
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("movedItemToOrg", cipherView.name, orgName),
);
});
await this.formPromise;
return true;
} catch (e) {
this.logService.error(e);
}
return false;
}
get canSave() {
if (this.collections != null) {
for (let i = 0; i < this.collections.length; i++) {
if (this.collections[i].checked) {
return true;
}
}
}
return false;
}
}

View File

@@ -54,7 +54,6 @@ import { UserTypePipe } from "./pipes/user-type.pipe";
import { EllipsisPipe } from "./platform/pipes/ellipsis.pipe";
import { FingerprintPipe } from "./platform/pipes/fingerprint.pipe";
import { I18nPipe } from "./platform/pipes/i18n.pipe";
import { PasswordStrengthComponent } from "./tools/password-strength/password-strength.component";
import { IconComponent } from "./vault/components/icon.component";
@NgModule({
@@ -108,7 +107,6 @@ import { IconComponent } from "./vault/components/icon.component";
TrueFalseValueDirective,
LaunchClickDirective,
UserNamePipe,
PasswordStrengthComponent,
UserTypePipe,
IfFeatureDirective,
FingerprintPipe,
@@ -143,7 +141,6 @@ import { IconComponent } from "./vault/components/icon.component";
CopyClickDirective,
LaunchClickDirective,
UserNamePipe,
PasswordStrengthComponent,
UserTypePipe,
IfFeatureDirective,
FingerprintPipe,

View File

@@ -11,6 +11,10 @@ import {
DefaultOrganizationUserApiService,
OrganizationUserApiService,
} from "@bitwarden/admin-console/common";
import {
ChangePasswordService,
DefaultChangePasswordService,
} from "@bitwarden/angular/auth/password-management/change-password";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
@@ -18,19 +22,13 @@ import {
DefaultLoginComponentService,
DefaultLoginDecryptionOptionsService,
DefaultRegistrationFinishService,
DefaultSetPasswordJitService,
DefaultTwoFactorAuthComponentService,
DefaultTwoFactorAuthEmailComponentService,
DefaultTwoFactorAuthWebAuthnComponentService,
LoginComponentService,
LoginDecryptionOptionsService,
RegistrationFinishService as RegistrationFinishServiceAbstraction,
SetPasswordJitService,
TwoFactorAuthComponentService,
TwoFactorAuthEmailComponentService,
TwoFactorAuthWebAuthnComponentService,
ChangePasswordService,
DefaultChangePasswordService,
} from "@bitwarden/auth/angular";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
@@ -59,7 +57,6 @@ import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstracti
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
InternalOrganizationServiceAbstraction,
@@ -116,6 +113,8 @@ import { AvatarService } from "@bitwarden/common/auth/services/avatar.service";
import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation";
import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation";
import { MasterPasswordApiService } from "@bitwarden/common/auth/services/master-password/master-password-api.service.implementation";
import { DefaultOrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/default-organization-invite.service";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { PasswordResetEnrollmentServiceImplementation } from "@bitwarden/common/auth/services/password-reset-enrollment.service.implementation";
import { SsoLoginService } from "@bitwarden/common/auth/services/sso-login.service";
import { TokenService } from "@bitwarden/common/auth/services/token.service";
@@ -168,6 +167,10 @@ import {
MasterPasswordServiceAbstraction,
} from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service";
import {
SendPasswordService,
DefaultSendPasswordService,
} from "@bitwarden/common/key-management/sends";
import {
DefaultVaultTimeoutService,
DefaultVaultTimeoutSettingsService,
@@ -257,7 +260,6 @@ import { ApiService } from "@bitwarden/common/services/api.service";
import { AuditService } from "@bitwarden/common/services/audit.service";
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
import { SearchService } from "@bitwarden/common/services/search.service";
import {
PasswordStrengthService,
PasswordStrengthServiceAbstraction,
@@ -279,6 +281,7 @@ import {
FolderService as FolderServiceAbstraction,
InternalFolderService,
} from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/vault/abstractions/search.service";
import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service";
import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import {
@@ -295,6 +298,7 @@ import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-u
import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service";
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { SearchService } from "@bitwarden/common/vault/services/search.service";
import { TotpService } from "@bitwarden/common/vault/services/totp.service";
import { VaultSettingsService } from "@bitwarden/common/vault/services/vault-settings/vault-settings.service";
import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
@@ -339,6 +343,8 @@ import {
VaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
import { DefaultSetInitialPasswordService } from "../auth/password-management/set-initial-password/default-set-initial-password.service.implementation";
import { SetInitialPasswordService } from "../auth/password-management/set-initial-password/set-initial-password.service.abstraction";
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
@@ -496,6 +502,7 @@ const safeProviders: SafeProvider[] = [
VaultTimeoutSettingsService,
KdfConfigService,
TaskSchedulerService,
ConfigService,
],
}),
safeProvider({
@@ -1179,7 +1186,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: DevicesServiceAbstraction,
useClass: DevicesServiceImplementation,
deps: [DevicesApiServiceAbstraction, AppIdServiceAbstraction],
deps: [AppIdServiceAbstraction, DevicesApiServiceAbstraction, I18nServiceAbstraction],
}),
safeProvider({
provide: AuthRequestApiServiceAbstraction,
@@ -1314,7 +1321,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: AutofillSettingsServiceAbstraction,
useClass: AutofillSettingsService,
deps: [StateProvider, PolicyServiceAbstraction, AccountService],
deps: [StateProvider, PolicyServiceAbstraction, AccountService, RestrictedItemTypesService],
}),
safeProvider({
provide: BadgeSettingsServiceAbstraction,
@@ -1329,7 +1336,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: VaultSettingsServiceAbstraction,
useClass: VaultSettingsService,
deps: [StateProvider],
deps: [StateProvider, RestrictedItemTypesService],
}),
safeProvider({
provide: MigrationRunner,
@@ -1404,15 +1411,20 @@ const safeProviders: SafeProvider[] = [
deps: [StateProvider],
}),
safeProvider({
provide: SetPasswordJitService,
useClass: DefaultSetPasswordJitService,
provide: OrganizationInviteService,
useClass: DefaultOrganizationInviteService,
deps: [],
}),
safeProvider({
provide: SetInitialPasswordService,
useClass: DefaultSetInitialPasswordService,
deps: [
ApiServiceAbstraction,
MasterPasswordApiServiceAbstraction,
KeyService,
EncryptService,
I18nServiceAbstraction,
KdfConfigService,
KeyService,
MasterPasswordApiServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
OrganizationApiServiceAbstraction,
OrganizationUserApiService,
@@ -1444,11 +1456,6 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultTwoFactorAuthWebAuthnComponentService,
deps: [],
}),
safeProvider({
provide: TwoFactorAuthEmailComponentService,
useClass: DefaultTwoFactorAuthEmailComponentService,
deps: [],
}),
safeProvider({
provide: ViewCacheService,
useExisting: NoopViewCacheService,
@@ -1482,6 +1489,11 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultCipherAuthorizationService,
deps: [CollectionService, OrganizationServiceAbstraction, AccountServiceAbstraction],
}),
safeProvider({
provide: SendPasswordService,
useClass: DefaultSendPasswordService,
deps: [CryptoFunctionServiceAbstraction],
}),
safeProvider({
provide: LoginApprovalComponentServiceAbstraction,
useClass: DefaultLoginApprovalComponentService,

View File

@@ -1,14 +0,0 @@
<div class="progress">
<div
class="progress-bar {{ color }}"
role="progressbar"
[ngStyle]="{ width: scoreWidth + '%' }"
attr.aria-valuenow="{{ scoreWidth }}"
aria-valuemin="0"
aria-valuemax="100"
>
<ng-container *ngIf="showText && text">
{{ text }}
</ng-container>
</div>
</div>

View File

@@ -1,118 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, EventEmitter, Input, OnChanges, Output } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
export interface PasswordColorText {
color: string;
text: string;
}
/**
* @deprecated July 2024: Use new PasswordStrengthV2Component instead
*/
@Component({
selector: "app-password-strength",
templateUrl: "password-strength.component.html",
standalone: false,
})
export class PasswordStrengthComponent implements OnChanges {
@Input() showText = false;
@Input() email: string;
@Input() name: string;
@Input() set password(value: string) {
this.updatePasswordStrength(value);
}
@Output() passwordStrengthResult = new EventEmitter<any>();
@Output() passwordScoreColor = new EventEmitter<PasswordColorText>();
masterPasswordScore: number;
scoreWidth = 0;
color = "bg-danger";
text: string;
private masterPasswordStrengthTimeout: any;
//used by desktop and browser to display strength text color
get masterPasswordScoreColor() {
switch (this.masterPasswordScore) {
case 4:
return "success";
case 3:
return "primary";
case 2:
return "warning";
default:
return "danger";
}
}
//used by desktop and browser to display strength text
get masterPasswordScoreText() {
switch (this.masterPasswordScore) {
case 4:
return this.i18nService.t("strong");
case 3:
return this.i18nService.t("good");
case 2:
return this.i18nService.t("weak");
default:
return this.masterPasswordScore != null ? this.i18nService.t("weak") : null;
}
}
constructor(
private i18nService: I18nService,
private passwordStrengthService: PasswordStrengthServiceAbstraction,
) {}
ngOnChanges(): void {
this.masterPasswordStrengthTimeout = setTimeout(() => {
this.scoreWidth = this.masterPasswordScore == null ? 0 : (this.masterPasswordScore + 1) * 20;
switch (this.masterPasswordScore) {
case 4:
this.color = "bg-success";
this.text = this.i18nService.t("strong");
break;
case 3:
this.color = "bg-primary";
this.text = this.i18nService.t("good");
break;
case 2:
this.color = "bg-warning";
this.text = this.i18nService.t("weak");
break;
default:
this.color = "bg-danger";
this.text = this.masterPasswordScore != null ? this.i18nService.t("weak") : null;
break;
}
this.setPasswordScoreText(this.color, this.text);
}, 300);
}
updatePasswordStrength(password: string) {
const masterPassword = password;
if (this.masterPasswordStrengthTimeout != null) {
clearTimeout(this.masterPasswordStrengthTimeout);
}
const strengthResult = this.passwordStrengthService.getPasswordStrength(
masterPassword,
this.email,
this.name?.trim().toLowerCase().split(" "),
);
this.passwordStrengthResult.emit(strengthResult);
this.masterPasswordScore = strengthResult == null ? null : strengthResult.score;
}
setPasswordScoreText(color: string, text: string) {
color = color.slice(3);
this.passwordScoreColor.emit({ color: color, text: text });
}
}

View File

@@ -148,14 +148,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
return null;
}
get isSafari() {
return this.platformUtilsService.isSafari();
}
get isDateTimeLocalSupported(): boolean {
return !(this.platformUtilsService.isFirefox() || this.platformUtilsService.isSafari());
}
async ngOnInit() {
this.accountService.activeAccount$
.pipe(

View File

@@ -12,7 +12,6 @@ import {
combineLatest,
} from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -25,6 +24,7 @@ import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { DialogService, ToastService } from "@bitwarden/components";
@Directive()

View File

@@ -1,11 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { UserId } from "@bitwarden/common/types/guid";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { DynamicTreeNode } from "../vault-filter/models/dynamic-tree-node.model";
@@ -14,11 +13,14 @@ import { DynamicTreeNode } from "../vault-filter/models/dynamic-tree-node.model"
* @deprecated August 30 2022: Use new VaultFilterService with observables
*/
export abstract class DeprecatedVaultFilterService {
buildOrganizations: () => Promise<Organization[]>;
buildNestedFolders: (organizationId?: string) => Observable<DynamicTreeNode<FolderView>>;
buildCollections: (organizationId?: string) => Promise<DynamicTreeNode<CollectionView>>;
buildCollapsedFilterNodes: () => Promise<Set<string>>;
storeCollapsedFilterNodes: (collapsedFilterNodes: Set<string>) => Promise<void>;
checkForSingleOrganizationPolicy: () => Promise<boolean>;
checkForOrganizationDataOwnershipPolicy: () => Promise<boolean>;
abstract buildOrganizations(): Promise<Organization[]>;
abstract buildNestedFolders(organizationId?: string): Observable<DynamicTreeNode<FolderView>>;
abstract buildCollections(organizationId?: string): Promise<DynamicTreeNode<CollectionView>>;
abstract buildCollapsedFilterNodes(userId: UserId): Promise<Set<string>>;
abstract storeCollapsedFilterNodes(
collapsedFilterNodes: Set<string>,
userId: UserId,
): Promise<void>;
abstract checkForSingleOrganizationPolicy(): Promise<boolean>;
abstract checkForOrganizationDataOwnershipPolicy(): Promise<boolean>;
}

View File

@@ -1,125 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CdkDragDrop, moveItemInArray } from "@angular/cdk/drag-drop";
import { Directive, Input, OnChanges, SimpleChanges } from "@angular/core";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { EventType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FieldType, CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
@Directive()
export class AddEditCustomFieldsComponent implements OnChanges {
@Input() cipher: CipherView;
@Input() thisCipherType: CipherType;
@Input() editMode: boolean;
addFieldType: FieldType = FieldType.Text;
addFieldTypeOptions: any[];
addFieldLinkedTypeOption: any;
linkedFieldOptions: any[] = [];
cipherType = CipherType;
fieldType = FieldType;
eventType = EventType;
constructor(
private i18nService: I18nService,
private eventCollectionService: EventCollectionService,
) {
this.addFieldTypeOptions = [
{ name: i18nService.t("cfTypeText"), value: FieldType.Text },
{ name: i18nService.t("cfTypeHidden"), value: FieldType.Hidden },
{ name: i18nService.t("cfTypeBoolean"), value: FieldType.Boolean },
];
this.addFieldLinkedTypeOption = {
name: this.i18nService.t("cfTypeLinked"),
value: FieldType.Linked,
};
}
ngOnChanges(changes: SimpleChanges) {
if (changes.thisCipherType != null) {
this.setLinkedFieldOptions();
if (!changes.thisCipherType.firstChange) {
this.resetCipherLinkedFields();
}
}
}
addField() {
if (this.cipher.fields == null) {
this.cipher.fields = [];
}
const f = new FieldView();
f.type = this.addFieldType;
f.newField = true;
if (f.type === FieldType.Linked) {
f.linkedId = this.linkedFieldOptions[0].value;
}
this.cipher.fields.push(f);
}
removeField(field: FieldView) {
const i = this.cipher.fields.indexOf(field);
if (i > -1) {
this.cipher.fields.splice(i, 1);
}
}
toggleFieldValue(field: FieldView) {
const f = field as any;
f.showValue = !f.showValue;
if (this.editMode && f.showValue) {
// 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.eventCollectionService.collect(
EventType.Cipher_ClientToggledHiddenFieldVisible,
this.cipher.id,
);
}
}
trackByFunction(index: number, item: any) {
return index;
}
drop(event: CdkDragDrop<string[]>) {
moveItemInArray(this.cipher.fields, event.previousIndex, event.currentIndex);
}
private setLinkedFieldOptions() {
if (this.cipher.linkedFieldOptions == null) {
return;
}
const options: any = [];
this.cipher.linkedFieldOptions.forEach((linkedFieldOption, id) =>
options.push({ name: this.i18nService.t(linkedFieldOption.i18nKey), value: id }),
);
this.linkedFieldOptions = options.sort(Utils.getSortFunction(this.i18nService, "name"));
}
private resetCipherLinkedFields() {
if (this.cipher.fields == null || this.cipher.fields.length === 0) {
return;
}
// Delete any Linked custom fields if the item type does not support them
if (this.cipher.linkedFieldOptions == null) {
this.cipher.fields = this.cipher.fields.filter((f) => f.type !== FieldType.Linked);
return;
}
this.cipher.fields
.filter((f) => f.type === FieldType.Linked)
.forEach((f) => (f.linkedId = this.linkedFieldOptions[0].value));
}
}

View File

@@ -1,855 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DatePipe } from "@angular/common";
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { concatMap, firstValueFrom, map, Observable, Subject, switchMap, takeUntil } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
import { EventType } from "@bitwarden/common/enums";
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import {
CipherService,
EncryptionContext,
} from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { generate_ssh_key } from "@bitwarden/sdk-internal";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault";
@Directive()
export class AddEditComponent implements OnInit, OnDestroy {
@Input() cloneMode = false;
@Input() folderId: string = null;
@Input() cipherId: string;
@Input() type: CipherType;
@Input() collectionIds: string[];
@Input() organizationId: string = null;
@Input() collectionId: string = null;
@Output() onSavedCipher = new EventEmitter<CipherView>();
@Output() onDeletedCipher = new EventEmitter<CipherView>();
@Output() onRestoredCipher = new EventEmitter<CipherView>();
@Output() onCancelled = new EventEmitter<CipherView>();
@Output() onEditAttachments = new EventEmitter<CipherView>();
@Output() onShareCipher = new EventEmitter<CipherView>();
@Output() onEditCollections = new EventEmitter<CipherView>();
@Output() onGeneratePassword = new EventEmitter();
@Output() onGenerateUsername = new EventEmitter();
canDeleteCipher$: Observable<boolean>;
editMode = false;
cipher: CipherView;
folders$: Observable<FolderView[]>;
collections: CollectionView[] = [];
title: string;
formPromise: Promise<any>;
deletePromise: Promise<any>;
restorePromise: Promise<any>;
checkPasswordPromise: Promise<number>;
showPassword = false;
showPrivateKey = false;
showTotpSeed = false;
showCardNumber = false;
showCardCode = false;
cipherType = CipherType;
cardBrandOptions: any[];
cardExpMonthOptions: any[];
identityTitleOptions: any[];
uriMatchOptions: any[];
ownershipOptions: any[] = [];
autofillOnPageLoadOptions: any[];
currentDate = new Date();
allowPersonal = true;
reprompt = false;
canUseReprompt = true;
organization: Organization;
/**
* Flag to determine if the action is being performed from the admin console.
*/
isAdminConsoleAction: boolean = false;
protected componentName = "";
protected destroy$ = new Subject<void>();
protected writeableCollections: CollectionView[];
private organizationDataOwnershipAppliesToUser: boolean;
private previousCipherId: string;
get fido2CredentialCreationDateValue(): string {
const dateCreated = this.i18nService.t("dateCreated");
const creationDate = this.datePipe.transform(
this.cipher?.login?.fido2Credentials?.[0]?.creationDate,
"short",
);
return `${dateCreated} ${creationDate}`;
}
constructor(
protected cipherService: CipherService,
protected folderService: FolderService,
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected auditService: AuditService,
protected accountService: AccountService,
protected collectionService: CollectionService,
protected messagingService: MessagingService,
protected eventCollectionService: EventCollectionService,
protected policyService: PolicyService,
protected logService: LogService,
protected passwordRepromptService: PasswordRepromptService,
private organizationService: OrganizationService,
protected dialogService: DialogService,
protected win: Window,
protected datePipe: DatePipe,
protected configService: ConfigService,
protected cipherAuthorizationService: CipherAuthorizationService,
protected toastService: ToastService,
protected sdkService: SdkService,
private sshImportPromptService: SshImportPromptService,
) {
this.cardBrandOptions = [
{ name: "-- " + i18nService.t("select") + " --", value: null },
{ name: "Visa", value: "Visa" },
{ name: "Mastercard", value: "Mastercard" },
{ name: "American Express", value: "Amex" },
{ name: "Discover", value: "Discover" },
{ name: "Diners Club", value: "Diners Club" },
{ name: "JCB", value: "JCB" },
{ name: "Maestro", value: "Maestro" },
{ name: "UnionPay", value: "UnionPay" },
{ name: "RuPay", value: "RuPay" },
{ name: i18nService.t("other"), value: "Other" },
];
this.cardExpMonthOptions = [
{ name: "-- " + i18nService.t("select") + " --", value: null },
{ name: "01 - " + i18nService.t("january"), value: "1" },
{ name: "02 - " + i18nService.t("february"), value: "2" },
{ name: "03 - " + i18nService.t("march"), value: "3" },
{ name: "04 - " + i18nService.t("april"), value: "4" },
{ name: "05 - " + i18nService.t("may"), value: "5" },
{ name: "06 - " + i18nService.t("june"), value: "6" },
{ name: "07 - " + i18nService.t("july"), value: "7" },
{ name: "08 - " + i18nService.t("august"), value: "8" },
{ name: "09 - " + i18nService.t("september"), value: "9" },
{ name: "10 - " + i18nService.t("october"), value: "10" },
{ name: "11 - " + i18nService.t("november"), value: "11" },
{ name: "12 - " + i18nService.t("december"), value: "12" },
];
this.identityTitleOptions = [
{ name: "-- " + i18nService.t("select") + " --", value: null },
{ name: i18nService.t("mr"), value: i18nService.t("mr") },
{ name: i18nService.t("mrs"), value: i18nService.t("mrs") },
{ name: i18nService.t("ms"), value: i18nService.t("ms") },
{ name: i18nService.t("mx"), value: i18nService.t("mx") },
{ name: i18nService.t("dr"), value: i18nService.t("dr") },
];
this.uriMatchOptions = [
{ name: i18nService.t("defaultMatchDetection"), value: null },
{ name: i18nService.t("baseDomain"), value: UriMatchStrategy.Domain },
{ name: i18nService.t("host"), value: UriMatchStrategy.Host },
{ name: i18nService.t("startsWith"), value: UriMatchStrategy.StartsWith },
{ name: i18nService.t("regEx"), value: UriMatchStrategy.RegularExpression },
{ name: i18nService.t("exact"), value: UriMatchStrategy.Exact },
{ name: i18nService.t("never"), value: UriMatchStrategy.Never },
];
this.autofillOnPageLoadOptions = [
{ name: i18nService.t("autoFillOnPageLoadUseDefault"), value: null },
{ name: i18nService.t("autoFillOnPageLoadYes"), value: true },
{ name: i18nService.t("autoFillOnPageLoadNo"), value: false },
];
}
async ngOnInit() {
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId),
),
concatMap(async (policyAppliesToActiveUser) => {
this.organizationDataOwnershipAppliesToUser = policyAppliesToActiveUser;
await this.init();
}),
takeUntil(this.destroy$),
)
.subscribe();
this.writeableCollections = await this.loadCollections();
this.canUseReprompt = await this.passwordRepromptService.enabled();
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
async init() {
if (this.ownershipOptions.length) {
this.ownershipOptions = [];
}
if (this.organizationDataOwnershipAppliesToUser) {
this.allowPersonal = false;
} else {
const myEmail = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
this.ownershipOptions.push({ name: myEmail, value: null });
}
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
const orgs = await firstValueFrom(this.organizationService.organizations$(userId));
orgs
.filter((org) => org.isMember)
.sort(Utils.getSortFunction(this.i18nService, "name"))
.forEach((o) => {
if (o.enabled && o.status === OrganizationUserStatusType.Confirmed) {
this.ownershipOptions.push({ name: o.name, value: o.id });
}
});
if (!this.allowPersonal && this.organizationId == undefined) {
this.organizationId = this.defaultOwnerId;
}
}
async load() {
this.editMode = this.cipherId != null;
if (this.editMode) {
this.editMode = true;
if (this.cloneMode) {
this.cloneMode = true;
this.title = this.i18nService.t("addItem");
} else {
this.title = this.i18nService.t("editItem");
}
} else {
this.title = this.i18nService.t("addItem");
}
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const loadedAddEditCipherInfo = await this.loadAddEditCipherInfo(activeUserId);
if (this.cipher == null) {
if (this.editMode) {
const cipher = await this.loadCipher(activeUserId);
this.cipher = await this.cipherService.decrypt(cipher, activeUserId);
// Adjust Cipher Name if Cloning
if (this.cloneMode) {
this.cipher.name += " - " + this.i18nService.t("clone");
// If not allowing personal ownership, update cipher's org Id to prompt downstream changes
if (this.cipher.organizationId == null && !this.allowPersonal) {
this.cipher.organizationId = this.organizationId;
}
}
} else {
this.cipher = new CipherView();
this.cipher.organizationId = this.organizationId == null ? null : this.organizationId;
this.cipher.folderId = this.folderId;
this.cipher.type = this.type == null ? CipherType.Login : this.type;
this.cipher.login = new LoginView();
this.cipher.login.uris = [new LoginUriView()];
this.cipher.card = new CardView();
this.cipher.identity = new IdentityView();
this.cipher.secureNote = new SecureNoteView();
this.cipher.secureNote.type = SecureNoteType.Generic;
this.cipher.sshKey = new SshKeyView();
this.cipher.reprompt = CipherRepromptType.None;
}
}
if (this.cipher != null && (!this.editMode || loadedAddEditCipherInfo || this.cloneMode)) {
await this.organizationChanged();
if (
this.collectionIds != null &&
this.collectionIds.length > 0 &&
this.collections.length > 0
) {
this.collections.forEach((c) => {
if (this.collectionIds.indexOf(c.id) > -1) {
(c as any).checked = true;
}
});
}
}
// Only Admins can clone a cipher to different owner
if (this.cloneMode && this.cipher.organizationId != null) {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const cipherOrg = (
await firstValueFrom(this.organizationService.memberOrganizations$(activeUserId))
).find((o) => o.id === this.cipher.organizationId);
if (cipherOrg != null && !cipherOrg.isAdmin && !cipherOrg.permissions.editAnyCollection) {
this.ownershipOptions = [{ name: cipherOrg.name, value: cipherOrg.id }];
}
}
// We don't want to copy passkeys when we clone a cipher
if (this.cloneMode && this.cipher?.login?.hasFido2Credentials) {
this.cipher.login.fido2Credentials = null;
}
this.folders$ = this.folderService.folderViews$(activeUserId);
if (this.editMode && this.previousCipherId !== this.cipherId) {
void this.eventCollectionService.collectMany(EventType.Cipher_ClientViewed, [this.cipher]);
}
this.previousCipherId = this.cipherId;
this.reprompt = this.cipher.reprompt !== CipherRepromptType.None;
if (this.reprompt) {
this.cipher.login.autofillOnPageLoad = this.autofillOnPageLoadOptions[2].value;
}
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(
this.cipher,
this.isAdminConsoleAction,
);
if (!this.editMode || this.cloneMode) {
// Creating an ssh key directly while filtering to the ssh key category
// must force a key to be set. SSH keys must never be created with an empty private key field
if (
this.cipher.type === CipherType.SshKey &&
(this.cipher.sshKey.privateKey == null || this.cipher.sshKey.privateKey === "")
) {
await this.generateSshKey(false);
}
}
}
async submit(): Promise<boolean> {
if (this.cipher.isDeleted) {
return this.restore();
}
// normalize card expiry year on save
if (this.cipher.type === this.cipherType.Card) {
this.cipher.card.expYear = normalizeExpiryYearFormat(this.cipher.card.expYear);
}
// trim whitespace from the TOTP field
if (this.cipher.type === this.cipherType.Login && this.cipher.login.totp) {
this.cipher.login.totp = this.cipher.login.totp.trim();
}
if (this.cipher.name == null || this.cipher.name === "") {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("nameRequired"),
});
return false;
}
if (
(!this.editMode || this.cloneMode) &&
!this.allowPersonal &&
this.cipher.organizationId == null
) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("personalOwnershipSubmitError"),
});
return false;
}
if (
(!this.editMode || this.cloneMode) &&
this.cipher.type === CipherType.Login &&
this.cipher.login.uris != null &&
this.cipher.login.uris.length === 1 &&
(this.cipher.login.uris[0].uri == null || this.cipher.login.uris[0].uri === "")
) {
this.cipher.login.uris = [];
}
// Allows saving of selected collections during "Add" and "Clone" flows
if ((!this.editMode || this.cloneMode) && this.cipher.organizationId != null) {
this.cipher.collectionIds =
this.collections == null
? []
: this.collections.filter((c) => (c as any).checked).map((c) => c.id);
}
// Clear current Cipher Id if exists to trigger "Add" cipher flow
if (this.cloneMode) {
this.cipher.id = null;
}
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipher = await this.encryptCipher(activeUserId);
try {
this.formPromise = this.saveCipher(cipher);
const savedCipher = await this.formPromise;
// Reset local cipher from the saved cipher returned from the server
this.cipher = await savedCipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(savedCipher, activeUserId),
);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t(this.editMode && !this.cloneMode ? "editedItem" : "addedItem"),
});
this.onSavedCipher.emit(this.cipher);
this.messagingService.send(this.editMode && !this.cloneMode ? "editedCipher" : "addedCipher");
return true;
} catch (e) {
this.logService.error(e);
}
return false;
}
addUri() {
if (this.cipher.type !== CipherType.Login) {
return;
}
if (this.cipher.login.uris == null) {
this.cipher.login.uris = [];
}
this.cipher.login.uris.push(new LoginUriView());
}
removeUri(uri: LoginUriView) {
if (this.cipher.type !== CipherType.Login || this.cipher.login.uris == null) {
return;
}
const i = this.cipher.login.uris.indexOf(uri);
if (i > -1) {
this.cipher.login.uris.splice(i, 1);
}
}
removePasskey() {
if (this.cipher.type !== CipherType.Login || this.cipher.login.fido2Credentials == null) {
return;
}
this.cipher.login.fido2Credentials = null;
}
onCardNumberChange(): void {
this.cipher.card.brand = CardView.getCardBrandByPatterns(this.cipher.card.number);
}
getCardExpMonthDisplay() {
return this.cardExpMonthOptions.find((x) => x.value == this.cipher.card.expMonth)?.name;
}
trackByFunction(index: number, item: any) {
return index;
}
cancel() {
this.onCancelled.emit(this.cipher);
}
attachments() {
this.onEditAttachments.emit(this.cipher);
}
share() {
this.onShareCipher.emit(this.cipher);
}
editCollections() {
this.onEditCollections.emit(this.cipher);
}
async delete(): Promise<boolean> {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteItem" },
content: {
key: this.cipher.isDeleted ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation",
},
type: "warning",
});
if (!confirmed) {
return false;
}
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.deletePromise = this.deleteCipher(activeUserId);
await this.deletePromise;
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t(
this.cipher.isDeleted ? "permanentlyDeletedItem" : "deletedItem",
),
});
this.onDeletedCipher.emit(this.cipher);
this.messagingService.send(
this.cipher.isDeleted ? "permanentlyDeletedCipher" : "deletedCipher",
);
} catch (e) {
this.logService.error(e);
}
return true;
}
async restore(): Promise<boolean> {
if (!this.cipher.isDeleted) {
return false;
}
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.restorePromise = this.restoreCipher(activeUserId);
await this.restorePromise;
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("restoredItem"),
});
this.onRestoredCipher.emit(this.cipher);
this.messagingService.send("restoredCipher");
} catch (e) {
this.logService.error(e);
}
return true;
}
async generateUsername(): Promise<boolean> {
if (this.cipher.login?.username?.length) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "overwriteUsername" },
content: { key: "overwriteUsernameConfirmation" },
type: "warning",
});
if (!confirmed) {
return false;
}
}
this.onGenerateUsername.emit();
return true;
}
async generatePassword(): Promise<boolean> {
if (this.cipher.login?.password?.length) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "overwritePassword" },
content: { key: "overwritePasswordConfirmation" },
type: "warning",
});
if (!confirmed) {
return false;
}
}
this.onGeneratePassword.emit();
return true;
}
togglePassword() {
this.showPassword = !this.showPassword;
if (this.editMode && this.showPassword) {
document.getElementById("loginPassword")?.focus();
void this.eventCollectionService.collectMany(EventType.Cipher_ClientToggledPasswordVisible, [
this.cipher,
]);
}
}
toggleTotpSeed() {
this.showTotpSeed = !this.showTotpSeed;
if (this.editMode && this.showTotpSeed) {
document.getElementById("loginTotp")?.focus();
void this.eventCollectionService.collectMany(EventType.Cipher_ClientToggledTOTPSeedVisible, [
this.cipher,
]);
}
}
async toggleCardNumber() {
this.showCardNumber = !this.showCardNumber;
if (this.showCardNumber) {
void this.eventCollectionService.collectMany(
EventType.Cipher_ClientToggledCardNumberVisible,
[this.cipher],
);
}
}
toggleCardCode() {
this.showCardCode = !this.showCardCode;
document.getElementById("cardCode").focus();
if (this.editMode && this.showCardCode) {
void this.eventCollectionService.collectMany(EventType.Cipher_ClientToggledCardCodeVisible, [
this.cipher,
]);
}
}
togglePrivateKey() {
this.showPrivateKey = !this.showPrivateKey;
}
toggleUriOptions(uri: LoginUriView) {
const u = uri as any;
u.showOptions = u.showOptions == null && uri.match != null ? false : !u.showOptions;
}
loginUriMatchChanged(uri: LoginUriView) {
const u = uri as any;
u.showOptions = u.showOptions == null ? true : u.showOptions;
}
async organizationChanged() {
if (this.writeableCollections != null) {
this.writeableCollections.forEach((c) => ((c as any).checked = false));
}
if (this.cipher.organizationId != null) {
this.collections = this.writeableCollections?.filter(
(c) => c.organizationId === this.cipher.organizationId,
);
// If there's only one collection, check it by default
if (this.collections.length === 1) {
(this.collections[0] as any).checked = true;
}
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const org = (
await firstValueFrom(this.organizationService.organizations$(activeUserId))
).find((org) => org.id === this.cipher.organizationId);
if (org != null) {
this.cipher.organizationUseTotp = org.useTotp;
}
} else {
this.collections = [];
}
}
async checkPassword() {
if (this.checkPasswordPromise != null) {
return;
}
if (
this.cipher.login == null ||
this.cipher.login.password == null ||
this.cipher.login.password === ""
) {
return;
}
this.checkPasswordPromise = this.auditService.passwordLeaked(this.cipher.login.password);
const matches = await this.checkPasswordPromise;
this.checkPasswordPromise = null;
if (matches > 0) {
this.toastService.showToast({
variant: "warning",
title: null,
message: this.i18nService.t("passwordExposed", matches.toString()),
});
} else {
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("passwordSafe"),
});
}
}
repromptChanged() {
this.reprompt = !this.reprompt;
if (this.reprompt) {
this.cipher.reprompt = CipherRepromptType.Password;
this.cipher.login.autofillOnPageLoad = this.autofillOnPageLoadOptions[2].value;
} else {
this.cipher.reprompt = CipherRepromptType.None;
this.cipher.login.autofillOnPageLoad = this.autofillOnPageLoadOptions[0].value;
}
}
protected async loadCollections() {
const allCollections = await this.collectionService.getAllDecrypted();
return allCollections.filter((c) => !c.readOnly);
}
protected loadCipher(userId: UserId) {
return this.cipherService.get(this.cipherId, userId);
}
protected encryptCipher(userId: UserId) {
return this.cipherService.encrypt(this.cipher, userId);
}
protected saveCipher(data: EncryptionContext) {
let orgAdmin = this.organization?.canEditAllCiphers;
// if a cipher is unassigned we want to check if they are an admin or have permission to edit any collection
if (!data.cipher.collectionIds) {
orgAdmin = this.organization?.canEditUnassignedCiphers;
}
return this.cipher.id == null
? this.cipherService.createWithServer(data, orgAdmin)
: this.cipherService.updateWithServer(data, orgAdmin);
}
protected deleteCipher(userId: UserId) {
return this.cipher.isDeleted
? this.cipherService.deleteWithServer(this.cipher.id, userId, this.asAdmin)
: this.cipherService.softDeleteWithServer(this.cipher.id, userId, this.asAdmin);
}
protected restoreCipher(userId: UserId) {
return this.cipherService.restoreWithServer(this.cipher.id, userId, this.asAdmin);
}
/**
* Determines if a cipher must be deleted as an admin by belonging to an organization and being unassigned to a collection.
*/
get asAdmin(): boolean {
return (
this.cipher.organizationId !== null &&
this.cipher.organizationId.length > 0 &&
(this.organization?.canEditAllCiphers ||
!this.cipher.collectionIds ||
this.cipher.collectionIds.length === 0)
);
}
get defaultOwnerId(): string | null {
return this.ownershipOptions[0].value;
}
async loadAddEditCipherInfo(userId: UserId): Promise<boolean> {
const addEditCipherInfo: any = await firstValueFrom(
this.cipherService.addEditCipherInfo$(userId),
);
const loadedSavedInfo = addEditCipherInfo != null;
if (loadedSavedInfo) {
this.cipher = addEditCipherInfo.cipher;
this.collectionIds = addEditCipherInfo.collectionIds;
if (!this.editMode && !this.allowPersonal && this.cipher.organizationId == null) {
// This is a new cipher and personal ownership isn't allowed, so we need to set the default owner
this.cipher.organizationId = this.defaultOwnerId;
}
}
await this.cipherService.setAddEditCipherInfo(null, userId);
return loadedSavedInfo;
}
async copy(value: string, typeI18nKey: string, aType: string): Promise<boolean> {
if (value == null) {
return false;
}
const copyOptions = this.win != null ? { window: this.win } : null;
this.platformUtilsService.copyToClipboard(value, copyOptions);
this.toastService.showToast({
variant: "info",
title: null,
message: this.i18nService.t("valueCopied", this.i18nService.t(typeI18nKey)),
});
if (typeI18nKey === "password") {
void this.eventCollectionService.collectMany(EventType.Cipher_ClientCopiedPassword, [
this.cipher,
]);
} else if (typeI18nKey === "securityCode") {
void this.eventCollectionService.collectMany(EventType.Cipher_ClientCopiedCardCode, [
this.cipher,
]);
} else if (aType === "H_Field") {
void this.eventCollectionService.collectMany(EventType.Cipher_ClientCopiedHiddenField, [
this.cipher,
]);
}
return true;
}
async importSshKeyFromClipboard() {
const key = await this.sshImportPromptService.importSshKeyFromClipboard();
if (key != null) {
this.cipher.sshKey.privateKey = key.privateKey;
this.cipher.sshKey.publicKey = key.publicKey;
this.cipher.sshKey.keyFingerprint = key.keyFingerprint;
}
}
private async generateSshKey(showNotification: boolean = true) {
await firstValueFrom(this.sdkService.client$);
const sshKey = generate_ssh_key("Ed25519");
this.cipher.sshKey.privateKey = sshKey.privateKey;
this.cipher.sshKey.publicKey = sshKey.publicKey;
this.cipher.sshKey.keyFingerprint = sshKey.fingerprint;
if (showNotification) {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("sshKeyGenerated"),
});
}
}
async typeChange() {
if (this.cipher.type === CipherType.SshKey) {
await this.generateSshKey();
}
}
}

View File

@@ -1,354 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
@Directive()
export class AttachmentsComponent implements OnInit {
@Input() cipherId: string;
@Input() viewOnly: boolean;
@Output() onUploadedAttachment = new EventEmitter<CipherView>();
@Output() onDeletedAttachment = new EventEmitter();
@Output() onReuploadedAttachment = new EventEmitter();
cipher: CipherView;
cipherDomain: Cipher;
canAccessAttachments: boolean;
formPromise: Promise<any>;
deletePromises: { [id: string]: Promise<CipherData> } = {};
reuploadPromises: { [id: string]: Promise<any> } = {};
emergencyAccessId?: string = null;
protected componentName = "";
constructor(
protected cipherService: CipherService,
protected i18nService: I18nService,
protected keyService: KeyService,
protected encryptService: EncryptService,
protected platformUtilsService: PlatformUtilsService,
protected apiService: ApiService,
protected win: Window,
protected logService: LogService,
protected stateService: StateService,
protected fileDownloadService: FileDownloadService,
protected dialogService: DialogService,
protected billingAccountProfileStateService: BillingAccountProfileStateService,
protected accountService: AccountService,
protected toastService: ToastService,
protected configService: ConfigService,
) {}
async ngOnInit() {
await this.init();
}
async submit() {
const fileEl = document.getElementById("file") as HTMLInputElement;
const files = fileEl.files;
if (files == null || files.length === 0) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("selectFile"),
});
return;
}
if (files[0].size > 524288000) {
// 500 MB
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("maxFileSize"),
});
return;
}
try {
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.formPromise = this.saveCipherAttachment(files[0], activeUserId);
this.cipherDomain = await this.formPromise;
this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("attachmentSaved"),
});
this.onUploadedAttachment.emit(this.cipher);
} catch (e) {
this.logService.error(e);
}
// reset file input
// ref: https://stackoverflow.com/a/20552042
fileEl.type = "";
fileEl.type = "file";
fileEl.value = "";
}
async delete(attachment: AttachmentView) {
if (this.deletePromises[attachment.id] != null) {
return;
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteAttachment" },
content: { key: "deleteAttachmentConfirmation" },
type: "warning",
});
if (!confirmed) {
return;
}
try {
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.deletePromises[attachment.id] = this.deleteCipherAttachment(attachment.id, activeUserId);
const updatedCipher = await this.deletePromises[attachment.id];
const cipher = new Cipher(updatedCipher);
this.cipher = await this.cipherService.decrypt(cipher, activeUserId);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("deletedAttachment"),
});
const i = this.cipher.attachments.indexOf(attachment);
if (i > -1) {
this.cipher.attachments.splice(i, 1);
}
} catch (e) {
this.logService.error(e);
}
this.deletePromises[attachment.id] = null;
this.onDeletedAttachment.emit(this.cipher);
}
async download(attachment: AttachmentView) {
const a = attachment as any;
if (a.downloading) {
return;
}
if (!this.canAccessAttachments) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("premiumRequired"),
message: this.i18nService.t("premiumRequiredDesc"),
});
return;
}
let url: string;
try {
const attachmentDownloadResponse = await this.apiService.getAttachmentData(
this.cipher.id,
attachment.id,
this.emergencyAccessId,
);
url = attachmentDownloadResponse.url;
} catch (e) {
if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) {
url = attachment.url;
} else if (e instanceof ErrorResponse) {
throw new Error((e as ErrorResponse).getSingleMessage());
} else {
throw e;
}
}
a.downloading = true;
const response = await fetch(new Request(url, { cache: "no-store" }));
if (response.status !== 200) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
a.downloading = false;
return;
}
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const decBuf = await this.cipherService.getDecryptedAttachmentBuffer(
this.cipherDomain.id as CipherId,
attachment,
response,
activeUserId,
);
this.fileDownloadService.download({
fileName: attachment.fileName,
blobData: decBuf,
});
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("fileSavedToDevice"),
});
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
}
a.downloading = false;
}
protected async init() {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.cipherDomain = await this.loadCipher(activeUserId);
this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId);
const canAccessPremium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId),
);
this.canAccessAttachments = canAccessPremium || this.cipher.organizationId != null;
if (!this.canAccessAttachments) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "premiumRequired" },
content: { key: "premiumRequiredDesc" },
acceptButtonText: { key: "learnMore" },
type: "success",
});
if (confirmed) {
this.platformUtilsService.launchUri(
"https://vault.bitwarden.com/#/settings/subscription/premium",
);
}
}
}
protected async reuploadCipherAttachment(attachment: AttachmentView, admin: boolean) {
const a = attachment as any;
if (attachment.key != null || a.downloading || this.reuploadPromises[attachment.id] != null) {
return;
}
try {
this.reuploadPromises[attachment.id] = Promise.resolve().then(async () => {
// 1. Download
a.downloading = true;
const response = await fetch(new Request(attachment.url, { cache: "no-store" }));
if (response.status !== 200) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
a.downloading = false;
return;
}
try {
// 2. Resave
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getUserId),
);
const decBuf = await this.cipherService.getDecryptedAttachmentBuffer(
this.cipherDomain.id as CipherId,
attachment,
response,
activeUserId,
);
this.cipherDomain = await this.cipherService.saveAttachmentRawWithServer(
this.cipherDomain,
attachment.fileName,
decBuf,
activeUserId,
admin,
);
this.cipher = await this.cipherService.decrypt(this.cipherDomain, activeUserId);
// 3. Delete old
this.deletePromises[attachment.id] = this.deleteCipherAttachment(
attachment.id,
activeUserId,
);
await this.deletePromises[attachment.id];
const foundAttachment = this.cipher.attachments.filter((a2) => a2.id === attachment.id);
if (foundAttachment.length > 0) {
const i = this.cipher.attachments.indexOf(foundAttachment[0]);
if (i > -1) {
this.cipher.attachments.splice(i, 1);
}
}
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("attachmentSaved"),
});
this.onReuploadedAttachment.emit();
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
}
a.downloading = false;
});
await this.reuploadPromises[attachment.id];
} catch (e) {
this.logService.error(e);
}
}
protected loadCipher(userId: UserId) {
return this.cipherService.get(this.cipherId, userId);
}
protected saveCipherAttachment(file: File, userId: UserId) {
return this.cipherService.saveAttachmentWithServer(this.cipherDomain, file, userId);
}
protected deleteCipherAttachment(attachmentId: string, userId: UserId) {
return this.cipherService.deleteAttachmentWithServer(
this.cipher.id,
attachmentId,
userId,
false,
);
}
protected async reupload(attachment: AttachmentView) {
// TODO: This should be removed but is needed since we re-use the same template
}
}

View File

@@ -13,7 +13,7 @@ import {
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { buildCipherIcon, CipherIconDetails } from "@bitwarden/common/vault/icon/build-cipher-icon";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
@Component({
selector: "app-vault-icon",
@@ -25,7 +25,7 @@ export class IconComponent {
/**
* The cipher to display the icon for.
*/
cipher = input.required<CipherView>();
cipher = input.required<CipherViewLike>();
imageLoaded = signal(false);

View File

@@ -1,48 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, OnInit } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view";
import { ToastService } from "@bitwarden/components";
@Directive()
export class PasswordHistoryComponent implements OnInit {
cipherId: string;
history: PasswordHistoryView[] = [];
constructor(
protected cipherService: CipherService,
protected platformUtilsService: PlatformUtilsService,
protected i18nService: I18nService,
protected accountService: AccountService,
private win: Window,
private toastService: ToastService,
) {}
async ngOnInit() {
await this.init();
}
copy(password: string) {
const copyOptions = this.win != null ? { window: this.win } : null;
this.platformUtilsService.copyToClipboard(password, copyOptions);
this.toastService.showToast({
variant: "info",
title: null,
message: this.i18nService.t("valueCopied", this.i18nService.t("password")),
});
}
protected async init() {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipher = await this.cipherService.get(this.cipherId, activeUserId);
const decCipher = await this.cipherService.decrypt(cipher, activeUserId);
this.history = decCipher.passwordHistory == null ? [] : decCipher.passwordHistory;
}
}

View File

@@ -1,5 +1,5 @@
<div
class="tw-rounded-2xl tw-bg-primary-100 tw-border-primary-600 tw-border-solid tw-border tw-p-4 tw-pt-3 tw-flex tw-flex-col tw-gap-2"
class="tw-rounded-2xl tw-bg-primary-100 tw-border-primary-600 tw-border-solid tw-border tw-p-4 tw-pt-3 tw-flex tw-flex-col tw-gap-2 tw-mb-4"
>
<div class="tw-flex tw-justify-between tw-items-start tw-flex-grow">
<div>

View File

@@ -15,26 +15,29 @@ import {
takeUntil,
} from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items";
import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
@Directive()
export class VaultItemsComponent implements OnInit, OnDestroy {
export class VaultItemsComponent<C extends CipherViewLike> implements OnInit, OnDestroy {
@Input() activeCipherId: string = null;
@Output() onCipherClicked = new EventEmitter<CipherView>();
@Output() onCipherRightClicked = new EventEmitter<CipherView>();
@Output() onCipherClicked = new EventEmitter<C>();
@Output() onCipherRightClicked = new EventEmitter<C>();
@Output() onAddCipher = new EventEmitter<CipherType | undefined>();
@Output() onAddCipherOptions = new EventEmitter();
loaded = false;
ciphers: CipherView[] = [];
ciphers: C[] = [];
deleted = false;
organization: Organization;
CipherType = CipherType;
@@ -55,7 +58,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
protected searchPending = false;
/** Construct filters as an observable so it can be appended to the cipher stream. */
private _filter$ = new BehaviorSubject<(cipher: CipherView) => boolean | null>(null);
private _filter$ = new BehaviorSubject<(cipher: C) => boolean | null>(null);
private destroy$ = new Subject<void>();
private isSearchable: boolean = false;
private _searchText$ = new BehaviorSubject<string>("");
@@ -71,7 +74,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
return this._filter$.value;
}
set filter(value: (cipher: CipherView) => boolean | null) {
set filter(value: (cipher: C) => boolean | null) {
this._filter$.next(value);
}
@@ -102,13 +105,13 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
async load(filter: (cipher: CipherView) => boolean = null, deleted = false) {
async load(filter: (cipher: C) => boolean = null, deleted = false) {
this.deleted = deleted ?? false;
await this.applyFilter(filter);
this.loaded = true;
}
async reload(filter: (cipher: CipherView) => boolean = null, deleted = false) {
async reload(filter: (cipher: C) => boolean = null, deleted = false) {
this.loaded = false;
await this.load(filter, deleted);
}
@@ -117,15 +120,15 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
await this.reload(this.filter, this.deleted);
}
async applyFilter(filter: (cipher: CipherView) => boolean = null) {
async applyFilter(filter: (cipher: C) => boolean = null) {
this.filter = filter;
}
selectCipher(cipher: CipherView) {
selectCipher(cipher: C) {
this.onCipherClicked.emit(cipher);
}
rightClickCipher(cipher: CipherView) {
rightClickCipher(cipher: C) {
this.onCipherRightClicked.emit(cipher);
}
@@ -141,7 +144,8 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
return !this.searchPending && this.isSearchable;
}
protected deletedFilter: (cipher: CipherView) => boolean = (c) => c.isDeleted === this.deleted;
protected deletedFilter: (cipher: C) => boolean = (c) =>
CipherViewLikeUtils.isDeleted(c) === this.deleted;
/**
* Creates stream of dependencies that results in the list of ciphers to display
@@ -156,7 +160,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
.pipe(
switchMap((userId) =>
combineLatest([
this.cipherService.cipherViews$(userId).pipe(filter((ciphers) => ciphers != null)),
this.cipherService.cipherListViews$(userId).pipe(filter((ciphers) => ciphers != null)),
this.cipherService.failedToDecryptCiphers$(userId),
this._searchText$,
this._filter$,
@@ -165,12 +169,12 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
]),
),
switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId, restricted]) => {
let allCiphers = indexedCiphers ?? [];
let allCiphers = (indexedCiphers ?? []) as C[];
const _failedCiphers = failedCiphers ?? [];
allCiphers = [..._failedCiphers, ...allCiphers];
allCiphers = [..._failedCiphers, ...allCiphers] as C[];
const restrictedTypeFilter = (cipher: CipherView) =>
const restrictedTypeFilter = (cipher: CipherViewLike) =>
!this.restrictedItemTypesService.isCipherRestricted(cipher, restricted);
return this.searchService.searchCiphers(

View File

@@ -1,50 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, Input } from "@angular/core";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { EventType } from "@bitwarden/common/enums";
import { FieldType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
@Directive()
export class ViewCustomFieldsComponent {
@Input() cipher: CipherView;
@Input() promptPassword: () => Promise<boolean>;
@Input() copy: (value: string, typeI18nKey: string, aType: string) => void;
fieldType = FieldType;
constructor(private eventCollectionService: EventCollectionService) {}
async toggleFieldValue(field: FieldView) {
if (!(await this.promptPassword())) {
return;
}
const f = field as any;
f.showValue = !f.showValue;
f.showCount = false;
if (f.showValue) {
// 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.eventCollectionService.collect(
EventType.Cipher_ClientToggledHiddenFieldVisible,
this.cipher.id,
);
}
}
async toggleFieldCount(field: FieldView) {
if (!field.showValue) {
return;
}
field.showCount = !field.showCount;
}
setTextDataOnDrag(event: DragEvent, data: string) {
event.dataTransfer.setData("text", data);
}
}

View File

@@ -1,568 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DatePipe } from "@angular/common";
import {
ChangeDetectorRef,
Directive,
EventEmitter,
Input,
NgZone,
OnDestroy,
OnInit,
Output,
} from "@angular/core";
import {
BehaviorSubject,
combineLatest,
filter,
firstValueFrom,
map,
Observable,
of,
switchMap,
tap,
} from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EventType } from "@bitwarden/common/enums";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { CipherType, FieldType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { Launchable } from "@bitwarden/common/vault/interfaces/launchable";
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { TotpInfo } from "@bitwarden/common/vault/services/totp.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { PasswordRepromptService } from "@bitwarden/vault";
const BroadcasterSubscriptionId = "BaseViewComponent";
@Directive()
export class ViewComponent implements OnDestroy, OnInit {
/** Observable of cipherId$ that will update each time the `Input` updates */
private _cipherId$ = new BehaviorSubject<string>(null);
@Input()
set cipherId(value: string) {
this._cipherId$.next(value);
}
get cipherId(): string {
return this._cipherId$.getValue();
}
@Input() collectionId: string;
@Output() onEditCipher = new EventEmitter<CipherView>();
@Output() onCloneCipher = new EventEmitter<CipherView>();
@Output() onShareCipher = new EventEmitter<CipherView>();
@Output() onDeletedCipher = new EventEmitter<CipherView>();
@Output() onRestoredCipher = new EventEmitter<CipherView>();
canDeleteCipher$: Observable<boolean>;
canRestoreCipher$: Observable<boolean>;
cipher: CipherView;
showPassword: boolean;
showPasswordCount: boolean;
showCardNumber: boolean;
showCardCode: boolean;
showPrivateKey: boolean;
canAccessPremium: boolean;
showPremiumRequiredTotp: boolean;
fieldType = FieldType;
checkPasswordPromise: Promise<number>;
folder: FolderView;
cipherType = CipherType;
private previousCipherId: string;
protected passwordReprompted = false;
/**
* Represents TOTP information including display formatting and timing
*/
protected totpInfo$: Observable<TotpInfo> | undefined;
get fido2CredentialCreationDateValue(): string {
const dateCreated = this.i18nService.t("dateCreated");
const creationDate = this.datePipe.transform(
this.cipher?.login?.fido2Credentials?.[0]?.creationDate,
"short",
);
return `${dateCreated} ${creationDate}`;
}
constructor(
protected cipherService: CipherService,
protected folderService: FolderService,
protected totpService: TotpService,
protected tokenService: TokenService,
protected i18nService: I18nService,
protected keyService: KeyService,
protected encryptService: EncryptService,
protected platformUtilsService: PlatformUtilsService,
protected auditService: AuditService,
protected win: Window,
protected broadcasterService: BroadcasterService,
protected ngZone: NgZone,
protected changeDetectorRef: ChangeDetectorRef,
protected eventCollectionService: EventCollectionService,
protected apiService: ApiService,
protected passwordRepromptService: PasswordRepromptService,
private logService: LogService,
protected stateService: StateService,
protected fileDownloadService: FileDownloadService,
protected dialogService: DialogService,
protected datePipe: DatePipe,
protected accountService: AccountService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
protected toastService: ToastService,
private cipherAuthorizationService: CipherAuthorizationService,
protected configService: ConfigService,
) {}
ngOnInit() {
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
// 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.ngZone.run(async () => {
switch (message.command) {
case "syncCompleted":
if (message.successfully) {
this.changeDetectorRef.detectChanges();
}
break;
}
});
});
// Set up the subscription to the activeAccount$ and cipherId$ observables
combineLatest([this.accountService.activeAccount$.pipe(getUserId), this._cipherId$])
.pipe(
tap(() => this.cleanUp()),
switchMap(([userId, cipherId]) => {
const cipher$ = this.cipherService.cipherViews$(userId).pipe(
map((ciphers) => ciphers?.find((c) => c.id === cipherId)),
filter((cipher) => !!cipher),
);
return combineLatest([of(userId), cipher$]);
}),
)
.subscribe(([userId, cipher]) => {
this.cipher = cipher;
void this.constructCipherDetails(userId);
});
}
ngOnDestroy() {
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
this.cleanUp();
}
async edit() {
this.onEditCipher.emit(this.cipher);
}
async clone() {
if (this.cipher.login?.hasFido2Credentials) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "passkeyNotCopied" },
content: { key: "passkeyNotCopiedAlert" },
type: "info",
});
if (!confirmed) {
return false;
}
}
if (await this.promptPassword()) {
this.onCloneCipher.emit(this.cipher);
return true;
}
return false;
}
async share() {
if (await this.promptPassword()) {
this.onShareCipher.emit(this.cipher);
return true;
}
return false;
}
async delete(): Promise<boolean> {
if (!(await this.promptPassword())) {
return;
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteItem" },
content: {
key: this.cipher.isDeleted ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation",
},
type: "warning",
});
if (!confirmed) {
return false;
}
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.deleteCipher(activeUserId);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t(
this.cipher.isDeleted ? "permanentlyDeletedItem" : "deletedItem",
),
});
this.onDeletedCipher.emit(this.cipher);
} catch (e) {
this.logService.error(e);
}
return true;
}
async restore(): Promise<boolean> {
if (!this.cipher.isDeleted) {
return false;
}
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.restoreCipher(activeUserId);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("restoredItem"),
});
this.onRestoredCipher.emit(this.cipher);
} catch (e) {
this.logService.error(e);
}
return true;
}
async togglePassword() {
if (!(await this.promptPassword())) {
return;
}
this.showPassword = !this.showPassword;
this.showPasswordCount = false;
if (this.showPassword) {
// 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.eventCollectionService.collect(
EventType.Cipher_ClientToggledPasswordVisible,
this.cipherId,
);
}
}
async togglePasswordCount() {
if (!this.showPassword) {
return;
}
this.showPasswordCount = !this.showPasswordCount;
}
async toggleCardNumber() {
if (!(await this.promptPassword())) {
return;
}
this.showCardNumber = !this.showCardNumber;
if (this.showCardNumber) {
// 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.eventCollectionService.collect(
EventType.Cipher_ClientToggledCardNumberVisible,
this.cipherId,
);
}
}
async toggleCardCode() {
if (!(await this.promptPassword())) {
return;
}
this.showCardCode = !this.showCardCode;
if (this.showCardCode) {
// 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.eventCollectionService.collect(
EventType.Cipher_ClientToggledCardCodeVisible,
this.cipherId,
);
}
}
togglePrivateKey() {
this.showPrivateKey = !this.showPrivateKey;
}
async checkPassword() {
if (
this.cipher.login == null ||
this.cipher.login.password == null ||
this.cipher.login.password === ""
) {
return;
}
this.checkPasswordPromise = this.auditService.passwordLeaked(this.cipher.login.password);
const matches = await this.checkPasswordPromise;
if (matches > 0) {
this.toastService.showToast({
variant: "warning",
title: null,
message: this.i18nService.t("passwordExposed", matches.toString()),
});
} else {
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("passwordSafe"),
});
}
}
async launch(uri: Launchable, cipherId?: string) {
if (!uri.canLaunch) {
return;
}
if (cipherId) {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.cipherService.updateLastLaunchedDate(cipherId, activeUserId);
}
this.platformUtilsService.launchUri(uri.launchUri);
}
async copy(value: string, typeI18nKey: string, aType: string): Promise<boolean> {
if (value == null) {
return false;
}
if (
this.passwordRepromptService.protectedFields().includes(aType) &&
!(await this.promptPassword())
) {
return false;
}
const copyOptions = this.win != null ? { window: this.win } : null;
this.platformUtilsService.copyToClipboard(value, copyOptions);
this.toastService.showToast({
variant: "info",
title: null,
message: this.i18nService.t("valueCopied", this.i18nService.t(typeI18nKey)),
});
if (typeI18nKey === "password") {
// 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.eventCollectionService.collect(EventType.Cipher_ClientCopiedPassword, this.cipherId);
} else if (typeI18nKey === "securityCode") {
// 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.eventCollectionService.collect(EventType.Cipher_ClientCopiedCardCode, this.cipherId);
} else if (aType === "H_Field") {
// 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.eventCollectionService.collect(EventType.Cipher_ClientCopiedHiddenField, this.cipherId);
}
return true;
}
setTextDataOnDrag(event: DragEvent, data: string) {
event.dataTransfer.setData("text", data);
}
async downloadAttachment(attachment: AttachmentView) {
if (!(await this.promptPassword())) {
return;
}
const a = attachment as any;
if (a.downloading) {
return;
}
if (this.cipher.organizationId == null && !this.canAccessPremium) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("premiumRequired"),
message: this.i18nService.t("premiumRequiredDesc"),
});
return;
}
let url: string;
try {
const attachmentDownloadResponse = await this.apiService.getAttachmentData(
this.cipher.id,
attachment.id,
);
url = attachmentDownloadResponse.url;
} catch (e) {
if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) {
url = attachment.url;
} else if (e instanceof ErrorResponse) {
throw new Error((e as ErrorResponse).getSingleMessage());
} else {
throw e;
}
}
a.downloading = true;
const response = await fetch(new Request(url, { cache: "no-store" }));
if (response.status !== 200) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
a.downloading = false;
return;
}
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const decBuf = await this.cipherService.getDecryptedAttachmentBuffer(
this.cipher.id as CipherId,
attachment,
response,
activeUserId,
);
this.fileDownloadService.download({
fileName: attachment.fileName,
blobData: decBuf,
});
} catch {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
}
a.downloading = false;
}
protected deleteCipher(userId: UserId) {
return this.cipher.isDeleted
? this.cipherService.deleteWithServer(this.cipher.id, userId)
: this.cipherService.softDeleteWithServer(this.cipher.id, userId);
}
protected restoreCipher(userId: UserId) {
return this.cipherService.restoreWithServer(this.cipher.id, userId);
}
protected async promptPassword() {
if (this.cipher.reprompt === CipherRepromptType.None || this.passwordReprompted) {
return true;
}
return (this.passwordReprompted = await this.passwordRepromptService.showPasswordPrompt());
}
private cleanUp() {
this.cipher = null;
this.folder = null;
this.showPassword = false;
this.showCardNumber = false;
this.showCardCode = false;
this.passwordReprompted = false;
}
/**
* When a cipher is viewed, construct all details for the view that are not directly
* available from the cipher object itself.
*/
private async constructCipherDetails(userId: UserId) {
this.canAccessPremium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId),
);
this.showPremiumRequiredTotp =
this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp;
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher);
this.canRestoreCipher$ = this.cipherAuthorizationService.canRestoreCipher$(this.cipher);
if (this.cipher.folderId) {
this.folder = await (
await firstValueFrom(this.folderService.folderViews$(userId))
).find((f) => f.id == this.cipher.folderId);
}
const canGenerateTotp =
this.cipher.type === CipherType.Login &&
this.cipher.login.totp &&
(this.cipher.organizationUseTotp || this.canAccessPremium);
this.totpInfo$ = canGenerateTotp
? this.totpService.getCode$(this.cipher.login.totp).pipe(
map((response) => {
const epoch = Math.round(new Date().getTime() / 1000.0);
const mod = epoch % response.period;
// Format code
const totpCodeFormatted =
response.code.length > 4
? `${response.code.slice(0, Math.floor(response.code.length / 2))} ${response.code.slice(Math.floor(response.code.length / 2))}`
: response.code;
return {
totpCode: response.code,
totpCodeFormatted,
totpDash: +(Math.round(((78.6 / response.period) * mod + "e+2") as any) + "e-2"),
totpSec: response.period - mod,
totpLow: response.period - mod <= 7,
} as TotpInfo;
}),
)
: undefined;
if (this.previousCipherId !== this.cipherId) {
// 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.eventCollectionService.collect(EventType.Cipher_ClientViewed, this.cipherId);
}
this.previousCipherId = this.cipherId;
}
}

View File

@@ -9,7 +9,6 @@ import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { BiometricStateService } from "@bitwarden/key-management";
@@ -24,7 +23,6 @@ export class AccountSecurityNudgeService extends DefaultSingleNudgeService {
private vaultProfileService = inject(VaultProfileService);
private logService = inject(LogService);
private pinService = inject(PinServiceAbstraction);
private vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
private biometricStateService = inject(BiometricStateService);
private policyService = inject(PolicyService);
private organizationService = inject(OrganizationService);
@@ -76,7 +74,10 @@ export class AccountSecurityNudgeService extends DefaultSingleNudgeService {
hasSpotlightDismissed: status.hasSpotlightDismissed || hideNudge,
};
if (isPinSet || biometricUnlockEnabled || hasOrgWithRemovePinPolicyOn) {
if (
(isPinSet || biometricUnlockEnabled || hasOrgWithRemovePinPolicyOn) &&
!status.hasSpotlightDismissed
) {
await this.setNudgeStatus(nudgeType, acctSecurityNudgeStatus, userId);
}
return acctSecurityNudgeStatus;

View File

@@ -25,9 +25,9 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.getNudgeStatus$(nudgeType, userId),
this.cipherService.cipherViews$(userId),
this.cipherService.cipherListViews$(userId),
this.organizationService.organizations$(userId),
this.collectionService.decryptedCollections$,
this.collectionService.decryptedCollections$(userId),
]).pipe(
switchMap(([nudgeStatus, ciphers, orgs, collections]) => {
const vaultHasContents = !(ciphers == null || ciphers.length === 0);
@@ -42,7 +42,7 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
const orgIds = new Set(orgs.map((org) => org.id));
const canCreateCollections = orgs.some((org) => org.canCreateNewCollections);
const hasManageCollections = collections.some(
(c) => c.manage && orgIds.has(c.organizationId),
(c) => c.manage && orgIds.has(c.organizationId!),
);
// When the user has dismissed the nudge or spotlight, return the nudge status directly

View File

@@ -44,7 +44,11 @@ export class HasItemsNudgeService extends DefaultSingleNudgeService {
return cipher.deletedDate == null;
});
if (profileOlderThanCutoff && filteredCiphers.length > 0) {
if (
profileOlderThanCutoff &&
filteredCiphers.length > 0 &&
!nudgeStatus.hasSpotlightDismissed
) {
const dismissedStatus = {
hasSpotlightDismissed: true,
hasBadgeDismissed: true,

View File

@@ -49,7 +49,7 @@ export class NewItemNudgeService extends DefaultSingleNudgeService {
const ciphersBoolean = ciphers.some((cipher) => cipher.type === currentType);
if (ciphersBoolean) {
if (ciphersBoolean && !nudgeStatus.hasSpotlightDismissed) {
const dismissedStatus = {
hasSpotlightDismissed: true,
hasBadgeDismissed: true,

View File

@@ -27,7 +27,7 @@ export class VaultSettingsImportNudgeService extends DefaultSingleNudgeService {
this.getNudgeStatus$(nudgeType, userId),
this.cipherService.cipherViews$(userId),
this.organizationService.organizations$(userId),
this.collectionService.decryptedCollections$,
this.collectionService.decryptedCollections$(userId),
]).pipe(
switchMap(([nudgeStatus, ciphers, orgs, collections]) => {
const vaultHasMoreThanOneItem = (ciphers?.length ?? 0) > 1;
@@ -46,7 +46,7 @@ export class VaultSettingsImportNudgeService extends DefaultSingleNudgeService {
const orgIds = new Set(orgs.map((org) => org.id));
const canCreateCollections = orgs.some((org) => org.canCreateNewCollections);
const hasManageCollections = collections.some(
(c) => c.manage && orgIds.has(c.organizationId),
(c) => c.manage && orgIds.has(c.organizationId!),
);
// When the user has dismissed the nudge or spotlight, return the nudge status directly

View File

@@ -23,6 +23,7 @@ import {
HasItemsNudgeService,
EmptyVaultNudgeService,
NewAccountNudgeService,
AccountSecurityNudgeService,
VaultSettingsImportNudgeService,
} from "./custom-nudges-services";
import { DefaultSingleNudgeService } from "./default-single-nudge.service";
@@ -37,7 +38,11 @@ describe("Vault Nudges Service", () => {
getFeatureFlag: jest.fn().mockReturnValue(true),
};
const nudgeServices = [EmptyVaultNudgeService, NewAccountNudgeService];
const nudgeServices = [
EmptyVaultNudgeService,
NewAccountNudgeService,
AccountSecurityNudgeService,
];
beforeEach(async () => {
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
@@ -68,6 +73,10 @@ describe("Vault Nudges Service", () => {
provide: EmptyVaultNudgeService,
useValue: mock<EmptyVaultNudgeService>(),
},
{
provide: AccountSecurityNudgeService,
useValue: mock<AccountSecurityNudgeService>(),
},
{
provide: VaultSettingsImportNudgeService,
useValue: mock<VaultSettingsImportNudgeService>(),

View File

@@ -7,6 +7,9 @@ import { firstValueFrom, Observable } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
@@ -29,6 +32,8 @@ export class VaultFilterComponent implements OnInit {
@Output() onAddFolder = new EventEmitter<never>();
@Output() onEditFolder = new EventEmitter<FolderView>();
private activeUserId: UserId;
isLoaded = false;
collapsedFilterNodes: Set<string>;
organizations: Organization[];
@@ -37,14 +42,20 @@ export class VaultFilterComponent implements OnInit {
collections: DynamicTreeNode<CollectionView>;
folders$: Observable<DynamicTreeNode<FolderView>>;
constructor(protected vaultFilterService: DeprecatedVaultFilterService) {}
constructor(
protected vaultFilterService: DeprecatedVaultFilterService,
protected accountService: AccountService,
) {}
get displayCollections() {
return this.collections?.fullList != null && this.collections.fullList.length > 0;
}
async ngOnInit(): Promise<void> {
this.collapsedFilterNodes = await this.vaultFilterService.buildCollapsedFilterNodes();
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.collapsedFilterNodes = await this.vaultFilterService.buildCollapsedFilterNodes(
this.activeUserId,
);
this.organizations = await this.vaultFilterService.buildOrganizations();
if (this.organizations != null && this.organizations.length > 0) {
this.activeOrganizationDataOwnershipPolicy =
@@ -68,7 +79,10 @@ export class VaultFilterComponent implements OnInit {
} else {
this.collapsedFilterNodes.add(node.id);
}
await this.vaultFilterService.storeCollapsedFilterNodes(this.collapsedFilterNodes);
await this.vaultFilterService.storeCollapsedFilterNodes(
this.collapsedFilterNodes,
this.activeUserId,
);
}
async applyFilter(filter: VaultFilter) {

View File

@@ -1,11 +1,14 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { CipherStatus } from "./cipher-status.model";
export type VaultFilterFunction = (cipher: CipherView) => boolean;
export type VaultFilterFunction = (cipher: CipherViewLike) => boolean;
export class VaultFilter {
cipherType?: CipherType;
@@ -44,10 +47,10 @@ export class VaultFilter {
cipherPassesFilter = cipher.favorite;
}
if (this.status === "trash" && cipherPassesFilter) {
cipherPassesFilter = cipher.isDeleted;
cipherPassesFilter = CipherViewLikeUtils.isDeleted(cipher);
}
if (this.cipherType != null && cipherPassesFilter) {
cipherPassesFilter = cipher.type === this.cipherType;
cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType;
}
if (this.selectedFolder && this.selectedFolderId == null && cipherPassesFilter) {
cipherPassesFilter = cipher.folderId == null;
@@ -68,7 +71,7 @@ export class VaultFilter {
cipherPassesFilter = cipher.organizationId === this.selectedOrganizationId;
}
if (this.myVaultOnly && cipherPassesFilter) {
cipherPassesFilter = cipher.organizationId === null;
cipherPassesFilter = cipher.organizationId == null;
}
return cipherPassesFilter;
};

View File

@@ -1,18 +1,23 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { firstValueFrom, from, map, mergeMap, Observable, switchMap, take } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import {
CollectionService,
CollectionTypes,
CollectionView,
} from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ActiveUserState, StateProvider } from "@bitwarden/common/platform/state";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
@@ -28,10 +33,9 @@ const NestingDelimiter = "/";
@Injectable()
export class VaultFilterService implements DeprecatedVaultFilterServiceAbstraction {
private collapsedGroupingsState: ActiveUserState<string[]> =
this.stateProvider.getActive(COLLAPSED_GROUPINGS);
private readonly collapsedGroupings$: Observable<Set<string>> =
this.collapsedGroupingsState.state$.pipe(map((c) => new Set(c)));
private collapsedGroupingsState(userId: UserId): SingleUserState<string[]> {
return this.stateProvider.getUser(userId, COLLAPSED_GROUPINGS);
}
constructor(
protected organizationService: OrganizationService,
@@ -41,14 +45,21 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
protected policyService: PolicyService,
protected stateProvider: StateProvider,
protected accountService: AccountService,
protected configService: ConfigService,
protected i18nService: I18nService,
) {}
async storeCollapsedFilterNodes(collapsedFilterNodes: Set<string>): Promise<void> {
await this.collapsedGroupingsState.update(() => Array.from(collapsedFilterNodes));
async storeCollapsedFilterNodes(
collapsedFilterNodes: Set<string>,
userId: UserId,
): Promise<void> {
await this.collapsedGroupingsState(userId).update(() => Array.from(collapsedFilterNodes));
}
async buildCollapsedFilterNodes(): Promise<Set<string>> {
return await firstValueFrom(this.collapsedGroupings$);
async buildCollapsedFilterNodes(userId: UserId): Promise<Set<string>> {
return await firstValueFrom(
this.collapsedGroupingsState(userId).state$.pipe(map((c) => new Set(c))),
);
}
async buildOrganizations(): Promise<Organization[]> {
@@ -98,13 +109,26 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
}
async buildCollections(organizationId?: string): Promise<DynamicTreeNode<CollectionView>> {
const storedCollections = await this.collectionService.getAllDecrypted();
let collections: CollectionView[];
if (organizationId != null) {
collections = storedCollections.filter((c) => c.organizationId === organizationId);
} else {
collections = storedCollections;
const storedCollections = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.collectionService.decryptedCollections$(userId)),
),
);
const orgs = await this.buildOrganizations();
const defaulCollectionsFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.CreateDefaultLocation,
);
let collections =
organizationId == null
? storedCollections
: storedCollections.filter((c) => c.organizationId === organizationId);
if (defaulCollectionsFlagEnabled) {
collections = sortDefaultCollections(collections, orgs, this.i18nService.collator);
}
const nestedCollections = await this.collectionService.getAllNested(collections);
return new DynamicTreeNode<CollectionView>({
fullList: collections,
@@ -141,7 +165,7 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
folderCopy.id = f.id;
folderCopy.revisionDate = f.revisionDate;
const parts = f.name != null ? f.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, null, NestingDelimiter);
ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, undefined, NestingDelimiter);
});
return nodes;
}
@@ -154,3 +178,31 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
return ServiceUtils.getTreeNodeObjectFromList(folders, id) as TreeNode<FolderView>;
}
}
/**
* Sorts collections with default user collections at the top, sorted by organization name.
* Remaining collections are sorted by name.
* @param collections - The list of collections to sort.
* @param orgs - The list of organizations to use for sorting default user collections.
* @returns Sorted list of collections.
*/
export function sortDefaultCollections(
collections: CollectionView[],
orgs: Organization[] = [],
collator: Intl.Collator,
): CollectionView[] {
const sortedDefaultCollectionTypes = collections
.filter((c) => c.type === CollectionTypes.DefaultUserCollection)
.sort((a, b) => {
const aName = orgs.find((o) => o.id === a.organizationId)?.name ?? a.organizationId;
const bName = orgs.find((o) => o.id === b.organizationId)?.name ?? b.organizationId;
if (!aName || !bName) {
throw new Error("Collection does not have an organizationId.");
}
return collator.compare(aName, bName);
});
return [
...sortedDefaultCollectionTypes,
...collections.filter((c) => c.type !== CollectionTypes.DefaultUserCollection),
];
}

View File

@@ -1,114 +0,0 @@
import { Component, Input, OnInit } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { ToastService } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import {
InputPasswordComponent,
InputPasswordFlow,
} from "../input-password/input-password.component";
import { PasswordInputResult } from "../input-password/password-input-result";
import { ChangePasswordService } from "./change-password.service.abstraction";
@Component({
selector: "auth-change-password",
templateUrl: "change-password.component.html",
imports: [InputPasswordComponent, I18nPipe],
})
export class ChangePasswordComponent implements OnInit {
@Input() inputPasswordFlow: InputPasswordFlow = InputPasswordFlow.ChangePassword;
activeAccount: Account | null = null;
email?: string;
userId?: UserId;
masterPasswordPolicyOptions?: MasterPasswordPolicyOptions;
initializing = true;
submitting = false;
constructor(
private accountService: AccountService,
private changePasswordService: ChangePasswordService,
private i18nService: I18nService,
private messagingService: MessagingService,
private policyService: PolicyService,
private toastService: ToastService,
private syncService: SyncService,
) {}
async ngOnInit() {
this.activeAccount = await firstValueFrom(this.accountService.activeAccount$);
this.userId = this.activeAccount?.id;
this.email = this.activeAccount?.email;
if (!this.userId) {
throw new Error("userId not found");
}
this.masterPasswordPolicyOptions = await firstValueFrom(
this.policyService.masterPasswordPolicyOptions$(this.userId),
);
this.initializing = false;
}
async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.submitting = true;
try {
if (passwordInputResult.rotateUserKey) {
if (this.activeAccount == null) {
throw new Error("activeAccount not found");
}
if (
passwordInputResult.currentPassword == null ||
passwordInputResult.newPasswordHint == null
) {
throw new Error("currentPassword or newPasswordHint not found");
}
await this.syncService.fullSync(true);
await this.changePasswordService.rotateUserKeyMasterPasswordAndEncryptedData(
passwordInputResult.currentPassword,
passwordInputResult.newPassword,
this.activeAccount,
passwordInputResult.newPasswordHint,
);
} else {
if (!this.userId) {
throw new Error("userId not found");
}
await this.changePasswordService.changePassword(passwordInputResult, this.userId);
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("masterPasswordChanged"),
message: this.i18nService.t("masterPasswordChangedDesc"),
});
this.messagingService.send("logout");
}
} catch {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("errorOccurred"),
});
} finally {
this.submitting = false;
}
}
}

View File

@@ -1,10 +1,6 @@
/**
* This barrel file should only contain Angular exports
*/
// change password
export * from "./change-password/change-password.component";
export * from "./change-password/change-password.service.abstraction";
export * from "./change-password/default-change-password.service";
// fingerprint dialog
export * from "./fingerprint-dialog/fingerprint-dialog.component";
@@ -45,11 +41,6 @@ export * from "./registration/registration-env-selector/registration-env-selecto
export * from "./registration/registration-finish/registration-finish.service";
export * from "./registration/registration-finish/default-registration-finish.service";
// set password (JIT user)
export * from "./set-password-jit/set-password-jit.component";
export * from "./set-password-jit/set-password-jit.service.abstraction";
export * from "./set-password-jit/default-set-password-jit.service";
// user verification
export * from "./user-verification/user-verification-dialog.component";
export * from "./user-verification/user-verification-dialog.types";

View File

@@ -32,7 +32,7 @@
</bit-form-field>
<div class="tw-mb-6">
<bit-form-field>
<bit-form-field [disableMargin]="true">
<bit-label>{{ "newMasterPass" | i18n }}</bit-label>
<input
id="input-password-form_new-password"

View File

@@ -129,7 +129,7 @@ export class InputPasswordComponent implements OnInit {
@Input({ transform: (val: string) => val?.trim().toLowerCase() }) email?: string;
@Input() userId?: UserId;
@Input() loading = false;
@Input() masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null;
@Input() masterPasswordPolicyOptions?: MasterPasswordPolicyOptions;
@Input() inlineButtons = false;
@Input() primaryButtonText?: Translation;
@@ -169,7 +169,7 @@ export class InputPasswordComponent implements OnInit {
protected get minPasswordLengthMsg() {
if (
this.masterPasswordPolicyOptions != null &&
this.masterPasswordPolicyOptions != undefined &&
this.masterPasswordPolicyOptions.minLength > 0
) {
return this.i18nService.t("characterMinimum", this.masterPasswordPolicyOptions.minLength);
@@ -463,7 +463,7 @@ export class InputPasswordComponent implements OnInit {
/**
* Returns `true` if the current password is correct (it can be used to successfully decrypt
* the masterKeyEncrypedUserKey), `false` otherwise
* the masterKeyEncryptedUserKey), `false` otherwise
*/
private async verifyCurrentPassword(
currentPassword: string,

View File

@@ -249,7 +249,7 @@ export class LoginDecryptionOptionsComponent implements OnInit {
}
try {
const { publicKey, privateKey } = await this.keyService.initAccount();
const { publicKey, privateKey } = await this.keyService.initAccount(this.activeAccountId);
const keysRequest = new KeysRequest(publicKey, privateKey.encryptedString);
await this.apiService.postAccountKeys(keysRequest);

View File

@@ -1,11 +1,22 @@
# Authentication Flows Documentation
# Login via Auth Request Documentation
<br>
**Table of Contents**
> - [Standard Auth Request Flows](#standard-auth-request-flows)
> - [Admin Auth Request Flow](#admin-auth-request-flow)
> - [Summary Table](#summary-table)
> - [State Management](#state-management)
<br>
## Standard Auth Request Flows
### Flow 1: Unauthed user requests approval from device; Approving device has a masterKey in memory
1. Unauthed user clicks "Login with device"
2. Navigates to /login-with-device which creates a StandardAuthRequest
2. Navigates to `/login-with-device` which creates a `StandardAuthRequest`
3. Receives approval from a device with authRequestPublicKey(masterKey)
4. Decrypts masterKey
5. Decrypts userKey
@@ -14,7 +25,7 @@
### Flow 2: Unauthed user requests approval from device; Approving device does NOT have a masterKey in memory
1. Unauthed user clicks "Login with device"
2. Navigates to /login-with-device which creates a StandardAuthRequest
2. Navigates to `/login-with-device` which creates a `StandardAuthRequest`
3. Receives approval from a device with authRequestPublicKey(userKey)
4. Decrypts userKey
5. Proceeds to vault
@@ -34,9 +45,9 @@ get into this flow:
### Flow 3: Authed SSO TD user requests approval from device; Approving device has a masterKey in memory
1. SSO TD user authenticates via SSO
2. Navigates to /login-initiated
2. Navigates to `/login-initiated`
3. Clicks "Approve from your other device"
4. Navigates to /login-with-device which creates a StandardAuthRequest
4. Navigates to `/login-with-device` which creates a `StandardAuthRequest`
5. Receives approval from device with authRequestPublicKey(masterKey)
6. Decrypts masterKey
7. Decrypts userKey
@@ -46,22 +57,24 @@ get into this flow:
### Flow 4: Authed SSO TD user requests approval from device; Approving device does NOT have a masterKey in memory
1. SSO TD user authenticates via SSO
2. Navigates to /login-initiated
2. Navigates to `/login-initiated`
3. Clicks "Approve from your other device"
4. Navigates to /login-with-device which creates a StandardAuthRequest
4. Navigates to `/login-with-device` which creates a `StandardAuthRequest`
5. Receives approval from device with authRequestPublicKey(userKey)
6. Decrypts userKey
7. Establishes trust (if required)
8. Proceeds to vault
<br>
## Admin Auth Request Flow
### Flow: Authed SSO TD user requests admin approval
1. SSO TD user authenticates via SSO
2. Navigates to /login-initiated
2. Navigates to `/login-initiated`
3. Clicks "Request admin approval"
4. Navigates to /admin-approval-requested which creates an AdminAuthRequest
4. Navigates to `/admin-approval-requested` which creates an `AdminAuthRequest`
5. Receives approval from device with authRequestPublicKey(userKey)
6. Decrypts userKey
7. Establishes trust (if required)
@@ -70,21 +83,25 @@ get into this flow:
**Note:** TDE users are required to be enrolled in admin account recovery, which gives the admin access to the user's
userKey. This is how admins are able to send over the authRequestPublicKey(userKey) to the user to allow them to unlock.
<br>
## Summary Table
| Flow | Auth Status | Clicks Button [active route] | Navigates to | Approving device has masterKey in memory\* |
| --------------- | ----------- | --------------------------------------------------- | ------------------------- | ------------------------------------------------- |
| Standard Flow 1 | unauthed | "Login with device" [/login] | /login-with-device | yes |
| Standard Flow 2 | unauthed | "Login with device" [/login] | /login-with-device | no |
| Standard Flow 3 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | yes |
| Standard Flow 4 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | no |
| Admin Flow | authed | "Request admin approval" [/login-initiated] | /admin-approval-requested | NA - admin requests always send encrypted userKey |
| Flow | Auth Status | Clicks Button [active route] | Navigates to | Approving device has masterKey in memory\* |
| --------------- | ----------- | ----------------------------------------------------- | --------------------------- | ------------------------------------------------- |
| Standard Flow 1 | unauthed | "Login with device" [`/login`] | `/login-with-device` | yes |
| Standard Flow 2 | unauthed | "Login with device" [`/login`] | `/login-with-device` | no |
| Standard Flow 3 | authed | "Approve from your other device" [`/login-initiated`] | `/login-with-device` | yes |
| Standard Flow 4 | authed | "Approve from your other device" [`/login-initiated`] | `/login-with-device` | no |
| Admin Flow | authed | "Request admin approval"<br>[`/login-initiated`] | `/admin-approval-requested` | NA - admin requests always send encrypted userKey |
**Note:** The phrase "in memory" here is important. It is possible for a user to have a master password for their
account, but not have a masterKey IN MEMORY for a specific device. For example, if a user registers an account with a
master password, then joins an SSO TD org, then logs in to a device via SSO and admin auth request, they are now logged
into that device but that device does not have masterKey IN MEMORY.
<br>
## State Management
### View Cache
@@ -102,6 +119,8 @@ The cache is used to:
2. Allow resumption of pending auth requests
3. Enable processing of approved requests after extension close and reopen.
<br>
### Component State Variables
Key state variables maintained during the authentication process:
@@ -149,6 +168,8 @@ protected flow = Flow.StandardAuthRequest
- Affects UI rendering and request handling
- Set based on route and authentication state
<br>
### State Flow Examples
#### Standard Auth Request Cache Flow
@@ -186,6 +207,8 @@ protected flow = Flow.StandardAuthRequest
- Either resumes monitoring or starts new request
- Clears state after successful approval
<br>
### State Cleanup
State cleanup occurs in several scenarios:

View File

@@ -18,9 +18,11 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -122,6 +124,8 @@ export class LoginComponent implements OnInit, OnDestroy {
private logService: LogService,
private validationService: ValidationService,
private loginSuccessHandlerService: LoginSuccessHandlerService,
private masterPasswordService: MasterPasswordServiceAbstraction,
private configService: ConfigService,
) {
this.clientType = this.platformUtilsService.getClientType();
}
@@ -225,7 +229,21 @@ export class LoginComponent implements OnInit, OnDestroy {
return;
}
const credentials = new PasswordLoginCredentials(email, masterPassword);
// Try to retrieve any org policies from an org invite now so we can send it to the
// login strategies. Since it is optional and we only want to be doing this on the
// web we will only send in content in the right context.
const orgPoliciesFromInvite = this.loginComponentService.getOrgPoliciesFromOrgInvite
? await this.loginComponentService.getOrgPoliciesFromOrgInvite()
: null;
const orgMasterPasswordPolicyOptions = orgPoliciesFromInvite?.enforcedPasswordPolicyOptions;
const credentials = new PasswordLoginCredentials(
email,
masterPassword,
undefined,
orgMasterPasswordPolicyOptions,
);
try {
const authResult = await this.loginStrategyService.logIn(credentials);
@@ -284,7 +302,7 @@ export class LoginComponent implements OnInit, OnDestroy {
This is now unsupported and requires a downgraded client */
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccured"),
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("legacyEncryptionUnsupported"),
});
return;
@@ -305,7 +323,7 @@ export class LoginComponent implements OnInit, OnDestroy {
await this.loginSuccessHandlerService.run(authResult.userId);
// Determine where to send the user next
// The AuthGuard will handle routing to update-temp-password based on state
// The AuthGuard will handle routing to change-password based on state
// TODO: PM-18269 - evaluate if we can combine this with the
// password evaluation done in the password login strategy.
@@ -317,7 +335,7 @@ export class LoginComponent implements OnInit, OnDestroy {
if (orgPolicies) {
// Since we have retrieved the policies, we can go ahead and set them into state for future use
// e.g., the update-password page currently only references state for policy data and
// e.g., the change-password page currently only references state for policy data and
// doesn't fallback to pulling them from the server like it should if they are null.
await this.setPoliciesIntoState(authResult.userId, orgPolicies.policies);
@@ -325,7 +343,7 @@ export class LoginComponent implements OnInit, OnDestroy {
orgPolicies.enforcedPasswordPolicyOptions,
);
if (isPasswordChangeRequired) {
await this.router.navigate(["update-password"]);
await this.router.navigate(["change-password"]);
return;
}
}
@@ -337,9 +355,15 @@ export class LoginComponent implements OnInit, OnDestroy {
await this.router.navigate(["vault"]);
}
}
/**
* Checks if the master password meets the enforced policy requirements
* and if the user is required to change their password.
*
* TODO: This is duplicate checking that we want to only do in the password login strategy.
* Once we no longer need the policies state being set to reference later in change password
* via using the Admin Console's new policy endpoint changes we can remove this. Consult
* PM-23001 for details.
*/
private async isPasswordChangeRequiredByOrgPolicy(
enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions,

View File

@@ -2,11 +2,16 @@ import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { firstValueFrom, Subject, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { LoginSuccessHandlerService } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
@@ -61,6 +66,9 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
private logService: LogService,
private i18nService: I18nService,
private loginSuccessHandlerService: LoginSuccessHandlerService,
private configService: ConfigService,
private accountService: AccountService,
private masterPasswordService: MasterPasswordServiceAbstraction,
) {}
async ngOnInit() {
@@ -141,8 +149,21 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.loginSuccessHandlerService.run(authResult.userId);
// If verification succeeds, navigate to vault
await this.router.navigate(["/vault"]);
// TODO: PM-22663 use the new service to handle routing.
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const forceSetPasswordReason = await firstValueFrom(
this.masterPasswordService.forceSetPasswordReason$(activeUserId),
);
if (
forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword ||
forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset
) {
await this.router.navigate(["/change-password"]);
} else {
await this.router.navigate(["/vault"]);
}
} catch (e) {
this.logService.error(e);
let errorMessage =

View File

@@ -1,7 +1,7 @@
import { MockProxy, mock } from "jest-mock-extended";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";

View File

@@ -3,8 +3,11 @@
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { RegisterFinishRequest } from "@bitwarden/common/auth/models/request/registration/register-finish.request";
import {
EncryptedString,
EncString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { KeyService } from "@bitwarden/key-management";
import { PasswordInputResult } from "../../input-password/password-input-result";

View File

@@ -10,7 +10,9 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { RegisterVerificationEmailClickedRequest } from "@bitwarden/common/auth/models/request/registration/register-verification-email-clicked.request";
import { HttpStatusCode } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
@@ -77,6 +79,7 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
private logService: LogService,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private loginSuccessHandlerService: LoginSuccessHandlerService,
private configService: ConfigService,
) {}
async ngOnInit() {
@@ -186,11 +189,18 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
return;
}
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("youHaveBeenLoggedIn"),
});
const endUserActivationFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM19315EndUserActivationMvp,
);
if (!endUserActivationFlagEnabled) {
// Only show the toast when the end user activation feature flag is _not_ enabled
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("youHaveBeenLoggedIn"),
});
}
await this.loginSuccessHandlerService.run(authenticationResult.userId);

View File

@@ -1,245 +0,0 @@
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import {
FakeUserDecryptionOptions as UserDecryptionOptions,
InternalUserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models/response/organization-keys.response";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
import { PasswordInputResult } from "../input-password/password-input-result";
import { DefaultSetPasswordJitService } from "./default-set-password-jit.service";
import { SetPasswordCredentials } from "./set-password-jit.service.abstraction";
describe("DefaultSetPasswordJitService", () => {
let sut: DefaultSetPasswordJitService;
let apiService: MockProxy<ApiService>;
let masterPasswordApiService: MockProxy<MasterPasswordApiService>;
let keyService: MockProxy<KeyService>;
let encryptService: MockProxy<EncryptService>;
let i18nService: MockProxy<I18nService>;
let kdfConfigService: MockProxy<KdfConfigService>;
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
beforeEach(() => {
apiService = mock<ApiService>();
masterPasswordApiService = mock<MasterPasswordApiService>();
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
i18nService = mock<I18nService>();
kdfConfigService = mock<KdfConfigService>();
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
organizationApiService = mock<OrganizationApiServiceAbstraction>();
organizationUserApiService = mock<OrganizationUserApiService>();
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
sut = new DefaultSetPasswordJitService(
apiService,
masterPasswordApiService,
keyService,
encryptService,
i18nService,
kdfConfigService,
masterPasswordService,
organizationApiService,
organizationUserApiService,
userDecryptionOptionsService,
);
});
it("should instantiate the DefaultSetPasswordJitService", () => {
expect(sut).not.toBeFalsy();
});
describe("setPassword", () => {
let masterKey: MasterKey;
let userKey: UserKey;
let userKeyEncString: EncString;
let protectedUserKey: [UserKey, EncString];
let keyPair: [string, EncString];
let keysRequest: KeysRequest;
let organizationKeys: OrganizationKeysResponse;
let orgPublicKey: Uint8Array;
let orgSsoIdentifier: string;
let orgId: string;
let resetPasswordAutoEnroll: boolean;
let userId: UserId;
let passwordInputResult: PasswordInputResult;
let credentials: SetPasswordCredentials;
let userDecryptionOptionsSubject: BehaviorSubject<UserDecryptionOptions>;
let setPasswordRequest: SetPasswordRequest;
beforeEach(() => {
masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey;
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
userKeyEncString = new EncString("userKeyEncrypted");
protectedUserKey = [userKey, userKeyEncString];
keyPair = ["publicKey", new EncString("privateKey")];
keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString);
organizationKeys = {
privateKey: "orgPrivateKey",
publicKey: "orgPublicKey",
} as OrganizationKeysResponse;
orgPublicKey = Utils.fromB64ToArray(organizationKeys.publicKey);
orgSsoIdentifier = "orgSsoIdentifier";
orgId = "orgId";
resetPasswordAutoEnroll = false;
userId = "userId" as UserId;
passwordInputResult = {
newMasterKey: masterKey,
newServerMasterKeyHash: "newServerMasterKeyHash",
newLocalMasterKeyHash: "newLocalMasterKeyHash",
newPasswordHint: "newPasswordHint",
kdfConfig: DEFAULT_KDF_CONFIG,
newPassword: "newPassword",
};
credentials = {
newMasterKey: passwordInputResult.newMasterKey,
newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash,
newLocalMasterKeyHash: passwordInputResult.newLocalMasterKeyHash,
newPasswordHint: passwordInputResult.newPasswordHint,
kdfConfig: passwordInputResult.kdfConfig,
orgSsoIdentifier,
orgId,
resetPasswordAutoEnroll,
userId,
};
userDecryptionOptionsSubject = new BehaviorSubject(null);
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
setPasswordRequest = new SetPasswordRequest(
passwordInputResult.newServerMasterKeyHash,
protectedUserKey[1].encryptedString,
passwordInputResult.newPasswordHint,
orgSsoIdentifier,
keysRequest,
passwordInputResult.kdfConfig.kdfType,
passwordInputResult.kdfConfig.iterations,
);
});
function setupSetPasswordMocks(hasUserKey = true) {
if (!hasUserKey) {
keyService.userKey$.mockReturnValue(of(null));
keyService.makeUserKey.mockResolvedValue(protectedUserKey);
} else {
keyService.userKey$.mockReturnValue(of(userKey));
keyService.encryptUserKeyWithMasterKey.mockResolvedValue(protectedUserKey);
}
keyService.makeKeyPair.mockResolvedValue(keyPair);
masterPasswordApiService.setPassword.mockResolvedValue(undefined);
masterPasswordService.setForceSetPasswordReason.mockResolvedValue(undefined);
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
userDecryptionOptionsService.setUserDecryptionOptions.mockResolvedValue(undefined);
kdfConfigService.setKdfConfig.mockResolvedValue(undefined);
keyService.setUserKey.mockResolvedValue(undefined);
keyService.setPrivateKey.mockResolvedValue(undefined);
masterPasswordService.setMasterKeyHash.mockResolvedValue(undefined);
}
function setupResetPasswordAutoEnrollMocks(organizationKeysExist = true) {
if (organizationKeysExist) {
organizationApiService.getKeys.mockResolvedValue(organizationKeys);
} else {
organizationApiService.getKeys.mockResolvedValue(null);
return;
}
keyService.userKey$.mockReturnValue(of(userKey));
encryptService.encapsulateKeyUnsigned.mockResolvedValue(userKeyEncString);
organizationUserApiService.putOrganizationUserResetPasswordEnrollment.mockResolvedValue(
undefined,
);
}
it("should set password successfully (given a user key)", async () => {
// Arrange
setupSetPasswordMocks();
// Act
await sut.setPassword(credentials);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
});
it("should set password successfully (given no user key)", async () => {
// Arrange
setupSetPasswordMocks(false);
// Act
await sut.setPassword(credentials);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
});
it("should handle reset password auto enroll", async () => {
// Arrange
credentials.resetPasswordAutoEnroll = true;
setupSetPasswordMocks();
setupResetPasswordAutoEnrollMocks();
// Act
await sut.setPassword(credentials);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(organizationApiService.getKeys).toHaveBeenCalledWith(orgId);
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(userKey, orgPublicKey);
expect(
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
).toHaveBeenCalled();
});
it("when handling reset password auto enroll, it should throw an error if organization keys are not found", async () => {
// Arrange
credentials.resetPasswordAutoEnroll = true;
setupSetPasswordMocks();
setupResetPasswordAutoEnrollMocks(false);
// Act and Assert
await expect(sut.setPassword(credentials)).rejects.toThrow();
expect(
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,178 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
OrganizationUserApiService,
OrganizationUserResetPasswordEnrollmentRequest,
} from "@bitwarden/admin-console/common";
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { KdfConfigService, KeyService, KdfConfig } from "@bitwarden/key-management";
import {
SetPasswordCredentials,
SetPasswordJitService,
} from "./set-password-jit.service.abstraction";
export class DefaultSetPasswordJitService implements SetPasswordJitService {
constructor(
protected apiService: ApiService,
protected masterPasswordApiService: MasterPasswordApiService,
protected keyService: KeyService,
protected encryptService: EncryptService,
protected i18nService: I18nService,
protected kdfConfigService: KdfConfigService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected organizationApiService: OrganizationApiServiceAbstraction,
protected organizationUserApiService: OrganizationUserApiService,
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
) {}
async setPassword(credentials: SetPasswordCredentials): Promise<void> {
const {
newMasterKey,
newServerMasterKeyHash,
newLocalMasterKeyHash,
newPasswordHint,
kdfConfig,
orgSsoIdentifier,
orgId,
resetPasswordAutoEnroll,
userId,
} = credentials;
for (const [key, value] of Object.entries(credentials)) {
if (value == null) {
throw new Error(`${key} not found. Could not set password.`);
}
}
const protectedUserKey = await this.makeProtectedUserKey(newMasterKey, userId);
if (protectedUserKey == null) {
throw new Error("protectedUserKey not found. Could not set password.");
}
// Since this is an existing JIT provisioned user in a MP encryption org setting first password,
// they will not already have a user asymmetric key pair so we must create it for them.
const [keyPair, keysRequest] = await this.makeKeyPairAndRequest(protectedUserKey);
const request = new SetPasswordRequest(
newServerMasterKeyHash,
protectedUserKey[1].encryptedString,
newPasswordHint,
orgSsoIdentifier,
keysRequest,
kdfConfig.kdfType,
kdfConfig.iterations,
);
await this.masterPasswordApiService.setPassword(request);
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
// User now has a password so update account decryption options in state
await this.updateAccountDecryptionProperties(newMasterKey, kdfConfig, protectedUserKey, userId);
await this.keyService.setPrivateKey(keyPair[1].encryptedString, userId);
await this.masterPasswordService.setMasterKeyHash(newLocalMasterKeyHash, userId);
if (resetPasswordAutoEnroll) {
await this.handleResetPasswordAutoEnroll(newServerMasterKeyHash, orgId, userId);
}
}
private async makeProtectedUserKey(
masterKey: MasterKey,
userId: UserId,
): Promise<[UserKey, EncString]> {
let protectedUserKey: [UserKey, EncString] = null;
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (userKey == null) {
protectedUserKey = await this.keyService.makeUserKey(masterKey);
} else {
protectedUserKey = await this.keyService.encryptUserKeyWithMasterKey(masterKey);
}
return protectedUserKey;
}
private async makeKeyPairAndRequest(
protectedUserKey: [UserKey, EncString],
): Promise<[[string, EncString], KeysRequest]> {
const keyPair = await this.keyService.makeKeyPair(protectedUserKey[0]);
if (keyPair == null) {
throw new Error("keyPair not found. Could not set password.");
}
const keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString);
return [keyPair, keysRequest];
}
private async updateAccountDecryptionProperties(
masterKey: MasterKey,
kdfConfig: KdfConfig,
protectedUserKey: [UserKey, EncString],
userId: UserId,
) {
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
);
userDecryptionOpts.hasMasterPassword = true;
await this.userDecryptionOptionsService.setUserDecryptionOptions(userDecryptionOpts);
await this.kdfConfigService.setKdfConfig(userId, kdfConfig);
await this.masterPasswordService.setMasterKey(masterKey, userId);
await this.keyService.setUserKey(protectedUserKey[0], userId);
}
private async handleResetPasswordAutoEnroll(
masterKeyHash: string,
orgId: string,
userId: UserId,
) {
const organizationKeys = await this.organizationApiService.getKeys(orgId);
if (organizationKeys == null) {
throw new Error(this.i18nService.t("resetPasswordOrgKeysError"));
}
const publicKey = Utils.fromB64ToArray(organizationKeys.publicKey);
// RSA Encrypt user key with organization public key
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (userKey == null) {
throw new Error("userKey not found. Could not handle reset password auto enroll.");
}
const encryptedUserKey = await this.encryptService.encapsulateKeyUnsigned(userKey, publicKey);
const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest();
resetRequest.masterPasswordHash = masterKeyHash;
resetRequest.resetPasswordKey = encryptedUserKey.encryptedString;
await this.organizationUserApiService.putOrganizationUserResetPasswordEnrollment(
orgId,
userId,
resetRequest,
);
}
}

View File

@@ -1,24 +0,0 @@
<ng-container *ngIf="syncLoading">
<i class="bwi bwi-spinner bwi-spin tw-mr-2" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
{{ "loading" | i18n }}
</ng-container>
<ng-container *ngIf="!syncLoading">
<app-callout
type="warning"
title="{{ 'resetPasswordPolicyAutoEnroll' | i18n }}"
*ngIf="resetPasswordAutoEnroll"
>
{{ "resetPasswordAutoEnrollInviteWarning" | i18n }}
</app-callout>
<auth-input-password
[flow]="inputPasswordFlow"
[email]="email"
[userId]="userId"
[loading]="submitting"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
[primaryButtonText]="{ key: 'createAccount' }"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
></auth-input-password>
</ng-container>

View File

@@ -1,135 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { UserId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { ToastService } from "../../../../components/src/toast";
import {
InputPasswordComponent,
InputPasswordFlow,
} from "../input-password/input-password.component";
import { PasswordInputResult } from "../input-password/password-input-result";
import {
SetPasswordCredentials,
SetPasswordJitService,
} from "./set-password-jit.service.abstraction";
@Component({
selector: "auth-set-password-jit",
templateUrl: "set-password-jit.component.html",
imports: [CommonModule, InputPasswordComponent, JslibModule],
})
export class SetPasswordJitComponent implements OnInit {
protected inputPasswordFlow = InputPasswordFlow.SetInitialPasswordAuthedUser;
protected email: string;
protected masterPasswordPolicyOptions: MasterPasswordPolicyOptions;
protected orgId: string;
protected orgSsoIdentifier: string;
protected resetPasswordAutoEnroll: boolean;
protected submitting = false;
protected syncLoading = true;
protected userId: UserId;
constructor(
private accountService: AccountService,
private activatedRoute: ActivatedRoute,
private i18nService: I18nService,
private organizationApiService: OrganizationApiServiceAbstraction,
private policyApiService: PolicyApiServiceAbstraction,
private router: Router,
private setPasswordJitService: SetPasswordJitService,
private syncService: SyncService,
private toastService: ToastService,
private validationService: ValidationService,
) {}
async ngOnInit() {
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
this.userId = activeAccount?.id;
this.email = activeAccount?.email;
await this.syncService.fullSync(true);
this.syncLoading = false;
await this.handleQueryParams();
}
private async handleQueryParams() {
const qParams = await firstValueFrom(this.activatedRoute.queryParams);
if (qParams.identifier != null) {
try {
this.orgSsoIdentifier = qParams.identifier;
const autoEnrollStatus = await this.organizationApiService.getAutoEnrollStatus(
this.orgSsoIdentifier,
);
this.orgId = autoEnrollStatus.id;
this.resetPasswordAutoEnroll = autoEnrollStatus.resetPasswordEnabled;
this.masterPasswordPolicyOptions =
await this.policyApiService.getMasterPasswordPolicyOptsForOrgUser(autoEnrollStatus.id);
} catch {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
}
}
}
protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.submitting = true;
const credentials: SetPasswordCredentials = {
newMasterKey: passwordInputResult.newMasterKey,
newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash,
newLocalMasterKeyHash: passwordInputResult.newLocalMasterKeyHash,
newPasswordHint: passwordInputResult.newPasswordHint,
kdfConfig: passwordInputResult.kdfConfig,
orgSsoIdentifier: this.orgSsoIdentifier,
orgId: this.orgId,
resetPasswordAutoEnroll: this.resetPasswordAutoEnroll,
userId: this.userId,
};
try {
await this.setPasswordJitService.setPassword(credentials);
} catch (e) {
this.validationService.showError(e);
this.submitting = false;
return;
}
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("accountSuccessfullyCreated"),
});
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("inviteAccepted"),
});
this.submitting = false;
await this.router.navigate(["vault"]);
}
}

View File

@@ -1,35 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey } from "@bitwarden/common/types/key";
import { KdfConfig } from "@bitwarden/key-management";
export interface SetPasswordCredentials {
newMasterKey: MasterKey;
newServerMasterKeyHash: string;
newLocalMasterKeyHash: string;
newPasswordHint: string;
kdfConfig: KdfConfig;
orgSsoIdentifier: string;
orgId: string;
resetPasswordAutoEnroll: boolean;
userId: UserId;
}
/**
* This service handles setting a password for a "just-in-time" provisioned user.
*
* A "just-in-time" (JIT) provisioned user is a user who does not have a registered account at the
* time they first click "Login with SSO". Once they click "Login with SSO" we register the account on
* the fly ("just-in-time").
*/
export abstract class SetPasswordJitService {
/**
* Sets the password for a JIT provisioned user.
*
* @param credentials An object of the credentials needed to set the password for a JIT provisioned user
* @throws If any property on the `credentials` object is null or undefined, or if a protectedUserKey
* or newKeyPair could not be created.
*/
setPassword: (credentials: SetPasswordCredentials) => Promise<void>;
}

View File

@@ -23,12 +23,10 @@ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response";
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -118,7 +116,6 @@ export class SsoComponent implements OnInit {
private toastService: ToastService,
private ssoComponentService: SsoComponentService,
private loginSuccessHandlerService: LoginSuccessHandlerService,
private configService: ConfigService,
) {
environmentService.environment$.pipe(takeUntilDestroyed()).subscribe((env) => {
this.redirectUri = env.getWebVaultUrl() + "/sso-connector.html";
@@ -534,11 +531,7 @@ export class SsoComponent implements OnInit {
}
private async handleChangePasswordRequired(orgIdentifier: string) {
const isSetInitialPasswordRefactorFlagOn = await this.configService.getFeatureFlag(
FeatureFlag.PM16117_SetInitialPasswordRefactor,
);
const route = isSetInitialPasswordRefactorFlagOn ? "set-initial-password" : "set-password-jit";
const route = "set-initial-password";
await this.router.navigate([route], {
queryParams: {
identifier: orgIdentifier,

View File

@@ -1,3 +1,2 @@
export * from "./two-factor-auth-email";
export * from "./two-factor-auth-duo";
export * from "./two-factor-auth-webauthn";

View File

@@ -1,6 +0,0 @@
import { TwoFactorAuthEmailComponentService } from "./two-factor-auth-email-component.service";
export class DefaultTwoFactorAuthEmailComponentService
implements TwoFactorAuthEmailComponentService {
// no default implementation
}

View File

@@ -1,2 +0,0 @@
export * from "./default-two-factor-auth-email-component.service";
export * from "./two-factor-auth-email-component.service";

View File

@@ -1,165 +0,0 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import {
TwoFactorAuthEmailComponentCache,
TwoFactorAuthEmailComponentCacheService,
} from "./two-factor-auth-email-component-cache.service";
describe("TwoFactorAuthEmailCache", () => {
describe("fromJSON", () => {
it("returns null when input is null", () => {
const result = TwoFactorAuthEmailComponentCache.fromJSON(null as any);
expect(result).toBeNull();
});
it("creates a TwoFactorAuthEmailCache instance from valid JSON", () => {
const jsonData = { emailSent: true };
const result = TwoFactorAuthEmailComponentCache.fromJSON(jsonData);
expect(result).not.toBeNull();
expect(result).toBeInstanceOf(TwoFactorAuthEmailComponentCache);
expect(result?.emailSent).toBe(true);
});
});
});
describe("TwoFactorAuthEmailComponentCacheService", () => {
let service: TwoFactorAuthEmailComponentCacheService;
let mockViewCacheService: MockProxy<ViewCacheService>;
let mockConfigService: MockProxy<ConfigService>;
let cacheData: BehaviorSubject<TwoFactorAuthEmailComponentCache | null>;
let mockSignal: any;
beforeEach(() => {
mockViewCacheService = mock<ViewCacheService>();
mockConfigService = mock<ConfigService>();
cacheData = new BehaviorSubject<TwoFactorAuthEmailComponentCache | null>(null);
mockSignal = jest.fn(() => cacheData.getValue());
mockSignal.set = jest.fn((value: TwoFactorAuthEmailComponentCache | null) =>
cacheData.next(value),
);
mockViewCacheService.signal.mockReturnValue(mockSignal);
TestBed.configureTestingModule({
providers: [
TwoFactorAuthEmailComponentCacheService,
{ provide: ViewCacheService, useValue: mockViewCacheService },
{ provide: ConfigService, useValue: mockConfigService },
],
});
service = TestBed.inject(TwoFactorAuthEmailComponentCacheService);
});
it("creates the service", () => {
expect(service).toBeTruthy();
});
describe("init", () => {
it("sets featureEnabled to true when flag is enabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
await service.init();
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
);
service.cacheData({ emailSent: true });
expect(mockSignal.set).toHaveBeenCalled();
});
it("sets featureEnabled to false when flag is disabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
await service.init();
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
);
service.cacheData({ emailSent: true });
expect(mockSignal.set).not.toHaveBeenCalled();
});
});
describe("cacheData", () => {
beforeEach(async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
await service.init();
});
it("caches email sent state when feature is enabled", () => {
service.cacheData({ emailSent: true });
expect(mockSignal.set).toHaveBeenCalledWith({
emailSent: true,
});
});
it("does not cache data when feature is disabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
await service.init();
service.cacheData({ emailSent: true });
expect(mockSignal.set).not.toHaveBeenCalled();
});
});
describe("clearCachedData", () => {
beforeEach(async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
await service.init();
});
it("clears cached data when feature is enabled", () => {
service.clearCachedData();
expect(mockSignal.set).toHaveBeenCalledWith(null);
});
it("does not clear cached data when feature is disabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
await service.init();
service.clearCachedData();
expect(mockSignal.set).not.toHaveBeenCalled();
});
});
describe("getCachedData", () => {
beforeEach(async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
await service.init();
});
it("returns cached data when feature is enabled", () => {
const testData = new TwoFactorAuthEmailComponentCache();
testData.emailSent = true;
cacheData.next(testData);
const result = service.getCachedData();
expect(result).toEqual(testData);
expect(mockSignal).toHaveBeenCalled();
});
it("returns null when feature is disabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
await service.init();
const result = service.getCachedData();
expect(result).toBeNull();
expect(mockSignal).not.toHaveBeenCalled();
});
});
});

Some files were not shown because too many files have changed in this diff Show More