mirror of
https://github.com/bitwarden/browser
synced 2026-03-01 02:51:24 +00:00
Merge branch 'main' into km/auto-kdf
This commit is contained in:
@@ -991,7 +991,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: SignalRConnectionService,
|
||||
useClass: SignalRConnectionService,
|
||||
deps: [ApiServiceAbstraction, LogService],
|
||||
deps: [ApiServiceAbstraction, LogService, PlatformUtilsServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: WebPushConnectionService,
|
||||
@@ -1246,7 +1246,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: AnonymousHubServiceAbstraction,
|
||||
useClass: AnonymousHubService,
|
||||
deps: [EnvironmentService, AuthRequestServiceAbstraction],
|
||||
deps: [EnvironmentService, AuthRequestServiceAbstraction, PlatformUtilsServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ValidationServiceAbstraction,
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
block
|
||||
buttonType="primary"
|
||||
(click)="continuePressed()"
|
||||
[bitTooltip]="ssoRequired ? ('yourOrganizationRequiresSingleSignOn' | i18n) : ''"
|
||||
[addTooltipToDescribedby]="ssoRequired"
|
||||
[disabled]="ssoRequired"
|
||||
>
|
||||
{{ "continue" | i18n }}
|
||||
@@ -59,6 +61,8 @@
|
||||
block
|
||||
buttonType="secondary"
|
||||
(click)="handleLoginWithPasskeyClick()"
|
||||
[bitTooltip]="ssoRequired ? ('yourOrganizationRequiresSingleSignOn' | i18n) : ''"
|
||||
[addTooltipToDescribedby]="ssoRequired"
|
||||
[disabled]="ssoRequired"
|
||||
>
|
||||
<i class="bwi bwi-passkey tw-mr-1" aria-hidden="true"></i>
|
||||
@@ -67,7 +71,13 @@
|
||||
</ng-container>
|
||||
|
||||
<!-- Button to Login with SSO -->
|
||||
<button type="button" bitButton block buttonType="secondary" (click)="handleSsoClick()">
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
block
|
||||
[buttonType]="ssoRequired ? 'primary' : 'secondary'"
|
||||
(click)="handleSsoClick()"
|
||||
>
|
||||
<i class="bwi bwi-provider tw-mr-1" aria-hidden="true"></i>
|
||||
{{ "useSingleSignOn" | i18n }}
|
||||
</button>
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
IconButtonModule,
|
||||
LinkModule,
|
||||
ToastService,
|
||||
TooltipDirective,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { LoginComponentService, PasswordPolicies } from "./login-component.service";
|
||||
@@ -82,6 +83,7 @@ export enum LoginUiState {
|
||||
JslibModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
TooltipDirective,
|
||||
],
|
||||
})
|
||||
export class LoginComponent implements OnInit, OnDestroy {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { Component, inject, OnDestroy, OnInit } from "@angular/core";
|
||||
import {
|
||||
AbstractControl,
|
||||
FormBuilder,
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
EnvironmentService,
|
||||
Region,
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.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 {
|
||||
@@ -51,6 +53,25 @@ function selfHostedEnvSettingsFormValidator(): ValidatorFn {
|
||||
};
|
||||
}
|
||||
|
||||
function onlyHttpsValidator(): ValidatorFn {
|
||||
const i18nService = inject(I18nService);
|
||||
const platformUtilsService = inject(PlatformUtilsService);
|
||||
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
const url = control.value as string;
|
||||
|
||||
if (url && !url.startsWith("https://") && !platformUtilsService.isDev()) {
|
||||
return {
|
||||
onlyHttpsAllowed: {
|
||||
message: i18nService.t("selfHostedEnvMustUseHttps"),
|
||||
},
|
||||
}; // invalid
|
||||
}
|
||||
|
||||
return null; // valid
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for configuring self-hosted environment settings.
|
||||
*/
|
||||
@@ -89,12 +110,12 @@ export class SelfHostedEnvConfigDialogComponent implements OnInit, OnDestroy {
|
||||
|
||||
formGroup = this.formBuilder.group(
|
||||
{
|
||||
baseUrl: [""],
|
||||
webVaultUrl: [""],
|
||||
apiUrl: [""],
|
||||
identityUrl: [""],
|
||||
iconsUrl: [""],
|
||||
notificationsUrl: [""],
|
||||
baseUrl: ["", [onlyHttpsValidator()]],
|
||||
webVaultUrl: ["", [onlyHttpsValidator()]],
|
||||
apiUrl: ["", [onlyHttpsValidator()]],
|
||||
identityUrl: ["", [onlyHttpsValidator()]],
|
||||
iconsUrl: ["", [onlyHttpsValidator()]],
|
||||
notificationsUrl: ["", [onlyHttpsValidator()]],
|
||||
},
|
||||
{ validators: selfHostedEnvSettingsFormValidator() },
|
||||
);
|
||||
@@ -162,10 +183,11 @@ export class SelfHostedEnvConfigDialogComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
submit = async () => {
|
||||
this.formGroup.markAllAsTouched();
|
||||
this.showErrorSummary = false;
|
||||
|
||||
if (this.formGroup.invalid) {
|
||||
this.showErrorSummary = true;
|
||||
this.showErrorSummary = Boolean(this.formGroup.errors?.["atLeastOneUrlIsRequired"]);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ import { CipherShareRequest } from "../vault/models/request/cipher-share.request
|
||||
import { CipherRequest } from "../vault/models/request/cipher.request";
|
||||
import { AttachmentUploadDataResponse } from "../vault/models/response/attachment-upload-data.response";
|
||||
import { AttachmentResponse } from "../vault/models/response/attachment.response";
|
||||
import { CipherResponse } from "../vault/models/response/cipher.response";
|
||||
import { CipherMiniResponse, CipherResponse } from "../vault/models/response/cipher.response";
|
||||
import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response";
|
||||
|
||||
/**
|
||||
@@ -215,7 +215,10 @@ export abstract class ApiService {
|
||||
id: string,
|
||||
request: CipherCollectionsRequest,
|
||||
): Promise<OptionalCipherResponse>;
|
||||
abstract putCipherCollectionsAdmin(id: string, request: CipherCollectionsRequest): Promise<any>;
|
||||
abstract putCipherCollectionsAdmin(
|
||||
id: string,
|
||||
request: CipherCollectionsRequest,
|
||||
): Promise<CipherMiniResponse>;
|
||||
abstract postPurgeCiphers(
|
||||
request: SecretVerificationRequest,
|
||||
organizationId?: string,
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
NotificationResponse,
|
||||
} from "../../models/response/notification.response";
|
||||
import { EnvironmentService } from "../../platform/abstractions/environment.service";
|
||||
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
|
||||
import { InsecureUrlNotAllowedError } from "../../services/api-errors";
|
||||
import { AnonymousHubService as AnonymousHubServiceAbstraction } from "../abstractions/anonymous-hub.service";
|
||||
|
||||
export class AnonymousHubService implements AnonymousHubServiceAbstraction {
|
||||
@@ -27,10 +29,14 @@ export class AnonymousHubService implements AnonymousHubServiceAbstraction {
|
||||
constructor(
|
||||
private environmentService: EnvironmentService,
|
||||
private authRequestService: AuthRequestServiceAbstraction,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {}
|
||||
|
||||
async createHubConnection(token: string) {
|
||||
this.url = (await firstValueFrom(this.environmentService.environment$)).getNotificationsUrl();
|
||||
if (!this.url.startsWith("https://") && !this.platformUtilsService.isDev()) {
|
||||
throw new InsecureUrlNotAllowedError();
|
||||
}
|
||||
|
||||
this.anonHubConnection = new HubConnectionBuilder()
|
||||
.withUrl(this.url + "/anonymous-hub?Token=" + token, {
|
||||
|
||||
@@ -166,7 +166,7 @@ export class DefaultDomainSettingsService implements DomainSettingsService {
|
||||
if (!policy?.enabled || policy?.data == null) {
|
||||
return null;
|
||||
}
|
||||
const data = policy.data?.defaultUriMatchStrategy;
|
||||
const data = policy.data?.uriMatchDetection;
|
||||
// Validate that data is a valid UriMatchStrategy value
|
||||
return Object.values(UriMatchStrategy).includes(data) ? data : null;
|
||||
}),
|
||||
|
||||
@@ -37,6 +37,7 @@ export enum FeatureFlag {
|
||||
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
|
||||
PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption",
|
||||
WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2",
|
||||
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
|
||||
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
|
||||
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
|
||||
|
||||
@@ -124,6 +125,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
|
||||
[FeatureFlag.PM25174_DisableType0Decryption]: FALSE,
|
||||
[FeatureFlag.WindowsBiometricsV2]: FALSE,
|
||||
[FeatureFlag.LinuxBiometricsV2]: FALSE,
|
||||
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
|
||||
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@ import { Observable, Subscription } from "rxjs";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { NotificationResponse } from "../../../models/response/notification.response";
|
||||
import { InsecureUrlNotAllowedError } from "../../../services/api-errors";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { PlatformUtilsService } from "../../abstractions/platform-utils.service";
|
||||
|
||||
// 2 Minutes
|
||||
const MIN_RECONNECT_TIME = 2 * 60 * 1000;
|
||||
@@ -69,12 +71,17 @@ export class SignalRConnectionService {
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly logService: LogService,
|
||||
private readonly platformUtilsService: PlatformUtilsService,
|
||||
private readonly hubConnectionBuilderFactory: () => HubConnectionBuilder = () =>
|
||||
new HubConnectionBuilder(),
|
||||
private readonly timeoutManager: TimeoutManager = globalThis,
|
||||
) {}
|
||||
|
||||
connect$(userId: UserId, notificationsUrl: string) {
|
||||
if (!notificationsUrl.startsWith("https://") && !this.platformUtilsService.isDev()) {
|
||||
throw new InsecureUrlNotAllowedError();
|
||||
}
|
||||
|
||||
return new Observable<SignalRNotification>((subsciber) => {
|
||||
const connection = this.hubConnectionBuilderFactory()
|
||||
.withUrl(notificationsUrl + "/hub", {
|
||||
|
||||
9
libs/common/src/services/api-errors.ts
Normal file
9
libs/common/src/services/api-errors.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export class InsecureUrlNotAllowedError extends Error {
|
||||
constructor(url?: string) {
|
||||
if (url === undefined) {
|
||||
super("Insecure URL not allowed. All URLs must use HTTPS.");
|
||||
} else {
|
||||
super(`Insecure URL not allowed: ${url}. All URLs must use HTTPS.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { Environment, EnvironmentService } from "../platform/abstractions/enviro
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
|
||||
|
||||
import { InsecureUrlNotAllowedError } from "./api-errors";
|
||||
import { ApiService, HttpOperations } from "./api.service";
|
||||
|
||||
describe("ApiService", () => {
|
||||
@@ -411,4 +412,39 @@ describe("ApiService", () => {
|
||||
).rejects.toMatchObject(error);
|
||||
},
|
||||
);
|
||||
|
||||
it("throws error when trying to fetch an insecure URL", async () => {
|
||||
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "http://example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal ?? undefined,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 204,
|
||||
headers: new Headers(),
|
||||
} satisfies Partial<Response> as Response);
|
||||
});
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
await expect(
|
||||
async () => await sut.send("GET", "/something", null, true, true, null),
|
||||
).rejects.toThrow(InsecureUrlNotAllowedError);
|
||||
expect(nativeFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,6 +117,8 @@ import { AttachmentResponse } from "../vault/models/response/attachment.response
|
||||
import { CipherResponse } from "../vault/models/response/cipher.response";
|
||||
import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response";
|
||||
|
||||
import { InsecureUrlNotAllowedError } from "./api-errors";
|
||||
|
||||
export type HttpOperations = {
|
||||
createRequest: (url: string, request: RequestInit) => Request;
|
||||
};
|
||||
@@ -1310,6 +1312,10 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
if (!request.url.startsWith("https://") && !this.platformUtilsService.isDev()) {
|
||||
throw new InsecureUrlNotAllowedError();
|
||||
}
|
||||
|
||||
if (request.method === "GET") {
|
||||
request.headers.set("Cache-Control", "no-store");
|
||||
request.headers.set("Pragma", "no-cache");
|
||||
|
||||
55
libs/common/src/vault/abstractions/cipher-risk.service.ts
Normal file
55
libs/common/src/vault/abstractions/cipher-risk.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
CipherRiskResult,
|
||||
CipherRiskOptions,
|
||||
ExposedPasswordResult,
|
||||
PasswordReuseMap,
|
||||
CipherId,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
|
||||
export abstract class CipherRiskService {
|
||||
/**
|
||||
* Compute password risks for multiple ciphers.
|
||||
* Only processes Login ciphers with passwords.
|
||||
*
|
||||
* @param ciphers - The ciphers to evaluate for password risks
|
||||
* @param userId - The user ID for SDK client context
|
||||
* @param options - Optional configuration for risk computation (passwordMap, checkExposed)
|
||||
* @returns Array of CipherRisk results from SDK containing password_strength, exposed_result, and reuse_count
|
||||
*/
|
||||
abstract computeRiskForCiphers(
|
||||
ciphers: CipherView[],
|
||||
userId: UserId,
|
||||
options?: CipherRiskOptions,
|
||||
): Promise<CipherRiskResult[]>;
|
||||
|
||||
/**
|
||||
* Compute password risk for a single cipher by its ID. Will automatically build a password reuse map
|
||||
* from all the user's ciphers via the CipherService.
|
||||
* @param cipherId
|
||||
* @param userId
|
||||
* @param checkExposed - Whether to check if the password has been exposed in data breaches via HIBP
|
||||
* @returns CipherRisk result from SDK containing password_strength, exposed_result, and reuse_count
|
||||
*/
|
||||
abstract computeCipherRiskForUser(
|
||||
cipherId: CipherId,
|
||||
userId: UserId,
|
||||
checkExposed?: boolean,
|
||||
): Promise<CipherRiskResult>;
|
||||
|
||||
/**
|
||||
* Build a password reuse map for the given ciphers.
|
||||
* Maps each password to the number of times it appears across ciphers.
|
||||
* Only processes Login ciphers with passwords.
|
||||
*
|
||||
* @param ciphers - The ciphers to analyze for password reuse
|
||||
* @param userId - The user ID for SDK client context
|
||||
* @returns A map of password to count of occurrences
|
||||
*/
|
||||
abstract buildPasswordReuseMap(ciphers: CipherView[], userId: UserId): Promise<PasswordReuseMap>;
|
||||
}
|
||||
|
||||
// Re-export SDK types for convenience
|
||||
export type { CipherRiskResult, CipherRiskOptions, ExposedPasswordResult, PasswordReuseMap };
|
||||
@@ -14,6 +14,11 @@ import { SshKeyApi } from "../api/ssh-key.api";
|
||||
import { AttachmentResponse } from "./attachment.response";
|
||||
import { PasswordHistoryResponse } from "./password-history.response";
|
||||
|
||||
export type CipherMiniResponse = Omit<
|
||||
CipherResponse,
|
||||
"edit" | "viewPassword" | "folderId" | "favorite" | "permissions"
|
||||
>;
|
||||
|
||||
export class CipherResponse extends BaseResponse {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
|
||||
@@ -1117,7 +1117,13 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
async saveCollectionsWithServerAdmin(cipher: Cipher): Promise<Cipher> {
|
||||
const request = new CipherCollectionsRequest(cipher.collectionIds);
|
||||
const response = await this.apiService.putCipherCollectionsAdmin(cipher.id, request);
|
||||
const data = new CipherData(response);
|
||||
// The response will be incomplete with several properties missing values
|
||||
// We will assign those properties values so the SDK decryption can complete
|
||||
const completedResponse = new CipherResponse(response);
|
||||
completedResponse.edit = true;
|
||||
completedResponse.viewPassword = true;
|
||||
completedResponse.favorite = false;
|
||||
const data = new CipherData(completedResponse);
|
||||
return new Cipher(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import type { CipherRiskOptions, CipherId, CipherRiskResult } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { asUuid } from "../../platform/abstractions/sdk/sdk.service";
|
||||
import { MockSdkService } from "../../platform/spec/mock-sdk.service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { CipherService } from "../abstractions/cipher.service";
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
import { LoginView } from "../models/view/login.view";
|
||||
|
||||
import { DefaultCipherRiskService } from "./default-cipher-risk.service";
|
||||
|
||||
describe("DefaultCipherRiskService", () => {
|
||||
let cipherRiskService: DefaultCipherRiskService;
|
||||
let sdkService: MockSdkService;
|
||||
let mockCipherService: jest.Mocked<CipherService>;
|
||||
|
||||
const mockUserId = "test-user-id" as UserId;
|
||||
const mockCipherId1 = "cbea34a8-bde4-46ad-9d19-b05001228ab2";
|
||||
const mockCipherId2 = "cbea34a8-bde4-46ad-9d19-b05001228ab3";
|
||||
const mockCipherId3 = "cbea34a8-bde4-46ad-9d19-b05001228ab4";
|
||||
|
||||
beforeEach(() => {
|
||||
sdkService = new MockSdkService();
|
||||
mockCipherService = mock<CipherService>();
|
||||
cipherRiskService = new DefaultCipherRiskService(sdkService, mockCipherService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("computeRiskForCiphers", () => {
|
||||
it("should call SDK cipher_risk().compute_risk() with correct parameters", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const mockRiskResults: CipherRiskResult[] = [
|
||||
{
|
||||
id: mockCipherId1 as any,
|
||||
password_strength: 3,
|
||||
exposed_result: { type: "NotChecked" },
|
||||
reuse_count: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue(mockRiskResults);
|
||||
|
||||
const cipher = new CipherView();
|
||||
cipher.id = mockCipherId1;
|
||||
cipher.type = CipherType.Login;
|
||||
cipher.login = new LoginView();
|
||||
cipher.login.password = "test-password";
|
||||
cipher.login.username = "test@example.com";
|
||||
|
||||
const options: CipherRiskOptions = {
|
||||
checkExposed: true,
|
||||
passwordMap: undefined,
|
||||
hibpBaseUrl: undefined,
|
||||
};
|
||||
|
||||
const results = await cipherRiskService.computeRiskForCiphers([cipher], mockUserId, options);
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
id: expect.anything(),
|
||||
password: "test-password",
|
||||
username: "test@example.com",
|
||||
},
|
||||
],
|
||||
options,
|
||||
);
|
||||
expect(results).toEqual(mockRiskResults);
|
||||
});
|
||||
|
||||
it("should filter out non-Login ciphers", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([]);
|
||||
|
||||
const loginCipher = new CipherView();
|
||||
loginCipher.id = mockCipherId1;
|
||||
loginCipher.type = CipherType.Login;
|
||||
loginCipher.login = new LoginView();
|
||||
loginCipher.login.password = "password1";
|
||||
|
||||
const cardCipher = new CipherView();
|
||||
cardCipher.id = mockCipherId2;
|
||||
cardCipher.type = CipherType.Card;
|
||||
|
||||
const identityCipher = new CipherView();
|
||||
identityCipher.id = mockCipherId3;
|
||||
identityCipher.type = CipherType.Identity;
|
||||
|
||||
await cipherRiskService.computeRiskForCiphers(
|
||||
[loginCipher, cardCipher, identityCipher],
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
id: expect.anything(),
|
||||
password: "password1",
|
||||
}),
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter out Login ciphers without passwords", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([]);
|
||||
|
||||
const cipherWithPassword = new CipherView();
|
||||
cipherWithPassword.id = mockCipherId1;
|
||||
cipherWithPassword.type = CipherType.Login;
|
||||
cipherWithPassword.login = new LoginView();
|
||||
cipherWithPassword.login.password = "password1";
|
||||
|
||||
const cipherWithoutPassword = new CipherView();
|
||||
cipherWithoutPassword.id = mockCipherId2;
|
||||
cipherWithoutPassword.type = CipherType.Login;
|
||||
cipherWithoutPassword.login = new LoginView();
|
||||
cipherWithoutPassword.login.password = undefined;
|
||||
|
||||
const cipherWithEmptyPassword = new CipherView();
|
||||
cipherWithEmptyPassword.id = mockCipherId3;
|
||||
cipherWithEmptyPassword.type = CipherType.Login;
|
||||
cipherWithEmptyPassword.login = new LoginView();
|
||||
cipherWithEmptyPassword.login.password = "";
|
||||
|
||||
await cipherRiskService.computeRiskForCiphers(
|
||||
[cipherWithPassword, cipherWithoutPassword, cipherWithEmptyPassword],
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
password: "password1",
|
||||
}),
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("should return empty array when no valid Login ciphers provided", async () => {
|
||||
const cardCipher = new CipherView();
|
||||
cardCipher.type = CipherType.Card;
|
||||
|
||||
const results = await cipherRiskService.computeRiskForCiphers([cardCipher], mockUserId);
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle multiple Login ciphers", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const mockRiskResults: CipherRiskResult[] = [
|
||||
{
|
||||
id: mockCipherId1 as any,
|
||||
password_strength: 3,
|
||||
exposed_result: { type: "Found", value: 5 },
|
||||
reuse_count: 2,
|
||||
},
|
||||
{
|
||||
id: mockCipherId2 as any,
|
||||
password_strength: 4,
|
||||
exposed_result: { type: "NotChecked" },
|
||||
reuse_count: 1,
|
||||
},
|
||||
];
|
||||
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue(mockRiskResults);
|
||||
|
||||
const cipher1 = new CipherView();
|
||||
cipher1.id = mockCipherId1;
|
||||
cipher1.type = CipherType.Login;
|
||||
cipher1.login = new LoginView();
|
||||
cipher1.login.password = "password1";
|
||||
cipher1.login.username = "user1@example.com";
|
||||
|
||||
const cipher2 = new CipherView();
|
||||
cipher2.id = mockCipherId2;
|
||||
cipher2.type = CipherType.Login;
|
||||
cipher2.login = new LoginView();
|
||||
cipher2.login.password = "password2";
|
||||
cipher2.login.username = "user2@example.com";
|
||||
|
||||
const results = await cipherRiskService.computeRiskForCiphers([cipher1, cipher2], mockUserId);
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({ password: "password1", username: "user1@example.com" }),
|
||||
expect.objectContaining({ password: "password2", username: "user2@example.com" }),
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(results).toEqual(mockRiskResults);
|
||||
});
|
||||
|
||||
it("should use default options when options not provided", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([]);
|
||||
|
||||
const cipher = new CipherView();
|
||||
cipher.id = mockCipherId1;
|
||||
cipher.type = CipherType.Login;
|
||||
cipher.login = new LoginView();
|
||||
cipher.login.password = "test-password";
|
||||
|
||||
await cipherRiskService.computeRiskForCiphers([cipher], mockUserId);
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(expect.any(Array), {
|
||||
checkExposed: false,
|
||||
passwordMap: undefined,
|
||||
hibpBaseUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle ciphers without username", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([]);
|
||||
|
||||
const cipher = new CipherView();
|
||||
cipher.id = mockCipherId1;
|
||||
cipher.type = CipherType.Login;
|
||||
cipher.login = new LoginView();
|
||||
cipher.login.password = "test-password";
|
||||
cipher.login.username = undefined;
|
||||
|
||||
await cipherRiskService.computeRiskForCiphers([cipher], mockUserId);
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
password: "test-password",
|
||||
username: undefined,
|
||||
}),
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPasswordReuseMap", () => {
|
||||
it("should call SDK cipher_risk().password_reuse_map() with correct parameters", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const mockReuseMap = {
|
||||
password1: 2,
|
||||
password2: 1,
|
||||
};
|
||||
|
||||
mockCipherRiskClient.password_reuse_map.mockReturnValue(mockReuseMap);
|
||||
|
||||
const cipher1 = new CipherView();
|
||||
cipher1.id = mockCipherId1;
|
||||
cipher1.type = CipherType.Login;
|
||||
cipher1.login = new LoginView();
|
||||
cipher1.login.password = "password1";
|
||||
|
||||
const cipher2 = new CipherView();
|
||||
cipher2.id = mockCipherId2;
|
||||
cipher2.type = CipherType.Login;
|
||||
cipher2.login = new LoginView();
|
||||
cipher2.login.password = "password2";
|
||||
|
||||
const result = await cipherRiskService.buildPasswordReuseMap([cipher1, cipher2], mockUserId);
|
||||
|
||||
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ password: "password1" }),
|
||||
expect.objectContaining({ password: "password2" }),
|
||||
]);
|
||||
expect(result).toEqual(mockReuseMap);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeCipherRiskForUser", () => {
|
||||
it("should compute risk for a single cipher with password reuse map", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
// Setup cipher data
|
||||
const cipher1 = new CipherView();
|
||||
cipher1.id = mockCipherId1;
|
||||
cipher1.type = CipherType.Login;
|
||||
cipher1.login = new LoginView();
|
||||
cipher1.login.password = "password1";
|
||||
cipher1.login.username = "user1@example.com";
|
||||
|
||||
const cipher2 = new CipherView();
|
||||
cipher2.id = mockCipherId2;
|
||||
cipher2.type = CipherType.Login;
|
||||
cipher2.login = new LoginView();
|
||||
cipher2.login.password = "password1"; // Same password as cipher1
|
||||
cipher2.login.username = "user2@example.com";
|
||||
|
||||
const allCiphers = [cipher1, cipher2];
|
||||
|
||||
// Mock cipherViews$ observable
|
||||
mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject(allCiphers));
|
||||
|
||||
// Mock password reuse map
|
||||
const mockReuseMap = { password1: 2 };
|
||||
mockCipherRiskClient.password_reuse_map.mockReturnValue(mockReuseMap);
|
||||
|
||||
// Mock compute_risk result
|
||||
const mockRiskResult: CipherRiskResult = {
|
||||
id: mockCipherId1 as any,
|
||||
password_strength: 3,
|
||||
exposed_result: { type: "NotChecked" },
|
||||
reuse_count: 2,
|
||||
};
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([mockRiskResult]);
|
||||
|
||||
const result = await cipherRiskService.computeCipherRiskForUser(
|
||||
asUuid<CipherId>(mockCipherId1),
|
||||
mockUserId,
|
||||
true,
|
||||
);
|
||||
|
||||
// Verify cipherViews$ was called
|
||||
expect(mockCipherService.cipherViews$).toHaveBeenCalledWith(mockUserId);
|
||||
|
||||
// Verify password_reuse_map was called with all ciphers
|
||||
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ password: "password1", username: "user1@example.com" }),
|
||||
expect.objectContaining({ password: "password1", username: "user2@example.com" }),
|
||||
]);
|
||||
|
||||
// Verify compute_risk was called with target cipher and password map
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ password: "password1", username: "user1@example.com" })],
|
||||
{
|
||||
passwordMap: mockReuseMap,
|
||||
checkExposed: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockRiskResult);
|
||||
});
|
||||
|
||||
it("should throw error when cipher is not found", async () => {
|
||||
const cipher1 = new CipherView();
|
||||
cipher1.id = mockCipherId1;
|
||||
cipher1.type = CipherType.Login;
|
||||
cipher1.login = new LoginView();
|
||||
cipher1.login.password = "password1";
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject([cipher1]));
|
||||
|
||||
const nonExistentId = "00000000-0000-0000-0000-000000000000";
|
||||
await expect(
|
||||
cipherRiskService.computeCipherRiskForUser(asUuid<CipherId>(nonExistentId), mockUserId),
|
||||
).rejects.toThrow(`Cipher with id ${asUuid<CipherId>(nonExistentId)} not found`);
|
||||
});
|
||||
|
||||
it("should use checkExposed parameter correctly", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const cipher = new CipherView();
|
||||
cipher.id = mockCipherId1;
|
||||
cipher.type = CipherType.Login;
|
||||
cipher.login = new LoginView();
|
||||
cipher.login.password = "password1";
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject([cipher]));
|
||||
mockCipherRiskClient.password_reuse_map.mockReturnValue({});
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([
|
||||
{
|
||||
id: mockCipherId1 as any,
|
||||
password_strength: 4,
|
||||
exposed_result: { type: "NotChecked" },
|
||||
reuse_count: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
await cipherRiskService.computeCipherRiskForUser(
|
||||
asUuid<CipherId>(mockCipherId1),
|
||||
mockUserId,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(expect.any(Array), {
|
||||
passwordMap: expect.any(Object),
|
||||
checkExposed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should default checkExposed to true when not provided", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const cipher = new CipherView();
|
||||
cipher.id = mockCipherId1;
|
||||
cipher.type = CipherType.Login;
|
||||
cipher.login = new LoginView();
|
||||
cipher.login.password = "password1";
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject([cipher]));
|
||||
mockCipherRiskClient.password_reuse_map.mockReturnValue({});
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([
|
||||
{
|
||||
id: mockCipherId1 as any,
|
||||
password_strength: 4,
|
||||
exposed_result: { type: "Found", value: 10 },
|
||||
reuse_count: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
await cipherRiskService.computeCipherRiskForUser(asUuid<CipherId>(mockCipherId1), mockUserId);
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(expect.any(Array), {
|
||||
passwordMap: expect.any(Object),
|
||||
checkExposed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle ciphers without passwords when building password map", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const cipherWithPassword = new CipherView();
|
||||
cipherWithPassword.id = mockCipherId1;
|
||||
cipherWithPassword.type = CipherType.Login;
|
||||
cipherWithPassword.login = new LoginView();
|
||||
cipherWithPassword.login.password = "password1";
|
||||
|
||||
const cipherWithoutPassword = new CipherView();
|
||||
cipherWithoutPassword.id = mockCipherId2;
|
||||
cipherWithoutPassword.type = CipherType.Login;
|
||||
cipherWithoutPassword.login = new LoginView();
|
||||
cipherWithoutPassword.login.password = "";
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(
|
||||
new BehaviorSubject([cipherWithPassword, cipherWithoutPassword]),
|
||||
);
|
||||
mockCipherRiskClient.password_reuse_map.mockReturnValue({});
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([
|
||||
{
|
||||
id: mockCipherId1 as any,
|
||||
password_strength: 4,
|
||||
exposed_result: { type: "NotChecked" },
|
||||
reuse_count: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
await cipherRiskService.computeCipherRiskForUser(asUuid<CipherId>(mockCipherId1), mockUserId);
|
||||
|
||||
// Verify password_reuse_map only received cipher with password
|
||||
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ password: "password1" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle non-Login ciphers in vault when building password map", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const loginCipher = new CipherView();
|
||||
loginCipher.id = mockCipherId1;
|
||||
loginCipher.type = CipherType.Login;
|
||||
loginCipher.login = new LoginView();
|
||||
loginCipher.login.password = "password1";
|
||||
|
||||
const cardCipher = new CipherView();
|
||||
cardCipher.id = mockCipherId2;
|
||||
cardCipher.type = CipherType.Card;
|
||||
|
||||
const noteCipher = new CipherView();
|
||||
noteCipher.id = mockCipherId3;
|
||||
noteCipher.type = CipherType.SecureNote;
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(
|
||||
new BehaviorSubject([loginCipher, cardCipher, noteCipher]),
|
||||
);
|
||||
mockCipherRiskClient.password_reuse_map.mockReturnValue({});
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([
|
||||
{
|
||||
id: mockCipherId1 as any,
|
||||
password_strength: 4,
|
||||
exposed_result: { type: "NotChecked" },
|
||||
reuse_count: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
await cipherRiskService.computeCipherRiskForUser(asUuid<CipherId>(mockCipherId1), mockUserId);
|
||||
|
||||
// Verify password_reuse_map only received Login cipher
|
||||
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ password: "password1" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should compute fresh password map on each call", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const cipher = new CipherView();
|
||||
cipher.id = mockCipherId1;
|
||||
cipher.type = CipherType.Login;
|
||||
cipher.login = new LoginView();
|
||||
cipher.login.password = "password1";
|
||||
|
||||
mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject([cipher]));
|
||||
mockCipherRiskClient.password_reuse_map.mockReturnValue({ password1: 1 });
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([
|
||||
{
|
||||
id: mockCipherId1 as any,
|
||||
password_strength: 4,
|
||||
exposed_result: { type: "NotChecked" },
|
||||
reuse_count: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
// First call
|
||||
await cipherRiskService.computeCipherRiskForUser(asUuid<CipherId>(mockCipherId1), mockUserId);
|
||||
|
||||
// Second call
|
||||
await cipherRiskService.computeCipherRiskForUser(asUuid<CipherId>(mockCipherId1), mockUserId);
|
||||
|
||||
// Verify password_reuse_map was called twice (fresh computation each time)
|
||||
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
115
libs/common/src/vault/services/default-cipher-risk.service.ts
Normal file
115
libs/common/src/vault/services/default-cipher-risk.service.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { firstValueFrom, switchMap } from "rxjs";
|
||||
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import {
|
||||
CipherLoginDetails,
|
||||
CipherRiskOptions,
|
||||
PasswordReuseMap,
|
||||
CipherId,
|
||||
CipherRiskResult,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { SdkService, asUuid } from "../../platform/abstractions/sdk/sdk.service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { CipherRiskService as CipherRiskServiceAbstraction } from "../abstractions/cipher-risk.service";
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
|
||||
export class DefaultCipherRiskService implements CipherRiskServiceAbstraction {
|
||||
constructor(
|
||||
private sdkService: SdkService,
|
||||
private cipherService: CipherService,
|
||||
) {}
|
||||
|
||||
async computeRiskForCiphers(
|
||||
ciphers: CipherView[],
|
||||
userId: UserId,
|
||||
options?: CipherRiskOptions,
|
||||
): Promise<CipherRiskResult[]> {
|
||||
const loginDetails = this.mapToLoginDetails(ciphers);
|
||||
|
||||
if (loginDetails.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
using ref = sdk.take();
|
||||
const cipherRiskClient = ref.value.vault().cipher_risk();
|
||||
return await cipherRiskClient.compute_risk(
|
||||
loginDetails,
|
||||
options ?? { checkExposed: false },
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async computeCipherRiskForUser(
|
||||
cipherId: CipherId,
|
||||
userId: UserId,
|
||||
checkExposed: boolean = true,
|
||||
): Promise<CipherRiskResult> {
|
||||
// Get all ciphers for the user
|
||||
const allCiphers = await firstValueFrom(this.cipherService.cipherViews$(userId));
|
||||
|
||||
// Find the specific cipher
|
||||
const targetCipher = allCiphers?.find((c) => asUuid<CipherId>(c.id) === cipherId);
|
||||
if (!targetCipher) {
|
||||
throw new Error(`Cipher with id ${cipherId} not found`);
|
||||
}
|
||||
|
||||
// Build fresh password reuse map from all ciphers
|
||||
const passwordMap = await this.buildPasswordReuseMap(allCiphers, userId);
|
||||
|
||||
// Call existing computeRiskForCiphers with single cipher and map
|
||||
const results = await this.computeRiskForCiphers([targetCipher], userId, {
|
||||
passwordMap,
|
||||
checkExposed,
|
||||
});
|
||||
|
||||
return results[0];
|
||||
}
|
||||
|
||||
async buildPasswordReuseMap(ciphers: CipherView[], userId: UserId): Promise<PasswordReuseMap> {
|
||||
const loginDetails = this.mapToLoginDetails(ciphers);
|
||||
|
||||
if (loginDetails.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
using ref = sdk.take();
|
||||
const cipherRiskClient = ref.value.vault().cipher_risk();
|
||||
return cipherRiskClient.password_reuse_map(loginDetails);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps CipherView array to CipherLoginDetails array for SDK consumption.
|
||||
* Only includes Login ciphers with non-empty passwords.
|
||||
*/
|
||||
private mapToLoginDetails(ciphers: CipherView[]): CipherLoginDetails[] {
|
||||
return ciphers
|
||||
.filter((cipher) => {
|
||||
return (
|
||||
cipher.type === CipherType.Login &&
|
||||
cipher.login?.password != null &&
|
||||
cipher.login.password !== ""
|
||||
);
|
||||
})
|
||||
.map(
|
||||
(cipher) =>
|
||||
({
|
||||
id: asUuid<CipherId>(cipher.id),
|
||||
password: cipher.login.password!,
|
||||
username: cipher.login.username,
|
||||
}) satisfies CipherLoginDetails,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export * from "./table";
|
||||
export * from "./tabs";
|
||||
export * from "./toast";
|
||||
export * from "./toggle-group";
|
||||
export * from "./tooltip";
|
||||
export * from "./typography";
|
||||
export * from "./utils";
|
||||
export * from "./stepper";
|
||||
|
||||
@@ -6,12 +6,15 @@ import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
export type FolderRelationship = [cipherIndex: number, folderIndex: number];
|
||||
export type CollectionRelationship = [cipherIndex: number, collectionIndex: number];
|
||||
|
||||
export class ImportResult {
|
||||
success = false;
|
||||
errorMessage: string;
|
||||
ciphers: CipherView[] = [];
|
||||
folders: FolderView[] = [];
|
||||
folderRelationships: [number, number][] = [];
|
||||
folderRelationships: FolderRelationship[] = [];
|
||||
collections: CollectionView[] = [];
|
||||
collectionRelationships: [number, number][] = [];
|
||||
collectionRelationships: CollectionRelationship[] = [];
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
CollectionService,
|
||||
CollectionTypes,
|
||||
CollectionView,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||
@@ -194,7 +198,7 @@ describe("ImportService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passing importTarget as null on setImportTarget with organizationId throws error", async () => {
|
||||
it("passing importTarget as undefined on setImportTarget with organizationId throws error", async () => {
|
||||
const setImportTargetMethod = importService["setImportTarget"](
|
||||
null,
|
||||
organizationId,
|
||||
@@ -204,10 +208,10 @@ describe("ImportService", () => {
|
||||
await expect(setImportTargetMethod).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("passing importTarget as null on setImportTarget throws error", async () => {
|
||||
it("passing importTarget as undefined on setImportTarget throws error", async () => {
|
||||
const setImportTargetMethod = importService["setImportTarget"](
|
||||
null,
|
||||
"",
|
||||
undefined,
|
||||
new Object() as CollectionView,
|
||||
);
|
||||
|
||||
@@ -239,11 +243,40 @@ describe("ImportService", () => {
|
||||
importResult.ciphers.push(createCipher({ name: "cipher2" }));
|
||||
importResult.folderRelationships.push([0, 0]);
|
||||
|
||||
await importService["setImportTarget"](importResult, "", mockImportTargetFolder);
|
||||
await importService["setImportTarget"](importResult, undefined, mockImportTargetFolder);
|
||||
expect(importResult.folderRelationships.length).toEqual(2);
|
||||
expect(importResult.folderRelationships[0]).toEqual([1, 0]);
|
||||
expect(importResult.folderRelationships[1]).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("If importTarget is of type DefaultUserCollection sets it as new root for all ciphers as nesting is not supported", async () => {
|
||||
importResult.collections.push(mockCollection1);
|
||||
importResult.collections.push(mockCollection2);
|
||||
importResult.ciphers.push(createCipher({ name: "cipher1" }));
|
||||
importResult.ciphers.push(createCipher({ name: "cipher2" }));
|
||||
importResult.ciphers.push(createCipher({ name: "cipher3" }));
|
||||
|
||||
importResult.collectionRelationships.push([0, 0]);
|
||||
importResult.collectionRelationships.push([1, 1]);
|
||||
importResult.collectionRelationships.push([2, 0]);
|
||||
|
||||
mockImportTargetCollection.type = CollectionTypes.DefaultUserCollection;
|
||||
await importService["setImportTarget"](
|
||||
importResult,
|
||||
organizationId,
|
||||
mockImportTargetCollection,
|
||||
);
|
||||
expect(importResult.collections.length).toBe(1);
|
||||
expect(importResult.collections[0]).toBe(mockImportTargetCollection);
|
||||
|
||||
expect(importResult.collectionRelationships.length).toEqual(3);
|
||||
expect(importResult.collectionRelationships[0]).toEqual([0, 0]);
|
||||
expect(importResult.collectionRelationships[1]).toEqual([1, 0]);
|
||||
expect(importResult.collectionRelationships[2]).toEqual([2, 0]);
|
||||
|
||||
expect(importResult.collectionRelationships.map((r) => r[0])).toEqual([0, 1, 2]);
|
||||
expect(importResult.collectionRelationships.every((r) => r[1] === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CollectionService,
|
||||
CollectionWithIdRequest,
|
||||
CollectionView,
|
||||
CollectionTypes,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
@@ -101,7 +102,7 @@ import {
|
||||
ImportType,
|
||||
regularImportOptions,
|
||||
} from "../models/import-options";
|
||||
import { ImportResult } from "../models/import-result";
|
||||
import { CollectionRelationship, FolderRelationship, ImportResult } from "../models/import-result";
|
||||
import { ImportApiServiceAbstraction } from "../services/import-api.service.abstraction";
|
||||
import { ImportServiceAbstraction } from "../services/import.service.abstraction";
|
||||
|
||||
@@ -473,19 +474,20 @@ export class ImportService implements ImportServiceAbstraction {
|
||||
|
||||
private async setImportTarget(
|
||||
importResult: ImportResult,
|
||||
organizationId: string,
|
||||
organizationId: OrganizationId | undefined,
|
||||
importTarget: FolderView | CollectionView,
|
||||
) {
|
||||
if (!importTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Importing into an organization
|
||||
if (organizationId) {
|
||||
if (!(importTarget instanceof CollectionView)) {
|
||||
throw new Error(this.i18nService.t("errorAssigningTargetCollection"));
|
||||
}
|
||||
|
||||
const noCollectionRelationShips: [number, number][] = [];
|
||||
const noCollectionRelationShips: CollectionRelationship[] = [];
|
||||
importResult.ciphers.forEach((c, index) => {
|
||||
if (
|
||||
!Array.isArray(importResult.collectionRelationships) ||
|
||||
@@ -495,15 +497,28 @@ export class ImportService implements ImportServiceAbstraction {
|
||||
}
|
||||
});
|
||||
|
||||
const collections: CollectionView[] = [...importResult.collections];
|
||||
importResult.collections = [importTarget as CollectionView];
|
||||
// My Items collections do not support collection nesting.
|
||||
// Flatten all ciphers from nested collections into the import target.
|
||||
if (importTarget.type === CollectionTypes.DefaultUserCollection) {
|
||||
importResult.collections = [importTarget];
|
||||
|
||||
const flattenRelationships: CollectionRelationship[] = [];
|
||||
importResult.ciphers.forEach((c, index) => {
|
||||
flattenRelationships.push([index, 0]);
|
||||
});
|
||||
importResult.collectionRelationships = flattenRelationships;
|
||||
return;
|
||||
}
|
||||
|
||||
const collections = [...importResult.collections];
|
||||
importResult.collections = [importTarget];
|
||||
collections.map((x) => {
|
||||
const f = new CollectionView(x);
|
||||
f.name = `${importTarget.name}/${x.name}`;
|
||||
importResult.collections.push(f);
|
||||
});
|
||||
|
||||
const relationships: [number, number][] = [...importResult.collectionRelationships];
|
||||
const relationships = [...importResult.collectionRelationships];
|
||||
importResult.collectionRelationships = [...noCollectionRelationShips];
|
||||
relationships.map((x) => {
|
||||
importResult.collectionRelationships.push([x[0], x[1] + 1]);
|
||||
@@ -512,11 +527,12 @@ export class ImportService implements ImportServiceAbstraction {
|
||||
return;
|
||||
}
|
||||
|
||||
// Importing into personal vault
|
||||
if (!(importTarget instanceof FolderView)) {
|
||||
throw new Error(this.i18nService.t("errorAssigningTargetFolder"));
|
||||
}
|
||||
|
||||
const noFolderRelationShips: [number, number][] = [];
|
||||
const noFolderRelationShips: FolderRelationship[] = [];
|
||||
importResult.ciphers.forEach((c, index) => {
|
||||
if (Utils.isNullOrEmpty(c.folderId)) {
|
||||
c.folderId = importTarget.id;
|
||||
@@ -524,8 +540,8 @@ export class ImportService implements ImportServiceAbstraction {
|
||||
}
|
||||
});
|
||||
|
||||
const folders: FolderView[] = [...importResult.folders];
|
||||
importResult.folders = [importTarget as FolderView];
|
||||
const folders = [...importResult.folders];
|
||||
importResult.folders = [importTarget];
|
||||
folders.map((x) => {
|
||||
const newFolderName = `${importTarget.name}/${x.name}`;
|
||||
const f = new FolderView();
|
||||
@@ -533,7 +549,7 @@ export class ImportService implements ImportServiceAbstraction {
|
||||
importResult.folders.push(f);
|
||||
});
|
||||
|
||||
const relationships: [number, number][] = [...importResult.folderRelationships];
|
||||
const relationships = [...importResult.folderRelationships];
|
||||
importResult.folderRelationships = [...noFolderRelationShips];
|
||||
relationships.map((x) => {
|
||||
importResult.folderRelationships.push([x[0], x[1] + 1]);
|
||||
|
||||
Reference in New Issue
Block a user