Skip to content

Commit

Permalink
Add the ability for custom validation logic to be injected into `User…
Browse files Browse the repository at this point in the history
…VerificationDialogComponent` (#8770)

* Introduce `verificationType`

* Update template to use `verificationType`

* Implement a path for `verificationType = 'custom'`

* Delete `clientSideOnlyVerification`

* Update `EnrollMasterPasswordResetComponent` to include a server-side hash check

* Better describe the custom scenerio through comments

* Add an example of the custom verficiation scenerio

* Move execution of verification function into try/catch

* Migrate existing uses of `clientSideOnlyVerification`

* Use generic type option instead of casting

* Change "given" to "determined" in a comment
  • Loading branch information
addisonbeck authored and Cesar Gonzalez committed Jul 1, 2024
1 parent 5260e5a commit c69c4b6
Show file tree
Hide file tree
Showing 7 changed files with 108 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ describe("Fido2UserVerificationService", () => {
);

expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
verificationType: "client",
});
expect(result).toBe(true);
});
Expand All @@ -105,7 +105,7 @@ describe("Fido2UserVerificationService", () => {
);

expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
verificationType: "client",
});
expect(result).toBe(true);
});
Expand All @@ -122,7 +122,7 @@ describe("Fido2UserVerificationService", () => {
);

expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
verificationType: "client",
});
expect(result).toBe(true);
});
Expand All @@ -135,7 +135,7 @@ describe("Fido2UserVerificationService", () => {
);

expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
verificationType: "client",
});
expect(result).toBe(true);
});
Expand Down Expand Up @@ -198,7 +198,7 @@ describe("Fido2UserVerificationService", () => {
);

expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
verificationType: "client",
});
expect(result).toBe(true);
});
Expand All @@ -214,7 +214,7 @@ describe("Fido2UserVerificationService", () => {
);

expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
verificationType: "client",
});
expect(result).toBe(true);
});
Expand All @@ -231,7 +231,7 @@ describe("Fido2UserVerificationService", () => {
);

expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
verificationType: "client",
});
expect(result).toBe(true);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export class Fido2UserVerificationService {

private async showUserVerificationDialog(): Promise<boolean> {
const result = await UserVerificationDialogComponent.open(this.dialogService, {
clientSideOnlyVerification: true,
verificationType: "client",
});

if (result.userAction === "cancel") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { UserVerificationDialogComponent } from "@bitwarden/auth/angular";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import { OrganizationUserResetPasswordEnrollmentRequest } from "@bitwarden/common/admin-console/abstractions/organization-user/requests";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { VerificationWithSecret } from "@bitwarden/common/auth/types/verification";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
Expand All @@ -26,43 +28,50 @@ export class EnrollMasterPasswordReset {
i18nService: I18nService,
syncService: SyncService,
logService: LogService,
userVerificationService: UserVerificationService,
) {
const result = await UserVerificationDialogComponent.open(dialogService, {
title: "enrollAccountRecovery",
calloutOptions: {
text: "resetPasswordEnrollmentWarning",
type: "warning",
},
verificationType: {
type: "custom",
verificationFn: async (secret: VerificationWithSecret) => {
const request =
await userVerificationService.buildRequest<OrganizationUserResetPasswordEnrollmentRequest>(
secret,
);
request.resetPasswordKey = await resetPasswordService.buildRecoveryKey(
data.organization.id,
);

// Process the enrollment request, which is an endpoint that is
// gated by a server-side check of the master password hash
await organizationUserService.putOrganizationUserResetPasswordEnrollment(
data.organization.id,
data.organization.userId,
request,
);
return true;
},
},
});

// Handle the result of the dialog based on user action and verification success
// User canceled enrollment
if (result.userAction === "cancel") {
return;
}

// User confirmed the dialog so check verification success
// Enrollment failed
if (!result.verificationSuccess) {
// verification failed
return;
}

// Verification succeeded
// Enrollment succeeded
try {
// This object is missing most of the properties in the
// `OrganizationUserResetPasswordEnrollmentRequest()`, but those
// properties don't carry over to the server model anyway and are
// never used by this flow.
const request = new OrganizationUserResetPasswordEnrollmentRequest();
request.resetPasswordKey = await resetPasswordService.buildRecoveryKey(data.organization.id);

await organizationUserService.putOrganizationUserResetPasswordEnrollment(
data.organization.id,
data.organization.userId,
request,
);

platformUtilsService.showToast("success", null, i18nService.t("enrollPasswordResetSuccess"));

await syncService.fullSync(true);
} catch (e) {
logService.error(e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
Expand Down Expand Up @@ -48,6 +49,7 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private dialogService: DialogService,
private resetPasswordService: OrganizationUserResetPasswordService,
private userVerificationService: UserVerificationService,
) {}

async ngOnInit() {
Expand Down Expand Up @@ -155,6 +157,7 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
this.i18nService,
this.syncService,
this.logService,
this.userVerificationService,
);
} else {
// Remove reset password
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
<!-- Show optional content when verification is server side or client side and verification methods were found. -->
<ng-container
*ngIf="
!dialogOptions.clientSideOnlyVerification ||
(dialogOptions.clientSideOnlyVerification &&
dialogOptions.verificationType !== 'client' ||
(dialogOptions.verificationType === 'client' &&
activeClientVerificationOption !== ActiveClientVerificationOption.None)
"
>
Expand All @@ -29,7 +29,7 @@
<!-- Shown when client side verification methods picked and no verification methods found -->
<ng-container
*ngIf="
dialogOptions.clientSideOnlyVerification &&
dialogOptions.verificationType === 'client' &&
activeClientVerificationOption === ActiveClientVerificationOption.None
"
>
Expand All @@ -41,7 +41,7 @@
<app-user-verification-form-input
[(invalidSecret)]="invalidSecret"
formControlName="secret"
[verificationType]="dialogOptions.clientSideOnlyVerification ? 'client' : 'server'"
[verificationType]="dialogOptions.verificationType === 'client' ? 'client' : 'server'"
(activeClientVerificationOptionChange)="handleActiveClientVerificationOptionChange($event)"
(biometricsVerificationResultChange)="handleBiometricsVerificationResultChange($event)"
></app-user-verification-form-input>
Expand All @@ -50,8 +50,8 @@
<!-- Confirm button container - shown for server side validation but hidden if client side validation + biometrics -->
<ng-container
*ngIf="
!dialogOptions.clientSideOnlyVerification ||
(dialogOptions.clientSideOnlyVerification &&
dialogOptions.verificationType !== 'client' ||
(dialogOptions.verificationType === 'client' &&
activeClientVerificationOption !== ActiveClientVerificationOption.Biometrics)
"
>
Expand Down Expand Up @@ -85,10 +85,12 @@
<ng-container
*ngIf="activeClientVerificationOption === ActiveClientVerificationOption.None"
>
<!-- For no client verifications found, show set a pin confirm button.
Note: this doesn't make sense for web as web doesn't support PINs, but this is how we are handling it for now
as the expectation is that only browser and desktop will use the new clientSideOnlyVerification flow.
We might genericize this in the future to just tell the user they need to configure a valid user verification option like PIN or Biometrics. -->
<!--
For no client verifications found, show set a pin confirm button.
Note: this doesn't make sense for web as web doesn't support PINs, but this is how we are handling it for now
as the expectation is that only browser and desktop will use the new verificationType 'client' flow.
We might genericize this in the future to just tell the user they need to configure a valid user verification option like PIN or Biometrics.
-->
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "setPin" | i18n }}
</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,31 @@ export class UserVerificationDialogComponent {
* return;
* }
*
* ----------------------------------------------------------
*
* @example
* // Example 4: Custom user verification validation
*
* const result = await UserVerificationDialogComponent.open(dialogService, {
* verificationType: {
* type: "custom",
* // Pass in a function that will be used to validate the input of the
* // verification dialog, returning true when finished.
* verificationFn: async (secret: VerificationWithSecret) => {
* const request = await userVerificationService.buildRequest<CustomRequestType>(secret);
*
* // ... Do something with the custom request type
*
* await someServicer.sendMyRequestThatVerfiesUserIdentity(
* // ... Some other data
* request,
* );
* return true;
* },
* },
* });
*
* // ... Evaluate the result as usual
*/
static async open(
dialogService: DialogService,
Expand Down Expand Up @@ -202,6 +227,18 @@ export class UserVerificationDialogComponent {
}

try {
if (
typeof this.dialogOptions.verificationType === "object" &&
this.dialogOptions.verificationType.type === "custom"
) {
const success = await this.dialogOptions.verificationType.verificationFn(this.secret.value);
this.close({
userAction: "confirm",
verificationSuccess: success,
});
return;
}

// TODO: once we migrate all user verification scenarios to use this new implementation,
// we should consider refactoring the user verification service handling of the
// OTP and MP flows to not throw errors on verification failure.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { VerificationWithSecret } from "@bitwarden/common/auth/types/verification";
import { ButtonType } from "@bitwarden/components";

/**
Expand Down Expand Up @@ -60,12 +61,27 @@ export type UserVerificationDialogOptions = {
*/
confirmButtonOptions?: UserVerificationConfirmButtonOptions;

/**
* Indicates whether the verification is only performed client-side. Includes local MP verification, PIN, and Biometrics.
* Optional.
* **Important:** Only for use on desktop and browser platforms as when there are no client verification methods, the user is instructed to set a pin (which is not supported on web)
/** The validation method used to verify the secret.
*
* Possible values:
*
* - "default": Perform the default validation operation for the determined
* secret type. This would, for example, validate master passwords
* locally but OTPs on the server.
* - "client": Only do a client-side verification with no possible server
* request. Includes local MP verification, PIN, and Biometrics.
* **Important:** This option is only for use on desktop and browser
* platforms. When there are no client verification methods the user is
* instructed to set a pin, and this is not supported on web.
* - "custom": Custom validation is done to verify the secret. This is
* passed in from callers when opening the dialog. The custom type is
* meant to provide a mechanism where users can call a secured endpoint
* that performs user verification server side.
*/
clientSideOnlyVerification?: boolean;
verificationType?:
| "default"
| "client"
| { type: "custom"; verificationFn: (secret: VerificationWithSecret) => Promise<boolean> };
};

/**
Expand Down

0 comments on commit c69c4b6

Please sign in to comment.