1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-20 03:13:55 +00:00

merge main, fix conflicts

This commit is contained in:
jng
2025-08-14 12:37:39 -04:00
1353 changed files with 39599 additions and 25767 deletions

View File

@@ -1,18 +1,24 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable } from "rxjs";
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 collectionAdminViews$(
organizationId: string,
userId: UserId,
): Observable<CollectionAdminView[]>;
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

@@ -4,7 +4,10 @@ import { CollectionAccessSelectionView } from "./collection-access-selection.vie
import { CollectionAccessDetailsResponse } from "./collection.response";
import { CollectionView } from "./collection.view";
// TODO: this is used to represent the pseudo "Unassigned" collection as well as
// the user's personal vault (as a pseudo organization). This should be separated out into different values.
export const Unassigned = "unassigned";
export type Unassigned = typeof Unassigned;
export class CollectionAdminView extends CollectionView {
groups: CollectionAccessSelectionView[] = [];

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

@@ -54,7 +54,7 @@ describe("Collection", () => {
it("Decrypt", async () => {
const collection = new Collection();
collection.id = "id";
collection.id = "id" as CollectionId;
collection.organizationId = "orgId" as OrganizationId;
collection.name = mockEnc("encName");
collection.externalId = "extId";

View File

@@ -1,7 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import Domain from "@bitwarden/common/platform/models/domain/domain-base";
import Domain, { EncryptableKeys } from "@bitwarden/common/platform/models/domain/domain-base";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { CollectionData } from "./collection.data";
@@ -15,16 +14,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: CollectionId | undefined;
organizationId: OrganizationId | 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 +50,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

@@ -2,6 +2,7 @@ import { Jsonify } from "type-fest";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { View } from "@bitwarden/common/models/view/view";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
import { Collection, CollectionType, CollectionTypes } from "./collection";
@@ -10,9 +11,9 @@ import { CollectionAccessDetailsResponse } from "./collection.response";
export const NestingDelimiter = "/";
export class CollectionView implements View, ITreeNodeObject {
id: string | undefined;
organizationId: string | undefined;
name: string | undefined;
id: CollectionId | undefined;
organizationId: OrganizationId | undefined;
name: string = "";
externalId: string | undefined;
// readOnly applies to the items within a collection
readOnly: boolean = false;

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,14 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { combineLatest, firstValueFrom, from, map, Observable, of, switchMap } from "rxjs";
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/key-management/crypto/models/enc-string";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import { CollectionAdminService, CollectionService } from "../abstractions";
@@ -26,37 +31,23 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
private collectionService: CollectionService,
) {}
async getAll(organizationId: string): Promise<CollectionAdminView[]> {
const collectionResponse =
await this.apiService.getManyCollectionsWithAccessDetails(organizationId);
collectionAdminViews$(organizationId: string, userId: UserId): Observable<CollectionAdminView[]> {
return combineLatest([
this.keyService.orgKeys$(userId),
from(this.apiService.getManyCollectionsWithAccessDetails(organizationId)),
]).pipe(
switchMap(([orgKey, res]) => {
if (res?.data == null || res.data.length === 0) {
return of([]);
}
if (collectionResponse?.data == null || collectionResponse.data.length === 0) {
return [];
}
return await this.decryptMany(organizationId, collectionResponse.data);
}
async get(
organizationId: string,
collectionId: string,
): Promise<CollectionAdminView | undefined> {
const collectionResponse = await this.apiService.getCollectionAccessDetails(
organizationId,
collectionId,
return this.decryptMany(organizationId, res.data, orgKey);
}),
);
if (collectionResponse == null) {
return undefined;
}
const [view] = await this.decryptMany(organizationId, [collectionResponse]);
return view;
}
async save(collection: CollectionAdminView): Promise<CollectionDetailsResponse> {
const request = await this.encrypt(collection);
async save(collection: CollectionAdminView, userId: UserId): Promise<CollectionDetailsResponse> {
const request = await this.encrypt(collection, userId);
let response: CollectionDetailsResponse;
if (collection.id == null) {
@@ -71,9 +62,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;
@@ -110,13 +101,15 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
private async decryptMany(
organizationId: string,
collections: CollectionResponse[] | CollectionAccessDetailsResponse[],
orgKeys: Record<OrganizationId, OrgKey>,
): Promise<CollectionAdminView[]> {
const orgKey = await this.keyService.getOrgKey(organizationId);
const promises = collections.map(async (c) => {
const view = new CollectionAdminView();
view.id = c.id;
view.name = await this.encryptService.decryptString(new EncString(c.name), orgKey);
view.name = await this.encryptService.decryptString(
new EncString(c.name),
orgKeys[organizationId as OrganizationId],
);
view.externalId = c.externalId;
view.organizationId = c.organizationId;
@@ -136,11 +129,15 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
return await Promise.all(promises);
}
private async encrypt(model: CollectionAdminView): Promise<CollectionRequest> {
private async encrypt(model: CollectionAdminView, userId: UserId): Promise<CollectionRequest> {
if (model.organizationId == null) {
throw new Error("Collection has no organization id.");
}
const key = await this.keyService.getOrgKey(model.organizationId);
const key = await firstValueFrom(
this.keyService
.orgKeys$(userId)
.pipe(map((orgKeys) => orgKeys[model.organizationId] ?? null)),
);
if (key == null) {
throw new Error("No key for this collection's organization.");
}

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 { 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 { 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 { 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

@@ -1,3 +1,5 @@
import { EncString as SdkEncString } from "@bitwarden/sdk-internal";
type OrganizationUserBulkRequestEntry = {
id: string;
key: string;
@@ -5,8 +7,10 @@ type OrganizationUserBulkRequestEntry = {
export class OrganizationUserBulkConfirmRequest {
keys: OrganizationUserBulkRequestEntry[];
defaultUserCollectionName: SdkEncString | undefined;
constructor(keys: OrganizationUserBulkRequestEntry[]) {
constructor(keys: OrganizationUserBulkRequestEntry[], defaultUserCollectionName?: SdkEncString) {
this.keys = keys;
this.defaultUserCollectionName = defaultUserCollectionName;
}
}

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 { 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 { 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 { 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 accountService: AccountService,
protected dialogService: DialogService,
protected i18nService: I18nService,
protected kdfConfigService: KdfConfigService,
protected keyService: KeyService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected messagingService: MessagingService,
protected platformUtilsService: PlatformUtilsService,
protected policyService: PolicyService,
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,84 +0,0 @@
<ng-container
*ngIf="{
selectedRegion: selectedRegion$ | async,
} as data"
>
<div class="tw-text-sm tw-text-muted tw-leading-7 tw-font-normal tw-pl-4">
{{ "accessing" | i18n }}:
<button
type="button"
(click)="toggle(null)"
cdkOverlayOrigin
#trigger="cdkOverlayOrigin"
aria-haspopup="dialog"
aria-controls="cdk-overlay-container"
>
<span class="tw-text-primary-600 tw-text-sm tw-font-semibold">
<ng-container *ngIf="data.selectedRegion; else fallback">
{{ data.selectedRegion.domain }}
</ng-container>
<ng-template #fallback>
{{ "selfHostedServer" | i18n }}
</ng-template>
</span>
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
</button>
</div>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="isOpen"
[cdkConnectedOverlayPositions]="overlayPosition"
[cdkConnectedOverlayHasBackdrop]="true"
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
(backdropClick)="isOpen = false"
(detach)="close()"
>
<div class="tw-box-content">
<div
class="tw-bg-background tw-w-full tw-shadow-md tw-p-2 tw-rounded-md"
data-testid="environment-selector-dialog"
[@transformPanel]="'open'"
cdkTrapFocus
cdkTrapFocusAutoCapture
role="dialog"
aria-modal="true"
>
<ng-container *ngFor="let region of availableRegions; let i = index">
<button
type="button"
class="tw-text-main tw-w-full tw-text-left tw-py-0 tw-border tw-border-transparent tw-transition-all tw-duration-200 tw-ease-in-out tw-pr-2 tw-rounded-md"
(click)="toggle(region.key)"
[attr.aria-pressed]="data.selectedRegion === region ? 'true' : 'false'"
[attr.data-testid]="'environment-selector-dialog-item-' + i"
>
<i
class="bwi bwi-fw bwi-sm bwi-check"
style="padding-bottom: 1px"
aria-hidden="true"
[style.visibility]="data.selectedRegion === region ? 'visible' : 'hidden'"
></i>
<span>{{ region.domain }}</span>
</button>
<br />
</ng-container>
<button
type="button"
class="tw-text-main tw-w-full tw-text-left tw-py-0 tw-pr-2 tw-border tw-border-transparent tw-transition-all tw-duration-200 tw-ease-in-out tw-rounded-md"
(click)="toggle(ServerEnvironmentType.SelfHosted)"
[attr.aria-pressed]="data.selectedRegion ? 'false' : 'true'"
data-testid="environment-selector-dialog-item-self-hosted"
>
<i
class="bwi bwi-fw bwi-sm bwi-check"
style="padding-bottom: 1px"
aria-hidden="true"
[style.visibility]="data.selectedRegion ? 'hidden' : 'visible'"
></i>
<span>{{ "selfHostedServer" | i18n }}</span>
</button>
</div>
</div>
</ng-template>
</ng-container>

View File

@@ -1,135 +0,0 @@
import { animate, state, style, transition, trigger } from "@angular/animations";
import { ConnectedPosition } from "@angular/cdk/overlay";
import { Component, EventEmitter, Output, Input, OnInit, OnDestroy } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { Observable, map, 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 { SelfHostedEnvConfigDialogComponent } from "@bitwarden/auth/angular";
import {
EnvironmentService,
Region,
RegionConfig,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components";
export const ExtensionDefaultOverlayPosition: ConnectedPosition[] = [
{
originX: "start",
originY: "top",
overlayX: "start",
overlayY: "bottom",
},
];
export const DesktopDefaultOverlayPosition: ConnectedPosition[] = [
{
originX: "start",
originY: "top",
overlayX: "start",
overlayY: "bottom",
},
];
export interface EnvironmentSelectorRouteData {
overlayPosition?: ConnectedPosition[];
}
@Component({
selector: "environment-selector",
templateUrl: "environment-selector.component.html",
animations: [
trigger("transformPanel", [
state(
"void",
style({
opacity: 0,
}),
),
transition(
"void => open",
animate(
"100ms linear",
style({
opacity: 1,
}),
),
),
transition("* => void", animate("100ms linear", style({ opacity: 0 }))),
]),
],
standalone: false,
})
export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
@Output() onOpenSelfHostedSettings = new EventEmitter<void>();
@Input() overlayPosition: ConnectedPosition[] = [
{
originX: "start",
originY: "bottom",
overlayX: "start",
overlayY: "top",
},
];
protected isOpen = false;
protected ServerEnvironmentType = Region;
protected availableRegions = this.environmentService.availableRegions();
protected selectedRegion$: Observable<RegionConfig | undefined> =
this.environmentService.environment$.pipe(
map((e) => e.getRegion()),
map((r) => this.availableRegions.find((ar) => ar.key === r)),
);
private destroy$ = new Subject<void>();
constructor(
protected environmentService: EnvironmentService,
private route: ActivatedRoute,
private dialogService: DialogService,
private toastService: ToastService,
private i18nService: I18nService,
) {}
ngOnInit() {
this.route.data.pipe(takeUntil(this.destroy$)).subscribe((data) => {
if (data && data["overlayPosition"]) {
this.overlayPosition = data["overlayPosition"];
}
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
async toggle(option: Region) {
this.isOpen = !this.isOpen;
if (option === null) {
return;
}
/**
* Opens the self-hosted settings dialog when the self-hosted option is selected.
*/
if (option === Region.SelfHosted) {
const dialogResult = await SelfHostedEnvConfigDialogComponent.open(this.dialogService);
if (dialogResult) {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("environmentSaved"),
});
}
// Don't proceed to setEnvironment when the self-hosted dialog is cancelled
return;
}
await this.environmentService.setEnvironment(option);
}
close() {
this.isOpen = false;
}
}

View File

@@ -1,300 +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 { 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 { 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 { 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 { 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(
protected accountService: AccountService,
protected dialogService: DialogService,
protected encryptService: EncryptService,
protected i18nService: I18nService,
protected kdfConfigService: KdfConfigService,
protected keyService: KeyService,
protected masterPasswordApiService: MasterPasswordApiService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected messagingService: MessagingService,
protected organizationApiService: OrganizationApiServiceAbstraction,
protected organizationUserApiService: OrganizationUserApiService,
protected platformUtilsService: PlatformUtilsService,
protected policyApiService: PolicyApiServiceAbstraction,
protected policyService: PolicyService,
protected route: ActivatedRoute,
protected router: Router,
protected ssoLoginService: SsoLoginServiceAbstraction,
protected syncService: SyncService,
protected toastService: ToastService,
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
) {
super(
accountService,
dialogService,
i18nService,
kdfConfigService,
keyService,
masterPasswordService,
messagingService,
platformUtilsService,
policyService,
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

@@ -4,11 +4,9 @@ import { Directive, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
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 { PinServiceAbstraction } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DialogRef } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";

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 { 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 { 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 { 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(
accountService,
dialogService,
i18nService,
kdfConfigService,
keyService,
masterPasswordService,
messagingService,
platformUtilsService,
policyService,
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 { 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 { 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 { 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(
accountService,
dialogService,
i18nService,
kdfConfigService,
keyService,
masterPasswordService,
messagingService,
platformUtilsService,
policyService,
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

@@ -6,8 +6,7 @@
bit-item-content
type="button"
[attr.tabindex]="device.pendingAuthRequest != null ? 0 : null"
(click)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
(keydown.enter)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
(click)="answerAuthRequest(device.pendingAuthRequest)"
>
<!-- Default Content -->
<span class="tw-text-base">{{ device.displayName }}</span>
@@ -21,7 +20,7 @@
<!-- Secondary Content -->
<span slot="secondary" class="tw-text-sm">
<span>{{ "needsApproval" | i18n }}</span>
<br />
<div>
<span class="tw-font-semibold"> {{ "firstLogin" | i18n }}: </span>
<span>{{ device.firstLogin | date: "medium" }}</span>

View File

@@ -1,16 +1,11 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { Component, EventEmitter, Input, Output } from "@angular/core";
// 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 { BadgeModule, 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({
@@ -21,24 +16,12 @@ import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
})
export class DeviceManagementItemGroupComponent {
@Input() devices: DeviceDisplayData[] = [];
@Output() onAuthRequestAnswered = new EventEmitter<DevicePendingAuthRequest>();
constructor(private dialogService: DialogService) {}
protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
protected answerAuthRequest(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);
}
this.onAuthRequestAnswered.emit(pendingAuthRequest);
}
}

View File

@@ -1,4 +1,4 @@
<bit-table-scroll [dataSource]="tableDataSource" [rowSize]="50">
<bit-table-scroll [dataSource]="tableDataSource" [rowSize]="64">
<!-- Table Header -->
<ng-container header>
<th
@@ -6,7 +6,6 @@
[class]="column.headerClass"
bitCell
[bitSortable]="column.sortable ? column.name : ''"
[default]="column.name === 'loginStatus' ? 'desc' : false"
scope="col"
role="columnheader"
>
@@ -17,24 +16,17 @@
<!-- Table Rows -->
<ng-template bitRowDef let-device>
<!-- Column: Device Name -->
<td bitCell class="tw-flex tw-gap-2">
<td bitCell class="tw-flex tw-gap-2 tw-items-center tw-h-16">
<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)"
>
<a bitLink href="#" appStopClick (click)="answerAuthRequest(device.pendingAuthRequest)">
{{ device.displayName }}
</a>
<div class="tw-text-sm tw-text-muted">
{{ "needsApproval" | i18n }}
</div>
<br />
} @else {
<span>{{ device.displayName }}</span>
<div *ngIf="device.isTrusted" class="tw-text-sm tw-text-muted">

View File

@@ -1,24 +1,18 @@
import { CommonModule } from "@angular/common";
import { Component, Input, OnChanges, SimpleChanges } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from "@angular/core";
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({
@@ -29,6 +23,8 @@ import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
})
export class DeviceManagementTableComponent implements OnChanges {
@Input() devices: DeviceDisplayData[] = [];
@Output() onAuthRequestAnswered = new EventEmitter<DevicePendingAuthRequest>();
protected tableDataSource = new TableDataSource<DeviceDisplayData>();
protected readonly columnConfig = [
@@ -52,10 +48,7 @@ export class DeviceManagementTableComponent implements OnChanges {
},
];
constructor(
private i18nService: I18nService,
private dialogService: DialogService,
) {}
constructor(private i18nService: I18nService) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes.devices) {
@@ -63,24 +56,10 @@ export class DeviceManagementTableComponent implements OnChanges {
}
}
protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
protected answerAuthRequest(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,
);
}
this.onAuthRequestAnswered.emit(pendingAuthRequest);
}
}

View File

@@ -12,7 +12,7 @@
</button>
<bit-popover [title]="'whatIsADevice' | i18n" #infoPopover>
<p>{{ "aDeviceIs" | i18n }}</p>
<p class="tw-mb-0">{{ "aDeviceIs" | i18n }}</p>
</bit-popover>
</div>
@@ -30,11 +30,13 @@
<auth-device-management-table
ngClass="tw-hidden md:tw-block"
[devices]="devices"
(onAuthRequestAnswered)="handleAuthRequestAnswered($event)"
></auth-device-management-table>
<!-- List View: displays on small screens -->
<auth-device-management-item-group
ngClass="md:tw-hidden"
[devices]="devices"
(onAuthRequestAnswered)="handleAuthRequestAnswered($event)"
></auth-device-management-item-group>
}

View File

@@ -16,14 +16,18 @@ 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 { ButtonModule, DialogService, PopoverModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { LoginApprovalDialogComponent } from "../login-approval";
import { DeviceManagementComponentServiceAbstraction } from "./device-management-component.service.abstraction";
import { DeviceManagementItemGroupComponent } from "./device-management-item-group.component";
import { DeviceManagementTableComponent } from "./device-management-table.component";
import { clearAuthRequestAndResortDevices, resortDevices } from "./resort-devices.helper";
export interface DeviceDisplayData {
creationDate: string;
displayName: string;
firstLogin: Date;
icon: string;
@@ -66,6 +70,7 @@ export class DeviceManagementComponent implements OnInit {
private destroyRef: DestroyRef,
private deviceManagementComponentService: DeviceManagementComponentServiceAbstraction,
private devicesService: DevicesServiceAbstraction,
private dialogService: DialogService,
private i18nService: I18nService,
private messageListener: MessageListener,
private validationService: ValidationService,
@@ -130,6 +135,7 @@ export class DeviceManagementComponent implements OnInit {
}
return {
creationDate: device.creationDate,
displayName: this.devicesService.getReadableDeviceTypeName(device.type),
firstLogin: device.creationDate ? new Date(device.creationDate) : new Date(),
icon: this.getDeviceIcon(device.type),
@@ -141,7 +147,8 @@ export class DeviceManagementComponent implements OnInit {
pendingAuthRequest: device.response?.devicePendingAuthRequest ?? null,
};
})
.filter((device) => device !== null);
.filter((device) => device !== null)
.sort(resortDevices);
}
private async upsertDeviceWithPendingAuthRequest(authRequestId: string) {
@@ -151,6 +158,7 @@ export class DeviceManagementComponent implements OnInit {
}
const upsertDevice: DeviceDisplayData = {
creationDate: "",
displayName: this.devicesService.getReadableDeviceTypeName(
authRequestResponse.requestDeviceTypeValue,
),
@@ -174,8 +182,9 @@ export class DeviceManagementComponent implements OnInit {
);
if (existingDevice?.id && existingDevice.creationDate) {
upsertDevice.id = existingDevice.id;
upsertDevice.creationDate = existingDevice.creationDate;
upsertDevice.firstLogin = new Date(existingDevice.creationDate);
upsertDevice.id = existingDevice.id;
}
}
@@ -186,10 +195,10 @@ export class DeviceManagementComponent implements OnInit {
if (existingDeviceIndex >= 0) {
// Update existing device in device list
this.devices[existingDeviceIndex] = upsertDevice;
this.devices = [...this.devices];
this.devices = [...this.devices].sort(resortDevices);
} else {
// Add new device to device list
this.devices = [upsertDevice, ...this.devices];
this.devices = [upsertDevice, ...this.devices].sort(resortDevices);
}
}
@@ -227,4 +236,18 @@ export class DeviceManagementComponent implements OnInit {
const metadata = DeviceTypeMetadata[type];
return metadata ? (categoryIconMap[metadata.category] ?? defaultIcon) : defaultIcon;
}
protected async handleAuthRequestAnswered(pendingAuthRequest: DevicePendingAuthRequest) {
const loginApprovalDialog = LoginApprovalDialogComponent.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

@@ -23,7 +23,7 @@ export function clearAuthRequestAndResortDevices(
*
* This is a helper function that gets passed to the `Array.sort()` method
*/
function resortDevices(deviceA: DeviceDisplayData, deviceB: DeviceDisplayData) {
export function resortDevices(deviceA: DeviceDisplayData, deviceB: DeviceDisplayData) {
// Devices with a pending auth request should be first
if (deviceA.pendingAuthRequest) {
return -1;
@@ -40,11 +40,11 @@ function resortDevices(deviceA: DeviceDisplayData, deviceB: DeviceDisplayData) {
return 1;
}
// Then sort the rest by display name (alphabetically)
if (deviceA.displayName < deviceB.displayName) {
// Then sort the rest by creation date (newest to oldest)
if (deviceA.creationDate > deviceB.creationDate) {
return -1;
}
if (deviceA.displayName > deviceB.displayName) {
if (deviceA.creationDate < deviceB.creationDate) {
return 1;
}

View File

@@ -0,0 +1,48 @@
<ng-container
*ngIf="{
selectedRegion: selectedRegion$ | async,
} as data"
>
<div class="tw-mb-1">
<bit-menu #environmentOptions>
<button
*ngFor="let region of availableRegions; let i = index"
bitMenuItem
type="button"
[attr.aria-pressed]="data.selectedRegion === region ? 'true' : 'false'"
(click)="toggle(region.key)"
>
<i
class="bwi bwi-fw bwi-sm bwi-check"
style="padding-bottom: 1px"
aria-hidden="true"
[style.visibility]="data.selectedRegion === region ? 'visible' : 'hidden'"
></i>
<span>{{ region.domain }}</span>
</button>
<button
bitMenuItem
type="button"
[attr.aria-pressed]="data.selectedRegion ? 'false' : 'true'"
(click)="toggle(ServerEnvironmentType.SelfHosted)"
>
<i
class="bwi bwi-fw bwi-sm bwi-check"
style="padding-bottom: 1px"
aria-hidden="true"
[style.visibility]="data.selectedRegion ? 'hidden' : 'visible'"
></i>
<span>{{ "selfHostedServer" | i18n }}</span>
</button>
</bit-menu>
<div bitTypography="body2">
{{ "accessing" | i18n }}:
<button [bitMenuTriggerFor]="environmentOptions" bitLink type="button">
<b class="tw-text-primary-600 tw-font-semibold">{{
data.selectedRegion?.domain || ("selfHostedServer" | i18n)
}}</b>
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
</button>
</div>
</div>
</ng-container>

View File

@@ -0,0 +1,75 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy } from "@angular/core";
import { Observable, map, Subject } 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 { SelfHostedEnvConfigDialogComponent } from "@bitwarden/auth/angular";
import {
EnvironmentService,
Region,
RegionConfig,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
DialogService,
LinkModule,
MenuModule,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
@Component({
selector: "environment-selector",
templateUrl: "environment-selector.component.html",
standalone: true,
imports: [CommonModule, I18nPipe, MenuModule, LinkModule, TypographyModule],
})
export class EnvironmentSelectorComponent implements OnDestroy {
protected ServerEnvironmentType = Region;
protected availableRegions = this.environmentService.availableRegions();
protected selectedRegion$: Observable<RegionConfig | undefined> =
this.environmentService.environment$.pipe(
map((e) => e.getRegion()),
map((r) => this.availableRegions.find((ar) => ar.key === r)),
);
private destroy$ = new Subject<void>();
constructor(
public environmentService: EnvironmentService,
private dialogService: DialogService,
private toastService: ToastService,
private i18nService: I18nService,
) {}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
async toggle(option: Region) {
if (option === null) {
return;
}
/**
* Opens the self-hosted settings dialog when the self-hosted option is selected.
*/
if (option === Region.SelfHosted) {
const dialogResult = await SelfHostedEnvConfigDialogComponent.open(this.dialogService);
if (dialogResult) {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("environmentSaved"),
});
}
// Don't proceed to setEnvironment when the self-hosted dialog is cancelled
return;
}
await this.environmentService.setEnvironment(option);
}
}

View File

@@ -68,10 +68,7 @@ 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, canActivate: [authGuard] },
{ path: "update-temp-password", component: EmptyComponent, canActivate: [authGuard] },
{ path: "change-password", component: EmptyComponent },
{ path: "remove-password", component: EmptyComponent, canActivate: [authGuard] },
]),
@@ -125,109 +122,58 @@ describe("AuthGuard", () => {
});
describe("given user is Locked", () => {
describe("given the PM16117_SetInitialPasswordRefactor feature flag is ON", () => {
it("should redirect to /set-initial-password when the user has ForceSetPasswordReaason.TdeOffboardingUntrustedDevice", async () => {
const { router } = setup(
AuthenticationStatus.Locked,
ForceSetPasswordReason.TdeOffboardingUntrustedDevice,
false,
FeatureFlag.PM16117_SetInitialPasswordRefactor,
);
it("should redirect to /set-initial-password when the user has ForceSetPasswordReaason.TdeOffboardingUntrustedDevice", async () => {
const { router } = setup(
AuthenticationStatus.Locked,
ForceSetPasswordReason.TdeOffboardingUntrustedDevice,
false,
);
await router.navigate(["guarded-route"]);
expect(router.url).toBe("/set-initial-password");
});
await router.navigate(["guarded-route"]);
expect(router.url).toBe("/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,
FeatureFlag.PM16117_SetInitialPasswordRefactor,
);
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,
);
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", () => {
describe("given the PM16117_SetInitialPasswordRefactor feature flag is ON", () => {
const tests = [
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
ForceSetPasswordReason.TdeOffboarding,
];
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,
FeatureFlag.PM16117_SetInitialPasswordRefactor,
);
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 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(["guarded-route"]);
expect(router.url).toContain("/set-initial-password");
});
});
});
describe("given the PM16117_SetInitialPasswordRefactor feature flag is OFF", () => {
const tests = [
{
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,
@@ -236,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");
@@ -256,7 +197,6 @@ describe("AuthGuard", () => {
AuthenticationStatus.Unlocked,
ForceSetPasswordReason.AdminForcePasswordReset,
false,
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
);
await router.navigate(["/change-password"]);
@@ -265,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();
@@ -44,16 +41,11 @@ export const authGuard: CanActivateFn = async (
masterPasswordService.forceSetPasswordReason$(userId),
);
const isSetInitialPasswordFlagOn = await configService.getFeatureFlag(
FeatureFlag.PM16117_SetInitialPasswordRefactor,
);
// User JIT provisioned into a master-password-encryption org
if (
authStatus === AuthenticationStatus.Locked &&
forceSetPasswordReason === ForceSetPasswordReason.SsoNewJitProvisionedUser &&
!routerState.url.includes("set-initial-password") &&
isSetInitialPasswordFlagOn
!routerState.url.includes("set-initial-password")
) {
return router.createUrlTree(["/set-initial-password"]);
}
@@ -62,8 +54,7 @@ export const authGuard: CanActivateFn = async (
if (
authStatus === AuthenticationStatus.Locked &&
forceSetPasswordReason === ForceSetPasswordReason.TdeOffboardingUntrustedDevice &&
!routerState.url.includes("set-initial-password") &&
isSetInitialPasswordFlagOn
!routerState.url.includes("set-initial-password")
) {
return router.createUrlTree(["/set-initial-password"]);
}
@@ -90,39 +81,28 @@ export const authGuard: CanActivateFn = async (
return router.createUrlTree(["/remove-password"]);
}
// TDE org user has "manage account recovery" permission
// 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.TdeUserWithoutPasswordHasPasswordResetPermission &&
!routerState.url.includes("set-password") &&
(forceSetPasswordReason ===
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission ||
forceSetPasswordReason === ForceSetPasswordReason.TdeOffboarding) &&
!routerState.url.includes("set-initial-password")
) {
const route = isSetInitialPasswordFlagOn ? "/set-initial-password" : "/set-password";
const route = "/set-initial-password";
return router.createUrlTree([route]);
}
// TDE Offboarding on trusted device
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]);
}
const isChangePasswordFlagOn = await configService.getFeatureFlag(
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
);
// Post- Account Recovery or Weak Password on login
// Handle cases where a user has a password but needs to set a new one:
// - Account recovery
// - 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

@@ -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,30 @@
import { TestBed } from "@angular/core/testing";
import { DefaultLoginApprovalDialogComponentService } from "./default-login-approval-dialog-component.service";
import { LoginApprovalDialogComponent } from "./login-approval-dialog.component";
describe("DefaultLoginApprovalDialogComponentService", () => {
let service: DefaultLoginApprovalDialogComponentService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [DefaultLoginApprovalDialogComponentService],
});
service = TestBed.inject(DefaultLoginApprovalDialogComponentService);
});
it("is created successfully", () => {
expect(service).toBeTruthy();
});
it("has showLoginRequestedAlertIfWindowNotVisible method that is a no-op", async () => {
const loginApprovalDialogComponent = {} as LoginApprovalDialogComponent;
const result = await service.showLoginRequestedAlertIfWindowNotVisible(
loginApprovalDialogComponent.email,
);
expect(result).toBeUndefined();
});
});

View File

@@ -0,0 +1,16 @@
import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval-dialog-component.service.abstraction";
/**
* Default implementation of the LoginApprovalDialogComponentServiceAbstraction.
*/
export class DefaultLoginApprovalDialogComponentService
implements LoginApprovalDialogComponentServiceAbstraction
{
/**
* No-op implementation of the showLoginRequestedAlertIfWindowNotVisible method.
* @returns
*/
async showLoginRequestedAlertIfWindowNotVisible(email?: string): Promise<void> {
return;
}
}

View File

@@ -0,0 +1,3 @@
export * from "./login-approval-dialog.component";
export * from "./login-approval-dialog-component.service.abstraction";
export * from "./default-login-approval-dialog-component.service";

View File

@@ -1,7 +1,7 @@
/**
* Abstraction for the LoginApprovalComponent service.
* Abstraction for the LoginApprovalDialogComponent service.
*/
export abstract class LoginApprovalComponentServiceAbstraction {
export abstract class LoginApprovalDialogComponentServiceAbstraction {
/**
* Shows a login requested alert if the window is not visible.
*/

View File

@@ -1,5 +1,6 @@
<bit-dialog>
<span bitDialogTitle>{{ "areYouTryingToAccessYourAccount" | i18n }}</span>
<span bitDialogTitle>{{ "loginRequest" | i18n }}</span>
<ng-container bitDialogContent>
<ng-container *ngIf="loading">
<div class="tw-flex tw-items-center tw-justify-center" *ngIf="loading">
@@ -8,28 +9,29 @@
</ng-container>
<ng-container *ngIf="!loading">
<h4 class="tw-mb-3">{{ "accessAttemptBy" | i18n: email }}</h4>
<p>{{ "accessAttemptBy" | i18n: email }}</p>
<div>
<b>{{ "fingerprintPhraseHeader" | i18n }}</b>
<span class="tw-font-medium">{{ "fingerprintPhraseHeader" | i18n }}</span>
<p class="tw-text-code">{{ fingerprintPhrase }}</p>
</div>
<div>
<b>{{ "deviceType" | i18n }}</b>
<p>{{ authRequestResponse?.requestDeviceType }}</p>
<span class="tw-font-medium">{{ "deviceType" | i18n }}</span>
<p>{{ readableDeviceTypeName }}</p>
</div>
<div>
<b>{{ "location" | i18n }}</b>
<span class="tw-font-medium">{{ "location" | i18n }}</span>
<p>
<span class="tw-capitalize">{{ authRequestResponse?.requestCountryName }} </span>
({{ authRequestResponse?.requestIpAddress }})
</p>
</div>
<div>
<b>{{ "time" | i18n }}</b>
<span class="tw-font-medium">{{ "time" | i18n }}</span>
<p>{{ requestTimeText }}</p>
</div>
</ng-container>
</ng-container>
<ng-container bitDialogFooter>
<button
bitButton

View File

@@ -2,34 +2,33 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import {
AuthRequestServiceAbstraction,
LoginApprovalComponentServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
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 { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
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 { DialogRef, DIALOG_DATA, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { LoginApprovalComponent } from "./login-approval.component";
import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval-dialog-component.service.abstraction";
import { LoginApprovalDialogComponent } from "./login-approval-dialog.component";
describe("LoginApprovalComponent", () => {
let component: LoginApprovalComponent;
let fixture: ComponentFixture<LoginApprovalComponent>;
describe("LoginApprovalDialogComponent", () => {
let component: LoginApprovalDialogComponent;
let fixture: ComponentFixture<LoginApprovalDialogComponent>;
let authRequestService: MockProxy<AuthRequestServiceAbstraction>;
let accountService: MockProxy<AccountService>;
let apiService: MockProxy<ApiService>;
let i18nService: MockProxy<I18nService>;
let authRequestService: MockProxy<AuthRequestServiceAbstraction>;
let devicesService: MockProxy<DevicesServiceAbstraction>;
let dialogRef: MockProxy<DialogRef>;
let i18nService: MockProxy<I18nService>;
let logService: MockProxy<LogService>;
let toastService: MockProxy<ToastService>;
let validationService: MockProxy<ValidationService>;
@@ -38,11 +37,13 @@ describe("LoginApprovalComponent", () => {
const testPublicKey = "test-public-key";
beforeEach(async () => {
authRequestService = mock<AuthRequestServiceAbstraction>();
accountService = mock<AccountService>();
apiService = mock<ApiService>();
i18nService = mock<I18nService>();
authRequestService = mock<AuthRequestServiceAbstraction>();
devicesService = mock<DevicesServiceAbstraction>();
dialogRef = mock<DialogRef>();
i18nService = mock<I18nService>();
logService = mock<LogService>();
toastService = mock<ToastService>();
validationService = mock<ValidationService>();
@@ -54,27 +55,26 @@ describe("LoginApprovalComponent", () => {
});
await TestBed.configureTestingModule({
imports: [LoginApprovalComponent],
imports: [LoginApprovalDialogComponent],
providers: [
{ provide: DIALOG_DATA, useValue: { notificationId: testNotificationId } },
{ provide: AuthRequestServiceAbstraction, useValue: authRequestService },
{ provide: AccountService, useValue: accountService },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
{ provide: I18nService, useValue: i18nService },
{ provide: ApiService, useValue: apiService },
{ provide: AppIdService, useValue: mock<AppIdService>() },
{ provide: KeyService, useValue: mock<KeyService>() },
{ provide: AuthRequestServiceAbstraction, useValue: authRequestService },
{ provide: DevicesServiceAbstraction, useValue: devicesService },
{ provide: DialogRef, useValue: dialogRef },
{ provide: I18nService, useValue: i18nService },
{ provide: LogService, useValue: logService },
{ provide: ToastService, useValue: toastService },
{ provide: ValidationService, useValue: validationService },
{
provide: LoginApprovalComponentServiceAbstraction,
useValue: mock<LoginApprovalComponentServiceAbstraction>(),
provide: LoginApprovalDialogComponentServiceAbstraction,
useValue: mock<LoginApprovalDialogComponentServiceAbstraction>(),
},
],
}).compileComponents();
fixture = TestBed.createComponent(LoginApprovalComponent);
fixture = TestBed.createComponent(LoginApprovalDialogComponent);
component = fixture.componentInstance;
});
@@ -119,7 +119,6 @@ describe("LoginApprovalComponent", () => {
expect(authRequestService.approveOrDenyAuthRequest).toHaveBeenCalledWith(false, response);
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "info",
title: null,
message: "denied message",
});
});

View File

@@ -1,24 +1,18 @@
// 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, OnDestroy, Inject } from "@angular/core";
import { Subject, firstValueFrom, map } from "rxjs";
import { firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
AuthRequestServiceAbstraction,
LoginApprovalComponentServiceAbstraction as LoginApprovalComponentService,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
// 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 { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
DIALOG_DATA,
DialogRef,
@@ -28,84 +22,100 @@ import {
DialogService,
ToastService,
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
const RequestTimeOut = 60000 * 15; //15 Minutes
const RequestTimeUpdate = 60000 * 5; //5 Minutes
import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval-dialog-component.service.abstraction";
const RequestTimeOut = 60000 * 15; // 15 Minutes
const RequestTimeUpdate = 60000 * 5; // 5 Minutes
export interface LoginApprovalDialogParams {
notificationId: string;
}
@Component({
selector: "login-approval",
templateUrl: "login-approval.component.html",
imports: [CommonModule, AsyncActionsModule, ButtonModule, DialogModule, JslibModule],
templateUrl: "login-approval-dialog.component.html",
imports: [AsyncActionsModule, ButtonModule, CommonModule, DialogModule, JslibModule],
})
export class LoginApprovalComponent implements OnInit, OnDestroy {
export class LoginApprovalDialogComponent implements OnInit, OnDestroy {
authRequestId: string;
authRequestResponse?: AuthRequestResponse;
email?: string;
fingerprintPhrase?: string;
interval?: NodeJS.Timeout;
loading = true;
notificationId: string;
private destroy$ = new Subject<void>();
email: string;
fingerprintPhrase: string;
authRequestResponse: AuthRequestResponse;
interval: NodeJS.Timeout;
requestTimeText: string;
readableDeviceTypeName?: string;
requestTimeText?: string;
constructor(
@Inject(DIALOG_DATA) private params: LoginApprovalDialogParams,
protected authRequestService: AuthRequestServiceAbstraction,
protected accountService: AccountService,
protected platformUtilsService: PlatformUtilsService,
protected i18nService: I18nService,
protected apiService: ApiService,
protected appIdService: AppIdService,
protected keyService: KeyService,
private accountService: AccountService,
private apiService: ApiService,
private authRequestService: AuthRequestServiceAbstraction,
private devicesService: DevicesServiceAbstraction,
private dialogRef: DialogRef,
private i18nService: I18nService,
private loginApprovalDialogComponentService: LoginApprovalDialogComponentServiceAbstraction,
private logService: LogService,
private toastService: ToastService,
private loginApprovalComponentService: LoginApprovalComponentService,
private validationService: ValidationService,
) {
this.notificationId = params.notificationId;
this.authRequestId = params.notificationId;
}
async ngOnDestroy(): Promise<void> {
clearInterval(this.interval);
this.destroy$.next();
this.destroy$.complete();
}
async ngOnInit() {
if (this.notificationId != null) {
try {
this.authRequestResponse = await this.apiService.getAuthRequest(this.notificationId);
} catch (error) {
this.validationService.showError(error);
}
const publicKey = Utils.fromB64ToArray(this.authRequestResponse.publicKey);
this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
this.email,
publicKey,
);
this.updateTimeText();
this.interval = setInterval(() => {
this.updateTimeText();
}, RequestTimeUpdate);
// 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.loginApprovalComponentService.showLoginRequestedAlertIfWindowNotVisible(this.email);
this.loading = false;
if (this.authRequestId == null) {
this.logService.error("LoginApprovalDialogComponent: authRequestId is null");
return;
}
try {
this.authRequestResponse = await this.apiService.getAuthRequest(this.authRequestId);
} catch (error) {
this.validationService.showError(error);
this.logService.error("LoginApprovalDialogComponent: getAuthRequest error", error);
}
if (this.authRequestResponse == null) {
this.logService.error("LoginApprovalDialogComponent: authRequestResponse not found");
return;
}
const publicKey = Utils.fromB64ToArray(this.authRequestResponse.publicKey);
this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
if (!this.email) {
this.logService.error("LoginApprovalDialogComponent: email not found");
return;
}
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
this.email,
publicKey,
);
this.readableDeviceTypeName = this.devicesService.getReadableDeviceTypeName(
this.authRequestResponse.requestDeviceTypeValue,
);
this.updateTimeText();
this.interval = setInterval(() => {
this.updateTimeText();
}, RequestTimeUpdate);
await this.loginApprovalDialogComponentService.showLoginRequestedAlertIfWindowNotVisible(
this.email,
);
this.loading = false;
}
/**
@@ -114,7 +124,7 @@ export class LoginApprovalComponent implements OnInit, OnDestroy {
* @param data Configuration for the dialog
*/
static open(dialogService: DialogService, data: LoginApprovalDialogParams) {
return dialogService.open(LoginApprovalComponent, { data });
return dialogService.open(LoginApprovalDialogComponent, { data });
}
denyLogin = async () => {
@@ -126,11 +136,10 @@ export class LoginApprovalComponent implements OnInit, OnDestroy {
};
private async retrieveAuthRequestAndRespond(approve: boolean) {
this.authRequestResponse = await this.apiService.getAuthRequest(this.notificationId);
this.authRequestResponse = await this.apiService.getAuthRequest(this.authRequestId);
if (this.authRequestResponse.requestApproved || this.authRequestResponse.responseDate != null) {
this.toastService.showToast({
variant: "info",
title: null,
message: this.i18nService.t("thisRequestIsNoLongerValid"),
});
} else {
@@ -148,23 +157,26 @@ export class LoginApprovalComponent implements OnInit, OnDestroy {
if (loginResponse.requestApproved) {
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t(
"logInConfirmedForEmailOnDevice",
"loginRequestApprovedForEmailOnDevice",
this.email,
loginResponse.requestDeviceType,
this.devicesService.getReadableDeviceTypeName(loginResponse.requestDeviceTypeValue),
),
});
} else {
this.toastService.showToast({
variant: "info",
title: null,
message: this.i18nService.t("youDeniedALogInAttemptFromAnotherDevice"),
message: this.i18nService.t("youDeniedLoginAttemptFromAnotherDevice"),
});
}
}
updateTimeText() {
if (this.authRequestResponse == null) {
this.logService.error("LoginApprovalDialogComponent: authRequestResponse not found");
return;
}
const requestDate = new Date(this.authRequestResponse.creationDate);
const requestDateUTC = Date.UTC(
requestDate.getUTCFullYear(),
@@ -201,7 +213,6 @@ export class LoginApprovalComponent implements OnInit, OnDestroy {
this.dialogRef.close();
this.toastService.showToast({
variant: "info",
title: null,
message: this.i18nService.t("loginRequestHasAlreadyExpired"),
});
}

View File

@@ -178,6 +178,9 @@ export class ChangePasswordComponent implements OnInit {
// 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);

View File

@@ -59,4 +59,10 @@ export abstract class ChangePasswordService {
* - 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

@@ -0,0 +1,262 @@
import { CommonModule } from "@angular/common";
import { SimpleChange } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ReactiveFormsModule } from "@angular/forms";
import { mock, MockProxy } from "jest-mock-extended";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SelectModule, FormFieldModule, BitSubmitDirective } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { ManageTaxInformationComponent } from "./manage-tax-information.component";
describe("ManageTaxInformationComponent", () => {
let sut: ManageTaxInformationComponent;
let fixture: ComponentFixture<ManageTaxInformationComponent>;
let mockTaxService: MockProxy<TaxServiceAbstraction>;
beforeEach(async () => {
mockTaxService = mock();
await TestBed.configureTestingModule({
declarations: [ManageTaxInformationComponent],
providers: [
{ provide: TaxServiceAbstraction, useValue: mockTaxService },
{ provide: I18nService, useValue: { t: (key: string) => key } },
],
imports: [
CommonModule,
ReactiveFormsModule,
SelectModule,
FormFieldModule,
BitSubmitDirective,
I18nPipe,
],
}).compileComponents();
fixture = TestBed.createComponent(ManageTaxInformationComponent);
sut = fixture.componentInstance;
fixture.autoDetectChanges();
});
afterEach(() => {
jest.clearAllMocks();
});
it("creates successfully", () => {
expect(sut).toBeTruthy();
});
it("should initialize with all values empty in startWith", async () => {
// Arrange
sut.startWith = {
country: "",
postalCode: "",
taxId: "",
line1: "",
line2: "",
city: "",
state: "",
};
// Act
fixture.detectChanges();
// Assert
const startWithValue = sut.startWith;
expect(startWithValue.line1).toHaveLength(0);
expect(startWithValue.line2).toHaveLength(0);
expect(startWithValue.city).toHaveLength(0);
expect(startWithValue.state).toHaveLength(0);
expect(startWithValue.postalCode).toHaveLength(0);
expect(startWithValue.country).toHaveLength(0);
expect(startWithValue.taxId).toHaveLength(0);
});
it("should update the tax information protected state when form is updated", async () => {
// Arrange
const line1Value = "123 Street";
const line2Value = "Apt. 5";
const cityValue = "New York";
const stateValue = "NY";
const countryValue = "USA";
const postalCodeValue = "123 Street";
sut.startWith = {
country: countryValue,
postalCode: "",
taxId: "",
line1: "",
line2: "",
city: "",
state: "",
};
sut.showTaxIdField = false;
mockTaxService.isCountrySupported.mockResolvedValue(true);
// Act
await sut.ngOnInit();
fixture.detectChanges();
const line1: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='line1']",
);
const line2: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='line2']",
);
const city: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='city']",
);
const state: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='state']",
);
const postalCode: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='postalCode']",
);
line1.value = line1Value;
line2.value = line2Value;
city.value = cityValue;
state.value = stateValue;
postalCode.value = postalCodeValue;
line1.dispatchEvent(new Event("input"));
line2.dispatchEvent(new Event("input"));
city.dispatchEvent(new Event("input"));
state.dispatchEvent(new Event("input"));
postalCode.dispatchEvent(new Event("input"));
await fixture.whenStable();
// Assert
// Assert that the internal tax information reflects the form
const taxInformation = sut.getTaxInformation();
expect(taxInformation.line1).toBe(line1Value);
expect(taxInformation.line2).toBe(line2Value);
expect(taxInformation.city).toBe(cityValue);
expect(taxInformation.state).toBe(stateValue);
expect(taxInformation.postalCode).toBe(postalCodeValue);
expect(taxInformation.country).toBe(countryValue);
expect(taxInformation.taxId).toHaveLength(0);
expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue);
expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(2);
});
it("should not show address fields except postal code if country is not supported for taxes", async () => {
// Arrange
const countryValue = "UNKNOWN";
sut.startWith = {
country: countryValue,
postalCode: "",
taxId: "",
line1: "",
line2: "",
city: "",
state: "",
};
sut.showTaxIdField = false;
mockTaxService.isCountrySupported.mockResolvedValue(false);
// Act
await sut.ngOnInit();
fixture.detectChanges();
const line1: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='line1']",
);
const line2: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='line2']",
);
const city: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='city']",
);
const state: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='state']",
);
const postalCode: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='postalCode']",
);
// Assert
expect(line1).toBeNull();
expect(line2).toBeNull();
expect(city).toBeNull();
expect(state).toBeNull();
//Should be visible
expect(postalCode).toBeTruthy();
expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue);
expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(1);
});
it("should not show the tax id field if showTaxIdField is set to false", async () => {
// Arrange
const countryValue = "USA";
sut.startWith = {
country: countryValue,
postalCode: "",
taxId: "",
line1: "",
line2: "",
city: "",
state: "",
};
sut.showTaxIdField = false;
mockTaxService.isCountrySupported.mockResolvedValue(true);
// Act
await sut.ngOnInit();
fixture.detectChanges();
// Assert
const taxId: HTMLInputElement = fixture.nativeElement.querySelector(
"input[formControlName='taxId']",
);
expect(taxId).toBeNull();
expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue);
expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(1);
});
it("should clear the tax id field if showTaxIdField is set to false after being true", async () => {
// Arrange
const countryValue = "USA";
const taxIdValue = "A12345678";
sut.startWith = {
country: countryValue,
postalCode: "",
taxId: taxIdValue,
line1: "",
line2: "",
city: "",
state: "",
};
sut.showTaxIdField = true;
mockTaxService.isCountrySupported.mockResolvedValue(true);
await sut.ngOnInit();
fixture.detectChanges();
const initialTaxIdValue = fixture.nativeElement.querySelector(
"input[formControlName='taxId']",
).value;
// Act
sut.showTaxIdField = false;
sut.ngOnChanges({ showTaxIdField: new SimpleChange(true, false, false) });
fixture.detectChanges();
// Assert
const taxId = fixture.nativeElement.querySelector("input[formControlName='taxId']");
expect(taxId).toBeNull();
const taxInformation = sut.getTaxInformation();
expect(taxInformation.taxId).toBeNull();
expect(initialTaxIdValue).toEqual(taxIdValue);
expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue);
expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,6 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import {
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
} from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { debounceTime } from "rxjs/operators";
@@ -13,7 +22,7 @@ import { CountryListItem, TaxInformation } from "@bitwarden/common/billing/model
templateUrl: "./manage-tax-information.component.html",
standalone: false,
})
export class ManageTaxInformationComponent implements OnInit, OnDestroy {
export class ManageTaxInformationComponent implements OnInit, OnDestroy, OnChanges {
@Input() startWith: TaxInformation;
@Input() onSubmit?: (taxInformation: TaxInformation) => Promise<void>;
@Input() showTaxIdField: boolean = true;
@@ -56,7 +65,7 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy {
}
submit = async () => {
this.formGroup.markAllAsTouched();
this.markAllAsTouched();
if (this.formGroup.invalid) {
return;
}
@@ -65,7 +74,7 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy {
};
validate(): boolean {
this.formGroup.markAllAsTouched();
this.markAllAsTouched();
return this.formGroup.valid;
}
@@ -142,6 +151,13 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy {
});
}
ngOnChanges(changes: SimpleChanges): void {
// Clear the value of the tax-id if states have been changed in the parent component
if (!changes.showTaxIdField.currentValue) {
this.formGroup.controls.taxId.setValue(null);
}
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();

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

@@ -18,17 +18,14 @@ import {
// 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 {
DefaultLoginApprovalComponentService,
DefaultLoginComponentService,
DefaultLoginDecryptionOptionsService,
DefaultRegistrationFinishService,
DefaultSetPasswordJitService,
DefaultTwoFactorAuthComponentService,
DefaultTwoFactorAuthWebAuthnComponentService,
LoginComponentService,
LoginDecryptionOptionsService,
RegistrationFinishService as RegistrationFinishServiceAbstraction,
SetPasswordJitService,
TwoFactorAuthComponentService,
TwoFactorAuthWebAuthnComponentService,
} from "@bitwarden/auth/angular";
@@ -42,7 +39,6 @@ import {
DefaultLoginSuccessHandlerService,
DefaultLogoutService,
InternalUserDecryptionOptionsServiceAbstraction,
LoginApprovalComponentServiceAbstraction,
LoginEmailService,
LoginEmailServiceAbstraction,
LoginStrategyService,
@@ -50,8 +46,6 @@ import {
LoginSuccessHandlerService,
LogoutReason,
LogoutService,
PinService,
PinServiceAbstraction,
UserDecryptionOptionsService,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
@@ -112,6 +106,7 @@ import { AccountServiceImplementation } from "@bitwarden/common/auth/services/ac
import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service";
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { AvatarService } from "@bitwarden/common/auth/services/avatar.service";
import { DefaultActiveUserAccessor } from "@bitwarden/common/auth/services/default-active-user.accessor";
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";
@@ -154,11 +149,9 @@ import { OrganizationBillingApiService } from "@bitwarden/common/billing/service
import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service";
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
import { TaxService } from "@bitwarden/common/billing/services/tax.service";
import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service";
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { BulkEncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/bulk-encrypt.service.implementation";
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/multithread-encrypt.service.implementation";
import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation";
import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation";
@@ -169,6 +162,8 @@ 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 { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation";
import {
SendPasswordService,
DefaultSendPasswordService,
@@ -236,6 +231,7 @@ import { StorageServiceProvider } from "@bitwarden/common/platform/services/stor
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
import {
ActiveUserAccessor,
ActiveUserStateProvider,
DerivedStateProvider,
GlobalStateProvider,
@@ -345,6 +341,8 @@ import {
VaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
import { DefaultLoginApprovalDialogComponentService } from "../auth/login-approval/default-login-approval-dialog-component.service";
import { LoginApprovalDialogComponentServiceAbstraction } from "../auth/login-approval/login-approval-dialog-component.service.abstraction";
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";
@@ -533,13 +531,13 @@ const safeProviders: SafeProvider[] = [
stateService: StateServiceAbstraction,
autofillSettingsService: AutofillSettingsServiceAbstraction,
encryptService: EncryptService,
bulkEncryptService: BulkEncryptService,
fileUploadService: CipherFileUploadServiceAbstraction,
configService: ConfigService,
stateProvider: StateProvider,
accountService: AccountServiceAbstraction,
logService: LogService,
cipherEncryptionService: CipherEncryptionService,
messagingService: MessagingServiceAbstraction,
) =>
new CipherService(
keyService,
@@ -550,13 +548,13 @@ const safeProviders: SafeProvider[] = [
stateService,
autofillSettingsService,
encryptService,
bulkEncryptService,
fileUploadService,
configService,
stateProvider,
accountService,
logService,
cipherEncryptionService,
messagingService,
),
deps: [
KeyService,
@@ -567,13 +565,13 @@ const safeProviders: SafeProvider[] = [
StateServiceAbstraction,
AutofillSettingsServiceAbstraction,
EncryptService,
BulkEncryptService,
CipherFileUploadServiceAbstraction,
ConfigService,
StateProvider,
AccountServiceAbstraction,
LogService,
CipherEncryptionService,
MessagingServiceAbstraction,
],
}),
safeProvider({
@@ -969,14 +967,9 @@ const safeProviders: SafeProvider[] = [
}),
safeProvider({
provide: EncryptService,
useClass: MultithreadEncryptServiceImplementation,
useClass: EncryptServiceImplementation,
deps: [CryptoFunctionServiceAbstraction, LogService, LOG_MAC_FAILURES],
}),
safeProvider({
provide: BulkEncryptService,
useClass: BulkEncryptServiceImplementation,
deps: [CryptoFunctionServiceAbstraction, LogService],
}),
safeProvider({
provide: EventUploadServiceAbstraction,
useClass: EventUploadService,
@@ -1023,6 +1016,8 @@ const safeProviders: SafeProvider[] = [
KeyGenerationServiceAbstraction,
EncryptService,
LogService,
CryptoFunctionServiceAbstraction,
AccountServiceAbstraction,
],
}),
safeProvider({
@@ -1281,10 +1276,15 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultGlobalStateProvider,
deps: [StorageServiceProvider, LogService],
}),
safeProvider({
provide: ActiveUserAccessor,
useClass: DefaultActiveUserAccessor,
deps: [AccountServiceAbstraction],
}),
safeProvider({
provide: ActiveUserStateProvider,
useClass: DefaultActiveUserStateProvider,
deps: [AccountServiceAbstraction, SingleUserStateProvider],
deps: [ActiveUserAccessor, SingleUserStateProvider],
}),
safeProvider({
provide: SingleUserStateProvider,
@@ -1417,21 +1417,6 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultOrganizationInviteService,
deps: [],
}),
safeProvider({
provide: SetPasswordJitService,
useClass: DefaultSetPasswordJitService,
deps: [
EncryptService,
I18nServiceAbstraction,
KdfConfigService,
KeyService,
MasterPasswordApiServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
OrganizationApiServiceAbstraction,
OrganizationUserApiService,
InternalUserDecryptionOptionsServiceAbstraction,
],
}),
safeProvider({
provide: SetInitialPasswordService,
useClass: DefaultSetInitialPasswordService,
@@ -1512,8 +1497,8 @@ const safeProviders: SafeProvider[] = [
deps: [CryptoFunctionServiceAbstraction],
}),
safeProvider({
provide: LoginApprovalComponentServiceAbstraction,
useClass: DefaultLoginApprovalComponentService,
provide: LoginApprovalDialogComponentServiceAbstraction,
useClass: DefaultLoginApprovalDialogComponentService,
deps: [],
}),
safeProvider({

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

@@ -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";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.

View File

@@ -63,7 +63,7 @@ export class FolderAddEditComponent implements OnInit {
try {
const activeUserId = await firstValueFrom(this.activeUserId$);
const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId);
const userKey = await this.keyService.getUserKey(activeUserId);
const folder = await this.folderService.encrypt(this.folder, userKey);
this.formPromise = this.folderApiService.save(folder, activeUserId);
await this.formPromise;

View File

@@ -1,19 +1,44 @@
<div class="tw-flex tw-justify-center tw-items-center" aria-hidden="true">
<!-- Applying width and height styles directly to synchronize icon sizing between web/browser/desktop -->
<div
class="tw-flex tw-justify-center tw-items-center"
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
aria-hidden="true"
>
<ng-container *ngIf="data$ | async as data">
<img
[src]="data.image"
*ngIf="data.imageEnabled && data.image"
class="tw-size-6 tw-rounded-md"
alt=""
decoding="async"
loading="lazy"
[ngClass]="{ 'tw-invisible tw-absolute': !imageLoaded() }"
(load)="imageLoaded.set(true)"
(error)="imageLoaded.set(false)"
/>
<i
class="tw-w-6 tw-text-muted bwi bwi-lg {{ data.icon }}"
*ngIf="!data.imageEnabled || !data.image || !imageLoaded()"
></i>
@if (data.imageEnabled && data.image) {
<img
[src]="data.image"
class="tw-rounded-md"
alt=""
decoding="async"
loading="lazy"
[ngClass]="{
'tw-invisible tw-absolute': !imageLoaded(),
'tw-size-6': !coloredIcon(),
}"
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
(load)="imageLoaded.set(true)"
(error)="imageLoaded.set(false)"
/>
}
@if (!data.imageEnabled || !data.image || !imageLoaded()) {
<div
[ngClass]="{
'tw-flex tw-items-center tw-justify-center': coloredIcon(),
'tw-bg-illustration-bg-primary tw-rounded-full':
data.icon?.startsWith('bwi-') && coloredIcon(),
}"
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
>
<i
class="tw-text-muted bwi bwi-lg {{ data.icon }}"
[ngStyle]="{
color: coloredIcon() ? 'rgb(var(--color-illustration-outline))' : null,
width: data.icon?.startsWith('credit-card') && coloredIcon() ? '36px' : null,
height: data.icon?.startsWith('credit-card') && coloredIcon() ? '30px' : null,
}"
></i>
</div>
}
</ng-container>
</div>

View File

@@ -27,6 +27,11 @@ export class IconComponent {
*/
cipher = input.required<CipherViewLike>();
/**
* coloredIcon will adjust the size of favicons and the colors of the text icon when user is in the item details view.
*/
coloredIcon = input<boolean>(false);
imageLoaded = signal(false);
protected data$: Observable<CipherIconDetails>;

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 tw-mb-4"
class="tw-rounded-2xl tw-bg-primary-100 tw-border-primary-600 tw-border-solid tw-border tw-p-4 tw-pt-2 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>
@@ -20,6 +20,7 @@
(click)="handleDismiss()"
[attr.title]="'close' | i18n"
[attr.aria-label]="'close' | i18n"
class="-tw-me-2"
></button>
</div>

View File

@@ -3,12 +3,10 @@ import { Observable, combineLatest, from, of } from "rxjs";
import { catchError, switchMap } from "rxjs/operators";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
// 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 { 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 { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { BiometricStateService } from "@bitwarden/key-management";

View File

@@ -5,7 +5,7 @@ import { combineLatest, Observable, of, switchMap } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
@@ -27,7 +27,7 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
this.getNudgeStatus$(nudgeType, 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! as OrganizationId),
);
// When the user has dismissed the nudge or spotlight, return the nudge status directly

View File

@@ -5,7 +5,7 @@ import { combineLatest, Observable, of, switchMap } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
@@ -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! as OrganizationId),
);
// When the user has dismissed the nudge or spotlight, return the nudge status directly

View File

@@ -2,13 +2,11 @@ import { TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { firstValueFrom, 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 { PinServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";

View File

@@ -160,7 +160,6 @@ export class NudgesService {
hasActiveBadges$(userId: UserId): Observable<boolean> {
// Add more nudge types here if they have the settings badge feature
const nudgeTypes = [
NudgeType.AccountSecurity,
NudgeType.EmptyVaultNudge,
NudgeType.DownloadBitwarden,
NudgeType.AutofillNudge,

View File

@@ -109,7 +109,12 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
}
async buildCollections(organizationId?: string): Promise<DynamicTreeNode<CollectionView>> {
const storedCollections = await this.collectionService.getAllDecrypted();
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,

View File

@@ -41,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";
@@ -62,10 +57,6 @@ export * from "./sso/default-sso-component.service";
// self hosted environment configuration dialog
export * from "./self-hosted-env-config-dialog/self-hosted-env-config-dialog.component";
// login approval
export * from "./login-approval/login-approval.component";
export * from "./login-approval/default-login-approval-component.service";
// two factor auth
export * from "./two-factor-auth";

View File

@@ -133,6 +133,7 @@
<bit-label>
{{ "rotateAccountEncKey" | i18n }}
<a
bitLink
href="https://bitwarden.com/help/account-encryption-key/#rotate-your-encryption-key"
target="_blank"
rel="noreferrer"

View File

@@ -28,6 +28,7 @@ import {
FormFieldModule,
IconButtonModule,
InputModule,
LinkModule,
ToastService,
Translation,
} from "@bitwarden/components";
@@ -112,6 +113,7 @@ interface InputPasswordForm {
PasswordCalloutComponent,
PasswordStrengthV2Component,
ReactiveFormsModule,
LinkModule,
SharedModule,
],
})

View File

@@ -1,25 +0,0 @@
import { TestBed } from "@angular/core/testing";
import { DefaultLoginApprovalComponentService } from "./default-login-approval-component.service";
import { LoginApprovalComponent } from "./login-approval.component";
describe("DefaultLoginApprovalComponentService", () => {
let service: DefaultLoginApprovalComponentService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [DefaultLoginApprovalComponentService],
});
service = TestBed.inject(DefaultLoginApprovalComponentService);
});
it("is created successfully", () => {
expect(service).toBeTruthy();
});
it("has showLoginRequestedAlertIfWindowNotVisible method that is a no-op", async () => {
const loginApprovalComponent = {} as LoginApprovalComponent;
await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalComponent.email);
});
});

View File

@@ -1,16 +0,0 @@
import { LoginApprovalComponentServiceAbstraction } from "../../common/abstractions/login-approval-component.service.abstraction";
/**
* Default implementation of the LoginApprovalComponentServiceAbstraction.
*/
export class DefaultLoginApprovalComponentService
implements LoginApprovalComponentServiceAbstraction
{
/**
* No-op implementation of the showLoginRequestedAlertIfWindowNotVisible method.
* @returns
*/
async showLoginRequestedAlertIfWindowNotVisible(email?: string): Promise<void> {
return;
}
}

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

@@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { IsActiveMatchOptions, Router, RouterModule } from "@angular/router";
import { Observable, filter, firstValueFrom, map, merge, race, take, timer } from "rxjs";
import { Observable, firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
@@ -185,17 +185,15 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
this.accountService.activeAccount$.pipe(map((a) => a?.email));
const loginEmail$: Observable<string | null> = this.loginEmailService.loginEmail$;
// Use merge as we want to get the first value from either observable.
const firstEmail$ = merge(loginEmail$, activeAccountEmail$).pipe(
filter((e): e is string => !!e), // convert null/undefined to false and filter out so we narrow type to string
take(1), // complete after first value
);
let loginEmail: string | undefined = (await firstValueFrom(loginEmail$)) ?? undefined;
const emailRetrievalTimeout$ = timer(2500).pipe(map(() => undefined as undefined));
if (!loginEmail) {
loginEmail = (await firstValueFrom(activeAccountEmail$)) ?? undefined;
}
// Wait for either the first email or the timeout to occur so we can proceed
// neither above observable will complete, so we have to add a timeout
this.email = await firstValueFrom(race(firstEmail$, emailRetrievalTimeout$));
this.email = loginEmail;
if (!this.email) {
await this.handleMissingEmail();

View File

@@ -23,7 +23,7 @@ export abstract class LoginComponentService {
* Gets the organization policies if there is an organization invite.
* - Used by: Web
*/
getOrgPoliciesFromOrgInvite?: () => Promise<PasswordPolicies | null>;
getOrgPoliciesFromOrgInvite?: (email: string) => Promise<PasswordPolicies | null>;
/**
* Indicates whether login with passkey is supported on the given client

View File

@@ -53,14 +53,14 @@
buttonType="secondary"
(click)="handleLoginWithPasskeyClick()"
>
<i class="bwi bwi-passkey tw-mr-1"></i>
<i class="bwi bwi-passkey tw-mr-1" aria-hidden="true"></i>
{{ "logInWithPasskey" | i18n }}
</button>
</ng-container>
<!-- Button to Login with SSO -->
<button type="button" bitButton block buttonType="secondary" (click)="handleSsoClick()">
<i class="bwi bwi-provider tw-mr-1"></i>
<i class="bwi bwi-provider tw-mr-1" aria-hidden="true"></i>
{{ "useSingleSignOn" | i18n }}
</button>
</div>
@@ -96,7 +96,7 @@
buttonType="secondary"
(click)="startAuthRequestLogin()"
>
<i class="bwi bwi-mobile"></i>
<i class="bwi bwi-mobile" aria-hidden="true"></i>
{{ "loginWithDevice" | i18n }}
</button>
</ng-container>

View File

@@ -18,7 +18,6 @@ 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
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";
@@ -81,6 +80,7 @@ export class LoginComponent implements OnInit, OnDestroy {
clientType: ClientType;
ClientType = ClientType;
orgPoliciesFromInvite: PasswordPolicies | null = null;
LoginUiState = LoginUiState;
isKnownDevice = false;
loginUiState: LoginUiState = LoginUiState.EMAIL_ENTRY;
@@ -230,29 +230,22 @@ export class LoginComponent implements OnInit, OnDestroy {
return;
}
let credentials: PasswordLoginCredentials;
// 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.
this.orgPoliciesFromInvite = this.loginComponentService.getOrgPoliciesFromOrgInvite
? await this.loginComponentService.getOrgPoliciesFromOrgInvite(email)
: null;
if (
await this.configService.getFeatureFlag(FeatureFlag.PM16117_ChangeExistingPasswordRefactor)
) {
// 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 =
this.orgPoliciesFromInvite?.enforcedPasswordPolicyOptions;
const orgMasterPasswordPolicyOptions = orgPoliciesFromInvite?.enforcedPasswordPolicyOptions;
credentials = new PasswordLoginCredentials(
email,
masterPassword,
undefined,
orgMasterPasswordPolicyOptions,
);
} else {
credentials = new PasswordLoginCredentials(email, masterPassword);
}
const credentials = new PasswordLoginCredentials(
email,
masterPassword,
undefined,
orgMasterPasswordPolicyOptions,
);
try {
const authResult = await this.loginStrategyService.logIn(credentials);
@@ -332,35 +325,22 @@ 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.
// If there's an existing org invite, use it to get the org's password policies
// so we can evaluate the MP against the org policies
if (this.loginComponentService.getOrgPoliciesFromOrgInvite) {
const orgPolicies: PasswordPolicies | null =
await this.loginComponentService.getOrgPoliciesFromOrgInvite();
if (this.orgPoliciesFromInvite) {
// Since we have retrieved the policies, we can go ahead and set them into state for future use
// 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, this.orgPoliciesFromInvite.policies);
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
// doesn't fallback to pulling them from the server like it should if they are null.
await this.setPoliciesIntoState(authResult.userId, orgPolicies.policies);
const isPasswordChangeRequired = await this.isPasswordChangeRequiredByOrgPolicy(
orgPolicies.enforcedPasswordPolicyOptions,
);
if (isPasswordChangeRequired) {
const changePasswordFeatureFlagOn = await this.configService.getFeatureFlag(
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
);
await this.router.navigate(
changePasswordFeatureFlagOn ? ["change-password"] : ["update-password"],
);
return;
}
const isPasswordChangeRequired = await this.isPasswordChangeRequiredByOrgPolicy(
this.orgPoliciesFromInvite.enforcedPasswordPolicyOptions,
);
if (isPasswordChangeRequired) {
await this.router.navigate(["change-password"]);
return;
}
}

View File

@@ -10,7 +10,6 @@ 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
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";
@@ -151,25 +150,17 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
this.loginSuccessHandlerService.run(authResult.userId);
// 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 (
await this.configService.getFeatureFlag(FeatureFlag.PM16117_ChangeExistingPasswordRefactor)
forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword ||
forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset
) {
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"]);
}
await this.router.navigate(["/change-password"]);
} else {
await this.router.navigate(["/vault"]);
}

View File

@@ -28,10 +28,6 @@ export class DefaultRegistrationFinishService implements RegistrationFinishServi
return null;
}
determineLoginSuccessRoute(): Promise<string> {
return Promise.resolve("/vault");
}
async finishRegistration(
email: string,
passwordInputResult: PasswordInputResult,

View File

@@ -204,8 +204,7 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
await this.loginSuccessHandlerService.run(authenticationResult.userId);
const successRoute = await this.registrationFinishService.determineLoginSuccessRoute();
await this.router.navigate([successRoute]);
await this.router.navigate(["/vault"]);
} catch (e) {
// If login errors, redirect to login page per product. Don't show error
this.logService.error("Error logging in after registration: ", e.message);

View File

@@ -16,11 +16,6 @@ export abstract class RegistrationFinishService {
*/
abstract getMasterPasswordPolicyOptsFromOrgInvite(): Promise<MasterPasswordPolicyOptions | null>;
/**
* Returns the route the user is redirected to after a successful login.
*/
abstract determineLoginSuccessRoute(): Promise<string>;
/**
* Finishes the registration process by creating a new user account.
*

View File

@@ -1,241 +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 { 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 { 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 } 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 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(() => {
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(
encryptService,
i18nService,
kdfConfigService,
keyService,
masterPasswordApiService,
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,176 +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 { 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 { 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 {
SetPasswordCredentials,
SetPasswordJitService,
} from "./set-password-jit.service.abstraction";
export class DefaultSetPasswordJitService implements SetPasswordJitService {
constructor(
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 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

@@ -2,7 +2,7 @@ import { Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, Router, convertToParamMap } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { BehaviorSubject, of } from "rxjs";
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import {
@@ -24,8 +24,10 @@ import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
import {
InternalMasterPasswordServiceAbstraction,
MasterPasswordServiceAbstraction,
} from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@@ -66,7 +68,7 @@ describe("TwoFactorAuthComponent", () => {
let mockLoginEmailService: MockProxy<LoginEmailServiceAbstraction>;
let mockUserDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
let mockSsoLoginService: MockProxy<SsoLoginServiceAbstraction>;
let mockMasterPasswordService: FakeMasterPasswordService;
let mockMasterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
let mockAccountService: FakeAccountService;
let mockDialogService: MockProxy<DialogService>;
let mockToastService: MockProxy<ToastService>;
@@ -107,7 +109,7 @@ describe("TwoFactorAuthComponent", () => {
mockUserDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
mockSsoLoginService = mock<SsoLoginServiceAbstraction>();
mockAccountService = mockAccountServiceWith(userId);
mockMasterPasswordService = new FakeMasterPasswordService();
mockMasterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
mockDialogService = mock<DialogService>();
mockToastService = mock<ToastService>();
mockTwoFactorAuthCompService = mock<TwoFactorAuthComponentService>();
@@ -212,6 +214,7 @@ describe("TwoFactorAuthComponent", () => {
},
{ provide: AuthService, useValue: mockAuthService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: MasterPasswordServiceAbstraction, useValue: mockMasterPasswordService },
],
});
@@ -267,54 +270,16 @@ describe("TwoFactorAuthComponent", () => {
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.noMasterPassword);
});
describe("Given the PM16117_SetInitialPasswordRefactor feature flag is ON", () => {
it("navigates to the /set-initial-password route when user doesn't have a MP and key connector isn't enabled", async () => {
// Arrange
mockConfigService.getFeatureFlag.mockResolvedValue(true);
// Act
await component.submit("testToken");
// Assert
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith(["set-initial-password"], {
queryParams: {
identifier: component.orgSsoIdentifier,
},
});
});
});
describe("Given the PM16117_SetInitialPasswordRefactor feature flag is OFF", () => {
it("navigates to the /set-password route when user doesn't have a MP and key connector isn't enabled", async () => {
// Arrange
mockConfigService.getFeatureFlag.mockResolvedValue(false);
// Act
await component.submit("testToken");
// Assert
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith(["set-password"], {
queryParams: {
identifier: component.orgSsoIdentifier,
},
});
});
});
});
describe("Given the PM16117_SetInitialPasswordRefactor feature flag is ON", () => {
it("does not navigate to the /set-initial-password route when the user has key connector even if user has no master password", async () => {
it("navigates to the /set-initial-password route when user doesn't have a MP and key connector isn't enabled", async () => {
// Arrange
mockConfigService.getFeatureFlag.mockResolvedValue(true);
selectedUserDecryptionOptions.next(
mockUserDecryptionOpts.noMasterPasswordWithKeyConnector,
);
// Act
await component.submit("testToken");
await component.submit(token, remember);
expect(mockRouter.navigate).not.toHaveBeenCalledWith(["set-initial-password"], {
// Assert
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith(["set-initial-password"], {
queryParams: {
identifier: component.orgSsoIdentifier,
},
@@ -322,21 +287,19 @@ describe("TwoFactorAuthComponent", () => {
});
});
describe("Given the PM16117_SetInitialPasswordRefactor feature flag is OFF", () => {
it("does not navigate to the /set-password route when the user has key connector even if user has no master password", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
it("does not navigate to the /set-initial-password route when the user has key connector even if user has no master password", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
selectedUserDecryptionOptions.next(
mockUserDecryptionOpts.noMasterPasswordWithKeyConnector,
);
selectedUserDecryptionOptions.next(
mockUserDecryptionOpts.noMasterPasswordWithKeyConnector,
);
await component.submit(token, remember);
await component.submit(token, remember);
expect(mockRouter.navigate).not.toHaveBeenCalledWith(["set-password"], {
queryParams: {
identifier: component.orgSsoIdentifier,
},
});
expect(mockRouter.navigate).not.toHaveBeenCalledWith(["set-initial-password"], {
queryParams: {
identifier: component.orgSsoIdentifier,
},
});
});
});
@@ -344,6 +307,9 @@ describe("TwoFactorAuthComponent", () => {
it("navigates to the component's defined success route (vault is default) when the login is successful", async () => {
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult());
mockAuthService.activeAccountStatus$ = new BehaviorSubject(AuthenticationStatus.Unlocked);
mockMasterPasswordService.forceSetPasswordReason$.mockReturnValue(
of(ForceSetPasswordReason.None),
);
// Act
await component.submit("testToken");
@@ -409,7 +375,7 @@ describe("TwoFactorAuthComponent", () => {
await component.submit(token, remember);
// Assert
expect(mockMasterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith(
expect(mockMasterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
userId,
);

View File

@@ -17,7 +17,6 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import {
LoginStrategyServiceAbstraction,
LoginEmailServiceAbstraction,
UserDecryptionOptionsServiceAbstraction,
TrustedDeviceUserDecryptionOption,
UserDecryptionOptions,
@@ -32,9 +31,7 @@ import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-p
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
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";
@@ -156,7 +153,6 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
private activatedRoute: ActivatedRoute,
private logService: LogService,
private twoFactorService: TwoFactorService,
private loginEmailService: LoginEmailServiceAbstraction,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private ssoLoginService: SsoLoginServiceAbstraction,
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
@@ -171,7 +167,6 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
private loginSuccessHandlerService: LoginSuccessHandlerService,
private twoFactorAuthComponentCacheService: TwoFactorAuthComponentCacheService,
private authService: AuthService,
private configService: ConfigService,
) {}
async ngOnInit() {
@@ -507,19 +502,15 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
}
// TODO: PM-22663 use the new service to handle routing.
if (
await this.configService.getFeatureFlag(FeatureFlag.PM16117_ChangeExistingPasswordRefactor)
) {
const forceSetPasswordReason = await firstValueFrom(
this.masterPasswordService.forceSetPasswordReason$(userId),
);
const forceSetPasswordReason = await firstValueFrom(
this.masterPasswordService.forceSetPasswordReason$(userId),
);
if (
forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword ||
forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset
) {
return "change-password";
}
if (
forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword ||
forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset
) {
return "change-password";
}
return "vault";
@@ -575,11 +566,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
}
private async handleChangePasswordRequired(orgIdentifier: string | undefined) {
const isSetInitialPasswordRefactorFlagOn = await this.configService.getFeatureFlag(
FeatureFlag.PM16117_SetInitialPasswordRefactor,
);
const route = isSetInitialPasswordRefactorFlagOn ? "set-initial-password" : "set-password";
const route = "set-initial-password";
await this.router.navigate([route], {
queryParams: {
identifier: orgIdentifier,

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 { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable";
@@ -10,20 +8,20 @@ import { UserKey, MasterKey } from "@bitwarden/common/types/key";
export abstract class AuthRequestServiceAbstraction {
/** Emits an auth request id when an auth request has been approved. */
authRequestPushNotification$: Observable<string>;
abstract authRequestPushNotification$: Observable<string>;
/**
* Emits when a login has been approved by an admin. This emission is specifically for the
* purpose of notifying the consuming component to display a toast informing the user.
*/
adminLoginApproved$: Observable<void>;
abstract adminLoginApproved$: Observable<void>;
/**
* Returns an admin auth request for the given user if it exists.
* @param userId The user id.
* @throws If `userId` is not provided.
*/
abstract getAdminAuthRequest: (userId: UserId) => Promise<AdminAuthRequestStorable | null>;
abstract getAdminAuthRequest(userId: UserId): Promise<AdminAuthRequestStorable | null>;
/**
* Sets an admin auth request for the given user.
* Note: use {@link clearAdminAuthRequest} to clear the request.
@@ -31,16 +29,16 @@ export abstract class AuthRequestServiceAbstraction {
* @param userId The user id.
* @throws If `authRequest` or `userId` is not provided.
*/
abstract setAdminAuthRequest: (
abstract setAdminAuthRequest(
authRequest: AdminAuthRequestStorable,
userId: UserId,
) => Promise<void>;
): Promise<void>;
/**
* Clears an admin auth request for the given user.
* @param userId The user id.
* @throws If `userId` is not provided.
*/
abstract clearAdminAuthRequest: (userId: UserId) => Promise<void>;
abstract clearAdminAuthRequest(userId: UserId): Promise<void>;
/**
* Gets a list of standard pending auth requests for the user.
* @returns An observable of an array of auth request.
@@ -61,42 +59,42 @@ export abstract class AuthRequestServiceAbstraction {
* approval was successful.
* @throws If the auth request is missing an id or key.
*/
abstract approveOrDenyAuthRequest: (
abstract approveOrDenyAuthRequest(
approve: boolean,
authRequest: AuthRequestResponse,
) => Promise<AuthRequestResponse>;
): Promise<AuthRequestResponse>;
/**
* Sets the `UserKey` from an auth request. Auth request must have a `UserKey`.
* @param authReqResponse The auth request.
* @param authReqPrivateKey The private key corresponding to the public key sent in the auth request.
* @param userId The ID of the user for whose account we will set the key.
*/
abstract setUserKeyAfterDecryptingSharedUserKey: (
abstract setUserKeyAfterDecryptingSharedUserKey(
authReqResponse: AuthRequestResponse,
authReqPrivateKey: ArrayBuffer,
userId: UserId,
) => Promise<void>;
): Promise<void>;
/**
* Sets the `MasterKey` and `MasterKeyHash` from an auth request. Auth request must have a `MasterKey` and `MasterKeyHash`.
* @param authReqResponse The auth request.
* @param authReqPrivateKey The private key corresponding to the public key sent in the auth request.
* @param userId The ID of the user for whose account we will set the keys.
*/
abstract setKeysAfterDecryptingSharedMasterKeyAndHash: (
abstract setKeysAfterDecryptingSharedMasterKeyAndHash(
authReqResponse: AuthRequestResponse,
authReqPrivateKey: ArrayBuffer,
userId: UserId,
) => Promise<void>;
): Promise<void>;
/**
* Decrypts a `UserKey` from a public key encrypted `UserKey`.
* @param pubKeyEncryptedUserKey The public key encrypted `UserKey`.
* @param privateKey The private key corresponding to the public key used to encrypt the `UserKey`.
* @returns The decrypted `UserKey`.
*/
abstract decryptPubKeyEncryptedUserKey: (
abstract decryptPubKeyEncryptedUserKey(
pubKeyEncryptedUserKey: string,
privateKey: ArrayBuffer,
) => Promise<UserKey>;
): Promise<UserKey>;
/**
* Decrypts a `MasterKey` and `MasterKeyHash` from a public key encrypted `MasterKey` and `MasterKeyHash`.
* @param pubKeyEncryptedMasterKey The public key encrypted `MasterKey`.
@@ -104,18 +102,18 @@ export abstract class AuthRequestServiceAbstraction {
* @param privateKey The private key corresponding to the public key used to encrypt the `MasterKey` and `MasterKeyHash`.
* @returns The decrypted `MasterKey` and `MasterKeyHash`.
*/
abstract decryptPubKeyEncryptedMasterKeyAndHash: (
abstract decryptPubKeyEncryptedMasterKeyAndHash(
pubKeyEncryptedMasterKey: string,
pubKeyEncryptedMasterKeyHash: string,
privateKey: ArrayBuffer,
) => Promise<{ masterKey: MasterKey; masterKeyHash: string }>;
): Promise<{ masterKey: MasterKey; masterKeyHash: string }>;
/**
* Handles incoming auth request push notifications.
* @param notification push notification.
* @remark We should only be receiving approved push notifications to prevent enumeration.
*/
abstract sendAuthRequestPushNotification: (notification: AuthRequestPushNotification) => void;
abstract sendAuthRequestPushNotification(notification: AuthRequestPushNotification): void;
/**
* Creates a dash-delimited fingerprint for use in confirming the `AuthRequest` between the requesting and approving device.

View File

@@ -1,9 +1,7 @@
export * from "./auth-request-api.service";
export * from "./pin.service.abstraction";
export * from "./login-email.service";
export * from "./login-strategy.service";
export * from "./user-decryption-options.service.abstraction";
export * from "./auth-request.service.abstraction";
export * from "./login-approval-component.service.abstraction";
export * from "./login-success-handler.service";
export * from "./logout.service";

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 { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type";
@@ -20,60 +18,60 @@ export abstract class LoginStrategyServiceAbstraction {
* The current strategy being used to authenticate.
* Emits null if the session has timed out.
*/
currentAuthType$: Observable<AuthenticationType | null>;
abstract currentAuthType$: Observable<AuthenticationType | null>;
/**
* If the login strategy uses the email address of the user, this
* will return it. Otherwise, it will return null.
*/
getEmail: () => Promise<string | null>;
abstract getEmail(): Promise<string | null>;
/**
* If the user is logging in with a master password, this will return
* the master password hash. Otherwise, it will return null.
*/
getMasterPasswordHash: () => Promise<string | null>;
abstract getMasterPasswordHash(): Promise<string | null>;
/**
* If the user is logging in with SSO, this will return
* the email auth token. Otherwise, it will return null.
* @see {@link SsoLoginStrategyData.ssoEmail2FaSessionToken}
*/
getSsoEmail2FaSessionToken: () => Promise<string | null>;
abstract getSsoEmail2FaSessionToken(): Promise<string | null>;
/**
* Returns the access code if the user is logging in with an
* Auth Request. Otherwise, it will return null.
*/
getAccessCode: () => Promise<string | null>;
abstract getAccessCode(): Promise<string | null>;
/**
* Returns the auth request ID if the user is logging in with an
* Auth Request. Otherwise, it will return null.
*/
getAuthRequestId: () => Promise<string | null>;
abstract getAuthRequestId(): Promise<string | null>;
/**
* Sends a token request to the server using the provided credentials.
*/
logIn: (
abstract logIn(
credentials:
| UserApiLoginCredentials
| PasswordLoginCredentials
| SsoLoginCredentials
| AuthRequestLoginCredentials
| WebAuthnLoginCredentials,
) => Promise<AuthResult>;
): Promise<AuthResult>;
/**
* Sends a token request to the server with the provided two factor token.
* This uses data stored from {@link LoginStrategyServiceAbstraction.logIn}, so that must be called first.
* Returns an error if no session data is found.
*/
logInTwoFactor: (twoFactor: TokenTwoFactorRequest) => Promise<AuthResult>;
abstract logInTwoFactor(twoFactor: TokenTwoFactorRequest): Promise<AuthResult>;
/**
* Creates a master key from the provided master password and email.
*/
makePreloginKey: (masterPassword: string, email: string) => Promise<MasterKey>;
abstract makePreloginKey(masterPassword: string, email: string): Promise<MasterKey>;
/**
* Emits true if the authentication session has expired.
*/
authenticationSessionTimeout$: Observable<boolean>;
abstract get authenticationSessionTimeout$(): Observable<boolean>;
/**
* Sends a token request to the server with the provided device verification OTP.
*/
logInNewDeviceVerification: (deviceVerificationOtp: string) => Promise<AuthResult>;
abstract logInNewDeviceVerification(deviceVerificationOtp: string): Promise<AuthResult>;
}

View File

@@ -327,6 +327,7 @@ describe("LoginStrategy", () => {
const tokenResponse = identityTokenResponseFactory();
tokenResponse.privateKey = null;
keyService.makeKeyPair.mockResolvedValue(["PUBLIC_KEY", new EncString("PRIVATE_KEY")]);
keyService.getUserKey.mockResolvedValue(userKey);
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
masterPasswordService.masterKeySubject.next(masterKey);
@@ -343,6 +344,15 @@ describe("LoginStrategy", () => {
expect(apiService.postAccountKeys).toHaveBeenCalled();
});
it("throws if userKey is CoseEncrypt0 (V2 encryption) in createKeyPairForOldAccount", async () => {
keyService.getUserKey.mockResolvedValue({
inner: () => ({ type: 7 }),
} as UserKey);
await expect(passwordLoginStrategy["createKeyPairForOldAccount"](userId)).resolves.toBe(
undefined,
);
});
});
describe("Two-factor authentication", () => {

View File

@@ -31,6 +31,7 @@ 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 { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Account, AccountProfile } from "@bitwarden/common/platform/models/domain/account";
import { UserId } from "@bitwarden/common/types/guid";
import {
@@ -325,7 +326,11 @@ export abstract class LoginStrategy {
protected async createKeyPairForOldAccount(userId: UserId) {
try {
const userKey = await this.keyService.getUserKeyWithLegacySupport(userId);
const userKey = await this.keyService.getUserKey(userId);
if (userKey.inner().type == EncryptionType.CoseEncrypt0) {
throw new Error("Cannot create key pair for account on V2 encryption");
}
const [publicKey, privateKey] = await this.keyService.makeKeyPair(userKey);
if (!privateKey.encryptedString) {
throw new Error("Failed to create encrypted private key");

View File

@@ -12,7 +12,6 @@ import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/id
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
import {
@@ -221,7 +220,10 @@ describe("PasswordLoginStrategy", () => {
await passwordLoginStrategy.logIn(credentials);
expect(policyService.evaluateMasterPassword).not.toHaveBeenCalled();
expect(masterPasswordService.mock.setForceSetPasswordReason).not.toHaveBeenCalledWith(
ForceSetPasswordReason.WeakMasterPassword,
userId,
);
});
it("does not force the user to update their master password when it meets requirements", async () => {
@@ -230,7 +232,10 @@ describe("PasswordLoginStrategy", () => {
await passwordLoginStrategy.logIn(credentials);
expect(policyService.evaluateMasterPassword).toHaveBeenCalled();
expect(masterPasswordService.mock.setForceSetPasswordReason).not.toHaveBeenCalledWith(
ForceSetPasswordReason.WeakMasterPassword,
userId,
);
});
it("when given master password policies as part of the login credentials from an org invite, it combines them with the token response policies to evaluate the user's password as weak", async () => {
@@ -242,12 +247,6 @@ describe("PasswordLoginStrategy", () => {
policyService.evaluateMasterPassword.mockReturnValue(false);
tokenService.decodeAccessToken.mockResolvedValue({ sub: userId });
jest
.spyOn(configService, "getFeatureFlag")
.mockImplementation((flag: FeatureFlag) =>
Promise.resolve(flag === FeatureFlag.PM16117_ChangeExistingPasswordRefactor),
);
credentials.masterPasswordPoliciesFromOrgInvite = Object.assign(
new MasterPasswordPolicyOptions(),
{
@@ -296,9 +295,16 @@ describe("PasswordLoginStrategy", () => {
it("forces the user to update their master password on successful login when it does not meet master password policy requirements", async () => {
passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any);
policyService.evaluateMasterPassword.mockReturnValue(false);
tokenService.decodeAccessToken.mockResolvedValue({ sub: userId });
const combinedMasterPasswordPolicyOptions = Object.assign(new MasterPasswordPolicyOptions(), {
enforceOnLogin: true,
});
policyService.combineMasterPasswordPolicyOptions.mockReturnValue(
combinedMasterPasswordPolicyOptions,
);
policyService.evaluateMasterPassword.mockReturnValue(false);
await passwordLoginStrategy.logIn(credentials);
expect(policyService.evaluateMasterPassword).toHaveBeenCalled();
@@ -330,9 +336,16 @@ describe("PasswordLoginStrategy", () => {
it("forces the user to update their master password on successful 2FA login when it does not meet master password policy requirements", async () => {
passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any);
policyService.evaluateMasterPassword.mockReturnValue(false);
tokenService.decodeAccessToken.mockResolvedValue({ sub: userId });
const combinedMasterPasswordPolicyOptions = Object.assign(new MasterPasswordPolicyOptions(), {
enforceOnLogin: true,
});
policyService.combineMasterPasswordPolicyOptions.mockReturnValue(
combinedMasterPasswordPolicyOptions,
);
policyService.evaluateMasterPassword.mockReturnValue(false);
const token2FAResponse = new IdentityTwoFactorResponse({
TwoFactorProviders: ["0"],
TwoFactorProviders2: { 0: null },

View File

@@ -12,7 +12,6 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide
import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
@@ -171,35 +170,22 @@ export class PasswordLoginStrategy extends LoginStrategy {
return;
}
// The identity result can contain master password policies for the user's organizations
let masterPasswordPolicyOptions: MasterPasswordPolicyOptions | undefined;
// The identity result can contain master password policies for the user's organizations.
// Get the master password policy options from both the org invite and the identity response.
const masterPasswordPolicyOptions = this.policyService.combineMasterPasswordPolicyOptions(
credentials.masterPasswordPoliciesFromOrgInvite,
this.getMasterPasswordPolicyOptionsFromResponse(identityResponse),
);
// We deliberately do not check enforceOnLogin as existing users who are logging
// in after getting an org invite should always be forced to set a password that
// meets the org's policy. Org Invite -> Registration also works this way for
// new BW users as well.
if (
await this.configService.getFeatureFlag(FeatureFlag.PM16117_ChangeExistingPasswordRefactor)
!credentials.masterPasswordPoliciesFromOrgInvite &&
!masterPasswordPolicyOptions?.enforceOnLogin
) {
// Get the master password policy options from both the org invite and the identity response.
masterPasswordPolicyOptions = this.policyService.combineMasterPasswordPolicyOptions(
credentials.masterPasswordPoliciesFromOrgInvite,
this.getMasterPasswordPolicyOptionsFromResponse(identityResponse),
);
// We deliberately do not check enforceOnLogin as existing users who are logging
// in after getting an org invite should always be forced to set a password that
// meets the org's policy. Org Invite -> Registration also works this way for
// new BW users as well.
if (
!credentials.masterPasswordPoliciesFromOrgInvite &&
!masterPasswordPolicyOptions?.enforceOnLogin
) {
return;
}
} else {
masterPasswordPolicyOptions =
this.getMasterPasswordPolicyOptionsFromResponse(identityResponse);
if (!masterPasswordPolicyOptions?.enforceOnLogin) {
return;
}
return;
}
// If there is a policy active, evaluate the supplied password before its no longer in memory

View File

@@ -10,7 +10,6 @@ import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncryptedString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
@@ -83,6 +82,7 @@ describe("SsoLoginStrategy", () => {
const ssoCodeVerifier = "SSO_CODE_VERIFIER";
const ssoRedirectUrl = "SSO_REDIRECT_URL";
const ssoOrgId = "SSO_ORG_ID";
const privateKey = "userKeyEncryptedPrivateKey";
beforeEach(async () => {
accountService = mockAccountServiceWith(userId);
@@ -114,6 +114,9 @@ describe("SsoLoginStrategy", () => {
tokenService.decodeAccessToken.mockResolvedValue({
sub: userId,
});
keyService.userEncryptedPrivateKey$
.calledWith(userId)
.mockReturnValue(of(privateKey as EncryptedString));
const mockVaultTimeoutAction = VaultTimeoutAction.Lock;
const mockVaultTimeoutActionBSub = new BehaviorSubject<VaultTimeoutAction>(
@@ -163,6 +166,7 @@ describe("SsoLoginStrategy", () => {
it("sends SSO information to server", async () => {
apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory());
keyService.hasUserKey.mockResolvedValue(true);
await ssoLoginStrategy.logIn(credentials);
@@ -185,6 +189,7 @@ describe("SsoLoginStrategy", () => {
it("does not set keys for new SSO user flow", async () => {
const tokenResponse = identityTokenResponseFactory();
tokenResponse.key = null;
tokenResponse.privateKey = null;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
await ssoLoginStrategy.logIn(credentials);
@@ -210,42 +215,28 @@ describe("SsoLoginStrategy", () => {
);
});
describe("given the PM16117_SetInitialPasswordRefactor feature flag is ON", () => {
beforeEach(() => {
configService.getFeatureFlag.mockImplementation(async (flag) => {
if (flag === FeatureFlag.PM16117_SetInitialPasswordRefactor) {
return true;
}
return false;
});
});
describe("given the user does not have the `trustedDeviceOption`, does not have a master password, is not using key connector, does not have a user key, but they DO have a `userKeyEncryptedPrivateKey`", () => {
it("should set the forceSetPasswordReason to TdeOffboardingUntrustedDevice", async () => {
// Arrange
const mockUserDecryptionOptions: IUserDecryptionOptionsServerResponse = {
HasMasterPassword: false,
TrustedDeviceOption: null,
KeyConnectorOption: null,
};
const tokenResponse = identityTokenResponseFactory(null, mockUserDecryptionOptions);
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
describe("given the user does not have the `trustedDeviceOption`, does not have a master password, is not using key connector, does not have a user key, but they DO have a `userKeyEncryptedPrivateKey`", () => {
it("should set the forceSetPasswordReason to TdeOffboardingUntrustedDevice", async () => {
// Arrange
const mockUserDecryptionOptions: IUserDecryptionOptionsServerResponse = {
HasMasterPassword: false,
TrustedDeviceOption: null,
KeyConnectorOption: null,
};
const tokenResponse = identityTokenResponseFactory(null, mockUserDecryptionOptions);
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
keyService.hasUserKey.mockResolvedValue(false);
keyService.userEncryptedPrivateKey$.mockReturnValue(
of("userKeyEncryptedPrivateKey" as EncryptedString),
);
keyService.hasUserKey.mockResolvedValue(false);
// Act
await ssoLoginStrategy.logIn(credentials);
// Act
await ssoLoginStrategy.logIn(credentials);
// Assert
expect(masterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledTimes(1);
expect(masterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith(
ForceSetPasswordReason.TdeOffboardingUntrustedDevice,
userId,
);
});
// Assert
expect(masterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledTimes(1);
expect(masterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith(
ForceSetPasswordReason.TdeOffboardingUntrustedDevice,
userId,
);
});
});

View File

@@ -9,7 +9,6 @@ import { SsoTokenRequest } from "@bitwarden/common/auth/models/request/identity-
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { HttpStatusCode } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
@@ -344,38 +343,18 @@ export class SsoLoginStrategy extends LoginStrategy {
tokenResponse: IdentityTokenResponse,
userId: UserId,
): Promise<void> {
const isSetInitialPasswordFlagOn = await this.configService.getFeatureFlag(
FeatureFlag.PM16117_SetInitialPasswordRefactor,
);
if (isSetInitialPasswordFlagOn) {
if (tokenResponse.hasMasterKeyEncryptedUserKey()) {
// User has masterKeyEncryptedUserKey, so set the userKeyEncryptedPrivateKey
// Note: new JIT provisioned SSO users will not yet have a user asymmetric key pair
// and so we don't want them falling into the createKeyPairForOldAccount flow
await this.keyService.setPrivateKey(
tokenResponse.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
userId,
);
} else if (tokenResponse.privateKey) {
// User doesn't have masterKeyEncryptedUserKey but they do have a userKeyEncryptedPrivateKey
// This is just existing TDE users or a TDE offboarder on an untrusted device
await this.keyService.setPrivateKey(tokenResponse.privateKey, userId);
}
// else {
// User could be new JIT provisioned SSO user in either a MP encryption org OR a TDE org.
// In either case, the user doesn't yet have a user asymmetric key pair, a user key, or a master key + master key encrypted user key.
// }
} else {
// A user that does not yet have a masterKeyEncryptedUserKey set is a new SSO user
const newSsoUser = tokenResponse.key == null;
if (!newSsoUser) {
await this.keyService.setPrivateKey(
tokenResponse.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
userId,
);
}
if (tokenResponse.hasMasterKeyEncryptedUserKey()) {
// User has masterKeyEncryptedUserKey, so set the userKeyEncryptedPrivateKey
// Note: new JIT provisioned SSO users will not yet have a user asymmetric key pair
// and so we don't want them falling into the createKeyPairForOldAccount flow
await this.keyService.setPrivateKey(
tokenResponse.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
userId,
);
} else if (tokenResponse.privateKey) {
// User doesn't have masterKeyEncryptedUserKey but they do have a userKeyEncryptedPrivateKey
// This is just existing TDE users or a TDE offboarder on an untrusted device
await this.keyService.setPrivateKey(tokenResponse.privateKey, userId);
}
}
@@ -431,30 +410,25 @@ export class SsoLoginStrategy extends LoginStrategy {
// - UserDecryptionOptions.UsesKeyConnector is undefined. -- they aren't using key connector
// - UserKey is not set after successful login -- because automatic decryption is not available
// - userKeyEncryptedPrivateKey is set after successful login -- this is the key differentiator between a TDE org user logging into an untrusted device and MP encryption JIT provisioned user logging in for the first time.
const isSetInitialPasswordFlagOn = await this.configService.getFeatureFlag(
FeatureFlag.PM16117_SetInitialPasswordRefactor,
// Why is that the case? Because we set the userKeyEncryptedPrivateKey when we create the userKey, and this is serving as a proxy to tell us that the userKey has been created already (when enrolling in TDE).
const hasUserKeyEncryptedPrivateKey = await firstValueFrom(
this.keyService.userEncryptedPrivateKey$(userId),
);
const hasUserKey = await this.keyService.hasUserKey(userId);
if (isSetInitialPasswordFlagOn) {
const hasUserKeyEncryptedPrivateKey = await firstValueFrom(
this.keyService.userEncryptedPrivateKey$(userId),
// TODO: PM-23491 we should explore consolidating this logic into a flag on the server. It could be set when an org is switched from TDE to MP encryption for each org user.
if (
!userDecryptionOptions.trustedDeviceOption &&
!userDecryptionOptions.hasMasterPassword &&
!userDecryptionOptions.keyConnectorOption?.keyConnectorUrl &&
hasUserKeyEncryptedPrivateKey &&
!hasUserKey
) {
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.TdeOffboardingUntrustedDevice,
userId,
);
const hasUserKey = await this.keyService.hasUserKey(userId);
// TODO: PM-23491 we should explore consolidating this logic into a flag on the server. It could be set when an org is switched from TDE to MP encryption for each org user.
if (
!userDecryptionOptions.trustedDeviceOption &&
!userDecryptionOptions.hasMasterPassword &&
!userDecryptionOptions.keyConnectorOption?.keyConnectorUrl &&
hasUserKeyEncryptedPrivateKey &&
!hasUserKey
) {
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.TdeOffboardingUntrustedDevice,
userId,
);
return true;
}
return true;
}
// Check if user has permission to set password but hasn't yet

View File

@@ -86,9 +86,6 @@ describe("AuthRequestService", () => {
describe("approveOrDenyAuthRequest", () => {
beforeEach(() => {
encryptService.rsaEncrypt.mockResolvedValue({
encryptedString: "ENCRYPTED_STRING",
} as EncString);
encryptService.encapsulateKeyUnsigned.mockResolvedValue({
encryptedString: "ENCRYPTED_STRING",
} as EncString);

View File

@@ -1,4 +1,3 @@
export * from "./pin/pin.service.implementation";
export * from "./login-email/login-email.service";
export * from "./login-strategies/login-strategy.service";
export * from "./user-decryption-options/user-decryption-options.service";

View File

@@ -0,0 +1,5 @@
# client-type
Owned by: platform
Exports the ClientType enum

View File

@@ -0,0 +1,3 @@
import baseConfig from "../../eslint.config.mjs";
export default [...baseConfig];

View File

@@ -0,0 +1,10 @@
module.exports = {
displayName: "client-type",
preset: "../../jest.preset.js",
testEnvironment: "node",
transform: {
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
},
moduleFileExtensions: ["ts", "js", "html"],
coverageDirectory: "../../coverage/libs/client-type",
};

View File

@@ -0,0 +1,11 @@
{
"name": "@bitwarden/client-type",
"version": "0.0.1",
"description": "Exports the ClientType enum",
"private": true,
"type": "commonjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "GPL-3.0",
"author": "platform"
}

View File

@@ -0,0 +1,33 @@
{
"name": "client-type",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/client-type/src",
"projectType": "library",
"tags": [],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/client-type",
"main": "libs/client-type/src/index.ts",
"tsConfig": "libs/client-type/tsconfig.lib.json",
"assets": ["libs/client-type/*.md"]
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/client-type/**/*.ts"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/client-type/jest.config.js"
}
}
}
}

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