1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 00:33:44 +00:00

[PM-5189] Incorporating work done for inline menu field qualification

This commit is contained in:
Cesar Gonzalez
2024-06-17 06:18:18 -05:00
334 changed files with 6302 additions and 4270 deletions

View File

@@ -17,9 +17,12 @@ import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response";
import {
MasterPasswordVerification,
MasterPasswordVerificationResponse,
} from "@bitwarden/common/auth/types/verification";
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@@ -29,7 +32,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
import { HashPurpose, KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
@@ -45,7 +48,7 @@ export class LockComponent implements OnInit, OnDestroy {
pinEnabled = false;
masterPasswordEnabled = false;
webVaultHostname = "";
formPromise: Promise<MasterPasswordPolicyResponse>;
formPromise: Promise<MasterPasswordVerificationResponse>;
supportsBiometric: boolean;
biometricLock: boolean;
@@ -218,51 +221,30 @@ export class LockComponent implements OnInit, OnDestroy {
}
private async doUnlockWithMasterPassword() {
const kdfConfig = await this.kdfConfigService.getKdfConfig();
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
const masterKey = await this.cryptoService.makeMasterKey(
this.masterPassword,
this.email,
kdfConfig,
);
const storedMasterKeyHash = await firstValueFrom(
this.masterPasswordService.masterKeyHash$(userId),
);
const verification = {
type: VerificationType.MasterPassword,
secret: this.masterPassword,
} as MasterPasswordVerification;
let passwordValid = false;
if (storedMasterKeyHash != null) {
// Offline unlock possible
passwordValid = await this.cryptoService.compareAndUpdateKeyHash(
this.masterPassword,
masterKey,
let response: MasterPasswordVerificationResponse;
try {
this.formPromise = this.userVerificationService.verifyUserByMasterPassword(
verification,
userId,
this.email,
);
} else {
// Online only
const request = new SecretVerificationRequest();
const serverKeyHash = await this.cryptoService.hashMasterKey(
this.masterPassword,
masterKey,
HashPurpose.ServerAuthorization,
response = await this.formPromise;
this.enforcedMasterPasswordOptions = MasterPasswordPolicyOptions.fromResponse(
response.policyOptions,
);
request.masterPasswordHash = serverKeyHash;
try {
this.formPromise = this.apiService.postAccountVerifyPassword(request);
const response = await this.formPromise;
this.enforcedMasterPasswordOptions = MasterPasswordPolicyOptions.fromResponse(response);
passwordValid = true;
const localKeyHash = await this.cryptoService.hashMasterKey(
this.masterPassword,
masterKey,
HashPurpose.LocalAuthorization,
);
await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId);
} catch (e) {
this.logService.error(e);
} finally {
this.formPromise = null;
}
passwordValid = true;
} catch (e) {
this.logService.error(e);
} finally {
this.formPromise = null;
}
if (!passwordValid) {
@@ -274,8 +256,9 @@ export class LockComponent implements OnInit, OnDestroy {
return;
}
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey);
await this.masterPasswordService.setMasterKey(masterKey, userId);
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
response.masterKey,
);
await this.setUserKeyAndContinue(userKey, true);
}

View File

@@ -14,7 +14,9 @@ import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -56,6 +58,8 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
protected twoFactorRoute = "2fa";
protected successRoute = "vault";
// TODO: remove when email verification flag is removed
protected registerRoute = "/register";
protected forcePasswordResetRoute = "update-temp-password";
protected destroy$ = new Subject<void>();
@@ -83,11 +87,21 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
protected loginEmailService: LoginEmailServiceAbstraction,
protected ssoLoginService: SsoLoginServiceAbstraction,
protected webAuthnLoginService: WebAuthnLoginServiceAbstraction,
protected configService: ConfigService,
) {
super(environmentService, i18nService, platformUtilsService);
}
async ngOnInit() {
// TODO: remove when email verification flag is removed
const emailVerification = await this.configService.getFeatureFlag(
FeatureFlag.EmailVerification,
);
if (emailVerification) {
this.registerRoute = "/signup";
}
this.route?.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => {
if (!params) {
return;

View File

@@ -1,4 +1,5 @@
export * from "./add-account-credit-dialog/add-account-credit-dialog.component";
export * from "./invoices/invoices.component";
export * from "./manage-tax-information/manage-tax-information.component";
export * from "./select-payment-method/select-payment-method.component";
export * from "./verify-bank-account/verify-bank-account.component";

View File

@@ -0,0 +1,66 @@
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<bit-table *ngIf="!loading">
<ng-container header>
<tr>
<th bitCell>{{ "date" | i18n }}</th>
<th bitCell>{{ "invoiceNumberHeader" | i18n }}</th>
<th bitCell>{{ "total" | i18n }}</th>
<th bitCell>{{ "status" | i18n }}</th>
</tr>
</ng-container>
<ng-template body>
<tr bitRow *ngFor="let invoice of invoices">
<td bitCell>{{ invoice.date | date: "mediumDate" }}</td>
<td bitCell>
<a
href="{{ invoice.url }}"
target="_blank"
rel="noreferrer"
title="{{ 'viewInvoice' | i18n }}"
>
{{ invoice.number }}
</a>
</td>
<td bitCell>{{ invoice.total | currency: "$" }}</td>
<td bitCell>{{ invoice.status | titlecase }}</td>
<td bitCell>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="default"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #rowMenu>
<a
bitMenuItem
href="{{ invoice.pdfUrl }}"
target="_blank"
rel="noreferrer"
class="tw-mr-2"
appA11yTitle="{{ 'viewInvoice' | i18n }}"
>
<i aria-hidden="true" class="bwi bwi-file-pdf"></i>
{{ "viewInvoice" | i18n }}
</a>
<button
type="button"
bitMenuItem
*ngIf="getClientInvoiceReport"
(click)="runExport(invoice.id)"
>
<i aria-hidden="true" class="bwi bwi-sign-in"></i>
{{ "exportClientReport" | i18n }}
</button>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>

View File

@@ -0,0 +1,49 @@
import { Component, Input, OnInit } from "@angular/core";
import {
InvoiceResponse,
InvoicesResponse,
} from "@bitwarden/common/billing/models/response/invoices.response";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
@Component({
selector: "app-invoices",
templateUrl: "./invoices.component.html",
})
export class InvoicesComponent implements OnInit {
@Input() startWith?: InvoicesResponse;
@Input() getInvoices?: () => Promise<InvoicesResponse>;
@Input() getClientInvoiceReport?: (invoiceId: string) => Promise<string>;
@Input() getClientInvoiceReportName?: (invoiceResponse: InvoiceResponse) => string;
protected invoices: InvoiceResponse[] = [];
protected loading = true;
constructor(private fileDownloadService: FileDownloadService) {}
runExport = async (invoiceId: string): Promise<void> => {
const blobData = await this.getClientInvoiceReport(invoiceId);
let fileName = "report.csv";
if (this.getClientInvoiceReportName) {
const invoice = this.invoices.find((invoice) => invoice.id === invoiceId);
fileName = this.getClientInvoiceReportName(invoice);
}
this.fileDownloadService.download({
fileName,
blobData,
blobOptions: {
type: "text/csv",
},
});
};
async ngOnInit(): Promise<void> {
if (this.startWith) {
this.invoices = this.startWith.invoices;
} else if (this.getInvoices) {
const response = await this.getInvoices();
this.invoices = response.invoices;
}
this.loading = false;
}
}

View File

@@ -4,6 +4,7 @@ import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import {
AddAccountCreditDialogComponent,
InvoicesComponent,
ManageTaxInformationComponent,
SelectPaymentMethodComponent,
VerifyBankAccountComponent,
@@ -15,8 +16,11 @@ import {
CheckboxModule,
DialogModule,
FormFieldModule,
IconButtonModule,
MenuModule,
RadioButtonModule,
SelectModule,
TableModule,
ToastModule,
TypographyModule,
} from "@bitwarden/components";
@@ -66,6 +70,9 @@ import { IconComponent } from "./vault/components/icon.component";
CheckboxModule,
DialogModule,
TypographyModule,
TableModule,
MenuModule,
IconButtonModule,
],
declarations: [
A11yInvalidDirective,
@@ -96,6 +103,7 @@ import { IconComponent } from "./vault/components/icon.component";
IfFeatureDirective,
FingerprintPipe,
AddAccountCreditDialogComponent,
InvoicesComponent,
ManageTaxInformationComponent,
SelectPaymentMethodComponent,
VerifyBankAccountComponent,
@@ -130,6 +138,7 @@ import { IconComponent } from "./vault/components/icon.component";
IfFeatureDirective,
FingerprintPipe,
AddAccountCreditDialogComponent,
InvoicesComponent,
ManageTaxInformationComponent,
SelectPaymentMethodComponent,
VerifyBankAccountComponent,

View File

@@ -486,6 +486,7 @@ const safeProviders: SafeProvider[] = [
UserVerificationServiceAbstraction,
LogService,
InternalAccountService,
EnvironmentService,
],
}),
safeProvider({
@@ -874,7 +875,6 @@ const safeProviders: SafeProvider[] = [
provide: UserVerificationServiceAbstraction,
useClass: UserVerificationService,
deps: [
StateServiceAbstraction,
CryptoServiceAbstraction,
AccountServiceAbstraction,
InternalMasterPasswordServiceAbstraction,

View File

@@ -1,5 +1,6 @@
import { Component } from "@angular/core";
import { ActivatedRoute, RouterModule } from "@angular/router";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router";
import { Subject, filter, switchMap, takeUntil, tap } from "rxjs";
import { AnonLayoutComponent } from "@bitwarden/auth/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -17,31 +18,64 @@ export interface AnonLayoutWrapperData {
templateUrl: "anon-layout-wrapper.component.html",
imports: [AnonLayoutComponent, RouterModule],
})
export class AnonLayoutWrapperComponent {
export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected pageTitle: string;
protected pageSubtitle: string;
protected pageIcon: Icon;
protected showReadonlyHostname: boolean;
constructor(
private router: Router,
private route: ActivatedRoute,
private i18nService: I18nService,
) {
const routeData = this.route.snapshot.firstChild?.data;
) {}
if (!routeData) {
ngOnInit(): void {
// Set the initial page data on load
this.setAnonLayoutWrapperData(this.route.snapshot.firstChild?.data);
// Listen for page changes and update the page data appropriately
this.router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
// reset page data on page changes
tap(() => this.resetPageData()),
switchMap(() => this.route.firstChild?.data || null),
takeUntil(this.destroy$),
)
.subscribe((firstChildRouteData: Data | null) => {
this.setAnonLayoutWrapperData(firstChildRouteData);
});
}
private setAnonLayoutWrapperData(firstChildRouteData: Data | null) {
if (!firstChildRouteData) {
return;
}
if (routeData["pageTitle"] !== undefined) {
this.pageTitle = this.i18nService.t(routeData["pageTitle"]);
if (firstChildRouteData["pageTitle"] !== undefined) {
this.pageTitle = this.i18nService.t(firstChildRouteData["pageTitle"]);
}
if (routeData["pageSubtitle"] !== undefined) {
this.pageSubtitle = this.i18nService.t(routeData["pageSubtitle"]);
if (firstChildRouteData["pageSubtitle"] !== undefined) {
this.pageSubtitle = this.i18nService.t(firstChildRouteData["pageSubtitle"]);
}
this.pageIcon = routeData["pageIcon"];
this.showReadonlyHostname = routeData["showReadonlyHostname"];
this.pageIcon = firstChildRouteData["pageIcon"];
this.showReadonlyHostname = firstChildRouteData["showReadonlyHostname"];
}
private resetPageData() {
this.pageTitle = null;
this.pageSubtitle = null;
this.pageIcon = null;
this.showReadonlyHostname = null;
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -14,10 +14,10 @@
<p *ngIf="subtitle" bitTypography="body1">{{ subtitle }}</p>
</div>
<div
class="tw-mb-auto tw-min-w-full tw-max-w-md tw-mx-auto tw-flex tw-flex-col tw-items-center sm:tw-min-w-[28rem]"
class="tw-mb-auto tw-w-full tw-max-w-md tw-mx-auto tw-flex tw-flex-col tw-items-center sm:tw-min-w-[28rem]"
>
<div
class="tw-rounded-xl tw-mb-9 tw-mx-auto tw-min-w-full sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-300 sm:tw-p-8"
class="tw-rounded-xl tw-mb-9 tw-mx-auto tw-w-full sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-300 sm:tw-p-8"
>
<ng-content></ng-content>
</div>

View File

@@ -17,5 +17,6 @@ export * from "./user-verification/user-verification-form-input.component";
// registration
export * from "./registration/registration-start/registration-start.component";
export * from "./registration/registration-finish/registration-finish.component";
export * from "./registration/registration-start/registration-start-secondary.component";
export * from "./registration/registration-env-selector/registration-env-selector.component";

View File

@@ -93,6 +93,9 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy {
// Save this off so we can reset the value to the previously selected region
// if the self hosted settings are closed without saving.
this.selectedRegionFromEnv = selectedRegionFromEnv;
// Emit the initial value
this.selectedRegionChange.emit(selectedRegionFromEnv);
}),
takeUntil(this.destroy$),
)

View File

@@ -0,0 +1 @@
<h3>This component will be built in the next phase of email verification work.</h3>

View File

@@ -0,0 +1,15 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { RouterModule } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@Component({
standalone: true,
selector: "auth-registration-finish",
templateUrl: "./registration-finish.component.html",
imports: [CommonModule, JslibModule, RouterModule],
})
export class RegistrationFinishComponent {
constructor() {}
}

View File

@@ -1,3 +1,3 @@
<span
>{{ "alreadyHaveAccount" | i18n }} <a routerLink="/login">{{ "logIn" | i18n }}</a></span
>{{ "alreadyHaveAccount" | i18n }} <a [routerLink]="loginRoute">{{ "logIn" | i18n }}</a></span
>

View File

@@ -1,15 +1,32 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { RouterModule } from "@angular/router";
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute, RouterModule } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
/**
* RegistrationStartSecondaryComponentData
* @loginRoute: string - The client specific route to the login page - configured at the app-routing.module level.
*/
export interface RegistrationStartSecondaryComponentData {
loginRoute: string;
}
@Component({
standalone: true,
selector: "auth-registration-start-secondary",
templateUrl: "./registration-start-secondary.component.html",
imports: [CommonModule, JslibModule, RouterModule],
})
export class RegistrationStartSecondaryComponent {
constructor() {}
export class RegistrationStartSecondaryComponent implements OnInit {
loginRoute: string;
constructor(private activatedRoute: ActivatedRoute) {}
async ngOnInit() {
const routeData = await firstValueFrom(this.activatedRoute.data);
this.loginRoute = routeData["loginRoute"];
}
}

View File

@@ -23,38 +23,60 @@
<bit-form-control *ngIf="!isSelfHost">
<input
id="register-start-form-input-accept-policies"
id="register-start-form-input-receive-marketing-emails"
type="checkbox"
bitCheckbox
formControlName="acceptPolicies"
formControlName="receiveMarketingEmails"
/>
<bit-label for="register-start-form-input-accept-policies">
{{ "acceptPolicies" | i18n }}
<bit-label for="register-start-form-input-receive-marketing-emails">
{{ "receiveMarketingEmails" | i18n }}
<a
bitLink
linkType="primary"
href="https://bitwarden.com/terms/"
href="https://bitwarden.com/email-preferences"
target="_blank"
rel="noreferrer"
>{{ "termsOfService" | i18n }}</a
>,
<a
bitLink
linkType="primary"
href="https://bitwarden.com/privacy/"
target="_blank"
rel="noreferrer"
>{{ "privacyPolicy" | i18n }}</a
>{{ "unsubscribe" | i18n }}</a
>
{{ "atAnyTime" | i18n }}
</bit-label>
</bit-form-control>
<button [block]="true" type="submit" buttonType="primary" bitButton bitFormButton>
<button
[block]="true"
type="submit"
buttonType="primary"
bitButton
bitFormButton
class="tw-mb-3"
>
{{ "continue" | i18n }}
</button>
<p bitTypography="helper" class="tw-text-main tw-text-xs tw-mb-0">
{{ "byContinuingYouAgreeToThe" | i18n }}
<a
bitLink
linkType="primary"
href="https://bitwarden.com/terms/"
target="_blank"
rel="noreferrer"
>{{ "termsOfService" | i18n }}</a
>
{{ "and" | i18n }}
<a
bitLink
linkType="primary"
href="https://bitwarden.com/privacy/"
target="_blank"
rel="noreferrer"
>{{ "privacyPolicy" | i18n }}</a
>
</p>
<bit-error-summary *ngIf="showErrorSummary" [formGroup]="formGroup"></bit-error-summary></form
></ng-container>
<ng-container *ngIf="state === RegistrationStartState.CHECK_EMAIL">
<div class="tw-flex tw-flex-col tw-items-center tw-justify-center">
<bit-icon [icon]="Icons.RegistrationCheckEmailIcon" class="tw-mb-6"></bit-icon>

View File

@@ -1,17 +1,12 @@
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, OnDestroy, OnInit, Output } from "@angular/core";
import {
AbstractControl,
FormBuilder,
FormControl,
ReactiveFormsModule,
ValidatorFn,
Validators,
} from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { RegisterSendVerificationEmailRequest } from "@bitwarden/common/auth/models/request/registration/register-send-verification-email.request";
import { RegionConfig, Region } from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
@@ -31,6 +26,12 @@ export enum RegistrationStartState {
CHECK_EMAIL = "CheckEmail",
}
const DEFAULT_MARKETING_EMAILS_PREF_BY_REGION: Record<Region, boolean> = {
[Region.US]: true,
[Region.EU]: false,
[Region.SelfHosted]: false,
};
@Component({
standalone: true,
selector: "auth-registration-start",
@@ -60,20 +61,19 @@ export class RegistrationStartComponent implements OnInit, OnDestroy {
formGroup = this.formBuilder.group({
email: ["", [Validators.required, Validators.email]],
name: [""],
acceptPolicies: [false, [this.acceptPoliciesValidator()]],
selectedRegion: [null],
receiveMarketingEmails: [false],
});
get email(): FormControl {
return this.formGroup.get("email") as FormControl;
get email() {
return this.formGroup.controls.email;
}
get name(): FormControl {
return this.formGroup.get("name") as FormControl;
get name() {
return this.formGroup.controls.name;
}
get acceptPolicies(): FormControl {
return this.formGroup.get("acceptPolicies") as FormControl;
get receiveMarketingEmails() {
return this.formGroup.controls.receiveMarketingEmails;
}
emailReadonly: boolean = false;
@@ -86,8 +86,9 @@ export class RegistrationStartComponent implements OnInit, OnDestroy {
private formBuilder: FormBuilder,
private route: ActivatedRoute,
private platformUtilsService: PlatformUtilsService,
private accountApiService: AccountApiService,
private router: Router,
) {
// TODO: this needs to update if user selects self hosted
this.isSelfHost = platformUtilsService.isSelfHost();
}
@@ -107,6 +108,18 @@ export class RegistrationStartComponent implements OnInit, OnDestroy {
});
}
setReceiveMarketingEmailsByRegion(region: RegionConfig | Region.SelfHosted) {
let defaultValue;
if (region === Region.SelfHosted) {
defaultValue = DEFAULT_MARKETING_EMAILS_PREF_BY_REGION[region];
} else {
const regionKey = (region as RegionConfig).key;
defaultValue = DEFAULT_MARKETING_EMAILS_PREF_BY_REGION[regionKey];
}
this.receiveMarketingEmails.setValue(defaultValue);
}
submit = async () => {
const valid = this.validateForm();
@@ -114,14 +127,31 @@ export class RegistrationStartComponent implements OnInit, OnDestroy {
return;
}
// TODO: Implement registration logic
const request: RegisterSendVerificationEmailRequest = new RegisterSendVerificationEmailRequest(
this.email.value,
this.name.value,
this.receiveMarketingEmails.value,
);
const result = await this.accountApiService.registerSendVerificationEmail(request);
if (typeof result === "string") {
// we received a token, so the env doesn't support email verification
// send the user directly to the finish registration page with the token as a query param
await this.router.navigate(["/finish-signup"], { queryParams: { token: result } });
}
// Result is null, so email verification is required
this.state = RegistrationStartState.CHECK_EMAIL;
this.registrationStartStateChange.emit(this.state);
};
handleSelectedRegionChange(region: RegionConfig | Region.SelfHosted | null) {
this.isSelfHost = region === Region.SelfHosted;
if (region !== null) {
this.setReceiveMarketingEmailsByRegion(region);
}
}
private validateForm(): boolean {
@@ -139,14 +169,6 @@ export class RegistrationStartComponent implements OnInit, OnDestroy {
this.registrationStartStateChange.emit(this.state);
}
private acceptPoliciesValidator(): ValidatorFn {
return (control: AbstractControl) => {
const ctrlValue = control.value;
return !ctrlValue && !this.isSelfHost ? { required: true } : null;
};
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();

View File

@@ -8,8 +8,9 @@ import * as stories from "./registration-start.stories";
The Auth-owned RegistrationStartComponent is to be used for the first step in the new email
verification stage gated registration process. It collects the environment (required), the user's
email address (required) and optionally their name. On cloud environments, it requires acceptance of
the terms of service and the privacy policy; the checkbox is hidden on self hosted environments.
email address (required) and optionally their name. On cloud environments, it offers a checkbox for
the user to choose to receive marketing emails or not with the default value changing based on the
environment (e.g., true for US, false for EU).
## Web Examples
@@ -36,8 +37,10 @@ field will be set to readonly. `emailReadonly` is primarily for the organization
Behavior to note:
- The self hosted option is present in the environment selector.
- If you go from non-self hosted to self hosted, the terms of service and privacy policy checkbox
will disappear.
- If you go from non-self hosted to self hosted, the receive marketing emails checkbox will
disappear.
- If you change regions, the receive marketing emails checkbox default value will change based on
the region.
### US Region
@@ -49,8 +52,8 @@ Behavior to note:
### Self Hosted
Note the fact that the terms of service and privacy policy checkbox is not present when the
environment is self hosted.
Note the fact that the receive marketing emails checkbox is not present when the environment is self
hosted.
<Story of={stories.DesktopSelfHostExample} />
@@ -59,8 +62,10 @@ environment is self hosted.
Behavior to note:
- The self hosted option is present in the environment selector.
- If you go from non-self hosted to self hosted, the terms of service and privacy policy checkbox
will disappear.
- If you go from non-self hosted to self hosted, the receive marketing emails checkbox will
disappear.
- If you change regions, the receive marketing emails checkbox default value will change based on
the region.
### US Region
@@ -72,7 +77,7 @@ Behavior to note:
### Self Hosted
Note the fact that the terms of service and privacy policy checkbox is not present when the
environment is self hosted.
Note the fact that the receive marketing emails checkbox is not present when the environment is self
hosted.
<Story of={stories.BrowserExtensionSelfHostExample} />

View File

@@ -6,6 +6,7 @@ import { RouterTestingModule } from "@angular/router/testing";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { of } from "rxjs";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { ClientType } from "@bitwarden/common/enums";
import {
Environment,
@@ -53,7 +54,6 @@ const decorators = (options: {
LinkModule,
TypographyModule,
AsyncActionsModule,
BrowserAnimationsModule,
],
providers: [
{
@@ -64,6 +64,7 @@ const decorators = (options: {
}),
applicationConfig({
providers: [
importProvidersFrom(BrowserAnimationsModule),
importProvidersFrom(PreloadedEnglishI18nModule),
{
provide: EnvironmentService,
@@ -91,6 +92,12 @@ const decorators = (options: {
showToast: (options: ToastOptions) => {},
} as Partial<ToastService>,
},
{
provide: AccountApiService,
useValue: {
registerSendVerificationEmail: () => Promise.resolve(null),
} as Partial<AccountApiService>,
},
],
}),
];

View File

@@ -61,7 +61,6 @@ import { IdentityCaptchaResponse } from "../auth/models/response/identity-captch
import { IdentityTokenResponse } from "../auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response";
import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response";
import { MasterPasswordPolicyResponse } from "../auth/models/response/master-password-policy.response";
import { PreloginResponse } from "../auth/models/response/prelogin.response";
import { RegisterResponse } from "../auth/models/response/register.response";
import { SsoPreValidateResponse } from "../auth/models/response/sso-pre-validate.response";
@@ -175,9 +174,6 @@ export abstract class ApiService {
postAccountKeys: (request: KeysRequest) => Promise<any>;
postAccountVerifyEmail: () => Promise<any>;
postAccountVerifyEmailToken: (request: VerifyEmailRequest) => Promise<any>;
postAccountVerifyPassword: (
request: SecretVerificationRequest,
) => Promise<MasterPasswordPolicyResponse>;
postAccountRecoverDelete: (request: DeleteRecoverRequest) => Promise<any>;
postAccountRecoverDeleteToken: (request: VerifyDeleteRecoverRequest) => Promise<any>;
postAccountKdf: (request: KdfRequest) => Promise<any>;

View File

@@ -117,6 +117,9 @@ export abstract class OrganizationService {
hasOrganizations: () => Promise<boolean>;
get$: (id: string) => Observable<Organization | undefined>;
get: (id: string) => Promise<Organization>;
/**
* @deprecated This method is only used in key connector and will be removed soon as part of https://bitwarden.atlassian.net/browse/AC-2252.
*/
getAll: (userId?: string) => Promise<Organization[]>;
/**

View File

@@ -1,4 +1,4 @@
import { ProductType } from "../../../enums/product-type.enum";
import { ProductTierType } from "../../../billing/enums/product-tier-type.enum";
import { OrganizationUserStatusType, OrganizationUserType } from "../../enums";
import { ORGANIZATIONS } from "../../services/organization/organization.service";
@@ -47,7 +47,7 @@ describe("ORGANIZATIONS state", () => {
isMember: false,
familySponsorshipFriendlyName: "fsfn",
familySponsorshipAvailable: false,
planProductType: ProductType.Free,
productTierType: ProductTierType.Free,
keyConnectorEnabled: false,
keyConnectorUrl: "kcu",
accessSecretsManager: false,

View File

@@ -1,6 +1,6 @@
import { Jsonify } from "type-fest";
import { ProductType } from "../../../enums";
import { ProductTierType } from "../../../billing/enums";
import { OrganizationUserStatusType, OrganizationUserType, ProviderType } from "../../enums";
import { PermissionsApi } from "../api/permissions.api";
import { ProfileOrganizationResponse } from "../response/profile-organization.response";
@@ -45,7 +45,7 @@ export class OrganizationData {
isMember: boolean;
familySponsorshipFriendlyName: string;
familySponsorshipAvailable: boolean;
planProductType: ProductType;
productTierType: ProductTierType;
keyConnectorEnabled: boolean;
keyConnectorUrl: string;
familySponsorshipLastSyncDate?: Date;
@@ -104,7 +104,7 @@ export class OrganizationData {
this.providerType = response.providerType;
this.familySponsorshipFriendlyName = response.familySponsorshipFriendlyName;
this.familySponsorshipAvailable = response.familySponsorshipAvailable;
this.planProductType = response.planProductType;
this.productTierType = response.planProductType;
this.keyConnectorEnabled = response.keyConnectorEnabled;
this.keyConnectorUrl = response.keyConnectorUrl;
this.familySponsorshipLastSyncDate = response.familySponsorshipLastSyncDate;

View File

@@ -1,6 +1,6 @@
import { Jsonify } from "type-fest";
import { ProductType } from "../../../enums";
import { ProductTierType } from "../../../billing/enums";
import { OrganizationUserStatusType, OrganizationUserType, ProviderType } from "../../enums";
import { PermissionsApi } from "../api/permissions.api";
import { OrganizationData } from "../data/organization.data";
@@ -58,7 +58,7 @@ export class Organization {
isMember: boolean;
familySponsorshipFriendlyName: string;
familySponsorshipAvailable: boolean;
planProductType: ProductType;
productTierType: ProductTierType;
keyConnectorEnabled: boolean;
keyConnectorUrl: string;
familySponsorshipLastSyncDate?: Date;
@@ -123,7 +123,7 @@ export class Organization {
this.isMember = obj.isMember;
this.familySponsorshipFriendlyName = obj.familySponsorshipFriendlyName;
this.familySponsorshipAvailable = obj.familySponsorshipAvailable;
this.planProductType = obj.planProductType;
this.productTierType = obj.productTierType;
this.keyConnectorEnabled = obj.keyConnectorEnabled;
this.keyConnectorUrl = obj.keyConnectorUrl;
this.familySponsorshipLastSyncDate = obj.familySponsorshipLastSyncDate;

View File

@@ -1,4 +1,4 @@
import { ProductType } from "../../../enums";
import { ProductTierType } from "../../../billing/enums";
import { BaseResponse } from "../../../models/response/base.response";
import { OrganizationUserStatusType, OrganizationUserType, ProviderType } from "../../enums";
import { PermissionsApi } from "../api/permissions.api";
@@ -42,7 +42,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
providerType?: ProviderType;
familySponsorshipFriendlyName: string;
familySponsorshipAvailable: boolean;
planProductType: ProductType;
planProductType: ProductTierType;
keyConnectorEnabled: boolean;
keyConnectorUrl: string;
familySponsorshipLastSyncDate?: Date;

View File

@@ -1,5 +1,27 @@
import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request";
import { Verification } from "../types/verification";
export abstract class AccountApiService {
/**
* Deletes an account that has confirmed the operation is authorized
*
* @param verification - authorizes the account deletion operation.
* @returns A promise that resolves when the account is
* successfully deleted.
*/
abstract deleteAccount(verification: Verification): Promise<void>;
/**
* Sends a verification email as part of the registration process.
*
* @param request - The request object containing
* information needed to send the verification email, such as the user's email address.
* @returns A promise that resolves to a string tokencontaining the user's encrypted
* information which must be submitted to complete registration or `null` if
* email verification is enabled (users must get the token by clicking a
* link in the email that will be sent to them).
*/
abstract registerSendVerificationEmail(
request: RegisterSendVerificationEmailRequest,
): Promise<null | string>;
}

View File

@@ -1,6 +1,11 @@
import { SecretVerificationRequest } from "../../models/request/secret-verification.request";
import { VerifyOTPRequest } from "../../models/request/verify-otp.request";
import { MasterPasswordPolicyResponse } from "../../models/response/master-password-policy.response";
export abstract class UserVerificationApiServiceAbstraction {
postAccountVerifyOTP: (request: VerifyOTPRequest) => Promise<void>;
postAccountRequestOTP: () => Promise<void>;
postAccountVerifyPassword: (
request: SecretVerificationRequest,
) => Promise<MasterPasswordPolicyResponse>;
}

View File

@@ -1,19 +1,53 @@
import { UserId } from "../../../types/guid";
import { SecretVerificationRequest } from "../../models/request/secret-verification.request";
import { UserVerificationOptions } from "../../types/user-verification-options";
import { Verification } from "../../types/verification";
import {
MasterPasswordVerification,
MasterPasswordVerificationResponse,
Verification,
} from "../../types/verification";
export abstract class UserVerificationService {
/**
* Returns the available verification options for the user, can be
* restricted to a specific type of verification.
* @param verificationType Type of verification to restrict the options to
* @returns Available verification options for the user
*/
getAvailableVerificationOptions: (
verificationType: keyof UserVerificationOptions,
) => Promise<UserVerificationOptions>;
/**
* Create a new request model to be used for server-side verification
* @param verification User-supplied verification data (Master Password or OTP)
* @param requestClass The request model to create
* @param alreadyHashed Whether the master password is already hashed
* @throws Error if the verification data is invalid
*/
buildRequest: <T extends SecretVerificationRequest>(
verification: Verification,
requestClass?: new () => T,
alreadyHashed?: boolean,
) => Promise<T>;
/**
* Verifies the user using the provided verification data.
* PIN or biometrics are verified client-side.
* OTP is sent to the server for verification (with no other data)
* Master Password verifies client-side first if there is a MP hash, or server-side if not.
* @param verification User-supplied verification data (OTP, MP, PIN, or biometrics)
* @throws Error if the verification data is invalid or the verification fails
*/
verifyUser: (verification: Verification) => Promise<boolean>;
/**
* Request a one-time password (OTP) to be sent to the user's email
*/
requestOTP: () => Promise<void>;
/**
* Check if user has master password or only uses passwordless technologies to log in
* Check if user has master password or can only use passwordless technologies to log in
* Note: This only checks the server, not the local state
* @param userId The user id to check. If not provided, the current user is used
* @returns True if the user has a master password
* @deprecated Use UserDecryptionOptionsService.hasMasterPassword$ instead
*/
hasMasterPassword: (userId?: string) => Promise<boolean>;
/**
@@ -22,8 +56,19 @@ export abstract class UserVerificationService {
* @returns True if the user has a master password and has used it in the current session
*/
hasMasterPasswordAndMasterKeyHash: (userId?: string) => Promise<boolean>;
getAvailableVerificationOptions: (
verificationType: keyof UserVerificationOptions,
) => Promise<UserVerificationOptions>;
/**
* Verifies the user using the provided master password.
* Attempts to verify client-side first, then server-side if necessary.
* IMPORTANT: Will throw an error if the master password is invalid.
* @param verification Master Password verification data
* @param userId The user to verify
* @param email The user's email
* @throws Error if the master password is invalid
* @returns An object containing the master key, and master password policy options if verified on server.
*/
verifyUserByMasterPassword: (
verification: MasterPasswordVerification,
userId: UserId,
email: string,
) => Promise<MasterPasswordVerificationResponse>;
}

View File

@@ -0,0 +1,7 @@
export class RegisterSendVerificationEmailRequest {
constructor(
public email: string,
public name: string,
public receiveMarketingEmails: boolean,
) {}
}

View File

@@ -1,8 +1,13 @@
import { firstValueFrom } from "rxjs";
import { ApiService } from "../../abstractions/api.service";
import { ErrorResponse } from "../../models/response/error.response";
import { EnvironmentService } from "../../platform/abstractions/environment.service";
import { LogService } from "../../platform/abstractions/log.service";
import { AccountApiService } from "../abstractions/account-api.service";
import { InternalAccountService } from "../abstractions/account.service";
import { UserVerificationService } from "../abstractions/user-verification/user-verification.service.abstraction";
import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request";
import { Verification } from "../types/verification";
export class AccountApiServiceImplementation implements AccountApiService {
@@ -11,6 +16,7 @@ export class AccountApiServiceImplementation implements AccountApiService {
private userVerificationService: UserVerificationService,
private logService: LogService,
private accountService: InternalAccountService,
private environmentService: EnvironmentService,
) {}
async deleteAccount(verification: Verification): Promise<void> {
@@ -23,4 +29,33 @@ export class AccountApiServiceImplementation implements AccountApiService {
throw e;
}
}
async registerSendVerificationEmail(
request: RegisterSendVerificationEmailRequest,
): Promise<null | string> {
const env = await firstValueFrom(this.environmentService.environment$);
try {
const response = await this.apiService.send(
"POST",
"/accounts/register/send-verification-email",
request,
false,
true,
env.getIdentityUrl(),
);
return response;
} catch (e: unknown) {
if (e instanceof ErrorResponse) {
if (e.statusCode === 204) {
// No content is a success response.
return null;
}
}
this.logService.error(e);
throw e;
}
}
}

View File

@@ -1,6 +1,8 @@
import { ApiService } from "../../../abstractions/api.service";
import { UserVerificationApiServiceAbstraction } from "../../abstractions/user-verification/user-verification-api.service.abstraction";
import { SecretVerificationRequest } from "../../models/request/secret-verification.request";
import { VerifyOTPRequest } from "../../models/request/verify-otp.request";
import { MasterPasswordPolicyResponse } from "../../models/response/master-password-policy.response";
export class UserVerificationApiService implements UserVerificationApiServiceAbstraction {
constructor(private apiService: ApiService) {}
@@ -11,4 +13,9 @@ export class UserVerificationApiService implements UserVerificationApiServiceAbs
async postAccountRequestOTP(): Promise<void> {
return this.apiService.send("POST", "/accounts/request-otp", null, true, false);
}
postAccountVerifyPassword(
request: SecretVerificationRequest,
): Promise<MasterPasswordPolicyResponse> {
return this.apiService.send("POST", "/accounts/verify-password", request, true, true);
}
}

View File

@@ -0,0 +1,418 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import {
PinLockType,
PinServiceAbstraction,
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec";
import { VaultTimeoutSettingsService } from "../../../abstractions/vault-timeout/vault-timeout-settings.service";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
import { HashPurpose } from "../../../platform/enums";
import { Utils } from "../../../platform/misc/utils";
import { UserId } from "../../../types/guid";
import { MasterKey } from "../../../types/key";
import { KdfConfigService } from "../../abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "../../abstractions/master-password.service.abstraction";
import { UserVerificationApiServiceAbstraction } from "../../abstractions/user-verification/user-verification-api.service.abstraction";
import { VerificationType } from "../../enums/verification-type";
import { KdfConfig } from "../../models/domain/kdf-config";
import { MasterPasswordPolicyResponse } from "../../models/response/master-password-policy.response";
import { MasterPasswordVerification } from "../../types/verification";
import { UserVerificationService } from "./user-verification.service";
describe("UserVerificationService", () => {
let sut: UserVerificationService;
const cryptoService = mock<CryptoService>();
const masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
const i18nService = mock<I18nService>();
const userVerificationApiService = mock<UserVerificationApiServiceAbstraction>();
const userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
const pinService = mock<PinServiceAbstraction>();
const logService = mock<LogService>();
const vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
const platformUtilsService = mock<PlatformUtilsService>();
const kdfConfigService = mock<KdfConfigService>();
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
beforeEach(() => {
jest.clearAllMocks();
accountService = mockAccountServiceWith(mockUserId);
sut = new UserVerificationService(
cryptoService,
accountService,
masterPasswordService,
i18nService,
userVerificationApiService,
userDecryptionOptionsService,
pinService,
logService,
vaultTimeoutSettingsService,
platformUtilsService,
kdfConfigService,
);
});
describe("getAvailableVerificationOptions", () => {
describe("client verification type", () => {
it("correctly returns master password availability", async () => {
setMasterPasswordAvailability(true);
setPinAvailability("DISABLED");
disableBiometricsAvailability();
const result = await sut.getAvailableVerificationOptions("client");
expect(result).toEqual({
client: {
masterPassword: true,
pin: false,
biometrics: false,
},
server: {
masterPassword: false,
otp: false,
},
});
});
test.each([
[true, "PERSISTENT"],
[true, "EPHEMERAL"],
[false, "DISABLED"],
])(
"returns %s for PIN availability when pin lock type is %s",
async (expectedPin: boolean, pinLockType: PinLockType) => {
setMasterPasswordAvailability(false);
setPinAvailability(pinLockType);
disableBiometricsAvailability();
const result = await sut.getAvailableVerificationOptions("client");
expect(result).toEqual({
client: {
masterPassword: false,
pin: expectedPin,
biometrics: false,
},
server: {
masterPassword: false,
otp: false,
},
});
},
);
test.each([
[true, true, true, true],
[true, true, true, false],
[true, true, false, false],
[false, true, false, true],
[false, false, false, false],
[false, false, true, false],
[false, false, false, true],
])(
"returns %s for biometrics availability when isBiometricLockSet is %s, hasUserKeyStored is %s, and supportsSecureStorage is %s",
async (
expectedReturn: boolean,
isBiometricsLockSet: boolean,
isBiometricsUserKeyStored: boolean,
platformSupportSecureStorage: boolean,
) => {
setMasterPasswordAvailability(false);
setPinAvailability("DISABLED");
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(isBiometricsLockSet);
cryptoService.hasUserKeyStored.mockResolvedValue(isBiometricsUserKeyStored);
platformUtilsService.supportsSecureStorage.mockReturnValue(platformSupportSecureStorage);
const result = await sut.getAvailableVerificationOptions("client");
expect(result).toEqual({
client: {
masterPassword: false,
pin: false,
biometrics: expectedReturn,
},
server: {
masterPassword: false,
otp: false,
},
});
},
);
});
describe("server verification type", () => {
it("correctly returns master password availability", async () => {
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of({
hasMasterPassword: true,
} as UserDecryptionOptions),
);
const result = await sut.getAvailableVerificationOptions("server");
expect(result).toEqual({
client: {
masterPassword: false,
pin: false,
biometrics: false,
},
server: {
masterPassword: true,
otp: false,
},
});
});
it("correctly returns OTP availability", async () => {
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of({
hasMasterPassword: false,
} as UserDecryptionOptions),
);
const result = await sut.getAvailableVerificationOptions("server");
expect(result).toEqual({
client: {
masterPassword: false,
pin: false,
biometrics: false,
},
server: {
masterPassword: false,
otp: true,
},
});
});
});
});
describe("verifyUserByMasterPassword", () => {
beforeAll(() => {
i18nService.t.calledWith("invalidMasterPassword").mockReturnValue("Invalid master password");
kdfConfigService.getKdfConfig.mockResolvedValue("kdfConfig" as unknown as KdfConfig);
masterPasswordService.masterKey$.mockReturnValue(of("masterKey" as unknown as MasterKey));
cryptoService.hashMasterKey
.calledWith("password", "masterKey" as unknown as MasterKey, HashPurpose.LocalAuthorization)
.mockResolvedValue("localHash");
});
describe("client-side verification", () => {
beforeEach(() => {
setMasterPasswordAvailability(true);
});
it("returns if verification is successful", async () => {
cryptoService.compareAndUpdateKeyHash.mockResolvedValueOnce(true);
const result = await sut.verifyUserByMasterPassword(
{
type: VerificationType.MasterPassword,
secret: "password",
} as MasterPasswordVerification,
mockUserId,
"email",
);
expect(cryptoService.compareAndUpdateKeyHash).toHaveBeenCalled();
expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(
"localHash",
mockUserId,
);
expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith("masterKey", mockUserId);
expect(result).toEqual({
policyOptions: null,
masterKey: "masterKey",
});
});
it("throws if verification fails", async () => {
cryptoService.compareAndUpdateKeyHash.mockResolvedValueOnce(false);
await expect(
sut.verifyUserByMasterPassword(
{
type: VerificationType.MasterPassword,
secret: "password",
} as MasterPasswordVerification,
mockUserId,
"email",
),
).rejects.toThrow("Invalid master password");
expect(cryptoService.compareAndUpdateKeyHash).toHaveBeenCalled();
expect(masterPasswordService.setMasterKeyHash).not.toHaveBeenCalledWith();
expect(masterPasswordService.setMasterKey).not.toHaveBeenCalledWith();
});
});
describe("server-side verification", () => {
beforeEach(() => {
setMasterPasswordAvailability(false);
});
it("returns if verification is successful", async () => {
cryptoService.hashMasterKey
.calledWith(
"password",
"masterKey" as unknown as MasterKey,
HashPurpose.ServerAuthorization,
)
.mockResolvedValueOnce("serverHash");
userVerificationApiService.postAccountVerifyPassword.mockResolvedValueOnce(
"MasterPasswordPolicyOptions" as unknown as MasterPasswordPolicyResponse,
);
const result = await sut.verifyUserByMasterPassword(
{
type: VerificationType.MasterPassword,
secret: "password",
} as MasterPasswordVerification,
mockUserId,
"email",
);
expect(cryptoService.compareAndUpdateKeyHash).not.toHaveBeenCalled();
expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(
"localHash",
mockUserId,
);
expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith("masterKey", mockUserId);
expect(result).toEqual({
policyOptions: "MasterPasswordPolicyOptions",
masterKey: "masterKey",
});
});
it("throws if verification fails", async () => {
cryptoService.hashMasterKey
.calledWith(
"password",
"masterKey" as unknown as MasterKey,
HashPurpose.ServerAuthorization,
)
.mockResolvedValueOnce("serverHash");
userVerificationApiService.postAccountVerifyPassword.mockRejectedValueOnce(new Error());
await expect(
sut.verifyUserByMasterPassword(
{
type: VerificationType.MasterPassword,
secret: "password",
} as MasterPasswordVerification,
mockUserId,
"email",
),
).rejects.toThrow("Invalid master password");
expect(cryptoService.compareAndUpdateKeyHash).not.toHaveBeenCalled();
expect(masterPasswordService.setMasterKeyHash).not.toHaveBeenCalledWith();
expect(masterPasswordService.setMasterKey).not.toHaveBeenCalledWith();
});
});
describe("error handling", () => {
it("throws if any of the parameters are nullish", async () => {
await expect(
sut.verifyUserByMasterPassword(
{
type: VerificationType.MasterPassword,
secret: null,
} as MasterPasswordVerification,
mockUserId,
"email",
),
).rejects.toThrow(
"Master Password is required. Cannot verify user without a master password.",
);
await expect(
sut.verifyUserByMasterPassword(
{
type: VerificationType.MasterPassword,
secret: "password",
} as MasterPasswordVerification,
null,
"email",
),
).rejects.toThrow("User ID is required. Cannot verify user by master password.");
await expect(
sut.verifyUserByMasterPassword(
{
type: VerificationType.MasterPassword,
secret: "password",
} as MasterPasswordVerification,
mockUserId,
null,
),
).rejects.toThrow("Email is required. Cannot verify user by master password.");
});
it("throws if kdf config is not available", async () => {
kdfConfigService.getKdfConfig.mockResolvedValueOnce(null);
await expect(
sut.verifyUserByMasterPassword(
{
type: VerificationType.MasterPassword,
secret: "password",
} as MasterPasswordVerification,
mockUserId,
"email",
),
).rejects.toThrow("KDF config is required. Cannot verify user by master password.");
});
it("throws if master key cannot be created", async () => {
kdfConfigService.getKdfConfig.mockResolvedValueOnce("kdfConfig" as unknown as KdfConfig);
masterPasswordService.masterKey$.mockReturnValueOnce(of(null));
cryptoService.makeMasterKey.mockResolvedValueOnce(null);
await expect(
sut.verifyUserByMasterPassword(
{
type: VerificationType.MasterPassword,
secret: "password",
} as MasterPasswordVerification,
mockUserId,
"email",
),
).rejects.toThrow("Master key could not be created to verify the master password.");
});
});
});
// Helpers
function setMasterPasswordAvailability(hasMasterPassword: boolean) {
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of({
hasMasterPassword: hasMasterPassword,
} as UserDecryptionOptions),
);
masterPasswordService.masterKeyHash$.mockReturnValue(
of(hasMasterPassword ? "masterKeyHash" : null),
);
}
function setPinAvailability(type: PinLockType) {
pinService.getPinLockType.mockResolvedValue(type);
}
function disableBiometricsAvailability() {
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(false);
}
});

View File

@@ -8,7 +8,7 @@ import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
import { StateService } from "../../../platform/abstractions/state.service";
import { HashPurpose } from "../../../platform/enums";
import { KeySuffixOptions } from "../../../platform/enums/key-suffix-options.enum";
import { UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key";
@@ -20,9 +20,11 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from ".
import { VerificationType } from "../../enums/verification-type";
import { SecretVerificationRequest } from "../../models/request/secret-verification.request";
import { VerifyOTPRequest } from "../../models/request/verify-otp.request";
import { MasterPasswordPolicyResponse } from "../../models/response/master-password-policy.response";
import { UserVerificationOptions } from "../../types/user-verification-options";
import {
MasterPasswordVerification,
MasterPasswordVerificationResponse,
OtpVerification,
PinVerification,
ServerSideVerification,
@@ -37,7 +39,6 @@ import {
*/
export class UserVerificationService implements UserVerificationServiceAbstraction {
constructor(
private stateService: StateService,
private cryptoService: CryptoService,
private accountService: AccountService,
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
@@ -54,14 +55,14 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
async getAvailableVerificationOptions(
verificationType: keyof UserVerificationOptions,
): Promise<UserVerificationOptions> {
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (verificationType === "client") {
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
const [userHasMasterPassword, pinLockType, biometricsLockSet, biometricsUserKeyStored] =
await Promise.all([
this.hasMasterPasswordAndMasterKeyHash(),
this.hasMasterPasswordAndMasterKeyHash(userId),
this.pinService.getPinLockType(userId),
this.vaultTimeoutSettingsService.isBiometricLockSet(),
this.cryptoService.hasUserKeyStored(KeySuffixOptions.Biometric),
this.vaultTimeoutSettingsService.isBiometricLockSet(userId),
this.cryptoService.hasUserKeyStored(KeySuffixOptions.Biometric, userId),
]);
// note: we do not need to check this.platformUtilsService.supportsBiometric() because
@@ -83,7 +84,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
} else {
// server
// Don't check if have MP hash locally, because we are going to send the secret to the server to be verified.
const userHasMasterPassword = await this.hasMasterPassword();
const userHasMasterPassword = await this.hasMasterPassword(userId);
return {
client: {
@@ -96,12 +97,6 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
}
}
/**
* Create a new request model to be used for server-side verification
* @param verification User-supplied verification data (Master Password or OTP)
* @param requestClass The request model to create
* @param alreadyHashed Whether the master password is already hashed
*/
async buildRequest<T extends SecretVerificationRequest>(
verification: ServerSideVerification,
requestClass?: new () => T,
@@ -134,11 +129,6 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
return request;
}
/**
* Used to verify Master Password, PIN, or biometrics client-side, or send the OTP to the server for verification (with no other data)
* Generally used for client-side verification only.
* @param verification User-supplied verification data (OTP, MP, PIN, or biometrics)
*/
async verifyUser(verification: Verification): Promise<boolean> {
if (verification == null) {
throw new Error("Verification is required.");
@@ -156,7 +146,8 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
case VerificationType.OTP:
return this.verifyUserByOTP(verification);
case VerificationType.MasterPassword:
return this.verifyUserByMasterPassword(verification, userId, email);
await this.verifyUserByMasterPassword(verification, userId, email);
return true;
case VerificationType.PIN:
return this.verifyUserByPIN(verification, userId);
case VerificationType.Biometrics:
@@ -179,33 +170,70 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
return true;
}
private async verifyUserByMasterPassword(
async verifyUserByMasterPassword(
verification: MasterPasswordVerification,
userId: UserId,
email: string,
): Promise<boolean> {
): Promise<MasterPasswordVerificationResponse> {
if (!verification.secret) {
throw new Error("Master Password is required. Cannot verify user without a master password.");
}
if (!userId) {
throw new Error("User ID is required. Cannot verify user by master password.");
}
if (!email) {
throw new Error("Email is required. Cannot verify user by master password.");
}
const kdfConfig = await this.kdfConfigService.getKdfConfig();
if (!kdfConfig) {
throw new Error("KDF config is required. Cannot verify user by master password.");
}
let masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
if (!masterKey) {
masterKey = await this.cryptoService.makeMasterKey(
verification.secret,
email,
await this.kdfConfigService.getKdfConfig(),
);
masterKey = await this.cryptoService.makeMasterKey(verification.secret, email, kdfConfig);
}
const passwordValid = await this.cryptoService.compareAndUpdateKeyHash(
if (!masterKey) {
throw new Error("Master key could not be created to verify the master password.");
}
let policyOptions: MasterPasswordPolicyResponse | null;
// Client-side verification
if (await this.hasMasterPasswordAndMasterKeyHash(userId)) {
const passwordValid = await this.cryptoService.compareAndUpdateKeyHash(
verification.secret,
masterKey,
);
if (!passwordValid) {
throw new Error(this.i18nService.t("invalidMasterPassword"));
}
policyOptions = null;
} else {
// Server-side verification
const request = new SecretVerificationRequest();
const serverKeyHash = await this.cryptoService.hashMasterKey(
verification.secret,
masterKey,
HashPurpose.ServerAuthorization,
);
request.masterPasswordHash = serverKeyHash;
try {
policyOptions = await this.userVerificationApiService.postAccountVerifyPassword(request);
} catch (e) {
throw new Error(this.i18nService.t("invalidMasterPassword"));
}
}
const localKeyHash = await this.cryptoService.hashMasterKey(
verification.secret,
masterKey,
HashPurpose.LocalAuthorization,
);
if (!passwordValid) {
throw new Error(this.i18nService.t("invalidMasterPassword"));
}
// TODO: we should re-evaluate later on if user verification should have the side effect of modifying state. Probably not.
await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId);
await this.masterPasswordService.setMasterKey(masterKey, userId);
return true;
return { policyOptions, masterKey };
}
private async verifyUserByPIN(verification: PinVerification, userId: UserId): Promise<boolean> {
@@ -236,13 +264,6 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
await this.userVerificationApiService.postAccountRequestOTP();
}
/**
* Check if user has master password or can only use passwordless technologies to log in
* Note: This only checks the server, not the local state
* @param userId The user id to check. If not provided, the current user is used
* @returns True if the user has a master password
* @deprecated Use UserDecryptionOptionsService.hasMasterPassword$ instead
*/
async hasMasterPassword(userId?: string): Promise<boolean> {
if (userId) {
const decryptionOptions = await firstValueFrom(

View File

@@ -1,4 +1,6 @@
import { MasterKey } from "../../types/key";
import { VerificationType } from "../enums/verification-type";
import { MasterPasswordPolicyResponse } from "../models/response/master-password-policy.response";
export type OtpVerification = { type: VerificationType.OTP; secret: string };
export type MasterPasswordVerification = { type: VerificationType.MasterPassword; secret: string };
@@ -17,3 +19,8 @@ export function verificationHasSecret(
}
export type ServerSideVerification = OtpVerification | MasterPasswordVerification;
export type MasterPasswordVerificationResponse = {
masterKey: MasterKey;
policyOptions: MasterPasswordPolicyResponse;
};

View File

@@ -2,6 +2,7 @@ import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
import { TokenizedPaymentMethodRequest } from "@bitwarden/common/billing/models/request/tokenized-payment-method.request";
import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request";
import { InvoicesResponse } from "@bitwarden/common/billing/models/response/invoices.response";
import { PaymentInformationResponse } from "@bitwarden/common/billing/models/response/payment-information.response";
import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request";
@@ -41,6 +42,10 @@ export abstract class BillingApiServiceAbstraction {
getPlans: () => Promise<ListResponse<PlanResponse>>;
getProviderClientInvoiceReport: (providerId: string, invoiceId: string) => Promise<string>;
getProviderInvoices: (providerId: string) => Promise<InvoicesResponse>;
getProviderPaymentInformation: (providerId: string) => Promise<PaymentInformationResponse>;
getProviderSubscription: (providerId: string) => Promise<ProviderSubscriptionResponse>;

View File

@@ -3,3 +3,4 @@ export * from "./plan-sponsorship-type.enum";
export * from "./plan-type.enum";
export * from "./transaction-type.enum";
export * from "./bitwarden-product-type.enum";
export * from "./product-tier-type.enum";

View File

@@ -1,4 +1,4 @@
export enum ProductType {
export enum ProductTierType {
Free = 0,
Families = 1,
Teams = 2,

View File

@@ -0,0 +1,4 @@
export enum ProductType {
PasswordManager = 0,
SecretsManager = 1,
}

View File

@@ -0,0 +1,34 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
export class InvoicesResponse extends BaseResponse {
invoices: InvoiceResponse[] = [];
constructor(response: any) {
super(response);
const invoices = this.getResponseProperty("Invoices");
if (invoices && invoices.length) {
this.invoices = invoices.map((t: any) => new InvoiceResponse(t));
}
}
}
export class InvoiceResponse extends BaseResponse {
id: string;
date: string;
number: string;
total: number;
status: string;
url: string;
pdfUrl: string;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("Id");
this.date = this.getResponseProperty("Date");
this.number = this.getResponseProperty("Number");
this.total = this.getResponseProperty("Total");
this.status = this.getResponseProperty("Status");
this.url = this.getResponseProperty("Url");
this.pdfUrl = this.getResponseProperty("PdfUrl");
}
}

View File

@@ -1,10 +1,9 @@
import { ProductType } from "../../../enums";
import { ProductTierType, PlanType } from "../../../billing/enums";
import { BaseResponse } from "../../../models/response/base.response";
import { PlanType } from "../../enums";
export class PlanResponse extends BaseResponse {
type: PlanType;
product: ProductType;
productTier: ProductTierType;
name: string;
isAnnual: boolean;
nameLocalizationKey: string;
@@ -32,7 +31,7 @@ export class PlanResponse extends BaseResponse {
constructor(response: any) {
super(response);
this.type = this.getResponseProperty("Type");
this.product = this.getResponseProperty("Product");
this.productTier = this.getResponseProperty("ProductTier");
this.name = this.getResponseProperty("Name");
this.isAnnual = this.getResponseProperty("IsAnnual");
this.nameLocalizationKey = this.getResponseProperty("NameLocalizationKey");

View File

@@ -1,3 +1,5 @@
import { InvoicesResponse } from "@bitwarden/common/billing/models/response/invoices.response";
import { ApiService } from "../../abstractions/api.service";
import { BillingApiServiceAbstraction } from "../../billing/abstractions";
import { PaymentMethodType } from "../../billing/enums";
@@ -106,6 +108,28 @@ export class BillingApiService implements BillingApiServiceAbstraction {
return new ListResponse(r, PlanResponse);
}
async getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise<string> {
const response = await this.apiService.send(
"GET",
"/providers/" + providerId + "/billing/invoices/" + invoiceId,
null,
true,
true,
);
return response as string;
}
async getProviderInvoices(providerId: string): Promise<InvoicesResponse> {
const response = await this.apiService.send(
"GET",
"/providers/" + providerId + "/billing/invoices",
null,
true,
true,
);
return new InvoicesResponse(response);
}
async getProviderPaymentInformation(providerId: string): Promise<PaymentInformationResponse> {
const response = await this.apiService.send(
"GET",

View File

@@ -17,6 +17,8 @@ export enum FeatureFlag {
RestrictProviderAccess = "restrict-provider-access",
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
BulkDeviceApproval = "bulk-device-approval",
EmailVerification = "email-verification",
InlineMenuFieldQualification = "inline-menu-field-qualification",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -44,6 +46,8 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.RestrictProviderAccess]: FALSE,
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
[FeatureFlag.BulkDeviceApproval]: FALSE,
[FeatureFlag.EmailVerification]: FALSE,
[FeatureFlag.InlineMenuFieldQualification]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -6,4 +6,3 @@ export * from "./http-status-code.enum";
export * from "./integration-type.enum";
export * from "./native-messaging-version.enum";
export * from "./notification-type.enum";
export * from "./product-type.enum";

View File

@@ -70,7 +70,6 @@ import { IdentityCaptchaResponse } from "../auth/models/response/identity-captch
import { IdentityTokenResponse } from "../auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response";
import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response";
import { MasterPasswordPolicyResponse } from "../auth/models/response/master-password-policy.response";
import { PreloginResponse } from "../auth/models/response/prelogin.response";
import { RegisterResponse } from "../auth/models/response/register.response";
import { SsoPreValidateResponse } from "../auth/models/response/sso-pre-validate.response";
@@ -424,12 +423,6 @@ export class ApiService implements ApiServiceAbstraction {
return this.send("POST", "/accounts/verify-email-token", request, false, false);
}
postAccountVerifyPassword(
request: SecretVerificationRequest,
): Promise<MasterPasswordPolicyResponse> {
return this.send("POST", "/accounts/verify-password", request, true, true);
}
postAccountRecoverDelete(request: DeleteRecoverRequest): Promise<any> {
return this.send("POST", "/accounts/delete-recover", request, false, false);
}
@@ -1883,9 +1876,12 @@ export class ApiService implements ApiServiceAbstraction {
const responseType = response.headers.get("content-type");
const responseIsJson = responseType != null && responseType.indexOf("application/json") !== -1;
const responseIsCsv = responseType != null && responseType.indexOf("text/csv") !== -1;
if (hasResponse && response.status === 200 && responseIsJson) {
const responseJson = await response.json();
return responseJson;
} else if (hasResponse && response.status === 200 && responseIsCsv) {
return await response.text();
} else if (response.status !== 200) {
const error = await this.handleError(response, false, authed);
return Promise.reject(error);

View File

@@ -51,7 +51,7 @@ export class PassphraseGeneratorStrategy
// select which word gets the number, if any
let luckyNumber = -1;
if (o.includeNumber) {
luckyNumber = await this.randomizer.uniform(0, o.numWords);
luckyNumber = await this.randomizer.uniform(0, o.numWords - 1);
}
// generate the passphrase

View File

@@ -47,7 +47,7 @@ export class PassphraseGeneratorStrategy
// select which word gets the number, if any
let luckyNumber = -1;
if (o.includeNumber) {
luckyNumber = await this.randomizer.uniform(0, o.numWords);
luckyNumber = await this.randomizer.uniform(0, o.numWords - 1);
}
// generate the passphrase