1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 16:53:34 +00:00

Merge branch 'main' into auth/pm-8111/browser-refresh-login-component

This commit is contained in:
rr-bw
2024-09-07 12:45:18 -07:00
194 changed files with 5670 additions and 1594 deletions

View File

@@ -101,11 +101,11 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent imp
get masterPasswordWarningText(): string {
if (this.reason == ForceSetPasswordReason.WeakMasterPassword) {
return this.i18nService.t("weakMasterPasswordWarning");
return this.i18nService.t("updateWeakMasterPasswordWarning");
} else if (this.reason == ForceSetPasswordReason.TdeOffboarding) {
return this.i18nService.t("tdeDisabledMasterPasswordRequired");
} else {
return this.i18nService.t("masterPasswordWarning");
return this.i18nService.t("updateMasterPasswordWarning");
}
}

View File

@@ -125,16 +125,12 @@ import {
} from "@bitwarden/common/autofill/services/domain-settings.service";
import {
BillingApiServiceAbstraction,
BraintreeServiceAbstraction,
OrganizationBillingServiceAbstraction,
StripeServiceAbstraction,
} from "@bitwarden/common/billing/abstractions";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service";
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
import { BraintreeService } from "@bitwarden/common/billing/services/payment-processors/braintree.service";
import { StripeService } from "@bitwarden/common/billing/services/payment-processors/stripe.service";
import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { BulkEncryptService } from "@bitwarden/common/platform/abstractions/bulk-encrypt.service";
@@ -1257,16 +1253,6 @@ const safeProviders: SafeProvider[] = [
useClass: KdfConfigService,
deps: [StateProvider],
}),
safeProvider({
provide: BraintreeServiceAbstraction,
useClass: BraintreeService,
deps: [LogService],
}),
safeProvider({
provide: StripeServiceAbstraction,
useClass: StripeService,
deps: [LogService],
}),
safeProvider({
provide: SetPasswordJitService,
useClass: DefaultSetPasswordJitService,

View File

@@ -1,6 +1,4 @@
export * from "./account/billing-account-profile-state.service";
export * from "./billing-api.service.abstraction";
export * from "./organization-billing.service";
export * from "./payment-processors/braintree.service.abstraction";
export * from "./payment-processors/stripe.service.abstraction";
export * from "./provider-billing.service.abstraction";

View File

@@ -1,28 +0,0 @@
export abstract class BraintreeServiceAbstraction {
/**
* Utilizes the Braintree SDK to create a [Braintree drop-in]{@link https://braintree.github.io/braintree-web-drop-in/docs/current/Dropin.html} instance attached to the container ID specified as part of the {@link loadBraintree} method.
*/
createDropin: () => void;
/**
* Loads the Bitwarden dropin.js script in the <head> element of the current page.
* This script attaches the Braintree SDK to the window.
* @param containerId - The ID of the HTML element where the Braintree drop-in will be loaded at.
* @param autoCreateDropin - Specifies whether the Braintree drop-in should be created when dropin.js loads.
*/
loadBraintree: (containerId: string, autoCreateDropin: boolean) => void;
/**
* Invokes the Braintree [requestPaymentMethod]{@link https://braintree.github.io/braintree-web-drop-in/docs/current/Dropin.html#requestPaymentMethod} method
* in order to generate a payment method token using the active Braintree drop-in.
*/
requestPaymentMethod: () => Promise<string>;
/**
* Removes the following elements from the <head> of the current page:
* - The Bitwarden dropin.js script
* - Any <script> elements that contain the word "paypal"
* - The Braintree drop-in stylesheet
*/
unloadBraintree: () => void;
}

View File

@@ -1,45 +0,0 @@
import { BankAccount } from "@bitwarden/common/billing/models/domain";
export abstract class StripeServiceAbstraction {
/**
* Loads [Stripe JS]{@link https://docs.stripe.com/js} in the <head> element of the current page and mounts
* Stripe credit card [elements]{@link https://docs.stripe.com/js/elements_object/create} into the HTML elements with the provided element IDS.
* We do this to avoid having to load the Stripe JS SDK on every page of the Web Vault given many pages contain sensitive information.
* @param elementIds - The ID attributes of the HTML elements used to load the Stripe JS credit card elements.
*/
loadStripe: (
elementIds: { cardNumber: string; cardExpiry: string; cardCvc: string },
autoMount: boolean,
) => void;
/**
* Re-mounts previously created Stripe credit card [elements]{@link https://docs.stripe.com/js/elements_object/create} into the HTML elements
* specified during the {@link loadStripe} call. This is useful for when those HTML elements are removed from the DOM by Angular.
*/
mountElements: () => void;
/**
* Creates a Stripe [SetupIntent]{@link https://docs.stripe.com/api/setup_intents} and uses the resulting client secret
* to invoke the Stripe JS [confirmUsBankAccountSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_us_bank_account_setup} method,
* thereby creating and storing a Stripe [PaymentMethod]{@link https://docs.stripe.com/api/payment_methods}.
* @returns The ID of the newly created PaymentMethod.
*/
setupBankAccountPaymentMethod: (
clientSecret: string,
bankAccount: BankAccount,
) => Promise<string>;
/**
* Creates a Stripe [SetupIntent]{@link https://docs.stripe.com/api/setup_intents} and uses the resulting client secret
* to invoke the Stripe JS [confirmCardSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_card_setup} method,
* thereby creating and storing a Stripe [PaymentMethod]{@link https://docs.stripe.com/api/payment_methods}.
* @returns The ID of the newly created PaymentMethod.
*/
setupCardPaymentMethod: (clientSecret: string) => Promise<string>;
/**
* Removes {@link https://docs.stripe.com/js} from the <head> element of the current page as well as all
* Stripe-managed <iframe> elements.
*/
unloadStripe: () => void;
}

View File

@@ -1,89 +0,0 @@
import { LogService } from "../../../platform/abstractions/log.service";
import { BraintreeServiceAbstraction } from "../../abstractions";
export class BraintreeService implements BraintreeServiceAbstraction {
private braintree: any;
private containerId: string;
constructor(private logService: LogService) {}
createDropin() {
window.setTimeout(() => {
const window$ = window as any;
window$.braintree.dropin.create(
{
authorization: process.env.BRAINTREE_KEY,
container: this.containerId,
paymentOptionPriority: ["paypal"],
paypal: {
flow: "vault",
buttonStyle: {
label: "pay",
size: "medium",
shape: "pill",
color: "blue",
tagline: "false",
},
},
},
(error: any, instance: any) => {
if (error != null) {
this.logService.error(error);
return;
}
this.braintree = instance;
},
);
}, 250);
}
loadBraintree(containerId: string, autoCreateDropin: boolean) {
const script = window.document.createElement("script");
script.id = "dropin-script";
script.src = `scripts/dropin.js?cache=${process.env.CACHE_TAG}`;
script.async = true;
if (autoCreateDropin) {
script.onload = () => this.createDropin();
}
this.containerId = containerId;
window.document.head.appendChild(script);
}
requestPaymentMethod(): Promise<string> {
return new Promise((resolve, reject) => {
this.braintree.requestPaymentMethod((error: any, payload: any) => {
if (error) {
this.logService.error(error);
reject(error.message);
} else {
resolve(payload.nonce as string);
}
});
});
}
unloadBraintree() {
const script = window.document.getElementById("dropin-script");
window.document.head.removeChild(script);
window.setTimeout(() => {
const scripts = Array.from(window.document.head.querySelectorAll("script")).filter(
(script) => script.src != null && script.src.indexOf("paypal") > -1,
);
scripts.forEach((script) => {
try {
window.document.head.removeChild(script);
} catch (error) {
this.logService.error(error);
}
});
const stylesheet = window.document.head.querySelector("#braintree-dropin-stylesheet");
if (stylesheet != null) {
try {
window.document.head.removeChild(stylesheet);
} catch (error) {
this.logService.error(error);
}
}
}, 500);
}
}

View File

@@ -1,142 +0,0 @@
import { LogService } from "../../../platform/abstractions/log.service";
import { StripeServiceAbstraction } from "../../abstractions";
import { BankAccount } from "../../models/domain";
export class StripeService implements StripeServiceAbstraction {
private stripe: any;
private elements: any;
private elementIds: {
cardNumber: string;
cardExpiry: string;
cardCvc: string;
};
constructor(private logService: LogService) {}
loadStripe(
elementIds: { cardNumber: string; cardExpiry: string; cardCvc: string },
autoMount: boolean,
) {
this.elementIds = elementIds;
const script = window.document.createElement("script");
script.id = "stripe-script";
script.src = "https://js.stripe.com/v3?advancedFraudSignals=false";
script.onload = () => {
const window$ = window as any;
this.stripe = window$.Stripe(process.env.STRIPE_KEY);
this.elements = this.stripe.elements();
const options = this.getElementOptions();
setTimeout(() => {
this.elements.create("cardNumber", options);
this.elements.create("cardExpiry", options);
this.elements.create("cardCvc", options);
if (autoMount) {
this.mountElements();
}
}, 50);
};
window.document.head.appendChild(script);
}
mountElements() {
setTimeout(() => {
const cardNumber = this.elements.getElement("cardNumber");
const cardExpiry = this.elements.getElement("cardExpiry");
const cardCvc = this.elements.getElement("cardCvc");
cardNumber.mount(this.elementIds.cardNumber);
cardExpiry.mount(this.elementIds.cardExpiry);
cardCvc.mount(this.elementIds.cardCvc);
});
}
async setupBankAccountPaymentMethod(
clientSecret: string,
{ accountHolderName, routingNumber, accountNumber, accountHolderType }: BankAccount,
): Promise<string> {
const result = await this.stripe.confirmUsBankAccountSetup(clientSecret, {
payment_method: {
us_bank_account: {
routing_number: routingNumber,
account_number: accountNumber,
account_holder_type: accountHolderType,
},
billing_details: {
name: accountHolderName,
},
},
});
if (result.error || (result.setupIntent && result.setupIntent.status !== "requires_action")) {
this.logService.error(result.error);
throw result.error;
}
return result.setupIntent.payment_method as string;
}
async setupCardPaymentMethod(clientSecret: string): Promise<string> {
const cardNumber = this.elements.getElement("cardNumber");
const result = await this.stripe.confirmCardSetup(clientSecret, {
payment_method: {
card: cardNumber,
},
});
if (result.error || (result.setupIntent && result.setupIntent.status !== "succeeded")) {
this.logService.error(result.error);
throw result.error;
}
return result.setupIntent.payment_method as string;
}
unloadStripe() {
const script = window.document.getElementById("stripe-script");
window.document.head.removeChild(script);
window.setTimeout(() => {
const iFrames = Array.from(window.document.querySelectorAll("iframe")).filter(
(element) => element.src != null && element.src.indexOf("stripe") > -1,
);
iFrames.forEach((iFrame) => {
try {
window.document.body.removeChild(iFrame);
} catch (error) {
this.logService.error(error);
}
});
}, 500);
}
private getElementOptions(): any {
const options: any = {
style: {
base: {
color: null,
fontFamily:
'"Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif, ' +
'"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
fontSize: "14px",
fontSmoothing: "antialiased",
"::placeholder": {
color: null,
},
},
invalid: {
color: null,
},
},
classes: {
focus: "is-focused",
empty: "is-empty",
invalid: "is-invalid",
},
};
const style = getComputedStyle(document.documentElement);
options.style.base.color = `rgb(${style.getPropertyValue("--color-text-main")})`;
options.style.base["::placeholder"].color = `rgb(${style.getPropertyValue(
"--color-text-muted",
)})`;
options.style.invalid.color = `rgb(${style.getPropertyValue("--color-text-main")})`;
options.style.invalid.borderColor = `rgb(${style.getPropertyValue("--color-danger-600")})`;
return options;
}
}

View File

@@ -30,10 +30,13 @@ export enum FeatureFlag {
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
EnableUpgradePasswordManagerSub = "AC-2708-upgrade-password-manager-sub",
GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor",
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2",
AccountDeprovisioning = "pm-10308-account-deprovisioning",
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
AC2476_DeprecateStripeSourcesAPI = "AC-2476-deprecate-stripe-sources-api",
StorageReseedRefactor = "storage-reseed-refactor",
CipherKeyEncryption = "cipher-key-encryption",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -74,10 +77,13 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
[FeatureFlag.EnableUpgradePasswordManagerSub]: FALSE,
[FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE,
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
[FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE,
[FeatureFlag.StorageReseedRefactor]: FALSE,
[FeatureFlag.AccountDeprovisioning]: FALSE,
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
[FeatureFlag.AC2476_DeprecateStripeSourcesAPI]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -2,6 +2,7 @@ import { Observable } from "rxjs";
import { SemVer } from "semver";
import { FeatureFlag, FeatureFlagValueType } from "../../../enums/feature-flag.enum";
import { UserId } from "../../../types/guid";
import { Region } from "../environment.service";
import { ServerConfig } from "./server-config";
@@ -17,6 +18,18 @@ export abstract class ConfigService {
* @returns An observable that emits the value of the feature flag, updates as the server config changes
*/
getFeatureFlag$: <Flag extends FeatureFlag>(key: Flag) => Observable<FeatureFlagValueType<Flag>>;
/**
* Retrieves the cached feature flag value for a give user. This will NOT call to the server to get
* the most up to date feature flag.
* @param key The feature flag key to get the value for.
* @param userId The user id of the user to get the feature flag value for.
*/
abstract userCachedFeatureFlag$<Flag extends FeatureFlag>(
key: Flag,
userId: UserId,
): Observable<FeatureFlagValueType<Flag>>;
/**
* Retrieves the value of a feature flag for the currently active user
* @param key The feature flag to retrieve

View File

@@ -2,7 +2,6 @@
// eslint-disable-next-line @typescript-eslint/ban-types
export type SharedFlags = {
showPasswordless?: boolean;
enableCipherKeyEncryption?: boolean;
};
// required to avoid linting errors when there are no flags

View File

@@ -1,3 +1,4 @@
import { AllowedFeatureFlagTypes } from "../../../enums/feature-flag.enum";
import { BaseResponse } from "../../../models/response/base.response";
import { Region } from "../../abstractions/environment.service";
@@ -6,7 +7,7 @@ export class ServerConfigResponse extends BaseResponse {
gitHash: string;
server: ThirdPartyServerConfigResponse;
environment: EnvironmentServerConfigResponse;
featureStates: { [key: string]: string } = {};
featureStates: { [key: string]: AllowedFeatureFlagTypes } = {};
constructor(response: any) {
super(response);

View File

@@ -16,6 +16,7 @@ import {
import { subscribeTo } from "../../../../spec/observable-tracker";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { UserId } from "../../../types/guid";
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
import { ServerConfig } from "../../abstractions/config/server-config";
@@ -277,6 +278,48 @@ describe("ConfigService", () => {
});
});
describe("userCachedFeatureFlag$", () => {
it("maps saved user config to a feature flag", async () => {
const updateFeature = (value: boolean) => {
return new ServerConfig(
new ServerConfigData({
featureStates: {
"test-feature": value,
},
}),
);
};
const configService = new DefaultConfigService(
configApiService,
environmentService,
logService,
stateProvider,
authService,
);
userState.nextState(null);
const promise = firstValueFrom(
configService
.userCachedFeatureFlag$("test-feature" as FeatureFlag, userId)
.pipe(bufferCount(3)),
);
userState.nextState(updateFeature(true));
userState.nextState(updateFeature(false));
const values = await promise;
// We wouldn't normally expect this to be undefined, the logic
// should normally return the feature flags default value but since
// we are faking a feature flag key, undefined is expected
expect(values[0]).toBe(undefined);
expect(values[1]).toBe(true);
expect(values[2]).toBe(false);
});
});
describe("slow configuration", () => {
const environmentSubject = new BehaviorSubject<Environment>(null);

View File

@@ -115,16 +115,27 @@ export class DefaultConfigService implements ConfigService {
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag) {
return this.serverConfig$.pipe(
map((serverConfig) => {
if (serverConfig?.featureStates == null || serverConfig.featureStates[key] == null) {
return DefaultFeatureFlagValue[key];
}
return serverConfig.featureStates[key] as FeatureFlagValueType<Flag>;
}),
map((serverConfig) => this.getFeatureFlagValue(serverConfig, key)),
);
}
private getFeatureFlagValue<Flag extends FeatureFlag>(
serverConfig: ServerConfig | null,
flag: Flag,
) {
if (serverConfig?.featureStates == null || serverConfig.featureStates[flag] == null) {
return DefaultFeatureFlagValue[flag];
}
return serverConfig.featureStates[flag] as FeatureFlagValueType<Flag>;
}
userCachedFeatureFlag$<Flag extends FeatureFlag>(key: Flag, userId: UserId) {
return this.stateProvider
.getUser(userId, USER_SERVER_CONFIG)
.state$.pipe(map((config) => this.getFeatureFlagValue(config, key)));
}
async getFeatureFlag<Flag extends FeatureFlag>(key: Flag) {
return await firstValueFrom(this.getFeatureFlag$(key));
}

View File

@@ -166,7 +166,7 @@ describe("Cipher Service", () => {
);
configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(false));
setEncryptionKeyFlag(false);
configService.getFeatureFlag.mockResolvedValue(false);
const spy = jest.spyOn(cipherFileUploadService, "upload");
@@ -298,16 +298,16 @@ describe("Cipher Service", () => {
});
describe("cipher.key", () => {
it("is null when enableCipherKeyEncryption flag is false", async () => {
setEncryptionKeyFlag(false);
it("is null when feature flag is false", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
const cipher = await cipherService.encrypt(cipherView, userId);
expect(cipher.key).toBeNull();
});
it("is defined when enableCipherKeyEncryption flag is true", async () => {
setEncryptionKeyFlag(true);
it("is defined when feature flag flag is true", async () => {
configService.getFeatureFlag.mockResolvedValue(true);
const cipher = await cipherService.encrypt(cipherView, userId);
@@ -320,16 +320,16 @@ describe("Cipher Service", () => {
jest.spyOn<any, string>(cipherService, "encryptCipherWithCipherKey");
});
it("is not called when enableCipherKeyEncryption is false", async () => {
setEncryptionKeyFlag(false);
it("is not called when feature flag is false", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
await cipherService.encrypt(cipherView, userId);
expect(cipherService["encryptCipherWithCipherKey"]).not.toHaveBeenCalled();
});
it("is called when enableCipherKeyEncryption is true", async () => {
setEncryptionKeyFlag(true);
it("is called when feature flag is true", async () => {
configService.getFeatureFlag.mockResolvedValue(true);
await cipherService.encrypt(cipherView, userId);
@@ -345,7 +345,7 @@ describe("Cipher Service", () => {
let encryptedKey: EncString;
beforeEach(() => {
setEncryptionKeyFlag(true);
configService.getFeatureFlag.mockResolvedValue(true);
configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true));
searchService.indexedEntityId$ = of(null);
@@ -398,9 +398,3 @@ describe("Cipher Service", () => {
});
});
});
function setEncryptionKeyFlag(value: boolean) {
process.env.FLAGS = JSON.stringify({
enableCipherKeyEncryption: value,
});
}

View File

@@ -17,7 +17,6 @@ import { CryptoService } from "../../platform/abstractions/crypto.service";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { StateService } from "../../platform/abstractions/state.service";
import { flagEnabled } from "../../platform/misc/flags";
import { sequentialize } from "../../platform/misc/sequentialize";
import { Utils } from "../../platform/misc/utils";
import Domain from "../../platform/models/domain/domain-base";
@@ -1662,11 +1661,10 @@ export class CipherService implements CipherServiceAbstraction {
}
private async getCipherKeyEncryptionEnabled(): Promise<boolean> {
return (
flagEnabled("enableCipherKeyEncryption") &&
(await firstValueFrom(
this.configService.checkServerMeetsVersionRequirement$(CIPHER_KEY_ENC_MIN_SERVER_VER),
))
const featureEnabled = await this.configService.getFeatureFlag(FeatureFlag.CipherKeyEncryption);
const meetsServerVersion = await firstValueFrom(
this.configService.checkServerMeetsVersionRequirement$(CIPHER_KEY_ENC_MIN_SERVER_VER),
);
return featureEnabled && meetsServerVersion;
}
}

View File

@@ -51,10 +51,10 @@
bitSuffix
type="button"
data-testid="toggle-password-count"
[appA11yTitle]="'toggleCharacterCount' | i18n"
[appA11yTitle]="(showPasswordCount ? 'hideCharacterCount' : 'showCharacterCount') | i18n"
[attr.aria-expanded]="showPasswordCount"
appStopClick
(click)="togglePasswordCount()"
[attr.aria-label]="'additionalContentAvailable' | i18n"
></button>
<button
*ngIf="cipher.viewPassword"