mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 08:13:42 +00:00
Auth/PM-7321 - Registration with Email Verification - Registration Finish Component Implementation (#9653)
* PM-7321 - Temp add input password * PM-7321 - update input password based on latest PR changes to test. * PM-7321 - Progress on testing input password component + RegistrationFinishComponent checks * PM-7321 - more progress on registration finish. * PM-7321 - Wire up RegistrationFinishRequest model + AccountApiService abstraction + implementation changes for new method. * PM-7321 - WIP Registration Finish - wiring up request building and API call on submit. * PM-7321 - WIP registratin finish * PM-7321 - WIP on creating registration-finish service + web override to add org invite handling * PM-7321 - (1) Move web-registration-finish svc to web (2) Wire up exports (3) wire up RegistrationFinishComponent to call registration finish service * PM-7321 - Get CLI building * PM-7321 - Move all finish registration service and content to registration-finish feature folder. * PM-7321 - Fix RegistrationFinishService config * PM-7321 - RegistrationFinishComponent- handlePasswordFormSubmit - error handling WIP * PM-7321 - InputPasswordComp - Update to accept masterPasswordPolicyOptions as input instead of retrieving it as parent components in different scenarios will need to retrieve the policies differently (e.g., orgInvite token in registration vs direct call via org id post SSO on set password) * PM-7321 - Registration Finish - Add web specific logic for retrieving master password policies and passing them into the input password component. * PM-7321 - Registration Start - Send email via query param to registration finish page so it can create masterKey * PM-7321 - InputPassword comp - (1) Add loading input (2) Add email validation to submit logic. * PM-7321 - Registration Finish - Add submitting state and pass into input password so that the rest of the registration process keeps the child form disabled. * PM-7321 - Registration Finish - use validation service for error handling. * PM-7321 - All register routes must be dynamic and change if the feature flag changes. * PM-7321 - Test registration finish services. * PM-7321 - RegisterRouteService - Add comment documenting why the service exists. * PM-7321 - Add missing input password translations to browser & desktop * PM-7321 - WebRegistrationFinishSvc - apply PR feedback
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
|
||||
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
|
||||
import { DEFAULT_KDF_CONFIG } from "@bitwarden/common/auth/models/domain/kdf-config";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { PasswordInputResult } from "../../input-password/password-input-result";
|
||||
|
||||
import { DefaultRegistrationFinishService } from "./default-registration-finish.service";
|
||||
|
||||
describe("DefaultRegistrationFinishService", () => {
|
||||
let service: DefaultRegistrationFinishService;
|
||||
|
||||
let cryptoService: MockProxy<CryptoService>;
|
||||
let accountApiService: MockProxy<AccountApiService>;
|
||||
|
||||
beforeEach(() => {
|
||||
cryptoService = mock<CryptoService>();
|
||||
accountApiService = mock<AccountApiService>();
|
||||
|
||||
service = new DefaultRegistrationFinishService(cryptoService, accountApiService);
|
||||
});
|
||||
|
||||
it("instantiates", () => {
|
||||
expect(service).not.toBeFalsy();
|
||||
});
|
||||
|
||||
describe("getMasterPasswordPolicyOptsFromOrgInvite()", () => {
|
||||
it("returns null", async () => {
|
||||
const result = await service.getMasterPasswordPolicyOptsFromOrgInvite();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("finishRegistration()", () => {
|
||||
let email: string;
|
||||
let emailVerificationToken: string;
|
||||
let masterKey: MasterKey;
|
||||
let passwordInputResult: PasswordInputResult;
|
||||
let userKey: UserKey;
|
||||
let userKeyEncString: EncString;
|
||||
let userKeyPair: [string, EncString];
|
||||
let capchaBypassToken: string;
|
||||
|
||||
beforeEach(() => {
|
||||
email = "test@email.com";
|
||||
emailVerificationToken = "emailVerificationToken";
|
||||
masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey;
|
||||
passwordInputResult = {
|
||||
masterKey: masterKey,
|
||||
masterKeyHash: "masterKeyHash",
|
||||
kdfConfig: DEFAULT_KDF_CONFIG,
|
||||
hint: "hint",
|
||||
};
|
||||
|
||||
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
|
||||
userKeyEncString = new EncString("userKeyEncrypted");
|
||||
|
||||
userKeyPair = ["publicKey", new EncString("privateKey")];
|
||||
capchaBypassToken = "capchaBypassToken";
|
||||
});
|
||||
|
||||
it("throws an error if the user key cannot be created", async () => {
|
||||
cryptoService.makeUserKey.mockResolvedValue([null, null]);
|
||||
|
||||
await expect(service.finishRegistration(email, passwordInputResult)).rejects.toThrow(
|
||||
"User key could not be created",
|
||||
);
|
||||
});
|
||||
|
||||
it("registers the user and returns a captcha bypass token when given valid email verification input", async () => {
|
||||
cryptoService.makeUserKey.mockResolvedValue([userKey, userKeyEncString]);
|
||||
cryptoService.makeKeyPair.mockResolvedValue(userKeyPair);
|
||||
accountApiService.registerFinish.mockResolvedValue(capchaBypassToken);
|
||||
|
||||
const result = await service.finishRegistration(
|
||||
email,
|
||||
passwordInputResult,
|
||||
emailVerificationToken,
|
||||
);
|
||||
|
||||
expect(result).toEqual(capchaBypassToken);
|
||||
|
||||
expect(cryptoService.makeUserKey).toHaveBeenCalledWith(masterKey);
|
||||
expect(cryptoService.makeKeyPair).toHaveBeenCalledWith(userKey);
|
||||
expect(accountApiService.registerFinish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email,
|
||||
emailVerificationToken: emailVerificationToken,
|
||||
masterPasswordHash: passwordInputResult.masterKeyHash,
|
||||
masterPasswordHint: passwordInputResult.hint,
|
||||
userSymmetricKey: userKeyEncString.encryptedString,
|
||||
userAsymmetricKeys: {
|
||||
publicKey: userKeyPair[0],
|
||||
encryptedPrivateKey: userKeyPair[1].encryptedString,
|
||||
},
|
||||
kdf: passwordInputResult.kdfConfig.kdfType,
|
||||
kdfIterations: passwordInputResult.kdfConfig.iterations,
|
||||
kdfMemory: undefined,
|
||||
kdfParallelism: undefined,
|
||||
orgInviteToken: undefined, // OrgInvite only handled in web
|
||||
organizationUserId: undefined, // OrgInvite only handled in web
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
|
||||
import { RegisterFinishRequest } from "@bitwarden/common/auth/models/request/registration/register-finish.request";
|
||||
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
|
||||
import { PasswordInputResult } from "../../input-password/password-input-result";
|
||||
|
||||
import { RegistrationFinishService } from "./registration-finish.service";
|
||||
|
||||
export class DefaultRegistrationFinishService implements RegistrationFinishService {
|
||||
constructor(
|
||||
protected cryptoService: CryptoService,
|
||||
protected accountApiService: AccountApiService,
|
||||
) {}
|
||||
|
||||
getMasterPasswordPolicyOptsFromOrgInvite(): Promise<MasterPasswordPolicyOptions | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async finishRegistration(
|
||||
email: string,
|
||||
passwordInputResult: PasswordInputResult,
|
||||
emailVerificationToken?: string,
|
||||
): Promise<string> {
|
||||
const [newUserKey, newEncUserKey] = await this.cryptoService.makeUserKey(
|
||||
passwordInputResult.masterKey,
|
||||
);
|
||||
|
||||
if (!newUserKey || !newEncUserKey) {
|
||||
throw new Error("User key could not be created");
|
||||
}
|
||||
const userAsymmetricKeys = await this.cryptoService.makeKeyPair(newUserKey);
|
||||
|
||||
const registerRequest = await this.buildRegisterRequest(
|
||||
email,
|
||||
emailVerificationToken,
|
||||
passwordInputResult,
|
||||
newEncUserKey.encryptedString,
|
||||
userAsymmetricKeys,
|
||||
);
|
||||
|
||||
const capchaBypassToken = await this.accountApiService.registerFinish(registerRequest);
|
||||
|
||||
return capchaBypassToken;
|
||||
}
|
||||
|
||||
protected async buildRegisterRequest(
|
||||
email: string,
|
||||
emailVerificationToken: string,
|
||||
passwordInputResult: PasswordInputResult,
|
||||
encryptedUserKey: EncryptedString,
|
||||
userAsymmetricKeys: [string, EncString],
|
||||
): Promise<RegisterFinishRequest> {
|
||||
const userAsymmetricKeysRequest = new KeysRequest(
|
||||
userAsymmetricKeys[0],
|
||||
userAsymmetricKeys[1].encryptedString,
|
||||
);
|
||||
|
||||
return new RegisterFinishRequest(
|
||||
email,
|
||||
emailVerificationToken,
|
||||
passwordInputResult.masterKeyHash,
|
||||
passwordInputResult.hint,
|
||||
encryptedUserKey,
|
||||
userAsymmetricKeysRequest,
|
||||
passwordInputResult.kdfConfig.kdfType,
|
||||
passwordInputResult.kdfConfig.iterations,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1 +1,11 @@
|
||||
<h3>This component will be built in the next phase of email verification work.</h3>
|
||||
<div class="full-loading-spinner" *ngIf="loading">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
|
||||
</div>
|
||||
|
||||
<auth-input-password
|
||||
*ngIf="!loading"
|
||||
[email]="email"
|
||||
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
|
||||
[loading]="submitting"
|
||||
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
|
||||
></auth-input-password>
|
||||
|
||||
@@ -1,15 +1,101 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute, Params, Router, RouterModule } from "@angular/router";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { InputPasswordComponent } from "../../input-password/input-password.component";
|
||||
import { PasswordInputResult } from "../../input-password/password-input-result";
|
||||
|
||||
import { RegistrationFinishService } from "./registration-finish.service";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "auth-registration-finish",
|
||||
templateUrl: "./registration-finish.component.html",
|
||||
imports: [CommonModule, JslibModule, RouterModule],
|
||||
imports: [CommonModule, JslibModule, RouterModule, InputPasswordComponent],
|
||||
})
|
||||
export class RegistrationFinishComponent {
|
||||
constructor() {}
|
||||
export class RegistrationFinishComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
loading = true;
|
||||
submitting = false;
|
||||
email: string;
|
||||
|
||||
// Note: this token is the email verification token. It is always supplied as a query param, but
|
||||
// it either comes from the email verification email or, if email verification is disabled server side
|
||||
// via global settings, it comes directly from the registration-start component directly.
|
||||
emailVerificationToken: string;
|
||||
|
||||
masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null;
|
||||
|
||||
constructor(
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private router: Router,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
private registrationFinishService: RegistrationFinishService,
|
||||
private validationService: ValidationService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.listenForQueryParamChanges();
|
||||
this.masterPasswordPolicyOptions =
|
||||
await this.registrationFinishService.getMasterPasswordPolicyOptsFromOrgInvite();
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
private listenForQueryParamChanges() {
|
||||
this.activatedRoute.queryParams.pipe(takeUntil(this.destroy$)).subscribe((qParams: Params) => {
|
||||
if (qParams.email != null && qParams.email.indexOf("@") > -1) {
|
||||
this.email = qParams.email;
|
||||
}
|
||||
|
||||
if (qParams.token != null) {
|
||||
this.emailVerificationToken = qParams.token;
|
||||
}
|
||||
|
||||
if (qParams.fromEmail && qParams.fromEmail === "true") {
|
||||
this.toastService.showToast({
|
||||
title: null,
|
||||
message: this.i18nService.t("emailVerifiedV2"),
|
||||
variant: "success",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
|
||||
this.submitting = true;
|
||||
try {
|
||||
await this.registrationFinishService.finishRegistration(
|
||||
this.email,
|
||||
passwordInputResult,
|
||||
this.emailVerificationToken,
|
||||
);
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
this.submitting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("newAccountCreated"),
|
||||
});
|
||||
|
||||
this.submitting = false;
|
||||
await this.router.navigate(["/login"], { queryParams: { email: this.email } });
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
|
||||
import { PasswordInputResult } from "../../input-password/password-input-result";
|
||||
|
||||
export abstract class RegistrationFinishService {
|
||||
/**
|
||||
* Gets the master password policy options from an organization invite if it exits.
|
||||
* Organization invites can currently only be accepted on the web.
|
||||
*/
|
||||
abstract getMasterPasswordPolicyOptsFromOrgInvite(): Promise<MasterPasswordPolicyOptions | null>;
|
||||
|
||||
/**
|
||||
* Finishes the registration process by creating a new user account.
|
||||
*
|
||||
* @param email The email address of the user.
|
||||
* @param passwordInputResult The password input result.
|
||||
* @param emailVerificationToken The optional email verification token. Not present in org invite scenarios.
|
||||
* Returns a promise which resolves to the captcha bypass token string upon a successful account creation.
|
||||
*/
|
||||
abstract finishRegistration(
|
||||
email: string,
|
||||
passwordInputResult: PasswordInputResult,
|
||||
emailVerificationToken?: string,
|
||||
): Promise<string>;
|
||||
}
|
||||
@@ -138,7 +138,9 @@ export class RegistrationStartComponent implements OnInit, OnDestroy {
|
||||
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 } });
|
||||
await this.router.navigate(["/finish-signup"], {
|
||||
queryParams: { token: result, email: this.email.value },
|
||||
});
|
||||
}
|
||||
|
||||
// Result is null, so email verification is required
|
||||
|
||||
Reference in New Issue
Block a user